Skip to content

feat: v4.4.0 — MCP server (Phase 2) + sensitive flag (Phase 2.5) + JWT auth (Phase 3)#82

Merged
kennethphough merged 39 commits into
masterfrom
feature/phase-2.5
May 22, 2026
Merged

feat: v4.4.0 — MCP server (Phase 2) + sensitive flag (Phase 2.5) + JWT auth (Phase 3)#82
kennethphough merged 39 commits into
masterfrom
feature/phase-2.5

Conversation

@kennethphough

Copy link
Copy Markdown
Member

Summary

Three additive features land together as v4.4.0. No breaking changes — defaults preserve v4.3.x behavior bit-for-bit.

Phase Surface Opt-in via
Phase 2 — Embedded MCP server POST /mcp + 10 read tools, scope-gated, account-isolated, KyteMCPToken model Operator issues a token via Shipyard
Phase 2.5 — Sensitive-data flag New sensitive column on Controller / DataModel / ModelAttribute / KytePage; SensitivityPolicy consulted by ActivityLogger + ErrorHandler + MCP tools + AI gates Operator flips sensitive=1 on the row
Phase 3 — JWT auth POST /jwt/login + refresh/logout, JwtSessionStrategy, KyteRefreshToken model with RFC 6819 reuse detection Operator defines KYTE_JWT_SECRET; Application.auth_mode='jwt' per app

Plus 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)

  • HMAC: Shipyard login + CRUD works through new HmacSessionStrategy (dispatcher flipped from shadowon)
  • MCP: /mcp returns 401 on missing auth (correctly), authenticates with kmcp_live_… tokens
  • JWT: POST /jwt/login returns access + refresh tokens; using access token as Authorization: Bearer … against /Application and /KyteUser/id/1 returns full data with session: "jwt" in the response envelope
  • Sensitive flag: migrations applied successfully, columns default to 0/'hmac'

Notable mid-rollout fix

Caught a real bug on dev: JwtSessionStrategy was populating $api->user but not setting $api->session->hasSession = true. Every JWT bearer request to a protected MVC endpoint hit ModelController::authenticate() and 403'd. Fixed in commit 8aea119 with a regression test (testPreAuthMarksSessionAsAuthenticatedForProtectedEndpoints). Without that test, future regressions of the same pattern would silently slip through.

Migrations

Apply in any order (all additive):

migrations/4.4.0_sensitive_columns.sql
migrations/4.4.0_jwt_refresh_tokens.sql
migrations/4.4.0_application_auth_mode.sql

Upgrade path for existing installs

  1. composer update keyqcloud/kyte-php
  2. Run the three migrations
  3. (Optional) Define KYTE_JWT_SECRET in config.php if enabling JWT
  4. (Optional) Flip AUTH_STRATEGY_DISPATCHER from shadowon (the new dispatcher is verified bit-identical to legacy via Phase 1 shadow soak)

Test plan

  • Unit suite: 180/180 green, PHPStan clean (latest CI run on the branch)
  • HMAC dev smoke: Shipyard + a test app verified working with dispatcher='on'
  • JWT dev smoke: login + bearer auth against protected endpoints verified
  • CI green on this PR
  • After merge + tag, customer dev/prod rollout per [task [FEATURE] Add element ID and class to side and top navigation items #10] sequence

🤖 Generated with Claude Code

kennethphough and others added 30 commits April 24, 2026 10:13
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>
kennethphough and others added 9 commits May 21, 2026 08:09
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>
@kennethphough
kennethphough merged commit 2f7d49b into master May 22, 2026
7 checks passed
@kennethphough
kennethphough deleted the feature/phase-2.5 branch May 22, 2026 11:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant