Uh oh!
There was an error while loading. Please reload this page.
fix(service-analytics): gate the /analytics/query SQL echo on debug, as the contract declares (#8286) - #8312
Conversation
…8286) POST /api/v1/analytics/query returned the executed statement to callers in data.sql on every deployment, NODE_ENV=production included, with no debug flag requested. The contract has always declared the field debug-only (AnalyticsResultResponseSchema: `sql: z.string().optional().describe('Executed SQL (if debug enabled)')`) — no implementation ever read a switch. The gate lands at the response-assembly seam, AnalyticsService.query, which is the single point every strategy's result leaves through: NativeSQLStrategy returns the statement it ran, ObjectQLStrategy renders a representative one, and FallbackDelegateStrategy passes through whatever the delegated service minted. queryDataset reaches the same seam via DatasetExecutor. generateSql — the dedicated /analytics/sql dry-run route — is deliberately not gated. New host switch `debugSql` (AnalyticsServicePlugin -> AnalyticsServiceConfig). Unset resolves to NODE_ENV === 'development' and nothing else: an unset NODE_ENV counts as production, matching how os start / os serve / os doctor read that absence. No request field: a caller-settable flag would let any tenant reopen the disclosure. Kept separate from the plugin's `debug` log-verbosity option so raising log level cannot widen what travels to a tenant. Tests: every absence pin is paired with a presence pin on the same cube, query and rows, differing only in the switch, and each arm captures the statement server-side so absence means withheld rather than never-minted. Three existing suites state the precondition they now depend on — including the #7598 cross-field pin, whose absence assertion would otherwise have stopped measuring the renderer's decline and started measuring the gate. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARidKDYSCD56LaygrvDPnk
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 1 package(s): 5 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
⛔ 3 release-owned page(s) also reference the affected code. These are read-only:
|
Uh oh!
There was an error while loading. Please reload this page.
Fixes#8286
POST /api/v1/analytics/queryreturned the executed statement to callers indata.sqlon every deployment —NODE_ENV=productionincluded — with no debug flag requested and none available to request. The contract has declared the field debug-only since it was introduced (packages/spec/src/api/analytics.zod.ts:75,sqltypedoptional()and described as "Executed SQL (if debug enabled)"); no implementation ever read a switch. This restores declared = enforced.packages/specis untouched — the contract was already right.What was disclosed, precisely
More than table and column names. The echoed statement carries the compiled read scope, so it describes the shape of the isolation predicate: on the reported deployment it showed
sys_userwalled by an enumerated"sys_user"."id" IN ($2, $3, …)member list rather than by anorganization_idcomparison — which column the wall is built on, and how — plus the bound-parameter arity, which counts the caller's own organization's membership.No wall was breached. Information disclosure only. The reporter ran the isolation probes on that deployment and every one held: cross-tenant read 404, cross-tenant update/delete 403 at row-level security, a foreign-org
filter/whereempty, batch write by foreign id per-rowPERMISSION_DENIED, audit log and activity stream partitioned. The wall works; it should not have been describing itself.Where the gate lands
At the response-assembly seam —
AnalyticsService.query, the single point every strategy's result leaves through — not on any one strategy. Triage's concern was measured and correct:sqlNativeSQLStrategyObjectQLStrategyFallbackDelegateStrategyGating one would have left the other two serving.
queryDatasetreaches the same seam throughDatasetExecutor, so dataset-backed dashboard and report responses inherit the verdict with no second gate to keep in step.generateSql— the dedicated/api/v1/analytics/sqldry-run route — is deliberately not gated: handing back a statement is that route's entire purpose, and it is where a debugging author should look.The switch
New
debugSqlonAnalyticsServicePlugin, forwarded toAnalyticsServiceConfig. Unset means "no host choice", which resolves toNODE_ENV === 'development'and nothing else — an unsetNODE_ENVcounts as production and the echo stays off, matching howos start,os serveandos doctoralready read that absence (the 2026-08-06 ruling for machine-readable environment answers). Of the two ways to be wrong, disclosing on a production deployment whose operator forgot the variable is the dangerous one.Two deliberate non-choices:
packages/spec, which this card forbids and does not need.debugoption, which stays server-side log verbosity. Folding them together means a support engineer raising log level on a live deployment silently reopens the disclosure.I took the debug-gate route rather than the card's alternative (drop
sqlfrom this response entirely). Measured, not assumed: nothing consumes the echo today — no reader in this repo outside tests, and../objectuihas no analyticssqlconsumer at all (its only SQL references areSqlDriverInterface/SqlQueryASTtype names in the data protocol). So removal would not have broken a consumer — but the field is declared in the response contract, and deleting a declared field to solve an enforcement bug is a contract change to fix an implementation defect. Gating it enforces what the contract already says, and keeps the echo reachable where an author legitimately needs it.Tests — every absence pin is paired
"
sqlis absent" is trivially green against any fixture that never minted one. Newsrc/__tests__/sql-echo-debug-gate.test.ts(15 pins) is built so no absence claim stands alone:executeRawSql/ the delegated service / the rendered echo) and asserts it is real, proving the fixture capable of disclosure before absence is claimed of it;queryDataset, plus the production default withdebugSqlnot passed at all (the repro), plusNODE_ENVunset andNODE_ENV=development, plus the ungated dry-run route.Absence is asserted as
toBeUndefined()withcross-field-engine-fallback.test.ts's own message — absent, not falsy, and not a second phrasing for one property.Three existing suites, triaged individually
analytics-service.test.ts:429(NativeSQL always includes sql) — its subject is routing, and the echo was only the witness it read;ObjectQLStrategywould have died there for want of an aggregate bridge, so a statement coming back means NativeSQL won. Re-pointed: the witness now states its precondition (debugSql: true) and the trailing comment no longer claims the echo is unconditional.dataset-selection-window.test.ts, the#3588 — the echoed SQL tells the truthblock — its subject is the echo's content, which still exists as behaviour with a precondition. The three tests enable the echo explicitly rather than inheriting whateverNODE_ENVthe runner has.cross-field-engine-fallback.test.ts:469— the important one. Its absence pin passes on this branch for free, because a gated-off service echoes nothing; it would have stopped measuring the renderer's decline and started measuring the gate. That service now enables the echo, sosqlbeing absent again means what the file says it means.Reverse verification
Direction predicted before running, then measured on
origin/main(source files reverted, new tests kept):NODE_ENV=production: the echo is absent (the reported deployment):AssertionError: the echo must be absent, not half-rendered: expected 'SELECT COUNT(*) AS "count" FROM "sys_…' to be undefined. These are the reds that prove the fix.mainthey pass for the wrong reason (an unconditional echo satisfies "present when debug is on"); after, for the right one. They accompany the fix rather than prove it.debugSqlis simply an unknown key onmain, so those edits change no reading there.Verification run
pnpm --filter @objectstack/service-analytics test— 74 files, 1670 tests, all pass.@objectstack/rest108 files / 1800 tests and@objectstack/runtime150 files / 2306 tests, all pass against the rebuiltdist.tsc --noEmiton the package with its closure built: 10 errors, identical to thecheck-type-check-coverageDEBT ledger entry — none from this diff.check:nul-bytes,check:docs-audit-scope,check:test-source-alias,check:type-source-resolution,check:changeset-gate-self-tests,check:objectui-changeset,check:query-options-erasure,check:type-check-coverage,check-changeset-fixed.mjs,check-changeset-no-major.mjs. The gate list was re-derived against the actual changed paths withscripts/pm/dispatch-gates.mjs, which added five families the dispatch list did not name; all five are above.Two local reds that are not this diff, reported rather than worked around:
check:type-check-debtrefuses to run locally without the whole workspace build closure on disk (--re-measure ... 47 workspace dependenc(ies) ... have no built type entry point) — the known local-ergonomics limitation tracked incheck:type-check-debt --re-measuretrusts staledist/: phantom upward drift for ledgered packages whose deps resolve to build artifacts, unless the caller builds the closure first #8271, and CI builds that closure before the step. The number it would measure is pinned directly above.check-objectui-pin-freshis red because.objectui-shano longer describes objectuimain. That file is byte-identical toorigin/mainhere and this branch touches no objectui file; it matched only because the diff adds a.changesetentry. Pre-existing repo state, and moving the console pin is a separate procedure.Scope
packages/services/service-analytics/src/**plus one changeset.packages/spec/src/**,docs/adr/**,.claude/skills/**,skills/**andcontent/docs/releases/**untouched. Nothing here goes near service-analytics' comparand allow-list files, so #8186 is not addressed here and its surface stays clear.Generated by Claude Code