From 09f0a916798cf7f6fbb262cad6f3e779d7569eb0 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 18 Aug 2026 23:16:35 +0000 Subject: [PATCH 1/2] test(showcase): land the seed fixtures the platform checklist could not run without (#9308) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three platform capabilities had no fixture anywhere in the reference app, so the checklist items covering them were not failing — they were unrunnable. 1. A second, actually LOGINABLE member. The demo personas have been sys_user rows since #3409/#3411 and neither could sign in, blocking every item that needs two acting identities. The non-obvious half: better-auth 1.7 keys accounts on (issuer, providerAccountId), so a credential row carrying any other issuer is invisible to sign-in and fails INVALID_EMAIL_OR_PASSWORD behind a "User not found" warn that points at the user row instead of the account. seed-approval-demo.ts now provisions the credential account through better-auth's own $context and READS the issuer off the dev admin's own credential row rather than re-spelling a constant plugin-auth owns. Dev-only by construction. 2. showcase_client_brief — the first object to opt into publicSharing. Carries redactFields, an expiry cap and an eligibility predicate; the seed holds a published brief (mint-eligible) and a draft one (refused RECORD_NOT_ELIGIBLE) so the predicate is falsifiable. Every other object still declines the opt-in, which is what keeps the per-object 422 a real control. 3. showcase_client_liaison — the app's first readable:false FLS grant, on the same three showcase_project budget figures showcase_contributor governs with readable:true/editable:false. All three move together because budget_remaining is a formula over budget - spent. Downstream reconciliations, each deliberate and none a re-baseline: access-matrix.json gains two rows and moves none; the persona x CRUD census follows the matrix (50/50 -> 54/54, arithmetic recorded at the assertion) and its fixture maps learn the new object; the position count pin follows the new position. Five checklist items are revised in the same change — gap text kept, marked closed-by-fixture, revision bumped, history appended (#7670 pattern). Not landed: the writable-package summary field for automation.rollup-summary-filter. It is not seed data and cannot be made into seed data — see FOLLOW-UPS.md section 6. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Y26DJEHSBhhAQ6wwfsHNza --- .../showcase-checklist-seed-fixtures.md | 49 +++++ docs/qa/platform-checklist/FOLLOW-UPS.md | 30 +++ .../areas/access-security.json | 34 ++- .../platform-checklist/areas/approvals.json | 27 ++- examples/app-showcase/access-matrix.json | 22 ++ examples/app-showcase/package.json | 3 +- .../src/data/objects/client-brief.object.ts | 103 +++++++++ .../app-showcase/src/data/objects/index.ts | 1 + examples/app-showcase/src/data/seed/index.ts | 42 +++- .../src/security/bind-position-sets.ts | 1 + .../src/security/demo-personas.ts | 72 ++++-- examples/app-showcase/src/security/index.ts | 2 + .../src/security/permission-sets.ts | 60 +++++ .../app-showcase/src/security/positions.ts | 13 ++ .../src/security/seed-approval-demo.ts | 165 +++++++++++++- examples/app-showcase/test/seed.test.ts | 12 +- ...se-client-liaison-fixtures.dogfood.test.ts | 206 ++++++++++++++++++ ...owcase-crud-persona-matrix.dogfood.test.ts | 25 ++- ...se-demo-personas-loginable.dogfood.test.ts | 159 ++++++++++++++ 19 files changed, 969 insertions(+), 57 deletions(-) create mode 100644 .changeset/showcase-checklist-seed-fixtures.md create mode 100644 examples/app-showcase/src/data/objects/client-brief.object.ts create mode 100644 packages/qa/dogfood/test/showcase-client-liaison-fixtures.dogfood.test.ts create mode 100644 packages/qa/dogfood/test/showcase-demo-personas-loginable.dogfood.test.ts diff --git a/.changeset/showcase-checklist-seed-fixtures.md b/.changeset/showcase-checklist-seed-fixtures.md new file mode 100644 index 0000000000..d84b3f066d --- /dev/null +++ b/.changeset/showcase-checklist-seed-fixtures.md @@ -0,0 +1,49 @@ +--- +"@objectstack/example-showcase": patch +--- + +Land the showcase seed fixtures the platform checklist could not run without (#9308) + +Three capabilities the platform ships had no fixture anywhere in the reference app, so the +checklist items covering them were not failing — they were unrunnable. Each is closed here +with the smallest stock addition that makes it observable, and with the negative control +left intact. + +**A second, actually loginable member.** The demo personas (Mei Phone the submitter, Ada +Auditor the sole `auditor`) have existed as `sys_user` rows since #3409/#3411, and neither +could sign in — so every item needing two acting identities was stuck: per-group 会签 needs +the two groups decided by two different people, submitter-side viewer gating needs the +submitter looking at their own request, and an out-of-office delegation is only falsifiable +when the delegate holds a separate token. The non-obvious half is why a password hash was +never enough: better-auth 1.7 keys accounts on `(issuer, providerAccountId)`, so a +credential row carrying any other issuer is invisible to sign-in, which then fails +`INVALID_EMAIL_OR_PASSWORD` behind a "User not found" warn pointing at the user row rather +than at the account. `seed-approval-demo.ts` now provisions the credential account through +better-auth's own `$context` — its hasher, its `internalAdapter.createAccount` — and READS +the issuer off the dev admin's own credential row instead of re-spelling a constant +`plugin-auth` owns, so the two cannot drift. Dev-only by construction: the bootstrap runs +only where the dev admin exists, and that admin is hard-gated on `NODE_ENV=development`. + +**An object that opts into `publicSharing`.** No stock object declared it, so +`POST /share-links` answered 422 `SHARING_NOT_ENABLED` for every showcase object and the +whole downstream half of link sharing — resolve, redaction, the audience and password +gates, fail-closed revoke — was unreachable. `showcase_client_brief` opts in with +`redactFields`, an expiry cap and an `eligibility` predicate, and the seed carries both a +`published` brief (mint-eligible) and a `draft` one (refused `RECORD_NOT_ELIGIBLE`) so the +predicate is falsifiable and not merely satisfied. Every other object still declines the +opt-in, which is what keeps the per-object 422 a real control. + +**A `readable: false` FLS grant.** The app governed the three `showcase_project` budget +figures with `readable: true, editable: false` — the WRITE half of field-level security — +and authored no read-withheld grant at all, leaving `plugin-security`'s field masker with +no stock fixture. `showcase_client_liaison` is that grant, on the same three fields, so the +two sets read side by side as the two halves of one mechanism. All three figures move +together because `budget_remaining` is a formula over `budget - spent` and masking one +leaks it back through arithmetic. + +Downstream reconciliations, each deliberate: `access-matrix.json` gains two rows and moves +none; the persona × CRUD sweep's census follows the matrix (50/50 → 54/54, arithmetic +recorded at the assertion) and its fixture maps learn the new object; the position count +pin follows the new position. The five checklist items whose `knownGaps` this closes are +revised in the same change — gap text kept, marked closed-by-fixture, `revision` bumped, +`history` appended. diff --git a/docs/qa/platform-checklist/FOLLOW-UPS.md b/docs/qa/platform-checklist/FOLLOW-UPS.md index 21274bd319..3d8dc3187a 100644 --- a/docs/qa/platform-checklist/FOLLOW-UPS.md +++ b/docs/qa/platform-checklist/FOLLOW-UPS.md @@ -136,3 +136,33 @@ Ledger 182 → 190 items; `coverage.json` 28 mapped / 2 waived → **30 mapped / `integration-system.datasource-credential-refusal-matrix`) assert guards that are already shipped and already public in their ADRs/issues; nothing here discloses an unfixed hole. + +## 6. Resolution 2026-08-18 — §3's fixture list, three of seven paid (#9308) + +§3 above is left exactly as written; this section is its resolution row, per this file's +own append-never-rewrite rule. Three of the seven fixtures §3 asked for landed in +`examples/app-showcase/**`, and the checklist items they unblock were revised in the same +change (gap text kept and marked closed-by-fixture, `revision` bumped, `history` +appended — the #7670 pattern). + +| §3 line | landed as | items unblocked | +|---|---|---| +| "a `publicSharing.enabled` object" | `showcase_client_brief` — `redactFields`, `maxExpiryDays`, and an `eligibility` predicate, with a `published` AND a `draft` brief seeded so the predicate is falsifiable | `access-security.share-link-capability-tokens` (was `blocked(fixture)`, now runnable) | +| "a second signed-up (non-admin) user in seeds" | both demo personas get a better-auth credential account at boot (`seed-approval-demo.ts` → `ensureCredentialAccount`), issuer derived from the dev admin's own row | `approvals.per-group-signoff`, `approvals.viewer-gating-submitter-side` (was `blocked(fixture)`), `approvals.ooo-delegation-reroute` | +| — (not in §3; found by #9308) | `showcase_client_liaison` — the app's first `readable: false` FLS grant | `access-security.fls-mask-and-strip` clause 5, whose UI half had no reachable fixture | + +**Still open from §3, unchanged and still correctly listed there:** the OIDC/social IdP, +the gantt fixture variants, the not-auto-bound audience suggestion, the +`IMPORT_CONSOLE_LIVE` import harness, and the escalation clock-control harness. + +**One fixture #9308 scoped but did NOT land: the writable-package summary field** for +`automation.rollup-summary-filter`'s editor half. It is not seed data and could not be +made into seed data. A writable package is a DB-backed `sys_packages` row plus authored +`sys_metadata` items (`isWritablePackage`: a booted CODE package is read-only by +definition, and the showcase is one), and a roll-up needs a parent AND a child object, so +the fixture is a boot-time metadata-authoring bootstrap that mints two tables on every +fresh boot of the reference app. Whether the showcase should ship a permanent writable +base is a showcase design call with consequences beyond this item — it is also the +contrast side `access-security.readonly-package-locks-studio` needs — so it is filed +separately rather than guessed at. The item keeps its `blocked(fixture)` and its +`knownGap` untouched. diff --git a/docs/qa/platform-checklist/areas/access-security.json b/docs/qa/platform-checklist/areas/access-security.json index 4e1196e1a2..dffa918188 100644 --- a/docs/qa/platform-checklist/areas/access-security.json +++ b/docs/qa/platform-checklist/areas/access-security.json @@ -621,7 +621,7 @@ "title": "Field-level security: editable:false strips/denies writes API-side and renders read-only in the UI; masked-read half needs an authored readable:false grant", "since": "v15", "status": "active", - "revision": 2, + "revision": 3, "priority": "P1", "surface": "mixed", "personas": [ @@ -632,10 +632,12 @@ "app": "showcase", "requires": [ "showcase_contributor.fields FLS on showcase_project budget figures (permission-sets.ts — keys are . qualified; bare keys silently enforce nothing, the compile-time lint security-fls-unqualified-key guards that)", - "a seeded showcase_project row with a non-null budget" + "a seeded showcase_project row with a non-null budget", + "showcase_client_liaison — the STOCK readable:false grant since #9308 fixture 4 (permission-sets.ts): showcase_project { allowRead: true } plus fields { 'showcase_project.budget' | '.spent' | '.budget_remaining': { readable: false, editable: false } }, bound to the client_liaison position. Grant it to a FRESH member and to nobody who also holds showcase_contributor: field permissions merge most-permissive across the sets a caller holds, so contributor's readable:true would legitimately un-mask all three" ], "knownGaps": [ - "[API half CLOSED by #9481; the seed gap itself stays open] stock showcase authors NO readable:false FLS grant, so the read-MASKING half (field absent/nulled on GET, plugin-security/src/field-masker.ts) has no stock fixture; to run it, author a scratch permission set carrying readable:false on a showcase_project field and grant it to a fresh member — if the run cannot author one through a supported surface, record that half blocked(fixture) rather than ticking on the write half alone. What changed: showcase-fls-read-mask-strip.dogfood.test.ts does exactly that at runtime, so the SERVER half no longer waits on the seed. The UI half still does — a console render cannot be driven off a permission set that exists only inside a test's stack — and #9308 fixture 4 is the card that would land the stock grant.", + "[CLOSED — API half by #9481, the seed gap itself by #9308 fixture 4; the gap text stays because it carries the reason] stock showcase authored NO readable:false FLS grant, so the read-MASKING half (plugin-security/src/field-masker.ts) had no stock fixture and a run had to author a scratch permission set at runtime — which the SERVER half could do (showcase-fls-read-mask-strip.dogfood.test.ts does exactly that) but the UI half could not, because a console render cannot be driven off a permission set that exists only inside a test's stack. showcase_client_liaison is now that grant on stock seeds, so clause 5 is runnable end to end: grant the set to a fresh member, sign in as them, and compare their API body and their console render against the admin's. packages/qa/dogfood/test/showcase-client-liaison-fixtures.dogfood.test.ts pins the server half of the stock grant (by id, in lists, through an explicit $select) with the admin as the entitled contrast.", + "all three budget figures are withheld together, not just `budget` — `budget_remaining` is a formula over `budget - spent`, so masking one of the three leaks it back through arithmetic. A run that ticks clause 5 having observed only `budget` absent has not checked the mask that matters.", "MASKED and STRIPPED are two different mechanisms with two different wire shapes, and this item's clause 4 (\"absent or nulled\") predates the measurement. Measured answer: a permission-set readable:false DELETES the key (strip); the #8993 `maskingRule` path REPLACES the value and leaves the key present (mask). A test that only checks \"I did not get the real value\" passes for both and pins neither, so the pin asserts key-absence. The masked half has no fixture at all — do not tick it off the strip pin." ] }, @@ -709,6 +711,12 @@ "date": "2026-08-18", "change": "READ half pinned (QA run #9401 scored this item partial on a write-only pin). automated.ref now names the read pin and states what it does NOT reach: the UI render, and the #8993 maskingRule path, which has no fixture in this repo. knownGaps records the measured mask-vs-strip distinction — readable:false DELETES the key, it does not null it — so the next runner does not tick the masked half off the strip pin", "ref": "#9481" + }, + { + "revision": 3, + "date": "2026-08-18", + "change": "the SEED gap is closed too: showcase_client_liaison is a stock permission set carrying readable:false on all three showcase_project budget figures, so clause 5's UI half is reachable for the first time — a real member, a real console render, no test-local stack. knownGaps rewritten as closed-by-fixture rather than deleted, with the merge caveat (do not also grant showcase_contributor) and the reason all three figures move together. The #8993 maskingRule gap is UNTOUCHED and still has no fixture anywhere in this repo", + "ref": "#9308" } ] }, @@ -1413,7 +1421,7 @@ "title": "Share-link capability tokens: anon resolve renders the record minus redactFields, password/audience gates hold, revoke/expire refuse without leaking", "since": "v16", "status": "active", - "revision": 1, + "revision": 2, "priority": "P2", "surface": "api", "personas": [ @@ -1424,17 +1432,15 @@ "fixtures": { "app": "showcase", "requires": [ - "an object declaring publicSharing { enabled:true, allowedAudiences, allowedPermissions, redactFields } with at least one visible record — REQUIRED to mint any link" + "an object declaring publicSharing { enabled:true, allowedAudiences, allowedPermissions, redactFields } with at least one visible record — REQUIRED to mint any link", + "showcase_client_brief is that object since #9308 fixture 2: publicSharing { enabled:true, allowedAudiences ['link_only','signed_in','email'], allowedPermissions ['view'], maxExpiryDays 30, redactFields ['internal_notes','deal_value'], eligibility \"record.status == 'published'\" }. The minter persona is a member holding showcase_client_liaison (bound to the client_liaison position); the seed carries a PUBLISHED brief ('Northwind — Website Relaunch brief', mint-eligible) and a DRAFT one ('Fabrikam — Compliance Audit brief', which the eligibility predicate refuses 422 RECORD_NOT_ELIGIBLE)" ], "knownGaps": [ - "NO stock showcase object sets publicSharing.enabled (verified across examples/app-showcase/src) — createLink answers 422 SHARING_NOT_ENABLED for every showcase object, so the resolve/redact/password/revoke clauses are BLOCKED until a publicSharing-enabled fixture object (e.g. qa_public_note with redactFields) is added; only the SHARING_NOT_ENABLED clause is runnable on stock today", - "the GET /share-links/:token/messages half is ai_conversations-only (Cloud/EE service-ai in the `cloud` repo) — split out as a separate knownGap; do not exercise messages against open-framework showcase" + "[CLOSED by #9308 fixture 2 — the gap text stays because it carries the reason] NO stock showcase object set publicSharing.enabled (verified across examples/app-showcase/src), so createLink answered 422 SHARING_NOT_ENABLED for every showcase object and only the SHARING_NOT_ENABLED clause was runnable; the resolve/redact/password/revoke clauses were BLOCKED. showcase_client_brief now opts in (see requires). NOTE the negative control did NOT move: every OTHER showcase object still declines the opt-in, so clause 1's 422 is still runnable — probe it against showcase_private_note, not against the new object", + "the GET /share-links/:token/messages half is ai_conversations-only (Cloud/EE service-ai in the `cloud` repo) — split out as a separate knownGap; do not exercise messages against open-framework showcase", + "audience `public` is deliberately NOT in the fixture object's allowedAudiences (nothing in the showcase wants an un-tokened, indexable surface), so a `public`-audience mint is refused PERMISSION/AUDIENCE-not-allowed rather than being a resolve variant. Record that as the declared policy, not as a defect" ] }, - "blocked": { - "by": "fixture", - "ref": "no showcase object sets publicSharing.enabled — needs a publicSharing-enabled fixture object (PENDING-GAPS.md §C share-link-capability-tokens)" - }, "steps": [ "STOCK-RUNNABLE probe: as a member, POST /api/v1/share-links {\"object\": \"showcase_private_note\", \"recordId\": \"\"} — capture the 422 SHARING_NOT_ENABLED that proves the block (no showcase object opts in)", "with a publicSharing-enabled fixture object F (redactFields declared): as a member who can see a record r of F, POST /api/v1/share-links {object:F, recordId:r, redactFields?, audience?, password?, expiresAt?} — capture the 201 {token}", @@ -1513,6 +1519,12 @@ "date": "2026-08-08", "change": "new — share-link capability tokens (ADR-0047): resolve-minus-redactFields, password/audience gates, fail-closed revoke/expire/record-gone, caller-scoped list; blocked(fixture) because no showcase object opts into publicSharing, messages half split as a Cloud/EE knownGap", "ref": "claude/platform-test-checklist-ocwugl" + }, + { + "revision": 2, + "date": "2026-08-18", + "change": "UNBLOCKED. showcase_client_brief is the stock publicSharing fixture and the seed carries both an eligible and an ineligible record, so mint / resolve / redaction are runnable on stock and the declared eligibility predicate is falsifiable too. `blocked` removed; the fixture is named in requires; the knownGap is rewritten as closed-by-fixture rather than deleted, with the warning that clause 1's negative control must stay pointed at an object that did NOT opt in. packages/qa/dogfood/test/showcase-client-liaison-fixtures.dogfood.test.ts pins the mint, the eligibility refusal and the redaction", + "ref": "#9308" } ] }, diff --git a/docs/qa/platform-checklist/areas/approvals.json b/docs/qa/platform-checklist/areas/approvals.json index a757a8db69..53278e8fc8 100644 --- a/docs/qa/platform-checklist/areas/approvals.json +++ b/docs/qa/platform-checklist/areas/approvals.json @@ -8,7 +8,7 @@ "title": "Per-group sign-off (会签) needs one approval from EACH group", "since": "v16", "status": "active", - "revision": 4, + "revision": 5, "priority": "P1", "surface": "browser", "personas": ["approver holding exactly one group (e.g. manager)", "second approver holding the other group (e.g. finance/auditor)"], @@ -19,8 +19,8 @@ "seed-approval-demo.ts wiring: dev admin holds manager/finance/legal/exec but NOT auditor, so the manager group resolves to the admin and the finance group to Ada Auditor only; EXP-2001 ($1,500) sits under the $5,000 committee threshold so the quorum flow does not also open on it; submitter is Mei Phone (usr_showcase_phone_demo)" ], "knownGaps": [ - "Ada Auditor exists as a routable sys_user row only — better-auth sign-in for her needs an account provisioned at runtime (seed-approval-demo.ts: 'sign-in still needs a better-auth account'); the finance-group decision therefore needs either a provisioned Ada account or the server-granted admin override (can_override) — the run record must state which path was used", - "provisioning that account needs more than a password hash: better-auth 1.7.0-rc.2 requires the credential account's issuer to equal local:credential (packages/plugins/plugin-auth/src/backfill-account-issuer.ts, CREDENTIAL_ISSUER). Without that one field, sign-in fails INVALID_EMAIL_OR_PASSWORD behind a misleading 'User not found' warn — the user exists and the hash is right, so the warn sends you looking in exactly the wrong place. With it set, prefer the real Ada session over the admin override (#7517)" + "[CLOSED by #9308 fixture 1 — the gap text stays because it carries the reason] Ada Auditor used to exist as a routable sys_user row only, so the finance-group decision needed either a hand-provisioned Ada account or the server-granted admin override (can_override), and the run record had to state which path was used. She is now a real login on a stock boot, so drive the REAL Ada session: the admin override is no longer an acceptable substitute here, because a decision laundered through the admin cannot show that the two groups resolved to two different people", + "[CLOSED by #9308 fixture 1 — the gap text stays because it carries the reason] provisioning that account needs more than a password hash: better-auth 1.7.0-rc.2 requires the credential account's issuer to equal local:credential (packages/plugins/plugin-auth/src/backfill-account-issuer.ts, CREDENTIAL_ISSUER). Without that one field, sign-in fails INVALID_EMAIL_OR_PASSWORD behind a misleading 'User not found' warn — the user exists and the hash is right, so the warn sends you looking in exactly the wrong place. What changed: examples/app-showcase/src/security/seed-approval-demo.ts now provisions the credential account itself (ensureCredentialAccount) through better-auth's own $context — its hasher, its internalAdapter.createAccount, and the issuer READ OFF the dev admin's own credential row rather than re-spelled — so both demo personas sign in with DEMO_PERSONA_PASSWORD ('showcase123', demo-personas.ts) on a stock dev boot. packages/qa/dogfood/test/showcase-demo-personas-loginable.dogfood.test.ts pins the sign-in end to end. Dev-only by construction: the bootstrap runs only when the dev admin exists, and that admin is hard-gated on NODE_ENV=development" ] }, "steps": [ @@ -86,7 +86,8 @@ { "revision": 1, "date": "2026-08-07", "change": "initial import from the #3358 evidence run (decisive oracle: group drops but request stays pending)", "ref": "#3358" }, { "revision": 2, "date": "2026-08-07", "change": "expanded to deep-test contract: concrete steps, multi-clause acceptance, negatives, variants", "ref": "claude/platform-test-checklist-ocwugl" }, { "revision": 3, "date": "2026-08-10", "change": "entry path refreshed: the inbox is opened through the account app's Approvals nav entry (component route) rather than the bare /system/approvals deep link, so the item exercises the path a real user takes after #7213", "ref": "#7331" }, - { "revision": 4, "date": "2026-08-11", "change": "knownGaps records the better-auth issuer=local:credential provisioning detail — the one field that decides whether Ada can be driven as a real second session or the run falls back to the admin override; it used to be rediscovered every sweep, behind a 'User not found' warn that points at the wrong cause", "ref": "#7530" } + { "revision": 4, "date": "2026-08-11", "change": "knownGaps records the better-auth issuer=local:credential provisioning detail — the one field that decides whether Ada can be driven as a real second session or the run falls back to the admin override; it used to be rediscovered every sweep, behind a 'User not found' warn that points at the wrong cause", "ref": "#7530" }, + { "revision": 5, "date": "2026-08-18", "change": "the fixture blocker is CLOSED, not merely documented: the showcase now provisions Ada's better-auth credential account at boot (issuer derived from the dev admin's own row), so she signs in on stock seeds. knownGaps rewritten as closed-by-fixture rather than deleted — the issuer detail is the reason the gap existed and the next sweep still needs it. The admin-override fallback is withdrawn as an acceptable path for this item", "ref": "#9308" } ] }, { @@ -243,7 +244,7 @@ "title": "A submitter who is not an approver sees no approver buttons", "since": "v16", "status": "active", - "revision": 4, + "revision": 5, "priority": "P1", "surface": "browser", "personas": ["submitter holding NO approver position on their own pending request"], @@ -252,11 +253,11 @@ "requires": ["one pending request routed to a position its submitter does not hold"], "knownGaps": [ "stock seeds route every request to positions the admin holds, so the admin is an approver on all of them (#3358) — needs one request addressed away from the signed-in persona", - "the natural persona exists since #3411 — Mei Phone (usr_showcase_phone_demo) submits EXP-2001/EXP-DEMO and holds no approver position — but she is a sys_user row only: signing in as her needs a better-auth account provisioned at runtime (seed-approval-demo.ts)", - "provisioning that account needs more than a password hash: better-auth 1.7.0-rc.2 requires the credential account's issuer to equal local:credential (packages/plugins/plugin-auth/src/backfill-account-issuer.ts, CREDENTIAL_ISSUER). Without that one field, sign-in fails INVALID_EMAIL_OR_PASSWORD behind a misleading 'User not found' warn — the user exists and the hash is right, so the warn sends you looking in exactly the wrong place. With it set this item's blocker is payable at runtime rather than blocked(fixture) (#7517)" + "[CLOSED by #9308 fixture 1 — the gap text stays because it carries the reason] the natural persona has existed since #3411 — Mei Phone (usr_showcase_phone_demo) submits EXP-2001/EXP-DEMO and holds no approver position — but she was a sys_user row only, so signing in as her needed an account provisioned by hand. She is now a real login on a stock boot: seed-approval-demo.ts gives both demo personas a better-auth credential account (ensureCredentialAccount), password DEMO_PERSONA_PASSWORD ('showcase123', demo-personas.ts)", + "[CLOSED by #9308 fixture 1 — the gap text stays because it carries the reason] provisioning that account needs more than a password hash: better-auth 1.7.0-rc.2 requires the credential account's issuer to equal local:credential (packages/plugins/plugin-auth/src/backfill-account-issuer.ts, CREDENTIAL_ISSUER). Without that one field, sign-in fails INVALID_EMAIL_OR_PASSWORD behind a misleading 'User not found' warn — the user exists and the hash is right, so the warn sends you looking in exactly the wrong place. The showcase now derives that issuer from the dev admin's own credential row and stamps it, so the field cannot drift from what better-auth looks accounts up under; packages/qa/dogfood/test/showcase-demo-personas-loginable.dogfood.test.ts pins both the issuer and the sign-in", + "the FIRST gap above is unaffected and still governs the fixture: stock seeds route every request to positions the admin holds, so the item's request must remain the one addressed AWAY from the signed-in persona (Mei's EXP-2001). A run that signs in as the admin proves nothing here, loginable personas or not" ] }, - "blocked": { "by": "fixture", "ref": "#3358 (needs a request routed to a position the viewing submitter does not hold)" }, "steps": [ "provision a sign-in account for the no-position submitter persona (Mei Phone) or an equivalent fixture user; sign in as that persona", "open the inbox through the account app's Approvals nav entry (/_console/apps/com.objectstack.account/component/approvals/inbox) — the 我发起的 tab must list her pending EXP-2001 request; the bare /system/approvals route stays supported but is not the user path being tested here (#7234)", @@ -304,7 +305,8 @@ { "revision": 1, "date": "2026-08-07", "change": "initial import from #3358; upgraded the oracle from DOM-only to both-sides (UI absence + server rejection)", "ref": "#3358" }, { "revision": 2, "date": "2026-08-07", "change": "expanded to deep-test contract: concrete steps, multi-clause acceptance, negatives, variants", "ref": "claude/platform-test-checklist-ocwugl" }, { "revision": 3, "date": "2026-08-10", "change": "entry path refreshed to the account app's Approvals nav entry (component route) after #7213/#7234", "ref": "#7331" }, - { "revision": 4, "date": "2026-08-11", "change": "knownGaps records the better-auth issuer=local:credential provisioning detail — with it, Mei's sign-in is provisionable at runtime and this item's standing fixture blocker becomes payable instead of permanent", "ref": "#7530" } + { "revision": 4, "date": "2026-08-11", "change": "knownGaps records the better-auth issuer=local:credential provisioning detail — with it, Mei's sign-in is provisionable at runtime and this item's standing fixture blocker becomes payable instead of permanent", "ref": "#7530" }, + { "revision": 5, "date": "2026-08-18", "change": "UNBLOCKED. The showcase provisions Mei's better-auth credential account at boot, so the submitter persona this item is built around is a real, stock login and `blocked(fixture)` is removed. knownGaps rewritten as closed-by-fixture rather than deleted, and the one gap that is NOT closed (stock requests route to the admin, so the persona choice is still load-bearing) is restated so it does not get read as closed by association", "ref": "#9308" } ] }, { @@ -631,7 +633,7 @@ "title": "An active out-of-office delegation reroutes an individually-routed approver to the delegate; expiring the window hands the slot back", "since": "v16", "status": "active", - "revision": 2, + "revision": 3, "priority": "P2", "surface": "mixed", "personas": ["dev admin — the DELEGATOR A: the delegation row is written BY them FOR them (sys_approval_delegation is self-service-only, so the delegator must be whoever is authenticated)", "a SECOND REAL runtime account — the DELEGATE B (Ada Auditor usr_showcase_auditor_demo, provisioned as a real login per knownGaps), who holds their own bearer token and decides the rerouted request as themselves"], @@ -643,7 +645,7 @@ ], "knownGaps": [ "⛔ the delegation CANNOT be set up as a third party on behalf of another delegator: sys_approval_delegation is self-service-only and there is no admin exemption (#4839 — bindDelegationWriteGuard rejects a foreign delegator_id with FORBIDDEN, and an absent one is stamped to the caller). Combined with the seeded demo personas having no auth account of their own, the 'admin delegates on behalf of Mei' shape this item used to prescribe is not constructible by ANYBODY. The run drives the mirror image instead — the admin delegates their OWN slot to a real second account — which is also the stronger test, because the delegate decides under a genuinely separate identity and the audit's no-laundering clause becomes falsifiable (#7517)", - "provisioning a seeded demo persona as a real login needs more than a password hash: better-auth 1.7.0-rc.2 requires the credential account's issuer to equal local:credential (packages/plugins/plugin-auth/src/backfill-account-issuer.ts, CREDENTIAL_ISSUER). Without that one field, sign-in fails INVALID_EMAIL_OR_PASSWORD behind a misleading 'User not found' warn — the user exists and the hash is right, so the warn sends you looking in exactly the wrong place. With it set, B is constructible: drive the real second account rather than recording blocked(fixture) (#7517)", + "[CLOSED by #9308 fixture 1 — the gap text stays because it carries the reason] provisioning a seeded demo persona as a real login needs more than a password hash: better-auth 1.7.0-rc.2 requires the credential account's issuer to equal local:credential (packages/plugins/plugin-auth/src/backfill-account-issuer.ts, CREDENTIAL_ISSUER). Without that one field, sign-in fails INVALID_EMAIL_OR_PASSWORD behind a misleading 'User not found' warn — the user exists and the hash is right, so the warn sends you looking in exactly the wrong place. Delegate B is now constructible with no setup: seed-approval-demo.ts provisions Ada Auditor's credential account at boot (issuer derived from the dev admin's own row), password DEMO_PERSONA_PASSWORD ('showcase123', demo-personas.ts), pinned by packages/qa/dogfood/test/showcase-demo-personas-loginable.dogfood.test.ts. Drive the real second account; do not record blocked(fixture) for this reason", "authoring the scratch flow needs a writable/scratch package — the showcase ships read-only" ] }, @@ -707,7 +709,8 @@ ], "history": [ { "revision": 1, "date": "2026-08-08", "change": "initial — pins the #1322 OOO delegation reroute: active A→B window reroutes an individually-routed slot to B (audited + notified, decided under B's own identity); expiry hands it back to A at resolution time", "ref": "claude/platform-test-checklist-ocwugl" }, - { "revision": 2, "date": "2026-08-11", "change": "persona shape rewritten around what is constructible: the old fixture had the admin write a delegation on behalf of a non-admin delegator, which sys_approval_delegation's self-service-only guard refuses from everybody (#4839) and which the named delegator could not do either (no auth account). Now A = the signed-in admin delegating their OWN slot and B = a second REAL runtime account deciding with its own bearer token — the mirror image #7517 actually drove, and the stronger test. Added the foreign-delegator refusal as a negative and the better-auth issuer provisioning detail to knownGaps", "ref": "#7530" } + { "revision": 2, "date": "2026-08-11", "change": "persona shape rewritten around what is constructible: the old fixture had the admin write a delegation on behalf of a non-admin delegator, which sys_approval_delegation's self-service-only guard refuses from everybody (#4839) and which the named delegator could not do either (no auth account). Now A = the signed-in admin delegating their OWN slot and B = a second REAL runtime account deciding with its own bearer token — the mirror image #7517 actually drove, and the stronger test. Added the foreign-delegator refusal as a negative and the better-auth issuer provisioning detail to knownGaps", "ref": "#7530" }, + { "revision": 3, "date": "2026-08-18", "change": "delegate B needs no hand-provisioning any more: the showcase gives Ada Auditor a real better-auth credential account on a stock boot, so the issuer knownGap is rewritten as closed-by-fixture. The OTHER two gaps are untouched and still bind — the self-service-only delegation guard (#4839) and the writable/scratch package the individually-routed flow needs", "ref": "#9308" } ] }, { diff --git a/examples/app-showcase/access-matrix.json b/examples/app-showcase/access-matrix.json index c2411fbd6d..50b6ac7cec 100644 --- a/examples/app-showcase/access-matrix.json +++ b/examples/app-showcase/access-matrix.json @@ -45,6 +45,28 @@ "modifyAllRecords": false, "sharingModel": "private" }, + { + "permissionSet": "showcase_client_liaison", + "object": "showcase_client_brief", + "create": true, + "read": true, + "edit": true, + "delete": false, + "viewAllRecords": false, + "modifyAllRecords": false, + "sharingModel": "public_read" + }, + { + "permissionSet": "showcase_client_liaison", + "object": "showcase_project", + "create": false, + "read": true, + "edit": false, + "delete": false, + "viewAllRecords": false, + "modifyAllRecords": false, + "sharingModel": "public_read_write" + }, { "permissionSet": "showcase_contributor", "object": "showcase_account", diff --git a/examples/app-showcase/package.json b/examples/app-showcase/package.json index 1febc52bc3..3f8d191cf2 100644 --- a/examples/app-showcase/package.json +++ b/examples/app-showcase/package.json @@ -9,7 +9,8 @@ "exports": { ".": "./objectstack.config.ts", "./objectstack.config": "./objectstack.config.ts", - "./coverage": "./src/coverage.ts" + "./coverage": "./src/coverage.ts", + "./security-personas": "./src/security/demo-personas.ts" }, "scripts": { "dev": "objectstack dev --seed-admin", diff --git a/examples/app-showcase/src/data/objects/client-brief.object.ts b/examples/app-showcase/src/data/objects/client-brief.object.ts new file mode 100644 index 0000000000..a8cf0a117e --- /dev/null +++ b/examples/app-showcase/src/data/objects/client-brief.object.ts @@ -0,0 +1,103 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { ObjectSchema, Field } from '@objectstack/spec/data'; + +/** + * Client Brief — the showcase's SHARE-LINK object (ADR-0047 / ADR-0111 D8). + * + * A client-facing summary of a project that a Client Liaison publishes by + * opaque capability token ("anyone with the link"), Notion/Figma style. It is + * the app's demonstration of `publicSharing`, and it was added because the + * showcase had NO demonstration of it at all: verified across + * `examples/app-showcase/src`, not one object opted in, so + * `POST /api/v1/share-links` answered 422 `SHARING_NOT_ENABLED` for every + * showcase object and every downstream behaviour of the feature — redaction, + * the password and audience gates, fail-closed revoke/expiry — was unreachable + * on stock fixtures (#9308 fixture 2). + * + * ## `publicSharing` is a DIFFERENT AXIS from `sharingModel` + * + * `sharingModel` (here `public_read`) governs PRINCIPAL-based access: which + * signed-in members may read the row, and who may write it (the owner). The + * `publicSharing` block below governs LINK-based access: whether a capability + * token may be minted for the row at all, which audiences and permissions the + * minter may select, how far out an expiry may be set, and which fields are + * stripped before anything leaves the server through a token. The two do not + * constrain each other, which is exactly why an object must opt into link + * sharing explicitly — a widely-readable object is not thereby publishable to + * the anonymous internet. + * + * ## Both directions of every declared key are demonstrable on the seed + * + * A policy whose keys fail OPEN when dropped (see the `publicSharing` history + * note in `object.zod.ts`) is worth seeding so that it can be FALSIFIED, not + * merely observed: + * + * • `redactFields` — `internal_notes` and `deal_value` carry the numbers and + * the candour a client must never see, while `title` / `summary` / + * `project` are the client-facing half. A resolve that returns either + * redacted field is a defect with an obvious name. + * • `eligibility` — only a `published` brief may be shared. The seed carries + * a `draft` one on purpose, so the refusal (422 `RECORD_NOT_ELIGIBLE`, + * #7861) is demonstrable on stock data rather than only its acceptance. + * • `maxExpiryDays` — a link asked to outlive 30 days is rejected. + * + * ## Why the internal fields live HERE rather than on `showcase_project` + * + * Redaction is per-OBJECT, so the fields a link must not carry have to be on + * the shared object. Hanging them off the brief (rather than sharing a project + * row directly) also keeps the fixture from touching `showcase_project`, whose + * OWD and field set sit upstream of a large part of the dogfood gate. + */ +export const ClientBrief = ObjectSchema.create({ + name: 'showcase_client_brief', + label: 'Client Brief', + pluralLabel: 'Client Briefs', + icon: 'share-2', + description: + 'A client-facing project brief published by opaque share link — the showcase\'s `publicSharing` demonstration (ADR-0047).', + + // Principal-based access: every member with the object bit reads every + // brief; only the owner writes it. Deliberately NOT the link axis below. + sharingModel: 'public_read', + + fields: { + title: Field.text({ label: 'Title', required: true, searchable: true, maxLength: 160 }), + summary: Field.text({ label: 'Client Summary', maxLength: 2000 }), + project: Field.lookup('showcase_project', { label: 'Project' }), + status: Field.select({ + label: 'Status', + options: [ + { label: 'Draft', value: 'draft', default: true, color: '#94A3B8' }, + { label: 'Published', value: 'published', color: '#10B981' }, + ], + }), + // ── The redacted half ──────────────────────────────────────────────── + // Never served through a share token (see `redactFields` below). Ordinary + // API access by an entitled member is unaffected — redaction applies only + // when the request principal is `kind:'share-link'`. + internal_notes: Field.text({ label: 'Internal Notes', maxLength: 2000 }), + deal_value: Field.currency({ label: 'Deal Value', scale: 2, min: 0 }), + // Owner anchor — auto-stamped on insert; the `public_read` OWD reads it to + // decide who may WRITE the row. + owner_id: Field.lookup('sys_user', { label: 'Owner' }), + }, + + // ── The link axis (ADR-0047) ─────────────────────────────────────────── + publicSharing: { + enabled: true, + // Every audience this item's runner drives, and no more. `public` (search + // engines may index, no token check) is deliberately absent: nothing in + // this app wants an un-tokened surface, and an audience the platform will + // accept is an audience someone can select. + allowedAudiences: ['link_only', 'signed_in', 'email'], + // Read-only links. `comment` / `edit` would hand a token holder write + // authority over a row whose owner never approved it. + allowedPermissions: ['view'], + maxExpiryDays: 30, + redactFields: ['internal_notes', 'deal_value'], + // [#7861] Evaluated against the candidate record at mint time — a draft + // brief cannot be published by link, whatever the caller holds. + eligibility: "record.status == 'published'", + }, +}); diff --git a/examples/app-showcase/src/data/objects/index.ts b/examples/app-showcase/src/data/objects/index.ts index d461ed1916..1b2949c288 100644 --- a/examples/app-showcase/src/data/objects/index.ts +++ b/examples/app-showcase/src/data/objects/index.ts @@ -13,6 +13,7 @@ export { CascadingSelect } from './cascading-select.object.js'; export { Preference } from './preference.object.js'; export { PrivateNote } from './private-note.object.js'; export { Announcement } from './announcement.object.js'; +export { ClientBrief } from './client-brief.object.js'; export { Inquiry } from './inquiry.object.js'; export { Contact } from './contact.object.js'; export { SemanticZoo, SemanticZooLegacy } from './semantic-zoo.object.js'; diff --git a/examples/app-showcase/src/data/seed/index.ts b/examples/app-showcase/src/data/seed/index.ts index d038914cbe..2894405d17 100644 --- a/examples/app-showcase/src/data/seed/index.ts +++ b/examples/app-showcase/src/data/seed/index.ts @@ -15,6 +15,7 @@ import { Contact } from '../objects/contact.object.js'; import { Inquiry } from '../objects/inquiry.object.js'; import { FieldZoo } from '../objects/field-zoo.object.js'; import { Announcement } from '../objects/announcement.object.js'; +import { ClientBrief } from '../objects/client-brief.object.js'; import { ADMIN_EMAIL, PHONE_DEMO_USER, AUDITOR_DEMO_USER } from '../../security/demo-personas.js'; /** @@ -496,4 +497,43 @@ const announcements = defineSeed(Announcement, { ], }); -export const ShowcaseSeedData = [accounts, contacts, inquiries, products, projects, tasks, categories, businessUnits, orgUnits, teams, memberships, fieldZoo, invoices, invoiceLines, expenseReports, expenseLines, preferences, announcements]; +/** + * Client briefs — the fixture behind the share-link demo (#9308 fixture 2). + * + * Two rows on purpose, one per side of `showcase_client_brief`'s declared + * `publicSharing.eligibility` (`record.status == 'published'`): the published + * brief is the one a capability token may be minted for, and the draft is the + * one whose mint must be REFUSED (422 `RECORD_NOT_ELIGIBLE`, #7861). Seeding + * only the shareable side would leave the predicate's enforcement unobservable + * on stock data — the same "demonstrate it in BOTH directions" reasoning the + * key-account sharing rule's seed follows. + * + * `internal_notes` and `deal_value` are populated on both, because they are the + * object's `redactFields`: a resolve that strips a field which was empty anyway + * proves nothing. + */ +const clientBriefs = defineSeed(ClientBrief, { + mode: 'upsert', + externalId: 'title', + records: [ + { + title: 'Northwind — Website Relaunch brief', + summary: + 'Discovery is complete and the new information architecture is signed off. Build starts this sprint; the first client-visible preview lands in three weeks.', + project: 'Website Relaunch', + status: 'published', + internal_notes: 'Client stakeholder is nervous about the timeline — do not surface the slip until the preview is up.', + deal_value: 150_000, + }, + { + title: 'Fabrikam — Compliance Audit brief', + summary: 'Draft summary of the audit scope, pending internal review before it goes to the client.', + project: 'Compliance Audit', + status: 'draft', + internal_notes: 'Budget is 98% consumed with a month of scope left. Renegotiate before this leaves the building.', + deal_value: 90_000, + }, + ], +}); + +export const ShowcaseSeedData = [accounts, contacts, inquiries, products, projects, tasks, categories, businessUnits, orgUnits, teams, memberships, fieldZoo, invoices, invoiceLines, expenseReports, expenseLines, preferences, announcements, clientBriefs]; diff --git a/examples/app-showcase/src/security/bind-position-sets.ts b/examples/app-showcase/src/security/bind-position-sets.ts index 32210c631e..72bcdde3c1 100644 --- a/examples/app-showcase/src/security/bind-position-sets.ts +++ b/examples/app-showcase/src/security/bind-position-sets.ts @@ -43,6 +43,7 @@ export const POSITION_PERMISSION_SET_BINDINGS: ReadonlyArray.` qualified — a bare key enforces NOTHING and is + // rejected at compile time by `security-fls-unqualified-key`. + // + // All three, not just `budget`: `budget_remaining` is a formula over + // `budget - spent`, so withholding `budget` alone would leak it back through + // arithmetic. A mask with a documented hole is worse than no mask, because it + // teaches the hole. + fields: { + 'showcase_project.budget': { readable: false, editable: false }, + 'showcase_project.spent': { readable: false, editable: false }, + 'showcase_project.budget_remaining': { readable: false, editable: false }, + }, +}); + // ── The `everyone` baseline suggestion (ADR-0090 D5) ─────────────────────── /** * `isDefault: true` is a SUGGESTION: "bind this set to the built-in @@ -289,4 +348,5 @@ export const allPermissionSets = [ MemberDefaultPermissionSet, GuestPortalPermissionSet, FieldOpsDelegatePermissionSet, + ClientLiaisonPermissionSet, ]; diff --git a/examples/app-showcase/src/security/positions.ts b/examples/app-showcase/src/security/positions.ts index 83aa54947e..39e0661c5d 100644 --- a/examples/app-showcase/src/security/positions.ts +++ b/examples/app-showcase/src/security/positions.ts @@ -64,6 +64,18 @@ export const FieldOpsDelegatePosition = definePosition({ description: 'Scoped administration of the Field Operations business-unit subtree.', }); +/** + * Client-facing coordinator (#9308 fixture 4). Reads projects to write the + * client briefs they publish by share link — and is the app's demonstration + * that a permission set can WITHHOLD a field's READ, not just its write: the + * bound set masks the three budget figures outright. + */ +export const ClientLiaisonPosition = definePosition({ + name: 'client_liaison', + label: 'Client Liaison', + description: 'Prepares and publishes client-facing project briefs; cannot see internal budget figures.', +}); + /** * External client audience — a position for external client principals. * External principals evaluate against each object's `externalSharingModel` @@ -107,6 +119,7 @@ export const allPositions = [ AuditorPosition, OpsPosition, FieldOpsDelegatePosition, + ClientLiaisonPosition, ClientPortalUserPosition, FinancePosition, LegalPosition, diff --git a/examples/app-showcase/src/security/seed-approval-demo.ts b/examples/app-showcase/src/security/seed-approval-demo.ts index 134084ab5c..ff6cb7e162 100644 --- a/examples/app-showcase/src/security/seed-approval-demo.ts +++ b/examples/app-showcase/src/security/seed-approval-demo.ts @@ -24,8 +24,12 @@ * 1. assign the dev-seeded admin to `manager` / `finance` / `legal` so they * are a resolvable approver on every demo request (and can act in the * inbox); - * 2. provision a phone-based demo user so the "phone sign-in surfaces" show - * a real number in the All Users list + record detail; + * 2. provision the two demo personas — a phone-based user so the "phone + * sign-in surfaces" show a real number in the All Users list + record + * detail, and an auditor — AND give each a better-auth credential account + * so they can genuinely sign in (#9308 fixture 1), which is what makes any + * two-identity demo (会签, submitter-side gating, OOO delegation) + * observable without inventing accounts by hand; * 3. launch one flow per approval behavior through the real automation engine, * so genuine, resumable pending requests land in the inbox — Invoice Dual * Sign-off (`unanimous`: finance ∧ legal), High-Value Committee @@ -44,7 +48,12 @@ * this file really creates, or a reassignment writes an inbox row addressed to * nobody (#7746). One registry, two consumers — so the two cannot drift apart. */ -import { ADMIN_EMAIL, PHONE_DEMO_USER, AUDITOR_DEMO_USER } from './demo-personas.js'; +import { + ADMIN_EMAIL, + PHONE_DEMO_USER, + AUDITOR_DEMO_USER, + DEMO_PERSONA_PASSWORD, +} from './demo-personas.js'; const SYS = { isSystem: true } as const; @@ -69,6 +78,31 @@ interface ApprovalDemoContext { hook?: (event: string, handler: () => Promise | void) => void; } +/** + * The slice of better-auth's own `$context` this file uses to make a persona + * LOGINABLE — the same two members `plugin-auth`'s admin `set-user-password` + * endpoint reaches for when it provisions a credential for a user that arrived + * without one (`admin-user-endpoints.ts` → `AuthContextLike`). Structural, so + * the showcase gains no dependency on better-auth's types. + */ +interface AuthContextLike { + password: { hash: (password: string) => Promise }; + internalAdapter: { + createAccount: (account: { + userId: string; + providerId: string; + issuer: string; + providerAccountId: string; + password: string; + }) => Promise; + }; +} + +/** The `auth` service `plugin-auth` registers (its `AuthManager`). */ +interface AuthManagerLike { + getAuthContext: () => Promise; +} + /** Minimal shape of the automation engine we drive (see service-automation). */ interface AutomationEngineLike { execute: ( @@ -136,6 +170,95 @@ async function assignPositions( } } +/** + * Read the issuer better-auth actually minted for the DEV ADMIN's local + * password account, so a persona's credential row is stamped with the same one. + * + * ## Why this is read and not written + * + * better-auth 1.7 keys account identity on `(issuer, providerAccountId)`: + * `findAccountByKey` looks a credential up under the issuer better-auth mints + * for itself, so a row carrying any other value — or none — is INVISIBLE and + * sign-in fails `INVALID_EMAIL_OR_PASSWORD` behind a "User not found" warn that + * points at the `sys_user` row, which is fine, rather than at the account, which + * is not. That one field is the whole reason a hand-hashed password was never + * enough, and it is what four checklist items had recorded as a knownGap. + * + * The value is `plugin-auth`'s to own (`CREDENTIAL_ISSUER` in + * `backfill-account-issuer.ts`), and an example app re-spelling a platform + * constant is how the two start disagreeing. So it is DERIVED from the admin + * account this same runtime already minted: whatever better-auth used there is + * by construction what a sign-in will look these personas up under. + * + * Undefined when it cannot be derived — never a guess. `backfill-account-issuer` + * makes the same call for the same reason: "a wrong issuer is indistinguishable + * from a missing one at sign-in, and it also occupies the unique slot the + * correct row needs." + */ +async function credentialIssuerFromAdmin( + ctx: ApprovalDemoContext, + adminUserId: string, +): Promise { + const account = await findOne(ctx, 'sys_account', { + user_id: adminUserId, + provider_id: 'credential', + }); + const issuer = account?.issuer; + return typeof issuer === 'string' && issuer.length > 0 ? issuer : undefined; +} + +/** + * Give a provisioned persona a better-auth credential account, so it can + * actually SIGN IN (#9308 fixture 1). + * + * Goes through better-auth's own `$context` — its hasher and its + * `internalAdapter.createAccount` — rather than writing `sys_account` by hand: + * the same path `plugin-auth`'s admin `set-user-password` takes for a user + * onboarded without a credential (see `admin-user-endpoints.ts`). A raw insert + * would have to reproduce better-auth's hash format and its id/column mapping, + * which is two more things that can silently drift out of agreement with the + * code that reads them back. + * + * Idempotent: a persona that already holds a credential account is left alone, + * so a persistent dev database keeps whatever password its operator set. + */ +async function ensureCredentialAccount( + ctx: ApprovalDemoContext, + userId: string, + issuer: string, + password: string, +): Promise { + const existing = await findOne(ctx, 'sys_account', { + user_id: userId, + provider_id: 'credential', + }); + if (existing) return false; + try { + const auth = await ctx.getService?.('auth'); + if (!auth || typeof auth.getAuthContext !== 'function') { + ctx.logger?.warn?.('[showcase] approval-demo: no auth service — persona stays un-loginable', { userId }); + return false; + } + const authCtx = await auth.getAuthContext(); + const hashed = await authCtx.password.hash(password); + await authCtx.internalAdapter.createAccount({ + userId, + providerId: 'credential', + issuer, + providerAccountId: userId, + password: hashed, + }); + ctx.logger?.info?.('[showcase] approval-demo persona is now loginable', { userId }); + return true; + } catch (err) { + ctx.logger?.warn?.('[showcase] approval-demo credential provisioning failed (persona stays un-loginable)', { + userId, + error: err instanceof Error ? err.message : String(err), + }); + return false; + } +} + /** * Provision a demo persona row (best-effort). Returns the user id, whether it * was just created or already present, so callers can route positions at it. @@ -156,10 +279,11 @@ async function ensureDemoUser( ctx.logger?.info?.('[showcase] approval-demo persona provisioned', { email: user.email }); return user.id; } catch (err) { - // Non-fatal: sign-in still needs a better-auth account; this row just makes - // the persona visible in the All Users list + record detail, and routable - // as an approver. - ctx.logger?.warn?.('[showcase] approval-demo persona insert failed (surfaces only)', { + // Non-fatal, and the whole persona is lost when it happens: with no row + // there is nothing for `ensureCredentialAccount` to attach a credential to, + // so the persona is neither visible in the All Users list nor routable as an + // approver nor able to sign in. + ctx.logger?.warn?.('[showcase] approval-demo persona insert failed (persona unavailable)', { email: user.email, error: err instanceof Error ? err.message : String(err), }); @@ -264,6 +388,33 @@ export function registerShowcaseApprovalDemo(ctx: ApprovalDemoContext): void { const auditorId = await ensureDemoUser(ctx, AUDITOR_DEMO_USER); if (auditorId) await assignPositions(ctx, auditorId, ['auditor'], organizationId, 'auditor'); + // [#9308 fixture 1] Make both personas SIGN-INABLE. Provisioning them as + // rows was never the hard half — the credential account was, and the issuer + // is the one field that decides whether better-auth can find it. Derived + // once from the admin's own account and reused for both personas; when it + // cannot be derived, nothing is written (a wrong issuer is worse than an + // absent one — see `credentialIssuerFromAdmin`). + // + // This is what turns "a second user exists" into "a second user can act": + // the per-group 会签 demo needs Ada to decide the `finance` group under her + // OWN identity rather than through the admin's override, the submitter-side + // viewer gating needs Mei to look at her own pending request, and an + // out-of-office delegation is only falsifiable when the delegate holds a + // separate bearer token. + const credentialIssuer = await credentialIssuerFromAdmin(ctx, adminId); + if (!credentialIssuer) { + ctx.logger?.warn?.( + '[showcase] approval-demo: could not derive the credential issuer from the dev admin — ' + + 'demo personas stay un-loginable (sign in as the admin instead)', + ); + } else { + for (const personaId of [submitterId, auditorId]) { + if (personaId) { + await ensureCredentialAccount(ctx, personaId, credentialIssuer, DEMO_PERSONA_PASSWORD); + } + } + } + let engine: AutomationEngineLike | undefined; try { engine = await ctx.getService?.('automation'); diff --git a/examples/app-showcase/test/seed.test.ts b/examples/app-showcase/test/seed.test.ts index b3f306becc..f2712e825c 100644 --- a/examples/app-showcase/test/seed.test.ts +++ b/examples/app-showcase/test/seed.test.ts @@ -184,11 +184,13 @@ describe('showcase stack', () => { // Four dataset-bound analytics reports: summary, chart, matrix, joined. expect((stack.reports ?? []).length).toBe(4); expect((stack.flows ?? []).length).toBeGreaterThan(0); - // Nine flat positions (contributor/manager/exec/auditor/ops/ - // field_ops_delegate/client_portal_user, plus finance/legal for the v16 - // approval sign-off flows) — the ADR-0090 distribution layer; `everyone` - // and `guest` are built-in anchors and never declared by the app. - expect((stack.positions ?? []).length).toBe(9); + // Ten flat positions (contributor/manager/exec/auditor/ops/ + // field_ops_delegate/client_liaison/client_portal_user, plus finance/legal + // for the v16 approval sign-off flows) — the ADR-0090 distribution layer; + // `everyone` and `guest` are built-in anchors and never declared by the app. + // `client_liaison` arrived with the FLS read-mask fixture (#9308 fixture 4): + // it is the holder of the app's only `readable: false` grant. + expect((stack.positions ?? []).length).toBe(10); expect((stack.agents ?? []).length).toBe(0); // AI agents are an enterprise (service-ai) feature; the open showcase ships none }); }); diff --git a/packages/qa/dogfood/test/showcase-client-liaison-fixtures.dogfood.test.ts b/packages/qa/dogfood/test/showcase-client-liaison-fixtures.dogfood.test.ts new file mode 100644 index 0000000000..fbab954b86 --- /dev/null +++ b/packages/qa/dogfood/test/showcase-client-liaison-fixtures.dogfood.test.ts @@ -0,0 +1,206 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// #9308 fixtures 2 + 4 — the two stock fixtures that arrived with the +// `client_liaison` persona, pinned on a real boot. +// +// Both were "declared nowhere", which is a worse state than "declared wrong": +// the platform shipped the capability, the checklist had an item for it, and +// the item could not be run at all because no stock object or permission set +// exercised the feature. Nothing was red; the coverage simply did not exist. +// +// FIXTURE 2 — `publicSharing` (ADR-0047). Verified across +// `examples/app-showcase/src` before this change: not one object set +// `publicSharing.enabled`, so `POST /api/v1/share-links` answered +// 422 SHARING_NOT_ENABLED for EVERY showcase object and the whole downstream +// half of `access-security.share-link-capability-tokens` — resolve, redaction, +// the audience/password gates, fail-closed revoke — was unreachable. +// `showcase_client_brief` opts in, and the seed carries both a `published` and +// a `draft` brief so the declared `eligibility` predicate is falsifiable too. +// +// FIXTURE 4 — a `readable: false` FLS grant. The showcase governed the same +// three `showcase_project` budget figures with `readable: true, editable: +// false` (the WRITE half of field-level security) and authored no read-withheld +// grant anywhere, so `plugin-security/src/field-masker.ts` — the code that +// STRIPS a withheld key on the way out — had no stock fixture. +// `showcase_client_liaison` is that grant. +// +// ## What each assertion is guarding against +// +// The opt-in is per OBJECT, so the negative control matters as much as the +// positive: a fixture that made link-minting work everywhere would be a +// regression dressed as coverage. And the redaction assertion is about KEY +// ABSENCE, not about "I did not get the real value" — a nulled key and a +// deleted key are two different wire shapes, and only one of them is what +// `applyRedaction` and the field masker actually do. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import showcaseStack from '@objectstack/example-showcase'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { showcaseAppDefaultSecurity } from './showcase-security.js'; + +const SYS = { isSystem: true } as const; +const LIAISON_SET = 'showcase_client_liaison'; +const LIAISON_EMAIL = 'client-liaison@verify.test'; + +/** The seeded briefs, by the `externalId` the seed keys them on. */ +const PUBLISHED_BRIEF = 'Northwind — Website Relaunch brief'; +const DRAFT_BRIEF = 'Fabrikam — Compliance Audit brief'; + +/** The object's declared `redactFields` — never served through a token. */ +const REDACTED = ['internal_notes', 'deal_value']; +/** The client-facing half — a resolve that strips these has over-redacted. */ +const PUBLIC_FIELDS = ['title', 'summary']; +/** The three figures `showcase_client_liaison` withholds. */ +const MASKED_FIELDS = ['budget', 'spent', 'budget_remaining']; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const rowsOf = (b: any): any[] => b?.records ?? b?.data ?? (Array.isArray(b) ? b : []); +/** Unwrap a by-id read — same shape helper the sibling FLS proof uses. */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const recordOf = (b: any): Record => b?.record ?? b?.data ?? b; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const idOf = (b: any) => b?.id ?? b?.record?.id ?? b?.data?.id; + +describe('showcase client-liaison fixtures (#9308 fixtures 2 + 4)', () => { + let stack: VerifyStack; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let ql: any; + let adminTok = ''; + let liaisonTok = ''; + let publishedBriefId = ''; + let draftBriefId = ''; + let noteId = ''; + let projectId = ''; + + beforeAll(async () => { + stack = await bootStack(showcaseStack, { security: showcaseAppDefaultSecurity() }); + adminTok = await stack.signIn(); + ql = await stack.kernel.getServiceAsync('objectql'); + + liaisonTok = await stack.signUp(LIAISON_EMAIL); + const set = await ql.findOne('sys_permission_set', { where: { name: LIAISON_SET }, context: SYS }); + expect(set?.id, `${LIAISON_SET} is seeded by the security bootstrap`).toBeTruthy(); + const uid = (await ql.findOne('sys_user', { where: { email: LIAISON_EMAIL }, context: SYS }))?.id; + await ql.insert('sys_user_permission_set', { user_id: uid, permission_set_id: set.id }, { context: SYS }); + + const briefBy = async (title: string) => + String((await ql.findOne('showcase_client_brief', { where: { title }, context: SYS }))?.id ?? ''); + publishedBriefId = await briefBy(PUBLISHED_BRIEF); + draftBriefId = await briefBy(DRAFT_BRIEF); + expect(publishedBriefId && draftBriefId, 'both seeded briefs exist').toBeTruthy(); + + projectId = String((await ql.find('showcase_project', { limit: 1, context: SYS }))?.[0]?.id ?? ''); + expect(projectId, 'a seeded project to read the masked figures off').toBeTruthy(); + + // A note the admin owns, for the "an object that did NOT opt in" control. + const note = await stack.apiAs(adminTok, 'POST', '/data/showcase_private_note', { + title: `share-link-control-${Date.now()}`, + }); + noteId = String(idOf(await note.json())); + expect(noteId, 'the control record').toBeTruthy(); + }, 300_000); + + afterAll(async () => { + await stack?.stop?.(); + }); + + // ── FIXTURE 2 ──────────────────────────────────────────────────────────── + + it('the opt-in is per OBJECT: an object that does not declare publicSharing still answers 422 SHARING_NOT_ENABLED', async () => { + const res = await stack.apiAs(adminTok, 'POST', '/share-links', { + object: 'showcase_private_note', + recordId: noteId, + }); + expect(res.status, 'showcase_private_note declares no publicSharing').toBe(422); + expect(await res.json()).toMatchObject({ error: { code: 'SHARING_NOT_ENABLED' } }); + }); + + it('the declared eligibility predicate is ENFORCED: a draft brief cannot be link-shared', async () => { + const res = await stack.apiAs(liaisonTok, 'POST', '/share-links', { + object: 'showcase_client_brief', + recordId: draftBriefId, + }); + expect( + res.status, + "publicSharing.eligibility is record.status == 'published'; the draft brief must be refused", + ).toBe(422); + expect(await res.json()).toMatchObject({ error: { code: 'RECORD_NOT_ELIGIBLE' } }); + }); + + it('a published brief MINTS a capability token, and the anonymous resolve renders it MINUS redactFields', async () => { + const minted = await stack.apiAs(liaisonTok, 'POST', '/share-links', { + object: 'showcase_client_brief', + recordId: publishedBriefId, + }); + const mintedBody = await minted.json(); + expect(minted.status, `mint: ${JSON.stringify(mintedBody)}`).toBeLessThan(300); + const token = String(mintedBody?.token ?? mintedBody?.data?.token ?? ''); + expect(token, 'the mint returns an opaque token').toBeTruthy(); + + // ANONYMOUS — no Authorization header at all. This is the whole point of a + // capability token: the URL is the credential. + const resolved = await stack.api(`/share-links/${encodeURIComponent(token)}/resolve`); + const body = await resolved.json(); + expect(resolved.status, `anonymous resolve: ${JSON.stringify(body)}`).toBe(200); + const record = (body?.record ?? body?.data?.record) as Record; + expect(record, 'the resolve carries the record').toBeTruthy(); + + for (const field of PUBLIC_FIELDS) { + expect(record, `the client-facing field '${field}' survives redaction`).toHaveProperty(field); + } + for (const field of REDACTED) { + // Key ABSENCE, not "not the real value": `applyRedaction` deletes the key. + // A nulled key would pass a looser assertion and pin nothing. + expect( + Object.keys(record), + `'${field}' is declared in publicSharing.redactFields and must never leave the server through a token`, + ).not.toContain(field); + } + }); + + // ── FIXTURE 4 ──────────────────────────────────────────────────────────── + + it('readable:false STRIPS the budget figures for the liaison — by id, in lists, and through an explicit select', async () => { + const byId = await stack.apiAs(liaisonTok, 'GET', `/data/showcase_project/${projectId}`); + expect(byId.status, 'the ROW is still served — FLS is a field gate, not a row gate').toBe(200); + const record = recordOf(await byId.json()); + expect(record?.name, 'the unrestricted fields still arrive').toBeTruthy(); + for (const field of MASKED_FIELDS) { + expect(Object.keys(record), `'${field}' is withheld by readable:false — the KEY is stripped`).not.toContain(field); + } + + const listed = await stack.apiAs(liaisonTok, 'GET', '/data/showcase_project'); + const rows = rowsOf(await listed.json()); + expect(rows.length, 'the liaison reads projects').toBeGreaterThan(0); + for (const row of rows) { + for (const field of MASKED_FIELDS) { + expect(Object.keys(row), `'${field}' is stripped from every listed row too`).not.toContain(field); + } + } + + const selected = await stack.apiAs(liaisonTok, 'GET', `/data/showcase_project?$select=name,budget`); + if (selected.status === 200) { + for (const row of rowsOf(await selected.json())) { + expect( + Object.keys(row), + "naming the withheld field in `$select` must not hand it back", + ).not.toContain('budget'); + } + } else { + // A refusal is also honest — what must never happen is a 200 carrying it. + expect(selected.status, 'a select naming a withheld field is refused, not served').toBeGreaterThanOrEqual(400); + } + }); + + it('the ENTITLED contrast: the admin reads the same row WITH the budget figures', async () => { + const res = await stack.apiAs(adminTok, 'GET', `/data/showcase_project/${projectId}`); + expect(res.status).toBe(200); + const record = recordOf(await res.json()); + for (const field of MASKED_FIELDS) { + expect( + Object.keys(record), + `'${field}' is present for a caller holding no withholding set — the lock keys on the CALLER, not the field`, + ).toContain(field); + } + }); +}); diff --git a/packages/qa/dogfood/test/showcase-crud-persona-matrix.dogfood.test.ts b/packages/qa/dogfood/test/showcase-crud-persona-matrix.dogfood.test.ts index 722c5fb5a3..ee50839d8d 100644 --- a/packages/qa/dogfood/test/showcase-crud-persona-matrix.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-crud-persona-matrix.dogfood.test.ts @@ -107,6 +107,12 @@ const UNJUDGED_ROWS = matrix.entries.filter((e) => !e.object.startsWith('showcas const MARKER: Record = { showcase_account: 'name', showcase_announcement: 'title', + // [#9308 fixture 2] The share-link object. It enters this sweep because the + // matrix gained a `showcase_client_liaison × showcase_client_brief` row, and + // the sweep is DERIVED from the matrix — a new granted object arrives here + // automatically, which is the design. Only the fixture maps grow; not one + // assertion below changes. + showcase_client_brief: 'title', showcase_contact: 'name', showcase_inquiry: 'name', showcase_invoice: 'name', @@ -141,6 +147,11 @@ interface PayloadCtx { const PAYLOAD: Record Record> = { showcase_account: (c) => ({ name: c.mark, status: 'prospect' }), showcase_announcement: (c) => ({ title: c.mark }), + // `status` is left at its `draft` default on purpose: this sweep judges CRUD + // bits, and a brief that is not `published` cannot be mint-eligible for a + // share link — so a row this sweep leaves behind can never become an + // accidental share-link fixture for another file. + showcase_client_brief: (c) => ({ title: c.mark, project: c.projectId }), showcase_contact: (c) => ({ name: c.mark, email: `contact-${Date.now()}@probe.test` }), showcase_inquiry: (c) => ({ name: c.mark, email: `inq-${Date.now()}@probe.test`, message: 'matrix probe' }), showcase_invoice: (c) => ({ name: c.mark, account: c.accountId, status: 'draft', owner: c.email }), @@ -425,8 +436,18 @@ describe('showcase: persona × CRUD-cell matrix (#9481)', () => { const deny = VERDICTS.filter((c) => c.expected === 'deny'); // A matrix in which everything denies proves nothing about over-tightening, // and one in which everything allows proves nothing about enforcement. - expect(allow.length, 'the ALLOW half is what catches an over-tightening regression').toBe(50); - expect(deny.length, 'the DENY half is what catches a widening regression').toBe(50); + // + // These two are a CENSUS of the matrix, so they move whenever the matrix + // legitimately grows — and the arithmetic of each move belongs here, or the + // next author cannot tell a widened grant from a widened fixture. + // 50/50 → 54/54 with the two rows #9308 added, both on the new + // `showcase_client_liaison` set: + // • × showcase_client_brief — create/read/edit allow, delete deny (3/1) + // • × showcase_project — read allow; create/edit/delete deny (1/3) + // No pre-existing cell changed side; `git diff` on access-matrix.json is + // the check that this is still true. + expect(allow.length, 'the ALLOW half is what catches an over-tightening regression').toBe(54); + expect(deny.length, 'the DENY half is what catches a widening regression').toBe(54); // Every set and every object of the business-object matrix was really driven. expect([...new Set(VERDICTS.map((c) => c.set))].sort()).toEqual(SETS); diff --git a/packages/qa/dogfood/test/showcase-demo-personas-loginable.dogfood.test.ts b/packages/qa/dogfood/test/showcase-demo-personas-loginable.dogfood.test.ts new file mode 100644 index 0000000000..697a96e68e --- /dev/null +++ b/packages/qa/dogfood/test/showcase-demo-personas-loginable.dogfood.test.ts @@ -0,0 +1,159 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// #9308 fixture 1 — the showcase's demo personas can actually SIGN IN. +// +// ## What was missing, and why "a sys_user row exists" was never the answer +// +// `seed-approval-demo.ts` has provisioned two persona ROWS since #3411/#3409: +// Mei Phone (the submitter who holds no approver position) and Ada Auditor (the +// only holder of the `auditor` position, which backs the `finance` group of the +// per-group 会签 demo). Both were display/routing identities only — enough to +// resolve a notify recipient, enough to route an approval, and not enough to +// open a second session. Every checklist item that needs TWO acting identities +// was blocked on that one gap: +// +// • approvals.per-group-signoff (P1) — the two groups must be decided +// by two DIFFERENT people, or one decision satisfies both tallies at once +// and "one approval per group" is unobservable. +// • approvals.viewer-gating-submitter-side (P1) — the submitter must look at +// their own pending request; as the admin they are also an approver. +// • approvals.ooo-delegation-reroute (P2) — the delegate must decide under +// their OWN bearer token, or the audit's no-laundering clause is vacuous. +// +// ## The non-obvious half this file pins +// +// A password hash is not enough. better-auth 1.7 keys account identity on +// `(issuer, providerAccountId)`, so a credential row whose `issuer` is not the +// local credential issuer is invisible to `findAccountByKey` — sign-in then +// fails `INVALID_EMAIL_OR_PASSWORD` behind a "User not found" warn that points +// at the `sys_user` row, which is fine, instead of at the account, which is not. +// Four checklist items had that recorded as a knownGap, each rediscovering it. +// +// So this file asserts the ISSUER explicitly and then asserts the thing the +// issuer exists for: a real sign-in over the real HTTP auth route, and a real +// authenticated request driven with the returned token. Either half alone can +// pass while the feature is broken — a correct-looking account nobody can use, +// or a token minted for an identity that turns out to be the admin. +// +// ## Why this boot passes `onEnable` +// +// The persona bootstrap is an `onEnable` → `kernel:bootstrapped` hook, and +// `bootStack(showcaseStack)` passes only the DEFAULT export, so the hook never +// runs in the ordinary dogfood boot. Spreading the stack and re-attaching +// `onEnable` is what makes this boot the one a `pnpm dev:showcase` operator +// actually gets (`AppPlugin` resolves the hook owner off the bundle). + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import showcaseStack, { onEnable } from '@objectstack/example-showcase'; +import { + ADMIN_EMAIL, + PHONE_DEMO_USER, + AUDITOR_DEMO_USER, + DEMO_PERSONA_PASSWORD, +} from '@objectstack/example-showcase/security-personas'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { showcaseAppDefaultSecurity } from './showcase-security.js'; + +const SYS = { isSystem: true } as const; + +/** The showcase bundle WITH its runtime hook — see the header. */ +const showcaseBundleWithHook = { ...(showcaseStack as Record), onEnable }; + +const rowsOf = (r: unknown): Array> => + Array.isArray(r) ? (r as Array>) : ((r as { records?: unknown[] })?.records as Array>) ?? []; + +describe('showcase demo personas are real logins (#9308 fixture 1)', () => { + let stack: VerifyStack; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let ql: any; + let adminId = ''; + + const userByEmail = async (email: string) => + rowsOf(await ql.find('sys_user', { where: { email }, limit: 1, context: SYS }))[0]; + + const credentialAccountOf = async (userId: string) => + rowsOf( + await ql.find( + 'sys_account', + { where: { user_id: userId, provider_id: 'credential' }, limit: 1, context: SYS }, + ), + )[0]; + + beforeAll(async () => { + stack = await bootStack(showcaseBundleWithHook, { security: showcaseAppDefaultSecurity() }); + await stack.signIn(); + ql = await stack.kernel.getServiceAsync('objectql'); + adminId = String((await userByEmail(ADMIN_EMAIL))?.id ?? ''); + expect(adminId, 'the dev admin the persona bootstrap keys off').toBeTruthy(); + }, 300_000); + + afterAll(async () => { + await stack?.stop?.(); + }); + + it('PREMISE: both persona rows exist and are NOT the admin', async () => { + for (const persona of [PHONE_DEMO_USER, AUDITOR_DEMO_USER]) { + const row = await userByEmail(persona.email); + expect(row?.id, `${persona.email} is provisioned as a sys_user`).toBe(persona.id); + expect(row?.id, `${persona.email} is a SEPARATE identity from the admin`).not.toBe(adminId); + } + }); + + it('each persona holds a credential account stamped with the SAME issuer better-auth minted for the admin', async () => { + const adminAccount = await credentialAccountOf(adminId); + const adminIssuer = adminAccount?.issuer; + // The control: better-auth really does stamp an issuer on the account it + // creates itself. Without this the assertions below could both be + // `undefined === undefined` and read as agreement. + expect(typeof adminIssuer, 'better-auth stamped an issuer on the dev admin credential').toBe('string'); + expect(String(adminIssuer).length, 'and it is not empty').toBeGreaterThan(0); + + for (const persona of [PHONE_DEMO_USER, AUDITOR_DEMO_USER]) { + const account = await credentialAccountOf(persona.id); + expect(account, `${persona.email} holds a credential account`).toBeTruthy(); + expect( + account?.issuer, + `${persona.email}'s credential issuer must equal the admin's — a different value is invisible to better-auth's findAccountByKey and sign-in fails INVALID_EMAIL_OR_PASSWORD`, + ).toBe(adminIssuer); + expect(account?.account_id, `${persona.email}'s account is keyed to its own user id`).toBe(persona.id); + } + }); + + it('each persona SIGNS IN over the real auth route, and the session resolves to that persona', async () => { + for (const persona of [PHONE_DEMO_USER, AUDITOR_DEMO_USER]) { + const token = await stack.signIn(persona.email, DEMO_PERSONA_PASSWORD); + expect(token, `${persona.email} received a session token`).toBeTruthy(); + + // A token alone proves the sign-in answered 200. Drive an authenticated + // request with it and read the identity back: this is what distinguishes + // "a second session" from "the admin session with a different label". + const me = await stack.apiAs(token, 'GET', '/auth/get-session'); + expect(me.status, `${persona.email} reaches an authenticated route`).toBeLessThan(300); + const body = (await me.json()) as { user?: { id?: unknown } }; + expect( + String(body?.user?.id ?? ''), + `${persona.email}'s session resolves to their own user id, not the admin's`, + ).toBe(persona.id); + } + }); + + it('the two sessions are the DISTINCT identities the 会签 demo needs: Ada holds auditor, Mei holds no approver position', async () => { + const positionsOf = async (userId: string) => + rowsOf(await ql.find('sys_user_position', { where: { user_id: userId }, context: SYS })) + .map((r) => String(r.position)) + .sort(); + + expect( + await positionsOf(AUDITOR_DEMO_USER.id), + 'Ada backs the finance group of the per-group demo and nothing else', + ).toEqual(['auditor']); + expect( + await positionsOf(PHONE_DEMO_USER.id), + 'Mei is a clean submitter — never one of her own approvers', + ).toEqual([]); + expect( + await positionsOf(adminId), + 'the admin holds the OTHER group, so the two groups resolve to two different people', + ).toContain('manager'); + }); +}); From 12b171d392bebdc3a4175372222f1148aaeeb0c0 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Aug 2026 00:14:11 +0000 Subject: [PATCH 2/2] i18n(showcase): translate the client-brief fixture into zh-CN (#9308) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `check:i18n-coverage` went red on PR #9789: untranslated declared strings for examples/app-showcase grew 393 -> 405. The showcase declares supportedLocales ['en', 'zh-CN'], so every label the new `showcase_client_brief` object declares owed a zh-CN rendering. Measured: all 12 are that object — label, pluralLabel, description, seven field labels and both `status` options. The new position and permission set are NOT counted by this rule, which counts object/field/option strings only. Both locales are written key for key. `check-i18n-coverage` is a two-sided ratchet, so an `en` entry landing without its zh-CN mirror is what widens the gap rather than closing it. Renderings follow the ones this bundle already established rather than being coined fresh — 标题 / 项目 / 状态 / 备注 / 负责人 are the words showcase_task and showcase_invoice already use for the same concepts, which is the whole point of the file: a list must not mix a translated 状态 with an English Owner beside it. 草稿 / 已发布 are new (nothing here rendered a draft state before); 已发布 follows the 已完成 pattern the bundle uses for a state that has been reached. The description is mirrored byte-for-byte from the object declaration on the en side and translated in full on the zh side, ADR reference included. A bundle entry overrides the schema label for its locale, so a "tidied" English description would silently rewrite product copy under the guise of translating it, and a half-dropped Chinese one is the locale degradation the bundle exists to prevent. Count back to 393 exactly, not merely stopped growing. The baseline in scripts/i18n-coverage-baseline.json is untouched (no --update anywhere). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Y26DJEHSBhhAQ6wwfsHNza --- .../src/system/translations/index.ts | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/examples/app-showcase/src/system/translations/index.ts b/examples/app-showcase/src/system/translations/index.ts index 8eabefaa90..e1d96286fc 100644 --- a/examples/app-showcase/src/system/translations/index.ts +++ b/examples/app-showcase/src/system/translations/index.ts @@ -118,6 +118,35 @@ export const ShowcaseTranslationBundle = { notes: { label: 'Notes' }, }, }, + // [#9308 fixture 2] The share-link object. Translated on BOTH sides at + // once, key for key: `check-i18n-coverage` is a two-sided ratchet, so an + // `en` entry landing without its zh-CN mirror is what WIDENS the gap + // rather than closing it. Every string the object declares is here — + // label, pluralLabel, description, all seven field labels and both + // `status` options — which is what returns the count to its baseline + // instead of merely stopping its growth. + showcase_client_brief: { + label: 'Client Brief', + pluralLabel: 'Client Briefs', + // Byte-for-byte the string the object DECLARES. A bundle entry + // overrides the schema label for its locale, so a "tidied" English + // description here would silently rewrite the product copy under the + // guise of translating it — the en side's job is to mirror, not edit. + description: + 'A client-facing project brief published by opaque share link — the showcase\'s `publicSharing` demonstration (ADR-0047).', + fields: { + title: { label: 'Title' }, + summary: { label: 'Client Summary' }, + project: { label: 'Project' }, + status: { + label: 'Status', + options: { draft: 'Draft', published: 'Published' }, + }, + internal_notes: { label: 'Internal Notes' }, + deal_value: { label: 'Deal Value' }, + owner_id: { label: 'Owner' }, + }, + }, showcase_invoice: { label: 'Invoice', pluralLabel: 'Invoices', @@ -454,6 +483,37 @@ export const ShowcaseTranslationBundle = { who_is_this: { label: '这是谁' }, }, }, + // [#9308 fixture 2] The zh-CN mirror of the `en` block above, key for + // key. Renderings follow the ones this bundle already established rather + // than being coined fresh: 标题 / 项目 / 状态 / 备注 / 负责人 are the words + // showcase_task and showcase_invoice already use for the same concepts, + // which is the whole point of the file — a list must not mix a translated + // 状态 with an English Owner next to it. + showcase_client_brief: { + label: '客户简报', + // Chinese does not inflect for number, so the plural is the same word — + // the same shape 客户 / 客户 and 联系人 / 联系人 already take here. + pluralLabel: '客户简报', + // A faithful rendering of the SAME sentence the en side carries, + // including the ADR reference — dropping half of it in translation is + // the locale degradation this bundle exists to prevent. + description: + '面向客户的项目简报,通过不透明的分享链接对外发布——本示例应用对 `publicSharing` 的演示(ADR-0047)。', + fields: { + title: { label: '标题' }, + summary: { label: '客户摘要' }, + project: { label: '项目' }, + status: { + label: '状态', + // 已发布 follows the 已完成 pattern this bundle uses for a state + // that has been reached, rather than the bare verb 发布. + options: { draft: '草稿', published: '已发布' }, + }, + internal_notes: { label: '内部备注' }, + deal_value: { label: '交易金额' }, + owner_id: { label: '负责人' }, + }, + }, showcase_invoice: { label: '发票', pluralLabel: '发票',