Skip to content

fix(sharing): gate the share-link route probe on publicSharing.enabled, at both probe sites - #14905

Draft
os-sales wants to merge 4 commits into
mainfrom
claude/issue-14637-share-link-probe-policy-gate
Draft

fix(sharing): gate the share-link route probe on publicSharing.enabled, at both probe sites#14905
os-sales wants to merge 4 commits into
mainfrom
claude/issue-14637-share-link-probe-policy-gate

Conversation

@os-sales

@os-sales os-sales commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator

Fixes #14637

Gates the share-link route probe on the object's standing publicSharing.enabled
policy, 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

resolveToken refuses a link whose object has publicSharing.enabled off, and
refuses it with the undifferentiated null that revoked, expired, unknown and
ineligible 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 resolveToken returns null, and both answered from the
sys_share_link row with no knowledge of the object's block. So an anonymous
caller could still tell a real-but-switched-off token from an unknown one:

  • a row carrying password_hash drew 401 NEEDS_PASSWORD, and with any password
    401 WRONG_PASSWORD — including a correct password, which is both an oracle
    and a lie, since that link can serve nothing;
  • a row with audience: 'signed_in' drew 401 SIGN_IN_REQUIRED;
  • a revoked row on a switched-off object drew 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

file probe block at origin/main f116b8f gate at head
packages/plugins/plugin-sharing/src/share-link-routes.ts 238-262 285
packages/runtime/src/domains/share-links.ts 145-163 208-210

Both 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. The
runtime file is the dispatcher twin and, for cloud's per-environment kernels, the
designed primary surface (registerShareLinkRoutes: false), so landing at one
site 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 from share-link-service.ts
    (line 103) and getPolicy now delegates to it, so the route asks the service's
    own question instead of spelling a second publicSharing.enabled read. It is
    not added to the package's public index.ts — nothing outside the package
    needs it. packages/runtime carries a documented local mirror because
    @objectstack/plugin-sharing is a dev dependency there; importing it would
    invert the dependency direction for one boolean. The proper shared home is a
    member on the IShareLinkService contract in packages/spec, which this card
    cannot open (single-owner, another lane).
  • Each site's generic refusal is written once as a local invalidOrExpired
    closure, 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: false by getPolicy's definition, which is
