Converge hosted-MCP OAuth + parity tools + app SSO (v4.20.0) - #124
Merged
Conversation
…nticate 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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
…n fresh objects) 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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
… database) 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) <noreply@anthropic.com>
…ng user on RDS) 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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
… + session 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) <noreply@anthropic.com>
…r-key alg) 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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
…, browser-bound state, no KyteUser fallback 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) <noreply@anthropic.com>
…d, dangling-link rebind 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) <noreply@anthropic.com>
…e are implemented) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
# Conflicts: # src/Core/Api.php
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
…rver instructions) 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) <noreply@anthropic.com>
…troller_guide 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) <noreply@anthropic.com>
…lete_page - 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) <noreply@anthropic.com>
…oller source 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) <noreply@anthropic.com>
…rift 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) <noreply@anthropic.com>
…e flags + configure_app_login 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) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Converges five validated-but-unmerged feature branches into the release line so a single install serves the full hosted-MCP surface: the OAuth connector (Claude.ai / ChatGPT web), the app/media/controller MCP tools, and app-level Microsoft/OIDC SSO.
Each branch was cut from the same base (current
master),0behind, touching near-disjoint files. The only merge conflict was an additive routing block insrc/Core/Api.php(both/oauth/*and/sso/*dispatch), resolved to keep both.What's included
OAuthEndpoint,KyteOAuthClient/KyteOAuthCode, RFC 8414/9728 discovery,/oauth/*routing,/mcpWWW-Authenticate). Lets Claude.ai/ChatGPT add/mcpas a hosted connector; mints scopedkmcp_live_tokens via auth-code + PKCE. Migration4.17.0.ControllerTools: create/update/delete_controller,schemascope).MediaTools: list/read/create/delete_media, base64 S3 upload).AppToolscreate/update/delete_application;DBI::getProvisioningConnection+ explicit privilege grant; async app teardown viaApplication.status+SiteProvisioningWorker; logs-bucket removal). Migration4.18.0.SsoEndpoint,KyteAppIdentityProvider/KyteSsoState/KyteSsoCode/KyteSsoIdentity,JwtEndpoint::issueSession). Kyte as OIDC relying party. Migration4.19.0.Security review
/security-reviewthis cycle. Findings closed + adversarially re-verified: identity now bound to the immutable id_tokensub(not mutable email; guest-exclusion + no-rebind), login-CSRF closed via browser-bound state cookie, and SSO refuses the platformKyteUserfallback. Rejected findings: Host-headerredirect_uri(dead code), cross-accountKyteUser(blocked by existingpreAuthaud guard).Validation (dev19, integration branch deployed)
OAuth discovery returns proper metadata;
/mcpcarries the discovery header; consent page + endpoint healthy; SSO/authorize302 intact; a real Microsoft login completed end-to-end (subject-linked session minted); Claude.ai connector requests + receives full scope (read,draft,commit,provision,schema).Notes
4.17→4.19are idempotent; a converged tag of v4.20.0 is intended after merge.feature/325-*(schema/model-layer — separate in-progress build).php -lclean across changed files; full PHPStan+baseline intended to run here in CI (onlyphp -lwas runnable on dev19).🤖 Generated with Claude Code