Skip to content

Commit 412d7dd

Browse files
docs(qa): checklist items for the 17.7 pre-release security fixes (#21943)
Part of #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 #21928 holds it). | Card row | Disposition | Item | |---|---|---| | #21792 (PR #21809) settings audit and secret-valued settings | new item | `platform-core.settings-audit-secret-fingerprint` | | #21846 (PR #21872) implicit account linking | new item | `identity-auth.implicit-account-linking-ownership` | | #21839 (PR #21890) share-link password | three clauses added, rev 4 to 5 | `access-security.share-link-capability-tokens` | | #21836 (PR #21879) global search skips unreadable objects, plus the two cases #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 #21845 CLI and quorum N1 notes | already applied by #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 `[#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 #21880 cases have no end-to-end pin yet, and knownGaps says so. The open pinyin-companion finding on #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 #21921 and the docs note #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 #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 (#3424) as never a distinctness FAIL. ## Remaining on #21932 (held, not in this PR) - The #21864 row (public-form withdrawal layering). Its PR is still open. - The #21928 row (run-state trigger record mask). That PR adds its own item in `automation.json`. #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 #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>
1 parent 13a22d0 commit 412d7dd

5 files changed

Lines changed: 376 additions & 15 deletions

File tree

‎docs/qa/platform-checklist/areas/access-security.json‎

Lines changed: 45 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1510,10 +1510,10 @@
15101510
},
15111511
{
15121512
"id": "access-security.share-link-capability-tokens",
1513-
"title": "Share-link capability tokens: anon resolve renders the record minus redactFields, password/audience gates hold, revoke/expire refuse without leaking",
1513+
"title": "Share-link capability tokens: anon resolve renders the record minus redactFields, password/audience gates hold, revoke/expire refuse without leaking, the stored password hash never leaves the server, the password travels in the X-Share-Password header, and public answers are never cached",
15141514
"since": "v16",
15151515
"status": "active",
1516-
"revision": 4,
1516+
"revision": 5,
15171517
"priority": "P2",
15181518
"surface": "api",
15191519
"personas": [
@@ -1541,7 +1541,10 @@
15411541
"mint audience:'signed_in'; anon resolve → 401 SIGN_IN_REQUIRED; mint audience:'email' and resolve with an email OFF the allowlist → refused",
15421542
"DELETE /api/v1/share-links/<idOrToken> (revoke); anon resolve → 410 EXPIRED_OR_REVOKED; mint a short-expiry link and, after it expires, resolve → 410 — the record must never appear",
15431543
"delete the shared record r, then anon resolve the still-live (un-passworded, link_only) token → 404 INVALID_OR_EXPIRED, byte-identical to the answer for an unknown token (fail-closed, #5190: \"that record is gone\" is itself information) — capture it beside an unknown-token resolve for the byte comparison",
1544-
"GET /api/v1/share-links?object=F&recordId=r as the minter vs a SECOND member — the list returns only the caller's own links"
1544+
"GET /api/v1/share-links?object=F&recordId=r as the minter vs a SECOND member — the list returns only the caller's own links",
1545+
"the stored hash never leaves: mint a password-gated link and capture the MINT response, the LIST (GET /api/v1/share-links?object=F&recordId=r as the minter) and a SUCCESSFUL resolve. Search every body for the key password_hash and for any hash-shaped value",
1546+
"the password travels in a header: resolve the password-gated link with the password ONLY in the X-Share-Password request header (no ?password=). Then send a wrong value in the header, then the right password as ?password= (the compatibility form). Send a CORS preflight for the resolve route from another origin, with Access-Control-Request-Headers: x-share-password",
1547+
"public answers are never cached: on every public resolve outcome above (200; 401 NEEDS_PASSWORD and WRONG_PASSWORD; 404 for an unknown token; 410 after revoke) and on GET /api/v1/share-links/<token>/messages (a refusal on open-framework showcase, see knownGaps; the refusal is the answer being scored here), read the response HEADERS. Then read the headers of the authenticated create, list and revoke responses"
15451548
],
15461549
"acceptance": [
15471550
{
@@ -1579,11 +1582,32 @@
15791582
"oracle": "api",
15801583
"verify": "minter's list contains the token; the second member's list for the same object/recordId excludes it (share-links.ts createdBy pin)",
15811584
"evidence": "both list bodies"
1585+
},
1586+
{
1587+
"clause": "the stored password hash never leaves the server: no exit (the mint response, the list, the redemption result) carries password_hash or any form of the stored hash",
1588+
"oracle": "api",
1589+
"verify": "none of the captured bodies contains the key password_hash or a hash-shaped value of it (share-link-service.ts withoutPasswordHash is the one exit projection). A client reads a link's password state from the resolve route's NEEDS_PASSWORD answer, as before",
1590+
"evidence": "the mint, list and resolve bodies, searched in full"
1591+
},
1592+
{
1593+
"clause": "the password is accepted from the X-Share-Password request header, the preferred form because a header stays out of the request URL: header-only with the right password answers 200 and a wrong header value answers 401 WRONG_PASSWORD. The ?password= query form is still accepted. A cross-origin preflight allows the header by default",
1594+
"oracle": "api",
1595+
"verify": "the three resolve traces (header right → 200, header wrong → 401 WRONG_PASSWORD, query right → 200) and the preflight's Access-Control-Allow-Headers naming x-share-password (DEFAULT_CORS_ALLOW_HEADERS in plugin-hono-server; a deployment passing its own allowHeaders must add it itself)",
1596+
"evidence": "the three traces and the preflight response headers"
1597+
},
1598+
{
1599+
"clause": "public share-link answers are never cached: BOTH public routes (/:token/resolve and /:token/messages) answer Cache-Control: no-store and Vary: X-Share-Password on EVERY outcome, success and refusal alike. The authenticated create, list and revoke routes do not carry these headers",
1600+
"oracle": "api",
1601+
"verify": "each public response's headers carry exactly Cache-Control: no-store and Vary: X-Share-Password (200, 401 x2, 404, 410, and the messages refusal); the authenticated list response carries neither",
1602+
"evidence": "the header block of every public response and of the authenticated list response"
15821603
}
15831604
],
15841605
"negative": [
15851606
"a resolve that returns the record after revoke/expiry, that includes a redactField, or that leaks another user's tokens in the list, is a FAIL",
1586-
"the /:token/messages branch is ai_conversations-only (Cloud/EE) — a knownGap, not a stock clause; do not tick it on open-framework showcase"
1607+
"the /:token/messages branch is ai_conversations-only (Cloud/EE) — a knownGap, not a stock clause; do not tick it on open-framework showcase",
1608+
"password_hash, or any stored form of the share-link password, in a mint, list or resolve body is a FAIL",
1609+
"a header-only correct password answering 401 NEEDS_PASSWORD is a FAIL: the route ignored the header, so a client is pushed back to putting the password in the URL",
1610+
"a public share-link response without Cache-Control: no-store, on ANY outcome including a refusal, is a FAIL. A cached answer can serve a password-released record to the next person on a shared browser or proxy"
15871611
],
15881612
"variants": [
15891613
"audience link_only",
@@ -1593,7 +1617,9 @@
15931617
"redactFields stripped",
15941618
"revoked",
15951619
"expired",
1596-
"record-gone (fail-closed)"
1620+
"record-gone (fail-closed)",
1621+
"password in the X-Share-Password header",
1622+
"password in ?password= (compatibility)"
15971623
],
15981624
"traps": [
15991625
"wrong-persona"
@@ -1604,7 +1630,14 @@
16041630
"packages/runtime/src/route-ledger.ts (share-links rows incl. public resolve/messages)",
16051631
"packages/plugins/plugin-sharing/src/objects/sys-share-link.object.ts",
16061632
"ADR-0047, ADR-0111 D8, #5190",
1607-
"cross-ref access-security.share-link-landing-page — the /s/:token console rendering of this surface (UI half): resolve/password/audience/revoke SEMANTICS are scored HERE, what the page renders of them is scored THERE (one defect, one count)"
1633+
"cross-ref access-security.share-link-landing-page — the /s/:token console rendering of this surface (UI half): resolve/password/audience/revoke SEMANTICS are scored HERE, what the page renders of them is scored THERE (one defect, one count)",
1634+
"packages/plugins/plugin-sharing/src/share-link-service.ts#withoutPasswordHash (the one exit projection: every copy of a link that leaves the service drops password_hash)",
1635+
"packages/plugins/plugin-sharing/src/share-link-routes.ts#SHARE_LINK_PUBLIC_RESPONSE_HEADERS (the plugin mount: no-store + Vary on both public routes; the x-share-password header read)",
1636+
"packages/runtime/src/domains/share-links.ts#PUBLIC_RESPONSE_HEADERS (the dispatcher mount: the same headers on every public outcome, including a throw outside the body try)",
1637+
"packages/plugins/plugin-hono-server/src/adapter.ts#DEFAULT_CORS_ALLOW_HEADERS (X-Share-Password in the default preflight allow-list)",
1638+
"packages/plugins/plugin-sharing/src/share-link-password.ts#hashShareLinkPassword (the stored form is the platform slow password hash; legacy forms still verify and are upgraded on the first successful redemption)",
1639+
"pins: packages/plugins/plugin-sharing/src/share-link-password.test.ts ('[#21839] the stored hash never leaves the server', '[#21839] how the password travels in', '[#21839] the public routes are never cached') · packages/runtime/src/domains/share-links-public-cache-headers.test.ts ('[#21839] dispatcher public share-link routes are never cached') · packages/plugins/plugin-hono-server/src/hono-plugin.test.ts ('should allow X-Share-Password by default')",
1640+
"content/docs/protocol/kernel/http-protocol.mdx (X-Share-Password in the allowed request headers) · #21839 · PR #21890"
16081641
],
16091642
"history": [
16101643
{
@@ -1630,6 +1663,12 @@
16301663
"date": "2026-10-04",
16311664
"change": "A5 record-gone leg and step 7 re-pointed to the measured answer: a deleted record's still-live link resolves 404 INVALID_OR_EXPIRED, byte-identical to an unknown token, never 410 RECORD_GONE (resolveToken's #5190 fail-closed record probe — share-link-service.ts loadRecordForServing — returns null, and the route's row probe in share-links.ts falls through to its one generic refusal, invalidOrExpired). Revoke/expiry legs unchanged (410 EXPIRED_OR_REVOKED). Stale clause found by the 17.7 pre-release runs",
16321665
"ref": "#21735"
1666+
},
1667+
{
1668+
"revision": 5,
1669+
"date": "2026-10-06",
1670+
"change": "three clauses added for the rules PR #21890 landed (#21839), with a step and a negative each: the stored password hash leaves the server on no exit (mint, list, redemption); the password is accepted from the X-Share-Password header (the ?password= form is still accepted, and the default CORS allow-list carries the header); and both public routes answer Cache-Control: no-store and Vary: X-Share-Password on every outcome, while the authenticated routes do not. The messages route is scored on its refusal, since its success half stays the Cloud/EE knownGap. Existing clauses, steps and indices are unchanged",
1671+
"ref": "#21932"
16331672
}
16341673
]
16351674
},

0 commit comments

Comments
 (0)