feat: v4.4.0 — MCP server (Phase 2) + sensitive flag (Phase 2.5) + JWT auth (Phase 3)#82
Merged
Conversation
Adds the data model for opaque MCP bearer tokens per design doc section 5.4.
Pure schema declaration — no controller, no strategy, no UI consumes it yet.
Phase 2 proper will add McpTokenStrategy (validation), the /mcp endpoint
(tool surface), and the Shipyard Tokens page (issuance UI).
Fields:
- token_hash : sha256 of raw token (64 chars hex). Only the hash is
stored; the raw token is shown once at creation.
- token_prefix : first ~12 chars for UI display.
- name : human label ("Claude Code - laptop").
- application : optional app scope (null = account-wide; reserved).
- scopes : CSV of read/draft/commit. Default on issuance "read,draft".
- expires_at : unix epoch. 0 = never (discouraged).
- last_used_at, last_used_ip: telemetry for revocation decisions.
- ip_allowlist : optional CIDR CSV.
- revoked_at : 0 = active, nonzero = revoked at that time.
Uses kyte_account for the account FK (standard Kyte framework convention),
not the design doc's 'account' shorthand. Design doc kept as-is for now;
the shorthand is clearer prose even if the code uses the longer name.
Index on token_hash (required for validation lookups) will be added in
the Phase 2 migration, not here — the model framework doesn't declare
indexes, and there's no validator querying token_hash yet anyway.
30/30 unit tests still green; new model is loaded by
Api::loadModelsAndControllers with no side effects (the \$KyteMCPToken
global is defined as a model constant but nothing reads it yet).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the strategy class that will handle Authorization: Bearer kmcp_live_... requests in Phase 2 proper. Currently a skeleton: - matches() always returns false — dispatcher never selects this strategy even if registered. - preAuth() throws LogicException (intentional tripwire; nothing should be invoking preAuth before the implementation lands). - verify() is a deliberate no-op (bearer tokens don't have a separate verify phase — validation happens in preAuth). The empty method satisfies the AuthStrategy interface and mirrors the two-phase shape of HmacSessionStrategy. Not registered in AuthDispatcher::buildDefault() yet — Phase 2 proper will add that when matches() and preAuth() are real. The skeleton exists now so the Phase 2 implementer (future-me, future- Kenneth, or future-Claude-session) has a committed place to fill in, and so the class structure + namespace + interface conformance can be reviewed before wiring. 30/30 tests still green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace Phase 2 commit 2's skeleton with a real implementation: matches() parses Authorization: Bearer kmcp_live_..., preAuth() does the sha256 lookup against KyteMCPToken, enforces revocation/expiry/IP allowlist, populates \$api->account from the token's FK, and updates last_used_at + last_used_ip synchronously. Scope enforcement deliberately stays out of preAuth (that belongs in the /mcp dispatcher). Strategy is still NOT registered in AuthDispatcher::buildDefault() — it goes live only when wired alongside the /mcp endpoint in a later commit. Until then this is exercised exclusively by McpTokenStrategyTest. Tests: 15 new cases covering matches() truth table, preAuth happy path, revoked/expired/unknown-token rejection, IP-allowlist enforcement (CIDR, v4 + v6 fallback path), audit-trail update, and verify() no-op contract. 45/45 unit tests green locally.
…tion Wires Phase 2 from "scaffolding" to "actually serves MCP requests": src/Mcp/Endpoint.php — runs the mcp/sdk Server against /mcp requests. authenticate() invokes AuthDispatcher::buildDefault()->select(), populates \$api->account from the token's FK, attaches the validated token + parsed scopes to the Api context. process() then builds a PSR-7 transport, instantiates the SDK Server with attribute-based tool discovery on src/Mcp/Tools/, and returns the response. handle() binds that to globals + SapiEmitter for production; process() is the testable seam. src/Mcp/Tools/AccountTools.php — first tool: list_applications. Returns Application rows scoped to \$api->account->id. Tool class is constructor- injected with Api via the SDK's PSR-11 container. src/Core/Api.php — hook \/mcp into route() before any of Kyte's MVC machinery runs (auth, session, response envelope, controller dispatch all bypassed). Also adds two new public properties on Api: mcpToken (the validated KyteMCPToken ModelObject) and mcpScopes (parsed scopes array). Declared rather than dynamic to satisfy PHP 8.2. src/Core/Auth/AuthDispatcher.php — buildDefault() now registers McpTokenStrategy first, then HmacSessionStrategy. Order is safe because McpTokenStrategy::matches() is strict-prefix on Bearer kmcp_live_, so non-MCP traffic still falls through to Hmac unchanged. composer.json — adds mcp/sdk ^0.4 + nyholm/psr7 ^1.8 + nyholm/psr7-server ^1.1 + laminas/laminas-httphandlerrunner ^2.12. Bumps PHP floor from >=7.2 to >=8.1 (mcp/sdk's minimum, and what every customer is actually running). composer.lock is gitignored per existing Kyte convention so it's not in this commit; CI resolves fresh. tests/McpEndpointTest.php — 6 integration tests: initialize handshake returns 200 + Mcp-Session-Id + tools capability, account+scopes get populated on the Api, missing/invalid/non-MCP-bearer auth returns 401 with JSON-RPC error envelope, AccountTools instantiates and returns [] for empty account. Full multi-step handshake (initialize → notifications /initialized → tools/list → tools/call) was already verified end-to-end during the SDK evaluation on dev — these tests prove the Kyte-side wiring. 51/51 unit tests green locally. AccountTools happy-path test against a populated Application table is deferred — the Application model has 'default' => null on its language column that DBI::createTable generates broken SQL for. Pre-existing DBI bug, unrelated to MCP, worth fixing in its own commit when someone is in DBI.
v4.3.1 (merged as 6913cab) fixed DBI::buildFieldDefinition's handling of 'default' => null, which was blocking DBI::createTable(Application) in this test. Brings back the happy-path test that Phase 2 commit 4 had to defer: seeds an Application row, drives AccountTools::listApplications directly, asserts the row comes back with account scoping intact. Also asserts apps[0]['id'] is an int rather than a string, which is the contract the tool's @return shape declares and which will matter once the first write_* tool starts using returned ids as input. 53/53 unit tests green.
Tools now declare their required scope via #[RequiresScope]; the dispatcher rejects calls whose token lacks the named scope and emits an MCP_SCOPE_VIOLATION audit row. Fail-closed default — tools without the attribute are unreachable. Hook point is a custom RequestHandlerInterface registered via Builder's addRequestHandler(), which the SDK merges ahead of its defaults. Server's first-supports-wins protocol routes every CallToolRequest through the wrapper; on scope pass we delegate to the SDK's own CallToolHandler unchanged, so the dispatch path stays the SDK's. Endpoint::process now externalizes the registry + reference handler so the wrapper composes cleanly without forking SDK code. Token PK is logged via ActivityLogger's record_id parameter rather than inside request_data, since redactSensitive substring-matches the "token" needle and would otherwise blank the surrogate. -32010 chosen for the JSON-RPC error code (-32001 used for auth, -32002 reserved by SDK for RESOURCE_NOT_FOUND). Five new tests in McpScopeTest cover: read-scoped pass, draft-only reject + audit row, undeclared-tool reject + audit row, empty-scope reject, and direct ScopeRegistry attribute lookup. 61/61 unit tests green. Closes the scope-enforcement gap noted in the 2026-04-26 recap; design doc R7 fulfilled for the violation event type. Remaining R7 action types (MCP_TOKEN_USE/ISSUE/REVOKE) land alongside their respective code paths.
Four new tools under #[McpTool] + #[RequiresScope('read')]:
list_controllers, read_controller, list_functions, read_function. All
land in src/Mcp/Tools/ControllerTools.php and pick up scope enforcement
automatically through the ScopedCallToolHandler dispatch wrapper.
read_function honors an optional version_number param and joins through
KyteFunctionVersion + KyteFunctionVersionContent to surface historical
snapshots — gives Claude a way to inspect prior states without Shipyard.
Account scoping is re-asserted in every tool. A token holder cannot
enumerate or read entities owned by another account by guessing ids;
each tool checks $entity->kyte_account against $api->account->id and
returns null/[] on mismatch. Tests cover the cross-account isolation
explicitly for each tool — this is the first place MCP tools touch
foreign-account data, so the contract needs to be visible.
Function model references go through constant('Function') because PHP's
parser treats `\Function` as a syntax error (function is a reserved
keyword). Existing controllers already use this pattern.
Caught a PHP gotcha mid-implementation: the `$base + [...]` union
operator keeps left-hand keys on collision, which silently dropped
version metadata when overriding base nulls. Switched to array_merge
where right-hand keys must win. Comment in the source explains.
Tests: 71/71 unit green (10 new in McpControllerToolsTest covering
happy-path + cross-account isolation for each tool, plus
read_function's live/versioned/unknown-version branches).
list_models(application_id) and read_model(model_id), under #[McpTool]
+ #[RequiresScope('read')]. Same shape as ControllerTools — list returns
shallow metadata, read returns the full record.
read_model decodes the stored model_definition JSON for Claude's
convenience — same denormalized cache Shipyard uses, kept in sync by
DataModelController + ModelAttributeController. If the JSON is missing
or malformed, definition comes back null with the raw value preserved
in raw_definition for debugging — defensive over throw, since the
dispatch shouldn't blow up on a single broken row.
Cross-account isolation re-asserted per tool, same pattern as commit 6:
$dm->kyte_account compared against $api->account->id, foreign ids
return null/[]. Tests cover both isolation and the JSON-decode branches
(well-formed, missing, malformed).
Tests: 76/76 unit green (5 new in McpModelToolsTest).
list_sites(application_id), list_pages(site_id), and read_page(page_id,
version_number?) in src/Mcp/Tools/PageTools.php. Pages live two FK hops
from the account; cross-account isolation is re-asserted at every link
so neither a foreign application_id, a foreign site_id, nor a foreign
page_id can leak data.
read_page handles four content paths symmetrically:
- default version → returns is_current=1 snapshot
- explicit version_number → returns named historical snapshot
- default version when no current version exists → returns metadata
+ empty content (page exists, no save yet — must stay discoverable)
- explicit version_number that doesn't exist → returns null (caller
asked for a specific version, an empty response would mislead)
list_sites omits AWS infra fields (s3BucketName, cfDistributionId,
aliasDomain, ga_code, etc.). Those are deployment plumbing, not user-
facing site attributes — surfacing them broadens the leak surface
without obvious benefit to a developer inspecting an app from Claude.
Tests: 85/85 unit green (9 new in McpPageToolsTest covering all four
read_page content paths plus cross-account isolation at every level
of the chain).
MCP spec requires structured tool output to be a JSON object (record),
not a list. The mcp/sdk's extractStructuredContent passes any returned
PHP array through verbatim, so a bare list violates client-side schema
validation. Surfaced as `expected record, received array` errors when
Claude Code called list_applications against the dev MCP endpoint.
All 6 list_* tools wrapped in single-key records:
- list_applications -> {applications: [...]}
- list_controllers -> {controllers: [...]}
- list_functions -> {functions: [...]}
- list_models -> {models: [...]}
- list_sites -> {sites: [...]}
- list_pages -> {pages: [...]}
read_* tools were already records (associative arrays), so they're
unaffected — no change to those four tools.
Updated docblocks to reflect the new return shape and added a brief
note in AccountTools explaining the wrapping pattern (cross-reference
for future tool authors). Updated 8 test assertions across four test
files.
Tests: 85/85 unit green.
Live wire test on dev surfaced that read_controller returned HTTP 202
with no body. Cause: Controller.code (and Function.code, and the
KytePageVersionContent {html,stylesheet,javascript} columns) are stored
bzip2-compressed by the existing Shipyard write paths. Read tools were
returning the raw binary blob through the MCP response, json_encode
choked on invalid UTF-8 bytes, and the SDK silently dropped the
response. The transport then returned 202 Accepted with no payload —
client never sees an error, just a timeout-shaped hang.
Added src/Mcp/Util/Bz2Codec.php — single static helper
(decompressIfBz2) that detects the BZ magic prefix, decodes if
present, falls back to raw bytes if decode fails (better than
blanking a partially-corrupted row). Wired through:
- read_controller (Controller.code)
- read_function, both live and versioned paths
- read_page (html, stylesheet, javascript on the version content)
Added bz2 PHP extension to tests/Dockerfile and CI workflow so
Bz2CodecTest can exercise real compress/decompress round-trips. Four
new tests: compressed→decoded round-trip, raw passthrough, empty/null
handling, corrupted-prefix fallback.
Tests: 89/89 unit green (4 new).
MCP spec § Security mandates Origin validation to prevent DNS rebinding
attacks. The attack vector is browser-only — malicious JavaScript on
attacker.com triggers cross-origin POSTs from the victim's browser to
exploit cached bearer-token state. CLI clients (Claude Code, curl) are
not exposed to the same risk because they have no shared-credential
context for an attacker to leverage.
Policy:
- No Origin header → allow (CLI clients don't send it)
- Origin present + matches MCP_ALLOWED_ORIGINS → allow
- Origin present + no match → 403 + JSON-RPC -32011
Allowlist source: per-install MCP_ALLOWED_ORIGINS PHP constant (CSV).
Empty / undefined means "no browser origins permitted." Restrictive
default is the right call for healthcare deployments — Claude.ai
custom-connector users opt in by setting the constant in config.php.
Origin check runs before authentication so we reject DNS rebinding
attempts without paying for the bearer-token DB lookup.
Three new tests in McpEndpointTest cover the policy paths. Also added a
session-dir wipe in setUp so tests with successful initialize calls
don't trip on accumulated FileSessionStore state from prior tests.
Surfaced an unrelated PHP gotcha while writing tests: empty('0') is
true, so 'version' => '0' makes Implementation::fromArray throw —
worth knowing for anyone hand-crafting clientInfo.
Tests: 92/92 unit green (3 new in McpEndpointTest).
The mcp/sdk v0.4.x Session::save() accesses the typed array property
$this->data without first hydrating it via readData(). Every other
method on the parent class lazy-inits, but save() is the lone outlier.
Result: a fatal "must not be accessed before initialization" on every
tool call that doesn't write session state — which is most of them.
The fatal kills the FPM worker mid-request; the response gets dropped
and the client sees HTTP 202 with an empty body and a hung wait.
Workaround composes around the bug instead of patching the vendor
directory:
- SaveSafeSession extends the SDK's Session and overrides save()
to route through all() (which lazy-inits properly).
- SaveSafeSessionFactory implements SessionFactoryInterface to
construct the subclass.
- Endpoint::process wires it via Builder::setSession(store, factory).
This avoids any composer post-install patch hackery; customers
installing the package get the fix automatically. Once the upstream PR
lands and we bump mcp/sdk, both files can be removed and Endpoint
reverted to plain setSession(store).
Three tests in SaveSafeSessionTest cover the bug-exposing path
(construct + immediate save), the set/save round-trip, and that the
factory actually returns the subclass.
Tests: 95/95 unit green (3 new in SaveSafeSessionTest).
Per design doc R7. ActivityLogger->log('MCP_TOKEN_USE', ...) fires from
McpTokenStrategy::preAuth right after the token validates and the
last_used_at/ip update lands. Best-effort — wrapped in try/catch so a
log failure doesn't break the auth decision; ActivityLogger itself
also swallows errors internally, this is belt-and-suspenders for the
audit guarantee.
Token id goes into record_id (not request_data) to dodge the
ActivityLogger redactSensitive substring match on "token" — same
pattern ScopedCallToolHandler uses for MCP_SCOPE_VIOLATION rows.
Payload carries scopes + client IP as the audit-relevant context.
ISSUE/REVOKE action types still owed but gated on the token-issuance
backend landing — no point logging events that have no code path
firing them yet. Will land alongside that work.
Tests: 96/96 unit green (1 new in McpTokenStrategyTest, plus
KyteActivityLog table now created in setUp).
ModelTest fixes: - Drop TestTable at start of testCreateTable so re-runs against the same MariaDB container start clean. Without this, prior-run rows accumulate and the count assertions later in the test (2, 3, etc.) start failing off-by-N. - Instantiate Api in setUp so STRICT_TYPING and the rest of the loadModelsAndControllers constants are defined. Test now runs in isolation (`phpunit --filter ModelTest`) instead of needing some other test file to have run first. GitHub Actions Node 24 bumps (ahead of 2026-06-02 deadline): - actions/checkout v4 -> v5 (php.yml) - actions/cache v4 -> v5 (php.yml) - actions/checkout v3 -> v5 (dependency-review.yml) - actions/dependency-review-action v3 -> v4 Tests: 96/96 unit green; ModelTest now green standalone too.
KyteMCPTokenController hooks into Kyte's standard model-controller
dispatch. POST /KyteMCPToken mints a new token, GET lists them, DELETE
revokes. Auth is whatever the install's existing strategy provides
(typically Shipyard's HMAC session) — MCP bearer tokens deliberately
don't authenticate this endpoint (chicken-and-egg).
Issuance:
- hook_preprocess(new): generates the raw token via random_bytes ->
base62, hashes (sha256), stores hash + 16-char prefix, force-overrides
kyte_account from auth context (closes a real privilege-escalation
vector — ModelController::new defaults kyte_account from request body
and only falls back to auth context, so a session for account A
could mint tokens for account B by sending kyte_account in the body).
- Validates scopes against {read, draft, commit}; rejects invalid.
- Defaults expires_at to now + 30 days when omitted (per design doc R2:
short TTL is a primary mitigation for bearer-token theft window).
- hook_response_data(new): injects raw_token into the response (the one
and only chance the caller has to capture it) and emits MCP_TOKEN_ISSUE.
Revoke:
- hook_response_data(delete): emits MCP_TOKEN_REVOKE with the prefix +
last_used_at/ip so the audit row identifies what was revoked.
Side-fix in the model: token_hash now `protected:true` so it stays out
of list/get response payloads. McpTokenStrategy::preAuth still queries
by it internally — the protected flag only affects response serialization,
not internal retrieve.
Bug discovered while writing tests: ModelController::sift() runs
strtotime() on date-typed fields. Passing a Unix int returns false
which lands as 0 in DB, silently breaking expiry. Workaround in the
controller: format expires_at as ISO 8601 before letting sift() see
it. Worth a separate fix to ModelController::sift to handle ints
natively, but out of scope for this commit.
Tests: 103/103 unit green (7 new in McpTokenControllerTest covering
issuance happy path, account-override security, scope validation,
default TTL, audit rows for ISSUE and REVOKE).
REMOTE_ADDR reflects the proxy edge IP when an install sits behind
Cloudflare or an LB, not the actual client. Two real consequences:
1. The IP allowlist on KyteMCPToken (design doc § 5.4) is broken —
every token's source IP looks like a Cloudflare IP, so pinning a
token to a developer's office network never matches and tokens
from anywhere on the internet pass the check.
2. Audit rows (MCP_TOKEN_USE, MCP_TOKEN_REVOKE, MCP_SCOPE_VIOLATION,
KyteMCPToken.last_used_ip) capture the proxy IP — useless for
forensics during a compliance review.
Resolver lives at Kyte\Mcp\Util\ClientIp::resolve(). Reads
CF-Connecting-IP first (Cloudflare's authoritative client IP, stripped
inbound so spoofing requires bypassing Cloudflare entirely), then
X-Forwarded-For first hop, falling back to REMOTE_ADDR. Resolution is
gated on the per-install KYTE_TRUST_PROXY_IP_HEADERS constant —
default-off so installs that DON'T sit behind a proxy aren't exposed
to header spoofing.
Wired into McpTokenStrategy::clientIp() so both the IP allowlist
check and the audit fields it populates use the resolved IP. Endpoint
and ScopedCallToolHandler don't need direct changes — they read IP
through ActivityLogger's context which is set up via $api, not raw
$_SERVER. (ActivityLogger itself still reads REMOTE_ADDR directly for
the global IP context — that's a broader fix, not in this scope.)
Tests: 109/109 unit green (6 new in ClientIpTest, plus the existing
IP-allowlist tests in McpTokenStrategyTest still pass — they don't
set proxy headers, so the resolver falls through to REMOTE_ADDR
regardless of the trust-gate state).
When the upstream mcp/sdk PR for Session::save() lazy-init lands and we bump the dep, this whole subclass + factory + tests + Endpoint wiring needs to be removed cleanly. Without an explicit checklist in the source, "delete the workaround" turns into a scavenger hunt for every place we wired it in. The block enumerates all six things to delete, in order, alongside the existing rationale docblock. Same checklist also lives in docs/design/upstream-sdk-followups.md under the cleanup tracker — both surfaces so anyone who edits this file or scans the design corpus sees it. Per Kenneth's call no /schedule reminder; rediscovery is via the source comment + followups doc.
Three new boolean columns (TINYINT 0/1, default 0). Default 0 means
existing installs are no-op until a flag is set.
Controller.sensitive Blanket flag. When 1, this controller's
request body and response are dropped from
activity / error logs and MCP read tools
refuse or redact source. Applies whether
the controller is model-bound or virtual
(no model).
DataModel.sensitive Blanket flag at the model level. Same
treatment when the model is the target of
the request.
ModelAttribute.sensitive Per-field flag. Distinct from the existing
'.protected' column (which blanks values
in GET responses only). 'sensitive' affects
log writes and MCP responses. Set both for
both behaviors.
Runtime API responses are unaffected — the flag governs log / MCP / AI
exposure only. The runtime service that consults these columns lands
in the next commit; ActivityLogger and ErrorHandler integration follow.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ALTER TABLE statements for the three columns added in the prior commit, following the existing migrations/<version>_<feature>.sql pattern (migrations/4.1.0_activity_log.sql is the prior example). Version prefix is provisional and may be renamed at release-time once the v4.4.0 cut is finalized. Existing installs upgrading from v4.3.x to v4.4.0 must apply this migration; without it, the new column references in the runtime SensitivityPolicy would fail at lookup time. The runtime falls back to permissive (no redaction beyond ActivityLogger's existing hardcoded SENSITIVE_FIELDS list) on lookup failure, so an unmigrated install degrades to v4.3.x behavior rather than to a crash. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Single source of truth for the three-tier sensitive flag check, used by ActivityLogger, ErrorHandler, MCP read tools, and AI error correction in subsequent commits. Public surface: isControllerSensitive(name, accountId): bool isModelSensitive(name, accountId): bool getSensitiveFields(modelName, accountId): array shouldDropPayload(controller, model, accountId): bool (OR of tiers 1+2) redactFields(data, modelName, accountId): mixed (tier 3) Per-request singleton with in-memory cache keyed by (scope, name, account). One DB hit per tuple per request. Foreign-account name collisions are isolated (a controller named X in account A with sensitive=1 does not affect account B's X). Failure mode is fail-permissive: any lookup exception is error_log'd and the call returns false / []. ActivityLogger's hardcoded SENSITIVE_FIELDS baseline still runs, so transient DB hiccups degrade to current behavior rather than to no redaction at all. 13 unit tests cover the matrix: each tier in isolation, cross-account isolation, shouldDropPayload OR-semantic, redactFields case-insensitive matching + nested recursion + model=null no-op for no-model controllers, null-arg permissive returns, and the per-request cache contract. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Test field rename from prior commit produced an alphabetical ordering different from what the assertion expected. The runtime sort() result is correct; only the expected array was stale. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ActivityLogger::log() now consults the policy before persisting the
request body. Behavior by tier:
Controller or DataModel flagged sensitive
→ request_data column written as NULL
→ PUT changes diff column also NULL (skipped before computeChanges)
→ All other metadata (user, account, action, model_name, response
code, IP, severity, etc.) still written so audit-trail "who did
what when" remains intact.
ModelAttribute flagged sensitive on a non-sensitive model
→ that field is replaced with '[REDACTED]' in request_data
→ the PUT changes diff redacts the field's old AND new values
→ other fields pass through unchanged.
No flags set
→ identical to prior behavior; the hardcoded SENSITIVE_FIELDS
baseline still runs unchanged.
computeChanges() gained an optional $modelName parameter so it can
consult the policy alongside the hardcoded list. Single source of
truth: SensitivityPolicy holds the decision logic; ActivityLogger
just asks.
The model-name string is passed for both the controller-tier and
model-tier lookups in shouldDropPayload(). For model-bound controllers
both tiers can resolve; for no-model controllers only the controller
tier resolves, which is the intended scope. Foreign-account name
collisions are isolated by the policy's account-scoped lookups.
6 integration tests cover: controller-only flag drops body, model
flag drops body and changes, field flag redacts on plain model,
baseline SENSITIVE_FIELDS still runs unflagged, PUT changes drop
on sensitive model, PUT changes redact flagged fields.
129/129 unit tests green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ErrorHandler had three write paths into KyteError, and the
AIErrorCorrection queue had no gate at all. This commit closes both.
Three KyteError write paths now consult the policy:
handleException consults shouldDropPayload(model, model, accountId).
On hit: KyteError.data and KyteError.response are
written as NULL. The exception metadata (message,
file, line, trace, model name, request_id, etc.)
is still persisted so the row remains useful for
audit. On miss with field-level flags: data and
response have flagged fields replaced with
'[REDACTED]' before print_r serialization.
handleError doesn't capture data/response by default — only
the AI-gate decision matters here; routed through
the same resolver for single-source-of-truth on
skipAI.
outputBufferCallback the captured buffer is an opaque string we can't
field-redact. On sensitive controller/model: data
column is NULL; the row is kept with a
sensitive_dropped: true marker in context so the
occurrence is still discoverable.
AI error correction gating happens at two layers:
ErrorHandler Computes skipAI inside resolveSensitivePayload and
short-circuits the AIErrorCorrection::queueForAnalysis
call when ANY tier is sensitive (controller,
model, or any flagged field on the model). The
gate is intentionally wider than the storage gate
— a partially-redacted payload still contains
contextual hints we don't want sent to Anthropic.
AIErrorCorrection Defense-in-depth check at the top of
queueForAnalysis. If any future caller routes a
sensitive context through, this returns early
before any data leaves the platform.
5 integration tests cover controller-flag, model-flag, field-flag
redaction in handleException, the no-flags baseline, and the AI
defense-in-depth gate firing on a sensitive context.
134/134 unit tests green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the third write-path: read tools now mirror the same policy
that gates ActivityLogger and ErrorHandler. AI clients see metadata
for everything (so they can reason about what exists) but source
code and schema fields are withheld whenever flagged.
Behavior by tool:
list_controllers Each row gains a `sensitive` boolean. Existence
and name are not gated; only code is.
read_controller When the controller is flagged sensitive, the
returned row has code:null and sensitive:true.
Other metadata (id, name, description, dataModel,
application, kyte_locked) still returns so the
caller can recognize the controller and know
its source is intentionally gated.
read_function Function source is gated when the parent
controller is flagged sensitive. The flag is
inherited from the live controller, not the
version snapshot — flagging a controller now
gates its historical function versions too.
This is correct policy semantics: the current
flag governs current access, even to past code.
list_models Each row gains a `sensitive` boolean.
read_model When the model is flagged sensitive, definition
is null. When it isn't but individual fields
carry ModelAttribute.sensitive, those fields
are stripped from definition.struct and surfaced
in a separate sensitive_fields list so the
caller knows which fields exist but are gated.
Pages and sites are not gated in this commit. Pages render UI; they
don't process request bodies the way controllers do. The gate point
is at the controller layer where data flows in.
8 new integration tests; existing McpControllerToolsTest and
McpModelToolsTest still green (no shape regression beyond the new
boolean fields they don't assert against).
142/142 unit tests green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…lysis Three CI tightenings, no behavior changes to library code: 1. PHP matrix. Unit tests now run on PHP 8.2 AND 8.3. composer.json already allowed >=8.1; 8.3 is the lowest non-EOL supported by both Symfony 7 and the mcp/sdk pin. Catches version-specific syntax, deprecations, and stdlib behavior changes before deploy. 2. Composer audit. New step in the test job runs `composer audit --no-dev` against the GitHub advisories database on every push. Fails CI on any new advisory in production dependencies. Dev-only deps (phpunit, phpstan) are excluded — they never ship. 3. PHPStan static analysis. New job at level 1 (undefined classes, methods, functions, variables). Catches the kind of latent bug that triggered the DBI::getConnection() private-method call I hit in a test earlier this branch — the test passed sanity-eye-check but failed at runtime. Level 1 plus baseline ratchets up over time as we clean legacy patterns. phpstan-baseline.neon captures the 183 pre-existing level-1 findings (most are unknown-constant references for Kyte's runtime- defined model-name constants — a structural artifact of how Api:: loadModelsAndControllers works, not actual bugs). New code is held to zero level-1 findings; the baseline only shrinks. phpstan-baseline.neon is committed deliberately. Without it, every unrelated PR would either inherit the legacy violations or have to update the baseline. Locking the baseline at this commit makes "baseline shrunk" diffs easy to spot in review. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
First chunk of the JWT auth strategy. Implements the AuthStrategy
contract for HS256-signed JWTs but doesn't yet wire it into the
dispatcher or expose login/refresh endpoints — those land in
subsequent commits so each is reviewable in isolation.
Design decision: HS256 (symmetric), not RS256.
Reopens design doc open question O7. RS256 would only add value
if verification ran on a different system than signing; in this
codebase the same kyte-php process signs AND verifies, so the
asymmetric overhead (RSA key generation, key file on disk, backup,
rotation policy, slower verify) buys nothing. One signing secret
in config.php is operationally simpler and equally secure for
the threat model. Section 13 change log to be updated alongside
the dispatcher-registration commit.
Library: firebase/php-jwt ^7.0. Lightweight (no transitive deps),
well-known in the PHP ecosystem, supports HS256 and the standard
claim set out of the box.
Strategy behavior:
matches() Strict Bearer + JWT-shape check. Does NOT claim
kmcp_live_ tokens — those belong to McpTokenStrategy.
Returns false when KYTE_JWT_SECRET is undefined so
installs that haven't opted in are unaffected.
preAuth() Decode + verify signature using KYTE_JWT_SECRET.
firebase/php-jwt enforces exp/nbf automatically.
Asserts iss matches KYTE_JWT_ISSUER (default 'kyte').
Resolves aud → account, sub → user, optional app
claim → application. Cross-account mismatch on user
or application throws SessionException.
verify() No-op. Full verification happens in preAuth.
Config constants (define in each install's config.php):
KYTE_JWT_SECRET Required. 256-bit+ random string.
KYTE_JWT_ISSUER Optional. Defaults to 'kyte'.
KYTE_JWT_ACCESS_TTL Optional. Seconds. Defaults to 900 (15 min).
KYTE_JWT_REFRESH_TTL Optional. Used by /jwt/refresh code in a
later commit. Defaults to 604800 (7 days).
14 unit tests cover the matches() matrix (header presence, scheme,
prefix, shape) and the preAuth verification matrix (tampered
signature, expiry, wrong issuer, unknown account/user, cross-
account user mismatch).
156/156 unit tests green; PHPStan clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Refresh tokens are opaque (not JWTs) and live in their own table. The
JWT auth flow exchanges them at /jwt/refresh for a new (access_jwt,
refresh_token) pair — single-use rotation with RFC 6819 reuse
detection.
Key fields:
token_hash sha256 of the raw refresh token. Only the hash is
stored; the raw token is returned once at issuance.
token_prefix First chars of the raw token (kref_v1_...) for
identification in admin UI and audit logs.
token_family 64-char hex shared by every token in a rotation
chain. Reuse detection works at the family level.
rotated_to Successor token id once this token is rotated;
0 while active. Provides the rotation audit trail.
revoked_at / revoked_reason captures normal rotation vs.
revoked_reason reuse-detected revocation vs. logout vs. admin
revocation.
expires_at Unix epoch. Defaults set by KYTE_JWT_REFRESH_TTL
(default 7 days).
Reuse detection logic (implemented in the /jwt/refresh endpoint commit):
- /jwt/refresh receives token A.
- If A.revoked_at != 0, A is a previously-rotated token being
presented again — the legitimate client should have moved on to
A's successor by now. Treat as a leak signal: revoke EVERY token
in A's family with reason='reuse_detected', force re-login.
- Otherwise normal rotation: mark A revoked with reason='rotated',
issue B with same family, A.rotated_to = B.id, return B.
Migration follows the existing `<version>_<feature>.sql` pattern.
Adds four indexes: unique on token_hash, lookup on token_family
(for reuse detection family revoke), lookup on user (for "list my
sessions"), composite on (kyte_account, expires_at) for sweepers.
Also fixes a flaky JWT strategy test where the "tamper signature"
mutation was a no-op when the random JWT signature happened not to
contain the swap character — changed to a definitive append.
156/156 unit tests green across three consecutive runs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Service class encapsulating the refresh-token rotation and revocation
logic. Used by the upcoming /jwt/login (issue), /jwt/refresh (rotate),
/jwt/logout (revokeByToken), and /jwt/logout-all (revokeAllForUser)
endpoints.
Public surface:
issue(userId, accountId, ?appId, ip) New family, returns
{raw, id, family, expires_at}.
Each call generates a fresh
64-char hex family — separate
logins get independent families
(matches the multilogon
expectation).
rotate(rawToken, ip) Validate + revoke + issue
successor in same family.
Three failure modes, each
with distinct semantics:
- Unknown → SessionException
- Expired → mark expired,
SessionException
(no family kill)
- Already revoked → reuse
signal, revoke
ENTIRE family
with reason
'reuse_detected',
SessionException
revokeByToken(rawToken, reason) Single-token revocation, idempotent.
Used by /jwt/logout.
revokeFamily(family, reason) Revoke every active token in a
family. Returns count.
revokeAllForUser(userId, accountId, reason) Logout-everywhere. Revokes every
active token across all families
for a user. Used by /jwt/logout-all.
Refresh tokens have the form `kref_v1_<43 url-safe base64 chars>`.
Stored as sha256 hash; raw token surfaced exactly once at issuance.
TTL controlled by KYTE_JWT_REFRESH_TTL (default 604800 = 7 days).
9 unit tests cover the full state machine: issuance returns expected
shape, hash is stored not raw, normal rotation preserves family +
records rotated_to, reuse triggers family-wide revocation, expired
tokens are isolated (no family kill), unknown tokens throw, revoke
is idempotent, family revocation hits only active rows, logout-all
spans families but not foreign users, multiple logins get separate
families.
165/165 unit tests green; PHPStan clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
URL handler for the /jwt/* endpoint family. Mirrors the static-handler
pattern Mcp\Endpoint uses for /mcp (process() pure, handle() binds to
globals + SAPI).
Endpoints, all POST:
/jwt/login {email, password, app_identifier?}
→ {access_token, refresh_token, token_type='Bearer',
expires_in, refresh_expires_at}
Looks up the user in KyteUser (or the app's
user_model when app_identifier is given), verifies
the hashed password, and mints both tokens. Each
login generates a fresh refresh token family — two
logins from the same user produce two independent
families (modern multilogon model; ALLOW_MULTILOGON
is not consulted here per the design discussion).
/jwt/refresh {refresh_token}
→ new (access, refresh) pair, presented token
revoked with reason='rotated'.
Reuse → 401 + family-wide revoke.
Expired → 401 + token marked expired (no family
kill — expiry is expected, not a leak signal).
/jwt/logout {refresh_token}
→ {ok: true}, idempotent. Revokes just the
presented token; other sessions / families
untouched.
/jwt/logout-all {refresh_token}
→ {ok: true, revoked: N}. Resolves the user from
the presented token (active or not — even expired
tokens can request a global logout), then revokes
every active refresh token for that user across
all families.
Access token claims: iss, sub (user id), aud (account id), iat, nbf,
exp, jti, email (when available), app (identifier string, when
app-scoped). Signed HS256 with KYTE_JWT_SECRET. TTL controlled by
KYTE_JWT_ACCESS_TTL (default 900s).
Username enumeration: login responds with the same 'invalid_credentials'
error for unknown users and wrong passwords. The shape of the
response does not let an attacker distinguish the two cases.
11 integration tests cover the full happy path + every error branch:
valid login + token round-trip, missing field, unknown user, wrong
password, two logins → two families, normal refresh rotation,
refresh reuse → family revoke, logout scoped to one token, logout-all
spans families, unknown action returns 404, GET returns 405.
Also fixes JwtSessionStrategyTest to read KYTE_JWT_SECRET at mint
time (PHP constants are immutable; whichever test file runs first
pins the value, so the second file's local-constant secret no longer
matched).
176/176 unit tests green; PHPStan clean; 3 consecutive runs stable.
Wiring this into Api::route() and registering JwtSessionStrategy in
the dispatcher lands in the next commit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two integration points, both following the patterns Phase 2 established:
1. AuthDispatcher::buildDefault() now registers three strategies in
priority order: McpToken → JwtSession → Hmac. Each strategy's
matches() is strict enough that order doesn't actually matter for
correctness (a Bearer kmcp_live_... will only match McpToken, a
Bearer JWT will only match JwtSession, and Hmac claims signed
requests via its own header check). Order is documented for
review clarity.
2. Api::route() intercepts the /jwt/ first URL segment and dispatches
to JwtEndpoint::handle() before any auth or MVC pipeline runs.
Mirrors how /mcp is handled at line 627 — same justification
(these endpoints don't fit the model-CRUD shape and must run
pre-auth so login can succeed without an existing session).
After this commit JWT is functional end-to-end:
- POST /jwt/login with email+password issues an access JWT plus a
rotating refresh token.
- Subsequent requests carrying `Authorization: Bearer <access_jwt>`
are claimed by JwtSessionStrategy.matches(), verified in preAuth(),
and populate $api->user / $api->account / $api->app for the
downstream MVC pipeline.
- POST /jwt/refresh rotates the refresh token, returning a new pair.
- POST /jwt/logout revokes one token. POST /jwt/logout-all revokes
every token for the user.
- HMAC-based clients are unaffected — HmacSessionStrategy is still
registered and claims any signed request as before. The three
strategies coexist on the same install, so existing integrations
keep working unchanged and new integrations can opt into JWT at
their own pace.
JWT is opt-in: KYTE_JWT_SECRET must be defined for any of this to
activate. Installs without the constant see JwtSessionStrategy.matches()
return false, and /jwt/login responds with a 500 (the JwtEndpoint
mintAccessJwt() guard refuses to issue without a secret) which is
the correct failure mode.
176/176 unit tests green; PHPStan clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Completes parity with the controller-side gating from the prior MCP
commit. Pages don't write to activity/error logs (no
ActivityLogger/ErrorHandler integration needed), so KytePage.sensitive
only governs MCP exposure — listed alongside the other three
sensitive tiers in the SensitivityPolicy docblock as tier 4.
Behavior:
list_pages Each row gains `sensitive: bool`. Existence and title
are not gated; only content is.
read_page When the page is flagged sensitive, html/stylesheet/
javascript come back as null and `sensitive: true` is
set. Metadata (id, title, description, page_type,
state, site, version) still returns so the caller can
recognize the page exists. The null content
distinguishes "exists but gated" from "exists but no
content yet" (empty strings — see read_page's existing
no-current-version path).
Migration: 4.4.0_sensitive_columns.sql appends one more ALTER for the
KytePage column. Single migration file kept as the deploy artifact
for all four sensitive-flag tiers.
3 new integration tests cover list_pages flag exposure, read_page
content withholding, and the unchanged behavior when not flagged.
179/179 unit tests green; PHPStan clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds top-of-file framing to ActivityLoggerSensitivityTest and
ErrorHandlerSensitivityTest explaining:
1. Which pattern they're the canonical coverage for (the
pass-through-controller pattern, where a Kyte controller accepts
an opaque body intended for forwarding to a downstream system
and should not capture that body into either log).
2. Why these integration tests are the right level of coverage
rather than a full end-to-end Api::route() drive. The leak
surfaces are ActivityLogger::log() and ErrorHandler::handleException();
Api::route() forwards $this->model and $this->data to those
methods in one line each. Driving from Api would exercise only
those forwarding lines at the cost of HMAC auth scaffolding and
additional brittleness against router refactors.
3. (ErrorHandler) the dual-write-site rationale: ErrorHandler's
exception path historically didn't consult any redaction policy
at all, so closing it was a separate gap that needed its own
coverage in addition to the activity log.
4. (ErrorHandler) why the AI defense-in-depth gate test is
co-located here.
Doc-only changes. No behavior change. 179/179 unit tests green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two PHP docblock edits to use industry-neutral phrasing for the same
underlying security rationale:
src/Mcp/Endpoint.php (Origin header validation)
"the right call for healthcare deployments where a permissive
allowlist would be a compliance finding" → describes the security
rationale (attack surface, opt-in signal) without naming any
particular regulated industry. The security argument applies
broadly to any deployment where browser-borne requests should be
explicitly allowlisted.
src/Mcp/Util/ClientIp.php (proxy-aware IP resolution)
"destroys forensic value during a compliance review" → "destroys
forensic value when reviewing security events after the fact".
Same point; doesn't presume a formal compliance regime.
No functional change. Doc-only.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Single VARCHAR(16) column on the Application model, default 'hmac'.
Drives whether Shipyard's page generators emit the v1.x HMAC
constructor or the v2 JWT constructor for the kyte-api-js SDK.
'hmac' (default) — `new Kyte(url, key, iden, num, app)` — preserves
the existing sign/rotate flow. All current
installs get this on upgrade (default value).
'jwt' — `new Kyte(url, null, null, null, app,
{ authMode: 'jwt' })` — pages auth via the
/jwt/login + /jwt/refresh endpoints introduced
in Phase 3.
Both strategies coexist on the server (the AuthDispatcher registers
HMAC, MCP, and JWT simultaneously) so an account can run a mix of
HMAC and JWT apps. Mid-flight switching for a single app is a
deliberate migration step — pages built before the switch will keep
using HMAC until rebuilt.
Migration follows the existing `<version>_<feature>.sql` pattern.
Default 'hmac' means existing apps are no-op until the operator
opts in via the Shipyard Application form (separate commit in the
shipyard repo).
179/179 unit tests green; PHPStan clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Every JWT-bearer request to a protected MVC endpoint was returning
403 "Unauthorized API request." despite the JWT decoding cleanly.
Root cause: ModelController::authenticate() (line ~189) gates
protected endpoints on `!$this->api->session->hasSession`. The HMAC
strategy reaches that gate via `$api->session->validate($sessionToken)`
inside HmacSessionStrategy.preAuth — `validate()` sets hasSession=true
as a side effect of consuming the cookie. JwtSessionStrategy never
went through validate() (JWT has no cookie-backed session — the
bearer IS the session), so hasSession stayed at its constructor
default of false. ModelController rejected every request.
Reproducer (live on dev): POST /jwt/login with valid creds returned
the expected token pair. Re-using that token as `Authorization:
Bearer <jwt>` against `/KyteUser/id/1` returned 403 with
{"session":"jwt","uid":1,"error":"Unauthorized API request."}.
The session/uid populated by preAuth proved the JWT path ran;
the error proved authenticate() then rejected anyway.
Fix: after JwtSessionStrategy.preAuth resolves the user and account,
set `$api->session->hasSession = true` directly. `isset` + property
check guards against the SessionManager not being present (it
always is per Api::route, but defensive).
Test gap: JwtEndpointTest covered /jwt/login returning a token, but
no test exercised that token against a protected endpoint. Added
testPreAuthMarksSessionAsAuthenticatedForProtectedEndpoints which
asserts hasSession=true after preAuth runs against a real
SessionManager — the test that would have caught this pre-merge.
180/180 unit tests green; PHPStan clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Comprehensive entry covering everything since v4.3.2:
- Phase 2: MCP server + KyteMCPToken + 10 read tools + scope
enforcement + Origin validation + proxy-aware client IP
- Phase 2.5: SensitivityPolicy + sensitive flag on Controller /
DataModel / ModelAttribute / KytePage + ActivityLogger
+ ErrorHandler + AI gates + MCP read-tool gating
- Phase 3: JwtSessionStrategy + JwtEndpoint (/jwt/login, refresh,
logout, logout-all) + RefreshTokenStore (RFC 6819 rotation
with reuse detection) + KyteRefreshToken model +
Application.auth_mode + AuthDispatcher updated to register
all three strategies + firebase/php-jwt dep
Plus the bundled migrations (sensitive_columns, jwt_refresh_tokens,
application_auth_mode), CI hardening (PHP 8.2/8.3 matrix + PHPStan
+ composer audit), and upgrade notes.
Explicitly flagged: no breaking changes — every addition is opt-in,
defaults preserve v4.3.x behavior bit-for-bit. HMAC customers can
upgrade in place; JWT requires KYTE_JWT_SECRET config addition.
The earlier v4.2.0 / v4.3.0 / v4.3.1 / v4.3.2 tagged releases never
got CHANGELOG entries — backfilling those is separate work.
Co-Authored-By: Claude Opus 4.7 (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.
Summary
Three additive features land together as v4.4.0. No breaking changes — defaults preserve v4.3.x behavior bit-for-bit.
POST /mcp+ 10 read tools, scope-gated, account-isolated,KyteMCPTokenmodelsensitivecolumn on Controller / DataModel / ModelAttribute / KytePage;SensitivityPolicyconsulted by ActivityLogger + ErrorHandler + MCP tools + AI gatessensitive=1on the rowPOST /jwt/login+ refresh/logout,JwtSessionStrategy,KyteRefreshTokenmodel with RFC 6819 reuse detectionKYTE_JWT_SECRET;Application.auth_mode='jwt'per appPlus CI hardening: PHP 8.2/8.3 matrix, PHPStan with baseline, composer audit. 180 unit tests, 531 assertions.
Dev verified end-to-end (2026-05-22)
HmacSessionStrategy(dispatcher flipped fromshadow→on)/mcpreturns 401 on missing auth (correctly), authenticates withkmcp_live_…tokensPOST /jwt/loginreturns access + refresh tokens; using access token asAuthorization: Bearer …against/Applicationand/KyteUser/id/1returns full data withsession: "jwt"in the response envelope0/'hmac'Notable mid-rollout fix
Caught a real bug on dev: JwtSessionStrategy was populating
$api->userbut not setting$api->session->hasSession = true. Every JWT bearer request to a protected MVC endpoint hitModelController::authenticate()and 403'd. Fixed in commit8aea119with a regression test (testPreAuthMarksSessionAsAuthenticatedForProtectedEndpoints). Without that test, future regressions of the same pattern would silently slip through.Migrations
Apply in any order (all additive):
Upgrade path for existing installs
composer update keyqcloud/kyte-phpKYTE_JWT_SECRETinconfig.phpif enabling JWTAUTH_STRATEGY_DISPATCHERfromshadow→on(the new dispatcher is verified bit-identical to legacy via Phase 1 shadow soak)Test plan
dispatcher='on'🤖 Generated with Claude Code