Uh oh!
There was an error while loading. Please reload this page.
fix(objectql): fold internal: true into the aggregate guard (#7922) - #7984
Conversation
`rejectCredentialAggregation` decided what to refuse by asking `collectCredentialFields`, a collector keyed on the field TYPE (`secret` / `password`). That left it blind to ADR-0100's third credential channel — an auth-subsystem one-way hash living in an ordinary `text` column — which is exactly the channel #7728 minted the type-independent `internal: true` flag for. So the read path understood "protected by flag" while the guard still only understood "protected by type": a flagged column that `find` omitted could be named as a `groupBy` dimension or a MIN/MAX measure and come back as the group key itself. The guard now takes the deduped union of the two collectors. Composition happens at the call site — the collectors stay separate because their other consumers answer differently (the read path MASKS a credential type and OMITS a flagged field, and a flagged column must never acquire a mask). Nothing is disclosed by this today: there is no `/data/:object/aggregate` route and analytics requires a declared dataset, so no reachable caller could reach the gap. It is closed because the inconsistency is what bites the next adopter — `sys_api_key.key` is a SHA-256 hash, but `sys_session.token` (#7823) is a live bearer credential. Tests extend the existing #7728 floor (`internal-fields.test.ts`) rather than forking a second copy. The three CONTROL cases come first and are the load-bearing ones: an unflagged column on an object that HAS a flagged one still aggregates, an object with no flagged field is untouched, and COUNT(*) is not a false positive. An over-broad guard breaks analytics silently, so all three were verified falsifiable against two deliberate mutations of the guard.
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 1 package(s): 15 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
⛔ 1 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.
Closes#7922.
Nothing is disclosed by this today, and this is not a security fix. There is no
/data/:object/aggregateroute, and analytics requires a declared dataset — no reachable caller could reach the gap. Re-checked on this branch, the card's grade stands unchanged.It is closed because the inconsistency is what bites the next adopter, not the current one.
sys_api_key.keyis a SHA-256 hash (aggregating it leaks little);sys_session.token(#7823) is a live bearer credential. The flag's own declaration — "the declared value is never returned on the generic data path" — reads as though it already covered both.The gap
rejectCredentialAggregationdecided what to refuse by askingcollectCredentialFields, a collector keyed on the field TYPE (secret/password). ADR-0100's third credential channel is an auth-subsystem one-way hash living in an ordinarytextcolumn, which no type-keyed collector can ever reach — which is precisely why #7728 (PR #7920) minted the type-independentinternal: trueflag and taughtfind/findOne/ the 201 create body / the by-id update body to omit it.The aggregation guard was never taught the same thing. The read path understood "protected by flag"; the guard still only understood "protected by type". A flagged column that
findomitted could be named as agroupBydimension or a MIN/MAX measure and come back as the group key itself — the flag's promise stopping at the edge ofaggregate().The composition, measured (not inferred from the names)
The card's⚠️ asked for this to be measured before writing the guard. Both collectors live in
packages/objectql/src/secret-fields.ts:managedBytreatmentitest_api_key(better-auth,key: text + internal)collectCredentialFields(:169)type === 'secret' || 'password'[]collectInternalReadFields(:144)internal === true(strict)['key']The union is clean, and for a stronger reason than "they happen to agree": neither side has a
managedByexemption, so the union cannot acquire one. The exemption that exists elsewhere (collectMaskedReadFieldsskipspasswordon better-auth so login reads still see the stored value) is on a third collector that does not feed this guard at all. There is no interaction to get wrong.Two details the measurement did surface, both handled:
secretcolumn also flaggedinternal), so the union is deduped — otherwise the error message names the field twice. Pinned by a test.requiredcolumn the mask is zero bits, and it would still put a value under a field whose declaration promises none). Composition happens at the call site; both docstrings now say so.What changed
packages/objectql/src/engine.ts(rejectCredentialAggregation, one seam) — refuse over the deduped union of the two collectors; error message and docstring updated to name both channels.packages/objectql/src/secret-fields.ts— docstrings only. Each collector now names the other as its counterpart in this gate, with the ⛔ against collapsing them.packages/objectql/src/internal-fields.test.ts— +8 cases extending the existing api-key-ui-lifecycle (secondary): thekeycolumn (SHA-256 hash) serializes over the data API, contradicting its own "never exposed to clients" description #7728 floor.No new error code is minted — the guard throws the same plain
Errorit always has, and theCannot aggregate credential field(s)prefix is kept deliberately so the pre-existing #3171 assertions insecret-fields.test.tsstay green rather than being edited to match a new message.Tests — the CONTROL cases come first, and are the load-bearing ones
A guard that refuses too much breaks analytics silently, so the three negatives were written before the guard was touched:
prefix) on an object that has a flagged one still aggregates — asserting the real buckets ({osk_: 2, svc_: 1}), not merely "does not throw";key, so a guard collecting field names globally rather than per-schema would fail here;COUNT(*)on the flagged object is not a false positive.Then the positives: flagged field as string
groupBy, as a structured{field}bucket, as a MIN/MAX measure, still refused on amanagedBy: 'better-auth'object, and the message names each refused field once without slandering the legitimate dimension alongside it.Per the card, this extends
internal-fields.test.tsrather than forking a second copy: the #3171 type-keyed floor insecret-fields.test.tsand theapi-key-hash-not-serializeddogfood test are reused as-is and both still pass.Reverse verification
origin/main, new tests in place5 failed | 13 passed. The failure output printed the disclosure itself:[{key: "sha256:deadbeefcafe", n: 1}, …]— one bucket per distinct hash, keyed by the hash.protectedFields = Object.keys(schema.fields)(refuse everything)3 failed, the third being "names every refused field once", which also catches over-breadth by seeingprefixin the messagehit = protectedFields(refuse regardless of reference)2 failedAll three CONTROL cases are therefore tests that can actually fail, which was the card's requirement for them.
Gates — all watched go green
pnpm --filter @objectstack/objectql testpnpm typecheckpnpm buildpnpm check:nul-bytespnpm check:error-code-casingpnpm check:query-options-erasurepnpm check:type-check-debtapi-key-hash-not-serialized.dogfood.test.tseslinton the three changed filescheck:query-options-erasurewent red first (242 → 257): my newaggregate()calls usedas any. Fixed by typing them — aconst SYSTEM: EngineReadOptionsfor the trailing options, and contract-typed query literals. Back to 242.check:type-check-debtthen went red (objectql TEST_DEBT 355 → 356): the structured-bucket case is genuinely off-contract, becauseEngineAggregateOptions.groupByis declaredz.array(z.string())while the engine reads{ field, dateGranularity }buckets too. Spelledas unknown as EngineAggregateOptions— the form the ratchet's own message prescribes for deliberately off-contract input, which names the contract being bypassed instead of erasing it. Back to 355.Neither baseline was raised and
--lowerwas not run. (check:type-check-debtwas run against a built closure, so its verdict is about this diff rather than a missing-module cascade.)Found and deliberately NOT fixed
EngineAggregateOptions.groupByis narrower than the engine's real contract (declaredstring[]; the engine and its date-bucketing path read structured{ field, dateGranularity }objects). That is the lint 规则:禁止对引擎/驱动查询选项做as any/: any擦除(#4721 的顺带项,已实测残余量) #4918 family, visible here only because it forced theas unknown asabove. Not touched.ctx.api.object().update()against a nonexistent id answers 400 (or worse) instead of 404, while the protocol and callData paths both gate correctly #7867's regions inengine.tswere not entered. The seam here is:9559-9621; the nearest Action-body writes have no not-found gate:ctx.api.object().update()against a nonexistent id answers 400 (or worse) instead of 404, while the protocol and callData paths both gate correctly #7867 seam is ~476 lines away. The import block at:126needed no edit at all —collectInternalReadFieldswas already imported there by fix(spec,objectql):internalfield flag — stopsys_api_key.keyserializing on the data API (#7728) #7920 — so the one plausibly shared spot in the file is untouched and the predicted one-line rebase does not arise.Left as a draft for PM review.
Generated by Claude Code