From 37ed529534e3d18d47ed7f1c228a48d6139323e7 Mon Sep 17 00:00:00 2001 From: Kenneth Hough Date: Thu, 6 Aug 2026 03:56:15 -0500 Subject: [PATCH 1/2] Add MCP anonymous-access tools (allow_public get/set) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - read_application/appToArray now expose auth_mode and allow_public (anonymous-access level) so the MCP client can see an app's access config. - New set_app_anonymous_access tool (provision scope): sets allow_public 0/1/2 (none / read-only / controller-governed). Level 2 is what public signup needs. Validates the level and re-scopes application_id to the token's account (a token cannot open another account's app). - Tests: McpAppToolsTest (default level, set levels, invalid-level + foreign-app rejection, read exposure); registered in phpunit.xml.dist. Motivated by QA: membership apps need anonymous public signup, but MCP had no way to read or set allow_public — the AI was blind and punted to Shipyard. Auth error strings intentionally left unchanged (fingerprinting concern). Co-Authored-By: Claude Opus 4.8 --- phpunit.xml.dist | 1 + src/Mcp/Tools/AppTools.php | 62 +++++++++++++++++- tests/McpAppToolsTest.php | 128 +++++++++++++++++++++++++++++++++++++ 3 files changed, 189 insertions(+), 2 deletions(-) create mode 100644 tests/McpAppToolsTest.php diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 350eaa56..67392ac5 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -16,6 +16,7 @@ tests/HmacSessionStrategyTest.php tests/JwtEndpointTest.php tests/JwtSessionStrategyTest.php + tests/McpAppToolsTest.php tests/McpControllerToolsTest.php tests/McpEndpointTest.php tests/McpModelToolsTest.php diff --git a/src/Mcp/Tools/AppTools.php b/src/Mcp/Tools/AppTools.php index c7eab0fd..2803ed49 100644 --- a/src/Mcp/Tools/AppTools.php +++ b/src/Mcp/Tools/AppTools.php @@ -261,12 +261,64 @@ public function configureAppLogin(int $application_id, string $user_model, strin } /** - * Read a single application's details (name, identifier, language, status). + * Set an application's anonymous (public, unauthenticated) access level. + * + * allow_public is a tri-state gate applied BEFORE controller auth: + * 0 = none (default): every request must authenticate (login/session or HMAC). + * 1 = read-only: unauthenticated callers may GET, regardless of a + * controller's allowableActions. Writes still require auth. + * 2 = controller-governed: unauthenticated callers may also write IF the + * target controller sets $this->requireAuth = false and permits the + * action. This is what PUBLIC SIGNUP needs — an anonymous visitor + * creating their own account before they can log in. + * + * Security: levels 1 and 2 expose data/behavior to unauthenticated callers. + * Use the narrowest level that works; pair level 2 with a signup controller + * that only permits the create it needs. + * + * @param int $application_id Application id (from list_applications). + * @param int $level 0 = none, 1 = read-only, 2 = controller-governed. + * @return array{updated: bool, application_id?: int, allow_public?: int, error?: string} + */ + #[McpTool(name: 'set_app_anonymous_access', description: 'Set an app\'s anonymous (unauthenticated) access level: 0 = none (default, all requests need auth), 1 = anonymous read-only (GET), 2 = controller-governed (anonymous writes allowed where a controller sets requireAuth=false + allowableActions). PUBLIC SIGNUP requires level 2 plus a signup controller with requireAuth=false. Levels 1-2 expose the app to unauthenticated callers — use the narrowest that works. Check the current level with read_application.')] + #[RequiresScope('provision')] + public function setAppAnonymousAccess(int $application_id, int $level): array + { + $accountId = $this->accountIdOrZero(); + if ($accountId === 0 || !$this->appBelongsToAccount($application_id, $accountId)) { + return ['updated' => false, 'error' => 'Application not found in this account.']; + } + if (!in_array($level, [0, 1, 2], true)) { + return ['updated' => false, 'error' => 'level must be 0 (none), 1 (read-only), or 2 (controller-governed).']; + } + + $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, ['allow_public' => $level]); + } catch (\Throwable $e) { + return ['updated' => false, 'error' => $e->getMessage()]; + } + + $labels = [0 => 'none', 1 => 'read-only', 2 => 'controller-governed']; + return [ + 'updated' => true, + 'application_id' => $application_id, + 'allow_public' => $level, + 'note' => "Anonymous access set to {$level} ({$labels[$level]})." + . ($level === 2 ? ' For public signup, ensure the signup controller sets requireAuth=false and permits create.' : ''), + ]; + } + + /** + * Read a single application's details (name, identifier, language, status, + * login config, auth_mode, allow_public). * * @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.')] + #[McpTool(name: 'read_application', description: 'Read a single Kyte application by id: name, identifier, language, status, login config (user_model + username/password fields), auth_mode, and allow_public (anonymous-access level: 0 none, 1 read-only, 2 controller-governed).')] #[RequiresScope('read')] public function readApplication(int $application_id): ?array { @@ -295,6 +347,12 @@ private function appToArray(int $appId): ?array '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, + // Access control. auth_mode = how API requests authenticate (e.g. hmac/jwt). + // allow_public = anonymous-access level: 0 none, 1 read-only (GET), 2 + // controller-governed (anonymous writes where a controller sets + // requireAuth=false). Public signup needs level 2. Set via set_app_anonymous_access. + 'auth_mode' => !empty($app->auth_mode) ? (string)$app->auth_mode : null, + 'allow_public' => isset($app->allow_public) ? (int)$app->allow_public : 0, ]; } diff --git a/tests/McpAppToolsTest.php b/tests/McpAppToolsTest.php new file mode 100644 index 00000000..995d4e15 --- /dev/null +++ b/tests/McpAppToolsTest.php @@ -0,0 +1,128 @@ +api = new Api(); + + foreach ([KyteAccount, Application] as $model) { + \Kyte\Core\DBI::createTable($model); + } + + \Kyte\Core\DBI::query("DELETE FROM `KyteAccount` WHERE number IN ('" . self::OWN_ACCOUNT . "','" . self::OTHER_ACCOUNT . "')"); + \Kyte\Core\DBI::query("DELETE FROM `Application` WHERE identifier LIKE 'mcp-app-test-%'"); + + $this->ownAccountId = $this->createAccount(self::OWN_ACCOUNT, 'Own'); + $this->otherAccountId = $this->createAccount(self::OTHER_ACCOUNT, 'Other'); + + $this->ownAppId = $this->createApp('mcp-app-test-own', $this->ownAccountId); + $this->otherAppId = $this->createApp('mcp-app-test-other', $this->otherAccountId); + + $this->api->account = new \Kyte\Core\ModelObject(KyteAccount); + $this->api->account->retrieve('id', $this->ownAccountId); + $this->api->mcpScopes = ['read', 'provision']; + + $this->tools = new AppTools($this->api); + + $_SERVER = ['REMOTE_ADDR' => '127.0.0.1']; + } + + public function testDefaultAnonymousAccessIsNone(): void + { + $app = $this->tools->readApplication($this->ownAppId); + $this->assertNotNull($app); + $this->assertSame(0, $app['allow_public'], 'a fresh app defaults to no anonymous access'); + $this->assertSame('hmac', $app['auth_mode'], 'auth_mode defaults to hmac'); + } + + public function testSetAnonymousAccessControllerGoverned(): void + { + $result = $this->tools->setAppAnonymousAccess($this->ownAppId, 2); + $this->assertTrue($result['updated'], $result['error'] ?? 'set failed'); + $this->assertSame(2, $result['allow_public']); + + $app = $this->tools->readApplication($this->ownAppId); + $this->assertSame(2, $app['allow_public'], 'read_application reflects the new level'); + } + + public function testSetAnonymousAccessReadOnlyThenNone(): void + { + $this->tools->setAppAnonymousAccess($this->ownAppId, 1); + $this->assertSame(1, $this->tools->readApplication($this->ownAppId)['allow_public']); + + $this->tools->setAppAnonymousAccess($this->ownAppId, 0); + $this->assertSame(0, $this->tools->readApplication($this->ownAppId)['allow_public']); + } + + public function testSetAnonymousAccessRejectsInvalidLevel(): void + { + foreach ([3, -1, 99] as $bad) { + $result = $this->tools->setAppAnonymousAccess($this->ownAppId, $bad); + $this->assertFalse($result['updated'], "level {$bad} must be rejected"); + } + $this->assertSame(0, $this->tools->readApplication($this->ownAppId)['allow_public'], 'rejected levels leave the app unchanged'); + } + + public function testSetAnonymousAccessRejectsForeignApp(): void + { + $result = $this->tools->setAppAnonymousAccess($this->otherAppId, 2); + $this->assertFalse($result['updated'], 'cannot open another account\'s app to anonymous access'); + + // And the foreign app is untouched. + $other = new \Kyte\Core\ModelObject(Application); + $other->retrieve('id', $this->otherAppId); + $this->assertSame(0, (int)$other->allow_public, 'foreign app stays at level 0'); + } + + public function testReadApplicationRejectsForeignApp(): void + { + $this->assertNull($this->tools->readApplication($this->otherAppId), 'a foreign app_id must not be readable'); + } + + private function createAccount(string $number, string $name): int + { + $obj = new \Kyte\Core\ModelObject(KyteAccount); + $obj->create(['number' => $number, 'name' => $name]); + return (int)$obj->id; + } + + private function createApp(string $identifier, int $accountId): int + { + $obj = new \Kyte\Core\ModelObject(Application); + $obj->create([ + 'name' => 'App ' . $identifier, + 'identifier' => $identifier, + 'kyte_account' => $accountId, + ]); + return (int)$obj->id; + } +} From 6e940d11003230246313a7ba93546e383ba3e785 Mon Sep 17 00:00:00 2001 From: Kenneth Hough Date: Thu, 6 Aug 2026 09:51:40 -0500 Subject: [PATCH 2/2] Add set_app_auth_mode + get_auth_guide (membership recipe) + kytejs signup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - set_app_auth_mode(app, hmac|jwt): sets Application.auth_mode AND regenerates the injected `k` bootstrap (kyte_connect) to match — JWT nulls the HMAC keys and passes {authMode:'jwt'}. Grounded in kyte-api-js: the anonymous/public path + bearer sessions exist ONLY in JWT mode, so flipping just the field would leave published pages booting the old mode. Republish caveat surfaced. - get_auth_guide: consolidated end-to-end membership recipe (auth_mode=jwt + allow_public=2 + password-flagged column + configure_app_login + a requireAuth=false signup controller + client signup/login/logout). Cross-linked from get_kytejs_guide, get_controller_guide, configure_app_login, and set_app_anonymous_access so the AI is pointed to it from wherever it lands. - get_kytejs_guide: add signup (anonymous k.post, JWT-only) + checkSession. - Refactor kyte_connect building into buildKyteConnect (hmac/jwt). - Tests: set_app_auth_mode (jwt regen + back-to-hmac + invalid + foreign-app). Co-Authored-By: Claude Opus 4.8 --- src/Mcp/Tools/AccountTools.php | 13 ++- src/Mcp/Tools/AppTools.php | 161 ++++++++++++++++++++++++++++-- src/Mcp/Tools/ControllerTools.php | 4 +- tests/McpAppToolsTest.php | 41 +++++++- 4 files changed, 205 insertions(+), 14 deletions(-) diff --git a/src/Mcp/Tools/AccountTools.php b/src/Mcp/Tools/AccountTools.php index 4e9bcfcf..e1ccad83 100644 --- a/src/Mcp/Tools/AccountTools.php +++ b/src/Mcp/Tools/AccountTools.php @@ -219,12 +219,22 @@ public function getKytejsGuide(): array . '403 the SDK auto-runs session-destroy + redirect-to-login — do NOT write your own 403 re-login.', 'session' => [ 'create' => 'k.sessionCreate(identity, onSuccess, onError) — log in. identity is an ' - . 'object (e.g. {email, password}). Optional 4th arg = a custom session controller name.', + . 'object (e.g. {email, password}). Optional 4th arg = a custom login/session controller ' + . 'name (defaults to the built-in "Session").', + 'signup' => 'MEMBERSHIP app: a not-yet-logged-in visitor registers with an ANONYMOUS create — ' + . 'k.post("User", {email, password, name}, null, [], onOk, onErr) — pointed at your user ' + . 'model / signup controller. This only works when the app is in JWT auth mode with anonymous ' + . 'access enabled AND a requireAuth=false signup controller permits it. In HMAC mode there is ' + . 'NO anonymous request path, so public signup cannot work. Call get_auth_guide for the full ' + . 'server+client recipe (auth_mode=jwt, allow_public=2, signup controller, then these calls).', '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.)', + 'check' => 'k.checkSession() returns a BOOLEAN — true when a session is active. Use it to ' + . 'gate protected pages (redirect unauthenticated visitors). The page bootstrap already ' + . 'ran k.init(), so an existing session is loaded.', ], 'example' => implode("\n", [ "// READ (default CRUD) — response.data is an ARRAY of rows", @@ -254,6 +264,7 @@ public function getKytejsGuide(): array '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).', + 'Building login / signup / a membership app? Call get_auth_guide — the full recipe spans app settings (auth_mode=jwt, allow_public=2) + a signup controller + these client calls, and is easy to assemble wrong.', ], ]; } diff --git a/src/Mcp/Tools/AppTools.php b/src/Mcp/Tools/AppTools.php index 2803ed49..22047388 100644 --- a/src/Mcp/Tools/AppTools.php +++ b/src/Mcp/Tools/AppTools.php @@ -96,20 +96,39 @@ private function generateKyteConnect(int $appId, int $accountId): void 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 - ); + // New apps default to HMAC (Application.auth_mode default). set_app_auth_mode + // regenerates this snippet if the app is switched to JWT. + $connect = $this->buildKyteConnect($host, (string)$app->identifier, 'hmac', (string)$key->public_key, (string)$key->identifier, (string)$acct->number); $app->save(['kyte_connect' => $connect]); } catch (\Throwable $e) { error_log('create_application: kyte_connect generation failed - ' . $e->getMessage()); } } + /** + * Build the `kyte_connect` bootstrap (the injected global `k`) for an app's + * auth mode. HMAC uses the signed-request keys; JWT nulls them and passes + * { authMode: 'jwt' } so the client uses bearer sessions + the anonymous path. + */ + private function buildKyteConnect(string $host, string $appIdentifier, string $mode, ?string $publicKey = null, ?string $keyIdentifier = null, ?string $accountNumber = null): string + { + if ($mode === 'jwt') { + return sprintf( + "let endpoint = 'https://%s';\nvar k = new Kyte(endpoint, null, null, null, '%s', { authMode: 'jwt' });\nk.init();", + $host, + $appIdentifier + ); + } + return sprintf( + "let endpoint = 'https://%s';var k = new Kyte(endpoint, '%s', '%s', '%s', '%s');k.init();", + $host, + (string)$publicKey, + (string)$keyIdentifier, + (string)$accountNumber, + $appIdentifier + ); + } + /** * Update a Kyte application's name or default language. * @@ -217,7 +236,7 @@ public function deleteApplication(int $application_id): array * @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.')] + #[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. Building login + public signup end-to-end? Call get_auth_guide first (you also need auth_mode=jwt + allow_public=2).')] #[RequiresScope('provision')] public function configureAppLogin(int $application_id, string $user_model, string $username_field, string $password_field): array { @@ -307,7 +326,71 @@ public function setAppAnonymousAccess(int $application_id, int $level): array 'application_id' => $application_id, 'allow_public' => $level, 'note' => "Anonymous access set to {$level} ({$labels[$level]})." - . ($level === 2 ? ' For public signup, ensure the signup controller sets requireAuth=false and permits create.' : ''), + . ($level === 2 ? ' For PUBLIC SIGNUP this is necessary but NOT sufficient: also set auth_mode=jwt (set_app_auth_mode) and give the signup controller requireAuth=false. Call get_auth_guide for the full recipe.' : ''), + ]; + } + + /** + * Set an application's API auth mode: 'hmac' (default, signed requests) or + * 'jwt' (bearer-token sessions + the anonymous/public path that PUBLIC SIGNUP + * needs). Also REGENERATES the app's injected `k` bootstrap (kyte_connect) to + * match — without that, published pages keep booting the old mode. Because the + * bootstrap is baked in at publish time, existing pages must be REPUBLISHED to + * pick up the change. + * + * @param int $application_id Application id (from list_applications). + * @param string $mode 'hmac' or 'jwt'. + * @return array{updated: bool, application_id?: int, auth_mode?: string, error?: string} + */ + #[McpTool(name: 'set_app_auth_mode', description: 'Set an app\'s API auth mode: "hmac" (default; signed requests, no anonymous path) or "jwt" (bearer-token sessions AND the anonymous/public path required for PUBLIC SIGNUP). Also regenerates the injected `k` client bootstrap to match — REPUBLISH pages afterward to pick it up. For a public-signup membership app you need auth_mode=jwt AND set_app_anonymous_access(2) AND a signup controller with requireAuth=false; the install must also have KYTE_JWT_SECRET set. Call get_auth_guide for the full recipe. Changing an existing app\'s mode changes how ALL its clients authenticate.')] + #[RequiresScope('provision')] + public function setAppAuthMode(int $application_id, string $mode): array + { + $accountId = $this->accountIdOrZero(); + if ($accountId === 0 || !$this->appBelongsToAccount($application_id, $accountId)) { + return ['updated' => false, 'error' => 'Application not found in this account.']; + } + if (!in_array($mode, ['hmac', 'jwt'], true)) { + return ['updated' => false, 'error' => "mode must be 'hmac' or 'jwt'."]; + } + + $app = new \Kyte\Core\ModelObject(\Application); + if (!$app->retrieve('id', $application_id)) { + return ['updated' => false, 'error' => 'Application not found.']; + } + + // Regenerate the injected `k` bootstrap to match the mode (else published + // pages keep booting the old auth mode). + $host = (defined('API_URL') && API_URL) ? (string)API_URL : (string)($_SERVER['HTTP_HOST'] ?? ''); + $connect = null; + if ($host !== '') { + if ($mode === 'jwt') { + $connect = $this->buildKyteConnect($host, (string)$app->identifier, 'jwt'); + } else { + $acct = new \Kyte\Core\ModelObject(\KyteAccount); + $key = new \Kyte\Core\ModelObject(\KyteAPIKey); + if ($acct->retrieve('id', $accountId) && $key->retrieve('kyte_account', $accountId)) { + $connect = $this->buildKyteConnect($host, (string)$app->identifier, 'hmac', (string)$key->public_key, (string)$key->identifier, (string)$acct->number); + } + } + } + + $save = ['auth_mode' => $mode]; + if ($connect !== null) { $save['kyte_connect'] = $connect; } + try { + $app->save($save); + } catch (\Throwable $e) { + return ['updated' => false, 'error' => $e->getMessage()]; + } + + return [ + 'updated' => true, + 'application_id' => $application_id, + 'auth_mode' => $mode, + 'kyte_connect_regenerated' => $connect !== null, + 'note' => $mode === 'jwt' + ? 'Set to JWT (bearer sessions + anonymous/public path). Bootstrap regenerated — REPUBLISH pages to apply. Public signup also needs set_app_anonymous_access(2), a requireAuth=false signup controller, and KYTE_JWT_SECRET on the install. See get_auth_guide.' + : 'Set to HMAC (signed requests; no anonymous path). Bootstrap regenerated — REPUBLISH pages to apply.', ]; } @@ -329,6 +412,64 @@ public function readApplication(int $application_id): ?array return $this->appToArray($application_id); } + /** + * Consolidated membership recipe. Keep in sync with: set_app_auth_mode + + * set_app_anonymous_access + configure_app_login (this file), add_attribute + * flags (ModelTools), the signup-controller pattern (get_controller_guide / + * KytePasswordResetController), and the client calls (get_kytejs_guide / + * kyte-api-js sessionCreate/sessionDestroy/checkSession). + * + * @return array + */ + #[McpTool(name: 'get_auth_guide', description: 'How to build user login + PUBLIC SIGNUP (a membership app) on Kyte end-to-end: the required app settings (auth_mode=jwt, allow_public=2), the user model + password flag, configure_app_login, the signup-controller pattern, and the client `k` calls. Call this BEFORE building any login/signup/membership flow — it ties together tools that are otherwise easy to assemble wrong.')] + #[RequiresScope('read')] + public function getAuthGuide(): array + { + return [ + 'overview' => + 'A membership app lets an app\'s OWN end-users sign up + log in (separate from the Kyte platform ' + . 'account). Two app-level settings are REQUIRED and are the usual reason signup/login silently ' + . 'fails: (1) auth_mode=jwt (set_app_auth_mode) — the anonymous/public request path and bearer ' + . 'sessions only exist in JWT mode; HMAC has NO anonymous path, so public signup is impossible in ' + . 'HMAC regardless of other settings. (2) allow_public=2 (set_app_anonymous_access) — lets an ' + . 'unauthenticated visitor reach a requireAuth=false controller to create their account. Verify both ' + . 'with read_application.', + 'recipe' => [ + '1. set_app_auth_mode(app, "jwt") — enables anonymous + JWT sessions; regenerates the injected `k`. Republish pages after.', + '2. set_app_anonymous_access(app, 2) — allow anonymous, controller-governed writes (needed for signup).', + '3. create_model "User" + add_attribute: email (s), password (s, password=true + protected=true), plus profile fields. password=true bcrypt-hashes on write; protected=true keeps the hash out of API output.', + '4. configure_app_login(app, "User", "email", "password") — points the login/session endpoint at your model.', + '5. Signup controller on the User model (see signup_controller) so an anonymous visitor can create an account.', + '6. Client pages: signup via anonymous k.post, login via k.sessionCreate, logout via k.sessionDestroy (see get_kytejs_guide).', + ], + 'signup_controller' => + 'Bind a controller to the User model, then override new() so anonymous visitors can register. In ' + . 'hook_init set $this->requireAuth = false and $this->allowableActions = ["new"] (ONLY create is ' + . 'public — reads/updates/deletes still require login). In new(), create the user with the PLAINTEXT ' + . 'password (Kyte hashes it via the password=true flag — do NOT hash it yourself or login double-hashes) ' + . 'and set $this->response["data"]. Mirrors KytePasswordResetController. Author with create_controller + ' + . 'create_function("hook_init") + create_function("new") + commit_draft. See get_controller_guide for exact ' + . 'signatures + the account-scoping rules.', + 'client' => [ + 'signup' => 'Anonymous create (visitor not logged in yet): k.post("User", {email, password, name}, null, [], onOk, onErr). Works ONLY with auth_mode=jwt + allow_public=2 + the signup controller\'s requireAuth=false.', + 'login' => 'k.sessionCreate({email, password}, onOk, onErr) — mints a JWT session (cookies; sent as Bearer on later calls).', + 'logout' => 'k.sessionDestroy(function(){ location.href = "/"; }) — ONE completion callback. Or k.addLogoutHandler(selector).', + 'gate' => 'k.checkSession() returns a boolean — redirect unauthenticated visitors off protected pages.', + ], + 'prerequisites' => + 'The INSTALL must have KYTE_JWT_SECRET configured for JWT sessions to mint/verify (platform config, ' + . 'not per-app). If login errors even with the recipe correct, verify the install has it.', + 'gotchas' => [ + 'You need BOTH: auth_mode=jwt WITHOUT allow_public=2 → anonymous signup still rejected; allow_public=2 WITHOUT auth_mode=jwt → the client cannot make the anonymous request at all.', + 'Changing auth_mode regenerates the injected `k` bootstrap — REPUBLISH existing pages or they keep booting the old mode.', + 'Do NOT hash the password in signup — the password=true flag hashes it; hashing yourself breaks login (double-hash).', + 'requireAuth=false belongs on the SIGNUP controller only, scoped to allowableActions=["new"] — never open the whole app.', + '"Unauthorized API request." during signup/login almost always means: auth_mode not jwt, allow_public not 2, or the controller still requireAuth=true.', + ], + 'verify' => 'read_application(app) returns auth_mode + allow_public — confirm jwt + 2 before debugging anything else.', + ]; + } + /** @return array|null */ private function appToArray(int $appId): ?array { diff --git a/src/Mcp/Tools/ControllerTools.php b/src/Mcp/Tools/ControllerTools.php index 96270e59..c3b97bd0 100644 --- a/src/Mcp/Tools/ControllerTools.php +++ b/src/Mcp/Tools/ControllerTools.php @@ -503,7 +503,9 @@ public function getControllerGuide(): array . '+ 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.', + . 'controller class, so do NOT add a class wrapper. ' + . '(Building a signup/login controller for a membership app? Call get_auth_guide for the ' + . 'end-to-end recipe — it needs app settings auth_mode=jwt + allow_public=2 alongside this controller.)', 'context' => [ '$this->user' => 'ALWAYS a ModelObject — when there is no session it is an EMPTY ' . 'object with no id (it is NEVER literally null). Guard with isset($this->user->id) ' diff --git a/tests/McpAppToolsTest.php b/tests/McpAppToolsTest.php index 995d4e15..c59db4a2 100644 --- a/tests/McpAppToolsTest.php +++ b/tests/McpAppToolsTest.php @@ -34,7 +34,7 @@ protected function setUp(): void $this->api = new Api(); - foreach ([KyteAccount, Application] as $model) { + foreach ([KyteAccount, Application, KyteAPIKey] as $model) { \Kyte\Core\DBI::createTable($model); } @@ -53,7 +53,7 @@ protected function setUp(): void $this->tools = new AppTools($this->api); - $_SERVER = ['REMOTE_ADDR' => '127.0.0.1']; + $_SERVER = ['REMOTE_ADDR' => '127.0.0.1', 'HTTP_HOST' => 'test.local']; } public function testDefaultAnonymousAccessIsNone(): void @@ -108,6 +108,43 @@ public function testReadApplicationRejectsForeignApp(): void $this->assertNull($this->tools->readApplication($this->otherAppId), 'a foreign app_id must not be readable'); } + public function testDefaultAuthModeIsHmac(): void + { + $this->assertSame('hmac', $this->tools->readApplication($this->ownAppId)['auth_mode']); + } + + public function testSetAuthModeToJwtRegeneratesBootstrap(): void + { + $result = $this->tools->setAppAuthMode($this->ownAppId, 'jwt'); + $this->assertTrue($result['updated'], $result['error'] ?? 'set failed'); + $this->assertSame('jwt', $result['auth_mode']); + $this->assertSame('jwt', $this->tools->readApplication($this->ownAppId)['auth_mode']); + + $app = new \Kyte\Core\ModelObject(Application); + $app->retrieve('id', $this->ownAppId); + $this->assertStringContainsString("authMode: 'jwt'", (string)$app->kyte_connect, 'the injected bootstrap must be regenerated for JWT'); + } + + public function testSetAuthModeBackToHmac(): void + { + $this->tools->setAppAuthMode($this->ownAppId, 'jwt'); + $this->tools->setAppAuthMode($this->ownAppId, 'hmac'); + $this->assertSame('hmac', $this->tools->readApplication($this->ownAppId)['auth_mode']); + } + + public function testSetAuthModeRejectsInvalidMode(): void + { + $result = $this->tools->setAppAuthMode($this->ownAppId, 'saml'); + $this->assertFalse($result['updated'], 'only hmac/jwt are valid'); + $this->assertSame('hmac', $this->tools->readApplication($this->ownAppId)['auth_mode'], 'a rejected mode leaves the app unchanged'); + } + + public function testSetAuthModeRejectsForeignApp(): void + { + $result = $this->tools->setAppAuthMode($this->otherAppId, 'jwt'); + $this->assertFalse($result['updated'], 'cannot change another account\'s app'); + } + private function createAccount(string $number, string $name): int { $obj = new \Kyte\Core\ModelObject(KyteAccount);