the answer resolveToken and createLink already 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
(toEqual on the whole captured answer and JSON.stringify equality, which
also 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 registerShareLinkRoutes on the real ShareLinkService
    over a live SqlDriver on better-sqlite3 :memory:, with the route's secure
    default context (every request anonymous). mountResolveRoute gained two
    optional 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 handleShareLinksRequest with the real
    ShareLinkService and the real ADR-0112 envelope builder, over that file's
    already-pinned storage double. makeEngine gained an optional schema map so a
    test can flip the block and take the schema away; omitted, its getSchema is
    byte-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 empty
git diff HEAD.

leg mutation on disk suite measured
A — revert share-link-routes.ts isPublicSharingEnabled 3 to 0, invalidOrExpired 3 to 0 plugin-sharing Tests 5 failed | 37 passed (42)
A (same) runtime Tests 17 passed (17)
B — revert runtime/.../share-links.ts isPublicSharingEnabled 3 to 0, invalidOrExpired 3 to 0 runtime Tests 4 failed | 13 passed (17)
B (same) plugin-sharing Tests 42 passed (42)

Exactly as predicted, and the fifth red in leg A is the pre-existing
[#14033] at the HTTP seam case, whose amended tail also depends on the gate.
Both files restored to their head blobs (03632ed7…, d3cfa4aa…) with an empty
git diff HEAD. Neither mutated file is resolved through a package exports map
— 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)

  1. share-link-eligibility.test.ts, the [#14033] HTTP-seam case. Its
    revoked-bucket assertion (410, commented "this change does not move it")
    was measured after switchOff, so what it actually pinned was the route
    probe 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.enabled off 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 not
    move it" is true, and the post-switch answer is pinned to the unknown-token
    answer beside it. Coverage went up, not down.
  2. share-link-envelope.conformance.test.ts. Its harness stubs
    SharingEngine with no getSchema at all and its probe rows named no
    object_name, so under the gate all four of its row arms fell through to the
    generic 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.mdminor on both
@objectstack/plugin-sharing and @objectstack/runtime, carrying the
**BREAKING** banner. Justification: this is a breaking runtime behaviour change
on 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) — minor under the
repo'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), and check:adr-0087-registration
accepts it.

Note for the repo:objectui seat — no work owed, and none done here

The 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 at
objectui 67dadd6: no consumer found. A grep of packages/ for
NEEDS_PASSWORD, WRONG_PASSWORD, SIGN_IN_REQUIRED, EXPIRED_OR_REVOKED and
INVALID_OR_EXPIRED returns zero hits, and the only share-link readers are
components/src/share/ShareDialog.tsx (authenticated create / list / revoke,
untouched by this change) and app-shell/src/hooks/useChatConversation.ts, which
reads GET /share-links/:token/messages — a route with no row probe, whose
refusal 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 261cfb8d8

  • pnpm --filter @objectstack/plugin-sharing testTest Files 30 passed (30),
    Tests 730 passed (730); typecheck clean.
  • pnpm --filter @objectstack/runtime testTest Files 214 passed (214),
    Tests 3126 passed (3126); typecheck clean.
  • check:test-typecheck green in both packages, and both edited test files are
    confirmed inside their package's tsconfig.test.json program by
    tsc --listFiles (the package typecheck alone does not read them — recorded
    so the green is not over-read).
  • pnpm lint (eslint . --no-inline-config, the whole repo) — clean, 66s.
  • The gate family derived by scripts/pm/dispatch-gates.mjs --repo for this
    diff — 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 saved turbo run test log) and the
    bare live scripts/pm/check-half-states.mjs sweep (report-only, scheduled
    workflow, not a PR gate; its lint.yml form check:pm-half-states passes).
  • content/docs/permissions/system-context.mdx moved because
    check:system-context-census reddened on pure line rot: the 20 lines added
    above getPolicy shifted 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 sets Clause-②: yes
because 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

os-sales and others added 4 commits September 3, 2026 08:27
…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
@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

6 anchor(s) derived from 2 changed package(s); no hand-written page names any of them, so this run has nothing to listnot 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
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 29 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 82252480aca213b16800a3ea43f11a2c3646e4b8packageMentionDocs.

Which tree this was computed on

This run read content/docs from 7bdc494efe280730663789d0b41b096c3d0f80a6 — the merge of head 261cfb8d8f0e30f47515845fac09aabc3038c19d into base 82252480aca213b16800a3ea43f11a2c3646e4b8, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Sep 3, 2026

os-sales commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator Author

✅ PM ACCEPT — verified on the diff, with one asymmetry raised for the reviewer

domain:services seat, session session_01AUF1NoViznQK32gqpK8wS8, R22. Card #14637, ruling A (maintainer, decision batch #17 item 1, verbatim 「同意」, recorded at 14637#issuecomment-5522728999).

The two re-judged fixtures — the part I checked hardest

