fix(sharing): gate the share-link route probe on publicSharing.enabled, at both probe sites - #14905
fix(sharing): gate the share-link route probe on publicSharing.enabled, at both probe sites#14905os-sales wants to merge 4 commits into
publicSharing.enabled, at both probe sites#14905Conversation
…d at both sites The route-level probe above `resolveToken` answered from the token row with no knowledge of the object's standing `publicSharing.enabled` policy, so a real-but-switched-off link carrying a `password_hash` still drew `401 NEEDS_PASSWORD` / `WRONG_PASSWORD` and one with `audience: 'signed_in'` still drew `401 SIGN_IN_REQUIRED` — the existence oracle `share-link-service` states in prose that it closes, re-opened one layer up. Both probe sites read the policy before answering from the row, and every arm (the 410 included) falls through to the generic `404 INVALID_OR_EXPIRED` that unknown, revoked, expired and ineligible tokens already give. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
…qual to unknown Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
The census page's line anchors into `share-link-service.ts` moved by exactly the 20 lines this branch inserted above `getPolicy`; repaired with the gate's own `--fix`, which rewrote 5 anchors and nothing else. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
…ope conformance double The envelope conformance harness stubs `SharingEngine` without `getSchema`, so under the gated probe every one of its four row arms (`NEEDS_PASSWORD`, `WRONG_PASSWORD`, `SIGN_IN_REQUIRED`, `EXPIRED_OR_REVOKED`) fell through to the generic 404 — correctly, and fail-closed, but the refusals whose ENVELOPE this module exists to pin were then unreachable. The double now declares the block for the object its probe rows name, and those rows name it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
📓 Docs Drift Check6 anchor(s) derived from 2 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 29 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 7bdc494efe280730663789d0b41b096c3d0f80a6 && git checkout 7bdc494efe280730663789d0b41b096c3d0f80a6
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 82252480aca213b16800a3ea43f11a2c3646e4b8 261cfb8d8f0e30f47515845fac09aabc3038c19d && git checkout -B drift-repro 82252480aca213b16800a3ea43f11a2c3646e4b8 && git merge --no-ff 261cfb8d8f0e30f47515845fac09aabc3038c19d
node scripts/docs-audit/affected-docs.mjs --json 82252480aca213b16800a3ea43f11a2c3646e4b8 |
✅ PM ACCEPT — verified on the diff, with one asymmetry raised for the reviewer
The two re-judged fixtures — the part I checked hardestEditing an existing test to accommodate a behaviour change is where a real regression hides, so both were read line by line rather than taken from the report. 1. 2. ⛔ Nothing was skipped, disabled or quarantined. The gate, verified structurallyBoth sites write the generic refusal once as a local
|
| site | call |
|---|---|
share-link-routes.ts |
engine.getSchema?.(row.object_name) — optional call |
runtime/src/domains/share-links.ts |
engine?.getSchema(row.object_name) — optional chain on engine only |
At the plugin site an engine lacking getSchema yields undefined ⇒ false ⇒ refuse, fail-closed. At the runtime site the same engine would throw, and a 500 is distinguishable from the 404 an unknown token gets — which is the shape of the oracle this PR closes, arriving by a different door.
I have not measured whether that is reachable — the runtime's per-env ObjectQL does carry getSchema, so it may be unreachable in practice, and I am not claiming it is a defect. I am raising it because this PR's whole thesis is that the two surfaces must answer identically, and here they defend differently against the same missing method.
Everything else, checked
- Census page: one line, row 37's five anchors
:449 :503 :507 :580 :610→:469 :523 :527 :600 :630— exactly +20 each, matching the 20 lines added abovegetPolicy. Pure line rot, repaired with the gate's own--fix; no prose or count hand-edited. - Changeset:
minoron both packages with an explicit BREAKING banner and an ADR-0087not-required (no-migration-prescription)marker carrying its reasoning — the shape this repo requires for a breaking runtime change, not a bare patch. - Ablation: two legs, direction predicted first, each site's pins reddening only for that site (5/42 and 4/17), mutation proven on disk by literal-text counts and restore proven by blob-hash equality plus an empty
git diff HEAD. The report also states why no rebuild is owed (relative imports) instead of skipping the question. - Typecheck NOT-MEASURED guard honoured: plain
tsc --listFileshas 0 hits for the edited test files, so the package typecheck says nothing about them; they are intsconfig.test.json, which is where the green comes from. That is the phantom-green trap being avoided rather than walked into. - Suites:
plugin-sharing730 passed,runtime3126 passed. Full-repopnpm lintexit 0, no narrowing. - Card citations re-derived and all held — including
share-link-eligibility.test.ts:992-1022. Nothing in the dispatch order was stale this time, and the report says so explicitly rather than staying silent.
⛔ Not undrafted and not armed. Clause-② yes per the ruling; needs:contract-review is on the card and on this PR. The tier route is currently via the director seat (see #14866's precedent) — this PR waits for a verdict, not for CI colour.
Generated by Claude Code
Contract review (Clause-②
|
| # | Patch | Owner |
|---|---|---|
| 1 | Rewrite the objectui section with the measured facts: for a link on a switched-off object the console renders the 404 copy instead of the password prompt (all three 401 arms) and instead of "expired or was revoked" (the 410 arm); both are the accepted consequence of option A; no objectui code change is owed | dev, patch round |
| 2 | Fix the mirror's citation at share-links.ts:52 → share-links-enforcement-context.test.ts |
dev, patch round |
| 3 | Re-post the card's claim in the machine-legible spelling | this seat — done, see below |
| 4 | File the follow-up card for the duplicated predicate | this seat — filing now |
On patch 3: the script says ⛔ do not fill the line in on the claiming seat's behalf. This seat is the claiming seat for #14637, so posting it is not acting on anyone's behalf — it is this seat making its own declaration in the spelling the gate can read. The judgement itself is unchanged: Clause-②: yes, exactly as originally declared in prose.
On patch 4, the review overturned the dev's reason for not filing, and correctly: the card's "duplicated twin" is the pre-existing two-surface probe, not the predicate copy this PR introduces, and #14637 closes with this PR, so the copy would be untracked. It also refuted the PR's own justification for the mirror — "importing it would invert the dependency direction" is true only of the specific home proposed; five packages are already in the dependencies of both plugin-sharing and runtime.
Non-blocking notes carried forward, not lost
§5 notes 2, 3 and 4 are worth folding into the patch round even though they do not gate: the changeset's "Consumer impact" paragraph names only the password consequence while the 410→404 shift is equally consumer-visible (note 2 — and patch 1's measurement is exactly what makes that concrete); only the revoked half of the 410 arm is pinned, an expired case is cheap (note 3); and runtime's getEngine() host/scoped divergence is inherited, not widened (note 4 — record, do not fix here).
⛔ Stays draft and unarmed. needs:contract-review remains hung on this PR and on #14637 until the required patches land and this seat re-reads the delta.
Generated by Claude Code
Fixes #14637
Gates the share-link route probe on the object's standing
publicSharing.enabledpolicy, at both probe sites, per the maintainer's ruling of 2026-09-03
(decision batch #17, item 1 — verbatim 「同意」, adopting option A; recorded on
14637#issuecomment-5522728999). Option B (keep the 401 and document the accepted
oracle) is the recorded fallback and is not taken; option C (gate only the two 401
arms) is rejected as proliferation.
The defect
resolveTokenrefuses a link whose object haspublicSharing.enabledoff, andrefuses it with the undifferentiated
nullthat revoked, expired, unknown andineligible tokens all get — because, in that gate's own prose, for a caller who
may hold nothing but a token, a distinguishable "sharing is off for this object"
is an existence oracle.
The HTTP layer above it re-opened exactly that oracle. Both share-link surfaces
run a row probe after
resolveTokenreturns null, and both answered from thesys_share_linkrow with no knowledge of the object's block. So an anonymouscaller could still tell a real-but-switched-off token from an unknown one:
password_hashdrew401 NEEDS_PASSWORD, and with any password401 WRONG_PASSWORD— including a correct password, which is both an oracleand a lie, since that link can serve nothing;
audience: 'signed_in'drew401 SIGN_IN_REQUIRED;410 EXPIRED_OR_REVOKED.A security property stated in one layer and defeated in the layer above it is
worse than one never claimed, because the next reader believes the comment.
The change, at both sites
origin/mainf116b8fpackages/plugins/plugin-sharing/src/share-link-routes.tspackages/runtime/src/domains/share-links.tsBoth probes read the policy before they answer from the row; when the block is
off every arm falls through to the generic
404 INVALID_OR_EXPIRED. Theruntime file is the dispatcher twin and, for cloud's per-environment kernels, the
designed primary surface (
registerShareLinkRoutes: false), so landing at onesite alone would have moved the oracle rather than closed it — which the ablation
below measures rather than asserts.
Two supporting shapes:
isPublicSharingEnabled(schema)is exported fromshare-link-service.ts(line 103) and
getPolicynow delegates to it, so the route asks the service'sown question instead of spelling a second
publicSharing.enabledread. It isnot added to the package's public
index.ts— nothing outside the packageneeds it.
packages/runtimecarries a documented local mirror because@objectstack/plugin-sharingis a dev dependency there; importing it wouldinvert the dependency direction for one boolean. The proper shared home is a
member on the
IShareLinkServicecontract inpackages/spec, which this cardcannot open (single-owner, another lane).
invalidOrExpiredclosure, so the gated arms are byte-identical to the unknown-token answer by
construction rather than by three copies staying in step.
Fail-closed on an unreadable policy is deliberate: an object whose schema the
engine cannot answer for is
enabled: falsebygetPolicy's definition, which isthe answer
resolveTokenandcreateLinkalready reach.Pins
Both sites, both shapes, plus the 410 arm and the unreadable-policy case — 4 new
cases per site, each asserting byte-equality with the unknown-token answer
(
toEqualon the whole captured answer andJSON.stringifyequality, whichalso pins key order) rather than merely "a 404", and each carrying its reverse
check that with the block ON the 401s, the 410 and the 200 are exactly what they
were.
packages/plugins/plugin-sharing/src/share-link-eligibility.test.ts:1155—driven through the real
registerShareLinkRouteson the realShareLinkServiceover a live
SqlDriveron better-sqlite3:memory:, with the route's securedefault context (every request anonymous).
mountResolveRoutegained twooptional widenings (a verified signed-in id; a request query), both inert for
every caller that predates them.
packages/runtime/src/domains/share-links-enforcement-context.test.ts:727—driven through the production
handleShareLinksRequestwith the realShareLinkServiceand the real ADR-0112 envelope builder, over that file'salready-pinned storage double.
makeEnginegained an optional schema map so atest can flip the block and take the schema away; omitted, its
getSchemaisbyte-for-byte what it was.
Ablation — direction predicted before the run
Predicted: reverting either site's gate reddens only that site's pins, and
leaves the other site green. Each leg reverts one file to the branch point, proves
the mutation reached disk by literal-text counts, runs both suites, restores with
git checkout HEAD --and proves the restore by blob-hash equality plus an emptygit diff HEAD.share-link-routes.tsisPublicSharingEnabled3 to 0,invalidOrExpired3 to 0Tests 5 failed | 37 passed (42)Tests 17 passed (17)runtime/.../share-links.tsisPublicSharingEnabled3 to 0,invalidOrExpired3 to 0Tests 4 failed | 13 passed (17)Tests 42 passed (42)Exactly as predicted, and the fifth red in leg A is the pre-existing
[#14033] at the HTTP seamcase, whose amended tail also depends on the gate.Both files restored to their head blobs (
03632ed7…,d3cfa4aa…) with an emptygit diff HEAD. Neither mutated file is resolved through a packageexportsmap— both are imported relatively by their own package's tests — so no rebuild sits
between the mutation and the reading, and the reds themselves are the proof the
mutated source was what ran.
Two existing fixtures re-judged (both recorded, neither weakened)
share-link-eligibility.test.ts, the[#14033]HTTP-seam case. Itsrevoked-bucket assertion (
410, commented "this change does not move it")was measured after
switchOff, so what it actually pinned was the routeprobe answering from the row with the block off — the very arm option A
changes. The reading it recorded is preserved: it is now taken before the
switch, where "the revoked bucket is a different status and [Decision] What does turning
publicSharing.enabledoff mean for an ALREADY-MINTED share link? — the parent switch is mint-only while its own child predicate is now a standing policy #14033 does notmove it" is true, and the post-switch answer is pinned to the unknown-token
answer beside it. Coverage went up, not down.
share-link-envelope.conformance.test.ts. Its harness stubsSharingEnginewith nogetSchemaat all and its probe rows named noobject_name, so under the gate all four of its row arms fell through to thegeneric 404 — correctly and fail-closed, but the refusals whose envelope
that module exists to pin became unreachable. The double now declares the block
for the object its rows name, and the rows name it. A fixture declaration, not
a relaxation; the gated behaviour is pinned in the file above.
Changeset
.changeset/share-link-probe-policy-gate.md— minor on both@objectstack/plugin-sharingand@objectstack/runtime, carrying the**BREAKING**banner. Justification: this is a breaking runtime behaviour changeon a published HTTP path (a refusal status changes for two link shapes and the
revoked arm), with no published export added, removed or re-shaped. That is the
same judgement and the same wording the sibling ruling took in
.changeset/share-link-enabled-at-redemption.md(#14033) —minorunder therepo's launch-window convention, where a breaking change does not burn a major
while the stack is in lockstep. Its ADR-0087 disposition is
not-required (no-migration-prescription), andcheck:adr-0087-registrationaccepts it.
Note for the
repo:objectuiseat — no work owed, and none done hereThe triage seat's recorded confidence gap was that the viewer might key its
password prompt on
401 NEEDS_PASSWORD, which option A would change. Measured atobjectui
67dadd6: no consumer found. A grep ofpackages/forNEEDS_PASSWORD,WRONG_PASSWORD,SIGN_IN_REQUIRED,EXPIRED_OR_REVOKEDandINVALID_OR_EXPIREDreturns zero hits, and the only share-link readers arecomponents/src/share/ShareDialog.tsx(authenticated create / list / revoke,untouched by this change) and
app-shell/src/hooks/useChatConversation.ts, whichreads
GET /share-links/:token/messages— a route with no row probe, whoserefusal is unchanged. So on that commit the accepted product consequence has no
front-end that can observe it. Recorded for the objectui seat; nothing in that
repo is changed by this PR and nothing is asked of it.
Verification — all at head
261cfb8d8pnpm --filter @objectstack/plugin-sharing test—Test Files 30 passed (30),Tests 730 passed (730);typecheckclean.pnpm --filter @objectstack/runtime test—Test Files 214 passed (214),Tests 3126 passed (3126);typecheckclean.check:test-typecheckgreen in both packages, and both edited test files areconfirmed inside their package's
tsconfig.test.jsonprogram bytsc --listFiles(the packagetypecheckalone does not read them — recordedso the green is not over-read).
pnpm lint(eslint . --no-inline-config, the whole repo) — clean, 66s.scripts/pm/dispatch-gates.mjs --repofor thisdiff — 62 families after the docs page entered it — all green, except two that
refuse to measure rather than measure wrong and are recorded as NOT MEASURED:
check-test-completeness(exit 3, needs a savedturbo run testlog) and thebare live
scripts/pm/check-half-states.mjssweep (report-only, scheduledworkflow, not a PR gate; its lint.yml form
check:pm-half-statespasses).content/docs/permissions/system-context.mdxmoved becausecheck:system-context-censusreddened on pure line rot: the 20 lines addedabove
getPolicyshifted 5 anchors by exactly 20. Repaired with the gate's own--fix, which rewrote those 5 anchors and nothing else.Status
Draft, and carrying
needs:contract-review: the ruling setsClause-②: yesbecause a published HTTP path changes its refusal for two link shapes. The
contract-review tier was unavailable when this was dispatched, so this PR is not
to be undrafted or armed on CI colour alone.
🤖 Generated with Claude Code
https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
Generated by Claude Code