fix(platform-objects,plugin-email): stop serving sys_email.headers_json on the generic read path — internal: true + a privileged readback seam - #8705
Merged
Conversation
…on on the generic read path internal: true on the column plus a privileged readback seam on the delivery paths, adopting the remedy #8118 ruled and PR #8348 landed. Refs #8149 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MX1qcBzfwZb5wkRrJTNbhH
… changeset + i18n Drops the driver-memory devDependency in favour of the stub-driver pattern objectql/src/internal-fields.test.ts already pins the flag against, so the package adds no new workspace dep, no vitest alias config and no entry to the shrink-only unaliased-import ledger. The at-rest pin now scans raw storage instead of reading back through the privileged accessor. Refs #8149 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MX1qcBzfwZb5wkRrJTNbhH
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
Contributor
📓 Docs Drift CheckThis PR changes 2 package(s): 5 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
This was referenced Aug 14, 2026
qq9340100
marked this pull request as ready for review
August 14, 2026 15:27
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #8149
sys_email.headers_jsonstored custom email headers in cleartext on a table the ordinary data API reads (enable.apiMethods: ['get', 'list']). Custom headers are the ordinary place a credential goes — an SMTP relay'sAuthorization, a provider token, a routing secret — so this is the same shape #8118 ruled on forsys_http_delivery.headers_json. Per the card's own instruction, this adopts the remedy #8118 landed (PR #8348) rather than deciding it a second time.Premise re-verified first
The card's unlock re-check step 2 asks for this before any code:
sys_email.headers_jsonstill exists on the merged ref, inpackages/platform-objects/src/audit/sys-email.object.ts, unflagged. Premise holds — this is the S-sized consumer application of a per-object mechanism, not an already-delivered card.The change
headers_jsonis declaredinternal: true. The engine omits it from every generic read — list, get, an explicit?select=headers_json, write-response bodies — with no system carve-out (api-key-ui-lifecycle (secondary): thekeycolumn (SHA-256 hash) serializes over the data API, contradicting its own "never exposed to clients" description #7728's design).SYSTEM_CTXdoes not reopen it, which is pinned as its own assertion.ObjectQL.resolveInternalFieldis consumed unchanged — nopackages/objectqlfile is touched in this PR.Why a readback seam was required (the half a bare flag would have broken)
sys_emailis not delivered from the in-memory message but from the row. Three paths re-read a persisted row and hand it toEmailService.deliverPersistedRow:email-plugin.ts),email.send.asyncqueue subscriber,outbox-sweep.ts).All three read through
engine.find, which is exactly what the flag empties. A flag alone would not merely have hidden the headers from the data API — it would have dropped them from the mail actually sent, on every durable path, while every row still reportedsent. So the recovery ships with the flag, at the one chokepoint all three funnel through.Fail-closed, unweakened. A message whose authored headers cannot be recovered is not sent without them: a missing header is not self-announcing — a relay that does not require it accepts the mail while the delivery silently deviates from the authored configuration. That case throws and leaves the row
queued, notfailed, so the queue retry or the next boot's sweep delivers it intact — the analogue of #8118's claim-TTL revert. The recovery deliberately sits outside the catch that marks rows failed.Differences from PR #8348 I had to bridge
SqlHttpOutbox.claim(), its single dispatch path.sys_emailhas three re-read paths, so the seam sits atdeliverPersistedRow, the one function all three call, reached through a new optionalEmailPersistence.readHeadersJson.source: 'flow'half (A4) does not transfer, and I am saying so rather than letting the coverage argument read as inherited. [security] sys_http_delivery.headers_json still stores webhook credentials in cleartext — and every services-lane shape for fixing it is structurally wrong #8118 needed the row layer to cover flow-interpolated headers that never pass throughWebhookSchema.sys_emailhas no analogous second population:apiMethodsadmits nocreate, and the only writer is the mail service's own persistence seam. The row-layer placement is still what I used — it means an in-process writer added later inherits the protection — but the coverage claim here rests on there being one producer, not on the row layer catching a second one.headers_jsonisrequired: false, so "key missing ⇒ the strip ran" is invalid here — most real rows legitimately carry no headers, and that inference broke ordinary sign-in on the sibling card. This seam probes the object schema flag instead, which is cardinality-independent and is how fix(service-messaging,objectql): stop serving sys_http_delivery.headers_json on the generic read path — internal: true + a batch privileged accessor (#8118) #8348'sSqlHttpOutboxprobes too, so it needs noabsenceProvesStripdiscriminator at all. Pinned by a test that a header-less message still delivers.Tests
New
packages/plugins/plugin-email/src/email-headers-internal.integration.test.ts— realObjectQL, realSysEmailschema, realEmailService. Every redaction pin is paired with its wire pin: a "fix" that merely dropped the headers would satisfy every read-path assertion and break every authenticated relay in production.Built on the stub-driver pattern
objectql/src/internal-fields.test.tsalready uses, so the package adds no new workspace dependency, no vitest alias config, and no entry to the shrink-only unaliased-import ledger.Reverse verification (direction predicted before running): removing
internal: true, rebuildingplatform-objects, and proving the mutation reacheddist/turns 4 tests RED. The first attempt was a false green — this suite resolves@objectstack/platform-objects/auditfrom builtdist/, so the un-rebuilt ablation ran the pre-mutation artifact. The 2 tests that stay green under ablation are the deliberately-invariant ones (header-less delivery; the accessor refusing an unflagged field). Restoration was re-verified indist/afterwards.Per-package readings, all at
16a35089c:@objectstack/plugin-email@objectstack/platform-objects@objectstack/cli@objectstack/plugin-auth@objectstack/lint@objectstack/dogfoodtypecheckgreen for both changed packages.Gate union re-derived against the real changed paths and run at
16a35089c(the final commit):check:changeset-gate-self-tests,check:objectui-changeset,check:test-source-alias,check:type-source-resolution,check-adr-0087-registration,check-changeset-no-major,check-empty-changeset,check:i18n,check:nul-bytes,check:query-options-erasure,check:type-check-coverage,check:engine-double-contract— 12/12 pass.check:type-check-debtalso passes, measured with the full workspace closure built (it refuses outright on an unbuilt worktree, which means NOT MEASURED rather than not-applicable).i18n bundles regenerated via
node scripts/check-i18n-bundles.mjs --write(platform-objectsowns ani18n-extract.config.ts); only theenbundle moved.Deliberately NOT closed
The row still holds the header map in cleartext at rest until retention ages it out — pinned as a test so a future card flips it deliberately rather than by accident.
Field.secret()was measured and rejected on #8118 (an orphansys_secretrow per message with no cascade or retention, a boot-window fail-open, a per-row decrypt on every delivery); this PR adopts that ruling unchanged rather than re-litigating it.Generated by Claude Code