diff --git a/migrations/4.17.0_oauth_as.sql b/migrations/4.17.0_oauth_as.sql new file mode 100644 index 00000000..abdc2b03 --- /dev/null +++ b/migrations/4.17.0_oauth_as.sql @@ -0,0 +1,81 @@ +-- ========================================================================= +-- Kyte v4.17.0 - OAuth 2.1 Authorization Server for the hosted MCP connector +-- ========================================================================= +-- IMPORTANT: Backup your database before running this migration. +-- +-- KYTE-#551 (hosted MCP: remote OAuth connector for Claude.ai / ChatGPT). +-- Creates the two tables backing this install's OAuth authorization server: +-- +-- KyteOAuthClient - RFC 7591 dynamically-registered OAuth clients +-- (Claude / ChatGPT register as public PKCE clients +-- before the authorization-code flow). +-- KyteOAuthCode - short-lived, single-use authorization codes issued at +-- /oauth/authorize and redeemed once at /oauth/token for +-- a freshly-minted scoped KyteMCPToken (the access token). +-- +-- The OAuth layer is a front door that mints existing `kmcp_live_` tokens; +-- all downstream MCP enforcement (McpTokenStrategy, scopes, sessions, audit) +-- is unchanged. See docs/design/hosted-mcp-oauth.md and the model specs in +-- src/Mvc/Model/KyteOAuthClient.php + KyteOAuthCode.php. +-- +-- Idempotent (CREATE TABLE IF NOT EXISTS) — safe to re-run; existing installs +-- that already created equivalent tables are unaffected. Conventions mirror +-- migrations/4.6.0_mcp_session_store.sql (BIGINT UNSIGNED ids, InnoDB, +-- utf8mb4). Portable to MySQL 8.x + MariaDB 10.5+. +-- ========================================================================= + +CREATE TABLE IF NOT EXISTS `KyteOAuthClient` ( + `id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + + `client_id` VARCHAR(64) NOT NULL COMMENT 'Public client identifier (opaque random) issued at registration', + `client_secret` VARCHAR(64) DEFAULT NULL COMMENT 'sha256 of confidential-client secret; NULL for public/PKCE clients', + `client_name` VARCHAR(255) DEFAULT NULL, + `redirect_uris` TEXT NOT NULL COMMENT 'JSON array of allowed redirect URIs (exact-matched at authorize/token)', + `grant_types` VARCHAR(255) DEFAULT NULL COMMENT 'CSV, default authorization_code', + `response_types` VARCHAR(255) DEFAULT NULL COMMENT 'CSV, default code', + `token_endpoint_auth_method` VARCHAR(64) DEFAULT NULL COMMENT 'none for public PKCE clients', + `scope` VARCHAR(512) DEFAULT NULL COMMENT 'Space-separated OAuth scopes requested at registration', + + `kyte_account` BIGINT UNSIGNED DEFAULT 0 COMMENT 'Nullable/0 — clients register before any user authenticates', + + `created_by` BIGINT UNSIGNED DEFAULT NULL, + `date_created` BIGINT UNSIGNED DEFAULT NULL, + `modified_by` BIGINT UNSIGNED DEFAULT NULL, + `date_modified` BIGINT UNSIGNED DEFAULT NULL, + `deleted_by` BIGINT UNSIGNED DEFAULT NULL, + `date_deleted` BIGINT UNSIGNED DEFAULT NULL, + `deleted` TINYINT UNSIGNED NOT NULL DEFAULT 0, + + UNIQUE KEY `idx_client_id` (`client_id`), + KEY `idx_account` (`kyte_account`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS `KyteOAuthCode` ( + `id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + + `code_hash` VARCHAR(64) NOT NULL COMMENT 'sha256 of the raw authorization code; raw code never stored', + `client_id` VARCHAR(64) NOT NULL COMMENT 'KyteOAuthClient.client_id this code was issued to', + `redirect_uri` VARCHAR(1024) NOT NULL COMMENT 'Must match exactly at /oauth/token', + `code_challenge` VARCHAR(255) NOT NULL COMMENT 'PKCE code_challenge (base64url sha256(verifier))', + `code_challenge_method` VARCHAR(16) NOT NULL COMMENT 'S256 only', + `scope` VARCHAR(512) DEFAULT NULL COMMENT 'Space-separated OAuth scopes granted at consent', + `kyte_scopes` VARCHAR(255) NOT NULL COMMENT 'CSV of kmcp scopes to mint (read/draft/commit/provision/schema)', + `application` BIGINT UNSIGNED DEFAULT NULL COMMENT 'Optional app scope for the minted token', + `expires_at` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT 'Unix epoch; codes are short-lived', + `consumed_at` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT 'Unix epoch of redemption; 0 = unused (single-use guard)', + + `kyte_account` BIGINT UNSIGNED NOT NULL COMMENT 'Account that consented; the minted token acts on this tenant', + + `created_by` BIGINT UNSIGNED DEFAULT NULL COMMENT 'KyteUser who approved consent', + `date_created` BIGINT UNSIGNED DEFAULT NULL, + `modified_by` BIGINT UNSIGNED DEFAULT NULL, + `date_modified` BIGINT UNSIGNED DEFAULT NULL, + `deleted_by` BIGINT UNSIGNED DEFAULT NULL, + `date_deleted` BIGINT UNSIGNED DEFAULT NULL, + `deleted` TINYINT UNSIGNED NOT NULL DEFAULT 0, + + UNIQUE KEY `idx_code_hash` (`code_hash`), + KEY `idx_client` (`client_id`), + KEY `idx_expires` (`expires_at`), + KEY `idx_account` (`kyte_account`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/migrations/4.18.0_application_status.sql b/migrations/4.18.0_application_status.sql new file mode 100644 index 00000000..fae24a90 --- /dev/null +++ b/migrations/4.18.0_application_status.sql @@ -0,0 +1,28 @@ +-- ========================================================================= +-- Kyte v4.18.0 - Application lifecycle status (async app teardown, KYTE-#559) +-- ========================================================================= +-- IMPORTANT: Backup your database before running this migration. +-- +-- Adds Application.status ('active' | 'deleting' | 'deleted'). When an app is +-- deleted it is marked 'deleting' (not dropped synchronously); the +-- SiteProvisioningWorker tears down each site's AWS infra (S3 + CloudFront + +-- ACM) then finalizes the app — drops the tenant DB + its user and sets +-- deleted=1/status='deleted'. This stops app deletion from orphaning site +-- infrastructure. +-- +-- Portable + idempotent: MySQL has no `ADD COLUMN IF NOT EXISTS`, so guard with +-- an information_schema check + a prepared statement (works on MySQL 8.x + +-- MariaDB 10.5+). Existing rows default to 'active'. +-- ========================================================================= + +SET @col_exists := ( + SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'Application' AND COLUMN_NAME = 'status' +); +SET @ddl := IF(@col_exists = 0, + 'ALTER TABLE `Application` ADD COLUMN `status` VARCHAR(20) NOT NULL DEFAULT ''active''', + 'DO 0' +); +PREPARE stmt FROM @ddl; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; diff --git a/migrations/4.19.0_app_sso.sql b/migrations/4.19.0_app_sso.sql new file mode 100644 index 00000000..43ab07ad --- /dev/null +++ b/migrations/4.19.0_app_sso.sql @@ -0,0 +1,131 @@ +-- ========================================================================= +-- Kyte v4.19.0 - App-level SSO (OIDC) tables (KYTE-#560) +-- ========================================================================= +-- IMPORTANT: Backup your database before running this migration. +-- +-- App-level Microsoft/OIDC SSO: an app's end users sign in with their identity +-- provider; Kyte (the relying party) validates the id_token and issues a Kyte +-- JWT session for the app's user_model. +-- +-- KyteAppIdentityProvider - per-app OIDC config (Shipyard-managed; carries a +-- libsodium-encrypted client_secret; never via MCP). +-- KyteSsoState - short-lived in-flight state/nonce/PKCE per login, +-- plus a browser-binding correlator (login-CSRF). +-- KyteSsoCode - single-use hand-off code delivering the session to +-- the app front-end (no tokens at rest). +-- KyteSsoIdentity - subject->app-user link (the authoritative SSO join +-- key; email is never the identity key). +-- +-- Idempotent (CREATE TABLE IF NOT EXISTS). Conventions mirror +-- migrations/4.6.0_mcp_session_store.sql. Portable MySQL 8.x / MariaDB 10.5+. +-- ========================================================================= + +CREATE TABLE IF NOT EXISTS `KyteAppIdentityProvider` ( + `id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + `application` BIGINT UNSIGNED NOT NULL, + `provider` VARCHAR(32) NOT NULL, + `enabled` TINYINT UNSIGNED NOT NULL DEFAULT 0, + `issuer` VARCHAR(512) DEFAULT NULL, + `discovery_url` VARCHAR(512) DEFAULT NULL, + `tenant` VARCHAR(128) DEFAULT NULL, + `client_id` VARCHAR(255) DEFAULT NULL, + `client_secret` TEXT DEFAULT NULL COMMENT 'libsodium secretbox, base64(nonce+ciphertext)', + `scopes` VARCHAR(512) DEFAULT 'openid profile email', + `redirect_uri` VARCHAR(1024) DEFAULT NULL, + `user_email_claim` VARCHAR(64) DEFAULT 'email', + `jit_enabled` TINYINT UNSIGNED NOT NULL DEFAULT 1, + `restrict_to_existing` TINYINT UNSIGNED NOT NULL DEFAULT 0, + `kyte_account` BIGINT UNSIGNED NOT NULL, + `created_by` BIGINT UNSIGNED DEFAULT NULL, + `date_created` BIGINT UNSIGNED DEFAULT NULL, + `modified_by` BIGINT UNSIGNED DEFAULT NULL, + `date_modified` BIGINT UNSIGNED DEFAULT NULL, + `deleted_by` BIGINT UNSIGNED DEFAULT NULL, + `date_deleted` BIGINT UNSIGNED DEFAULT NULL, + `deleted` TINYINT UNSIGNED NOT NULL DEFAULT 0, + KEY `idx_app_provider` (`application`, `provider`), + KEY `idx_account` (`kyte_account`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS `KyteSsoState` ( + `id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + `state` VARCHAR(64) NOT NULL, + `nonce` VARCHAR(64) NOT NULL, + `code_verifier` VARCHAR(128) NOT NULL, + `application` BIGINT UNSIGNED NOT NULL, + `provider` VARCHAR(32) DEFAULT NULL, + `return_url` VARCHAR(1024) DEFAULT NULL, + `redirect_uri` VARCHAR(1024) DEFAULT NULL, + `browser_hash` VARCHAR(64) DEFAULT NULL, + `expires_at` BIGINT UNSIGNED NOT NULL DEFAULT 0, + `consumed_at` BIGINT UNSIGNED NOT NULL DEFAULT 0, + `kyte_account` BIGINT UNSIGNED DEFAULT 0, + `created_by` BIGINT UNSIGNED DEFAULT NULL, + `date_created` BIGINT UNSIGNED DEFAULT NULL, + `modified_by` BIGINT UNSIGNED DEFAULT NULL, + `date_modified` BIGINT UNSIGNED DEFAULT NULL, + `deleted_by` BIGINT UNSIGNED DEFAULT NULL, + `date_deleted` BIGINT UNSIGNED DEFAULT NULL, + `deleted` TINYINT UNSIGNED NOT NULL DEFAULT 0, + UNIQUE KEY `idx_state` (`state`), + KEY `idx_expires` (`expires_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS `KyteSsoCode` ( + `id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + `code_hash` VARCHAR(64) NOT NULL, + `application` BIGINT UNSIGNED NOT NULL, + `sso_user_id` BIGINT UNSIGNED NOT NULL, + `expires_at` BIGINT UNSIGNED NOT NULL DEFAULT 0, + `consumed_at` BIGINT UNSIGNED NOT NULL DEFAULT 0, + `kyte_account` BIGINT UNSIGNED DEFAULT 0, + `created_by` BIGINT UNSIGNED DEFAULT NULL, + `date_created` BIGINT UNSIGNED DEFAULT NULL, + `modified_by` BIGINT UNSIGNED DEFAULT NULL, + `date_modified` BIGINT UNSIGNED DEFAULT NULL, + `deleted_by` BIGINT UNSIGNED DEFAULT NULL, + `date_deleted` BIGINT UNSIGNED DEFAULT NULL, + `deleted` TINYINT UNSIGNED NOT NULL DEFAULT 0, + UNIQUE KEY `idx_code_hash` (`code_hash`), + KEY `idx_expires` (`expires_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- KyteSsoIdentity: the federated-identity link binding an external IdP subject +-- to a local app user. This is the AUTHORITATIVE join key for SSO logins (the +-- immutable `sub` + tenant), NOT the mutable/unverified email claim — closing +-- the account-takeover vector where a token bearing another user's email would +-- otherwise be mapped onto that user's account. +CREATE TABLE IF NOT EXISTS `KyteSsoIdentity` ( + `id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + `application` BIGINT UNSIGNED NOT NULL, + `provider` VARCHAR(32) NOT NULL, + `subject` VARCHAR(255) NOT NULL, + `tenant_id` VARCHAR(128) DEFAULT NULL, + `sso_user_id` BIGINT UNSIGNED NOT NULL, + `email` VARCHAR(320) DEFAULT NULL, + `kyte_account` BIGINT UNSIGNED DEFAULT 0, + `created_by` BIGINT UNSIGNED DEFAULT NULL, + `date_created` BIGINT UNSIGNED DEFAULT NULL, + `modified_by` BIGINT UNSIGNED DEFAULT NULL, + `date_modified` BIGINT UNSIGNED DEFAULT NULL, + `deleted_by` BIGINT UNSIGNED DEFAULT NULL, + `date_deleted` BIGINT UNSIGNED DEFAULT NULL, + `deleted` TINYINT UNSIGNED NOT NULL DEFAULT 0, + UNIQUE KEY `idx_app_provider_subject` (`application`, `provider`, `subject`), + KEY `idx_sso_user` (`application`, `sso_user_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- --------------------------------------------------------------------------- +-- Idempotent ALTERs for installs that created the tables from an earlier cut +-- of this migration (before browser_hash / the identity table existed). +-- --------------------------------------------------------------------------- + +-- KyteSsoState.browser_hash — login-CSRF browser-binding correlator. +SET @add_browser_hash := ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `KyteSsoState` ADD COLUMN `browser_hash` VARCHAR(64) DEFAULT NULL AFTER `redirect_uri`', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'KyteSsoState' AND COLUMN_NAME = 'browser_hash' +); +PREPARE stmt FROM @add_browser_hash; EXECUTE stmt; DEALLOCATE PREPARE stmt; diff --git a/src/Core/Api.php b/src/Core/Api.php index 9de36126..2a10851f 100644 --- a/src/Core/Api.php +++ b/src/Core/Api.php @@ -701,6 +701,25 @@ public function route() { return; } + // /oauth/* (authorization server) and /.well-known/oauth-* + // (RFC 8414 / RFC 9728 discovery) are served by OAuthEndpoint — the + // OAuth 2.1 front door that lets Claude.ai / ChatGPT add /mcp as a + // hosted connector (KYTE-#551). Runs before the MVC pipeline, same + // rationale as /mcp and /jwt (own response shapes, pre-auth flow). + if (strcasecmp($firstSegment, 'oauth') === 0 + || stripos($path, '.well-known/oauth-') === 0) { + \Kyte\Core\Auth\OAuthEndpoint::handle($this); + return; + } + + // /sso/* — app-level OIDC SSO. An app's end users sign in via their + // identity provider (Microsoft/Entra first) and get a Kyte JWT + // session (KYTE-#560). Runs before the MVC pipeline like /jwt, /mcp. + if (strcasecmp($firstSegment, 'sso') === 0) { + \Kyte\Core\Auth\SsoEndpoint::handle($this); + return; + } + if (isset($_SERVER['HTTP_X_KYTE_APPID'])) { $this->appId = $_SERVER['HTTP_X_KYTE_APPID']; } diff --git a/src/Core/Auth/JwtEndpoint.php b/src/Core/Auth/JwtEndpoint.php index 1908576f..916dd22d 100644 --- a/src/Core/Auth/JwtEndpoint.php +++ b/src/Core/Auth/JwtEndpoint.php @@ -483,6 +483,45 @@ private static function passwordUpdate(array $body): array * email user email (if available) * app application identifier when app-scoped, omitted otherwise */ + /** + * Issue a full Kyte session (access JWT + refresh token) for an already- + * authenticated user. Shared by /jwt/login and app-level SSO (KYTE-#560) so + * both hand back the identical session shape. `$app` is the resolved + * Application ModelObject (or null for platform/KyteUser). + * + * @return array + */ + public static function issueSession(ModelObject $user, ModelObject $account, ?ModelObject $app, string $ip): array + { + $appIdentifier = $app !== null ? (string)$app->identifier : null; + $appId = $app !== null ? (int)$app->id : null; + + $accessToken = self::mintAccessJwt($user, $account, $appIdentifier); + $refresh = RefreshTokenStore::issue((int)$user->id, (int)$account->id, $appId, $ip); + + if (isset($user->kyte_model['struct']['lastLogin'])) { + try { + $user->save(['lastLogin' => time()]); + } catch (\Throwable $e) { + error_log('JwtEndpoint::issueSession lastLogin update failed - ' . $e->getMessage()); + } + } + + $userData = self::userToArray($user); + $useSessionMap = defined('USE_SESSION_MAP') && USE_SESSION_MAP; + + return [ + 'access_token' => $accessToken, + 'token_type' => 'Bearer', + 'expires_in' => self::accessTtl(), + 'refresh_token' => $refresh['raw'], + 'refresh_expires_at' => $refresh['expires_at'], + 'uid' => (int)$user->id, + 'account_id' => (int)$account->id, + 'data' => $useSessionMap ? $userData : [$userData], + ]; + } + private static function mintAccessJwt(ModelObject $user, ModelObject $account, ?string $appIdentifier): string { if (!defined('KYTE_JWT_SECRET') || KYTE_JWT_SECRET === '') { @@ -516,7 +555,7 @@ private static function mintAccessJwt(ModelObject $user, ModelObject $account, ? * * @return array{user_model: array, username_field: string, password_field: string, app: ?ModelObject} */ - private static function resolveAuthContext(?string $appIdentifier): array + public static function resolveAuthContext(?string $appIdentifier): array { $defaultUserField = defined('USERNAME_FIELD') ? USERNAME_FIELD : 'email'; $defaultPassField = defined('PASSWORD_FIELD') ? PASSWORD_FIELD : 'password'; diff --git a/src/Core/Auth/OAuthEndpoint.php b/src/Core/Auth/OAuthEndpoint.php new file mode 100644 index 00000000..66f59e2b --- /dev/null +++ b/src/Core/Auth/OAuthEndpoint.php @@ -0,0 +1,713 @@ + mint kmcp_live_ [P1-D] + * + * Blueprint: keyq-slipstream api/src/routes/oauth-as.ts (swap "mint PAT" -> + * "mint kmcp_live_"). Design: docs/design/hosted-mcp-oauth.md. + * + * This slice (P1-A) implements discovery only; register/authorize/token return + * 501 until their cards land. Testability: process() is pure (Api, $server, + * raw body) -> ['status', 'body'|'raw', 'headers']. + */ +class OAuthEndpoint +{ + /** + * kmcp scopes this AS can grant. Mirrors KyteMCPTokenController::VALID_SCOPES + * — the OAuth scope grant maps onto these before a token is minted. + */ + private const SUPPORTED_SCOPES = ['read', 'draft', 'commit', 'provision', 'schema']; + + /** Registration guardrails (open DCR — hardening tracked in #556). */ + private const MAX_REDIRECT_URIS = 10; + private const MAX_URI_LENGTH = 2048; + + /** Authorization codes are short-lived (single-use besides). */ + private const CODE_TTL_SECONDS = 300; + + /** Default access-token lifetime (30d) — override with KYTE_OAUTH_ACCESS_TTL. */ + private const DEFAULT_ACCESS_TTL = 2592000; + + public static function handle(Api $api): void + { + self::emitCorsHeaders(); + + if (($_SERVER['REQUEST_METHOD'] ?? '') === 'OPTIONS') { + $reqHeaders = $_SERVER['HTTP_ACCESS_CONTROL_REQUEST_HEADERS'] ?? ''; + header('Access-Control-Allow-Methods: GET, POST, OPTIONS'); + header("Access-Control-Allow-Headers: {$reqHeaders}"); + http_response_code(204); + return; + } + + $rawBody = (string)file_get_contents('php://input'); + $result = self::process($api, $_SERVER, $rawBody); + + foreach (($result['headers'] ?? []) as $header) { + header($header); + } + http_response_code((int)$result['status']); + + if (array_key_exists('raw', $result)) { + echo $result['raw']; + } elseif (isset($result['body'])) { + header('Content-Type: application/json; charset=utf-8'); + echo json_encode($result['body']); + } + } + + /** + * Pure dispatcher. Returns ['status' => int, 'body' => array|'raw' => string, + * 'headers' => string[]]. + */ + public static function process(Api $api, array $server, string $rawBody): array + { + $path = ltrim((string)parse_url($server['REQUEST_URI'] ?? '', PHP_URL_PATH), '/'); + + try { + if (strcasecmp($path, '.well-known/oauth-authorization-server') === 0) { + return self::authorizationServerMetadata($server); + } + // RFC 9728 allows a path-suffixed variant (…/mcp); match the family. + if (stripos($path, '.well-known/oauth-protected-resource') === 0) { + return self::protectedResourceMetadata($server); + } + + $method = strtoupper((string)($server['REQUEST_METHOD'] ?? 'GET')); + $segments = explode('/', $path); + $action = $segments[1] ?? ''; // oauth/ + switch ($action) { + case 'register': // RFC 7591 dynamic client registration [P1-B / #553] + if ($method !== 'POST') { + return self::error(405, 'invalid_request', 'POST required for /oauth/register.'); + } + return self::register(self::decodeBody($rawBody)); + case 'authorize': // interactive consent lives in Shipyard [P1-C / #554] + // The consent page (which reuses Shipyard's login) is a + // Shipyard SPA route. Redirect any direct hit there, + // preserving the OAuth query params. + $qs = (string)($server['QUERY_STRING'] ?? ''); + return [ + 'status' => 302, + 'headers' => [ + 'Location: ' . self::shipyardConsentUrl() . ($qs !== '' ? '?' . $qs : ''), + 'Cache-Control: no-store', + ], + ]; + case 'consent': // authed helpers the Shipyard consent page calls [P1-C / #554] + $sub = $segments[2] ?? ''; + if ($sub === 'client') { + if ($method !== 'GET') { + return self::error(405, 'invalid_request', 'GET required for /oauth/consent/client.'); + } + return self::consentClient($api, self::queryParams($server)); + } + if ($sub === 'approve') { + if ($method !== 'POST') { + return self::error(405, 'invalid_request', 'POST required for /oauth/consent/approve.'); + } + return self::consentApprove($api, self::decodeBody($rawBody)); + } + return self::error(404, 'not_found', "Unknown OAuth endpoint: /{$path}."); + case 'token': // code+verifier -> mint kmcp_live_ [P1-D / #555] + if ($method !== 'POST') { + return self::error(405, 'invalid_request', 'POST required for /oauth/token.'); + } + return self::token(self::parseTokenBody($rawBody, $server)); + default: + return self::error(404, 'not_found', "Unknown OAuth endpoint: /{$path}."); + } + } catch (\Throwable $e) { + error_log('OAuthEndpoint: ' . $e->getMessage()); + return self::error(500, 'server_error', 'Internal error.'); + } + } + + /** + * Canonical HTTPS base URL for this install's AS. Prefers an explicit + * operator override, then the server-set host (API_URL = SERVER_NAME, not + * the client-controlled Host header — avoids host-header injection into the + * issuer identifier), then HTTP_HOST as a last resort. + */ + public static function baseUrl(array $server): string + { + if (defined('KYTE_OAUTH_ISSUER') && KYTE_OAUTH_ISSUER) { + return rtrim((string)KYTE_OAUTH_ISSUER, '/'); + } + $host = (defined('API_URL') && API_URL) + ? (string)API_URL + : (string)($server['HTTP_HOST'] ?? 'localhost'); + return 'https://' . $host; + } + + private static function authorizationServerMetadata(array $server): array + { + $base = self::baseUrl($server); + return self::json(200, [ + 'issuer' => $base, + 'authorization_endpoint' => self::shipyardConsentUrl(), + 'token_endpoint' => $base . '/oauth/token', + 'registration_endpoint' => $base . '/oauth/register', + 'scopes_supported' => self::SUPPORTED_SCOPES, + 'response_types_supported' => ['code'], + 'grant_types_supported' => ['authorization_code'], + 'code_challenge_methods_supported' => ['S256'], + 'token_endpoint_auth_methods_supported' => ['none'], + ]); + } + + private static function protectedResourceMetadata(array $server): array + { + $base = self::baseUrl($server); + return self::json(200, [ + 'resource' => $base . '/mcp', + 'authorization_servers' => [$base], + 'scopes_supported' => self::SUPPORTED_SCOPES, + 'bearer_methods_supported' => ['header'], + ]); + } + + private static function notImplemented(string $what): array + { + return self::error(501, 'not_implemented', "OAuth /{$what} is not implemented yet (KYTE-#551)."); + } + + /** + * RFC 7591 dynamic client registration. Claude / ChatGPT self-register as + * PUBLIC clients (PKCE, no secret, token_endpoint_auth_method=none) before + * running the authorization-code flow. Open registration is the MCP model; + * the real gate is user consent (P1-C) + PKCE (P1-D), not client auth. + * + * @param array $body + */ + private static function register(array $body): array + { + // redirect_uris — required, non-empty; each https (or http on loopback + // for native/desktop per RFC 8252). Exact-matched at authorize/token. + $redirectUris = $body['redirect_uris'] ?? null; + if (!is_array($redirectUris) || count($redirectUris) === 0) { + return self::error(400, 'invalid_redirect_uri', 'redirect_uris is required (non-empty array).'); + } + if (count($redirectUris) > self::MAX_REDIRECT_URIS) { + return self::error(400, 'invalid_redirect_uri', 'Too many redirect_uris.'); + } + $uriError = self::validateRedirectUris($redirectUris); + if ($uriError !== null) { + return self::error(400, 'invalid_redirect_uri', $uriError); + } + + // Only the public-client authorization-code + PKCE profile is supported. + $grantTypes = self::intersectCsv($body['grant_types'] ?? ['authorization_code'], ['authorization_code']); + if ($grantTypes === '') { + return self::error(400, 'invalid_client_metadata', 'Only the authorization_code grant is supported.'); + } + $responseTypes = self::intersectCsv($body['response_types'] ?? ['code'], ['code']); + if ($responseTypes === '') { + return self::error(400, 'invalid_client_metadata', 'Only the "code" response_type is supported.'); + } + + $scope = self::filterScope(isset($body['scope']) ? (string)$body['scope'] : ''); + $clientName = isset($body['client_name']) && is_string($body['client_name']) + ? substr($body['client_name'], 0, 255) + : null; + $clientId = self::generateClientId(); + + $client = new \Kyte\Core\ModelObject(KyteOAuthClient); + $client->create([ + 'client_id' => $clientId, + 'client_name' => $clientName, + 'redirect_uris' => json_encode(array_values($redirectUris)), + 'grant_types' => $grantTypes, + 'response_types' => $responseTypes, + 'token_endpoint_auth_method' => 'none', + 'scope' => $scope, + 'kyte_account' => 0, // unscoped until consent binds an account + ]); + + // RFC 7591 §3.2.1 client information response. + return [ + 'status' => 201, + 'headers' => ['Cache-Control: no-store', 'Pragma: no-cache'], + 'body' => [ + 'client_id' => $clientId, + 'client_id_issued_at' => (int)$client->date_created, + 'client_name' => $clientName, + 'redirect_uris' => array_values($redirectUris), + 'grant_types' => explode(',', $grantTypes), + 'response_types' => explode(',', $responseTypes), + 'token_endpoint_auth_method' => 'none', + 'scope' => $scope, + ], + ]; + } + + /** @param array $uris Returns an error string, or null on pass. */ + private static function validateRedirectUris(array $uris): ?string + { + foreach ($uris as $uri) { + if (!is_string($uri) || $uri === '' || strlen($uri) > self::MAX_URI_LENGTH) { + return 'Each redirect_uri must be a non-empty URI under ' . self::MAX_URI_LENGTH . ' chars.'; + } + $parts = parse_url($uri); + if ($parts === false || empty($parts['scheme']) || empty($parts['host'])) { + return "Invalid redirect_uri: {$uri}"; + } + $scheme = strtolower($parts['scheme']); + $host = strtolower($parts['host']); + $loopback = in_array($host, ['localhost', '127.0.0.1', '::1', '[::1]'], true); + if ($scheme === 'https' || ($scheme === 'http' && $loopback)) { + continue; + } + return "redirect_uri must use https (or http on a loopback host): {$uri}"; + } + return null; + } + + /** + * Opaque, unguessable public client identifier. `kyoc_` = kyte-oauth-client. + * ~140 bits from a CSPRNG; the UNIQUE index is the collision backstop. + */ + private static function generateClientId(): string + { + $alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + $len = strlen($alphabet); + $body = ''; + $bytes = random_bytes(24); + for ($i = 0; $i < 24; $i++) { + $body .= $alphabet[ord($bytes[$i]) % $len]; + } + return 'kyoc_' . $body; + } + + /** + * Intersect a JSON-array-or-string metadata value with an allowlist, + * returning a CSV for storage. @param mixed $value + */ + private static function intersectCsv($value, array $allow): string + { + if (is_array($value)) { + $items = $value; + } elseif (is_string($value) && trim($value) !== '') { + $items = preg_split('/[\s,]+/', trim($value)) ?: []; + } else { + $items = []; + } + $keep = array_values(array_intersect(array_map('strval', $items), $allow)); + return implode(',', array_unique($keep)); + } + + /** + * Filter a requested OAuth scope string to what this AS grants; default to + * least-privilege "read" when nothing valid is requested. Space-separated + * per OAuth convention. The effective grant is re-confirmed at consent. + */ + private static function filterScope(string $scope): string + { + return implode(' ', self::scopeList($scope)); + } + + private static function decodeBody(string $rawBody): array + { + if (trim($rawBody) === '') { + return []; + } + $decoded = json_decode($rawBody, true); + return is_array($decoded) ? $decoded : []; + } + + // ----- P1-C: authorize / consent ------------------------------------- + + /** + * Consent URL in Shipyard — the interactive authorize page reuses the + * Shipyard login the user already has. Per-install SHIPYARD_URL; falls + * back to this host if unset. + */ + private static function shipyardConsentUrl(): string + { + $base = (defined('SHIPYARD_URL') && SHIPYARD_URL) + ? rtrim((string)SHIPYARD_URL, '/') + : self::baseUrl($_SERVER); + // Root .html (served like login/password/reset) — avoids the deploy + // bundle + directory-index concerns of a nested path. + return $base . '/oauth-authorize.html'; + } + + /** + * Return client display info for the consent screen (authenticated — the + * Shipyard consent page is post-login). Validates the authorize request + * without minting anything. + * + * @param array $params + */ + private static function consentClient(Api $api, array $params): array + { + $authErr = self::authenticateUser($api); + if ($authErr !== null) { + return $authErr; + } + + $v = self::validateAuthorizeParams($params); + if (isset($v['error'])) { + return self::error(400, (string)$v['error'], (string)$v['message']); + } + + $client = $v['client']; + $name = ($client->client_name !== null && $client->client_name !== '') + ? (string)$client->client_name + : 'The application'; + + return self::json(200, [ + 'client_id' => (string)$client->client_id, + 'client_name' => $name, + 'redirect_uri' => $v['redirect_uri'], + 'scopes' => $v['scopes'], // kmcp scopes the client requested + 'state' => $v['state'], + 'account' => ['id' => (int)$api->account->id], + ]); + } + + /** + * Mint a single-use authorization code bound to the consenting user's + * account + the PKCE challenge (authenticated). Returns the redirect target + * for the Shipyard page to send the browser back to the client. + * + * @param array $body + */ + private static function consentApprove(Api $api, array $body): array + { + $authErr = self::authenticateUser($api); + if ($authErr !== null) { + return $authErr; + } + + $v = self::validateAuthorizeParams($body); + if (isset($v['error'])) { + return self::error(400, (string)$v['error'], (string)$v['message']); + } + + $rawCode = self::generateAuthCode(); + $kyteScopes = implode(',', $v['scopes']); // kmcp scopes to mint (CSV) + + $code = new \Kyte\Core\ModelObject(KyteOAuthCode); + $code->create([ + 'code_hash' => hash('sha256', $rawCode), + 'client_id' => (string)$v['client']->client_id, + 'redirect_uri' => $v['redirect_uri'], + 'code_challenge' => $v['code_challenge'], + 'code_challenge_method' => 'S256', + 'scope' => implode(' ', $v['scopes']), + 'kyte_scopes' => $kyteScopes !== '' ? $kyteScopes : 'read', + 'application' => null, // account-wide (v1) + 'expires_at' => time() + self::CODE_TTL_SECONDS, + 'consumed_at' => 0, + 'kyte_account' => (int)$api->account->id, + ], (int)$api->user->id); // created_by = the consenting user + + return self::json(200, [ + 'redirect_uri' => $v['redirect_uri'], + 'code' => $rawCode, + 'state' => $v['state'], + ]); + } + + /** + * Validate an authorize/consent request (client + redirect_uri exact-match + * + response_type=code + PKCE S256). Returns the resolved bundle, or an + * ['error','message'] pair. + * + * @param array $p + * @return array + */ + private static function validateAuthorizeParams(array $p): array + { + $clientId = isset($p['client_id']) ? (string)$p['client_id'] : ''; + $redirectUri = isset($p['redirect_uri']) ? (string)$p['redirect_uri'] : ''; + $responseType = isset($p['response_type']) ? (string)$p['response_type'] : 'code'; + $challenge = isset($p['code_challenge']) ? (string)$p['code_challenge'] : ''; + $method = isset($p['code_challenge_method']) ? (string)$p['code_challenge_method'] : ''; + $state = isset($p['state']) ? (string)$p['state'] : ''; + $scopeStr = isset($p['scope']) ? (string)$p['scope'] : ''; + + if ($clientId === '') { + return ['error' => 'invalid_request', 'message' => 'client_id is required.']; + } + $client = self::findClient($clientId); + if ($client === null) { + return ['error' => 'invalid_client', 'message' => 'Unknown client_id.']; + } + if ($redirectUri === '' || !self::redirectUriAllowed($client, $redirectUri)) { + return ['error' => 'invalid_request', 'message' => 'redirect_uri does not match a registered URI.']; + } + if ($responseType !== 'code') { + return ['error' => 'unsupported_response_type', 'message' => 'Only response_type=code is supported.']; + } + if ($challenge === '') { + return ['error' => 'invalid_request', 'message' => 'code_challenge (PKCE) is required.']; + } + if (strtoupper($method) !== 'S256') { + return ['error' => 'invalid_request', 'message' => 'code_challenge_method must be S256.']; + } + + return [ + 'client' => $client, + 'redirect_uri' => $redirectUri, + 'scopes' => self::scopeList($scopeStr), + 'code_challenge' => $challenge, + 'state' => $state, + ]; + } + + private static function findClient(string $clientId): ?\Kyte\Core\ModelObject + { + $c = new \Kyte\Core\ModelObject(KyteOAuthClient); + return $c->retrieve('client_id', $clientId) ? $c : null; + } + + private static function redirectUriAllowed(\Kyte\Core\ModelObject $client, string $uri): bool + { + $registered = json_decode((string)$client->redirect_uris, true); + return is_array($registered) && in_array($uri, $registered, true); + } + + /** + * Authenticate the caller as a Kyte USER session (the Shipyard consent + * page's JWT/HMAC session), populating $api->user + $api->account. Returns + * a 401 error array on failure, or null on success. An MCP bearer is not a + * user session and is rejected. + */ + private static function authenticateUser(Api $api): ?array + { + try { + $strategy = AuthDispatcher::buildDefault()->select(); + if ($strategy === null || $strategy instanceof McpTokenStrategy) { + return self::error(401, 'login_required', 'Authentication required.'); + } + $strategy->preAuth($api); + $strategy->verify($api); + } catch (\Kyte\Exception\SessionException $e) { + return self::error(401, 'login_required', 'Authentication required.'); + } catch (\Throwable $e) { + error_log('OAuthEndpoint::authenticateUser - ' . $e->getMessage()); + return self::error(401, 'login_required', 'Authentication required.'); + } + + if (!isset($api->user->id) || !$api->user->id + || !isset($api->account->id) || !$api->account->id) { + return self::error(401, 'login_required', 'Authentication required.'); + } + return null; + } + + /** Filtered kmcp scope list (default least-privilege ['read']). */ + private static function scopeList(string $scope): array + { + $requested = trim($scope) !== '' ? (preg_split('/\s+/', trim($scope)) ?: []) : []; + $keep = array_values(array_intersect(array_map('strval', $requested), self::SUPPORTED_SCOPES)); + if (empty($keep)) { + $keep = ['read']; + } + return array_values(array_unique($keep)); + } + + private static function generateAuthCode(): string + { + $alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + $len = strlen($alphabet); + $body = ''; + $bytes = random_bytes(32); + for ($i = 0; $i < 32; $i++) { + $body .= $alphabet[ord($bytes[$i]) % $len]; + } + return 'kyac_' . $body; // kyte auth code + } + + /** @param array $server */ + private static function queryParams(array $server): array + { + $qs = (string)($server['QUERY_STRING'] ?? ''); + if ($qs === '') { + return []; + } + $out = []; + parse_str($qs, $out); + return $out; + } + + // ----- P1-D: token endpoint ------------------------------------------ + + /** + * OAuth 2.1 token endpoint (authorization_code grant + PKCE). Redeems a + * single-use KyteOAuthCode for a freshly-minted scoped `kmcp_live_` token — + * the OAuth access token IS a normal MCP bearer, so every downstream MCP + * request is authenticated exactly as before. + * + * @param array $body + */ + private static function token(array $body): array + { + $grantType = isset($body['grant_type']) ? (string)$body['grant_type'] : ''; + if ($grantType !== 'authorization_code') { + return self::error(400, 'unsupported_grant_type', 'Only the authorization_code grant is supported.'); + } + + $rawCode = isset($body['code']) ? (string)$body['code'] : ''; + $clientId = isset($body['client_id']) ? (string)$body['client_id'] : ''; + $redirect = isset($body['redirect_uri']) ? (string)$body['redirect_uri'] : ''; + $verifier = isset($body['code_verifier']) ? (string)$body['code_verifier'] : ''; + + if ($rawCode === '' || $clientId === '' || $redirect === '' || $verifier === '') { + return self::error(400, 'invalid_request', 'code, client_id, redirect_uri and code_verifier are required.'); + } + + $code = new \Kyte\Core\ModelObject(KyteOAuthCode); + if (!$code->retrieve('code_hash', hash('sha256', $rawCode))) { + return self::error(400, 'invalid_grant', 'Invalid authorization code.'); + } + if ((int)$code->consumed_at !== 0) { + return self::error(400, 'invalid_grant', 'Authorization code already used.'); + } + if ((int)$code->expires_at < time()) { + return self::error(400, 'invalid_grant', 'Authorization code expired.'); + } + if ((string)$code->client_id !== $clientId) { + return self::error(400, 'invalid_grant', 'client_id mismatch.'); + } + if ((string)$code->redirect_uri !== $redirect) { + return self::error(400, 'invalid_grant', 'redirect_uri mismatch.'); + } + + // PKCE S256: base64url(sha256(verifier)) must equal the stored challenge. + $computed = rtrim(strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), '='); + if (!hash_equals((string)$code->code_challenge, $computed)) { + return self::error(400, 'invalid_grant', 'PKCE verification failed.'); + } + + // Single-use: burn the code before issuing the token. + $code->save(['consumed_at' => time()]); + + $ttl = (defined('KYTE_OAUTH_ACCESS_TTL') && (int)KYTE_OAUTH_ACCESS_TTL > 0) + ? (int)KYTE_OAUTH_ACCESS_TTL + : self::DEFAULT_ACCESS_TTL; + $kyteScopes = (string)$code->kyte_scopes !== '' ? (string)$code->kyte_scopes : 'read'; + + $accessToken = self::mintMcpToken( + (int)$code->kyte_account, + $kyteScopes, + 'OAuth connector', + (int)$code->created_by, + time() + $ttl + ); + + return [ + 'status' => 200, + 'headers' => ['Cache-Control: no-store', 'Pragma: no-cache'], + 'body' => [ + 'access_token' => $accessToken, + 'token_type' => 'Bearer', + 'expires_in' => $ttl, + 'scope' => str_replace(',', ' ', $kyteScopes), + ], + ]; + } + + /** + * Mint a scoped kmcp_live_ token (account-wide) as the OAuth access token. + * Same format/storage as a Shipyard-issued token, so McpTokenStrategy + * validates it identically. Returns the raw token (shown once). + */ + private static function mintMcpToken(int $account, string $scopesCsv, string $name, int $createdBy, int $expiresAt): string + { + $alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + $len = strlen($alphabet); + $suffix = ''; + $bytes = random_bytes(32); + for ($i = 0; $i < 32; $i++) { + $suffix .= $alphabet[ord($bytes[$i]) % $len]; + } + $raw = McpTokenStrategy::TOKEN_PREFIX . $suffix; + + $token = new \Kyte\Core\ModelObject(KyteMCPToken); + $token->create([ + 'token_hash' => hash('sha256', $raw), + 'token_prefix' => substr($raw, 0, 16), + 'name' => $name, + 'application' => null, // account-wide (v1) + 'scopes' => $scopesCsv, + 'expires_at' => $expiresAt, + 'kyte_account' => $account, + ], $createdBy > 0 ? $createdBy : null); + + return $raw; + } + + /** + * The OAuth token endpoint is application/x-www-form-urlencoded by spec; + * accept JSON too for lenience. @return array + * @param array $server + */ + private static function parseTokenBody(string $rawBody, array $server): array + { + $ct = strtolower((string)($server['CONTENT_TYPE'] ?? $server['HTTP_CONTENT_TYPE'] ?? '')); + if (strpos($ct, 'application/json') !== false) { + return self::decodeBody($rawBody); + } + $out = []; + parse_str($rawBody, $out); + return $out; + } + + /** @param array $body */ + private static function json(int $status, array $body): array + { + return [ + 'status' => $status, + 'body' => $body, + 'headers' => ['Cache-Control: no-store'], + ]; + } + + private static function error(int $status, string $code, string $message): array + { + // OAuth 2.0 error response shape (RFC 6749 §5.2 / §4.1.2.1). + return [ + 'status' => $status, + 'body' => ['error' => $code, 'error_description' => $message], + 'headers' => ['Cache-Control: no-store'], + ]; + } + + private static function emitCorsHeaders(): void + { + // The Shipyard consent page + browser connectors hit these endpoints + // cross-origin. Auth is ALWAYS header-based (Authorization: Bearer / + // X-Kyte-*) — never an ambient cookie — so we reflect the Origin but + // deliberately do NOT send Access-Control-Allow-Credentials: an + // auth-code endpoint must not combine credentialed CORS with a + // reflected origin. Without credentials, a reflected origin exposes + // nothing: cross-site JS still cannot obtain the required bearer. + $origin = $_SERVER['HTTP_ORIGIN'] ?? ''; + if ($origin !== '') { + header("Access-Control-Allow-Origin: {$origin}"); + header('Vary: Origin'); + } + } +} diff --git a/src/Core/Auth/SsoEndpoint.php b/src/Core/Auth/SsoEndpoint.php new file mode 100644 index 00000000..9830ca0f --- /dev/null +++ b/src/Core/Auth/SsoEndpoint.php @@ -0,0 +1,855 @@ + + * Look up the app's provider config, run OIDC discovery, build + * state+nonce+PKCE, persist a KyteSsoState, 302 to the provider. + * GET /sso/callback?code=&state= [P1 next slice #561] + * Validate state, exchange the code, validate the id_token, map the + * user (JIT), mint a Kyte session, redirect back with a one-time code. + * POST /sso/exchange body {sso_code} [P1 next slice #561] + * Redeem the one-time code -> the Kyte JWT session (access+refresh). + * + * Config (KyteAppIdentityProvider) is Shipyard-managed and carries a + * KMS-encrypted client secret — never exposed via MCP. Design: + * docs/design/app-microsoft-sso.md. + */ +class SsoEndpoint +{ + private const STATE_TTL = 600; // 10 min to complete the provider round-trip + + public static function handle(Api $api): void + { + self::emitCorsHeaders(); + if (($_SERVER['REQUEST_METHOD'] ?? '') === 'OPTIONS') { + $reqHeaders = $_SERVER['HTTP_ACCESS_CONTROL_REQUEST_HEADERS'] ?? ''; + header('Access-Control-Allow-Methods: GET, POST, OPTIONS'); + header("Access-Control-Allow-Headers: {$reqHeaders}"); + http_response_code(204); + return; + } + + $rawBody = (string)file_get_contents('php://input'); + $result = self::process($api, $_SERVER, $rawBody); + + foreach (($result['headers'] ?? []) as $header) { + header($header); + } + http_response_code((int)$result['status']); + if (array_key_exists('raw', $result)) { + echo $result['raw']; + } elseif (isset($result['body'])) { + header('Content-Type: application/json; charset=utf-8'); + echo json_encode($result['body']); + } + } + + /** + * Pure dispatcher. @return array{status:int, body?:array, raw?:string, headers?:string[]} + */ + public static function process(Api $api, array $server, string $rawBody): array + { + $path = ltrim((string)parse_url($server['REQUEST_URI'] ?? '', PHP_URL_PATH), '/'); + $segments = explode('/', $path); + $action = $segments[1] ?? ''; // sso/ + + try { + switch ($action) { + case 'authorize': + return self::authorize(self::queryParams($server)); + case 'callback': + return self::callback(self::queryParams($server)); + case 'exchange': + return self::exchange(self::parseBody($rawBody, $server), self::clientIp($server)); + default: + return self::error(404, 'not_found', "Unknown SSO endpoint: /{$path}."); + } + } catch (\Throwable $e) { + error_log('SsoEndpoint: ' . $e->getMessage()); + return self::error(500, 'server_error', 'SSO error.'); + } + } + + /** + * Begin an SSO login: resolve the app's provider, discover the provider's + * authorization endpoint, and redirect the user there with state + nonce + + * PKCE (all persisted in a short-lived KyteSsoState for the callback). + * + * @param array $params + */ + private static function authorize(array $params): array + { + $appIdentifier = isset($params['app_identifier']) ? (string)$params['app_identifier'] : ''; + $providerName = isset($params['provider']) && $params['provider'] !== '' ? (string)$params['provider'] : 'microsoft'; + $returnUrl = isset($params['redirect']) ? (string)$params['redirect'] : ''; + + if ($appIdentifier === '') { + return self::error(400, 'invalid_request', 'app_identifier is required.'); + } + + $app = new ModelObject(\Application); + if (!$app->retrieve('identifier', $appIdentifier)) { + return self::error(404, 'not_found', 'Application not found.'); + } + + // Reject an off-app return target before we ever redirect to the IdP — + // the single-use sso_code must only ever be handed back to one of this + // app's own site domains (open-redirect / code-interception defense). + if (!self::isAllowedReturnUrl($app, $returnUrl)) { + return self::error(400, 'invalid_request', 'redirect is not an allowed return URL for this application.'); + } + + $cfg = new ModelObject(\KyteAppIdentityProvider); + $found = $cfg->retrieve('application', (int)$app->id, [ + ['field' => 'provider', 'value' => $providerName], + ['field' => 'enabled', 'value' => 1], + ]); + if (!$found) { + return self::error(404, 'sso_not_configured', "SSO ({$providerName}) is not enabled for this application."); + } + if (empty($cfg->client_id)) { + return self::error(500, 'sso_misconfigured', 'SSO provider is missing a client_id.'); + } + + // OIDC discovery → authorization_endpoint. + $discovery = self::discover($cfg); + if ($discovery === null || empty($discovery['authorization_endpoint'])) { + return self::error(502, 'discovery_failed', 'Could not load the SSO provider configuration.'); + } + + // state + nonce + PKCE + a browser-bound correlator (login-CSRF defense). + $state = self::randToken(32); + $nonce = self::randToken(32); + $verifier = self::randToken(64); + $browser = self::randToken(32); + $challenge = rtrim(strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), '='); + + $redirectUri = !empty($cfg->redirect_uri) ? (string)$cfg->redirect_uri : (self::baseUrl($_SERVER) . '/sso/callback'); + + $stateRow = new ModelObject(\KyteSsoState); + $stateRow->create([ + 'state' => $state, + 'nonce' => $nonce, + 'code_verifier' => $verifier, + 'browser_hash' => hash('sha256', $browser), + 'application' => (int)$app->id, + 'provider' => $providerName, + 'return_url' => $returnUrl, + 'redirect_uri' => $redirectUri, + 'expires_at' => time() + self::STATE_TTL, + 'consumed_at' => 0, + 'kyte_account' => (int)$app->kyte_account, + ]); + + $scopes = !empty($cfg->scopes) ? (string)$cfg->scopes : 'openid profile email'; + $authUrl = (string)$discovery['authorization_endpoint'] . '?' . http_build_query([ + 'client_id' => (string)$cfg->client_id, + 'response_type' => 'code', + 'redirect_uri' => $redirectUri, + 'response_mode' => 'query', + 'scope' => $scopes, + 'state' => $state, + 'nonce' => $nonce, + 'code_challenge' => $challenge, + 'code_challenge_method' => 'S256', + ]); + + // SameSite=Lax so the cookie rides the top-level GET redirect back from + // the IdP to /callback, but not arbitrary cross-site subrequests. + // Scoped to /sso; short-lived to match the state TTL. + $cookie = 'kyte_sso_bt=' . $browser + . '; Max-Age=' . self::STATE_TTL . '; Path=/sso; HttpOnly; Secure; SameSite=Lax'; + + return ['status' => 302, 'headers' => [ + 'Location: ' . $authUrl, + 'Set-Cookie: ' . $cookie, + 'Cache-Control: no-store', + ]]; + } + + /** + * Fetch the provider's OIDC discovery document (authorization/token + * endpoints + jwks_uri). Uses the explicit discovery_url, else the issuer + + * /.well-known/openid-configuration. + * + * @return array|null + */ + private static function discover(ModelObject $cfg): ?array + { + $url = !empty($cfg->discovery_url) + ? (string)$cfg->discovery_url + : rtrim((string)$cfg->issuer, '/') . '/.well-known/openid-configuration'; + if ($url === '/.well-known/openid-configuration') { + return null; + } + return self::httpGetJson($url); + } + + /** @return array|null */ + private static function httpGetJson(string $url): ?array + { + $ch = curl_init($url); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 10, + CURLOPT_FOLLOWLOCATION => false, + CURLOPT_HTTPHEADER => ['Accept: application/json'], + ]); + $body = curl_exec($ch); + $code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + if ($body === false || $code < 200 || $code >= 300) { + return null; + } + $decoded = json_decode((string)$body, true); + return is_array($decoded) ? $decoded : null; + } + + // ----- callback + exchange (KYTE-#560 P1) ---------------------------- + + /** + * OIDC callback: validate the state, exchange the code, validate the + * id_token, map/JIT the app user, and hand back a single-use sso_code the + * app front-end redeems at /sso/exchange. + * + * @param array $params + */ + private static function callback(array $params): array + { + $state = isset($params['state']) ? (string)$params['state'] : ''; + $code = isset($params['code']) ? (string)$params['code'] : ''; + $provErr = isset($params['error']) ? (string)$params['error'] : ''; + + if ($state === '') { + return self::error(400, 'invalid_request', 'Missing state.'); + } + + // Look up + single-use consume the in-flight state. + $st = new ModelObject(\KyteSsoState); + if (!$st->retrieve('state', $state)) { + return self::error(400, 'invalid_state', 'Unknown or expired login state.'); + } + if ((int)$st->consumed_at !== 0) { + return self::error(400, 'invalid_state', 'Login state already used.'); + } + if ((int)$st->expires_at < time()) { + return self::error(400, 'invalid_state', 'Login state expired.'); + } + + // Bind the callback to the browser that began the flow (login-CSRF / + // session-fixation defense). The correlator was set as an HttpOnly + // cookie at /authorize; require its hash to match this state row before + // consuming anything. + $expectHash = (string)($st->browser_hash ?? ''); + $browserTok = self::readCookie('kyte_sso_bt'); + if ($expectHash === '' || $browserTok === '' || !hash_equals($expectHash, hash('sha256', $browserTok))) { + return self::error(400, 'invalid_state', 'Login state not bound to this browser.'); + } + + $st->save(['consumed_at' => time()]); + + $returnUrl = (string)($st->return_url ?? ''); + + if ($provErr !== '') { + return self::backToApp($returnUrl, ['error' => $provErr]); + } + if ($code === '') { + return self::backToApp($returnUrl, ['error' => 'no_code']); + } + + $app = new ModelObject(\Application); + if (!$app->retrieve('id', (int)$st->application)) { + return self::error(400, 'invalid_state', 'Application not found.'); + } + $cfg = new ModelObject(\KyteAppIdentityProvider); + if (!$cfg->retrieve('application', (int)$app->id, [ + ['field' => 'provider', 'value' => (string)$st->provider], + ['field' => 'enabled', 'value' => 1], + ])) { + return self::backToApp($returnUrl, ['error' => 'sso_not_configured']); + } + + $disc = self::discover($cfg); + if ($disc === null || empty($disc['token_endpoint']) || empty($disc['jwks_uri'])) { + return self::backToApp($returnUrl, ['error' => 'discovery_failed']); + } + + // Exchange the authorization code (client_secret decrypted server-side). + try { + $secret = self::decryptSecret((string)$cfg->client_secret); + } catch (\Throwable $e) { + error_log('SsoEndpoint: secret decrypt failed - ' . $e->getMessage()); + return self::backToApp($returnUrl, ['error' => 'secret_error']); + } + $tok = self::httpPostForm((string)$disc['token_endpoint'], [ + 'grant_type' => 'authorization_code', + 'code' => $code, + 'redirect_uri' => (string)$st->redirect_uri, + 'client_id' => (string)$cfg->client_id, + 'client_secret' => $secret, + 'code_verifier' => (string)$st->code_verifier, + 'scope' => (string)($cfg->scopes ?: 'openid profile email'), + ]); + if ($tok === null || empty($tok['id_token'])) { + return self::backToApp($returnUrl, ['error' => 'token_exchange_failed']); + } + + // Validate the id_token (signature + aud + nonce + tenant). + $claims = self::validateIdToken((string)$tok['id_token'], $disc, $cfg, (string)$st->nonce); + if ($claims === null) { + return self::backToApp($returnUrl, ['error' => 'id_token_invalid']); + } + + // Map to an app user by the IMMUTABLE subject (sub) — never by the + // mutable/unverified email or preferred_username (account-takeover + // defense). Email is a display attribute + a first-login match hint, + // trusted only when the asserting tenant is authoritative. + $subject = isset($claims['sub']) ? (string)$claims['sub'] : ''; + if ($subject === '') { + return self::backToApp($returnUrl, ['error' => 'no_subject']); + } + $tid = isset($claims['tid']) ? (string)$claims['tid'] : ''; + + $emailClaim = (string)($cfg->user_email_claim ?: 'email'); + $rawEmail = $claims[$emailClaim] ?? ($claims['email'] ?? null); + $email = (is_string($rawEmail) && $rawEmail !== '') ? $rawEmail : null; + + $user = self::resolveSsoUser( + $app, $cfg, (string)$st->provider, $subject, $tid, $email, + (int)$cfg->jit_enabled === 1, (int)$cfg->restrict_to_existing === 1, $claims + ); + if (is_string($user)) { + return self::backToApp($returnUrl, ['error' => $user]); + } + + // Single-use hand-off code (no tokens at rest). + $rawCode = self::randToken(48); + $codeRow = new ModelObject(\KyteSsoCode); + $codeRow->create([ + 'code_hash' => hash('sha256', $rawCode), + 'application' => (int)$app->id, + 'sso_user_id' => (int)$user->id, + 'expires_at' => time() + 120, + 'consumed_at' => 0, + 'kyte_account' => (int)$app->kyte_account, + ]); + + // Defense in depth: the return_url was validated at /authorize, but never + // redirect the code to a host that isn't (still) one of the app's own — + // fall back to returning it as JSON rather than leaking it off-app. + if (!self::isAllowedReturnUrl($app, $returnUrl)) { + return self::backToApp('', ['sso_code' => $rawCode]); + } + + return self::backToApp($returnUrl, ['sso_code' => $rawCode]); + } + + /** + * Redeem a single-use sso_code for the Kyte JWT session (access + refresh). + * + * @param array $body + */ + private static function exchange(array $body, string $ip): array + { + $rawCode = isset($body['sso_code']) ? (string)$body['sso_code'] : ''; + if ($rawCode === '') { + return self::error(400, 'invalid_request', 'sso_code is required.'); + } + + $codeRow = new ModelObject(\KyteSsoCode); + if (!$codeRow->retrieve('code_hash', hash('sha256', $rawCode))) { + return self::error(400, 'invalid_grant', 'Invalid sso_code.'); + } + if ((int)$codeRow->consumed_at !== 0) { + return self::error(400, 'invalid_grant', 'sso_code already used.'); + } + if ((int)$codeRow->expires_at < time()) { + return self::error(400, 'invalid_grant', 'sso_code expired.'); + } + $codeRow->save(['consumed_at' => time()]); + + $app = new ModelObject(\Application); + if (!$app->retrieve('id', (int)$codeRow->application)) { + return self::error(400, 'invalid_grant', 'Application not found.'); + } + // resolveAuthContext registers the app's user_model + sets its DB context. + $ctx = JwtEndpoint::resolveAuthContext((string)$app->identifier); + if ($ctx['user_model'] === constant('KyteUser')) { + // Never mint a platform-user session from an SSO code (mirrors the + // callback-side refusal; a code should not exist for this case). + return self::error(400, 'invalid_grant', 'SSO requires a dedicated user model.'); + } + $user = new ModelObject($ctx['user_model']); + if (!$user->retrieve('id', (int)$codeRow->sso_user_id)) { + return self::error(400, 'invalid_grant', 'User not found.'); + } + $account = new ModelObject(\KyteAccount); + if (!$account->retrieve('id', (int)$app->kyte_account)) { + return self::error(500, 'server_error', 'Account not found.'); + } + + $session = JwtEndpoint::issueSession($user, $account, $app, $ip); + return ['status' => 200, 'headers' => ['Cache-Control: no-store'], 'body' => $session]; + } + + /** + * Validate an OIDC id_token: signature via the provider JWKS (firebase JWK), + * then audience, nonce, issuer, and tenant (tid) scoping. Returns the claims + * or null. + * + * @param array $disc + * @return array|null + */ + private static function validateIdToken(string $idToken, array $disc, ModelObject $cfg, string $expectedNonce): ?array + { + try { + $jwks = self::httpGetJson((string)$disc['jwks_uri']); + if ($jwks === null || empty($jwks['keys'])) { + return null; + } + // Azure/Microsoft JWKS keys omit the per-key "alg"; supply RS256 as + // the default so parseKeySet doesn't reject them. All Microsoft v2.0 + // id_tokens are RS256, and JWT::decode still enforces the header alg. + $keys = \Firebase\JWT\JWK::parseKeySet($jwks, 'RS256'); + // JWT::decode validates the signature + exp/nbf and throws otherwise. + $claims = (array)\Firebase\JWT\JWT::decode($idToken, $keys); + } catch (\Throwable $e) { + error_log('SsoEndpoint id_token validation: ' . $e->getMessage()); + return null; + } + + // Audience must be our client_id. + $aud = $claims['aud'] ?? null; + $clientId = (string)$cfg->client_id; + if (is_array($aud)) { + if (!in_array($clientId, array_map('strval', $aud), true)) { + return null; + } + } elseif ((string)$aud !== $clientId) { + return null; + } + + // Nonce must match the one we issued (id_token replay defense). + if (!isset($claims['nonce']) || !hash_equals($expectedNonce, (string)$claims['nonce'])) { + return null; + } + + // Issuer: exact-match the discovered issuer when it's concrete (a + // tenant-specific config). Skip when it carries a {placeholder} (the + // 'common'/'organizations' endpoints) — tenant scoping below covers it. + if (!empty($disc['issuer']) && strpos((string)$disc['issuer'], '{') === false + && isset($claims['iss']) && (string)$claims['iss'] !== (string)$disc['issuer']) { + return null; + } + + // Tenant scoping: the token's tid must match the app's configured tenant. + if (!empty($cfg->tenant) && (!isset($claims['tid']) || (string)$claims['tid'] !== (string)$cfg->tenant)) { + return null; + } + + return $claims; + } + + /** + * Resolve the app user for a validated SSO login, keyed on the immutable + * provider subject (never the mutable email). Runs in the app's user_model + + * DB context (resolveAuthContext sets that up). + * + * Returns the app-user ModelObject on success, or a string error code: + * 'sso_requires_user_model' — app has no dedicated user_model (would land + * on the platform KyteUser table — refused). + * 'user_not_provisioned' — restrict_to_existing / JIT off and no link. + * + * @return ModelObject|string + */ + private static function resolveSsoUser(ModelObject $app, ModelObject $cfg, string $provider, string $subject, string $tid, ?string $email, bool $jit, bool $restrict, array $claims = []) + { + $ctx = JwtEndpoint::resolveAuthContext((string)$app->identifier); + $userModel = $ctx['user_model']; + $usernameField = (string)$ctx['username_field']; + $passwordField = (string)($ctx['password_field'] ?? ''); + + // SSO must map into an app-specific user model, NEVER the shared platform + // KyteUser table (the Shipyard/console admin identity store). If the app + // hasn't configured user_model/username/password, resolveAuthContext + // falls back to KyteUser — refuse rather than provision/bind a platform + // identity for an external IdP user. + if ($userModel === constant('KyteUser')) { + error_log("SsoEndpoint: app '{$app->identifier}' has no dedicated user_model; refusing SSO into the platform KyteUser table."); + return 'sso_requires_user_model'; + } + + // 1. Returning user: resolve by the immutable (application, provider, + // subject) link. A token bearing someone else's email cannot reach + // another user's account here. + $link = new ModelObject(\KyteSsoIdentity); + $haveLink = $link->retrieve('subject', $subject, [ + ['field' => 'application', 'value' => (int)$app->id], + ['field' => 'provider', 'value' => $provider], + ]); + if ($haveLink) { + $user = new ModelObject($userModel); + if ($user->retrieve('id', (int)$link->sso_user_id)) { + if ($email !== null && $email !== (string)$link->email) { + $link->save(['email' => $email]); // refresh display attribute + } + return $user; + } + // Dangling link (the linked user was deleted). Re-provision and + // REBIND this same link row rather than minting a fresh orphan on + // every login (a second link row would violate the unique index). + if ($restrict || !$jit) { + return 'user_not_provisioned'; + } + $newId = self::jitCreateUser($app, $userModel, $usernameField, $passwordField, $email); + if ($newId === null) { + return 'user_not_provisioned'; + } + $link->save(['sso_user_id' => $newId, 'email' => $email ?? '']); + $rebound = new ModelObject($userModel); + return $rebound->retrieve('id', $newId) ? $rebound : 'user_not_provisioned'; + } + + // 2. First login for this subject. + // Link to a PRE-EXISTING app user by email ONLY when the token is a + // trustworthy assertion of that email's owner: + // (a) the config pins a single tenant AND the token's tid matches + // it (multi-tenant/'common' lets any tenant assert any email), + // (b) the user is a native MEMBER of that tenant, not a B2B guest + // (a guest's email is set by their home tenant, which the + // resource tenant does not own), and + // (c) the target account is not already bound to a different + // subject (never re-bind / hijack an account). + // Otherwise fall through to JIT (a fresh, subject-bound account). + $tenantAuthoritative = !empty($cfg->tenant) && $tid !== '' && $tid === (string)$cfg->tenant; + $isGuest = isset($claims['idp']) || (isset($claims['acct']) && (int)$claims['acct'] === 1); + + if ($email !== null && $tenantAuthoritative && !$isGuest) { + $existing = new ModelObject($userModel); + if ($existing->retrieve($usernameField, $email)) { + if (self::userAlreadyLinked($app, $provider, (int)$existing->id)) { + // Account already claimed by another SSO subject — refuse to + // attach a second identity to it. + error_log("SsoEndpoint: refusing to link subject to app user {$existing->id} already bound to another SSO identity."); + return 'user_not_provisioned'; + } + self::createLink($app, $provider, $subject, $tid, (int)$existing->id, $email); + return $existing; + } + } + + if ($restrict || !$jit) { + return 'user_not_provisioned'; + } + + // 3. JIT-provision a fresh app user bound to this subject. + $newId = self::jitCreateUser($app, $userModel, $usernameField, $passwordField, $email); + if ($newId === null) { + return 'user_not_provisioned'; + } + self::createLink($app, $provider, $subject, $tid, $newId, $email); + $newUser = new ModelObject($userModel); + return $newUser->retrieve('id', $newId) ? $newUser : 'user_not_provisioned'; + } + + /** + * Create a fresh app user for a JIT SSO provision. SSO users never + * password-login, but the model may require a password column — set a random + * (unusable) hash. Returns the new user id, or null on failure. + * + * @param array $userModel + */ + private static function jitCreateUser(ModelObject $app, array $userModel, string $usernameField, string $passwordField, ?string $email): ?int + { + $data = []; + if ($email !== null && isset($userModel['struct'][$usernameField])) { + $data[$usernameField] = $email; + } + if ($passwordField !== '' && isset($userModel['struct'][$passwordField])) { + $data[$passwordField] = password_hash(bin2hex(random_bytes(24)), PASSWORD_DEFAULT); + } + if (isset($userModel['struct']['kyte_account'])) { + $data['kyte_account'] = (int)$app->kyte_account; + } + try { + $newUser = new ModelObject($userModel); + if (!$newUser->create($data)) { + return null; + } + return (int)$newUser->id; + } catch (\Throwable $e) { + error_log('SsoEndpoint JIT user create failed: ' . $e->getMessage()); + return null; + } + } + + /** Does this app user already have an SSO identity link for this provider? */ + private static function userAlreadyLinked(ModelObject $app, string $provider, int $userId): bool + { + $links = new Model(\KyteSsoIdentity); + $links->retrieve('sso_user_id', $userId, false, [ + ['field' => 'application', 'value' => (int)$app->id], + ['field' => 'provider', 'value' => $provider], + ['field' => 'deleted', 'value' => 0], + ]); + return count($links->objects) > 0; + } + + /** Persist the (application, provider, subject) -> app-user identity link. */ + private static function createLink(ModelObject $app, string $provider, string $subject, string $tid, int $userId, ?string $email): void + { + try { + $link = new ModelObject(\KyteSsoIdentity); + $link->create([ + 'application' => (int)$app->id, + 'provider' => $provider, + 'subject' => $subject, + 'tenant_id' => $tid, + 'sso_user_id' => $userId, + 'email' => $email ?? '', + 'kyte_account' => (int)$app->kyte_account, + ]); + } catch (\Throwable $e) { + error_log('SsoEndpoint identity-link create failed: ' . $e->getMessage()); + } + } + + /** + * Redirect back to the app's return URL with a query (sso_code or error); + * JSON if no return URL was given. + * + * SECURITY: validating return_url against the app's own sites (open-redirect + * / code-interception defense) is required before exposure — flagged for the + * P1 security review (#561). + * + * @param array $query + */ + /** + * Is $returnUrl a safe place to hand the single-use sso_code back to? + * An empty return_url is allowed (the code is returned as JSON, no redirect). + * Otherwise it must be an absolute https URL — http only on loopback, for + * local dev — whose host is one of the app's own site domains. This stops an + * attacker-crafted /authorize link (?redirect=https://evil/) from delivering + * the code to a host they control. + */ + private static function isAllowedReturnUrl(ModelObject $app, string $returnUrl): bool + { + if ($returnUrl === '') { + return true; + } + $parts = parse_url($returnUrl); + if ($parts === false || empty($parts['host']) || empty($parts['scheme'])) { + return false; + } + $scheme = strtolower((string)$parts['scheme']); + $host = strtolower((string)$parts['host']); + $isLoopback = in_array($host, ['localhost', '127.0.0.1', '::1'], true); + if ($scheme !== 'https' && !($scheme === 'http' && $isLoopback)) { + return false; + } + return in_array($host, self::appReturnHosts($app), true); + } + + /** + * Lower-cased host names the app owns: every non-deleted KyteSite's cfDomain + * and aliasDomain, plus any custom Domain.domainName attached to those sites. + * + * @return string[] + */ + private static function appReturnHosts(ModelObject $app): array + { + $hosts = []; + $sites = new Model(\KyteSite); + $sites->retrieve('application', (int)$app->id, false, [['field' => 'deleted', 'value' => 0]]); + foreach ($sites->objects as $s) { + foreach (['cfDomain', 'aliasDomain'] as $f) { + $h = isset($s->$f) ? strtolower(trim((string)$s->$f)) : ''; + if ($h !== '') { + $hosts[] = $h; + } + } + $domains = new Model(\Domain); + $domains->retrieve('site', (int)$s->id, false, [['field' => 'deleted', 'value' => 0]]); + foreach ($domains->objects as $d) { + $h = isset($d->domainName) ? strtolower(trim((string)$d->domainName)) : ''; + if ($h !== '') { + $hosts[] = $h; + } + } + } + return array_values(array_unique($hosts)); + } + + private static function backToApp(string $returnUrl, array $query): array + { + if ($returnUrl === '') { + return ['status' => 200, 'body' => $query, 'headers' => ['Cache-Control: no-store']]; + } + $sep = strpos($returnUrl, '?') !== false ? '&' : '?'; + return [ + 'status' => 302, + 'headers' => ['Location: ' . $returnUrl . $sep . http_build_query($query), 'Cache-Control: no-store'], + ]; + } + + /** @param array $fields @return array|null */ + private static function httpPostForm(string $url, array $fields): ?array + { + $ch = curl_init($url); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 15, + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => http_build_query($fields), + CURLOPT_HTTPHEADER => ['Content-Type: application/x-www-form-urlencoded', 'Accept: application/json'], + ]); + $body = curl_exec($ch); + $code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + if ($body === false) { + return null; + } + $decoded = json_decode((string)$body, true); + if (!is_array($decoded)) { + return null; + } + if ($code < 200 || $code >= 300) { + error_log('SsoEndpoint token endpoint ' . $code . ': ' . substr((string)$body, 0, 300)); + return null; + } + return $decoded; + } + + /** @param array $server @return array */ + private static function parseBody(string $raw, array $server): array + { + $ct = strtolower((string)($server['CONTENT_TYPE'] ?? $server['HTTP_CONTENT_TYPE'] ?? '')); + if (strpos($ct, 'application/json') !== false) { + $d = json_decode($raw, true); + return is_array($d) ? $d : []; + } + $out = []; + parse_str($raw, $out); + return $out; + } + + /** @param array $server */ + private static function clientIp(array $server): string + { + return (string)($server['REMOTE_ADDR'] ?? ''); + } + + /** Read a single request cookie value from the Cookie header. */ + private static function readCookie(string $name): string + { + $header = (string)($_SERVER['HTTP_COOKIE'] ?? ''); + foreach (explode(';', $header) as $part) { + $kv = explode('=', trim($part), 2); + if (count($kv) === 2 && $kv[0] === $name) { + return urldecode($kv[1]); + } + } + return ''; + } + + /** + * 32-byte libsodium key for the client_secret at rest. Prefers an explicit + * install key, else derives deterministically from KYTE_JWT_SECRET (both are + * install-level secrets). See docs/design/app-microsoft-sso.md §6. + */ + private static function ssoKey(): string + { + if (defined('KYTE_SSO_SECRET_KEY') && KYTE_SSO_SECRET_KEY !== '') { + $k = base64_decode((string)KYTE_SSO_SECRET_KEY, true); + if ($k !== false && strlen($k) === SODIUM_CRYPTO_SECRETBOX_KEYBYTES) { + return $k; + } + } + if (defined('KYTE_JWT_SECRET') && KYTE_JWT_SECRET !== '') { + return hash('sha256', 'kyte-sso-secret:' . KYTE_JWT_SECRET, true); // 32 bytes + } + throw new \Exception('No SSO encryption key (set KYTE_SSO_SECRET_KEY or KYTE_JWT_SECRET).'); + } + + /** Encrypt a provider client_secret for storage (base64 of nonce+ciphertext). */ + public static function encryptSecret(string $plain): string + { + $nonce = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES); + return base64_encode($nonce . sodium_crypto_secretbox($plain, $nonce, self::ssoKey())); + } + + private static function decryptSecret(string $stored): string + { + $raw = base64_decode($stored, true); + if ($raw === false || strlen($raw) <= SODIUM_CRYPTO_SECRETBOX_NONCEBYTES) { + throw new \Exception('Invalid encrypted secret.'); + } + $nonce = substr($raw, 0, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES); + $cipher = substr($raw, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES); + $plain = sodium_crypto_secretbox_open($cipher, $nonce, self::ssoKey()); + if ($plain === false) { + throw new \Exception('Secret decryption failed.'); + } + return $plain; + } + + private static function randToken(int $len): string + { + $alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + $max = strlen($alphabet) - 1; + $out = ''; + $bytes = random_bytes($len); + for ($i = 0; $i < $len; $i++) { + $out .= $alphabet[ord($bytes[$i]) % ($max + 1)]; + } + return $out; + } + + public static function baseUrl(array $server): string + { + if (defined('KYTE_OAUTH_ISSUER') && KYTE_OAUTH_ISSUER) { + return rtrim((string)KYTE_OAUTH_ISSUER, '/'); + } + $host = (defined('API_URL') && API_URL) ? (string)API_URL : (string)($server['HTTP_HOST'] ?? 'localhost'); + return 'https://' . $host; + } + + /** @param array $server @return array */ + private static function queryParams(array $server): array + { + $qs = (string)($server['QUERY_STRING'] ?? ''); + if ($qs === '') { + return []; + } + $out = []; + parse_str($qs, $out); + return $out; + } + + private static function error(int $status, string $code, string $message): array + { + return ['status' => $status, 'body' => ['error' => $code, 'error_description' => $message], 'headers' => ['Cache-Control: no-store']]; + } + + private static function emitCorsHeaders(): void + { + $origin = $_SERVER['HTTP_ORIGIN'] ?? ''; + if ($origin !== '') { + header("Access-Control-Allow-Origin: {$origin}"); + header('Vary: Origin'); + } + } +} diff --git a/src/Core/DBI.php b/src/Core/DBI.php index 6a38f3ef..c5268790 100644 --- a/src/Core/DBI.php +++ b/src/Core/DBI.php @@ -326,6 +326,85 @@ private static function getConnection() } } + // Dedicated privileged connection for provisioning (CREATE/DROP DATABASE + + // CREATE/DROP USER), kept separate from the scoped runtime connection. + private static $provConn = null; + + /* + * Privileged provisioning connection. Used ONLY by createDatabase / + * dropDatabase so a SQL-injection or compromise on the scoped runtime user + * cannot escalate to server-level DDL — normal queries run on the scoped + * connection, which lacks CREATE/DROP DATABASE. + * + * Configured via KYTE_DB_PROVISION_USERNAME / KYTE_DB_PROVISION_PASSWORD + * (same host + CA bundle as the main connection; no default database — it + * creates them). When unset, falls back to the main connection so installs + * that don't provision, or that keep the privileges on the runtime user, + * behave exactly as before. + */ + private static function getProvisioningConnection() + { + if (!defined('KYTE_DB_PROVISION_USERNAME') || KYTE_DB_PROVISION_USERNAME === '') { + return self::getConnection(); + } + + if (self::$provConn) { + return self::$provConn; + } + + $user = KYTE_DB_PROVISION_USERNAME; + $pass = defined('KYTE_DB_PROVISION_PASSWORD') ? KYTE_DB_PROVISION_PASSWORD : ''; + + if (defined('KYTE_DB_CA_BUNDLE')) { + $conn = new \mysqli(); + $conn->ssl_set(null, null, KYTE_DB_CA_BUNDLE, null, null); + if (!$conn->real_connect(self::$dbHost, $user, $pass, null, null, null, MYSQLI_CLIENT_SSL)) { + throw new \Exception('Provisioning DB connection failed (SSL): ' . $conn->connect_error, (int)$conn->connect_errno); + } + } else { + $conn = new \mysqli(self::$dbHost, $user, $pass); + if ($conn->connect_error) { + throw new \Exception('Provisioning DB connection failed: ' . $conn->connect_error, (int)$conn->connect_errno); + } + } + if (true !== $conn->set_charset(self::$charset)) { + throw new \Exception($conn->error, (int)$conn->errno); + } + + self::$provConn = $conn; + return self::$provConn; + } + + /* + * Drop a tenant database (and optionally its dedicated user) via the + * privileged provisioning connection. Idempotent (IF EXISTS). + * + * @param string $name Database name. + * @param string|null $username Optional dedicated DB user to drop too. + */ + public static function dropDatabase($name, $username = null) + { + if (!$name) { + throw new \Exception("Database name must be specified"); + } + + $con = self::getProvisioningConnection(); + + $result = $con->query("DROP DATABASE IF EXISTS `{$name}`;"); + if ($result === false) { + throw new \Exception("Unable to drop database. [Error]: " . htmlspecialchars($con->error)); + } + + if ($username) { + // Best-effort user cleanup — a stray user is harmless if this fails + // (e.g. still referenced), so don't abort the teardown over it. + $con->query("DROP USER IF EXISTS '{$username}'@'%';"); + $con->query("FLUSH PRIVILEGES;"); + } + + return true; + } + /** * Begin database transaction * Provides ACID guarantees for multi-step operations @@ -611,8 +690,9 @@ public static function createDatabase($name, $username, &$password, $use = false throw new \Exception("Database username must be specified"); } - // db connection - $con = self::getConnection(); + // privileged provisioning connection (falls back to main when no + // dedicated provisioning identity is configured) + $con = self::getProvisioningConnection(); // create password $password = ''; @@ -634,8 +714,18 @@ public static function createDatabase($name, $username, &$password, $use = false throw new \Exception("Unable to create user. [Error]: ".htmlspecialchars($con->error)); } - // set privs - $result = $con->query("GRANT ALL PRIVILEGES ON `{$name}`.* TO '{$username}'@'%';"); + // Grant the tenant user the full app-relevant db-level privilege set. + // NOT "ALL PRIVILEGES": on a managed engine (RDS) the provisioning + // identity can only grant privileges it explicitly holds, and GRANT ALL + // is denied when it lacks even one privilege ALL expands to. This + // explicit list matches what the provisioning user is granted (KYTE-#205) + // and covers every normal application DB operation (DML + DDL + views / + // routines / triggers) — i.e. ALL minus GRANT OPTION, which a tenant + // user must never hold anyway. + $tenantPrivs = 'SELECT, INSERT, UPDATE, DELETE, CREATE, DROP, INDEX, ALTER, ' + . 'CREATE TEMPORARY TABLES, LOCK TABLES, EXECUTE, CREATE VIEW, SHOW VIEW, ' + . 'CREATE ROUTINE, ALTER ROUTINE, EVENT, TRIGGER, REFERENCES'; + $result = $con->query("GRANT {$tenantPrivs} ON `{$name}`.* TO '{$username}'@'%';"); if($result === false) { throw new \Exception("Unable to grant privileges. [Error]: ".htmlspecialchars($con->error)); } diff --git a/src/Cron/SiteProvisioningWorker.php b/src/Cron/SiteProvisioningWorker.php index 8b91e3ee..351ab347 100644 --- a/src/Cron/SiteProvisioningWorker.php +++ b/src/Cron/SiteProvisioningWorker.php @@ -38,10 +38,6 @@ public function execute() '', [] ); - if (empty($sites)) { - return json_encode(['processed' => 0, 'message' => 'No sites provisioning/deprovisioning.']); - } - $summary = []; foreach ($sites as $i => $site) { $id = (int) $site['id']; @@ -71,7 +67,67 @@ public function execute() $this->heartbeat(); } - return json_encode(['processed' => count($sites), 'sites' => $summary]); + // Finalize applications pending teardown (KYTE-#559): once ALL of an + // app's sites are fully deleted, drop its tenant DB + user and + // soft-delete the app row. Runs every tick, independent of the site loop. + $appSummary = $this->finalizeDeletingApplications(); + + if (empty($sites) && empty($appSummary)) { + return json_encode(['processed' => 0, 'message' => 'Nothing provisioning/deprovisioning.']); + } + return json_encode(['processed' => count($sites), 'sites' => $summary, 'apps' => $appSummary]); + } + + /** + * Complete teardown for applications marked 'deleting'. An app is finalized + * only once every one of its sites is fully deleted (their AWS infra torn + * down by advanceDelete) — then the tenant database + its user are dropped + * and the app row is soft-deleted. Idempotent + safe to re-run each tick. + * + * @return array + */ + private function finalizeDeletingApplications(): array + { + $apps = DBI::prepared_query( + "SELECT id, db_name, db_username FROM Application WHERE status = 'deleting' AND deleted = 0", + '', + [] + ); + + $out = []; + foreach ((array) $apps as $app) { + $id = (int) $app['id']; + try { + // Wait until no site of this app is still pending teardown. + $remaining = DBI::prepared_query( + "SELECT COUNT(*) AS c FROM KyteSite WHERE application = ? AND deleted = 0 AND status <> 'deleted'", + 'i', + [$id] + ); + $left = isset($remaining[0]['c']) ? (int) $remaining[0]['c'] : 0; + if ($left > 0) { + $out[$id] = "waiting on {$left} site(s)"; + continue; + } + + // All sites gone — drop the tenant DB + user, then soft-delete. + if (!empty($app['db_name'])) { + DBI::dropDatabase($app['db_name'], $app['db_username'] ?? null); + } + $appObj = new ModelObject(Application); + if ($appObj->retrieve('id', $id)) { + $appObj->save(['status' => 'deleted', 'deleted' => 1]); + } + $out[$id] = 'deleted'; + $this->log("App #{$id} finalized (tenant DB dropped, app removed)."); + } catch (\Throwable $e) { + $out[$id] = 'error: ' . $e->getMessage(); + $this->log("App #{$id} finalize failed: " . $e->getMessage()); + } + $this->heartbeat(); + } + + return $out; } /** diff --git a/src/Mcp/Endpoint.php b/src/Mcp/Endpoint.php index 3eb6c96b..4aa39aec 100644 --- a/src/Mcp/Endpoint.php +++ b/src/Mcp/Endpoint.php @@ -75,7 +75,13 @@ public static function process(Api $api, ServerRequestInterface $request): Respo try { self::authenticate($api, $request); } catch (SessionException $e) { - return self::jsonRpcError($psr17, 401, -32001, $e->getMessage()); + // RFC 9728 / MCP auth: point unauthenticated clients at this + // install's protected-resource metadata so Claude.ai / ChatGPT can + // auto-discover the OAuth authorization server (KYTE-#551). + $resourceMetadata = \Kyte\Core\Auth\OAuthEndpoint::baseUrl($request->getServerParams()) + . '/.well-known/oauth-protected-resource'; + return self::jsonRpcError($psr17, 401, -32001, $e->getMessage()) + ->withHeader('WWW-Authenticate', 'Bearer resource_metadata="' . $resourceMetadata . '"'); } catch (\Throwable $e) { return self::jsonRpcError($psr17, 500, -32603, 'Internal MCP error: ' . $e->getMessage()); } @@ -110,10 +116,32 @@ public static function process(Api $api, ServerRequestInterface $request): Respo 'Kyte low-code framework MCP endpoint' ) ->setInstructions( - 'Tools operate on the account associated with the bearer token. ' . - 'Use list_applications to discover apps, then traditional Kyte ' . - 'workflows for further work. Additional tools land in subsequent ' . - 'Phase 2 commits.' + 'Tools operate on the Kyte account tied to the bearer token. Start with ' + . 'list_applications (or get_app_info) to discover apps, then work down: ' + . 'models + controllers/functions (backend), sites + pages + scripts (frontend). ' + . "\n\n" + . 'CONNECTION: call get_app_info(application_id) for the API endpoint, the app ' + . 'identifier, and each site URL — do not guess or hard-code the endpoint.' + . "\n\n" + . 'WRITING PAGE / SCRIPT JS: Kyte injects a ready-to-use API client into every ' + . 'published page as the GLOBAL variable `k` (a Kyte instance). Do NOT create your ' + . 'own client or hard-code URLs/keys — just call `k`. Data access is model-based, ' + . 'not REST URLs: k.get(model, field, value, headers, onOk, onErr) to read, ' + . 'k.post(model, data, formData, headers, onOk, onErr) to create, ' + . 'k.put(model, field, value, data, formData, headers, onOk, onErr) to update, ' + . 'k.delete(model, field, value, headers, onOk, onErr) to delete. onOk receives a ' + . 'response whose `.data` is ALWAYS an array. `model` is a controller/model name ' + . '(e.g. "Task"). Call get_kytejs_guide for the full signatures + a worked example ' + . 'BEFORE writing any page or script JavaScript.' + . "\n\n" + . 'WRITING CONTROLLER / FUNCTION PHP: controller hooks and method overrides have ' + . 'template-specific signatures (several params are by-reference) and a specific ' + . '$this context ($this->user, $this->account, $this->response, $this->model) + ' + . 'query API. Call get_controller_guide for the exact signatures + examples BEFORE ' + . 'writing function code with write_function_code.' + . "\n\n" + . 'EDIT FLOW: create_* makes a draft; add code/content with write_page_part / ' + . 'write_script_content / write_function_code; publish with commit_draft.' ) ->setContainer($container) ->setRegistry($registry) diff --git a/src/Mcp/Tools/AccountTools.php b/src/Mcp/Tools/AccountTools.php index 4009b7cc..4e9bcfcf 100644 --- a/src/Mcp/Tools/AccountTools.php +++ b/src/Mcp/Tools/AccountTools.php @@ -56,4 +56,205 @@ public function listApplications(): array } return ['applications' => $out]; } + + /** + * Connection + deployment info for building against a Kyte app. + * + * QA hit this gap: nothing told the client the API endpoint, so it had to + * be hand-typed. This returns the API endpoint (what kyte-api-js is + * initialised with), the MCP endpoint, the account number, and — when an + * application_id is given — the app identifier plus each site with its live + * URL(s). Omit application_id for account-level info (endpoint + app list). + * + * @param int|null $application_id Optional Application id (from list_applications). + * @return array + */ + #[McpTool(name: 'get_app_info', description: 'Connection + deployment info for building against a Kyte app: the API endpoint (for kyte-api-js init), the MCP endpoint, account number, and — with an application_id — the app identifier and each site with its live URL(s). Call with no application_id for account-level info. Use this instead of guessing the API endpoint.')] + #[RequiresScope('read')] + public function getAppInfo(?int $application_id = null): array + { + $accountId = isset($this->api->account->id) ? (int)$this->api->account->id : 0; + if ($accountId === 0) { + return ['error' => 'No account context.']; + } + + $host = (defined('API_URL') && API_URL) ? (string)API_URL : (string)($_SERVER['HTTP_HOST'] ?? ''); + $endpoint = $host !== '' ? 'https://' . $host : ''; + + $out = [ + 'api_endpoint' => $endpoint, + 'mcp_endpoint' => $endpoint !== '' ? $endpoint . '/mcp' : '', + 'account' => [ + 'id' => $accountId, + 'number' => isset($this->api->account->number) ? (string)$this->api->account->number : '', + ], + ]; + + if ($application_id === null) { + $apps = new \Kyte\Core\Model(\Application); + $apps->retrieve('kyte_account', $accountId); + $out['applications'] = []; + foreach ($apps->objects as $a) { + $out['applications'][] = [ + 'id' => (int)$a->id, + 'name' => (string)($a->name ?? ''), + 'identifier' => (string)($a->identifier ?? ''), + ]; + } + return $out; + } + + $app = new \Kyte\Core\ModelObject(\Application); + if (!$app->retrieve('id', $application_id) || (int)$app->kyte_account !== $accountId) { + $out['error'] = 'Application not found in this account.'; + return $out; + } + $identifier = (string)($app->identifier ?? ''); + $out['application'] = [ + 'id' => (int)$app->id, + 'name' => (string)($app->name ?? ''), + 'identifier' => $identifier, + ]; + // How the frontend SDK is initialised — the endpoint + identifier pair + // the client would otherwise have to be told out of band. + $out['kyte_api_js_init'] = $endpoint !== '' + ? sprintf("new Kyte('%s', '%s', ...)", $endpoint, $identifier) + : ''; + + $sites = new \Kyte\Core\Model(\KyteSite); + $sites->retrieve('application', (int)$app->id, false, [ + ['field' => 'kyte_account', 'value' => $accountId], + ['field' => 'deleted', 'value' => 0], + ]); + $siteOut = []; + foreach ($sites->objects as $s) { + $cf = isset($s->cfDomain) ? (string)$s->cfDomain : ''; + $alias = isset($s->aliasDomain) ? (string)$s->aliasDomain : ''; + $custom = []; + $doms = new \Kyte\Core\Model(\Domain); + $doms->retrieve('site', (int)$s->id, false, [['field' => 'deleted', 'value' => 0]]); + foreach ($doms->objects as $d) { + if (!empty($d->domainName)) { + $custom[] = (string)$d->domainName; + } + } + $primary = $alias !== '' ? $alias : $cf; + $siteOut[] = [ + 'id' => (int)$s->id, + 'name' => (string)($s->name ?? ''), + 'region' => isset($s->region) ? (string)$s->region : null, + 'cloudfront_domain' => $cf !== '' ? $cf : null, + 'alias_domain' => $alias !== '' ? $alias : null, + 'custom_domains' => $custom, + 'url' => $primary !== '' ? 'https://' . $primary : null, + ]; + } + $out['sites'] = $siteOut; + return $out; + } + + /** + * The KyteJS reference for writing page/script JavaScript. Kyte apps talk to + * the backend through an injected client, NOT REST URLs — this tool gives an + * AI client the exact API so generated page/script JS actually works. + * + * ⚠️ MCP KNOWLEDGE BASE — KEEP IN SYNC. This guide is verified against the + * kyte-api-js SDK (kyte-source.js: get/post/put/delete, sessionCreate/Destroy, + * response + error handling). Any MATERIAL change to that SDK's method + * signatures, response/error shape, or the injected `k` bootstrap MUST update + * this guide in the same change, or AI-generated page JS will drift out of + * spec. Treat this as part of the SDK's public contract. + * + * @return array + */ + #[McpTool(name: 'get_kytejs_guide', description: 'How to write JavaScript for Kyte pages/scripts. Kyte injects a global API client `k` into every published page; page/script JS calls the backend via k.get/k.post/k.put/k.delete(model, ...). Returns the exact signatures, the response shape, session helpers, and a worked example. Call this BEFORE writing any page or script JavaScript.')] + #[RequiresScope('read')] + public function getKytejsGuide(): array + { + return [ + 'overview' => + 'Kyte publishes each page with an API client already CONSTRUCTED and INITIALISED as ' + . 'the GLOBAL variable `k` (the page bootstrap runs new Kyte(...) then k.init() for ' + . 'you). In page HTML/JS and in site scripts, use `k` directly. Do NOT construct your ' + . 'own client, do NOT call k.init() again, and do NOT hard-code the API URL/keys or ' + . 'use fetch()/REST — `k` owns the endpoint + credentials.', + 'data_model' => + 'Access is MODEL-based, not URL-based. The first argument to every call is a ' + . 'model/controller NAME string (e.g. "Task"). Use the EXACT name as defined in the ' + . 'app (from get_app_info / list_models / list_controllers) — do not guess casing; ' + . 'resolution happens server-side.', + 'methods' => [ + 'get' => 'k.get(model, field, value, headers, onSuccess, onError) — READ. field+value ' + . 'filters (e.g. "id", 42); field=null & value=null returns all. CAVEAT: a falsy ' + . 'value (0 or "") is dropped from the request — do not filter by a literal 0/"".', + 'post' => 'k.post(model, data, formData, headers, onSuccess, onError) — CREATE.', + 'put' => 'k.put(model, field, value, data, formData, headers, onSuccess, onError) — ' + . 'UPDATE the row(s) matching field=value with `data`.', + 'delete' => 'k.delete(model, field, value, headers, onSuccess, onError) — DELETE the ' + . 'row(s) matching field=value.', + ], + 'arguments' => [ + 'headers' => '`headers` is a REQUIRED positional slot BEFORE the callbacks — always ' + . 'pass [] when you have none. If you omit it and pass a callback in its place, the ' + . 'callback is silently treated as headers and never fires (a common bug).', + 'data' => '`data` (post/put) is a flat plain object {column: value}; it is ' + . 'URL-encoded for you. Not JSON, not nested.', + 'formData' => '`formData` is NOT a browser FormData object — it is a pre-serialized ' + . 'URL-encoded string appended to the body. Pass null unless you specifically need it.', + 'callbacks' => 'onSuccess is required (positional); onError is optional.', + ], + 'response' => + 'onSuccess receives the WHOLE response object (not just data) — it also carries ' + . 'total_count, total_filtered, account_id, etc. The SHAPE of response.data depends on ' + . 'the controller: DEFAULT CRUD returns an ARRAY of row objects (a single filtered ' + . 'record is response.data[0]); a CUSTOM controller (a get override) returns whatever ' + . 'it set — often a plain object or scalar. Do NOT assume response.data is an array ' + . 'when calling a custom controller — match how that controller sets its data.', + 'errors' => + 'onError usually receives the server error message STRING, but on transport / ' + . 'token-refresh failures it may receive an OBJECT (jqXHR or {error, detail}), and on a ' + . 'null / non-JSON error it may not fire at all. Normalise it: ' + . 'onError(err => { var msg = typeof err === "string" ? err : ((err && (err.error || ' + . '(err.responseJSON && err.responseJSON.error))) || "Request failed"); ... }). On HTTP ' + . '403 the SDK auto-runs session-destroy + redirect-to-login — do NOT write your own 403 re-login.', + 'session' => [ + 'create' => 'k.sessionCreate(identity, onSuccess, onError) — log in. identity is an ' + . 'object (e.g. {email, password}). Optional 4th arg = a custom session controller name.', + 'destroy' => 'k.sessionDestroy(onComplete) — takes ONE callback that runs after logout ' + . 'whether it succeeded or failed; put your redirect there: ' + . 'k.sessionDestroy(function(){ location.href = "/"; }). It is NOT (onSuccess, onError) ' + . '— a redirect passed as a 2nd arg is ignored. (Or k.addLogoutHandler(selector) wires ' + . 'a logout+redirect click handler for you.)', + ], + 'example' => implode("\n", [ + "// READ (default CRUD) — response.data is an ARRAY of rows", + "k.get('Task', null, null, [], function (response) {", + " response.data.forEach(function (t) { renderTask(t); });", + "}, function (err) { console.error(err); });", + "", + "// CREATE — default CRUD returns a one-element array", + "k.post('Task', { title: 'Buy milk', quadrant: 'urgent_important', done: 0 }, null, [], function (response) {", + " var created = response.data[0];", + "}, function (err) {});", + "", + "// UPDATE / DELETE by id (note headers = [] before the callbacks)", + "k.put('Task', 'id', taskId, { done: 1 }, null, [], function (r) {}, function (e) {});", + "k.delete('Task', 'id', taskId, [], function (r) {}, function (e) {});", + "", + "// CUSTOM controller (get override) — response.data is whatever it returns (here an object)", + "k.get('TaskStats', null, null, [], function (response) {", + " var stats = response.data; // e.g. { total: 10, done: 4 } — NOT an array", + "}, function (err) {});", + ]), + 'rules' => [ + 'Use the injected global `k` (already constructed + init()-ed) — never new Kyte(...), never hard-code endpoint/keys.', + 'First arg is the exact model/controller NAME string (not a URL); get names from get_app_info / list_models / list_controllers.', + '`headers` is a required positional [] before the callbacks.', + 'response.data is an ARRAY for default CRUD, but whatever the controller sets for a custom override — do not assume.', + 'onSuccess gets the full response object; onError may get a string OR an object OR not fire at all.', + '`formData` is a URL-encoded string, not a browser FormData object.', + 'k.sessionDestroy takes ONE completion callback (put the redirect there).', + ], + ]; + } } diff --git a/src/Mcp/Tools/AppTools.php b/src/Mcp/Tools/AppTools.php new file mode 100644 index 00000000..c7eab0fd --- /dev/null +++ b/src/Mcp/Tools/AppTools.php @@ -0,0 +1,311 @@ +|null, error?: string} + */ + #[McpTool(name: 'create_application', description: 'Create a new Kyte application (provisions an isolated tenant database). Uses the account\'s configured AWS credential — set one in Shipyard first if none exists.')] + #[RequiresScope('provision')] + public function createApplication(string $name, ?string $language = null): array + { + $accountId = $this->accountIdOrZero(); + if ($accountId === 0) { + return ['created' => false, 'error' => 'No account context.']; + } + + $api = $this->api; + $resp = []; + try { + $controller = new \Kyte\Mvc\Controller\ApplicationController(\Application, $api, 'm/d/Y H:i:s', $resp, true); + $data = ['name' => $name]; + if ($language !== null) { $data['language'] = $language; } + $controller->new($data); + } catch (\Throwable $e) { + return ['created' => false, 'error' => $e->getMessage()]; + } + + $newId = isset($resp['data'][0]['id']) ? (int)$resp['data'][0]['id'] : 0; + if ($newId === 0) { + return ['created' => false, 'error' => 'Application was not created.']; + } + + // Generate the app's kyte_connect snippet. Shipyard normally builds this; + // an MCP-created app would otherwise have an EMPTY one, and since it's + // injected into every published page (KytePageController) that leaves the + // global `k` client undefined — all frontend JS then fails at runtime. + $this->generateKyteConnect($newId, $accountId); + + return ['created' => true, 'application' => $this->appToArray($newId)]; + } + + /** + * Build + persist the Application.kyte_connect snippet — the + * `var k = new Kyte(endpoint, publicKey, identifier, accountNumber, appId);` + * bootstrap injected into every published page so page/script JS has the + * global `k` client. Deterministic from the account's API key + the app + * identifier. No-op (logged) if there's no API key or no resolvable endpoint, + * or if kyte_connect is already set. + */ + private function generateKyteConnect(int $appId, int $accountId): void + { + try { + $app = new \Kyte\Core\ModelObject(\Application); + if (!$app->retrieve('id', $appId) || (string)($app->kyte_connect ?? '') !== '') { + return; + } + $acct = new \Kyte\Core\ModelObject(\KyteAccount); + $key = new \Kyte\Core\ModelObject(\KyteAPIKey); + if (!$acct->retrieve('id', $accountId) || !$key->retrieve('kyte_account', $accountId)) { + error_log("create_application: no API key/account for kyte_connect (app {$appId}); pages will need it set before publish."); + return; + } + $host = (defined('API_URL') && API_URL) ? (string)API_URL : (string)($_SERVER['HTTP_HOST'] ?? ''); + if ($host === '') { + return; + } + $connect = sprintf( + "let endpoint = 'https://%s';var k = new Kyte(endpoint, '%s', '%s', '%s', '%s');k.init();", + $host, + (string)$key->public_key, + (string)$key->identifier, + (string)$acct->number, + (string)$app->identifier + ); + $app->save(['kyte_connect' => $connect]); + } catch (\Throwable $e) { + error_log('create_application: kyte_connect generation failed - ' . $e->getMessage()); + } + } + + /** + * Update a Kyte application's name or default language. + * + * @param int $application_id Application id. + * @param string|null $name New name. + * @param string|null $language New default language code. + * @return array{updated: bool, application?: array|null, error?: string} + */ + #[McpTool(name: 'update_application', description: 'Update a Kyte application\'s name or default language.')] + #[RequiresScope('provision')] + public function updateApplication(int $application_id, ?string $name = null, ?string $language = null): array + { + $accountId = $this->accountIdOrZero(); + if ($accountId === 0 || !$this->appBelongsToAccount($application_id, $accountId)) { + return ['updated' => false, 'error' => 'Application not found in this account.']; + } + + $data = []; + if ($name !== null) { $data['name'] = $name; } + if ($language !== null) { $data['language'] = $language; } + if (empty($data)) { + return ['updated' => false, 'error' => 'No updatable fields provided (name, language).']; + } + + $api = $this->api; + $resp = []; + try { + $controller = new \Kyte\Mvc\Controller\ApplicationController(\Application, $api, 'm/d/Y H:i:s', $resp, true); + $controller->update('id', $application_id, $data); + } catch (\Throwable $e) { + return ['updated' => false, 'error' => $e->getMessage()]; + } + return ['updated' => true, 'application' => $this->appToArray($application_id)]; + } + + /** + * Delete a Kyte application. This starts an ASYNCHRONOUS teardown: the app + * (and each of its sites) is marked "deleting", a background worker tears + * down all site AWS infrastructure (S3 + CloudFront + ACM), and once that's + * done it drops the tenant database and removes the app. Poll + * list_applications until the app disappears. + * + * @param int $application_id Application id. + * @return array{deleting: bool, application_id?: int, sites_tearing_down?: int, message?: string, error?: string} + */ + #[McpTool(name: 'delete_application', description: 'Delete a Kyte application — starts an asynchronous teardown of its sites (S3/CloudFront/ACM) and then its tenant database. Poll list_applications until the app disappears.')] + #[RequiresScope('provision')] + public function deleteApplication(int $application_id): array + { + $accountId = $this->accountIdOrZero(); + if ($accountId === 0 || !$this->appBelongsToAccount($application_id, $accountId)) { + return ['deleting' => false, 'error' => 'Application not found in this account.']; + } + + $app = new \Kyte\Core\ModelObject(\Application); + $app->retrieve('id', $application_id); + if ((string)($app->status ?? '') === 'deleting') { + return ['deleting' => true, 'application_id' => $application_id, 'message' => 'Teardown already in progress. Poll list_applications until the app disappears.']; + } + + // ApplicationController's delete hook marks the app + its sites 'deleting' + // (it does NOT drop anything synchronously); the SiteProvisioningWorker + // finalizes the teardown. + $api = $this->api; + $resp = []; + try { + $controller = new \Kyte\Mvc\Controller\ApplicationController(\Application, $api, 'm/d/Y H:i:s', $resp, true); + $controller->delete('id', $application_id); + } catch (\Throwable $e) { + return ['deleting' => false, 'error' => $e->getMessage()]; + } + + $sites = new \Kyte\Core\Model(\KyteSite); + $sites->retrieve('application', $application_id, false, [ + ['field' => 'kyte_account', 'value' => $accountId], + ]); + $n = 0; + foreach ($sites->objects as $s) { + if ((string)($s->status ?? '') === 'deleting') { $n++; } + } + + return [ + 'deleting' => true, + 'application_id' => $application_id, + 'sites_tearing_down' => $n, + 'message' => "Teardown started. {$n} site(s) tearing down in the background (S3/CloudFront take minutes); the tenant database drops and the app is removed once they're gone. Poll list_applications until it disappears.", + ]; + } + + /** + * Configure an application's built-in login: which user data model and which + * of its columns hold the username and password. WITHOUT this, the platform + * login/session endpoint authenticates against the platform user table and + * rejects your app's users — this is the usual reason "login rejects valid + * credentials." + * + * Pair it with a password column flagged password=true (add_attribute / + * update_attribute) so the framework hashes credentials for login. Your + * signup should store the PLAINTEXT password and let Kyte hash it — do not + * hash it yourself, or logins fail on a double-hash. + * + * @param int $application_id Application id (from list_applications). + * @param string $user_model DataModel name that holds app users (e.g. "User"). + * @param string $username_field Column used as the login username (e.g. "email"). + * @param string $password_field Column that holds the (hashed) password (e.g. "password"). + * @return array{configured: bool, application_id?: int, user_model?: string, username_field?: string, password_field?: string, error?: string} + */ + #[McpTool(name: 'configure_app_login', description: 'Configure an app\'s built-in login: which user data model + the username and password columns to authenticate against. Required for the login/session endpoint to accept your app\'s users (without it, login rejects valid credentials). Pair with a password column flagged password=true via add_attribute; signup should store the plaintext password and let Kyte hash it.')] + #[RequiresScope('provision')] + public function configureAppLogin(int $application_id, string $user_model, string $username_field, string $password_field): array + { + $accountId = $this->accountIdOrZero(); + if ($accountId === 0 || !$this->appBelongsToAccount($application_id, $accountId)) { + return ['configured' => false, 'error' => 'Application not found in this account.']; + } + if (trim($user_model) === '' || trim($username_field) === '' || trim($password_field) === '') { + return ['configured' => false, 'error' => 'user_model, username_field, and password_field are all required.']; + } + + // Verify the named user model exists in this app (clear error on a typo). + $dm = new \Kyte\Core\Model(\DataModel); + $dm->retrieve('application', $application_id, false, [ + ['field' => 'name', 'value' => $user_model], + ['field' => 'kyte_account', 'value' => $accountId], + ['field' => 'deleted', 'value' => 0], + ]); + if (count($dm->objects) === 0) { + return ['configured' => false, 'error' => "No data model named '{$user_model}' in this application."]; + } + + $app = new \Kyte\Core\ModelObject(\Application); + if (!$app->retrieve('id', $application_id)) { + return ['configured' => false, 'error' => 'Application not found.']; + } + $app->save([ + 'user_model' => $user_model, + 'username_colname' => $username_field, + 'password_colname' => $password_field, + ]); + + return [ + 'configured' => true, + 'application_id' => $application_id, + 'user_model' => $user_model, + 'username_field' => $username_field, + 'password_field' => $password_field, + 'note' => 'Login now authenticates against this model. Ensure the password column is flagged password=true (add_attribute) and that signup stores the plaintext password (Kyte hashes it).', + ]; + } + + /** + * Read a single application's details (name, identifier, language, status). + * + * @param int $application_id Application id (from list_applications). + * @return array|null + */ + #[McpTool(name: 'read_application', description: 'Read a single Kyte application by id: name, identifier, default language, and status.')] + #[RequiresScope('read')] + public function readApplication(int $application_id): ?array + { + $accountId = $this->accountIdOrZero(); + if ($accountId === 0 || !$this->appBelongsToAccount($application_id, $accountId)) { + return null; + } + return $this->appToArray($application_id); + } + + /** @return array|null */ + private function appToArray(int $appId): ?array + { + $app = new \Kyte\Core\ModelObject(\Application); + if (!$app->retrieve('id', $appId)) { + return null; + } + return [ + 'id' => (int)$app->id, + 'name' => isset($app->name) ? (string)$app->name : '', + 'identifier' => isset($app->identifier) ? (string)$app->identifier : '', + 'language' => isset($app->language) ? (string)$app->language : null, + 'status' => isset($app->status) ? (string)$app->status : 'active', + // Built-in login config (configure_app_login). Null user_model means + // the app has no login wired — the login endpoint won't accept app users. + 'user_model' => !empty($app->user_model) ? (string)$app->user_model : null, + 'username_field' => !empty($app->username_colname) ? (string)$app->username_colname : null, + 'password_field' => !empty($app->password_colname) ? (string)$app->password_colname : null, + ]; + } + + private function accountIdOrZero(): int + { + return isset($this->api->account->id) ? (int)$this->api->account->id : 0; + } + + private function appBelongsToAccount(int $appId, int $accountId): bool + { + $app = new \Kyte\Core\ModelObject(\Application); + return $app->retrieve('id', $appId) && (int)$app->kyte_account === $accountId; + } +} diff --git a/src/Mcp/Tools/ControllerTools.php b/src/Mcp/Tools/ControllerTools.php index cf4db12e..96270e59 100644 --- a/src/Mcp/Tools/ControllerTools.php +++ b/src/Mcp/Tools/ControllerTools.php @@ -232,6 +232,388 @@ public function readFunction(int $function_id, ?int $version_number = null): ?ar ]); } + /** + * Create a new custom API controller in an application. It is created with + * its generated base code; attach behaviour afterward with + * write_function_code (hooks / method overrides / custom helpers). Optionally + * bind a data model so the generated controller wires up shipyard_init. + * + * @param int $application_id Application id (from list_applications). + * @param string $name Controller name — unique within the app; must not collide with a built-in controller class. + * @param int|null $data_model_id Optional DataModel id to bind (from list_models). + * @param string|null $description Optional description. + * @return array{created: bool, controller?: array|null, error?: string} + */ + #[McpTool(name: 'create_controller', description: 'Create a new custom API controller in a Kyte application (with generated base code). Optionally bind a data model. Add behaviour afterward with write_function_code.')] + #[RequiresScope('schema')] + public function createController(int $application_id, string $name, ?int $data_model_id = null, ?string $description = null): array + { + $accountId = $this->accountIdOrZero(); + if ($accountId === 0 || !$this->applicationBelongsToAccount($application_id, $accountId)) { + return ['created' => false, 'error' => 'Application not found in this account.']; + } + if ($data_model_id !== null && !$this->dataModelBelongsToApp($data_model_id, $application_id, $accountId)) { + return ['created' => false, 'error' => 'Data model not found in this application.']; + } + + $api = $this->api; + $resp = []; + try { + $controller = new \Kyte\Mvc\Controller\ControllerController(\Controller, $api, 'm/d/Y H:i:s', $resp, true); + $data = ['name' => $name, 'application' => $application_id]; + if ($data_model_id !== null) { $data['dataModel'] = $data_model_id; } + if ($description !== null) { $data['description'] = $description; } + $controller->new($data); + } catch (\Throwable $e) { + return ['created' => false, 'error' => $e->getMessage()]; + } + + $newId = isset($resp['data'][0]['id']) ? (int)$resp['data'][0]['id'] : 0; + if ($newId === 0) { + return ['created' => false, 'error' => 'Controller was not created.']; + } + return ['created' => true, 'controller' => $this->readController($newId)]; + } + + /** + * Update a controller's name, description, or bound data model. Changing the + * name or bound model regenerates the controller's base code. Controller + * behaviour (functions) is edited with write_function_code, not here. + * + * @param int $controller_id Controller id. + * @param string|null $name New name (unique within the app). + * @param string|null $description New description. + * @param int|null $data_model_id New DataModel id to bind. + * @return array{updated: bool, controller?: array|null, error?: string} + */ + #[McpTool(name: 'update_controller', description: 'Update a controller\'s name, description, or bound data model. Edit controller behaviour (functions) with write_function_code.')] + #[RequiresScope('schema')] + public function updateController(int $controller_id, ?string $name = null, ?string $description = null, ?int $data_model_id = null): array + { + $accountId = $this->accountIdOrZero(); + if ($accountId === 0 || !$this->controllerBelongsToAccount($controller_id, $accountId)) { + return ['updated' => false, 'error' => 'Controller not found in this account.']; + } + + $ctrl = new \Kyte\Core\ModelObject(\Controller); + $ctrl->retrieve('id', $controller_id); + $appId = (int)$ctrl->application; + + $data = []; + // A name change is validated against the app scope by + // ControllerController::validateControllerUpdate, which reads + // application off the payload — so carry it whenever name is set. + if ($name !== null) { $data['name'] = $name; $data['application'] = $appId; } + if ($description !== null) { $data['description'] = $description; } + if ($data_model_id !== null) { + if (!$this->dataModelBelongsToApp($data_model_id, $appId, $accountId)) { + return ['updated' => false, 'error' => 'Data model not found in this controller\'s application.']; + } + $data['dataModel'] = $data_model_id; + } + if (empty($data)) { + return ['updated' => false, 'error' => 'No updatable fields provided (name, description, data_model_id).']; + } + + $api = $this->api; + $resp = []; + try { + $controller = new \Kyte\Mvc\Controller\ControllerController(\Controller, $api, 'm/d/Y H:i:s', $resp, true); + $controller->update('id', $controller_id, $data); + } catch (\Throwable $e) { + return ['updated' => false, 'error' => $e->getMessage()]; + } + return ['updated' => true, 'controller' => $this->readController($controller_id)]; + } + + /** + * Delete a controller and all of its functions. + * + * @param int $controller_id Controller id. + * @return array{deleted: bool, controller_id?: int, error?: string} + */ + #[McpTool(name: 'delete_controller', description: 'Delete a controller and all its functions.')] + #[RequiresScope('schema')] + public function deleteController(int $controller_id): array + { + $accountId = $this->accountIdOrZero(); + if ($accountId === 0 || !$this->controllerBelongsToAccount($controller_id, $accountId)) { + return ['deleted' => false, 'error' => 'Controller not found in this account.']; + } + $api = $this->api; + $resp = []; + try { + $controller = new \Kyte\Mvc\Controller\ControllerController(\Controller, $api, 'm/d/Y H:i:s', $resp, true); + $controller->delete('id', $controller_id); + } catch (\Throwable $e) { + return ['deleted' => false, 'error' => $e->getMessage()]; + } + return ['deleted' => true, 'controller_id' => $controller_id]; + } + + /** + * Add a function to a controller: a hook, a CRUD method override, or a + * custom helper. Created with the generated stub for its type; add real + * behaviour afterward with write_function_code, then publish with + * commit_draft (which regenerates the controller). Mirrors how Shipyard's + * function editor creates functions. + * + * Types: hooks — hook_init, hook_auth, hook_prequery, hook_preprocess, + * hook_response_data, hook_process_get_response; method overrides — new, + * update, get, delete; and custom (arbitrary helper). Hooks and overrides + * are unique per controller (one each); custom allows many. + * + * @param int $controller_id Controller id (from list_controllers). + * @param string $type Function type (see list above). + * @param string $name Function name — the PHP method name for a + * custom function; a label for hooks/overrides + * (the type is the slot). + * @param string|null $description Optional description. + * @return array{created: bool, function?: array|null, error?: string} + */ + #[McpTool(name: 'create_function', description: 'Add a function to a controller: a hook (hook_preprocess / hook_response_data / hook_init / hook_auth / hook_prequery / hook_process_get_response), a CRUD method override (new / update / get / delete), or a custom helper. Created as a stub — add behaviour with write_function_code then publish with commit_draft. Hooks and overrides are unique per controller; custom allows multiple.')] + #[RequiresScope('schema')] + public function createFunction(int $controller_id, string $type, string $name, ?string $description = null): array + { + $accountId = $this->accountIdOrZero(); + if ($accountId === 0 || !$this->controllerBelongsToAccount($controller_id, $accountId)) { + return ['created' => false, 'error' => 'Controller not found in this account.']; + } + + $validTypes = [ + 'hook_init', 'hook_auth', 'hook_prequery', 'hook_preprocess', + 'hook_response_data', 'hook_process_get_response', + 'new', 'update', 'get', 'delete', 'custom', + ]; + if (!in_array($type, $validTypes, true)) { + return ['created' => false, 'error' => "Invalid function type '{$type}'. Valid types: " . implode(', ', $validTypes) . '.']; + } + if (trim($name) === '') { + return ['created' => false, 'error' => 'Function name is required.']; + } + + // FunctionController's initial-version write attributes created_by to + // $api->user (and kyte_account to $api->account). MCP tokens populate + // account but NOT user, so bind a representative account user for the + // internal call and restore it after — without it the version write + // dereferences null. (create_controller doesn't need this; its + // controller never versions on create.) + $api = $this->api; + $priorUser = isset($api->user) ? $api->user : null; + $acctUser = new \Kyte\Core\ModelObject(\KyteUser); + if (!$acctUser->retrieve('kyte_account', $accountId)) { + return ['created' => false, 'error' => 'No user is available for this account to attribute the change to.']; + } + $api->user = $acctUser; + + $resp = []; + try { + $fnCtrl = new \Kyte\Mvc\Controller\FunctionController(constant('Function'), $api, 'm/d/Y H:i:s', $resp, true); + $data = ['name' => $name, 'controller' => $controller_id, 'type' => $type]; + if ($description !== null) { + $data['description'] = $description; + } + $fnCtrl->new($data); + } catch (\Throwable $e) { + return ['created' => false, 'error' => $e->getMessage()]; + } finally { + $api->user = $priorUser; + } + + $newId = isset($resp['data'][0]['id']) ? (int)$resp['data'][0]['id'] : 0; + if ($newId === 0) { + return ['created' => false, 'error' => 'Function was not created.']; + } + return [ + 'created' => true, + 'function' => $this->readFunction($newId), + 'note' => 'Stub created. Add behaviour with write_function_code, then publish with commit_draft.', + ]; + } + + /** + * Delete a single function from a controller. Removes the function + its + * versions and regenerates the parent controller's code without it. To + * remove a whole controller (and all its functions) use delete_controller. + * + * @param int $function_id Function id (from list_functions). + * @return array{deleted: bool, function_id?: int, error?: string} + */ + #[McpTool(name: 'delete_function', description: 'Delete a single function from a controller (removes its versions and regenerates the controller code). Use delete_controller to remove a whole controller.')] + #[RequiresScope('schema')] + public function deleteFunction(int $function_id): array + { + $accountId = $this->accountIdOrZero(); + if ($accountId === 0) { + return ['deleted' => false, 'error' => 'No account context.']; + } + $fn = new \Kyte\Core\ModelObject(constant('Function')); + if (!$fn->retrieve('id', $function_id) || (int)$fn->kyte_account !== $accountId) { + return ['deleted' => false, 'error' => 'Function not found in this account.']; + } + + // FunctionController's delete cleans up versions (content refcounting uses + // $api->user) — bind a representative account user, restored after. + $api = $this->api; + $priorUser = isset($api->user) ? $api->user : null; + $acctUser = new \Kyte\Core\ModelObject(\KyteUser); + if ($acctUser->retrieve('kyte_account', $accountId)) { + $api->user = $acctUser; + } + + $resp = []; + try { + $ctrl = new \Kyte\Mvc\Controller\FunctionController(constant('Function'), $api, 'm/d/Y H:i:s', $resp, true); + $ctrl->delete('id', $function_id); + } catch (\Throwable $e) { + return ['deleted' => false, 'error' => $e->getMessage()]; + } finally { + $api->user = $priorUser; + } + return ['deleted' => true, 'function_id' => $function_id]; + } + + /** + * The authoring reference for controller/function PHP. The signatures, + * by-reference params, available $this context, and query API of a Kyte + * controller are template-specific — an AI writing function code needs them + * to produce code that runs. Call before write_function_code. + * + * ⚠️ MCP KNOWLEDGE BASE — KEEP IN SYNC. This guide is verified against + * FunctionController::FUNCTION_TYPES (the hook/override templates) and + * ModelController (hook firing order, $this context, account scoping) plus + * Core/Model.php + Core/ModelObject.php (query API). Any MATERIAL change to a + * hook/override signature, the hook dispatch order, the $this context, the + * Model/ModelObject query API, or the response/error contract MUST update this + * guide in the same change, or AI-generated controller code will drift. + * + * @return array + */ + #[McpTool(name: 'get_controller_guide', description: 'How to write PHP for Kyte controllers/functions: the exact hook + method-override signatures (which params are by-reference), the $this context ($this->user / $this->account / $this->response / $this->model), the Model/ModelObject query API, error handling, and worked examples. Call this BEFORE writing controller function code with write_function_code.')] + #[RequiresScope('read')] + public function getControllerGuide(): array + { + return [ + 'overview' => + 'A Kyte controller extends \\Kyte\\Mvc\\Controller\\ModelController and is bound to a ' + . 'data model. The base implements default CRUD (new/update/get/delete) INCLUDING ' + . 'automatic per-account (kyte_account) tenant scoping, auth, FK handling, and ' + . 'populating $this->response. You customise via HOOKS (fire around the default flow) ' + . 'or METHOD OVERRIDES (replace a default operation). Author each with create_function ' + . '+ write_function_code + commit_draft. Write the COMPLETE method — the full ' + . '`public function ...(...) { ... }` matching the template signature (for a custom ' + . 'function you write the whole method). The framework wraps your functions in the ' + . 'controller class, so do NOT add a class wrapper.', + 'context' => [ + '$this->user' => 'ALWAYS a ModelObject — when there is no session it is an EMPTY ' + . 'object with no id (it is NEVER literally null). Guard with isset($this->user->id) ' + . '(NOT !$this->user, which is always false). For default CRUD, auth is already ' + . 'enforced (requireAuth defaults true) so $this->user->id is set there.', + '$this->account' => 'The Kyte account ModelObject ($this->account->id, ->number). Use ->id to scope your queries.', + '$this->response' => "The response envelope (array). Default CRUD sets \$this->response['data'] " + . "to a LIST of row arrays (one element even for a single create/update). In a custom " + . "get/override you may set it to whatever shape your page JS reads (a list, a plain " + . "object, or a scalar) — just keep the k.get response.data handling in sync.", + '$this->model' => "The resolved model-definition ARRAY (\$this->model['name'], " + . "\$this->model['struct'][]), set by shipyard_init() — it is the array value, not the constant name.", + '$this->api' => 'The Api instance.', + ], + 'hooks' => [ + 'hook_init()' => 'Runs during construction, BEFORE authentication — do NOT assume a ' + . 'logged-in user here ($this->user->id may be unset). Use for controller config (flags, allowableActions).', + 'hook_auth()' => 'Runs AFTER the session is validated — post-auth checks.', + 'hook_prequery($method, &$field, &$value, &$conditions, &$all, &$order)' => + 'Fires immediately before the DB query in GET and UPDATE ONLY ($method is "get" or ' + . '"update"). It is NOT called for new (no query) or delete. Mutate the by-ref params ' + . "to scope/filter — e.g. \$field='id'; \$value=\$this->user->id;. To constrain a " + . 'delete, override delete() or add conditions in hook_response_data("delete", ...).', + 'hook_preprocess($method, &$r, &$o = null)' => + 'Fires before the WRITE for new ($o is null) and update ($o = the existing row). NOT ' + . 'called for get or delete. $r is the incoming data BY-REFERENCE (validate/transform/' + . 'inject). throw \\Exception to abort.', + 'hook_response_data($method, $o, &$r = null, &$d = null)' => + 'For new/update/get: fires AFTER the op — $o = affected row, &$r = the outgoing ' + . 'response row (augment/redact), $d = original request data. FOR DELETE IT DIFFERS: ' + . 'it fires BEFORE the delete and &$r is the $autodelete BOOLEAN (default true) — set ' + . '$r=false to VETO the delete; there is no response row or $d for delete.', + 'hook_process_get_response(&$r)' => + 'Fires once at the end of get() with the assembled list ($r BY-REFERENCE) — final shaping of the GET response.', + ], + 'method_overrides' => [ + '_warning' => 'An override COMPLETELY REPLACES the base method — the base is NOT called ' + . 'for you, so you LOSE automatic kyte_account scoping, the auth gate, FK handling, ' + . 'and the default $this->response population. Either call the parent (parent::new' + . '($data), parent::get($field,$value), ...) and adjust, OR re-implement it: on WRITES ' + . 'set $data["kyte_account"] = $this->account->id; on READS add a kyte_account ' + . 'condition; and set $this->response["data"]. FORGETTING ACCOUNT SCOPING LEAKS OR ' + . 'WRITES CROSS-TENANT DATA.', + 'new($data)' => 'Replace create. $data = the posted object.', + 'update($field, $value, $data)' => 'Replace update of the row(s) where $field=$value with $data.', + 'get($field, $value)' => "Replace read (filter by \$field=\$value, or both null for all). Set \$this->response['data'].", + 'delete($field, $value)' => 'Replace delete of the row(s) where $field=$value.', + 'custom' => 'A custom function is a HELPER method on the controller — ' + . 'it is NOT reachable from the API by name. Only POST->new, PUT->update, GET->get, ' + . 'DELETE->delete are dispatched. Call a custom function yourself from a hook/override. ' + . 'To expose new behavior to the client, override one of the four CRUD methods (or ' + . 'branch on request state inside a hook).', + ], + 'query_api' => [ + 'model_multi' => "new \\Kyte\\Core\\Model(ModelName) — MANY rows. " + . "->retrieve(\$field=null, \$value=null, \$isLike=false, \$conditions=null, \$all=false, " + . "\$order=null, \$limit=null); then ->objects (array of ModelObject) and ->count() " + . "(number RETRIEVED, not the DB total). \$all=true includes soft-deleted rows.", + 'object_single' => "new \\Kyte\\Core\\ModelObject(ModelName) — ONE row. " + . "->retrieve(\$field, \$value, \$conditions=null, \$id=null, \$all=false) — NOTE the 3rd " + . "arg is \$conditions, NOT \$isLike (this DIFFERS from Model::retrieve — do not copy " + . "its arg order); returns bool. ->create(\$params, \$user=null) (auto-stamps " + . "deleted=0/date_created/created_by). ->save(\$params, \$user=null) (retrieve first). " + . "->delete(null,null,\$userId) is a SOFT delete (sets deleted=1); ->purge() hard-deletes.", + 'conditions' => "\$conditions = [['field'=>..., 'value'=>..., 'operator'=>'>=' (optional)]] " + . "AND-clauses; \$order = [['field'=>..., 'direction'=>'asc|desc']]. ModelName is the " + . "model's bare CONSTANT (e.g. Task), not a string.", + 'scoping' => 'Your ad-hoc Model/ModelObject queries are NOT auto-scoped by account — ' + . 'add the condition yourself: ->retrieve("f", $v, false, [["field"=>"kyte_account","value"=>$this->account->id]]).', + ], + 'errors' => + 'Throw \\Exception with a user-facing message to fail a request — the framework returns ' + . 'HTTP 400 with {error: message} (a SessionException gives 403), delivered to the ' + . "frontend's k.* error callback. Do not echo or return; use exceptions + \$this->response.", + 'example_get_override' => implode("\n", [ + "// A custom get override. Reachable from JS as k.get('SubdomainCheck', 'subdomain', value, [], ok, err).", + "public function get(\$field, \$value) {", + " if (!isset(\$this->user->id)) { throw new \\Exception('auth required'); } // NOT !\$this->user", + " if (\$field !== 'subdomain') { throw new \\Exception('invalid field'); }", + " \$sub = strtolower(trim(\$value));", + " \$sites = new \\Kyte\\Core\\Model(Site);", + " // scope your own queries by account", + " \$sites->retrieve('subdomain', \$sub, false, [['field' => 'kyte_account', 'value' => \$this->account->id]]);", + " // custom shape — the page reads response.data.available (this override returns an object, not a list)", + " \$this->response['data'] = ['subdomain' => \$sub, 'available' => (\$sites->count() === 0)];", + "}", + ]), + 'example_hook_prequery' => implode("\n", [ + "// hook_prequery fires for get + update ONLY (never new/delete)", + "public function hook_prequery(\$method, &\$field, &\$value, &\$conditions, &\$all, &\$order) {", + " switch (\$method) {", + " case 'get':", + " case 'update':", + " \$field = 'id'; // scope every read/update to the caller", + " \$value = (int)\$this->user->id;", + " break;", + " }", + "}", + ]), + ]; + } + + private function dataModelBelongsToApp(int $modelId, int $applicationId, int $accountId): bool + { + $m = new \Kyte\Core\ModelObject(\DataModel); + return $m->retrieve('id', $modelId) + && (int)$m->application === $applicationId + && (int)$m->kyte_account === $accountId; + } + private function accountIdOrZero(): int { return isset($this->api->account->id) ? (int)$this->api->account->id : 0; diff --git a/src/Mcp/Tools/MediaTools.php b/src/Mcp/Tools/MediaTools.php new file mode 100644 index 00000000..4844bf48 --- /dev/null +++ b/src/Mcp/Tools/MediaTools.php @@ -0,0 +1,243 @@ +>} + */ + #[McpTool(name: 'list_media', description: 'List the media files in a Kyte site\'s library (metadata only — call read_media for a download URL).')] + #[RequiresScope('read')] + public function listMedia(int $site_id): array + { + $accountId = $this->accountIdOrZero(); + if ($accountId === 0 || !$this->siteBelongsToAccount($site_id, $accountId)) { + return ['media' => []]; + } + + $model = new \Kyte\Core\Model(\Media); + $model->retrieve('site', $site_id, false, [ + ['field' => 'kyte_account', 'value' => $accountId], + ]); + + $out = []; + foreach ($model->objects as $md) { + $out[] = $this->mediaToArray($md); + } + return ['media' => $out]; + } + + /** + * Read a single media file's metadata plus a short-lived presigned download + * URL (valid ~60 minutes). + * + * @param int $media_id Media id (from list_media). + * @return array|null + */ + #[McpTool(name: 'read_media', description: 'Read a media file\'s metadata plus a short-lived presigned download URL.')] + #[RequiresScope('read')] + public function readMedia(int $media_id): ?array + { + $accountId = $this->accountIdOrZero(); + if ($accountId === 0) { + return null; + } + $media = new \Kyte\Core\ModelObject(\Media); + if (!$media->retrieve('id', $media_id) || (int)$media->kyte_account !== $accountId) { + return null; + } + + $out = $this->mediaToArray($media); + $s3ctx = $this->resolveSiteS3((int)$media->site, $accountId); + if ($s3ctx !== null && $media->s3key) { + try { + $out['download_url'] = $s3ctx->getObject((string)$media->s3key); + } catch (\Throwable $e) { + $out['download_url'] = null; + } + } + return $out; + } + + /** + * Upload a media file to a site's library. Pass the file bytes as base64. + * The server writes them to the site's S3 media bucket directly. + * + * @param int $site_id Site id (from list_sites). Must be provisioned (have a media bucket). + * @param string $filename File name (e.g. "logo.png"); sanitized to a safe key. + * @param string $content_base64 The file bytes, base64-encoded. Max 5 MB decoded. + * @param string|null $content_type MIME type (e.g. "image/png"); optional but recommended. + * @return array{created: bool, media?: array, error?: string} + */ + #[McpTool(name: 'create_media', description: 'Upload a media file (base64 bytes, max 5MB) to a Kyte site\'s S3 media library. Provide content_type (e.g. image/png) when known.')] + #[RequiresScope('provision')] + public function createMedia(int $site_id, string $filename, string $content_base64, ?string $content_type = null): array + { + $accountId = $this->accountIdOrZero(); + if ($accountId === 0 || !$this->siteBelongsToAccount($site_id, $accountId)) { + return ['created' => false, 'error' => 'Site not found in this account.']; + } + + $data = base64_decode($content_base64, true); + if ($data === false) { + return ['created' => false, 'error' => 'content_base64 is not valid base64.']; + } + if ($data === '') { + return ['created' => false, 'error' => 'content_base64 decoded to empty.']; + } + if (strlen($data) > self::MAX_UPLOAD_BYTES) { + return ['created' => false, 'error' => 'File exceeds the 5MB MCP upload limit; use the Shipyard uploader for larger files.']; + } + + $s3 = $this->resolveSiteS3($site_id, $accountId); + if ($s3 === null) { + return ['created' => false, 'error' => 'Site is not fully provisioned yet (no media bucket). Poll read_site until status is "active".']; + } + + $safe = preg_replace('/[^A-Za-z0-9_.-]/', '-', $filename); + if ($safe === null || $safe === '') { + return ['created' => false, 'error' => 'Invalid filename.']; + } + $key = date('Y-m-d') . '/' . $safe; + + $media = new \Kyte\Core\ModelObject(\Media); + try { + $media->create([ + 'name' => $safe, + 's3key' => $key, + 'site' => $site_id, + 'kyte_account' => $accountId, + ]); + } catch (\Throwable $e) { + return ['created' => false, 'error' => 'Failed to create media record: ' . $e->getMessage()]; + } + + // Server-side upload; roll the row back if S3 rejects it. + try { + $s3->write($key, $data, $content_type); + } catch (\Throwable $e) { + try { $media->delete(); } catch (\Throwable $ignore) {} + return ['created' => false, 'error' => 'Upload failed: ' . $e->getMessage()]; + } + + return ['created' => true, 'media' => $this->mediaToArray($media)]; + } + + /** + * Delete a media file (removes both the S3 object and the record). + * + * @param int $media_id Media id. + * @return array{deleted: bool, media_id?: int, error?: string} + */ + #[McpTool(name: 'delete_media', description: 'Delete a media file — removes both the S3 object and the record.')] + #[RequiresScope('provision')] + public function deleteMedia(int $media_id): array + { + $accountId = $this->accountIdOrZero(); + if ($accountId === 0) { + return ['deleted' => false, 'error' => 'Media not found in this account.']; + } + $media = new \Kyte\Core\ModelObject(\Media); + if (!$media->retrieve('id', $media_id) || (int)$media->kyte_account !== $accountId) { + return ['deleted' => false, 'error' => 'Media not found in this account.']; + } + + $key = $media->s3key ? (string)$media->s3key : ''; + $siteId = (int)$media->site; + + $media->delete(); + + // Best-effort S3 cleanup (the row is already gone; a stray object is + // harmless and surfaced nowhere). + if ($key !== '') { + $s3 = $this->resolveSiteS3($siteId, $accountId); + if ($s3 !== null) { + try { $s3->unlink($key); } catch (\Throwable $e) {} + } + } + + return ['deleted' => true, 'media_id' => $media_id]; + } + + /** + * Build an S3 client for a site's media bucket (site region + owning app's + * AWS creds), or null if the site isn't ours / not yet provisioned. + */ + private function resolveSiteS3(int $siteId, int $accountId): ?\Kyte\Aws\S3 + { + $site = new \Kyte\Core\ModelObject(\KyteSite); + if (!$site->retrieve('id', $siteId) || (int)$site->kyte_account !== $accountId) { + return null; + } + if (empty($site->s3MediaBucketName) || empty($site->region)) { + return null; + } + $app = new \Kyte\Core\ModelObject(\Application); + if (!$app->retrieve('id', (int)$site->application)) { + return null; + } + $credentials = new \Kyte\Aws\Credentials((string)$site->region, $app->aws_public_key, $app->aws_private_key); + return new \Kyte\Aws\S3($credentials, (string)$site->s3MediaBucketName); + } + + /** @return array */ + private function mediaToArray(\Kyte\Core\ModelObject $m): array + { + // isset() (not `?? ` / `!== null`) so a freshly-created object that + // never populated an optional column (e.g. thumbnail) doesn't emit an + // undefined-property warning into the response stream. + return [ + 'id' => (int)$m->id, + 'name' => isset($m->name) ? (string)$m->name : '', + 's3key' => isset($m->s3key) ? (string)$m->s3key : null, + 'thumbnail' => isset($m->thumbnail) ? (string)$m->thumbnail : null, + 'site' => isset($m->site) ? (int)$m->site : null, + ]; + } + + private function accountIdOrZero(): int + { + return isset($this->api->account->id) ? (int)$this->api->account->id : 0; + } + + private function siteBelongsToAccount(int $siteId, int $accountId): bool + { + $site = new \Kyte\Core\ModelObject(\KyteSite); + return $site->retrieve('id', $siteId) && (int)$site->kyte_account === $accountId; + } +} diff --git a/src/Mcp/Tools/ModelTools.php b/src/Mcp/Tools/ModelTools.php index 88afb4ff..6e8ef149 100644 --- a/src/Mcp/Tools/ModelTools.php +++ b/src/Mcp/Tools/ModelTools.php @@ -207,9 +207,9 @@ public function createModel(int $application_id, string $name): array * @param int|null $foreign_key_model Referenced model id for a foreign-key column. * @return array{added: bool, attribute?: array, error?: string} */ - #[McpTool(name: 'add_attribute', description: 'Add an attribute (column) to a data model. Applies a real ADD COLUMN migration. Decimal (d) needs precision+scale; varchar (s) needs size.')] + #[McpTool(name: 'add_attribute', description: 'Add an attribute (column) to a data model. Applies a real ADD COLUMN migration. Decimal (d) needs precision+scale; varchar (s) needs size. For a user-model password column set password=true (auto-hashes for login — store the PLAINTEXT in signup, do not hash it yourself) and protected=true (never return the hash via the API).')] #[RequiresScope('schema')] - public function addAttribute(int $model_id, string $name, string $type, ?int $size = null, ?int $precision = null, ?int $scale = null, bool $unsigned = false, bool $required = false, ?string $default = null, ?int $foreign_key_model = null): array + public function addAttribute(int $model_id, string $name, string $type, ?int $size = null, ?int $precision = null, ?int $scale = null, bool $unsigned = false, bool $required = false, ?string $default = null, ?int $foreign_key_model = null, bool $password = false, bool $protected = false, bool $sensitive = false): array { $accountId = $this->accountIdOrZero(); if ($accountId === 0 || !$this->modelBelongsToAccount($model_id, $accountId)) { @@ -222,7 +222,7 @@ public function addAttribute(int $model_id, string $name, string $type, ?int $si return ['added' => false, 'error' => 'Foreign-key model not found in this account.']; } - $data = $this->attributeData($model_id, $name, $type, $size, $precision, $scale, $unsigned, $required, $default, $foreign_key_model); + $data = $this->attributeData($model_id, $name, $type, $size, $precision, $scale, $unsigned, $required, $default, $foreign_key_model, $password, $protected, $sensitive); $resp = []; try { @@ -259,9 +259,9 @@ public function addAttribute(int $model_id, string $name, string $type, ?int $si * @param int|null $foreign_key_model Referenced model id for a foreign-key column. * @return array{updated: bool, attribute?: array, error?: string} */ - #[McpTool(name: 'update_attribute', description: 'Update an attribute definition (applies a real CHANGE COLUMN migration). name is required since the column definition is rewritten in full.')] + #[McpTool(name: 'update_attribute', description: 'Update an attribute definition (applies a real CHANGE COLUMN migration). name is required since the column definition is rewritten in full. Flags password/protected/sensitive default to false — pass them each time you want them kept (an omitted flag reverts to false).')] #[RequiresScope('schema')] - public function updateAttribute(int $attribute_id, string $name, string $type, ?int $size = null, ?int $precision = null, ?int $scale = null, bool $unsigned = false, bool $required = false, ?string $default = null, ?int $foreign_key_model = null): array + public function updateAttribute(int $attribute_id, string $name, string $type, ?int $size = null, ?int $precision = null, ?int $scale = null, bool $unsigned = false, bool $required = false, ?string $default = null, ?int $foreign_key_model = null, bool $password = false, bool $protected = false, bool $sensitive = false): array { $accountId = $this->accountIdOrZero(); $attr = $this->ownedAttribute($attribute_id, $accountId); @@ -275,7 +275,7 @@ public function updateAttribute(int $attribute_id, string $name, string $type, ? return ['updated' => false, 'error' => 'Foreign-key model not found in this account.']; } - $data = $this->attributeData((int)$attr->dataModel, $name, $type, $size, $precision, $scale, $unsigned, $required, $default, $foreign_key_model); + $data = $this->attributeData((int)$attr->dataModel, $name, $type, $size, $precision, $scale, $unsigned, $required, $default, $foreign_key_model, $password, $protected, $sensitive); unset($data['dataModel']); // not editable on update $resp = []; @@ -434,7 +434,7 @@ private function modelAttributeController(array &$resp): \Kyte\Mvc\Controller\Mo * * @return array */ - private function attributeData(int $modelId, string $name, string $type, ?int $size, ?int $precision, ?int $scale, bool $unsigned, bool $required, ?string $default, ?int $foreignKeyModel): array + private function attributeData(int $modelId, string $name, string $type, ?int $size, ?int $precision, ?int $scale, bool $unsigned, bool $required, ?string $default, ?int $foreignKeyModel, bool $password = false, bool $protected = false, bool $sensitive = false): array { $data = [ 'dataModel' => $modelId, @@ -442,6 +442,15 @@ private function attributeData(int $modelId, string $name, string $type, ?int $s 'type' => $type, 'required' => $required ? 1 : 0, 'unsigned' => $unsigned ? 1 : 0, + // Column metadata flags. `password` makes the framework hash the value + // on write (bcrypt) so the built-in login can password_verify it — + // your signup must store the PLAINTEXT and let Kyte hash it (do NOT + // hash it yourself, or it double-hashes). `protected` blanks the value + // in API responses (use with password so hashes never leave the + // server). `sensitive` marks it for redaction in logs. + 'password' => $password ? 1 : 0, + 'protected' => $protected ? 1 : 0, + 'sensitive' => $sensitive ? 1 : 0, ]; if ($size !== null) { $data['size'] = $size; } if ($precision !== null) { $data['precision'] = $precision; } @@ -483,6 +492,9 @@ private function attributeSummary(int $attributeId): array 'scale' => $a->scale !== null ? (int)$a->scale : null, 'required' => (int)($a->required ?? 0) === 1, 'unsigned' => (int)($a->unsigned ?? 0) === 1, + 'password' => (int)($a->password ?? 0) === 1, + 'protected' => (int)($a->protected ?? 0) === 1, + 'sensitive' => (int)($a->sensitive ?? 0) === 1, 'foreign_key_model' => $a->foreignKeyModel !== null ? (int)$a->foreignKeyModel : null, ]; } diff --git a/src/Mcp/Tools/PageTools.php b/src/Mcp/Tools/PageTools.php index d093fed9..685092f1 100644 --- a/src/Mcp/Tools/PageTools.php +++ b/src/Mcp/Tools/PageTools.php @@ -222,6 +222,110 @@ public function readPage(int $page_id, ?int $version_number = null): ?array ]); } + /** + * Create a page on a site (empty draft). Add HTML / CSS / JS afterward with + * write_page_part, then publish with commit_draft — mirrors Shipyard's + * new-page → edit → publish flow. No AWS/S3 happens at create (publishing + * does that later via commit_draft). + * + * @param int $site_id Site id (from list_sites). + * @param string $title Page title. + * @param string $path File path / URL for the page (e.g. index.html, + * tasks.html) — becomes the S3 key on publish. + * @param string|null $description Optional description. + * @return array{created: bool, page?: array|null, error?: string} + */ + #[McpTool(name: 'create_page', description: 'Create a page on a Kyte site (empty draft). Provide a path/URL like index.html or tasks.html. Add HTML/CSS/JS with write_page_part, then publish with commit_draft.')] + #[RequiresScope('schema')] + public function createPage(int $site_id, string $title, string $path, ?string $description = null): array + { + $accountId = $this->accountIdOrZero(); + if ($accountId === 0 || !$this->siteBelongsToAccount($site_id, $accountId)) { + return ['created' => false, 'error' => 'Site not found in this account.']; + } + if (trim($title) === '' || trim($path) === '') { + return ['created' => false, 'error' => 'title and path are required.']; + } + + // KytePageController attributes created_by + the page-data / initial + // version to $api->user, which MCP tokens don't populate (account only). + // Bind a representative account user for the internal call, restored after. + $api = $this->api; + $priorUser = isset($api->user) ? $api->user : null; + $acctUser = new \Kyte\Core\ModelObject(\KyteUser); + if (!$acctUser->retrieve('kyte_account', $accountId)) { + return ['created' => false, 'error' => 'No user is available for this account to attribute the change to.']; + } + $api->user = $acctUser; + + $resp = []; + try { + $ctrl = new \Kyte\Mvc\Controller\KytePageController(\KytePage, $api, 'm/d/Y H:i:s', $resp, true); + $data = ['site' => $site_id, 'title' => $title, 's3key' => $path]; + if ($description !== null) { + $data['description'] = $description; + } + $ctrl->new($data); + } catch (\Throwable $e) { + return ['created' => false, 'error' => $e->getMessage()]; + } finally { + $api->user = $priorUser; + } + + $newId = isset($resp['data'][0]['id']) ? (int)$resp['data'][0]['id'] : 0; + if ($newId === 0) { + return ['created' => false, 'error' => 'Page was not created.']; + } + return [ + 'created' => true, + 'page' => $this->readPage($newId), + 'note' => 'Empty draft page created. Add content with write_page_part, then publish with commit_draft.', + ]; + } + + /** + * Delete a page. Removes the page, its content/versions, and its library/ + * script assignments; for a PUBLISHED page it also removes the live file + * from S3, rewrites the sitemap, and invalidates CloudFront (KytePageController). + * + * @param int $page_id KytePage id (from list_pages). + * @return array{deleted: bool, page_id?: int, error?: string} + */ + #[McpTool(name: 'delete_page', description: 'Delete a page (and its versions). If the page was published, also removes the live file from S3 and invalidates CloudFront.')] + #[RequiresScope('schema')] + public function deletePage(int $page_id): array + { + $accountId = $this->accountIdOrZero(); + if ($accountId === 0) { + return ['deleted' => false, 'error' => 'No account context.']; + } + $page = new \Kyte\Core\ModelObject(\KytePage); + if (!$page->retrieve('id', $page_id) || (int)$page->kyte_account !== $accountId) { + return ['deleted' => false, 'error' => 'Page not found in this account.']; + } + + // KytePageController's delete cleans up page-data/versions/assignments + // (and S3/CloudFront for published pages) and attributes via $api->user, + // which MCP tokens don't populate. Bind a representative account user. + $api = $this->api; + $priorUser = isset($api->user) ? $api->user : null; + $acctUser = new \Kyte\Core\ModelObject(\KyteUser); + if ($acctUser->retrieve('kyte_account', $accountId)) { + $api->user = $acctUser; + } + + $resp = []; + try { + $ctrl = new \Kyte\Mvc\Controller\KytePageController(\KytePage, $api, 'm/d/Y H:i:s', $resp, true); + $ctrl->delete('id', $page_id); + } catch (\Throwable $e) { + return ['deleted' => false, 'error' => $e->getMessage()]; + } finally { + $api->user = $priorUser; + } + return ['deleted' => true, 'page_id' => $page_id]; + } + private function accountIdOrZero(): int { return isset($this->api->account->id) ? (int)$this->api->account->id : 0; diff --git a/src/Mcp/Tools/ScriptTools.php b/src/Mcp/Tools/ScriptTools.php new file mode 100644 index 00000000..898b9fa9 --- /dev/null +++ b/src/Mcp/Tools/ScriptTools.php @@ -0,0 +1,210 @@ +} + */ + #[McpTool(name: 'list_scripts', description: 'List scripts (JS/CSS assets) on a Kyte site. Metadata only — call read_script for content.')] + #[RequiresScope('read')] + public function listScripts(int $site_id): array + { + $accountId = $this->accountIdOrZero(); + if ($accountId === 0 || !$this->siteBelongsToAccount($site_id, $accountId)) { + return ['scripts' => []]; + } + + $model = new \Kyte\Core\Model(\KyteScript); + $model->retrieve('site', $site_id, false, [ + ['field' => 'kyte_account', 'value' => $accountId], + ]); + + $out = []; + foreach ($model->objects as $s) { + $out[] = [ + 'id' => (int)$s->id, + 'name' => (string)($s->name ?? ''), + 'script_type' => (string)($s->script_type ?? ''), + 'is_js_module' => (int)($s->is_js_module ?? 0) === 1, + 'include_all' => (int)($s->include_all ?? 0) === 1, + 'state' => (int)$s->state, + ]; + } + return ['scripts' => $out]; + } + + /** + * Read a script's live content. + * + * @param int $script_id KyteScript id (from list_scripts). + * @return array{id:int, name:string, script_type:string, is_js_module:bool, include_all:bool, state:int, content:string}|null + */ + #[McpTool(name: 'read_script', description: 'Read a site script including its source content.')] + #[RequiresScope('read')] + public function readScript(int $script_id): ?array + { + $accountId = $this->accountIdOrZero(); + if ($accountId === 0) { + return null; + } + + $s = new \Kyte\Core\ModelObject(\KyteScript); + if (!$s->retrieve('id', $script_id) || (int)$s->kyte_account !== $accountId) { + return null; + } + + return [ + 'id' => (int)$s->id, + 'name' => (string)($s->name ?? ''), + 'script_type' => (string)($s->script_type ?? ''), + 'is_js_module' => (int)($s->is_js_module ?? 0) === 1, + 'include_all' => (int)($s->include_all ?? 0) === 1, + 'state' => (int)$s->state, + 'content' => Bz2Codec::decompressIfBz2($s->content), + ]; + } + + /** + * Create a script on a site (empty). Add source with write_script_content, + * then publish with commit_draft. No AWS/S3 happens at create. + * + * @param int $site_id Site id (from list_sites). + * @param string $name Script name. + * @param string $filename File name for the asset (e.g. app.js, tasks.js) + * — stored under assets//. + * @param string|null $script_type Asset type: 'js' (default) or 'css'. + * @param bool $include_all Auto-include on every page of the site (default false). + * @return array{created: bool, script?: array|null, error?: string} + */ + #[McpTool(name: 'create_script', description: 'Create a site script (JS/CSS asset), empty. Provide a filename like app.js or tasks.js. Add source with write_script_content, then publish with commit_draft. Set include_all to auto-load it on every page.')] + #[RequiresScope('schema')] + public function createScript(int $site_id, string $name, string $filename, ?string $script_type = 'js', bool $include_all = false): array + { + $accountId = $this->accountIdOrZero(); + if ($accountId === 0 || !$this->siteBelongsToAccount($site_id, $accountId)) { + return ['created' => false, 'error' => 'Site not found in this account.']; + } + if (trim($name) === '' || trim($filename) === '') { + return ['created' => false, 'error' => 'name and filename are required.']; + } + $type = ($script_type !== null && trim($script_type) !== '') ? strtolower(trim($script_type)) : 'js'; + if (!in_array($type, ['js', 'css'], true)) { + return ['created' => false, 'error' => "Invalid script_type '{$type}'. Use 'js' or 'css'."]; + } + + // KyteScriptController attributes created_by + the initial version to + // $api->user, which MCP tokens don't populate (account only). Bind a + // representative account user for the internal call, restored after. + $api = $this->api; + $priorUser = isset($api->user) ? $api->user : null; + $acctUser = new \Kyte\Core\ModelObject(\KyteUser); + if (!$acctUser->retrieve('kyte_account', $accountId)) { + return ['created' => false, 'error' => 'No user is available for this account to attribute the change to.']; + } + $api->user = $acctUser; + + $resp = []; + try { + $ctrl = new \Kyte\Mvc\Controller\KyteScriptController(\KyteScript, $api, 'm/d/Y H:i:s', $resp, true); + $ctrl->new([ + 'site' => $site_id, + 'name' => $name, + 'script_type' => $type, + 's3key' => $filename, + 'content' => '', + 'include_all' => $include_all ? 1 : 0, + ]); + } catch (\Throwable $e) { + return ['created' => false, 'error' => $e->getMessage()]; + } finally { + $api->user = $priorUser; + } + + $newId = isset($resp['data'][0]['id']) ? (int)$resp['data'][0]['id'] : 0; + if ($newId === 0) { + return ['created' => false, 'error' => 'Script was not created.']; + } + return [ + 'created' => true, + 'script' => $this->readScript($newId), + 'note' => 'Empty script created. Add source with write_script_content, then publish with commit_draft.', + ]; + } + + /** + * Delete a site script. + * + * @param int $script_id KyteScript id (from list_scripts). + * @return array{deleted: bool, script_id?: int, error?: string} + */ + #[McpTool(name: 'delete_script', description: 'Delete a site script and its versions.')] + #[RequiresScope('schema')] + public function deleteScript(int $script_id): array + { + $accountId = $this->accountIdOrZero(); + if ($accountId === 0 || !$this->scriptBelongsToAccount($script_id, $accountId)) { + return ['deleted' => false, 'error' => 'Script not found in this account.']; + } + + $api = $this->api; + $priorUser = isset($api->user) ? $api->user : null; + $acctUser = new \Kyte\Core\ModelObject(\KyteUser); + if ($acctUser->retrieve('kyte_account', $accountId)) { + $api->user = $acctUser; + } + + $resp = []; + try { + $ctrl = new \Kyte\Mvc\Controller\KyteScriptController(\KyteScript, $api, 'm/d/Y H:i:s', $resp, true); + $ctrl->delete('id', $script_id); + } catch (\Throwable $e) { + return ['deleted' => false, 'error' => $e->getMessage()]; + } finally { + $api->user = $priorUser; + } + return ['deleted' => true, 'script_id' => $script_id]; + } + + private function accountIdOrZero(): int + { + return isset($this->api->account->id) ? (int)$this->api->account->id : 0; + } + + private function siteBelongsToAccount(int $siteId, int $accountId): bool + { + $site = new \Kyte\Core\ModelObject(\KyteSite); + return $site->retrieve('id', $siteId) && (int)$site->kyte_account === $accountId; + } + + private function scriptBelongsToAccount(int $scriptId, int $accountId): bool + { + $s = new \Kyte\Core\ModelObject(\KyteScript); + return $s->retrieve('id', $scriptId) && (int)$s->kyte_account === $accountId; + } +} diff --git a/src/Mvc/Controller/ApplicationController.php b/src/Mvc/Controller/ApplicationController.php index 816b6ea1..198dfe93 100644 --- a/src/Mvc/Controller/ApplicationController.php +++ b/src/Mvc/Controller/ApplicationController.php @@ -12,57 +12,54 @@ class ApplicationController extends ModelController public function hook_preprocess($method, &$r, &$o = null) { switch ($method) { case 'new': - // check aws creds and add if not present - if (!isset($r['aws_public_key'], $r['aws_private_key'], $r['aws_username'])) { - throw new \Exception('AWS Access and Secret key are required along with the username associated with the credential.'); - } + // Resolve the AWS credential for this application. + // + // Two paths: the Shipyard create form supplies a key inline; the + // MCP create_app tool does NOT pass secrets and instead relies on + // the account's already-configured key. Either way the resolved + // key's public/private values are copied onto the Application row + // (aws_public_key/aws_private_key) — the denormalized copy the + // publish/media/CloudFront paths read today. + // + // FORWARD-LOOKING (KYTE-#205): this is the single resolution point + // for application AWS credentials. When the credential model is + // consolidated — a platform default via the EC2 instance role plus + // an optional per-account override — only this block changes; the + // rest of app creation is credential-agnostic. $aws = new \Kyte\Core\ModelObject(KyteAWSKey); - if ($aws->retrieve('private_key', $r['aws_private_key'], [['field'=>'public_key', 'value'=>$r['aws_public_key']], ['field' => 'kyte_account', 'value' => $this->user->kyte_account]])) { - $r['aws_key'] = $aws->id; + $createdBy = isset($this->user->id) ? $this->user->id : null; + if (isset($r['aws_public_key'], $r['aws_private_key'], $r['aws_username'])) { + // Inline key (Shipyard): reuse the account's matching row or create it. + if (!$aws->retrieve('private_key', $r['aws_private_key'], [['field' => 'public_key', 'value' => $r['aws_public_key']], ['field' => 'kyte_account', 'value' => $this->account->id]])) { + if (!$aws->create([ + 'private_key' => $r['aws_private_key'], + 'public_key' => $r['aws_public_key'], + 'username' => $r['aws_username'], + 'created_by' => $createdBy, + 'kyte_account' => $this->account->id, + ])) { + throw new \Exception("Unable to create new AWS credentials."); + } + } } else { - if ($aws->create([ - 'private_key' => $r['aws_private_key'], - 'public_key' => $r['aws_public_key'], - 'username' => $r['aws_username'], - 'created_by' => $this->user->id, - 'kyte_account' => $this->account->id, - ])) { - $r['aws_key'] = $aws->id; - } else { - throw new \Exception("Unable to create new AWS credentials."); + // No inline key (MCP): use the account's existing credential. + if (!$aws->retrieve('kyte_account', $this->account->id)) { + throw new \Exception('No AWS credentials are configured for this account. Add them in Shipyard before creating an application.'); } + $r['aws_public_key'] = $aws->public_key; + $r['aws_private_key'] = $aws->private_key; } - - // create new application identifier - $r['identifier'] = uniqid(); - // create db name - $r['db_name'] = $r['identifier'].'_'.$this->account->number; - - // TODO: create new user and add privs to isolate db - // create new username - $r['db_username'] = 'db'.$r['identifier']; - - // TODO: create db in different cluster - // $r['db_host'] = ''; - - // create a bucket for storing logs - // get AWS credential - default to us-east-1 - $region = 'us-east-1'; - $credentials = new \Kyte\Aws\Credentials($region, $aws->aws_public_key, $aws->aws_private_key); - - // create s3 bucket for site data - $bucketName = strtolower(preg_replace('/[^A-Za-z0-9_-]/', '-', $r['name']).'-logs-'.$r['identifier'].'-'.time()); - $r['s3LogBucketName'] = $bucketName; - $r['s3LogBucketRegion'] = $region; - - $s3 = new \Kyte\Aws\S3($credentials, $bucketName); - try { - $s3->createBucket(); - } catch(\Exception $e) { - throw new \Exception("Unable to create new bucket for logs."); + $r['aws_key'] = $aws->id; + + // Application identifier + isolated tenant database (on the + // platform RDS — no S3 credentials needed for app creation). + $r['identifier'] = uniqid(); + $r['db_name'] = $r['identifier'] . '_' . $this->account->number; + $r['db_username'] = 'db' . $r['identifier']; + if (empty($r['db_password'])) { + $r['db_password'] = bin2hex(random_bytes(16)); } - // create database \Kyte\Core\DBI::createDatabase($r['db_name'], $r['db_username'], $r['db_password']); break; @@ -165,8 +162,23 @@ public function hook_response_data($method, $o, &$r = null, &$d = null) { // // delete distribution // $cf->delete(); - // delete database from cluster - \Kyte\Core\DBI::query("DROP DATABASE `{$o->db_name}`;"); + // Async teardown (KYTE-#559): don't drop anything synchronously. + // Mark the app + its sites 'deleting'; the SiteProvisioningWorker + // tears down each site's AWS infra (S3/CloudFront/ACM) over ticks, + // then finalizes the app (drops the tenant DB + its user, sets + // deleted=1/status='deleted'). $r is the base controller's + // $autodelete flag — set it false so the app row survives for the + // worker to finalize (and its sites aren't row-deleted out from + // under the teardown). + $r = false; + $o->save(['status' => 'deleting']); + $sites = new \Kyte\Core\Model(KyteSite); + $sites->retrieve('application', $o->id, false, []); + foreach ($sites->objects as $s) { + if ((string)($s->status ?? '') !== 'deleted') { + $s->save(['status' => 'deleting']); + } + } // // delete acm certificate // $acm = new \Kyte\Aws\Acm($credentials, $o->AcmArn); diff --git a/src/Mvc/Controller/FunctionController.php b/src/Mvc/Controller/FunctionController.php index c5202b26..0db90cf7 100644 --- a/src/Mvc/Controller/FunctionController.php +++ b/src/Mvc/Controller/FunctionController.php @@ -4,6 +4,10 @@ class FunctionController extends ModelController { + // ⚠️ These templates (hook + method-override signatures) are the source of + // truth for the MCP authoring guide (ControllerTools::getControllerGuide / + // the get_controller_guide tool). If you change a signature here, update that + // guide in the same change so AI-generated controller code doesn't drift. // Configuration for function types and their templates private const FUNCTION_TYPES = [ 'hook_init' => [ @@ -239,11 +243,19 @@ private function createFunctionVersion($functionObj, $data, $versionType = 'manu // Check if this exact content already exists $existingContent = $this->findExistingFunctionContent($contentHash); - + + // 'initial' is a SENTINEL (see the guard above) that forces the first + // version even with no diff — it is NOT a valid version_type enum value + // (auto_save|manual_save|publish|mcp_draft|mcp_commit). Persisting it + // verbatim gets rejected as "Data truncated for column 'version_type'", + // which silently broke initial-version creation on every function-create + // (Shipyard + MCP). Store the baseline as manual_save. + $storedVersionType = ($versionType === 'initial') ? 'manual_save' : $versionType; + $versionData = [ 'function' => $functionObj->id, 'version_number' => $nextVersion, - 'version_type' => $versionType, + 'version_type' => $storedVersionType, 'change_summary' => $changeSummary, 'changes_detected' => json_encode($changes), 'content_hash' => $contentHash, diff --git a/src/Mvc/Controller/KyteScriptController.php b/src/Mvc/Controller/KyteScriptController.php index ac3f73a1..c9eb75ad 100644 --- a/src/Mvc/Controller/KyteScriptController.php +++ b/src/Mvc/Controller/KyteScriptController.php @@ -232,11 +232,18 @@ private function createScriptVersion($scriptObj, $data, $versionType = 'manual_s // Check if this exact content already exists $existingContent = $this->findExistingScriptContent($contentHash); - + + // 'initial' is a SENTINEL (see the guard above) that forces the first + // version even with no diff — it is NOT a valid version_type enum value + // (auto_save|manual_save|publish|mcp_draft|mcp_commit) and would be + // rejected as "Data truncated for column 'version_type'". Store the + // baseline as manual_save. (Mirrors the FunctionController fix.) + $storedVersionType = ($versionType === 'initial') ? 'manual_save' : $versionType; + $versionData = [ 'script' => $scriptObj->id, 'version_number' => $nextVersion, - 'version_type' => $versionType, + 'version_type' => $storedVersionType, 'change_summary' => $changeSummary, 'changes_detected' => json_encode($changes), 'content_hash' => $contentHash, diff --git a/src/Mvc/Controller/ModelController.php b/src/Mvc/Controller/ModelController.php index 9b2e6fcc..0eb812df 100644 --- a/src/Mvc/Controller/ModelController.php +++ b/src/Mvc/Controller/ModelController.php @@ -824,7 +824,8 @@ public function update($field, $value, $data) $all = false; - + $order = null; // initialise before passing by-ref to hook_prequery (matches get()) + $this->hook_prequery('update', $field, $value, $conditions, $all, $order); // init object @@ -1125,6 +1126,11 @@ public function delete($field, $value) public function shipyard_init() {} // hook function - user defined + // ⚠️ These hook signatures + their dispatch order/semantics (which method + // each fires for, by-ref params, the delete $autodelete flag) are documented + // for AI clients in the get_controller_guide MCP tool + // (ControllerTools::getControllerGuide). Keep that guide in sync with any + // change here or to when/how these are invoked in new/update/get/delete. public function hook_init() {} public function hook_auth() {} public function hook_prequery($method, &$field, &$value, &$conditions, &$all, &$order) {} diff --git a/src/Mvc/Model/Application.php b/src/Mvc/Model/Application.php index fc847f2b..1b5d2b78 100644 --- a/src/Mvc/Model/Application.php +++ b/src/Mvc/Model/Application.php @@ -199,6 +199,17 @@ 'date' => false, ], + // Lifecycle status: 'active' (default), or 'deleting' while the + // SiteProvisioningWorker tears down the app's sites + drops the tenant + // DB (KYTE-#559). 'deleted' is set with deleted=1 at finalization. + 'status' => [ + 'type' => 's', + 'required' => false, + 'size' => 20, + 'default' => 'active', + 'date' => false, + ], + // framework attributes 'kyte_account' => [ diff --git a/src/Mvc/Model/KyteAppIdentityProvider.php b/src/Mvc/Model/KyteAppIdentityProvider.php new file mode 100644 index 00000000..dda58b70 --- /dev/null +++ b/src/Mvc/Model/KyteAppIdentityProvider.php @@ -0,0 +1,154 @@ + 'KyteAppIdentityProvider', + 'struct' => [ + 'application' => [ + 'type' => 'i', + 'required' => true, + 'size' => 11, + 'unsigned' => true, + 'date' => false, + 'fk' => ['model' => 'Application', 'field' => 'id'], + ], + + // 'microsoft' (first) | future 'google' | 'okta' | 'oidc' + 'provider' => [ + 'type' => 's', + 'required' => true, + 'size' => 32, + 'date' => false, + ], + + 'enabled' => [ + 'type' => 'i', + 'required' => false, + 'size' => 1, + 'unsigned' => true, + 'default' => 0, + 'date' => false, + ], + + // OIDC issuer, e.g. https://login.microsoftonline.com//v2.0 + 'issuer' => [ + 'type' => 's', + 'required' => false, + 'size' => 512, + 'date' => false, + ], + + // Optional explicit discovery URL; otherwise issuer + /.well-known/openid-configuration. + 'discovery_url' => [ + 'type' => 's', + 'required' => false, + 'size' => 512, + 'date' => false, + ], + + // Azure tenant id — used to build the issuer and to reject id_tokens + // whose `tid` claim doesn't match (tenant scoping). + 'tenant' => [ + 'type' => 's', + 'required' => false, + 'size' => 128, + 'date' => false, + ], + + 'client_id' => [ + 'type' => 's', + 'required' => false, + 'size' => 255, + 'date' => false, + ], + + // KMS-encrypted (base64 of the ciphertext blob). protected + never + // returned to a client; decrypted only server-side at token exchange. + 'client_secret' => [ + 'type' => 't', + 'required' => false, + 'date' => false, + 'protected' => true, + ], + + 'scopes' => [ + 'type' => 's', + 'required' => false, + 'size' => 512, + 'default' => 'openid profile email', + 'date' => false, + ], + + // Kyte's own /sso/callback URL (validated / exact-matched). + 'redirect_uri' => [ + 'type' => 's', + 'required' => false, + 'size' => 1024, + 'date' => false, + ], + + // Which id_token claim maps to the app user's username column. + 'user_email_claim' => [ + 'type' => 's', + 'required' => false, + 'size' => 64, + 'default' => 'email', + 'date' => false, + ], + + // JIT: auto-create the app user on first successful SSO (default on). + 'jit_enabled' => [ + 'type' => 'i', + 'required' => false, + 'size' => 1, + 'unsigned' => true, + 'default' => 1, + 'date' => false, + ], + + // If set, only pre-existing app users may sign in (no JIT create). + 'restrict_to_existing' => [ + 'type' => 'i', + 'required' => false, + 'size' => 1, + 'unsigned' => true, + 'default' => 0, + 'date' => false, + ], + + // framework attributes + + 'kyte_account' => [ + 'type' => 'i', + 'required' => true, + 'size' => 11, + 'unsigned' => true, + 'date' => false, + ], + + // audit attributes + + 'created_by' => ['type' => 'i', 'required' => false, 'date' => false], + 'date_created' => ['type' => 'i', 'required' => false, 'date' => true], + 'modified_by' => ['type' => 'i', 'required' => false, 'date' => false], + 'date_modified' => ['type' => 'i', 'required' => false, 'date' => true], + 'deleted_by' => ['type' => 'i', 'required' => false, 'date' => false], + 'date_deleted' => ['type' => 'i', 'required' => false, 'date' => true], + 'deleted' => ['type' => 'i', 'required' => false, 'size' => 1, 'default' => 0, 'date' => false], + ], +]; diff --git a/src/Mvc/Model/KyteOAuthClient.php b/src/Mvc/Model/KyteOAuthClient.php new file mode 100644 index 00000000..c60a7231 --- /dev/null +++ b/src/Mvc/Model/KyteOAuthClient.php @@ -0,0 +1,159 @@ + 'KyteOAuthClient', + 'struct' => [ + // Public client identifier issued at registration (opaque random). + // UNIQUE + indexed (see migration). Looked up at /oauth/authorize and + // /oauth/token. + 'client_id' => [ + 'type' => 's', + 'required' => true, + 'size' => 64, + 'date' => false, + ], + + // Confidential-client secret (sha256 at rest). MCP connectors are + // PUBLIC clients (token_endpoint_auth_method = "none") and have NO + // secret; reserved for a future confidential-client path. `protected` + // keeps it out of list/get responses. + 'client_secret' => [ + 'type' => 's', + 'required' => false, + 'size' => 64, + 'date' => false, + 'protected' => true, + ], + + // Human-facing client name from the registration request + // (e.g. "Claude", "ChatGPT"). + 'client_name' => [ + 'type' => 's', + 'required' => false, + 'size' => 255, + 'date' => false, + ], + + // JSON array of allowed redirect URIs. Enforced (exact match) at + // /oauth/authorize and /oauth/token. https-only (except http://localhost + // / 127.0.0.1 for native/desktop loopback per the OAuth native-app BCP). + 'redirect_uris' => [ + 'type' => 't', + 'required' => true, + 'date' => false, + ], + + // CSV of registered grant types. Default "authorization_code". + 'grant_types' => [ + 'type' => 's', + 'required' => false, + 'size' => 255, + 'date' => false, + ], + + // CSV of registered response types. Default "code". + 'response_types' => [ + 'type' => 's', + 'required' => false, + 'size' => 255, + 'date' => false, + ], + + // Client authentication method at the token endpoint. "none" for the + // public PKCE clients Claude/ChatGPT use. + 'token_endpoint_auth_method' => [ + 'type' => 's', + 'required' => false, + 'size' => 64, + 'date' => false, + ], + + // Space-separated OAuth scopes the client requested at registration. + 'scope' => [ + 'type' => 's', + 'required' => false, + 'size' => 512, + 'date' => false, + ], + + // framework attributes + + // Nullable (0) — clients register before any user authenticates, so a + // client is not bound to one account. + 'kyte_account' => [ + 'type' => 'i', + 'required' => false, + 'size' => 11, + 'unsigned' => true, + 'default' => 0, + 'date' => false, + ], + + // audit attributes + + 'created_by' => [ + 'type' => 'i', + 'required' => false, + 'date' => false, + ], + + 'date_created' => [ + 'type' => 'i', + 'required' => false, + 'date' => true, + ], + + 'modified_by' => [ + 'type' => 'i', + 'required' => false, + 'date' => false, + ], + + 'date_modified' => [ + 'type' => 'i', + 'required' => false, + 'date' => true, + ], + + 'deleted_by' => [ + 'type' => 'i', + 'required' => false, + 'date' => false, + ], + + 'date_deleted' => [ + 'type' => 'i', + 'required' => false, + 'date' => true, + ], + + 'deleted' => [ + 'type' => 'i', + 'required' => false, + 'size' => 1, + 'default' => 0, + 'date' => false, + ], + ], +]; diff --git a/src/Mvc/Model/KyteOAuthCode.php b/src/Mvc/Model/KyteOAuthCode.php new file mode 100644 index 00000000..3f97e8f4 --- /dev/null +++ b/src/Mvc/Model/KyteOAuthCode.php @@ -0,0 +1,183 @@ + 'KyteOAuthCode', + 'struct' => [ + // sha256 (hex, 64) of the raw authorization code. Only the hash is + // stored; the raw code is returned once in the authorize redirect and + // never recoverable. `protected` keeps it out of list/get responses. + 'code_hash' => [ + 'type' => 's', + 'required' => true, + 'size' => 64, + 'date' => false, + 'protected' => true, + ], + + // The client (KyteOAuthClient.client_id) this code was issued to. + // Must match the client presenting it at /oauth/token. + 'client_id' => [ + 'type' => 's', + 'required' => true, + 'size' => 64, + 'date' => false, + ], + + // The redirect_uri used at /oauth/authorize. Must match exactly at + // /oauth/token (OAuth 2.1 code-injection defense). + 'redirect_uri' => [ + 'type' => 's', + 'required' => true, + 'size' => 1024, + 'date' => false, + ], + + // PKCE code_challenge (base64url of sha256(verifier)). Verified against + // the client's code_verifier at redemption. `protected`. + 'code_challenge' => [ + 'type' => 's', + 'required' => true, + 'size' => 255, + 'date' => false, + 'protected' => true, + ], + + // PKCE method. Only "S256" is accepted (plain is rejected). + 'code_challenge_method' => [ + 'type' => 's', + 'required' => true, + 'size' => 16, + 'date' => false, + ], + + // Space-separated OAuth scopes granted at consent. + 'scope' => [ + 'type' => 's', + 'required' => false, + 'size' => 512, + 'date' => false, + ], + + // CSV of the kmcp scopes (read/draft/commit/provision/schema) the minted + // KyteMCPToken will carry — the OAuth→kmcp scope map resolved at consent. + 'kyte_scopes' => [ + 'type' => 's', + 'required' => true, + 'size' => 255, + 'date' => false, + ], + + // Optional app scope for the minted token (mirrors KyteMCPToken.application). + 'application' => [ + 'type' => 'i', + 'required' => false, + 'size' => 11, + 'unsigned' => true, + 'date' => false, + 'fk' => [ + 'model' => 'Application', + 'field' => 'id', + ], + ], + + // Expiry (unix epoch). Short — codes live ~seconds/minutes. + 'expires_at' => [ + 'type' => 'i', + 'required' => true, + 'size' => 11, + 'unsigned' => true, + 'default' => 0, + 'date' => true, + ], + + // Single-use marker (unix epoch of redemption). 0 = unused; nonzero = + // already redeemed → any further presentation is rejected. + 'consumed_at' => [ + 'type' => 'i', + 'required' => false, + 'size' => 11, + 'unsigned' => true, + 'default' => 0, + 'date' => true, + ], + + // framework attributes + + // The account that consented (bound at /oauth/authorize). REQUIRED — + // the minted token acts on this tenant. + 'kyte_account' => [ + 'type' => 'i', + 'required' => true, + 'size' => 11, + 'unsigned' => true, + 'date' => false, + ], + + // audit attributes — created_by = the KyteUser who approved consent. + + 'created_by' => [ + 'type' => 'i', + 'required' => false, + 'date' => false, + ], + + 'date_created' => [ + 'type' => 'i', + 'required' => false, + 'date' => true, + ], + + 'modified_by' => [ + 'type' => 'i', + 'required' => false, + 'date' => false, + ], + + 'date_modified' => [ + 'type' => 'i', + 'required' => false, + 'date' => true, + ], + + 'deleted_by' => [ + 'type' => 'i', + 'required' => false, + 'date' => false, + ], + + 'date_deleted' => [ + 'type' => 'i', + 'required' => false, + 'date' => true, + ], + + 'deleted' => [ + 'type' => 'i', + 'required' => false, + 'size' => 1, + 'default' => 0, + 'date' => false, + ], + ], +]; diff --git a/src/Mvc/Model/KyteSsoCode.php b/src/Mvc/Model/KyteSsoCode.php new file mode 100644 index 00000000..a8e61c10 --- /dev/null +++ b/src/Mvc/Model/KyteSsoCode.php @@ -0,0 +1,61 @@ + 'KyteSsoCode', + 'struct' => [ + // sha256 of the raw hand-off code; the raw code is only in the redirect. + 'code_hash' => [ + 'type' => 's', + 'required' => true, + 'size' => 64, + 'date' => false, + 'protected' => true, + ], + + 'application' => [ + 'type' => 'i', + 'required' => true, + 'size' => 11, + 'unsigned' => true, + 'date' => false, + 'fk' => ['model' => 'Application', 'field' => 'id'], + ], + + // The mapped app user's id within the app's user_model. + 'sso_user_id' => [ + 'type' => 'i', + 'required' => true, + 'size' => 11, + 'unsigned' => true, + 'date' => false, + ], + + 'expires_at' => [ + 'type' => 'i', 'required' => true, 'size' => 11, 'unsigned' => true, 'default' => 0, 'date' => true, + ], + 'consumed_at' => [ + 'type' => 'i', 'required' => false, 'size' => 11, 'unsigned' => true, 'default' => 0, 'date' => true, + ], + + // framework + audit + 'kyte_account' => ['type' => 'i', 'required' => false, 'size' => 11, 'unsigned' => true, 'default' => 0, 'date' => false], + 'created_by' => ['type' => 'i', 'required' => false, 'date' => false], + 'date_created' => ['type' => 'i', 'required' => false, 'date' => true], + 'modified_by' => ['type' => 'i', 'required' => false, 'date' => false], + 'date_modified' => ['type' => 'i', 'required' => false, 'date' => true], + 'deleted_by' => ['type' => 'i', 'required' => false, 'date' => false], + 'date_deleted' => ['type' => 'i', 'required' => false, 'date' => true], + 'deleted' => ['type' => 'i', 'required' => false, 'size' => 1, 'default' => 0, 'date' => false], + ], +]; diff --git a/src/Mvc/Model/KyteSsoIdentity.php b/src/Mvc/Model/KyteSsoIdentity.php new file mode 100644 index 00000000..f85c2f45 --- /dev/null +++ b/src/Mvc/Model/KyteSsoIdentity.php @@ -0,0 +1,64 @@ + 'KyteSsoIdentity', + 'struct' => [ + 'application' => [ + 'type' => 'i', + 'required' => true, + 'size' => 11, + 'unsigned' => true, + 'date' => false, + 'fk' => ['model' => 'Application', 'field' => 'id'], + ], + + // 'microsoft' | future 'google' | 'okta' | 'oidc' + 'provider' => ['type' => 's', 'required' => true, 'size' => 32, 'date' => false], + + // The immutable OIDC subject (the id_token `sub`, pairwise per client) — + // the identity key. Unique per (application, provider) via the migration. + 'subject' => ['type' => 's', 'required' => true, 'size' => 255, 'date' => false], + + // The IdP tenant (Entra `tid`) the subject belongs to — audit / scoping. + 'tenant_id' => ['type' => 's', 'required' => false, 'size' => 128, 'date' => false], + + // The linked app user's id within the app's user_model. + 'sso_user_id' => [ + 'type' => 'i', + 'required' => true, + 'size' => 11, + 'unsigned' => true, + 'date' => false, + ], + + // Last-seen email for the subject — display / support only, NOT a key. + 'email' => ['type' => 's', 'required' => false, 'size' => 320, 'date' => false], + + // framework + audit + 'kyte_account' => ['type' => 'i', 'required' => false, 'size' => 11, 'unsigned' => true, 'default' => 0, 'date' => false], + 'created_by' => ['type' => 'i', 'required' => false, 'date' => false], + 'date_created' => ['type' => 'i', 'required' => false, 'date' => true], + 'modified_by' => ['type' => 'i', 'required' => false, 'date' => false], + 'date_modified' => ['type' => 'i', 'required' => false, 'date' => true], + 'deleted_by' => ['type' => 'i', 'required' => false, 'date' => false], + 'date_deleted' => ['type' => 'i', 'required' => false, 'date' => true], + 'deleted' => ['type' => 'i', 'required' => false, 'size' => 1, 'default' => 0, 'date' => false], + ], +]; diff --git a/src/Mvc/Model/KyteSsoState.php b/src/Mvc/Model/KyteSsoState.php new file mode 100644 index 00000000..ecb08d42 --- /dev/null +++ b/src/Mvc/Model/KyteSsoState.php @@ -0,0 +1,81 @@ + 'KyteSsoState', + 'struct' => [ + // Opaque CSRF state echoed by the provider; unique + looked up at callback. + 'state' => [ + 'type' => 's', + 'required' => true, + 'size' => 64, + 'date' => false, + ], + + // id_token nonce (verified against the returned id_token). protected. + 'nonce' => [ + 'type' => 's', + 'required' => true, + 'size' => 64, + 'date' => false, + 'protected' => true, + ], + + // PKCE code_verifier presented at the token endpoint. protected. + 'code_verifier' => [ + 'type' => 's', + 'required' => true, + 'size' => 128, + 'date' => false, + 'protected' => true, + ], + + 'application' => [ + 'type' => 'i', + 'required' => true, + 'size' => 11, + 'unsigned' => true, + 'date' => false, + 'fk' => ['model' => 'Application', 'field' => 'id'], + ], + + 'provider' => ['type' => 's', 'required' => false, 'size' => 32, 'date' => false], + + // The app URL to return the user to after login completes. + 'return_url' => ['type' => 's', 'required' => false, 'size' => 1024, 'date' => false], + + // The redirect_uri sent to the provider (must match at token exchange). + 'redirect_uri' => ['type' => 's', 'required' => false, 'size' => 1024, 'date' => false], + + // sha256 of a browser-bound correlator set as an HttpOnly cookie at + // /authorize and required to match at /callback — ties the callback to + // the user agent that started the flow (login-CSRF / session-fixation + // defense; the OAuth `state` alone does not bind the browser). + 'browser_hash' => ['type' => 's', 'required' => false, 'size' => 64, 'date' => false], + + 'expires_at' => [ + 'type' => 'i', 'required' => true, 'size' => 11, 'unsigned' => true, 'default' => 0, 'date' => true, + ], + 'consumed_at' => [ + 'type' => 'i', 'required' => false, 'size' => 11, 'unsigned' => true, 'default' => 0, 'date' => true, + ], + + // framework + audit + 'kyte_account' => ['type' => 'i', 'required' => false, 'size' => 11, 'unsigned' => true, 'default' => 0, 'date' => false], + 'created_by' => ['type' => 'i', 'required' => false, 'date' => false], + 'date_created' => ['type' => 'i', 'required' => false, 'date' => true], + 'modified_by' => ['type' => 'i', 'required' => false, 'date' => false], + 'date_modified' => ['type' => 'i', 'required' => false, 'date' => true], + 'deleted_by' => ['type' => 'i', 'required' => false, 'date' => false], + 'date_deleted' => ['type' => 'i', 'required' => false, 'date' => true], + 'deleted' => ['type' => 'i', 'required' => false, 'size' => 1, 'default' => 0, 'date' => false], + ], +];