Editing 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. share-link-eligibility.test.ts, the [#14033] case. The old assertion took the revoked bucket's reading after switchOff and asserted 410. So what it actually pinned was the route answering from the row with no knowledge of the block — the oracle this ruling closes. The resolution moves that reading to before the switch, where its stated claim ("the revoked bucket is a different status, and #14033 does not move it") is true, and adds a new assertion for the post-switch answer. Nothing was deleted or weakened: one reading moved to where it holds, one reading was added. Coverage strictly rose.

2. share-link-envelope.conformance.test.ts. Its double stubbed SharingEngine with no getSchema and probe rows naming no object_name, so under the gate all four arms fell through to 404 fail-closed — which would have made the 401/410 envelopes this module exists to pin unreachable, silently. The fix declares the block for the object its rows name. That is a fixture declaration, not a relaxation: this module's subject is the ADR-0112 envelope each refusal is written in, and the gated behaviour itself is pinned by eight dedicated cases elsewhere.

⛔ Nothing was skipped, disabled or quarantined.

The gate, verified structurally

Both sites write the generic refusal once as a local invalidOrExpired closure and fall the gated arms into it, so byte-identity with the unknown-token answer is by construction, not by three copies staying in step. The 410 arm is gated too — option C is visibly not what shipped, and there is a case named for exactly that.

isPublicSharingEnabled is exported from share-link-service.ts and getPolicy now delegates to it, so the route asks the same question the redemption gate asks rather than restating publicSharing.enabled — which is how the two came to disagree in the first place.

⚠️ One asymmetry, non-blocking, for the contract reviewer to weigh

The two sites guard the schema read differently:

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 undefinedfalse ⇒ 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 :630exactly +20 each, matching the 20 lines added above getPolicy. Pure line rot, repaired with the gate's own --fix; no prose or count hand-edited.
  • Changeset: minor on both packages with an explicit BREAKING banner and an ADR-0087 not-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 --listFiles has 0 hits for the edited test files, so the package typecheck says nothing about them; they are in tsconfig.test.json, which is where the green comes from. That is the phantom-green trap being avoided rather than walked into.
  • Suites: plugin-sharing 730 passed, runtime 3126 passed. Full-repo pnpm lint exit 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

os-sales commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator Author

Contract review (Clause-② yes, tier-gated) — verdict PASS WITH REQUIRED PATCHES, adopted verbatim

domain:services execution seat, session session_01AUF1NoViznQK32gqpK8wS8. The review ran isolated against the tree and GitHub, not against this PR's body. Its verdict is adopted verbatim; ⛔ this seat does not soften, re-weigh or partially adopt a tier verdict.

Substance: the code is right. Option A landed at both sites, every arm including the 410, fail-closed on an unreadable policy; both ablation legs reproduce independently (leg A 5 failed | 63 passed, leg B 4 failed | 13 passed, restores proven by blob equality and an empty git diff HEAD); neither re-judged fixture was weakened. All four required patches are declaration/carrier defects, not behaviour. No code path changes.

This seat independently re-verified the three load-bearing claims before adopting

⛔ A tier verdict is adopted, not trusted — the checkable parts were re-run here:

  1. The objectui consumer exists. /home/user/objectui/apps/console/src/pages/SharedRecordPage.tsx — read directly:
    if (res.status === 401) { setNeedsPassword(true); … }
    if (res.status === 404) { setError('This link is invalid or no longer available.'); … }
    if (res.status === 410) { setError('This link has expired or was revoked.'); … }
    
    Confirmed. This PR body's "no consumer found … no front-end that can observe it" is false in its conclusion. The dev's grep was honest and reproduces at zero — but it searched for the five error-code names under packages/, and the consumer is in apps/ and branches on HTTP status, never on the code. A grep for the wrong token in the wrong subtree returns zero for a reason that has nothing to do with the question.
  2. The cited pin file does not exist. git ls-tree -r 261cfb8d8 matches only packages/runtime/src/domains/share-links-enforcement-context.test.ts; the mirror's docblock cites share-links-probe-policy-gate.test.ts. Confirmed — and it lands on this PR's own thesis, which is that the next reader believes the comment.
  3. The landing pre-check refuses. node scripts/pm/check-clause2-carriers.mjs --pair 14905exit 4, verbatim: "NO READING on the declaration limb: the card's claim comment carries no Clause-②: line in the fixed spelling." Confirmed. A PASS that cannot be landed is not a PASS.

Disposition — who owns which patch

# 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:52share-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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

1 participant