Repository navigation
fix(plugin-sharing, runtime): a password-protected signed_in share link answers an anonymous visitor SIGN_IN_REQUIRED, whatever password was sent - #23369
Conversation
…ounts Pins, on the plugin route and the dispatcher twin alike: an anonymous caller of a password-protected signed_in link reads one identical answer for no, a wrong and the correct password; a signed-in caller keeps the password arms; public and revoked controls are unchanged. Committed ahead of the fix so the measurement of today's order runs against this exact file. Claude-Session: https://claude.ai/code/session_01FzQy26KznSa5j2gnf3AzsE Co-authored-by: Claude <noreply@anthropic.com>
… before its password An anonymous caller of a password-protected signed_in link is refused by resolveToken on the audience before any password is verified, so the resolve refusal probe's password arm could only report whether a password was sent: it answered WRONG_PASSWORD for the correct password. Both mounts now answer such a caller SIGN_IN_REQUIRED whatever it sent; a signed-in caller keeps NEEDS_PASSWORD / WRONG_PASSWORD; every other arm keeps its order. The dispatcher twin keeps its own copy of the order: plugin-sharing is a dev dependency of runtime, so it cannot import a shared helper. The refusal-order table pins both copies. Claude-Session: https://claude.ai/code/session_01FzQy26KznSa5j2gnf3AzsE Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift Check4 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 — 31 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 d3c5cf52a1683682c4efdeaf688a97fce5fd3e37 && git checkout d3c5cf52a1683682c4efdeaf688a97fce5fd3e37
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin dc393266fda0bd7b7d22753d572584ee0d7cbf59 a6992eee303eb339bd10da1ade90dbb0fea07d32 && git checkout -B drift-repro dc393266fda0bd7b7d22753d572584ee0d7cbf59 && git merge --no-ff a6992eee303eb339bd10da1ade90dbb0fea07d32
node scripts/docs-audit/affected-docs.mjs --json dc393266fda0bd7b7d22753d572584ee0d7cbf59 |
Fixes #23328
Clause-②: no — every input refused today is still refused; only which refusal code an anonymous caller reads changes. Nothing is admitted that is refused today. ⛔ If the change would admit anything refused today, or make the answer differ by whether the password was correct before sign-in (an anonymous password oracle), the dev stops and reports
needs_decision.What changes
When
resolveTokenrefuses, the share-link resolve door probes the link row to name the refusal. For a live link with both a password and audiencesigned_in, that probe answered the password arm first. ButresolveTokenhad refused the anonymous caller on the audience before verifying any password, so the probe knew only whether a password was sent. The visitor with the correct password readWRONG_PASSWORD, and the console's shared-record page said "Wrong password." and never offered sign-in.Both mounts now judge the audience first. Before sign-in the probe asks the audience and never the password:
signed_in+ password link401 SIGN_IN_REQUIRED401 SIGN_IN_REQUIRED401 SIGN_IN_REQUIRED401 NEEDS_PASSWORD401 WRONG_PASSWORD200registerShareLinkRoutes(plugin-sharing/src/share-link-routes.ts) and the dispatcher twinhandleShareLinksRequest(runtime/src/domains/share-links.ts).404 INVALID_OR_EXPIREDand the410 EXPIRED_OR_REVOKEDare decided outside the live-link block this edit touches. A non-signed_inlink with a password still answers the password arms to an anonymous caller.resolveTokenis untouched: the cause was in the two probes, not in the service.signed_inlink, the probe now refuses before it reads the internalpassword_hashat all.SIGN_IN_REQUIREDto its sign-in state (apps/console/src/pages/shared-record-shape.ts,RESOLVE_GATE_BY_CODE). That code is unchanged at objectuiorigin/main2b06a91and at the pin1fb1ced362. After sign-in, the page receivesNEEDS_PASSWORDand shows its password prompt. No objectui edit.One rule, two mounts: why the twin keeps its own copy
The seat's direction was to put the order in one helper in
plugin-sharingthat the twin consumes, if the twin can importplugin-sharingtoday. It cannot:@objectstack/plugin-sharingis a devDependency of@objectstack/runtime(packages/runtime/package.json), and no non-test file underpackages/runtime/srcimports it. Consuming a helper from it would add a production dependency edge, so no dependency was added. Following the direction's fallback, the two copies are kept in step, each comment names the other, and one shared table test pins both mounts.The pin
packages/runtime/src/domains/share-links-refusal-order.test.ts: one table,describe.eachover both mounts' production entries. It runs over oneObjectQLon@objectstack/driver-sql+ better-sqlite3 with the shippedsys_share_link(internalpassword_hash), and oneShareLinkServicewith the production password hasher. The password is sent the way the console sends it. Refusals assert the ADR-0112 envelope (status+error.code,success: false).signed_in+ password, anonymous: no, wrong and correct password all401 SIGN_IN_REQUIRED.signed_in+ password, signed in:NEEDS_PASSWORD,WRONG_PASSWORD,200with the record.publiclink with a password answers anonymousNEEDS_PASSWORD/WRONG_PASSWORD/200; an unprotectedpubliclink serves; a revoked link answers410 EXPIRED_OR_REVOKEDto anonymous and signed-in callers alike.Measured before the fix (test committed first, run on the unmodified tree at
ea17bb7a36, both mounts): anonymous with no password answered401 NEEDS_PASSWORD; with a wrong one and with the correct one401 WRONG_PASSWORD. The signed-in rows and all controls already passed (8 failed / 18 passed). Today's order therefore had no correct-vs-wrong oracle, only the wrong code. The card's hypotheses H1 (on a real SqlDriver) and H2 (the twin, now run rather than read) both hold.Negative-pin ablation (two mutations, both mounts, then restored)
Both legs ran on the committed fix (
HEADa6992eee30), throughscripts/ablation-replace.mjs(anchor hit exactly once per file, blob changed), with atraprestore on absolute paths. Each leg rebuiltplugin-sharingand proved the marker reacheddist/withscripts/ablation-dist-preflight.mjsbefore reading the result. The runtime test reads that package throughdist/.Leg 1: today's password-first order restored on both mounts (marker
ablation23328PasswordFirst, present in 2 built files):The direction observed here is not the template's. Under today's order the oracle case goes red on its "against none" leg, because a wrong and the correct password both read
WRONG_PASSWORD. Its "correct against wrong" leg was not exercised. So:Leg 2: an oracle-creating anonymous arm on both mounts (marker
ablation23328Oracle, present in 2 built files). The anonymoussigned_inarm verifies the presented password throughresolveTokenand answersWRONG_PASSWORDwhen it fails,SIGN_IN_REQUIREDwhen it succeeds. That is the plausible "helpful" fix the card warns against:Restore, both legs:
ablation-replace --restoreper file: blob ==HEADblob (60b20243e310routes,04e3629ed6fbtwin) andgit diff HEADempty. Theplugin-sharingrebuild then passed preflight--absent(marker absent from all 6 built files; whole tree clean againstHEAD), and the table read26 passed (26)again. No ablation file is left in the tree.Verification
Everything below ran on
HEADa6992eee30, the final commit.origin/mainis18cd410dc4, unchanged since the branch point.pnpm --filter @objectstack/plugin-sharing test: 44 files, 1055 passed.run typecheck(tsc, the scripts project and the test layer): exit 0.pnpm --filter @objectstack/runtime exec vitest run --project local --maxWorkers=2: 372 files, 5402 passed, 19 skipped.--project repo: 3 files, 808 passed.run typecheck: exit 0. The new test is in the test-layer program:tsc -p tsconfig.test.json --listFileshas 1 hit and 0 errors in that file; the 190 ledgered errors are unchanged.share-links-refusal-order.test.ts: 26 passed (26).node scripts/pm/dispatch-gates.mjs --commands(no paths; change set from the merge base) derived 68 commands, and all 68 ran with exit 0.check:i18nandcheck:dual-build-cjs-loadsfirst answered exit 3PREREQUISITE NOT MET, which measures nothing. Rerun with their named build prerequisites, both exit 0.--ranreconciliation: 68 derived, 68 run, 0 NOT-MEASURED, derived from 68 recorded exit codes.--print-configresolves all three changed code files to a config, so none is ignored.eslint --no-inline-config --format jsonover them: 3 files, 0 errors, 0 warnings. Invariance: noparserOptions.projectis set and the 5 active rules are per-file AST rules, so this diff cannot change the verdict on any untouched file. The whole-repopnpm lintis CI's.Acceptance notes
emailaudience. A live link with a password and audienceemailanswers an anonymous caller who presents the correct password with no email, or an email off the allowlist,401 WRONG_PASSWORDon both mounts (measured in-process, both mounts, on the same real-SqlDriver harness). It is the same password-before-audience shape. It is not a mechanical fix: there is no email-audience refusal code, and answering the allowlist arm before the password arm would hand an anonymous token holder an allowlist-membership oracle. The console's share dialog offers noemailaudience today. Handed to the seat to file.resolveTokenwithout the caller's identity (itsserveignores the context the family door resolved), while the dispatcher twin passes it. A signed-in caller of asigned_inconversation link reads404 NOT_FOUNDfrom the plugin mount and200from the twin (measured, both mounts). The console offers thesigned_inaudience and fetches the conversation's messages. Handed to the seat to file.@objectstack/typesalready hosts the password-header reading both mounts share for exactly this reason (readSharePasswordHeader). That would be a follow-up outside this card's file surface.packages/runtimeis declared todomain:cli(comment 6158867992 on [PM seat] domain:cli · seat 2 — ⏳ vacant #22648) andplugin-sharingto [epic] ADR-0131 — total organization ownership: no NULL organization_id (v18 line) #15194 (comment 6158874477).Generated by Claude Code