From 2725b7a821e1a31207497a066fc47360fe0a7f20 Mon Sep 17 00:00:00 2001 From: Kenneth Hough Date: Fri, 31 Jul 2026 22:10:24 -0500 Subject: [PATCH 01/30] =?UTF-8?q?feat(551):=20OAuth=20AS=20foundation=20?= =?UTF-8?q?=E2=80=94=20discovery,=20storage=20models,=20WWW-Authenticate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P1-A of the hosted MCP OAuth connector (KYTE-#551). Foundation slice — makes the /mcp endpoint OAuth-discoverable by Claude.ai / ChatGPT web connectors. No behavior change to existing token auth. - KyteOAuthClient + KyteOAuthCode models (src/Mvc/Model) + migration 4.17.0_oauth_as.sql (idempotent CREATE TABLE, mirrors 4.6.0 conventions). - OAuthEndpoint (src/Core/Auth): serves RFC 8414 authorization-server metadata + RFC 9728 protected-resource metadata; register/authorize/token return 501 until P1-B/C/D (#553/#554/#555). Pure process() like JwtEndpoint. - Api::route(): dispatch /oauth/* + /.well-known/oauth-* to OAuthEndpoint before the MVC pipeline (same pattern as /mcp, /jwt). - Mcp\Endpoint: /mcp 401 now carries WWW-Authenticate: Bearer resource_metadata="…/.well-known/oauth-protected-resource" (RFC 9728) so connectors auto-discover the AS. Decision: hand-rolled auth-code+PKCE flow (not league/oauth2-server) — Kyte's access token is the opaque kmcp_live_ (no JWT); only crypto is PKCE S256 + random_bytes, already Kyte's pattern. See docs/design/hosted-mcp-oauth.md. PHPStan clean; php -l clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- migrations/4.17.0_oauth_as.sql | 81 +++++++++++++ src/Core/Api.php | 11 ++ src/Core/Auth/OAuthEndpoint.php | 182 +++++++++++++++++++++++++++++ src/Mcp/Endpoint.php | 8 +- src/Mvc/Model/KyteOAuthClient.php | 159 ++++++++++++++++++++++++++ src/Mvc/Model/KyteOAuthCode.php | 183 ++++++++++++++++++++++++++++++ 6 files changed, 623 insertions(+), 1 deletion(-) create mode 100644 migrations/4.17.0_oauth_as.sql create mode 100644 src/Core/Auth/OAuthEndpoint.php create mode 100644 src/Mvc/Model/KyteOAuthClient.php create mode 100644 src/Mvc/Model/KyteOAuthCode.php 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/src/Core/Api.php b/src/Core/Api.php index 9de36126..b475f109 100644 --- a/src/Core/Api.php +++ b/src/Core/Api.php @@ -701,6 +701,17 @@ 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; + } + if (isset($_SERVER['HTTP_X_KYTE_APPID'])) { $this->appId = $_SERVER['HTTP_X_KYTE_APPID']; } diff --git a/src/Core/Auth/OAuthEndpoint.php b/src/Core/Auth/OAuthEndpoint.php new file mode 100644 index 00000000..fd7caebd --- /dev/null +++ b/src/Core/Auth/OAuthEndpoint.php @@ -0,0 +1,182 @@ + 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']; + + 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); + } + + $segments = explode('/', $path); + $action = $segments[1] ?? ''; // oauth/ + switch ($action) { + case 'register': // RFC 7591 dynamic client registration [P1-B / #553] + return self::notImplemented('register'); + case 'authorize': // authorization-code + PKCE + consent [P1-C / #554] + return self::notImplemented('authorize'); + case 'token': // code+verifier -> mint kmcp_live_ [P1-D / #555] + return self::notImplemented('token'); + 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' => $base . '/oauth/authorize', + '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)."); + } + + /** @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 + { + // Browser-based connectors (claude.ai) hit discovery + token cross-origin. + $origin = $_SERVER['HTTP_ORIGIN'] ?? ''; + if ($origin !== '') { + header("Access-Control-Allow-Origin: {$origin}"); + header('Vary: Origin'); + } + header('Access-Control-Allow-Credentials: true'); + } +} diff --git a/src/Mcp/Endpoint.php b/src/Mcp/Endpoint.php index 3eb6c96b..5dd54802 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()); } 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, + ], + ], +]; From e06b8d065cfc1a84501dd9ea49797d02cad8d9de Mon Sep 17 00:00:00 2001 From: Kenneth Hough Date: Fri, 31 Jul 2026 22:50:07 -0500 Subject: [PATCH 02/30] =?UTF-8?q?feat(551):=20P1-B=20=E2=80=94=20OAuth=20d?= =?UTF-8?q?ynamic=20client=20registration=20(RFC=207591)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /oauth/register: Claude/ChatGPT self-register as public PKCE clients (token_endpoint_auth_method=none) before the authorization-code flow. - Validates redirect_uris (required; https, or http on loopback per RFC 8252; count + length capped), grant_types (authorization_code only), response_types (code only); filters scope to the AS's kmcp scopes (default read). - Persists a KyteOAuthClient (kyte_account=0, unscoped until consent) with an opaque CSPRNG client_id (kyoc_…); returns the RFC 7591 §3.2.1 client info. Open registration is the MCP model — the gate is consent (P1-C) + PKCE (P1-D), not client auth. Rate-limiting/hardening tracked in #556. PHPStan clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Core/Auth/OAuthEndpoint.php | 158 +++++++++++++++++++++++++++++++- 1 file changed, 157 insertions(+), 1 deletion(-) diff --git a/src/Core/Auth/OAuthEndpoint.php b/src/Core/Auth/OAuthEndpoint.php index fd7caebd..ac899652 100644 --- a/src/Core/Auth/OAuthEndpoint.php +++ b/src/Core/Auth/OAuthEndpoint.php @@ -37,6 +37,10 @@ class OAuthEndpoint */ 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; + public static function handle(Api $api): void { self::emitCorsHeaders(); @@ -82,11 +86,15 @@ public static function process(Api $api, array $server, string $rawBody): array 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] - return self::notImplemented('register'); + if ($method !== 'POST') { + return self::error(405, 'invalid_request', 'POST required for /oauth/register.'); + } + return self::register(self::decodeBody($rawBody)); case 'authorize': // authorization-code + PKCE + consent [P1-C / #554] return self::notImplemented('authorize'); case 'token': // code+verifier -> mint kmcp_live_ [P1-D / #555] @@ -149,6 +157,154 @@ 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 + { + $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 implode(' ', array_unique($keep)); + } + + private static function decodeBody(string $rawBody): array + { + if (trim($rawBody) === '') { + return []; + } + $decoded = json_decode($rawBody, true); + return is_array($decoded) ? $decoded : []; + } + /** @param array $body */ private static function json(int $status, array $body): array { From 238b965a7c6704361bd95746876cfb28efd93ea7 Mon Sep 17 00:00:00 2001 From: Kenneth Hough Date: Fri, 31 Jul 2026 23:05:09 -0500 Subject: [PATCH 03/30] =?UTF-8?q?feat(551):=20P1-C=20backend=20=E2=80=94?= =?UTF-8?q?=20authorize=20redirect=20+=20authed=20consent=20endpoints?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend half of the consent flow (KYTE-#551 P1-C). The interactive page lives in Shipyard (next); this adds the endpoints it calls. - authorization_endpoint (AS metadata) now points at SHIPYARD_URL/oauth/authorize (consent reuses Shipyard's login). GET /oauth/authorize on the API 302-redirects there too, preserving the OAuth query params. - GET /oauth/consent/client (authed): validates the authorize request (client + exact redirect_uri match + response_type=code + PKCE S256) and returns client display info (name, requested scopes) for the "Authorize Claude to access Kyte" screen. - POST /oauth/consent/approve (authed): mints a single-use, 300s KyteOAuthCode bound to the consenting user's account + PKCE challenge (account-wide, v1); returns {redirect_uri, code, state} for the page to redirect back to the client. - User auth via AuthDispatcher (JwtSessionStrategy/HMAC) → $api->user/account; an MCP bearer is rejected (consent needs a user session). PHPStan clean. Authed happy-path validates with the Shipyard page + login. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Core/Auth/OAuthEndpoint.php | 255 +++++++++++++++++++++++++++++++- 1 file changed, 247 insertions(+), 8 deletions(-) diff --git a/src/Core/Auth/OAuthEndpoint.php b/src/Core/Auth/OAuthEndpoint.php index ac899652..b78d8ac3 100644 --- a/src/Core/Auth/OAuthEndpoint.php +++ b/src/Core/Auth/OAuthEndpoint.php @@ -41,6 +41,9 @@ class OAuthEndpoint 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; + public static function handle(Api $api): void { self::emitCorsHeaders(); @@ -95,8 +98,33 @@ public static function process(Api $api, array $server, string $rawBody): array return self::error(405, 'invalid_request', 'POST required for /oauth/register.'); } return self::register(self::decodeBody($rawBody)); - case 'authorize': // authorization-code + PKCE + consent [P1-C / #554] - return self::notImplemented('authorize'); + 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] return self::notImplemented('token'); default: @@ -130,7 +158,7 @@ private static function authorizationServerMetadata(array $server): array $base = self::baseUrl($server); return self::json(200, [ 'issuer' => $base, - 'authorization_endpoint' => $base . '/oauth/authorize', + 'authorization_endpoint' => self::shipyardConsentUrl(), 'token_endpoint' => $base . '/oauth/token', 'registration_endpoint' => $base . '/oauth/register', 'scopes_supported' => self::SUPPORTED_SCOPES, @@ -287,22 +315,233 @@ private static function intersectCsv($value, array $allow): string * 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); + return $base . '/oauth/authorize'; + } + + /** + * 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 implode(' ', array_unique($keep)); + return array_values(array_unique($keep)); } - private static function decodeBody(string $rawBody): array + private static function generateAuthCode(): string { - if (trim($rawBody) === '') { + $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 []; } - $decoded = json_decode($rawBody, true); - return is_array($decoded) ? $decoded : []; + $out = []; + parse_str($qs, $out); + return $out; } /** @param array $body */ From d74b86ba78974c459d1b0d962ddf027b45bbf0d4 Mon Sep 17 00:00:00 2001 From: Kenneth Hough Date: Fri, 31 Jul 2026 23:11:36 -0500 Subject: [PATCH 04/30] =?UTF-8?q?feat(551):=20P1-D=20=E2=80=94=20OAuth=20t?= =?UTF-8?q?oken=20endpoint=20(mint=20kmcp=5Flive=5F=20via=20code+PKCE)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the backend OAuth flow (KYTE-#551 P1-D). POST /oauth/token, authorization_code grant: - Parses form-urlencoded (OAuth standard) or JSON body. - Looks up the KyteOAuthCode by sha256(code); rejects unknown/consumed/expired codes and client_id/redirect_uri mismatches (invalid_grant). - PKCE S256: base64url(sha256(code_verifier)) must equal the stored challenge (hash_equals). - Single-use: burns the code (consumed_at) before issuing. - Mints a scoped, account-wide kmcp_live_ KyteMCPToken as the access token (same format/storage as a Shipyard-issued token → McpTokenStrategy validates it identically), created_by = the consenting user. TTL via KYTE_OAUTH_ACCESS_TTL (default 30d). Returns {access_token, token_type, expires_in, scope}. Backend flow now complete: discover → register → authorize/consent → token. Remaining: the Shipyard consent page + full browser e2e. PHPStan clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Core/Auth/OAuthEndpoint.php | 131 +++++++++++++++++++++++++++++++- 1 file changed, 130 insertions(+), 1 deletion(-) diff --git a/src/Core/Auth/OAuthEndpoint.php b/src/Core/Auth/OAuthEndpoint.php index b78d8ac3..c7d5f46c 100644 --- a/src/Core/Auth/OAuthEndpoint.php +++ b/src/Core/Auth/OAuthEndpoint.php @@ -44,6 +44,9 @@ class OAuthEndpoint /** 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(); @@ -126,7 +129,10 @@ public static function process(Api $api, array $server, string $rawBody): array } return self::error(404, 'not_found', "Unknown OAuth endpoint: /{$path}."); case 'token': // code+verifier -> mint kmcp_live_ [P1-D / #555] - return self::notImplemented('token'); + 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}."); } @@ -544,6 +550,129 @@ private static function queryParams(array $server): array 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 { From ad6a6f94cab8f64abd452e00f9a9e0dbfe905952 Mon Sep 17 00:00:00 2001 From: Kenneth Hough Date: Fri, 31 Jul 2026 23:43:03 -0500 Subject: [PATCH 05/30] harden(551): drop Allow-Credentials from OAuth CORS (security review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Security review note #1: emitCorsHeaders reflected any Origin AND sent Access-Control-Allow-Credentials: true on all /oauth/* responses (incl. consent/approve, which returns the raw auth code). Not exploitable — auth is header-based (Bearer / X-Kyte-*), never a cookie, so attacker JS can't obtain the credential — but an auth-code endpoint must not pair credentialed CORS with a reflected origin. Drop Allow-Credentials; the consent page's fetch doesn't use credentials, so nothing breaks. PHPStan clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Core/Auth/OAuthEndpoint.php | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/Core/Auth/OAuthEndpoint.php b/src/Core/Auth/OAuthEndpoint.php index c7d5f46c..97b718c0 100644 --- a/src/Core/Auth/OAuthEndpoint.php +++ b/src/Core/Auth/OAuthEndpoint.php @@ -695,12 +695,17 @@ private static function error(int $status, string $code, string $message): array private static function emitCorsHeaders(): void { - // Browser-based connectors (claude.ai) hit discovery + token cross-origin. + // 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'); } - header('Access-Control-Allow-Credentials: true'); } } From d77e410ae596e5ceeab7c210ec49198df5d4cf46 Mon Sep 17 00:00:00 2001 From: Kenneth Hough Date: Sat, 1 Aug 2026 22:39:14 -0500 Subject: [PATCH 06/30] chore(551): point authorization_endpoint at /oauth-authorize.html (root) The Shipyard consent page ships as a root .html (like login/password/reset) so it's in the deploy bundle and served without directory-index concerns. Update shipyardConsentUrl() to match. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Core/Auth/OAuthEndpoint.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Core/Auth/OAuthEndpoint.php b/src/Core/Auth/OAuthEndpoint.php index 97b718c0..66f59e2b 100644 --- a/src/Core/Auth/OAuthEndpoint.php +++ b/src/Core/Auth/OAuthEndpoint.php @@ -346,7 +346,9 @@ private static function shipyardConsentUrl(): string $base = (defined('SHIPYARD_URL') && SHIPYARD_URL) ? rtrim((string)SHIPYARD_URL, '/') : self::baseUrl($_SERVER); - return $base . '/oauth/authorize'; + // Root .html (served like login/password/reset) — avoids the deploy + // bundle + directory-index concerns of a nested path. + return $base . '/oauth-authorize.html'; } /** From 460829d1460e5cfb2c9651dff6a50779af0a8cf3 Mon Sep 17 00:00:00 2001 From: Kenneth Hough Date: Sat, 1 Aug 2026 22:56:34 -0500 Subject: [PATCH 07/30] feat(343): MCP create/update/delete controller tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the "MCP can't create controllers" gap (KYTE-#343). Adds to ControllerTools: - create_controller(application_id, name, data_model_id?, description?) — new custom controller with generated base code; optional data-model binding. - update_controller(controller_id, name?, description?, data_model_id?) — rename/rebind (regenerates base code) / edit description. - delete_controller(controller_id) — removes the controller + its functions. All gated by the `schema` scope (structural app changes, same as the model tools); controller behaviour/code stays draft/commit via write_function_code. Each goes through ControllerController in INTERNAL mode (like SiteTools), with every id re-scoped to the token's account first. ControllerController::new has no $this->user dependency, so it's MCP-clean. PHPStan clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Mcp/Tools/ControllerTools.php | 127 ++++++++++++++++++++++++++++++ 1 file changed, 127 insertions(+) diff --git a/src/Mcp/Tools/ControllerTools.php b/src/Mcp/Tools/ControllerTools.php index cf4db12e..839c7c43 100644 --- a/src/Mcp/Tools/ControllerTools.php +++ b/src/Mcp/Tools/ControllerTools.php @@ -232,6 +232,133 @@ 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]; + } + + 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; From 488816391cf0432a3f1329a3a497fe8694876b66 Mon Sep 17 00:00:00 2001 From: Kenneth Hough Date: Sun, 2 Aug 2026 00:21:47 -0500 Subject: [PATCH 08/30] feat(345): MCP media tools (list / read / create / delete) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tool parity for site media libraries (KYTE-#345). New MediaTools: - list_media(site_id) [read] — metadata of a site's media files. - read_media(media_id) [read] — metadata + short-lived presigned download URL. - create_media(site_id, filename, content_base64, content_type?) [provision] — server-side upload of base64 bytes (max 5MB) to the site's S3 media bucket (the human Shipyard path uses a presigned browser POST, which doesn't fit an AI client; the server writes directly via Kyte\Aws\S3::write). Rolls the row back if the S3 write fails. - delete_media(media_id) [provision] — removes the S3 object + the record. Site-scoped (site → region + owning app AWS creds → media bucket); every id is re-scoped to the token's account. PHPStan clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Mcp/Tools/MediaTools.php | 240 +++++++++++++++++++++++++++++++++++ 1 file changed, 240 insertions(+) create mode 100644 src/Mcp/Tools/MediaTools.php diff --git a/src/Mcp/Tools/MediaTools.php b/src/Mcp/Tools/MediaTools.php new file mode 100644 index 00000000..4e5771eb --- /dev/null +++ b/src/Mcp/Tools/MediaTools.php @@ -0,0 +1,240 @@ +>} + */ + #[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 + { + return [ + 'id' => (int)$m->id, + 'name' => (string)($m->name ?? ''), + 's3key' => $m->s3key !== null ? (string)$m->s3key : null, + 'thumbnail' => $m->thumbnail !== null ? (string)$m->thumbnail : null, + 'site' => $m->site !== null ? (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; + } +} From cfe604f3afc03d278c70daec22adeb8acebc50dd Mon Sep 17 00:00:00 2001 From: Kenneth Hough Date: Sun, 2 Aug 2026 00:23:20 -0500 Subject: [PATCH 09/30] fix(345): guard mediaToArray with isset (undefined-property warning on fresh objects) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A freshly-created Media object only populates the columns passed to create(), so $m->thumbnail was undefined → PHP warning into the response stream. Use isset() for the optional fields. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Mcp/Tools/MediaTools.php | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/Mcp/Tools/MediaTools.php b/src/Mcp/Tools/MediaTools.php index 4e5771eb..4844bf48 100644 --- a/src/Mcp/Tools/MediaTools.php +++ b/src/Mcp/Tools/MediaTools.php @@ -218,12 +218,15 @@ private function resolveSiteS3(int $siteId, int $accountId): ?\Kyte\Aws\S3 /** @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' => (string)($m->name ?? ''), - 's3key' => $m->s3key !== null ? (string)$m->s3key : null, - 'thumbnail' => $m->thumbnail !== null ? (string)$m->thumbnail : null, - 'site' => $m->site !== null ? (int)$m->site : null, + '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, ]; } From 14d7ce129072907d5d15b2b7c8d74e6fa7f6c18d Mon Sep 17 00:00:00 2001 From: Kenneth Hough Date: Sun, 2 Aug 2026 01:00:25 -0500 Subject: [PATCH 10/30] feat: MCP application tools + remove dead app logs-bucket MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tool parity: create/update/delete application via MCP. Plus two ApplicationController cleanups discussed with Kenneth. ApplicationController::hook_preprocess('new'): - Remove the logs-bucket creation entirely — the s3LogBucketName/Region fields were written at create and read NOWHERE (kyte-php or Shipyard); dead since an unfinished intention, and its S3 call had a null-creds bug (fell back to the instance role). App creation now needs no S3 credentials. - Fix $this->user -> $this->account (MCP tokens have no user); created_by falls back to null. - Single forward-looking AWS-credential resolution point (KYTE-#205): inline key (Shipyard) OR the account's existing key (MCP, no secrets passed). db_password is generated when absent. AppTools (src/Mcp/Tools): create_application(name, language?), update_application, delete_application — all `provision` scope, account-scoped, via ApplicationController internal mode. delete_application refuses to delete an app with live sites (their AWS teardown is async — avoids orphaning S3/CloudFront/ACM); full cascade teardown + app lifecycle status is a separate follow-up. PHPStan clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Mcp/Tools/AppTools.php | 169 +++++++++++++++++++ src/Mvc/Controller/ApplicationController.php | 87 +++++----- 2 files changed, 211 insertions(+), 45 deletions(-) create mode 100644 src/Mcp/Tools/AppTools.php diff --git a/src/Mcp/Tools/AppTools.php b/src/Mcp/Tools/AppTools.php new file mode 100644 index 00000000..32773cbf --- /dev/null +++ b/src/Mcp/Tools/AppTools.php @@ -0,0 +1,169 @@ +|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.']; + } + return ['created' => true, 'application' => $this->appToArray($newId)]; + } + + /** + * 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 (drops its tenant database). Refuses if the app + * still has live sites — delete those first with delete_site. + * + * @param int $application_id Application id. + * @return array{deleted: bool, application_id?: int, error?: string} + */ + #[McpTool(name: 'delete_application', description: 'Delete a Kyte application (drops its tenant database). Refuses if the app still has live sites — delete those first with delete_site (their AWS teardown is asynchronous).')] + #[RequiresScope('provision')] + public function deleteApplication(int $application_id): array + { + $accountId = $this->accountIdOrZero(); + if ($accountId === 0 || !$this->appBelongsToAccount($application_id, $accountId)) { + return ['deleted' => false, 'error' => 'Application not found in this account.']; + } + + // Guard: don't orphan site infrastructure (S3 + CloudFront + ACM), whose + // teardown is asynchronous. Any site not fully "deleted" blocks the app + // delete; the caller tears sites down with delete_site first. + $sites = new \Kyte\Core\Model(\KyteSite); + $sites->retrieve('application', $application_id, false, [ + ['field' => 'kyte_account', 'value' => $accountId], + ]); + $live = 0; + foreach ($sites->objects as $s) { + if ((string)($s->status ?? '') !== 'deleted') { $live++; } + } + if ($live > 0) { + return ['deleted' => false, 'error' => "Application still has {$live} live site(s). Delete them first with delete_site (their S3/CloudFront teardown runs in the background), then retry."]; + } + + $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 ['deleted' => false, 'error' => $e->getMessage()]; + } + return ['deleted' => true, 'application_id' => $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, + ]; + } + + 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/Mvc/Controller/ApplicationController.php b/src/Mvc/Controller/ApplicationController.php index 816b6ea1..c2adb61d 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; From 6e8d85758701728ec4fbbfebb6b9a4df9f67e227 Mon Sep 17 00:00:00 2001 From: Kenneth Hough Date: Sun, 2 Aug 2026 01:12:56 -0500 Subject: [PATCH 11/30] feat: separate privileged DB connection for provisioning (create/drop database) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Provisioning (CREATE/DROP DATABASE + CREATE/DROP USER) needs DBA-level rights the scoped runtime `kyte` user deliberately lacks (Aurora→MariaDB hardening). Rather than re-grant those to the web-facing runtime user (a SQL-injection would then be able to create/drop any database), add a dedicated provisioning identity used ONLY by the provisioning code path. - DBI::getProvisioningConnection() — a separate SSL connection as KYTE_DB_PROVISION_USERNAME / KYTE_DB_PROVISION_PASSWORD (same host + CA bundle, no default DB). Falls back to the main connection when unset, so nothing changes on installs that don't provision. - DBI::createDatabase() now uses it; new DBI::dropDatabase(name, username?) drops the tenant DB + its dedicated user (idempotent) via the same connection. - ApplicationController delete uses DBI::dropDatabase (also cleans up the db user the old raw DROP DATABASE leaked). NOTE: still orphans site AWS infra — full cascade teardown is #559. Security win: a SQLi on normal queries can't escalate to server DDL — those run on the scoped connection. Forward step toward KYTE-#205 (provisioning identity). PHPStan clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Core/DBI.php | 84 +++++++++++++++++++- src/Mvc/Controller/ApplicationController.php | 7 +- 2 files changed, 87 insertions(+), 4 deletions(-) diff --git a/src/Core/DBI.php b/src/Core/DBI.php index 6a38f3ef..551ae42d 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 = ''; diff --git a/src/Mvc/Controller/ApplicationController.php b/src/Mvc/Controller/ApplicationController.php index c2adb61d..13a84222 100644 --- a/src/Mvc/Controller/ApplicationController.php +++ b/src/Mvc/Controller/ApplicationController.php @@ -162,8 +162,11 @@ 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}`;"); + // drop the tenant database + its dedicated user via the + // privileged provisioning connection (KYTE-#205). NOTE: this + // still ORPHANS the app's site AWS infra (S3/CloudFront/ACM) — + // full async cascade teardown is a separate build (see #559). + \Kyte\Core\DBI::dropDatabase($o->db_name, $o->db_username); // // delete acm certificate // $acm = new \Kyte\Aws\Acm($credentials, $o->AcmArn); From 461f572648f947fe34c4d6209bf5a2188c3a6c69 Mon Sep 17 00:00:00 2001 From: Kenneth Hough Date: Sun, 2 Aug 2026 01:33:12 -0500 Subject: [PATCH 12/30] fix: grant explicit tenant privileges (GRANT ALL denied to provisioning user on RDS) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit createDatabase granted 'ALL PRIVILEGES' on the tenant db, but the provisioning identity can only grant privileges it explicitly holds — on RDS 'GRANT ALL' is denied (surfaced as a misleading 'Access denied … to database'). Grant the explicit app-relevant set instead (= ALL minus GRANT OPTION); matches the provisioning user's own grants (#205). Verified against the dev RDS. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Core/DBI.php | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/Core/DBI.php b/src/Core/DBI.php index 551ae42d..c5268790 100644 --- a/src/Core/DBI.php +++ b/src/Core/DBI.php @@ -714,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)); } From 075aceaa43fbaeed6cc9a55bbd67d2aa71dd5141 Mon Sep 17 00:00:00 2001 From: Kenneth Hough Date: Sun, 2 Aug 2026 01:50:29 -0500 Subject: [PATCH 13/30] feat(559): async application teardown (stop orphaning site AWS infra) Deleting an app used to drop the tenant DB + soft-delete rows but ORPHAN all site AWS infra (S3/CloudFront/ACM). Now it's a proper async cascade: - Application.status ('active'|'deleting'|'deleted') + migration 4.18.0_application_status.sql (idempotent, defaults 'active'). - ApplicationController delete hook no longer drops anything synchronously: it marks the app + each of its sites 'deleting' and DEFERS the base controller's soft-delete ($autodelete=false) so the row survives for the worker. - SiteProvisioningWorker::finalizeDeletingApplications(): each tick, for every app in 'deleting', once ALL its sites are fully 'deleted' (their infra torn down by advanceDelete), drop the tenant DB + user (DBI::dropDatabase) and soft-delete the app. Runs regardless of whether any sites are in flight. - delete_application (MCP) now initiates the async teardown and returns in-progress + a poll hint; app output includes status. PHPStan clean. UI for the in-progress state is a Shipyard follow-up. Co-Authored-By: Claude Opus 4.8 (1M context) --- migrations/4.18.0_application_status.sql | 28 +++++++++ src/Cron/SiteProvisioningWorker.php | 66 ++++++++++++++++++-- src/Mcp/Tools/AppTools.php | 53 ++++++++++------ src/Mvc/Controller/ApplicationController.php | 22 +++++-- src/Mvc/Model/Application.php | 11 ++++ 5 files changed, 150 insertions(+), 30 deletions(-) create mode 100644 migrations/4.18.0_application_status.sql 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/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/Tools/AppTools.php b/src/Mcp/Tools/AppTools.php index 32773cbf..2e285e52 100644 --- a/src/Mcp/Tools/AppTools.php +++ b/src/Mcp/Tools/AppTools.php @@ -100,45 +100,57 @@ public function updateApplication(int $application_id, ?string $name = null, ?st } /** - * Delete a Kyte application (drops its tenant database). Refuses if the app - * still has live sites — delete those first with delete_site. + * 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{deleted: bool, application_id?: int, error?: string} + * @return array{deleting: bool, application_id?: int, sites_tearing_down?: int, message?: string, error?: string} */ - #[McpTool(name: 'delete_application', description: 'Delete a Kyte application (drops its tenant database). Refuses if the app still has live sites — delete those first with delete_site (their AWS teardown is asynchronous).')] + #[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 ['deleted' => false, 'error' => 'Application not found in this account.']; + return ['deleting' => false, 'error' => 'Application not found in this account.']; } - // Guard: don't orphan site infrastructure (S3 + CloudFront + ACM), whose - // teardown is asynchronous. Any site not fully "deleted" blocks the app - // delete; the caller tears sites down with delete_site first. - $sites = new \Kyte\Core\Model(\KyteSite); - $sites->retrieve('application', $application_id, false, [ - ['field' => 'kyte_account', 'value' => $accountId], - ]); - $live = 0; - foreach ($sites->objects as $s) { - if ((string)($s->status ?? '') !== 'deleted') { $live++; } - } - if ($live > 0) { - return ['deleted' => false, 'error' => "Application still has {$live} live site(s). Delete them first with delete_site (their S3/CloudFront teardown runs in the background), then retry."]; + $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 ['deleted' => false, 'error' => $e->getMessage()]; + return ['deleting' => false, 'error' => $e->getMessage()]; } - return ['deleted' => true, 'application_id' => $application_id]; + + $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.", + ]; } /** @return array|null */ @@ -153,6 +165,7 @@ private function appToArray(int $appId): ?array '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', ]; } diff --git a/src/Mvc/Controller/ApplicationController.php b/src/Mvc/Controller/ApplicationController.php index 13a84222..198dfe93 100644 --- a/src/Mvc/Controller/ApplicationController.php +++ b/src/Mvc/Controller/ApplicationController.php @@ -162,11 +162,23 @@ public function hook_response_data($method, $o, &$r = null, &$d = null) { // // delete distribution // $cf->delete(); - // drop the tenant database + its dedicated user via the - // privileged provisioning connection (KYTE-#205). NOTE: this - // still ORPHANS the app's site AWS infra (S3/CloudFront/ACM) — - // full async cascade teardown is a separate build (see #559). - \Kyte\Core\DBI::dropDatabase($o->db_name, $o->db_username); + // 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/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' => [ From 481286d8d7e43815291ee0e6297964c92ba8a48b Mon Sep 17 00:00:00 2001 From: Kenneth Hough Date: Sun, 2 Aug 2026 02:08:01 -0500 Subject: [PATCH 14/30] =?UTF-8?q?feat(560):=20app=20SSO=20P1=20foundation?= =?UTF-8?q?=20=E2=80=94=20OIDC=20config=20models=20+=20/sso/authorize?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit App-level Microsoft/OIDC SSO, foundation slice (KYTE-#560 P1). Kyte as the OIDC relying party. - Models: KyteAppIdentityProvider (per-app OIDC config; client_secret KMS- encrypted + protected; Shipyard-managed, never MCP), KyteSsoState (in-flight state/nonce/PKCE), KyteSsoCode (single-use session hand-off, no tokens at rest) + migration 4.19.0_app_sso.sql. - SsoEndpoint: GET /sso/authorize fully implemented — resolves the app's provider config, runs OIDC discovery, builds state+nonce+PKCE, persists a KyteSsoState, 302s to the provider's authorization_endpoint. callback + exchange are 501 stubs (next slice: token exchange + id_token validation + JIT user mapping + Kyte-session minting). - Api::route(): dispatch /sso/* to SsoEndpoint before the MVC pipeline. Design: docs/design/app-microsoft-sso.md. PHPStan clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- migrations/4.19.0_app_sso.sql | 87 ++++++++ src/Core/Api.php | 8 + src/Core/Auth/SsoEndpoint.php | 252 ++++++++++++++++++++++ src/Mvc/Model/KyteAppIdentityProvider.php | 154 +++++++++++++ src/Mvc/Model/KyteSsoCode.php | 61 ++++++ src/Mvc/Model/KyteSsoState.php | 75 +++++++ 6 files changed, 637 insertions(+) create mode 100644 migrations/4.19.0_app_sso.sql create mode 100644 src/Core/Auth/SsoEndpoint.php create mode 100644 src/Mvc/Model/KyteAppIdentityProvider.php create mode 100644 src/Mvc/Model/KyteSsoCode.php create mode 100644 src/Mvc/Model/KyteSsoState.php diff --git a/migrations/4.19.0_app_sso.sql b/migrations/4.19.0_app_sso.sql new file mode 100644 index 00000000..319a98f0 --- /dev/null +++ b/migrations/4.19.0_app_sso.sql @@ -0,0 +1,87 @@ +-- ========================================================================= +-- 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 +-- KMS-encrypted client_secret; never via MCP). +-- KyteSsoState - short-lived in-flight state/nonce/PKCE per login. +-- KyteSsoCode - single-use hand-off code delivering the session to +-- the app front-end (no tokens at rest). +-- +-- 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 'KMS-encrypted (base64 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, + `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; diff --git a/src/Core/Api.php b/src/Core/Api.php index 9de36126..4e6b3a53 100644 --- a/src/Core/Api.php +++ b/src/Core/Api.php @@ -701,6 +701,14 @@ public function route() { 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/SsoEndpoint.php b/src/Core/Auth/SsoEndpoint.php new file mode 100644 index 00000000..68527314 --- /dev/null +++ b/src/Core/Auth/SsoEndpoint.php @@ -0,0 +1,252 @@ + + * 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': // [P1 next slice — #561] + return self::notImplemented('callback'); + case 'exchange': // [P1 next slice — #561] + return self::notImplemented('exchange'); + 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.'); + } + + $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. + $state = self::randToken(32); + $nonce = self::randToken(32); + $verifier = self::randToken(64); + $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, + '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', + ]); + + return ['status' => 302, 'headers' => ['Location: ' . $authUrl, '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; + } + + 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; + } + + private static function notImplemented(string $what): array + { + return self::error(501, 'not_implemented', "SSO /{$what} is not implemented yet (KYTE-#560 P1 next slice)."); + } + + /** @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/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/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/KyteSsoState.php b/src/Mvc/Model/KyteSsoState.php new file mode 100644 index 00000000..e90f8541 --- /dev/null +++ b/src/Mvc/Model/KyteSsoState.php @@ -0,0 +1,75 @@ + '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], + + '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], + ], +]; From 58343562f518866ed70d8d10faa4f02187c99214 Mon Sep 17 00:00:00 2001 From: Kenneth Hough Date: Sun, 2 Aug 2026 02:21:24 -0500 Subject: [PATCH 15/30] =?UTF-8?q?feat(560):=20app=20SSO=20P1=20callback=20?= =?UTF-8?q?=E2=80=94=20token=20exchange=20+=20id=5Ftoken=20validation=20+?= =?UTF-8?q?=20session?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the P1 backend (KYTE-#560). /sso/callback + /sso/exchange: - /sso/callback: single-use-consume the KyteSsoState (state/nonce/PKCE), exchange the code at the provider token endpoint (client_secret decrypted server-side), validate the id_token (Microsoft JWKS via firebase JWK::parseKeySet — signature + exp/nbf, then aud=client_id, nonce, concrete-issuer, and tid tenant scoping), map the email claim to the app user (JIT-create unless restrict_to_existing), mint a single-use KyteSsoCode (no tokens at rest), redirect to the app's return_url?sso_code=… - /sso/exchange: redeem the sso_code → mint the Kyte JWT session via the shared JwtEndpoint::issueSession (+ resolveAuthContext for the app's user_model/DB context). Same session shape /jwt/login returns. - client_secret at rest: libsodium secretbox (SsoEndpoint::encrypt/decryptSecret) with an install key (KYTE_SSO_SECRET_KEY, else derived from KYTE_JWT_SECRET) — replaces the earlier KMS plan (simpler, no per-account key provisioning; behind one helper so KMS can swap in later). - JwtEndpoint: resolveAuthContext + new issueSession made public for reuse. SECURITY (for the P1 /security-review before exposure): return_url must be validated against the app's own sites (open-redirect / code-interception). PHPStan clean. Needs a real Azure app to validate e2e. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Core/Auth/JwtEndpoint.php | 41 +++- src/Core/Auth/SsoEndpoint.php | 367 +++++++++++++++++++++++++++++++++- 2 files changed, 403 insertions(+), 5 deletions(-) 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/SsoEndpoint.php b/src/Core/Auth/SsoEndpoint.php index 68527314..94b2f4de 100644 --- a/src/Core/Auth/SsoEndpoint.php +++ b/src/Core/Auth/SsoEndpoint.php @@ -69,10 +69,10 @@ public static function process(Api $api, array $server, string $rawBody): array switch ($action) { case 'authorize': return self::authorize(self::queryParams($server)); - case 'callback': // [P1 next slice — #561] - return self::notImplemented('callback'); - case 'exchange': // [P1 next slice — #561] - return self::notImplemented('exchange'); + 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}."); } @@ -198,6 +198,365 @@ private static function httpGetJson(string $url): ?array 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.'); + } + $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. + $emailClaim = (string)($cfg->user_email_claim ?: 'email'); + $email = $claims[$emailClaim] ?? ($claims['email'] ?? ($claims['preferred_username'] ?? null)); + if (!$email || !is_string($email)) { + return self::backToApp($returnUrl, ['error' => 'no_email_claim']); + } + $user = self::findOrCreateUser($app, $email, (int)$cfg->jit_enabled === 1, (int)$cfg->restrict_to_existing === 1); + if ($user === null) { + return self::backToApp($returnUrl, ['error' => 'user_not_provisioned']); + } + + // 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, + ]); + + 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); + $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; + } + $keys = \Firebase\JWT\JWK::parseKeySet($jwks); + // 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; + } + + /** + * Find the app user by the mapped email, or JIT-create it (unless + * restrict_to_existing / jit disabled). Runs in the app's user_model + DB + * context (resolveAuthContext sets that up). + */ + private static function findOrCreateUser(ModelObject $app, string $email, bool $jit, bool $restrict): ?ModelObject + { + $ctx = JwtEndpoint::resolveAuthContext((string)$app->identifier); + $userModel = $ctx['user_model']; + $usernameField = (string)$ctx['username_field']; + $passwordField = (string)($ctx['password_field'] ?? ''); + + $user = new ModelObject($userModel); + if ($user->retrieve($usernameField, $email)) { + return $user; + } + if ($restrict || !$jit) { + return null; + } + + // JIT provision. SSO users never password-login, but the model may + // require a password column — set a random (unusable) hash. + $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 $newUser; + } catch (\Throwable $e) { + error_log('SsoEndpoint JIT user create failed: ' . $e->getMessage()); + return null; + } + } + + /** + * 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 + */ + 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'] ?? ''); + } + + /** + * 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'; From a7e0e1b083798c1e86a63c5ad9059d490bde6666 Mon Sep 17 00:00:00 2001 From: Kenneth Hough Date: Sun, 2 Aug 2026 02:34:25 -0500 Subject: [PATCH 16/30] fix(560): pass RS256 default to JWK::parseKeySet (Azure JWKS omits per-key alg) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Microsoft's OIDC JWKS keys don't carry an alg member, which makes firebase/php-jwt JWK::parseKeySet throw 'JWK must contain an alg parameter' — surfacing as id_token_invalid on every real Microsoft login. Supply RS256 as the default algorithm (all MS v2.0 id_tokens are RS256; JWT::decode still enforces the token header alg on verify). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Core/Auth/SsoEndpoint.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/Core/Auth/SsoEndpoint.php b/src/Core/Auth/SsoEndpoint.php index 94b2f4de..2a41bbf5 100644 --- a/src/Core/Auth/SsoEndpoint.php +++ b/src/Core/Auth/SsoEndpoint.php @@ -366,7 +366,10 @@ private static function validateIdToken(string $idToken, array $disc, ModelObjec if ($jwks === null || empty($jwks['keys'])) { return null; } - $keys = \Firebase\JWT\JWK::parseKeySet($jwks); + // 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) { From 56a3c8b246090b575827dc24daa034b0109c7822 Mon Sep 17 00:00:00 2001 From: Kenneth Hough Date: Sun, 2 Aug 2026 02:42:30 -0500 Subject: [PATCH 17/30] fix(560): validate SSO return_url against the app's own site domains MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the open-redirect / sso_code-interception gap flagged for the P1 security review. backToApp previously 302'd to any ?redirect= host with the single-use code appended. - isAllowedReturnUrl(): empty return_url ok (code returned as JSON); otherwise requires absolute https (http only on loopback) whose host is one of the app's own domains — KyteSite cfDomain/aliasDomain + custom Domain.domainName for the app's sites. - Enforced at /authorize (fail fast, before redirecting to the IdP) and again as defense-in-depth before the code-carrying redirect in /callback (falls back to returning the code as JSON rather than leaking it off-app). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Core/Auth/SsoEndpoint.php | 71 +++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/src/Core/Auth/SsoEndpoint.php b/src/Core/Auth/SsoEndpoint.php index 2a41bbf5..a5fbdc02 100644 --- a/src/Core/Auth/SsoEndpoint.php +++ b/src/Core/Auth/SsoEndpoint.php @@ -2,6 +2,7 @@ namespace Kyte\Core\Auth; use Kyte\Core\Api; +use Kyte\Core\Model; use Kyte\Core\ModelObject; /** @@ -104,6 +105,13 @@ private static function authorize(array $params): array 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], @@ -305,6 +313,13 @@ private static function callback(array $params): array '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]); } @@ -460,6 +475,62 @@ private static function findOrCreateUser(ModelObject $app, string $email, bool $ * * @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 === '') { From ee43be804c9aedaa8daf30d8bb044c84180ca8f8 Mon Sep 17 00:00:00 2001 From: Kenneth Hough Date: Sun, 2 Aug 2026 03:04:08 -0500 Subject: [PATCH 18/30] =?UTF-8?q?fix(560):=20harden=20SSO=20identity=20?= =?UTF-8?q?=E2=80=94=20subject=20binding,=20verified-email=20trust,=20brow?= =?UTF-8?q?ser-bound=20state,=20no=20KyteUser=20fallback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Security review remediation (findings 1-3) before client exposure. Finding 1 (HIGH, account takeover) — identity was keyed on the mutable/ unverified email/preferred_username claim with no sub binding. - New KyteSsoIdentity link table: (application, provider, subject) -> app user. Returning users resolve by the immutable id_token ; a token bearing another user's email can no longer reach that account. - Drop the preferred_username fallback entirely. - First-login email linking to a PRE-EXISTING app user is allowed only when the asserting tenant is authoritative (pinned single-tenant config whose tid matches). In multi-tenant/'common' any tenant can assert any email, so email never matches an existing account there — JIT-create a fresh subject-bound user instead (or deny under restrict_to_existing). Finding 2 (login CSRF) — the OAuth state was not bound to the initiating browser. /authorize now sets an HttpOnly, Secure, SameSite=Lax correlator cookie and stores its sha256 on KyteSsoState.browser_hash; /callback requires the cookie to match before consuming the state. Finding 3 (privilege scope) — SSO could provision/match into the platform KyteUser table when an app has no user_model. resolveSsoUser + /exchange now refuse the KyteUser fallback (sso_requires_user_model); JIT create/lookup is scoped to the app's kyte_account. Models: KyteSsoIdentity (new), KyteSsoState.browser_hash. Migration adds the table + an idempotent ALTER for existing installs; corrects the stale 'KMS-encrypted' comments to libsodium. Co-Authored-By: Claude Opus 4.8 (1M context) --- migrations/4.19.0_app_sso.sql | 50 ++++++++- src/Core/Auth/SsoEndpoint.php | 170 ++++++++++++++++++++++++++---- src/Mvc/Model/KyteSsoIdentity.php | 64 +++++++++++ src/Mvc/Model/KyteSsoState.php | 6 ++ 4 files changed, 264 insertions(+), 26 deletions(-) create mode 100644 src/Mvc/Model/KyteSsoIdentity.php diff --git a/migrations/4.19.0_app_sso.sql b/migrations/4.19.0_app_sso.sql index 319a98f0..43ab07ad 100644 --- a/migrations/4.19.0_app_sso.sql +++ b/migrations/4.19.0_app_sso.sql @@ -8,10 +8,13 @@ -- JWT session for the app's user_model. -- -- KyteAppIdentityProvider - per-app OIDC config (Shipyard-managed; carries a --- KMS-encrypted client_secret; never via MCP). --- KyteSsoState - short-lived in-flight state/nonce/PKCE per login. +-- 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+. @@ -26,7 +29,7 @@ CREATE TABLE IF NOT EXISTS `KyteAppIdentityProvider` ( `discovery_url` VARCHAR(512) DEFAULT NULL, `tenant` VARCHAR(128) DEFAULT NULL, `client_id` VARCHAR(255) DEFAULT NULL, - `client_secret` TEXT DEFAULT NULL COMMENT 'KMS-encrypted (base64 ciphertext)', + `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', @@ -53,6 +56,7 @@ CREATE TABLE IF NOT EXISTS `KyteSsoState` ( `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, @@ -85,3 +89,43 @@ CREATE TABLE IF NOT EXISTS `KyteSsoCode` ( 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/Auth/SsoEndpoint.php b/src/Core/Auth/SsoEndpoint.php index a5fbdc02..69b9ff59 100644 --- a/src/Core/Auth/SsoEndpoint.php +++ b/src/Core/Auth/SsoEndpoint.php @@ -130,10 +130,11 @@ private static function authorize(array $params): array return self::error(502, 'discovery_failed', 'Could not load the SSO provider configuration.'); } - // state + nonce + PKCE. + // 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'); @@ -143,6 +144,7 @@ private static function authorize(array $params): array 'state' => $state, 'nonce' => $nonce, 'code_verifier' => $verifier, + 'browser_hash' => hash('sha256', $browser), 'application' => (int)$app->id, 'provider' => $providerName, 'return_url' => $returnUrl, @@ -165,7 +167,17 @@ private static function authorize(array $params): array 'code_challenge_method' => 'S256', ]); - return ['status' => 302, 'headers' => ['Location: ' . $authUrl, 'Cache-Control: no-store']]; + // 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', + ]]; } /** @@ -236,6 +248,17 @@ private static function callback(array $params): array 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 ?? ''); @@ -290,15 +313,26 @@ private static function callback(array $params): array return self::backToApp($returnUrl, ['error' => 'id_token_invalid']); } - // Map to an app user. - $emailClaim = (string)($cfg->user_email_claim ?: 'email'); - $email = $claims[$emailClaim] ?? ($claims['email'] ?? ($claims['preferred_username'] ?? null)); - if (!$email || !is_string($email)) { - return self::backToApp($returnUrl, ['error' => 'no_email_claim']); + // 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']); } - $user = self::findOrCreateUser($app, $email, (int)$cfg->jit_enabled === 1, (int)$cfg->restrict_to_existing === 1); - if ($user === null) { - return self::backToApp($returnUrl, ['error' => 'user_not_provisioned']); + $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 + ); + if (is_string($user)) { + return self::backToApp($returnUrl, ['error' => $user]); } // Single-use hand-off code (no tokens at rest). @@ -353,6 +387,11 @@ private static function exchange(array $body, string $ip): array } // 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.'); @@ -425,28 +464,80 @@ private static function validateIdToken(string $idToken, array $disc, ModelObjec } /** - * Find the app user by the mapped email, or JIT-create it (unless - * restrict_to_existing / jit disabled). Runs in the app's user_model + DB - * context (resolveAuthContext sets that up). + * 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 findOrCreateUser(ModelObject $app, string $email, bool $jit, bool $restrict): ?ModelObject + private static function resolveSsoUser(ModelObject $app, ModelObject $cfg, string $provider, string $subject, string $tid, ?string $email, bool $jit, bool $restrict) { $ctx = JwtEndpoint::resolveAuthContext((string)$app->identifier); $userModel = $ctx['user_model']; $usernameField = (string)$ctx['username_field']; $passwordField = (string)($ctx['password_field'] ?? ''); - $user = new ModelObject($userModel); - if ($user->retrieve($usernameField, $email)) { - return $user; + // 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 (user deleted) — fall through to re-provision/deny. + } + + // 2. First login for this subject. + // Only link to a PRE-EXISTING app user by email when the asserting + // tenant is authoritative for that email — i.e. a pinned single-tenant + // config whose tid matches. In a multi-tenant / 'common' config any + // tenant can assert any email, so email must never match an existing + // account (that is the takeover vector). + $tenantAuthoritative = !empty($cfg->tenant) && $tid !== '' && $tid === (string)$cfg->tenant; + + if ($email !== null && $tenantAuthoritative) { + $existing = new ModelObject($userModel); + if ($existing->retrieve($usernameField, $email)) { + self::createLink($app, $provider, $subject, $tid, (int)$existing->id, $email); + return $existing; + } } + if ($restrict || !$jit) { - return null; + return 'user_not_provisioned'; } - // JIT provision. SSO users never password-login, but the model may - // require a password column — set a random (unusable) hash. - $data = [$usernameField => $email]; + // 3. JIT-provision a fresh app user bound to this subject. SSO users + // never password-login, but the model may require a password column — + // set a random (unusable) hash. + $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); } @@ -456,12 +547,32 @@ private static function findOrCreateUser(ModelObject $app, string $email, bool $ try { $newUser = new ModelObject($userModel); if (!$newUser->create($data)) { - return null; + return 'user_not_provisioned'; } + self::createLink($app, $provider, $subject, $tid, (int)$newUser->id, $email); return $newUser; } catch (\Throwable $e) { error_log('SsoEndpoint JIT user create failed: ' . $e->getMessage()); - return null; + return 'user_not_provisioned'; + } + } + + /** 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()); } } @@ -590,6 +701,19 @@ 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 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 index e90f8541..ecb08d42 100644 --- a/src/Mvc/Model/KyteSsoState.php +++ b/src/Mvc/Model/KyteSsoState.php @@ -55,6 +55,12 @@ // 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, ], From 8e8579ca2f0e5d83a35bc83d00af0ef4ee73089f Mon Sep 17 00:00:00 2001 From: Kenneth Hough Date: Sun, 2 Aug 2026 03:13:05 -0500 Subject: [PATCH 19/30] =?UTF-8?q?fix(560):=20close=20SSO=20email-link=20re?= =?UTF-8?q?siduals=20=E2=80=94=20guest=20exclusion,=20no=20re-bind,=20dang?= =?UTF-8?q?ling-link=20rebind?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the verify pass on the identity hardening. The subject-binding closed the broad multi-tenant takeover; these close the narrow residuals it left in the first-login email-match branch: - Exclude B2B guests from email-linking: only link a pre-existing app user by email when the token is a native MEMBER of the pinned tenant (no `idp` claim / `acct` != 1). A guest's email is administered by their home tenant, which the resource tenant does not own — so guest email is no longer trusted to match an existing account (single-tenant first-login takeover vector). - Never attach a second SSO identity to an account already bound to another subject (userAlreadyLinked guard) — an already-provisioned user can't be re-bound/hijacked. - Dangling link (linked user was deleted): re-provision and REBIND the same link row instead of minting a fresh orphan on every login (the unique index would otherwise reject the second row and churn orphans). - Factored JIT create into jitCreateUser() (shared by the fresh + rebind paths). PHPStan/php -l clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Core/Auth/SsoEndpoint.php | 83 ++++++++++++++++++++++++++++------- 1 file changed, 67 insertions(+), 16 deletions(-) diff --git a/src/Core/Auth/SsoEndpoint.php b/src/Core/Auth/SsoEndpoint.php index 69b9ff59..c020aac2 100644 --- a/src/Core/Auth/SsoEndpoint.php +++ b/src/Core/Auth/SsoEndpoint.php @@ -329,7 +329,7 @@ private static function callback(array $params): array $user = self::resolveSsoUser( $app, $cfg, (string)$st->provider, $subject, $tid, $email, - (int)$cfg->jit_enabled === 1, (int)$cfg->restrict_to_existing === 1 + (int)$cfg->jit_enabled === 1, (int)$cfg->restrict_to_existing === 1, $claims ); if (is_string($user)) { return self::backToApp($returnUrl, ['error' => $user]); @@ -475,7 +475,7 @@ private static function validateIdToken(string $idToken, array $disc, ModelObjec * * @return ModelObject|string */ - private static function resolveSsoUser(ModelObject $app, ModelObject $cfg, string $provider, string $subject, string $tid, ?string $email, bool $jit, bool $restrict) + 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']; @@ -508,20 +508,44 @@ private static function resolveSsoUser(ModelObject $app, ModelObject $cfg, strin } return $user; } - // Dangling link (user deleted) — fall through to re-provision/deny. + // 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. - // Only link to a PRE-EXISTING app user by email when the asserting - // tenant is authoritative for that email — i.e. a pinned single-tenant - // config whose tid matches. In a multi-tenant / 'common' config any - // tenant can assert any email, so email must never match an existing - // account (that is the takeover vector). + // 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) { + 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; } @@ -531,9 +555,25 @@ private static function resolveSsoUser(ModelObject $app, ModelObject $cfg, strin return 'user_not_provisioned'; } - // 3. JIT-provision a fresh app user bound to this subject. SSO users - // never password-login, but the model may require a password column — - // set a random (unusable) hash. + // 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; @@ -547,16 +587,27 @@ private static function resolveSsoUser(ModelObject $app, ModelObject $cfg, strin try { $newUser = new ModelObject($userModel); if (!$newUser->create($data)) { - return 'user_not_provisioned'; + return null; } - self::createLink($app, $provider, $subject, $tid, (int)$newUser->id, $email); - return $newUser; + return (int)$newUser->id; } catch (\Throwable $e) { error_log('SsoEndpoint JIT user create failed: ' . $e->getMessage()); - return 'user_not_provisioned'; + 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 { From 65be96b11980c878f8ca280cf27b57dad9d4f95a Mon Sep 17 00:00:00 2001 From: Kenneth Hough Date: Sun, 2 Aug 2026 03:15:17 -0500 Subject: [PATCH 20/30] chore(560): remove now-dead notImplemented() helper (callback/exchange are implemented) Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Core/Auth/SsoEndpoint.php | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/Core/Auth/SsoEndpoint.php b/src/Core/Auth/SsoEndpoint.php index c020aac2..9830ca0f 100644 --- a/src/Core/Auth/SsoEndpoint.php +++ b/src/Core/Auth/SsoEndpoint.php @@ -827,11 +827,6 @@ public static function baseUrl(array $server): string return 'https://' . $host; } - private static function notImplemented(string $what): array - { - return self::error(501, 'not_implemented', "SSO /{$what} is not implemented yet (KYTE-#560 P1 next slice)."); - } - /** @param array $server @return array */ private static function queryParams(array $server): array { From 271b9ccafdb2ba7b4ddff3fff828cf68d6fe3936 Mon Sep 17 00:00:00 2001 From: Kenneth Hough Date: Sun, 2 Aug 2026 04:07:49 -0500 Subject: [PATCH 21/30] feat(mcp): add create_function tool (hooks / method overrides / custom) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit QA surfaced that MCP could edit existing functions (write_function_code) but not CREATE one. create_function adds a function to a controller via the FunctionController (generates the type's stub + initial version), then the caller adds behaviour with write_function_code and publishes with commit_draft. - schema scope; account-scoped (controller must belong to the token's account). - Validates function type against the FunctionController template set; hooks/ overrides are unique-per-controller (FunctionController enforces), custom allows multiple. - Binds a representative account user to $api->user for the internal call: FunctionController's initial-version write attributes created_by to $api->user, which MCP tokens don't populate (they set account only) — without it the version write dereferences null. Restored in a finally. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Mcp/Tools/ControllerTools.php | 80 +++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/src/Mcp/Tools/ControllerTools.php b/src/Mcp/Tools/ControllerTools.php index 839c7c43..decd166a 100644 --- a/src/Mcp/Tools/ControllerTools.php +++ b/src/Mcp/Tools/ControllerTools.php @@ -351,6 +351,86 @@ public function deleteController(int $controller_id): array 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.', + ]; + } + private function dataModelBelongsToApp(int $modelId, int $applicationId, int $accountId): bool { $m = new \Kyte\Core\ModelObject(\DataModel); From e160d891628b1eabd9ea06538a5d20ae908d30b1 Mon Sep 17 00:00:00 2001 From: Kenneth Hough Date: Sun, 2 Aug 2026 04:11:53 -0500 Subject: [PATCH 22/30] fix(functions): store 'initial' baseline version as a valid version_type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FunctionController creates a baseline version on function-create with version_type='initial', but the KyteFunctionVersion.version_type column is an enum(auto_save|manual_save|publish|mcp_draft|mcp_commit) — 'initial' is not a member, so the insert fails 'Data truncated for column version_type', silently breaking initial-version creation for BOTH Shipyard and MCP function creation. 'initial' is still used as the sentinel that forces the first version despite no diff; only the persisted value changes (-> manual_save). Surfaced by the new create_function MCP tool. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Mvc/Controller/FunctionController.php | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/Mvc/Controller/FunctionController.php b/src/Mvc/Controller/FunctionController.php index c5202b26..25dc9eb5 100644 --- a/src/Mvc/Controller/FunctionController.php +++ b/src/Mvc/Controller/FunctionController.php @@ -239,11 +239,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, From ad4a0305ec5589e02a99d76f0f1ccf4628f93509 Mon Sep 17 00:00:00 2001 From: Kenneth Hough Date: Sun, 2 Aug 2026 04:20:20 -0500 Subject: [PATCH 23/30] =?UTF-8?q?feat(mcp):=20add=20create=5Fpage=20tool?= =?UTF-8?q?=20(QA=20blocker=20=E2=80=94=20UI=20phase)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit create_page makes a page on a site as an empty draft (no AWS/S3 at create); the caller adds HTML/CSS/JS with write_page_part and publishes with commit_draft. Mirrors create_controller/create_function: internal KytePageController, schema scope, account-scoped, and binds a representative account user for the page-data/initial-version writes (MCP tokens set account but not $api->user). Surfaced by QA: MCP could edit existing pages (write_page_part) but not create one — blocking the UI phase of an A→Z app build. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Mcp/Tools/PageTools.php | 61 +++++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/src/Mcp/Tools/PageTools.php b/src/Mcp/Tools/PageTools.php index d093fed9..04043de9 100644 --- a/src/Mcp/Tools/PageTools.php +++ b/src/Mcp/Tools/PageTools.php @@ -222,6 +222,67 @@ 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.', + ]; + } + private function accountIdOrZero(): int { return isset($this->api->account->id) ? (int)$this->api->account->id : 0; From c900ea290e7d3d51177b8246d43bc913b2f5af55 Mon Sep 17 00:00:00 2001 From: Kenneth Hough Date: Sun, 2 Aug 2026 04:22:01 -0500 Subject: [PATCH 24/30] feat(mcp): add get_app_info tool (connection/endpoint discovery) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit QA hit this: nothing surfaced the API endpoint, so it had to be hand-typed into the client. get_app_info returns the API endpoint (what kyte-api-js is init'd with), the MCP endpoint, account number, and — with an application_id — the app identifier + a kyte_api_js_init hint + each site with its live URL(s) (cloudfront/alias/custom domains). Account-level (no app id) returns the endpoint + app list. read scope. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Mcp/Tools/AccountTools.php | 96 ++++++++++++++++++++++++++++++++++ 1 file changed, 96 insertions(+) diff --git a/src/Mcp/Tools/AccountTools.php b/src/Mcp/Tools/AccountTools.php index 4009b7cc..d4e40c11 100644 --- a/src/Mcp/Tools/AccountTools.php +++ b/src/Mcp/Tools/AccountTools.php @@ -56,4 +56,100 @@ 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; + } } From 43dee5d13f0c86f8512c6de921873b0c76f6bd16 Mon Sep 17 00:00:00 2001 From: Kenneth Hough Date: Sun, 2 Aug 2026 04:44:11 -0500 Subject: [PATCH 25/30] feat(mcp): script CRUD tools + KyteJS guidance (get_kytejs_guide + server instructions) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two QA-driven additions: Script tools (ScriptTools) — MCP could edit an existing script's content (write_script_content) but not list/read/create/delete. Adds list_scripts, read_script (read), create_script, delete_script (schema). create_script uses KyteScriptController internal + binds an account user (MCP tokens set account, not $api->user); same 'initial'->'manual_save' version_type enum fix as functions (KyteScriptVersion.version_type is the same restricted enum). KyteJS guidance — the AI generating page/script JS didn't know Kyte injects the API client as the global (k.get/k.post/k.put/k.delete(model,...)), so it wrote wrong code. MCP can carry guidance two ways, both added: (1) the server 'instructions' field (surfaced on connect — a lightweight always-on skill) now explains , get_app_info, and the create->write->commit flow; (2) a get_kytejs_guide tool (read) returns the full signatures + a worked example. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Mcp/Endpoint.php | 24 ++- src/Mcp/Tools/AccountTools.php | 73 +++++++ src/Mcp/Tools/ScriptTools.php | 210 ++++++++++++++++++++ src/Mvc/Controller/KyteScriptController.php | 11 +- 4 files changed, 312 insertions(+), 6 deletions(-) create mode 100644 src/Mcp/Tools/ScriptTools.php diff --git a/src/Mcp/Endpoint.php b/src/Mcp/Endpoint.php index 5dd54802..f294293a 100644 --- a/src/Mcp/Endpoint.php +++ b/src/Mcp/Endpoint.php @@ -116,10 +116,26 @@ 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" + . '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 d4e40c11..78a3908e 100644 --- a/src/Mcp/Tools/AccountTools.php +++ b/src/Mcp/Tools/AccountTools.php @@ -152,4 +152,77 @@ public function getAppInfo(?int $application_id = null): array $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. + * + * @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 instantiated as the ' + . 'GLOBAL variable `k` (a Kyte instance, from the kyte-api-js SDK loaded on the ' + . 'page). In page HTML/JS and in site scripts, use `k` directly for all backend ' + . 'calls. Do NOT create your own client (no `new Kyte(...)`), and do NOT hard-code ' + . 'the API URL, keys, or fetch()/REST paths — `k` is pre-configured with the ' + . "app's endpoint + credentials.", + 'data_model' => + 'Access is MODEL-based, not URL-based. The first argument to every call is a ' + . 'model/controller NAME (a string, e.g. "Task", "UserProfile") — the same name ' + . 'you gave create_model / create_controller. Kyte routes it to that controller.', + 'methods' => [ + 'get' => 'k.get(model, field, value, headers, onSuccess, onError) — READ. ' + . 'Pass field+value to filter (e.g. "id", 42), or field=null & value=null for all rows.', + 'post' => 'k.post(model, data, formData, headers, onSuccess, onError) — CREATE. ' + . '`data` is a plain object of column→value; pass formData=null unless uploading files.', + 'put' => 'k.put(model, field, value, data, formData, headers, onSuccess, onError) — ' + . 'UPDATE the row(s) matching field=value with the `data` object.', + 'delete' => 'k.delete(model, field, value, headers, onSuccess, onError) — DELETE the ' + . 'row(s) matching field=value.', + ], + 'callbacks' => + 'onSuccess(response): response.data is ALWAYS an array — a single record is ' + . 'response.data[0]. onError(error): error is a string message (or object). Both ' + . 'callbacks are required for robust code. `headers` is usually an empty array [].', + 'session' => [ + 'create' => 'k.sessionCreate(credentials, onSuccess, onError) — log a user in.', + 'destroy' => 'k.sessionDestroy(onSuccess, onError) — log out; then redirect.', + 'note' => 'Session state is managed by `k`; authenticated calls carry it automatically.', + ], + 'example' => implode("\n", [ + "// Read all Task rows for the current user", + "k.get('Task', null, null, [], function (response) {", + " const tasks = response.data; // always an array", + " tasks.forEach(t => renderTask(t));", + "}, function (err) {", + " console.error('load failed:', err);", + "});", + "", + "// Create a Task", + "k.post('Task', { title: 'Buy milk', quadrant: 'urgent_important', done: 0 }, null, [], function (response) {", + " const created = response.data[0]; // the new row", + " addTaskToDom(created);", + "}, function (err) { showError(err); });", + "", + "// Update a Task", + "k.put('Task', 'id', taskId, { done: 1 }, null, [], function (r) { /* ok */ }, function (e) {});", + "", + "// Delete a Task", + "k.delete('Task', 'id', taskId, [], function (r) { /* ok */ }, function (e) {});", + ]), + 'rules' => [ + 'Use the injected global `k` — never instantiate a client or hard-code the endpoint/keys.', + 'First arg is a model/controller NAME string, not a URL path.', + 'response.data is always an array (single record = response.data[0]).', + 'Attach both success and error callbacks.', + 'Get the endpoint/identifier/site URLs from get_app_info; you do not put them in JS yourself.', + ], + ]; + } } 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/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, From ec9f81cadbd926e75e9fb8dc09af493a529d2835 Mon Sep 17 00:00:00 2001 From: Kenneth Hough Date: Sun, 2 Aug 2026 19:33:32 -0500 Subject: [PATCH 26/30] feat(mcp): auto-generate kyte_connect on create_application + get_controller_guide MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two follow-ups from QA: 1. create_application now generates the app's kyte_connect snippet (the `var k = new Kyte(endpoint, pubKey, iden, acctNum, appId); k.init();` bootstrap injected into every published page). Shipyard builds this normally; an MCP-created app had an EMPTY one, leaving the global `k` undefined in published pages so all frontend JS failed at runtime. Deterministic from the account's API key + app identifier; no-op if already set / no key / no endpoint. 2. get_controller_guide (read) — the backend counterpart to get_kytejs_guide. Documents the controller hook + method-override signatures (which params are by-reference), the $this context ($this->user/account/response/model), the Model/ModelObject query API, error handling, and worked examples, so an AI writes function code that runs. Wired into the server instructions. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Mcp/Endpoint.php | 6 +++ src/Mcp/Tools/AppTools.php | 46 +++++++++++++++++ src/Mcp/Tools/ControllerTools.php | 85 +++++++++++++++++++++++++++++++ 3 files changed, 137 insertions(+) diff --git a/src/Mcp/Endpoint.php b/src/Mcp/Endpoint.php index f294293a..4aa39aec 100644 --- a/src/Mcp/Endpoint.php +++ b/src/Mcp/Endpoint.php @@ -134,6 +134,12 @@ public static function process(Api $api, ServerRequestInterface $request): Respo . '(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.' ) diff --git a/src/Mcp/Tools/AppTools.php b/src/Mcp/Tools/AppTools.php index 2e285e52..99dced9a 100644 --- a/src/Mcp/Tools/AppTools.php +++ b/src/Mcp/Tools/AppTools.php @@ -61,9 +61,55 @@ public function createApplication(string $name, ?string $language = null): array 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. * diff --git a/src/Mcp/Tools/ControllerTools.php b/src/Mcp/Tools/ControllerTools.php index decd166a..9f63db17 100644 --- a/src/Mcp/Tools/ControllerTools.php +++ b/src/Mcp/Tools/ControllerTools.php @@ -431,6 +431,91 @@ public function createFunction(int $controller_id, string $type, string $name, ? ]; } + /** + * 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. + * + * @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 the base ModelController and is bound to a data model. ' + . 'The base already implements default CRUD (new/update/get/delete) over the bound ' + . 'model — you only add what you need: HOOKS (fire around the default flow) or ' + . 'METHOD OVERRIDES (replace a default operation). Author each as a function ' + . '(create_function to add the slot, write_function_code to fill it, commit_draft ' + . 'to publish). Write ONLY the function body/signature shown — it is spliced into ' + . 'the generated controller class.', + 'context' => [ + '$this->user' => 'The authenticated user object, or null if unauthenticated. ALWAYS guard: if (!$this->user || !isset($this->user->id)) { throw new \\Exception("auth required"); }. For app endpoints this is the app user_model row.', + '$this->account' => 'The Kyte account (->id, ->number). Scope cross-model queries by it where relevant.', + '$this->response' => "The response envelope. For get/custom endpoints, set your payload with \$this->response['data'] = [...]; (an array/object). Default CRUD fills this for you.", + '$this->model' => 'The bound model definition constant. The base CRUD operates on it.', + '$this->api' => 'The Api instance (advanced use).', + ], + 'hooks' => [ + 'hook_init()' => 'Runs when the controller initialises. No params.', + 'hook_auth()' => 'Custom authentication gate. No params.', + 'hook_prequery($method, &$field, &$value, &$conditions, &$all, &$order)' => + 'Fires BEFORE the query. $field/$value/$conditions/$all/$order are BY-REFERENCE — ' + . 'mutate them to scope/filter. Classic use: force a row to the current user — ' + . "\$field='id'; \$value=\$this->user->id;. \$method is 'new'|'update'|'get'|'delete'.", + 'hook_preprocess($method, &$r, &$o = null)' => + 'Fires BEFORE a create/update write. $r is the incoming data (BY-REFERENCE — ' + . 'validate/transform/inject fields). $o is the existing row on update/delete. ' + . 'throw \\Exception to abort the write.', + 'hook_response_data($method, $o, &$r = null, &$d = null)' => + 'Fires AFTER the operation. $o is the affected row; $r is the response row ' + . '(BY-REFERENCE — augment/redact it); $d is the original request data.', + 'hook_process_get_response(&$r)' => + 'Shape the assembled GET response ($r is BY-REFERENCE).', + ], + 'method_overrides' => [ + 'new($data)' => 'Replace create. $data = the posted object. Set $this->response[\'data\'] with the result.', + '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). Return via \$this->response['data'] = [...].", + 'delete($field, $value)' => 'Replace delete of the row(s) where $field=$value.', + 'custom' => 'A custom function is any additional method — a custom endpoint / helper. Its name is the method name.', + ], + 'query_api' => [ + 'multi' => "\$m = new \\Kyte\\Core\\Model(ModelName); \$m->retrieve('field', \$value, \$isLike=false, \$conditions=[], \$all=false, \$order=[]); then \$m->objects (array) and \$m->count().", + 'single' => "\$o = new \\Kyte\\Core\\ModelObject(ModelName); \$o->retrieve('id', \$id); \$o->create([...]); \$o->save([...]); \$o->delete();", + 'conditions' => "\$conditions is an array of ['field'=>..., 'value'=>...] AND-clauses. ModelName is the model's bare CONSTANT (e.g. Task), not a string.", + ], + 'errors' => + 'Throw \\Exception with a user-facing message to fail a request — it is delivered to ' + . "the frontend's k.* error callback. Do not echo or return; use exceptions + " + . '$this->response.', + 'example_get_override' => implode("\n", [ + "public function get(\$field, \$value) {", + " if (!\$this->user || !isset(\$this->user->id)) { throw new \\Exception('auth required'); }", + " if (\$field !== 'subdomain') { throw new \\Exception('invalid field'); }", + " \$sub = strtolower(trim(\$value));", + " \$sites = new \\Kyte\\Core\\Model(Site);", + " \$sites->retrieve('subdomain', \$sub, false);", + " \$this->response['data'] = ['subdomain' => \$sub, 'available' => (\$sites->count() === 0)];", + "}", + ]), + 'example_hook_prequery' => implode("\n", [ + "public function hook_prequery(\$method, &\$field, &\$value, &\$conditions, &\$all, &\$order) {", + " switch (\$method) {", + " case 'update':", + " case 'get':", + " \$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); From 966c7ffcc6ced21b52acccc6b161a2efc5aab491 Mon Sep 17 00:00:00 2001 From: Kenneth Hough Date: Sun, 2 Aug 2026 19:44:37 -0500 Subject: [PATCH 27/30] =?UTF-8?q?feat(mcp):=20finish=20CRUD=20parity=20?= =?UTF-8?q?=E2=80=94=20read=5Fapplication,=20delete=5Ffunction,=20delete?= =?UTF-8?q?=5Fpage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - read_application (read): single app details by id (account-scoped). - delete_function (schema): remove one function via FunctionController (cleans versions + regenerates the controller code); delete_controller still cascades. - delete_page (schema): remove a page via KytePageController (page-data/versions/ assignments; + S3 file removal, sitemap rewrite, CloudFront invalidation for a published page). Both bind a representative account user for the internal controller (MCP tokens set account, not $api->user). Closes the audited CRUD-matrix gaps. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Mcp/Tools/AppTools.php | 17 ++++++++++++ src/Mcp/Tools/ControllerTools.php | 42 ++++++++++++++++++++++++++++++ src/Mcp/Tools/PageTools.php | 43 +++++++++++++++++++++++++++++++ 3 files changed, 102 insertions(+) diff --git a/src/Mcp/Tools/AppTools.php b/src/Mcp/Tools/AppTools.php index 99dced9a..352e6e61 100644 --- a/src/Mcp/Tools/AppTools.php +++ b/src/Mcp/Tools/AppTools.php @@ -199,6 +199,23 @@ public function deleteApplication(int $application_id): array ]; } + /** + * 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 { diff --git a/src/Mcp/Tools/ControllerTools.php b/src/Mcp/Tools/ControllerTools.php index 9f63db17..c321d3d0 100644 --- a/src/Mcp/Tools/ControllerTools.php +++ b/src/Mcp/Tools/ControllerTools.php @@ -431,6 +431,48 @@ public function createFunction(int $controller_id, string $type, string $name, ? ]; } + /** + * 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 diff --git a/src/Mcp/Tools/PageTools.php b/src/Mcp/Tools/PageTools.php index 04043de9..685092f1 100644 --- a/src/Mcp/Tools/PageTools.php +++ b/src/Mcp/Tools/PageTools.php @@ -283,6 +283,49 @@ public function createPage(int $site_id, string $title, string $path, ?string $d ]; } + /** + * 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; From 0ed5643a871b1ec84191e4ffa3399180cd53923e Mon Sep 17 00:00:00 2001 From: Kenneth Hough Date: Sun, 2 Aug 2026 19:53:48 -0500 Subject: [PATCH 28/30] fix(mcp): correct the authoring guides against the actual SDK + controller source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deep audit of get_kytejs_guide and get_controller_guide against kyte-api-js (kyte-source.js) and ModelController/FunctionController found several claims that would make an AI emit broken or cross-tenant-leaking code. Corrected both. get_kytejs_guide: - response.data is an array ONLY for default CRUD; a custom controller returns whatever it set (object/scalar). Success cb gets the WHOLE response object. - k.sessionDestroy takes ONE completion callback (runs on success OR failure), not (onSuccess, onError) — redirect goes there. - onError may receive a string OR an object OR not fire at all; 403 auto-runs session-destroy + redirect. - formData is a pre-serialized URL-encoded STRING, not a browser FormData. - headers is a required positional [] before the callbacks. k is pre-init()'ed. get_controller_guide: - hook_prequery fires for get + update ONLY (not new/delete). - hook_response_data's 3rd arg for delete is the $autodelete BOOLEAN (fires BEFORE delete; set false to veto) — not a response row. - overriding a CRUD method REPLACES the base, dropping automatic kyte_account scoping/auth/FK — must call parent:: or re-implement (tenant-leak warning). - custom functions are NOT API-routed; only POST/PUT/GET/DELETE dispatch. - ModelObject::retrieve 3rd arg is $conditions (NOT $isLike like Model::retrieve); Model::retrieve has a 7th $limit; ModelObject::delete is a soft delete (purge() hard-deletes); $this->user is an empty object not null (guard isset->id); $this->model is the definition array; response['data'] default is a list. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Mcp/Tools/AccountTools.php | 103 ++++++++++++++++----------- src/Mcp/Tools/ControllerTools.php | 112 ++++++++++++++++++++---------- 2 files changed, 140 insertions(+), 75 deletions(-) diff --git a/src/Mcp/Tools/AccountTools.php b/src/Mcp/Tools/AccountTools.php index 78a3908e..fe983d58 100644 --- a/src/Mcp/Tools/AccountTools.php +++ b/src/Mcp/Tools/AccountTools.php @@ -166,62 +166,87 @@ public function getKytejsGuide(): array { return [ 'overview' => - 'Kyte publishes each page with an API client already instantiated as the ' - . 'GLOBAL variable `k` (a Kyte instance, from the kyte-api-js SDK loaded on the ' - . 'page). In page HTML/JS and in site scripts, use `k` directly for all backend ' - . 'calls. Do NOT create your own client (no `new Kyte(...)`), and do NOT hard-code ' - . 'the API URL, keys, or fetch()/REST paths — `k` is pre-configured with the ' - . "app's endpoint + credentials.", + '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 (a string, e.g. "Task", "UserProfile") — the same name ' - . 'you gave create_model / create_controller. Kyte routes it to that controller.', + . '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. ' - . 'Pass field+value to filter (e.g. "id", 42), or field=null & value=null for all rows.', - 'post' => 'k.post(model, data, formData, headers, onSuccess, onError) — CREATE. ' - . '`data` is a plain object of column→value; pass formData=null unless uploading files.', + '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 the `data` object.', + . '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.', ], - 'callbacks' => - 'onSuccess(response): response.data is ALWAYS an array — a single record is ' - . 'response.data[0]. onError(error): error is a string message (or object). Both ' - . 'callbacks are required for robust code. `headers` is usually an empty array [].', + '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(credentials, onSuccess, onError) — log a user in.', - 'destroy' => 'k.sessionDestroy(onSuccess, onError) — log out; then redirect.', - 'note' => 'Session state is managed by `k`; authenticated calls carry it automatically.', + '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 all Task rows for the current user", + "// READ (default CRUD) — response.data is an ARRAY of rows", "k.get('Task', null, null, [], function (response) {", - " const tasks = response.data; // always an array", - " tasks.forEach(t => renderTask(t));", - "}, function (err) {", - " console.error('load failed:', err);", - "});", + " response.data.forEach(function (t) { renderTask(t); });", + "}, function (err) { console.error(err); });", "", - "// Create a Task", + "// CREATE — default CRUD returns a one-element array", "k.post('Task', { title: 'Buy milk', quadrant: 'urgent_important', done: 0 }, null, [], function (response) {", - " const created = response.data[0]; // the new row", - " addTaskToDom(created);", - "}, function (err) { showError(err); });", + " var created = response.data[0];", + "}, function (err) {});", "", - "// Update a Task", - "k.put('Task', 'id', taskId, { done: 1 }, null, [], function (r) { /* ok */ }, function (e) {});", + "// 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) {});", "", - "// Delete a Task", - "k.delete('Task', 'id', taskId, [], function (r) { /* ok */ }, 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` — never instantiate a client or hard-code the endpoint/keys.', - 'First arg is a model/controller NAME string, not a URL path.', - 'response.data is always an array (single record = response.data[0]).', - 'Attach both success and error callbacks.', - 'Get the endpoint/identifier/site URLs from get_app_info; you do not put them in JS yourself.', + '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/ControllerTools.php b/src/Mcp/Tools/ControllerTools.php index c321d3d0..6b1a3aa6 100644 --- a/src/Mcp/Tools/ControllerTools.php +++ b/src/Mcp/Tools/ControllerTools.php @@ -487,69 +487,109 @@ public function getControllerGuide(): array { return [ 'overview' => - 'A Kyte controller extends the base ModelController and is bound to a data model. ' - . 'The base already implements default CRUD (new/update/get/delete) over the bound ' - . 'model — you only add what you need: HOOKS (fire around the default flow) or ' - . 'METHOD OVERRIDES (replace a default operation). Author each as a function ' - . '(create_function to add the slot, write_function_code to fill it, commit_draft ' - . 'to publish). Write ONLY the function body/signature shown — it is spliced into ' - . 'the generated controller class.', + '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' => 'The authenticated user object, or null if unauthenticated. ALWAYS guard: if (!$this->user || !isset($this->user->id)) { throw new \\Exception("auth required"); }. For app endpoints this is the app user_model row.', - '$this->account' => 'The Kyte account (->id, ->number). Scope cross-model queries by it where relevant.', - '$this->response' => "The response envelope. For get/custom endpoints, set your payload with \$this->response['data'] = [...]; (an array/object). Default CRUD fills this for you.", - '$this->model' => 'The bound model definition constant. The base CRUD operates on it.', - '$this->api' => 'The Api instance (advanced use).', + '$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 when the controller initialises. No params.', - 'hook_auth()' => 'Custom authentication gate. No params.', + '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 BEFORE the query. $field/$value/$conditions/$all/$order are BY-REFERENCE — ' - . 'mutate them to scope/filter. Classic use: force a row to the current user — ' - . "\$field='id'; \$value=\$this->user->id;. \$method is 'new'|'update'|'get'|'delete'.", + '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 a create/update write. $r is the incoming data (BY-REFERENCE — ' - . 'validate/transform/inject fields). $o is the existing row on update/delete. ' - . 'throw \\Exception to abort the write.', + '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)' => - 'Fires AFTER the operation. $o is the affected row; $r is the response row ' - . '(BY-REFERENCE — augment/redact it); $d is the original request data.', + '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)' => - 'Shape the assembled GET response ($r is BY-REFERENCE).', + 'Fires once at the end of get() with the assembled list ($r BY-REFERENCE) — final shaping of the GET response.', ], 'method_overrides' => [ - 'new($data)' => 'Replace create. $data = the posted object. Set $this->response[\'data\'] with the result.', + '_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). Return via \$this->response['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 any additional method — a custom endpoint / helper. Its name is the method name.', + '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' => [ - 'multi' => "\$m = new \\Kyte\\Core\\Model(ModelName); \$m->retrieve('field', \$value, \$isLike=false, \$conditions=[], \$all=false, \$order=[]); then \$m->objects (array) and \$m->count().", - 'single' => "\$o = new \\Kyte\\Core\\ModelObject(ModelName); \$o->retrieve('id', \$id); \$o->create([...]); \$o->save([...]); \$o->delete();", - 'conditions' => "\$conditions is an array of ['field'=>..., 'value'=>...] AND-clauses. ModelName is the model's bare CONSTANT (e.g. Task), not a string.", + '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 — it is delivered to ' - . "the frontend's k.* error callback. Do not echo or return; use exceptions + " - . '$this->response.', + '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 (!\$this->user || !isset(\$this->user->id)) { throw new \\Exception('auth required'); }", + " 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);", - " \$sites->retrieve('subdomain', \$sub, false);", + " // 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 'update':", " case 'get':", - " \$field = 'id'; // scope every read/update to the caller", + " case 'update':", + " \$field = 'id'; // scope every read/update to the caller", " \$value = (int)\$this->user->id;", " break;", " }", From 2253aa8669843f0b3f89ec3f59c7b041ac85e9b9 Mon Sep 17 00:00:00 2001 From: Kenneth Hough Date: Sun, 2 Aug 2026 19:57:42 -0500 Subject: [PATCH 29/30] fix(controller): init $order before hook_prequery in update(); anti-drift notes - ModelController::update() passed $order to hook_prequery uninitialized (an undefined-variable-by-ref, unlike get() which sets $order=null). Initialise it. - Anti-drift: the get_kytejs_guide / get_controller_guide tools are the MCP "knowledge base" for AI code generation. Added KEEP-IN-SYNC notes on both guide methods AND pointers at the sources of truth (FunctionController::FUNCTION_TYPES, ModelController hook declarations) so a material change to the SDK or the hook/query contract updates the guide in the same change and doesn't drift. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Mcp/Tools/AccountTools.php | 7 +++++++ src/Mcp/Tools/ControllerTools.php | 8 ++++++++ src/Mvc/Controller/FunctionController.php | 4 ++++ src/Mvc/Controller/ModelController.php | 8 +++++++- 4 files changed, 26 insertions(+), 1 deletion(-) diff --git a/src/Mcp/Tools/AccountTools.php b/src/Mcp/Tools/AccountTools.php index fe983d58..4e9bcfcf 100644 --- a/src/Mcp/Tools/AccountTools.php +++ b/src/Mcp/Tools/AccountTools.php @@ -158,6 +158,13 @@ public function getAppInfo(?int $application_id = null): array * 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.')] diff --git a/src/Mcp/Tools/ControllerTools.php b/src/Mcp/Tools/ControllerTools.php index 6b1a3aa6..96270e59 100644 --- a/src/Mcp/Tools/ControllerTools.php +++ b/src/Mcp/Tools/ControllerTools.php @@ -479,6 +479,14 @@ public function deleteFunction(int $function_id): array * 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.')] diff --git a/src/Mvc/Controller/FunctionController.php b/src/Mvc/Controller/FunctionController.php index 25dc9eb5..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' => [ 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) {} From 9c67bfdceb68799ec71bf5c917c9ca51a557701e Mon Sep 17 00:00:00 2001 From: Kenneth Hough Date: Sun, 2 Aug 2026 22:41:01 -0500 Subject: [PATCH 30/30] =?UTF-8?q?feat(mcp):=20expose=20auth=20config=20?= =?UTF-8?q?=E2=80=94=20attribute=20password/protected/sensitive=20flags=20?= =?UTF-8?q?+=20configure=5Fapp=5Flogin?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit QA (A→Z app build) hit a wall: an AI-built User model couldn't do login because two server-side settings weren't reachable from the MCP. - add_attribute / update_attribute now accept password / protected / sensitive flags (DataModelController maps password->auto-hash, protected->blank in API output, sensitive->log redaction). Descriptions tell the AI to store the PLAINTEXT password in signup and let Kyte hash it (avoid double-hash), and to set protected so the hash never leaves the server. Surfaced in the summary. - configure_app_login (provision): sets Application.user_model / username_colname / password_colname — the actual reason app login rejected valid credentials (SessionController falls back to the platform user table when these are unset). Validates the named user model exists in the app. - read_application now surfaces the login config (user_model/username/password). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Mcp/Tools/AppTools.php | 66 ++++++++++++++++++++++++++++++++++++ src/Mcp/Tools/ModelTools.php | 26 ++++++++++---- 2 files changed, 85 insertions(+), 7 deletions(-) diff --git a/src/Mcp/Tools/AppTools.php b/src/Mcp/Tools/AppTools.php index 352e6e61..c7eab0fd 100644 --- a/src/Mcp/Tools/AppTools.php +++ b/src/Mcp/Tools/AppTools.php @@ -199,6 +199,67 @@ public function deleteApplication(int $application_id): array ]; } + /** + * 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). * @@ -229,6 +290,11 @@ private function appToArray(int $appId): ?array '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, ]; } 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, ]; }