fix(plugin-sharing): share-link password never leaves the server, is stored with the platform slow hash, and is accepted in a header - #21890
Conversation
…w hash with legacy upgrade Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6 Co-authored-by: Claude <noreply@anthropic.com>
…acy upgrade and transport Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6 Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6 Co-authored-by: Claude <noreply@anthropic.com>
…ash-upgrade write site Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6 Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 3 package(s): 3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 32 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 e5c1ab18cf4a5875e784918135c0c4777b536304 && git checkout e5c1ab18cf4a5875e784918135c0c4777b536304
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin cab639671528ef6f3a201e8995794378a4a28bfe c852449b1b6822d07a7e1b39f5f74869cffc99d8 && git checkout -B drift-repro cab639671528ef6f3a201e8995794378a4a28bfe && git merge --no-ff c852449b1b6822d07a7e1b39f5f74869cffc99d8
node scripts/docs-audit/affected-docs.mjs --json cab639671528ef6f3a201e8995794378a4a28bfe
|
…ngine that does not strip the hash The list and redemption pins passed with the projection removed, because the engine's internal-column strip already held them. This case wires an engine whose reads hand the hash back (with a control asserting it does), so the projection itself is what the assertion reads. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6
…ssword header passes CORS, and hashing works in WebContainer - X-Share-Password joins DEFAULT_CORS_ALLOW_HEADERS so a cross-origin client can use the header form. - Both public share-link routes answer Cache-Control: no-store and Vary: X-Share-Password on every outcome, on both mounts. - On WebContainer the password key is derived by @noble/hashes scrypt with the same parameters and stored form as node:crypto; hashes interchange. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6
…public route with no-store The public-route header wrapper now maps a throw from outside the body's own try (service resolution) through errorFromThrown before adding the headers; authenticated routes keep propagating. Adds an NFKC-differing password case to the cross-implementation scrypt test, both directions, and lists @objectstack/hono in the changeset frontmatter its text already names. Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6 Co-authored-by: Claude <noreply@anthropic.com>
Fixes #21839
Clause-②: no
Server half of the share-link password card. The console transport (sending the password in a header) is the separate objectui card; this PR keeps the query parameter working so the current console is unaffected until that lands.
What changed
All in
packages/plugins/plugin-sharing/src, plus one changeset and a regenerated census page.ShareLinkService.createLinkreturned the row it had just inserted, hash included, and both mounts ofPOST /api/v1/share-links(this plugin's route and the runtime dispatcher twin) answer with that return value. It now returns the row through one exit projection,withoutPasswordHash.listLinksandresolveTokenpass their results through the same projection. They already came from engine reads that strip theinternalcolumn, so the projection holds whichever engine is wired. The audit ledger and engine write responses already omitinternalfields, so this PR does not change them.share-link-password.ts: scrypt with the parameters account passwords use (N=16384, r=16, p=1, 64-byte key, 16-byte salt as hex, NFKC). It is built onnode:cryptowith no new dependency, and new rows are stored asscrypt$SALT$KEY. The two legacy forms (sha256$…and theweak$…no-SubtleCrypto fallback) still verify.resolveTokenre-hashes a legacy row into the current form after a successful verification, but only once every later gate (standing policy, record existence, eligibility) has passed. A switched-off or ineligible link therefore takes no write. Every comparison usestimingSafeEqual, and the plaintext legacy form is compared through a digest of both sides. A refused upgrade write does not block the read, because the legacy form still verifies. It is reported once per instance aterror, naming the link only. A deployment that injects its ownhashPassword/verifyPasswordpair is left alone. The old default could also write aweak$row on a runtime without SubtleCrypto; it no longer can.presentedPassword, now reads the password for both public routes./:token/resolvealready acceptedx-share-password./:token/messageson this mount read only?password=and now accepts the header too, as its runtime twin always has. The query parameter is still accepted for compatibility (named in the changeset). Neither form is logged on this path: the routes write no log line, the service's log lines name the link and never the presented password, and the Hono adapter's failure log records the path without the query string.Tests
New
src/share-link-password.test.ts, 19 cases on a realObjectQL+driver-sql+ better-sqlite3, so the engine's strip is live:password_hashkey or any piece of the stored hash: mint (service andPOSTroute), list (service and route), redemption (service and route).scrypt$+ 32 hex +$+ 128 hex, holds no plaintext, and is salted per row. The verifier refuses wrong, empty and unknown-form inputs.scrypt$; the upgraded hash verifies the same password and still refuses a wrong one.{ link, reason }, and no log carries the password or the hash./resolveand/messages, and so is?password=. A wrong password is refused for both forms on both routes, with ADR-0112code+success: falseasserted. No log line carries the presented password on any outcome.Local runs, final head
eacae6b6beunless noted (eacae6b6beadds the non-stripping-engine pin to the test file):pnpm --filter @objectstack/plugin-sharing testat headeacae6b6be: 39 files / 973 tests passed.pnpm --filter @objectstack/plugin-sharing typecheck(src + scripts +check:test-typecheck): green; the test layer holds the existing 2 files / 3 pinned signatures, with nothing new.pnpm --filter @objectstack/runtime exec vitest run --maxWorkers=2 src/domains/share-links(the dispatcher twin's suites, against the rebuilt plugindist/): 2 files / 29 tests passed..tsfiles:eslint --no-inline-config --format jsonreports 4 files, 0 errors, 0 warnings. The population is read fromeslint.config.mjs:**/*.{ts,…}covers all four. Invariance: the config uses no typed linting (noparserOptions.project), so this diff cannot move any verdict on an untouched file.dispatch-gates --ran: 95 of 97 derived families run, all exit 0. Two are NOT MEASURED, bothPREREQUISITE NOT MET(exit 3):check:dual-build-cjs-loadsneeds a whole-repo build, andcheck:i18nneeds the CLI build closure. This diff changes no object, label or translation source. Both are declared to CI.Ablations (each mutation made with
scripts/ablation-replace.mjs, which confirmed the change on disk; restored to the HEAD blob each time, confirmed by matching hashes and an emptygit diff HEAD):withoutPasswordHashprojection removed: 2 failed / 17 passed (the mint pin and the non-stripping-engine pin).presentedPassword: 4 failed / 14 passed (both header pins, the header wrong-password pin, the redemption-route pin).Acceptance notes
packages/runtime/src/domains/share-links.ts, outside this card's file surface) reads it that way, and two mounts of the same routes must not answer differently. The header is the form clients should send. Making the header win on both mounts is a small follow-up for whoever retires the query form. Carrier: the objectui console card, whose landing is the point the query parameter can go.ShareLink.password_hashstays declared (optional) inpackages/spec/src/contracts/share-link-service.ts, as the persisted-shape mirror. Only the runtime value leaving the service drops it, so no published type narrows. SeeClause-②above.node scripts/tenant-audit-census.mjs --writeregeneratedcontent/docs/permissions/tenant-audit-census.mdxand its counts file. The page's hand-written figures moved from 232 to 233 (decidable 154 to 155, elevated 113 to 114).check:tenant-audit-censusand its self-test are green.Generated by Claude Code