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/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 c7eab0fd..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
{
@@ -261,12 +280,128 @@ 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 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.',
+ ];
+ }
+
+ /**
+ * 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
{
@@ -277,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
{
@@ -295,6 +488,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/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
new file mode 100644
index 00000000..c59db4a2
--- /dev/null
+++ b/tests/McpAppToolsTest.php
@@ -0,0 +1,165 @@
+api = new Api();
+
+ foreach ([KyteAccount, Application, KyteAPIKey] 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', 'HTTP_HOST' => 'test.local'];
+ }
+
+ 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');
+ }
+
+ 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);
+ $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;
+ }
+}