Releases: keyqcloud/kyte-php
Release list
kyte-php v4.4.4
Bug Fix: /jwt/login still 500s on apps with custom user_model (continuation of 4.4.3)
v4.4.3 fixed the Undefined constant "User" fatal by calling Api::loadAppModels($app) in resolveAuthContext. That uncovered a second gap: the app-scoped model definition has appId set, which causes ModelObject->retrieve() to auto-switch to the app DB via Api::dbswitch(true). But the app-DB credentials (host/user/password) were never configured because Api::dbappconnect() is called only by the normal MVC pipeline (Api.php:690) — which JWT bypasses.
Result: mysqli received null host/user/password and tried a Unix-socket connection → No such file or directory → HTTP 500.
Fix: resolveAuthContext now also calls Api::dbappconnect($app->db_name, $app->db_username, $app->db_password) immediately after loadAppModels. The HMAC pipeline does both calls back-to-back at Api.php:688-690; JWT now matches.
Apps without a custom user_model (default KyteUser path) are unaffected — they were never in the app-DB branch.
No schema changes. Composer upgrade is sufficient.
kyte-php v4.4.3
Bug Fix: /jwt/login fatals on apps with custom user_model
When an Application's user_model is set to an app-specific DataModel (e.g. "User" rather than the default "KyteUser"), JwtEndpoint::resolveAuthContext called constant($app->user_model) to resolve the model definition. But the JWT endpoint dispatches before Api::loadAppModels() runs in the normal MVC pipeline, so the app-scoped constant wasn't yet defined. In PHP 8+ that's a fatal: Undefined constant "User". Surface to the client was HTTP 500.
HMAC /Session login did not hit this because Api::route() calls loadAppModels before reaching the session controller. JWT's whole point is to bypass that pipeline (login can't require auth), which is what created the gap.
Fix: resolveAuthContext now calls Api::loadAppModels($app) immediately after retrieving the Application, before referencing the constant. If the app references a name that no DataModel row defines, falls back to KyteUser (with an error_log breadcrumb) rather than fatal — login then naturally fails at password_verify, which is a safer surface than a 500.
No schema changes. Composer upgrade is sufficient.
kyte-php v4.4.2
Bug Fix: ErrorHandler crash when apiContext->key is a ModelObject
The enhanced v4.4 ErrorHandler bound $this->apiContext->key directly into the KyteError.api_key string column. $this->key is set in Api.php as new \Kyte\Core\ModelObject(KyteAPIKey) — an object, not a string. mysqli_stmt::execute() then threw Object of class Kyte\Core\ModelObject could not be converted to string inside the error handler. That secondary fatal swallowed the original error and surfaced to clients as a blank HTTP 500 with no log row written and no stack trace recoverable.
Customer-visible impact: any request that triggered the error handler — including expected exceptions on routes like POST /Session, GET on a controller hitting a runtime error, etc. — returned 500 with no body. Production traffic at ETOM was affected from the v4.4.0 deploy onward.
Fix: 'api_key' => isset($this->apiContext->key->public_key) ? (string)$this->apiContext->key->public_key : null — extract the scalar public_key string (which is the audit-relevant value anyway). Falls back to null when the ModelObject hasn't been retrieved or when there's no key context at all.
Regression test ErrorHandlerSensitivityTest::testApiContextKeyAsModelObjectLogsPublicKeyString exercises the previously-broken ModelObject path end to end. Pre-existing tests set $context->key = null and never hit the crash, which is why the regression slipped through 4.4.0 and 4.4.1.
No schema changes. Composer upgrade is sufficient.
kyte-php v4.4.1
Bug Fix: CORS preflight on /jwt/* endpoints
Browser CORS preflight requests (OPTIONS /jwt/login) were being routed
through JwtEndpoint::process(), which only accepts POST and returned
405 method_not_allowed with no CORS headers. Browsers then blocked
the actual POST /jwt/login, breaking JWT login for any same-page web
client (Shipyard 2.0+ in JWT mode, kyte-api-js v2 JWT consumers).
Root cause: Api::cors() runs inside validateRequest(), which lives
downstream of the /jwt dispatch in Api::route(). So /jwt/* skipped
CORS entirely. (Same is technically true for /mcp but MCP is
server-to-server and doesn't trigger browser preflight.)
Fix: JwtEndpoint::handle() now emits CORS headers on every response
and replies to OPTIONS with 204 No Content + CORS preflight headers
before falling through to process(). Mirrors the permissive Origin
policy in Api::cors().
Regression test: JwtEndpointTest::testHandleAnswersOptionsPreflightWith204
exercises the OPTIONS path through handle() end-to-end with output
buffering and asserts the 204 response.
No schema changes. Composer upgrade is sufficient. Customers running
Shipyard 2.0+ in JWT mode must update before JWT login will succeed
in a browser.
kyte-php v4.4.0
Phase 2 (MCP server) + Phase 2.5 (sensitive-data flag) + Phase 3 (JWT auth) all ship together. No breaking changes — every addition is opt-in, defaults preserve v4.3.x behavior bit-for-bit. Existing customers can upgrade in place without code changes.
New Feature: Embedded MCP server (Phase 2)
Each Kyte install can now expose a Model Context Protocol endpoint that lets AI clients (Claude Code, Claude.ai) inspect controllers, models, pages, and functions over a tenant-scoped bearer.
- New endpoint:
POST /mcp— handled byMcp\Endpointoutside the standard MVC pipeline. JSON-RPC over HTTP, protocol version2025-06-18. Bypasses Kyte's HMAC envelope. - New strategy:
McpTokenStrategyregistered with the auth dispatcher. StrictAuthorization: Bearer kmcp_live_…prefix match. - New model:
KyteMCPToken— opaque bearer tokens. Stored as sha256 hash, displayed as 16-char prefix for identification. Scoped (read/draft/commit), revokable, optional expiry, optional CIDR allowlist, optional application-binding. - New controller:
KyteMCPTokenController— issue, list, revoke. Generates the raw token at issuance (returned once, never recoverable). Force-overrideskyte_accountfrom auth context to close a privilege-escalation vector. EmitsMCP_TOKEN_ISSUE/MCP_TOKEN_REVOKE/MCP_TOKEN_USE/MCP_SCOPE_VIOLATIONaudit rows. - 10 read tools shipped:
list_applications,list_controllers,read_controller,list_functions,read_function(with optionalversion_number),list_models,read_model,list_sites,list_pages,read_page(with optionalversion_number). - Scope enforcement:
ScopedCallToolHandlerregistered ahead of the SDK's default handler. Tools declare required scope via#[RequiresScope('read'|'draft'|'commit')]attribute. Fail-closed default — a tool without the attribute is unreachable. - Account isolation: every tool re-asserts
entity.kyte_account === api.account.id. Foreign ids returnnull/[], never the foreign record. - bzip2 decompression:
Controller.code,Function.code, andKytePageVersionContent.{html,stylesheet,javascript}are stored compressed;Bz2Codec::decompressIfBz2wraps the read tools so source is returned in plaintext to the client. - Origin validation on
/mcp: per spec § Security, mitigates DNS rebinding. Policy is "no Origin → allow, Origin present → checkMCP_ALLOWED_ORIGINSconstant (CSV)". CLI clients (Claude Code) unaffected; restrictive default forces operator opt-in for browser origins. - Proxy-aware client IP:
Kyte\Mcp\Util\ClientIpreadsCF-Connecting-IPthenX-Forwarded-Forfirst hop then falls back toREMOTE_ADDR. Gated onKYTE_TRUST_PROXY_IP_HEADERSconstant — default-off so installs without a proxy aren't exposed to header spoofing. Wired intoMcpTokenStrategy::clientIp()for IP allowlist enforcement and audit fields.
New Feature: Sensitive-data flag (Phase 2.5)
A three-tier opt-in flag that prevents activity/error logs from capturing request bodies, and gates MCP read tools from exposing source for flagged entities. Designed for pass-through controllers whose payload contents are regulated data the platform should not store.
- New columns (default
0, no behavior change unless flipped):Controller.sensitive— blanket flag. When1, drops body+response from logs entirely. Handles virtual (no-model) pass-through controllers.DataModel.sensitive— same blanket treatment when the model is the request target.ModelAttribute.sensitive— per-field redaction. Distinct from existing.protected(which only blanks values in GET responses). Set both for both behaviors.KytePage.sensitive— MCP-only; withholdshtml/stylesheet/javascriptfromread_page. Pages don't write to activity logs.
- New service:
SensitivityPolicy(src/Core/SensitivityPolicy.php) — single source of truth, per-request singleton with in-memory cache keyed by(scope, name, account). One DB hit per tuple per request. Fail-permissive on lookup error so transient DB issues degrade to existingSENSITIVE_FIELDSbaseline rather than to no redaction at all. - ActivityLogger consults the policy before persisting
request_dataand the PUT changes diff. Blanket-sensitive → both fields null. Field-sensitive → flagged fields replaced with[REDACTED], other fields pass through. The hardcodedSENSITIVE_FIELDSlist (password / token / secret_key / etc.) still runs as a baseline on top. - ErrorHandler previously captured request body AND response payload into
KyteErrorwith zero redaction — closed in this release.handleException,handleError, andoutputBufferCallbackall consult the policy now. AI error-correction queue (AIErrorCorrection::queueForAnalysis) gains its own defense-in-depth check at the top: skips any sensitive-origin row, with audit-log breadcrumb. Regulated data never reaches Anthropic for analysis. - MCP read tools gated by the same flag: sensitive controller →
read_controllerreturnscode: null+sensitive: true; sensitive function (via parent controller) → same; sensitive model →read_modelreturnsdefinition: null+sensitive: true; sensitive field → stripped fromdefinition.struct, listed separately insensitive_fields; sensitive page → content fields null. List tools (list_controllers,list_models,list_pages) surface asensitive: boolon each row so AI clients see up front which entities are gated. - Runtime API responses unaffected — a sensitive controller still returns its normal response to the caller. The flag governs log / MCP / AI exposure only, not the live contract.
New Feature: JWT bearer authentication (Phase 3)
Modern auth path that coexists with the legacy HMAC sign/rotate. Customers can run a mix of HMAC apps and JWT apps on the same install.
- New strategy:
JwtSessionStrategy— HS256 access tokens with a configurable secret, claims{iss, sub, aud, exp, iat, nbf, jti, email, app}. StrictAuthorization: Bearer eyJ…prefix match (won't clash with MCP'skmcp_live_…). Decode + verify inpreAuth, sets$api->user,$api->account, and$api->session->hasSession = trueso the standard ModelController auth gate accepts the request. - New endpoint family:
POST /jwt/login,POST /jwt/refresh,POST /jwt/logout,POST /jwt/logout-all— handled byJwtEndpoint(same pattern asMcp\Endpoint, bypasses the MVC pipeline so login can run pre-auth). Login posts{email, password, app_identifier?}and returns{access_token, refresh_token, expires_in, token_type:'Bearer', refresh_expires_at}. - Refresh tokens: opaque (
kref_v1_…prefix), stored as sha256 hash in newKyteRefreshTokentable. Single-use rotation per RFC 6819 — every successful refresh revokes the presented token and issues a new one in the same family. Reuse detection: presenting a revoked token revokes the entire family (likely leak signal). Expiration without revocation does not trigger family kill. - Multilogon: separate logins always create separate families. A user signing in from laptop and phone gets two independent families — revoking one device does not affect the other. Distinct from HMAC's
ALLOW_MULTILOGONflag. AuthDispatcher::buildDefault()now registers three strategies: McpToken → JwtSession → Hmac. Eachmatches()is strict so order doesn't affect correctness; order is documented for review clarity. HMAC clients continue working unchanged viaHmacSessionStrategy.Application.auth_modecolumn added:'hmac'(default) or'jwt'. Drives whether Shipyard's page generator emits the v1.x HMAC constructor or the v2 JWT constructor for kyte-api-js consumers.- Configuration constants (define in
config.phpto enable JWT):KYTE_JWT_SECRET— required for JWT mode. At least 256 bits of entropy. Never commit to version control.KYTE_JWT_ISSUER— optional, defaults to'kyte'. Mismatched tokens are rejected at preAuth.KYTE_JWT_ACCESS_TTL— optional, default 900 seconds.KYTE_JWT_REFRESH_TTL— optional, default 604800 seconds (7 days).
- Dependency:
firebase/php-jwt ^7.0— lightweight, well-known, no transitive deps.
Bundled migrations
Three new SQL files in migrations/. Apply in order. All are additive (new columns / new table) — safe to apply ahead of code; new columns default to 0 / 'hmac' so legacy code sees no behavior change until the matching feature is opted into.
migrations/4.4.0_sensitive_columns.sql # Controller / DataModel / ModelAttribute / KytePage .sensitive
migrations/4.4.0_jwt_refresh_tokens.sql # KyteRefreshToken table
migrations/4.4.0_application_auth_mode.sql # Application.auth_mode
CI hardening
- PHP matrix in
.github/workflows/php.yml: tests now run on PHP 8.2 AND 8.3 against MariaDB 10.5.29. Catches version-specific syntax / deprecation issues. - PHPStan static analysis at level 1 with a baseline of pre-existing findings (
phpstan-baseline.neon). New code is held to zero level-1 violations; baseline only shrinks. - Composer audit step on every push — fails CI on a new advisory in production dependencies.
Test coverage
- 180 unit tests, 531 assertions. Up from 109 at end of Phase 2.
- New test files:
SensitivityPolicyTest,ActivityLoggerSensitivityTest,ErrorHandlerSensitivityTest,McpSensitivityTest,JwtSessionStrategyTest,JwtEndpointTest,RefreshTokenStoreTest. - Includes a regression test for the
hasSessionintegration bug surfaced during dev rollout: JwtSessionStrategy must mark$api->session->hasSession = trueafter validating the bearer, otherwiseModelController::authenticate()rejects every protected endpoint despite a valid JWT.
Upgrade notes
- Apply the three migrations above to your database.
- If using JWT mode: define
KYTE_JWT_SECRETinconfig.php(generate with `openssl rand...