Repository navigation
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>
…jectstack-ai#21943) Part of objectstack-ai#21932 Clause-②: no ## What changes The platform checklist gains items for the rules the 17.7 pre-release security follow-up landed, and two re-checks from the card are resolved. All edits are in `docs/qa/platform-checklist/areas/*.json`. `automation.json` is untouched (open PR objectstack-ai#21928 holds it). | Card row | Disposition | Item | |---|---|---| | objectstack-ai#21792 (PR objectstack-ai#21809) settings audit and secret-valued settings | new item | `platform-core.settings-audit-secret-fingerprint` | | objectstack-ai#21846 (PR objectstack-ai#21872) implicit account linking | new item | `identity-auth.implicit-account-linking-ownership` | | objectstack-ai#21839 (PR objectstack-ai#21890) share-link password | three clauses added, rev 4 to 5 | `access-security.share-link-capability-tokens` | | objectstack-ai#21836 (PR objectstack-ai#21879) global search skips unreadable objects, plus the two cases objectstack-ai#21880 lists | new item | `search.global-search-skips-unreadable` | | re-check 1: A2 / A7 and the plugin-driver boundary | rev 2 to 3 | `integration-system.datasource-credential-refusal-matrix` | | re-check 2: the objectstack-ai#21845 CLI and quorum N1 notes | already applied by objectstack-ai#21891, no edit | `cli.scaffold-first-run`, `cli.scaffold-console-first-paint`, `approvals.quorum-m-of-n` | Each item states rules, not reproductions. Withheld security detail stays out. ### Grounding, per row - **Settings audit fingerprint.** Both ledgers record the keyed digest for a secret-valued setting, or no fingerprint when none is available, and never the value or an unkeyed hash. Grounded in `settings-service.ts#secretAuditDigest`, `config-change-audit.ts#CONFIG_CHANGE_ACTION` and the contract text at `crypto-provider.ts#keyedDigest`. The pin is `settings-audit-secret-digest.test.ts` (7 cases). The offline check carries a positive control: the non-secret key's unkeyed digest IS found, so a no-hit on the secret rows means something. The no-keyed-digest arm cannot be reached on a stock boot, so that clause is scored from the pin. - **Implicit account linking.** Four rules: no implicit link to an unverified local user; an unlink is honoured; an explicit, signed-in link still works and lifts the refusal; the platform IdP exception holds only on its OAuth path. Grounded in `implicit-account-linking.ts` (`decideImplicitLink`, `IMPLICIT_LINK_REFUSED`, `PLATFORM_IDP_PROVIDER_ID`, `recordUnlinkTombstone`, `refuseImplicitAccountLink`) and the published `sso.mdx` section. The pin is `implicit-account-linking.test.ts`. The item reuses the local OIDC provider recipe from `identity-auth.linked-accounts-social`. The platform-IdP clause and the operator override are pin-scored, and knownGaps says why. - **Share-link password.** The stored hash leaves on no exit (mint, list, redemption). The password is accepted from the `X-Share-Password` header, the query form is still accepted, and the default CORS allow-list carries the header. Both public routes answer `Cache-Control: no-store` and `Vary: X-Share-Password` on every outcome, and the authenticated routes do not. Grounded in `share-link-service.ts#withoutPasswordHash`, `share-link-routes.ts#SHARE_LINK_PUBLIC_RESPONSE_HEADERS`, the runtime `share-links.ts#PUBLIC_RESPONSE_HEADERS` and `adapter.ts#DEFAULT_CORS_ALLOW_HEADERS`. The pins are the `[objectstack-ai#21839]` blocks in `share-link-password.test.ts`, `share-links-public-cache-headers.test.ts` and the hono-plugin CORS case. Existing clause indices are unchanged. - **Global search.** An unreadable object is never queried, named or counted. An explicit `objects=` naming one answers exactly as a name that matches no object. The object stays refused at its own door. Row scope still narrows a searched object, and a term found only in a field hidden from the caller yields no hit. Grounded in `protocol.ts#searchAll` (the `canReadObject` pre-filter and the `getQueryableFields` narrowing). The pins are the dogfood `search-skip-unreadable.dogfood.test.ts` and the 12 unit cases in `protocol.search-skip-unreadable.test.ts`. The two objectstack-ai#21880 cases have no end-to-end pin yet, and knownGaps says so. The open pinyin-companion finding on objectstack-ai#21880 is recorded as a knownGap with a flag-off instruction, at class level only. The persona reuses the area recipe `qa-contributor-bound-member`. - **Datasource credential matrix.** A2 / A7 (`acceptance[1]` and `acceptance[6]`) are recorded as a known environment gap. They need a reachable credential-protected database of a shipped driver, which no run has had. No recipe is claimed, because none is proven. A successful publish alone may not score them, and the stored-credential half of A7 can be read as a partial reading. Separately, the unknown-driver clause, step 7, its negative and the title now state the ruled boundary from objectstack-ai#21921 and the docs note objectstack-ai#21927. For a plugin driver, only the fixed spellings are redacted (the canonical keys, the former aliases and URL credentials). A non-canonical key served as written is the boundary, not a FAIL. Grounded in `common.zod.ts#CANONICAL_CREDENTIAL_KEYS` and `datasource-credential-redaction.ts#redactableConfigKeys`. ### Re-check 2 evidence (no edit) At the claim ref `9dce635337`: - `cli.scaffold-first-run` (rev 3) step 0 and `cli.scaffold-console-first-paint` (rev 3) step 0 both drop the trailing `npm install` and warn against adding it. Their rev 3 history entries cite objectstack-ai#21845. No other `npm install` step remains in `cli.json`. - `approvals.quorum-m-of-n` (rev 4) `negative[0]` requires a NON-PRIVILEGED repeat actor and names the documented admin override (objectstack-ai#3424) as never a distinctness FAIL. ## Remaining on objectstack-ai#21932 (held, not in this PR) - The objectstack-ai#21864 row (public-form withdrawal layering). Its PR is still open. - The objectstack-ai#21928 row (run-state trigger record mask). That PR adds its own item in `automation.json`. objectstack-ai#21932 remains open for these two rows. ## Validation (at `a72b827e43`) - `pnpm check:platform-checklist`: exit 0. It reports 15 areas and 273 items (269 active, 2 planned). The baseline was 270. Symbol anchors resolve 674 of 684 (baseline 657 of 667): all 17 new anchors resolve, and the objectstack-ai#16898 residual is unchanged at 10. - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 13 commands, and all 13 exit 0. `check:doc-formula-expressions` first exited 3 (PREREQUISITE NOT MET: `@objectstack/formula` and `@objectstack/lint` were not built). After building them it exited 0. `--ran` reconciliation: 13 derived, 13 run, 0 unrun. - No package source changed, so there is no package build, test or typecheck. No changeset: `docs/qa/**` publishes nothing. ## Acceptance notes - Source citations name test cases and symbols, never line numbers, because `check:platform-checklist` refuses a `file:line` pin. - `content/docs/data-modeling/drivers.mdx` says a plugin driver's `config` is "stored and served to administrators as written". The read redactor still withholds the canonical spellings (`password`, `authToken`), the former aliases and URL credentials for such a driver (`redactableConfigKeys`). So the docs sentence is slightly broader than the code, and the code is the more protective of the two. The checklist follows the code. This is noted only, with no card. Carrier: none. - A run of `search.global-search-skips-unreadable` picks the walled object and the hidden-field value on the live boot, behind premise guards. The item names likely candidates and does not assume them. --- _Generated by [Claude Code](https://claude.ai/code/session_01VDtqoecgES7ScQYGbFVDRv)_ 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