Skip to content

Releases: keyqcloud/kyte-php

kyte-php v4.8.1

Choose a tag to compare

@github-actions github-actions released this 01 Jun 00:18
a915040

Fix: republish is now fault-isolated (KYTE-#181) + partial content saves no longer blank block_layout (KYTE-#189)

Republish resilience (KYTE-#181). ApplicationController's republish_kyte_connect hook re-stamps every state=1 page across all of an app's sites. Previously it throwew on the first page with missing KytePageData, aborting the entire batch — every later site/page kept the stale connect string, leaving an app half-migrated (e.g. a JWT dashboard with an HMAC login page). Now each page is re-stamped inside a try/catch: failures are collected (page id, s3key, site, reason) and logged, and the loop continues. The result is surfaced on the response as republish_summary (succeeded / failed / failures[]) so the caller can show a real outcome instead of trusting a silent all-or-nothing hook. Also fixed: CloudFront invalidation ran once outside the sites loop, so only the last site was invalidated — it now runs per site, so multi-site apps no longer leave other sites' caches stale.

block_layout preservation (KYTE-#189). The KytePage update content-save unconditionally wrote block_layout, blanking it to empty when the field wasn't in the payload. A partial save — notably the Shipyard IDE, which sends only html/stylesheet/javascript — would therefore wipe an existing block-editor layout. The update now only overwrites block_layout when it's actually provided, preserving the stored value otherwise. (The IDE "save silently doesn't persist" half of #189 was already resolved by the v4.8.0 guard relaxation; this closes the data-loss edge.)

kyte-php v4.8.0

Choose a tag to compare

@github-actions github-actions released this 31 May 09:00
9021c54

Change: drop JavaScript obfuscation columns (Phase 2 — schema; resolves KYTE-#191 Phase 2)

Phase 2 of the JS-obfuscation removal (KYTE-#191). Phase 1 (v4.7.0) stopped obfuscation behaviorally but deliberately left the obfuscated columns in place (inert) so an expand/contract rollout could prove stable before any schema change. This release contracts: it removes the now-dead columns and all remaining code that touched them.

  1. Schema drop (9 tables, via migrations/4.8.0_drop_obfuscation_columns.sql). Drops the inert obfuscated content columns javascript_obfuscated (KytePageData, KytePageVersionContent, KyteSectionTemplate), content_js_obfuscated (KyteScript, KyteScriptVersionContent), and kyte_connect_obfuscated (Application), plus the now-unused flags obfuscate_js (KyteSectionTemplate, KyteScript, KytePage, KytePageVersion, KyteScriptVersion) and obfuscate_kyte_connect (Application). This reclaims the stored-duplicate storage bloat — every page/script/section had been storing a second bzcompressed copy of its JS that was never served. The migration does not pin an ALGORITHM: modern engines (MySQL 8.0.29+, MariaDB 10.5+) perform the DROP COLUMN as an INSTANT, no-rebuild operation automatically, and older engines fall back to INPLACE/COPY automatically. (An explicit ALGORITHM=INSTANT would error on an engine that can't honor it rather than fall back, so it is intentionally omitted.)

  2. Model definitions removed. The five field identifiers are deleted from the Mvc/Model definitions (KytePageData, KytePageVersionContent, KyteSectionTemplate, KyteScript, KyteScriptVersionContent, KytePage, KytePageVersion, KyteScriptVersion, Application).

  3. bz storage handling removed. All bzcompress/bzdecompress of the obfuscated fields is gone from the page/script/section/library/nav/sidenav/application controllers (compress-on-write, decompress-on-read, the storeVersionContent/storeScriptVersionContent writes, and the getCurrentPageData/getCurrentScriptData reads). The safeCompressCode/safeDecompressCode helpers are retained — content still uses them.

  4. isset() guards relaxed. The content-save/publish/decompress guards (e.g. KytePageController update + publishPage, and the footer/header decompress guards across the page/data/application/library/sidenav/navigation/script controllers) previously required the obfuscated field alongside html/stylesheet/javascript/block_layout. Only the obfuscated element was removed from each guard; the guards still validate the real content fields.

ROLLOUT RULE (code-first / drop-last, expand-contract): deploy the v4.8.0 code to all instances before running migrations/4.8.0_drop_obfuscation_columns.sql. The v4.8.0 code no longer references the obfuscated columns, so dropping them is safe once it is live; running the migration against v4.7.0-or-earlier code would break content save/publish/decompress. Shipyard may keep sending empty obfuscated payload keys harmlessly — v4.8.0 ignores unknown fields.

kyte-php v4.7.0

Choose a tag to compare

@github-actions github-actions released this 30 May 09:56
7c96eb0

Change: drop JavaScript obfuscation (Phase 1 — behavioral; resolves KYTE-#188, KYTE-#191 Phase 1)

JS obfuscation has been forced on every Kyte install since v1. It provides no real security (client JS runs in the browser and obfuscation is trivially reversible; it is not a recognized control under any compliance framework), it bloats storage (each obfuscated field is a stored duplicate of the source), and it has caused operational breakage — a WAF/firewall once blocked legitimate obfuscated JS. No customer ever asked for it.

It also drove a version-bloat bug (KYTE-#188): re-obfuscation is non-deterministic, so the publish/save path saw the obfuscated bytes change on every publish even when the human-authored source was byte-identical, spawning a content-identical KytePageVersion/KytePageVersionContent each time.

This release stops obfuscation behaviorally, without a schema change. The obfuscated columns are left in place (inert) and will be dropped in a follow-up (Phase 2) once this is confirmed stable across installs — expand/contract so a code rollback never strands a dropped column.

  1. Publish always serves plain source. KytePageController::buildJavaScript() (page JS, the kyte_connect block, and header/footer JS via buildHeaderFooterJS()) and KyteScriptController::handleScriptPublication() now emit the plain javascript / content / kyte_connect regardless of the obfuscate_js / obfuscate_kyte_connect flags. The plain source has always been stored alongside the obfuscated copy, so this is lossless; existing pages de-obfuscate on their next publish.

  2. Obfuscation removed from change-detection (the KYTE-#188 fix). detectChanges()/addChangedFieldsToVersion() (KytePage) and detectScriptChanges()/addChangedFieldsToScriptVersion() (KyteScript) no longer include javascript_obfuscated/content_js_obfuscated in their content-field sets or obfuscate_js in their metadata sets. A publish of unchanged source no longer spawns a spurious version. (generateContentHash() already excluded the obfuscated field.)

  3. New-app default. Application.obfuscate_kyte_connect now defaults to 0 (was 1). Moot behaviorally since publish ignores the flag, but keeps new rows honest.

No migration. No DB change. Backward/forward compatible during rollout: the storage hooks still accept the *_obfuscated columns, so an older Shipyard that still sends an obfuscated blob keeps working (kyte-php just ignores it at publish), and a newer Shipyard that sends an empty obfuscated value + obfuscate_js=0 also works. Phase 2 (a later release) removes the model fields, the remaining bz*/decompress handling, and drops the columns to reclaim the stored-duplicate bloat.

kyte-php v4.6.1

Choose a tag to compare

@github-actions github-actions released this 29 May 03:02
241cb88

Bug Fix (regression from 4.6.0): large MCP tool responses corrupt the session → read_page (and other large reads) fail

A read_page against a large page (≈300KB+ of HTML) failed with a 400 and "Control character error, possibly incorrectly encoded". Root cause is the new DbSessionStore from 4.6.0: it defined KyteMCPSession.payload as TEXT (64KB max).

The streamable-HTTP SDK persists its outgoing-message queue (_mcp.outgoing_queue) — the full JSON-RPC tool response — inside the session payload between handling and delivery. A large read produces a ~800KB response (the SDK also duplicates content as a text block and structuredContent); that overflows the 64KB column, MySQL silently truncates it at 65535 bytes, and the truncated JSON then fails json_decode on the next read → the ctrl-char error, surfaced to the client as a 400.

The FileSessionStore that 4.6.0 replaced wrote to files with no size cap, so large reads worked there — this is a parity regression, not a pre-existing bug.

Fix: KyteMCPSession.payload is now LONGTEXT (model type lt). migrations/4.6.1_mcp_session_payload_longtext.sql runs ALTER TABLE ... MODIFY payload LONGTEXT and purges any sessions already truncated under the old column (LENGTH(payload) >= 65535) so stale corrupt rows don't keep failing on resume. Single-instance installs on the file backend are unaffected. Run the migration on every install that took 4.6.0 with the default DB store.

kyte-php v4.6.0

Choose a tag to compare

@github-actions github-actions released this 29 May 01:52
b12a3da

Feature: DB-backed MCP session store (cross-instance / load-balanced support)

MCP protocol sessions were stored via the SDK's FileSessionStore under sys_get_temp_dir()local to a single host. On a deployment behind a load balancer (e.g. ETOM's two instances), the initialize request lands on instance A and the follow-up request hits instance B, which has no session file → the SDK returns -32600 "Session not found or has expired" → the MCP client falls back to OAuth discovery (which Kyte doesn't serve) → the connection fails. Single-instance installs were unaffected.

This release adds Kyte\Mcp\Session\DbSessionStore, which persists protocol sessions in a new KyteMCPSession table so any instance sharing the database resolves any session, and makes it the default backend.

  1. New table: KyteMCPSession (migrations/4.6.0_mcp_session_store.sql). Stores session_id (RFC4122 UUID, UNIQUE), the payload (the SDK's json_encoded session array), last_activity, and kyte_account. CREATE TABLE IF NOT EXISTS, safe to re-run. Auto-registered as a model via the Mvc/Model loader.

  2. DB store is now the default. Endpoint::buildSessionStore() selects the backend. The DB store is correct for any topology (single host, LB, future SaaS). Single-instance installs that prefer not to add the table can opt back to the file store with define('KYTE_MCP_SESSION_STORE', 'file');.

  3. TTL semantics preserved. last_activity is the last-write time; the SDK calls session->save() at the end of every handled request, so an active session's timestamp slides forward (idle timeout). exists() reports expiry without deleting; read() purges on expiry — both mirror FileSessionStore exactly. Idle TTL is KYTE_MCP_SESSION_TTL (default 3600s) for either backend.

  4. Tenancy & cleanup. Reads/writes/destroys are scoped to the bearer token's kyte_account (a session resolves only under the account that created it). gc() (the SDK runs it on ~1% of requests) is a global sweep of TTL-expired rows, capped at 1000 per call; rows are hard-deleted (purged), not tombstoned, so the session_id UNIQUE index stays clean.

Operational note: run migrations/4.6.0_mcp_session_store.sql as part of the upgrade. With the default DB backend active, the table must exist before MCP traffic is served. Multi-instance installs (ETOM) require no per-instance config beyond the shared DB; this unblocks the kyte-etometry MCP connection (Tempo KYTE-183). No change to MCP auth (KyteMCPToken), which was already DB-backed.

kyte-php v4.5.3

Choose a tag to compare

@github-actions github-actions released this 28 May 10:37
0ab20be

Bug Fix (critical): /jwt/refresh 401s for apps with a custom user_model

JwtEndpoint::refresh() reloaded the principal with a hardcoded new ModelObject(KyteUser). Apps that authenticate against an app-scoped user_model (a User DataModel, not KyteUser) store the user in the app DB, so KyteUser->retrieve('id', $userId) finds nothing → 401 invalid_credentials "Refresh token principal not found."

Impact: every JWT session on such an app dies on its first refresh — i.e. ~15 minutes (the access-token TTL) after login, presenting as "JWT randomly fails after a few minutes of inactivity." login() already handled custom user models (v4.4.3–4.4.5 via resolveAuthContext), but refresh() was never given the same treatment, so the bug was latent until an app with a custom user model was migrated HMAC→JWT.

Fix: refresh() now resolves the app from refresh_token.app_id, calls resolveAuthContext($appIdentifier) (which loads the app models + DB context and returns the correct user_model), and retrieves the principal from that model — mirroring login(). Default KyteUser path (no app scope) is unchanged.

No schema change. Composer upgrade is sufficient. Strongly recommended for any deployment running JWT on apps with custom user models.

Migration backfill: MCP token table

Adds migrations/4.5.3_mcp_tokens.sql — creates KyteMCPToken (consumed by McpTokenStrategy / the Shipyard Tokens page). The Phase 2 MCP code shipped without its table-creation migration; this closes that gap. CREATE TABLE IF NOT EXISTS, safe to re-run.

kyte-php v4.5.2

Choose a tag to compare

@github-actions github-actions released this 28 May 08:04
e5c4a64

Bug Fix: sensitive toggle on a Controller silently fails (TypeError on metadata-only PUT)

Completes the 4.5.1 fix for the third meta-controller. Toggling sensitive=1 on a Controller (the detail-page toggle) returned HTTP 200 with an empty body and reverted with "Save failed."

ControllerController::hook_preprocess (update) calls validateControllerUpdate($o, $r), which early-returns only when $o->name === $r['name']. A metadata-only PUT of {sensitive:1} omits name, so $r['name'] is null; "SomeController" === null is false, so it fell through and passed null into checkNameExistsInScope(string $name)TypeError → caught by the global exception handler (logged as a KyteError, hence the empty 200) → the update aborted before sensitive was saved.

Fix: validateControllerUpdate now returns early when name is absent — if (!isset($r['name']) || $o->name === $r['name']). The name-change path (which always sends name) is unaffected.

This is the Controller-level sibling of the 4.5.1 DataModel/ModelAttribute fixes — all three meta-controllers assumed every update carried the full record. With 4.5.2, sensitive flags can be set at controller, model, and field level. No schema change.

kyte-php v4.5.1

Choose a tag to compare

@github-actions github-actions released this 28 May 07:55
be90794

Bug Fix: sensitive toggle (and any metadata-only PUT) silently fails on DataModel / ModelAttribute

Toggling sensitive=1 on a model (Settings tab) or field never persisted: the PUT returned HTTP 200 with an empty body, and the Shipyard toggle reverted with "Save failed." Root cause is in two update hooks that assumed every update carries the full record:

  • DataModelController::hook_preprocess (update): if ($o->name != $r['name']) ran the table-rename path. A partial PUT of just {sensitive:1} omits name, so the comparison was true against nullDBI::renameTable($o->name, null) → throws "New table name cannot be empty"before $obj->save() persisted sensitive. Now gated on isset($r['name']) && $o->name != $r['name'].

  • ModelAttributeController::hook_preprocess (update): unconditionally ran DBI::changeColumn($tbl->name, $o->name, $r['name'], $attrs) on every update. A metadata-only PUT (no name) tried to rename the column to an empty name with an incomplete definition and threw. The CHANGE COLUMN path is now gated on isset($r['name']); the field-edit form (which always sends name) is unaffected.

Why it surfaced now: this is the first feature to PUT a single metadata field on these meta-models. The empty-200 was the swallowed hook exception (thrown after the 200 status path but before the response body was serialized). Controller sensitive toggles were never affected — ControllerController has no schema-altering update hook.

Impact: blocks enabling sensitive-data redaction at the model/field level (the redaction policy reads these flags at runtime). No schema change. Composer upgrade is sufficient.

kyte-php v4.5.0

Choose a tag to compare

@github-actions github-actions released this 28 May 06:48
adb50c1

Feature: JWT session lifetime caps (inactivity + absolute)

JWT sessions in 4.4.x were effectively unlimited — every /jwt/refresh issued a fresh refresh token with expires_at = now + 7d, and the 15-minute access TTL meant any page activity rotated the refresh token forward indefinitely. A user could stay logged in for weeks just by opening the app every few days. This violates OWASP ASVS V3 ("absolute timeout MUST exist") and is the wrong default for an admin tool / regulated-industry web app.

This release introduces a two-knob policy that matches the industry-standard pattern (sliding inactivity timeout + absolute family cap):

  1. Lowered inactivity timeout. KYTE_JWT_REFRESH_TTL default drops from 604800s (7d) to 14400s (4h). Closing the browser at 5pm now forces a re-login the next morning. Per-deployment override still applies — consumer mobile apps with "remember me" can opt for longer.

  2. New absolute family cap. KYTE_JWT_FAMILY_MAX_LIFETIME (new constant, default 43200s / 12h) caps total session lifetime from the original /jwt/login, independent of how active the user is. Enforced in RefreshTokenStore::rotate() — when crossed, the whole token family is revoked with revoked_reason='family_max_lifetime' and the user must re-authenticate.

  3. New column: KyteRefreshToken.family_started_at. Anchors the absolute cap to the original login moment. Set in issue(), copied forward unchanged in issueInFamily() on each rotation.

    Legacy tokens (issued before this column existed) are capped, not exempted. A pre-upgrade row has family_started_at = 0; rotate() anchors its cap to the token's date_created (the best available proxy for the original login). A pre-upgrade session whose login was more than KYTE_JWT_FAMILY_MAX_LIFETIME ago is revoked on its next rotation. This means the first deploy of 4.5.0 forces re-login for any JWT session older than 12 hours — intentional. An earlier draft of this change gave legacy tokens a free pass "to avoid disruption," but that let pre-upgrade 7-day sessions survive uncapped for up to a week after deploy (observed on dev), which defeats the purpose of an absolute cap.

    Operational note for upgrades with active JWT sessions: after running the migration, existing sessions older than the cap end on their next request (clean 401 → client re-login). If you want a hard cutover instead of a staggered one, revoke all pre-upgrade rows directly: UPDATE KyteRefreshToken SET revoked_at=UNIX_TIMESTAMP(), revoked_reason='legacy_purge_v4.5.0' WHERE family_started_at=0 AND revoked_at=0;

The defaults align with AWS Console (12h max), Microsoft 365 admin (1h/12h), and OWASP ASVS V3 absolute-timeout requirements. Customer mobile/consumer apps that need longer sessions can override both constants per-deployment.

Schema migration: KyteRefreshToken gains one unsigned-int column. Run migrations/4.5.0_jwt_family_lifetime.sql after composer update — Kyte does not auto-ALTER system tables. Same operational pattern as the 4.4.0 sensitive-columns + JWT-refresh migrations.

Config note: deployments that explicitly set KYTE_JWT_REFRESH_TTL in config.php (e.g. the 4.4.0 default of 604800) override the new 4h default — update those configs to 14400 and add KYTE_JWT_FAMILY_MAX_LIFETIME or the new inactivity timeout is silently shadowed.

Client pairing: kyte-api-js must be ≥ v2.0.2 (refresh-cookie TTL derived from refresh_expires_at). With the older v2.0.1 client, the browser cookie keeps a 30-day TTL, so an idle tab looks logged in under a dead server-side session until the next request fails. Both halves are required for correct UX.

Tests: RefreshTokenStoreTest covers (a) family_started_at set on issue, (b) preserved across rotation, (c) cap rejection past the window, (d) cap allows refresh inside the window, (e) legacy token within cap anchors to date_created, (f) legacy token past cap is revoked.

kyte-php v4.4.5

Choose a tag to compare

@github-actions github-actions released this 22 May 17:38

Bug Fix + Feature: JWT login parity with HMAC session response

Three changes for JWT login on apps with custom user_model:

  1. Account fallback for app-scoped users. JwtEndpoint::login bailed with 401 when user->kyte_account was 0. App-scoped User models don't carry that FK directly — the user belongs to the Application, and the Application carries the account FK. Fall back to $app->kyte_account when the user has no direct account.

  2. HMAC-parity response shape. Adds uid, account_id, and data (user payload, with protected fields stripped, wrapped per USE_SESSION_MAP) to the JWT login response. Customer frontends consuming session.data[0].fieldname or session.uid were silently failing under JWT because the payload was tokens-only.

  3. FK expansion in data. Mirrors HMAC's SESSION_RETURN_FK behavior — user.org is now expanded into the full Org object ({id:2, org_type:'LP', ...}) instead of returned as the raw integer FK. Bounded recursion at depth 3 to handle (rare) cyclic FKs. Frontends doing user.org.org_type now work.

Known follow-up: FK expansion is N+1 — see Tempo card #171 for optimization.

No schema changes. Composer upgrade is sufficient.