From 5e42cdbdf8054b85a05f740a6c1f8d592cd37335 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 11 Aug 2026 14:59:37 +0000 Subject: [PATCH] fix(showcase): seed assignees/owners that resolve to a real user (#7746) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The showcase seeded `showcase_task.assignee` and `showcase_project.owner` as bare emails — `ada@`/`linus@`/`grace@`/`sam@example.com` — that are no `sys_user`. Both fields are read as notify RECIPIENTS by the flows, and `RecipientResolver` (ADR-0030 P1) resolves an email-shaped recipient against `sys_user` and, on a MISS, keeps the string VERBATIM as the recipient id. So the stock reassignment demo did not fail loudly: it persisted a `sys_inbox_message` whose `user_id` was a literal email address — a row no authenticated user can read. The QA run that found this (#7690) had to sign up its own personas to test notify at all. Those fields now name the identities the app actually provisions on a dev boot: the `plugin-auth` dev admin plus the two personas `seed-approval-demo.ts` creates on `kernel:bootstrapped`. The persona constants move to `src/security/demo-personas.ts` so the seed and the bootstrap that creates the rows read one registry and cannot drift. Three distinct values keep the Kanban/gallery/list spread intact. `showcase_invoice.owner` deliberately keeps its `ada@`-style emails: it is the fixture for the ADR-0055 controlled-by-parent isolation demo, where an operator SIGNS UP as those emails. The provisioned personas hold no credential, so repointing invoices at them would delete that demo rather than fix it — reported on the issue instead of guessed at here. Guarded by `test/inert-wirings.test.ts` §5, which derives the recipient fields from the flows themselves (so a new notify node is covered automatically) and fails when a seeded recipient is an email the app never provisions. Verified on a real `pnpm dev` boot of the showcase: all 10 task assignees and 5 project owners resolve to `sys_user` rows, and a reassignment writes `sys_inbox_message.user_id = usr_showcase_auditor_demo` (a real id). The old value still reproduces the defect — `WARN [recipients] no 'sys_user' matched email 'ada@example.com'; keeping verbatim`, row keyed to the raw address. Refs #7690 --- examples/app-showcase/src/data/seed/index.ts | 58 ++++++-- .../src/security/demo-personas.ts | 106 ++++++++++++++ .../src/security/seed-approval-demo.ts | 31 ++--- .../app-showcase/test/inert-wirings.test.ts | 130 ++++++++++++++++++ 4 files changed, 288 insertions(+), 37 deletions(-) create mode 100644 examples/app-showcase/src/security/demo-personas.ts diff --git a/examples/app-showcase/src/data/seed/index.ts b/examples/app-showcase/src/data/seed/index.ts index 0b395113b0..176169fb3f 100644 --- a/examples/app-showcase/src/data/seed/index.ts +++ b/examples/app-showcase/src/data/seed/index.ts @@ -15,6 +15,34 @@ 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 { ADMIN_EMAIL, PHONE_DEMO_USER, AUDITOR_DEMO_USER } from '../../security/demo-personas.js'; + +/** + * ## Why assignees/owners are addressed to the PROVISIONED personas (#7746) + * + * `showcase_task.assignee` and `showcase_project.owner` are `Field.text`, but + * they are not free text: the notify nodes in `automation/flows` read them as + * RECIPIENTS (`recipients: ['{record.assignee}']`, `['{record.owner}']`). An + * email-shaped recipient is resolved against `sys_user`, and on a MISS the + * resolver keeps it VERBATIM as the recipient id — so a made-up address does + * not fail loudly, it persists a `sys_inbox_message` whose `user_id` is a + * string no authenticated user can ever match. + * + * The seed used to write `ada@` / `linus@` / `grace@` / `sam@example.com` + * here, none of which is a `sys_user`. Reassigning a seeded task — the stock + * demo of `showcase_task_assigned_notify` — therefore delivered into a void, + * and the QA run that found this (#7690) had to sign up its own personas to + * exercise the notify path at all. + * + * The three constants below are the identities this app actually provisions on + * a dev boot. `demo-personas.ts` documents that set, its dev-only limits, and + * why `invoice.owner` / `team.lead` deliberately KEEP their `ada@`-style + * emails. Three distinct values still spread across every Kanban column, + * gallery card and list, so the "feed every view" principle below is intact. + */ +const ADMIN = ADMIN_EMAIL; +const MEI = PHONE_DEMO_USER.email; +const ADA = AUDITOR_DEMO_USER.email; /** * Seed data sized to "feed every view": every Kanban column is populated, @@ -143,11 +171,11 @@ const projects = defineSeed(Project, { mode: 'upsert', externalId: 'name', records: [ - { name: 'Website Relaunch', account: 'Northwind', status: 'active', health: 'green', budget: 150_000, spent: 60_000, owner: 'ada@example.com', start_date: cel`daysAgo(30)`, end_date: cel`daysFromNow(60)` }, - { name: 'Data Platform', account: 'Contoso', status: 'active', health: 'yellow', budget: 600_000, spent: 420_000, owner: 'linus@example.com', start_date: cel`daysAgo(90)`, end_date: cel`daysFromNow(120)` }, - { name: 'Compliance Audit', account: 'Fabrikam', status: 'on_hold', health: 'red', budget: 90_000, spent: 88_000, owner: 'grace@example.com', start_date: cel`daysAgo(15)`, end_date: cel`daysFromNow(30)` }, - { name: 'Mobile App', account: 'Contoso', status: 'planned', health: 'green', budget: 200_000, spent: 0, owner: 'ada@example.com', start_date: cel`daysFromNow(14)`, end_date: cel`daysFromNow(140)` }, - { name: 'Legacy Sunset', account: 'Northwind', status: 'completed', health: 'green', budget: 50_000, spent: 48_000, owner: 'linus@example.com', start_date: cel`daysAgo(180)`, end_date: cel`daysAgo(20)` }, + { name: 'Website Relaunch', account: 'Northwind', status: 'active', health: 'green', budget: 150_000, spent: 60_000, owner: ADA, start_date: cel`daysAgo(30)`, end_date: cel`daysFromNow(60)` }, + { name: 'Data Platform', account: 'Contoso', status: 'active', health: 'yellow', budget: 600_000, spent: 420_000, owner: ADMIN, start_date: cel`daysAgo(90)`, end_date: cel`daysFromNow(120)` }, + { name: 'Compliance Audit', account: 'Fabrikam', status: 'on_hold', health: 'red', budget: 90_000, spent: 88_000, owner: MEI, start_date: cel`daysAgo(15)`, end_date: cel`daysFromNow(30)` }, + { name: 'Mobile App', account: 'Contoso', status: 'planned', health: 'green', budget: 200_000, spent: 0, owner: ADA, start_date: cel`daysFromNow(14)`, end_date: cel`daysFromNow(140)` }, + { name: 'Legacy Sunset', account: 'Northwind', status: 'completed', health: 'green', budget: 50_000, spent: 48_000, owner: ADMIN, start_date: cel`daysAgo(180)`, end_date: cel`daysAgo(20)` }, ], }); @@ -156,16 +184,16 @@ const tasks = defineSeed(Task, { mode: 'upsert', externalId: 'title', records: [ - { title: 'Audit current IA', project: 'Website Relaunch', assignee: 'ada@example.com', status: 'done', priority: 'medium', estimate_hours: 8, progress: 100, done: true, created_at: cel`daysAgo(20)`, start_date: cel`daysAgo(20)`, end_date: cel`daysAgo(18)`, due_date: cel`daysAgo(18)`, location: { lat: 47.6062, lng: -122.3321 } }, - { title: 'Design system', project: 'Website Relaunch', assignee: 'ada@example.com', status: 'in_review', priority: 'high', estimate_hours: 24, progress: 80, done: false, created_at: cel`daysAgo(14)`, start_date: cel`daysAgo(12)`, end_date: cel`daysFromNow(2)`, due_date: cel`daysFromNow(2)`, location: { lat: 37.7749, lng: -122.4194 } }, - { title: 'Build homepage', project: 'Website Relaunch', assignee: 'sam@example.com', status: 'in_progress', priority: 'high', estimate_hours: 40, progress: 45, done: false, created_at: cel`daysAgo(8)`, start_date: cel`daysAgo(6)`, end_date: cel`daysFromNow(10)`, due_date: cel`daysFromNow(10)`, location: { lat: 40.7128, lng: -74.0060 } }, - { title: 'SEO migration plan', project: 'Website Relaunch', assignee: 'sam@example.com', status: 'todo', priority: 'medium', estimate_hours: 16, progress: 0, done: false, created_at: cel`daysAgo(3)`, start_date: cel`daysFromNow(5)`, end_date: cel`daysFromNow(15)`, due_date: cel`daysFromNow(15)`, location: { lat: 30.2672, lng: -97.7431 } }, - { title: 'Content backlog', project: 'Website Relaunch', assignee: 'grace@example.com', status: 'backlog', priority: 'low', estimate_hours: 12, progress: 0, done: false, created_at: cel`daysAgo(2)`, due_date: cel`daysFromNow(30)`, location: { lat: 41.8781, lng: -87.6298 } }, - { title: 'Ingest pipeline', project: 'Data Platform', assignee: 'linus@example.com', status: 'in_progress', priority: 'urgent', estimate_hours: 60, progress: 55, done: false, created_at: cel`daysAgo(40)`, start_date: cel`daysAgo(35)`, end_date: cel`daysFromNow(20)`, due_date: cel`daysFromNow(20)`, location: { lat: 39.7392, lng: -104.9903 } }, - { title: 'Warehouse schema', project: 'Data Platform', assignee: 'linus@example.com', status: 'in_review', priority: 'high', estimate_hours: 30, progress: 90, done: false, created_at: cel`daysAgo(25)`, start_date: cel`daysAgo(22)`, end_date: cel`daysFromNow(3)`, due_date: cel`daysFromNow(3)`, location: { lat: 42.3601, lng: -71.0589 } }, - { title: 'PII access review', project: 'Compliance Audit', assignee: 'grace@example.com', status: 'todo', priority: 'urgent', estimate_hours: 20, progress: 0, done: false, created_at: cel`daysAgo(5)`, start_date: cel`daysFromNow(2)`, end_date: cel`daysFromNow(12)`, due_date: cel`daysFromNow(12)`, location: { lat: 38.9072, lng: -77.0369 } }, - { title: 'Evidence collection', project: 'Compliance Audit', assignee: 'grace@example.com', status: 'backlog', priority: 'medium', estimate_hours: 18, progress: 0, done: false, created_at: cel`daysAgo(1)`, due_date: cel`daysFromNow(25)`, location: { lat: 34.0522, lng: -118.2437 } }, - { title: 'App wireframes', project: 'Mobile App', assignee: 'ada@example.com', status: 'done', priority: 'medium', estimate_hours: 16, progress: 100, done: true, created_at: cel`daysAgo(10)`, start_date: cel`daysAgo(10)`, end_date: cel`daysAgo(6)`, due_date: cel`daysAgo(6)`, location: { lat: 45.5152, lng: -122.6784 } }, + { title: 'Audit current IA', project: 'Website Relaunch', assignee: ADA, status: 'done', priority: 'medium', estimate_hours: 8, progress: 100, done: true, created_at: cel`daysAgo(20)`, start_date: cel`daysAgo(20)`, end_date: cel`daysAgo(18)`, due_date: cel`daysAgo(18)`, location: { lat: 47.6062, lng: -122.3321 } }, + { title: 'Design system', project: 'Website Relaunch', assignee: ADA, status: 'in_review', priority: 'high', estimate_hours: 24, progress: 80, done: false, created_at: cel`daysAgo(14)`, start_date: cel`daysAgo(12)`, end_date: cel`daysFromNow(2)`, due_date: cel`daysFromNow(2)`, location: { lat: 37.7749, lng: -122.4194 } }, + { title: 'Build homepage', project: 'Website Relaunch', assignee: ADMIN, status: 'in_progress', priority: 'high', estimate_hours: 40, progress: 45, done: false, created_at: cel`daysAgo(8)`, start_date: cel`daysAgo(6)`, end_date: cel`daysFromNow(10)`, due_date: cel`daysFromNow(10)`, location: { lat: 40.7128, lng: -74.0060 } }, + { title: 'SEO migration plan', project: 'Website Relaunch', assignee: ADMIN, status: 'todo', priority: 'medium', estimate_hours: 16, progress: 0, done: false, created_at: cel`daysAgo(3)`, start_date: cel`daysFromNow(5)`, end_date: cel`daysFromNow(15)`, due_date: cel`daysFromNow(15)`, location: { lat: 30.2672, lng: -97.7431 } }, + { title: 'Content backlog', project: 'Website Relaunch', assignee: MEI, status: 'backlog', priority: 'low', estimate_hours: 12, progress: 0, done: false, created_at: cel`daysAgo(2)`, due_date: cel`daysFromNow(30)`, location: { lat: 41.8781, lng: -87.6298 } }, + { title: 'Ingest pipeline', project: 'Data Platform', assignee: ADMIN, status: 'in_progress', priority: 'urgent', estimate_hours: 60, progress: 55, done: false, created_at: cel`daysAgo(40)`, start_date: cel`daysAgo(35)`, end_date: cel`daysFromNow(20)`, due_date: cel`daysFromNow(20)`, location: { lat: 39.7392, lng: -104.9903 } }, + { title: 'Warehouse schema', project: 'Data Platform', assignee: ADMIN, status: 'in_review', priority: 'high', estimate_hours: 30, progress: 90, done: false, created_at: cel`daysAgo(25)`, start_date: cel`daysAgo(22)`, end_date: cel`daysFromNow(3)`, due_date: cel`daysFromNow(3)`, location: { lat: 42.3601, lng: -71.0589 } }, + { title: 'PII access review', project: 'Compliance Audit', assignee: MEI, status: 'todo', priority: 'urgent', estimate_hours: 20, progress: 0, done: false, created_at: cel`daysAgo(5)`, start_date: cel`daysFromNow(2)`, end_date: cel`daysFromNow(12)`, due_date: cel`daysFromNow(12)`, location: { lat: 38.9072, lng: -77.0369 } }, + { title: 'Evidence collection', project: 'Compliance Audit', assignee: MEI, status: 'backlog', priority: 'medium', estimate_hours: 18, progress: 0, done: false, created_at: cel`daysAgo(1)`, due_date: cel`daysFromNow(25)`, location: { lat: 34.0522, lng: -118.2437 } }, + { title: 'App wireframes', project: 'Mobile App', assignee: ADA, status: 'done', priority: 'medium', estimate_hours: 16, progress: 100, done: true, created_at: cel`daysAgo(10)`, start_date: cel`daysAgo(10)`, end_date: cel`daysAgo(6)`, due_date: cel`daysAgo(6)`, location: { lat: 45.5152, lng: -122.6784 } }, ], }); diff --git a/examples/app-showcase/src/security/demo-personas.ts b/examples/app-showcase/src/security/demo-personas.ts new file mode 100644 index 0000000000..fdb3aef340 --- /dev/null +++ b/examples/app-showcase/src/security/demo-personas.ts @@ -0,0 +1,106 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The identities the showcase actually PROVISIONS — the one place that answers + * "which `sys_user` rows exist on a fresh boot of this app?". + * + * ## Why this module exists (#7746) + * + * `sys_user` rows cannot be seeded — they come from sign-up — so for a long time + * the seed simply wrote plausible-looking emails (`ada@example.com`, + * `linus@example.com`, `sam@example.com`) into the fields that FEED THE NOTIFY + * PATH: `showcase_task.assignee` and `showcase_project.owner`. Those fields are + * `Field.text`, so nothing rejected the values, and every view rendered happily. + * + * The cost showed up one layer down. `RecipientResolver` (ADR-0030 P1) resolves + * an email-shaped recipient by looking it up in `sys_user` and, on a MISS, + * **keeps the string verbatim** as the recipient id + * (`service-messaging/src/recipient-resolver.ts` — "no 'sys_user' matched email + * '…'; keeping verbatim"). So the stock reassignment demo + * (`showcase_task_assigned_notify`) persisted a `sys_inbox_message` whose + * `user_id` was the literal text `ada@example.com`: a row addressed to nobody, + * which no authenticated user can ever read. The reference app's marquee + * "reassign a task and watch the inbox" story quietly delivered into a void. + * + * The fix is to address those fields to identities that REALLY EXIST. On a fresh + * dev boot that set is exactly three rows, and this module is their registry: + * + * 1. {@link ADMIN_EMAIL} — the dev admin seeded by `plugin-auth` + * (`OS_SEED_ADMIN_EMAIL`, default below). The only LOGINABLE one. + * 2. {@link PHONE_DEMO_USER} / 3. {@link AUDITOR_DEMO_USER} — the personas + * `seed-approval-demo.ts` provisions on `kernel:bootstrapped`. + * + * ## The honest limits of that set + * + * Both persona rows are **display/routing identities, not accounts**: they carry + * no better-auth credential, so they cannot sign in (see `ensureDemoUser`). They + * are enough to make a recipient RESOLVE — which is the whole of this defect — + * and enough to make the inbox row addressable to a real user id, but reading + * that row as that persona still needs the sign-up the showcase deliberately + * leaves to the operator. + * + * And all three are DEV-ONLY: the dev admin is gated on + * `NODE_ENV === 'development'`, and the two personas are provisioned only when + * that admin exists. In a real deployment a fresh showcase has NO users at all, + * so nothing here could resolve — that is a property of the environment, not a + * defect in the seed, and no seed value can repair it. + * + * ## What is deliberately NOT addressed to these, and why + * + * `showcase_team.lead` is display-only — it reaches no notify recipient, so it + * carries none of this defect and keeps its `ada@`-style email. + * + * `showcase_invoice.owner` is the harder case, and it is left alone KNOWINGLY + * rather than because it is safe. It genuinely does reach a notify recipient + * (`showcase_invoice_lifecycle` sends to `{record.owner}`), so it is the same + * defect class as the fields fixed above. But those three emails are also the + * fixture for the ADR-0055 controlled-by-parent isolation demo, whose whole + * point is that an operator SIGNS UP as `ada@example.com` and then sees only + * their own invoices — `qa/dogfood/test/showcase-invoice-seed-isolation. + * dogfood.test.ts` does exactly that, and pins the seeded owners. + * + * Repointing invoices at the personas above would not fix that demo, it would + * DELETE it: these personas hold no credential, so nobody can sign in as one + * and observe the row-level scoping. Making them loginable, or giving the + * showcase a set of real signup-able contributor accounts, is a larger design + * decision than this defect — so it is reported on #7746 rather than guessed + * at here, and `test/inert-wirings.test.ts` §5 carries the exemption with the + * same reasoning attached. + */ + +/** The dev admin `plugin-auth` seeds when `NODE_ENV === 'development'`. */ +export const ADMIN_EMAIL = 'admin@objectos.ai'; + +/** A phone-based demo persona (§6 "phone sign-in surfaces"). */ +export const PHONE_DEMO_USER = { + id: 'usr_showcase_phone_demo', + name: 'Mei Phone (demo)', + email: 'phone.demo@example.com', + phone_number: '+8613800138000', +} as const; + +/** + * A second persona holding ONLY `auditor`, which is the position behind the + * `finance` group of the per-group (会签) demo. It has to be a *different* user + * from the admin: with one user in both groups a single decision would satisfy + * both tallies at once, and "one approval per group" would never be observable. + */ +export const AUDITOR_DEMO_USER = { + id: 'usr_showcase_auditor_demo', + name: 'Ada Auditor (demo)', + email: 'auditor.demo@example.com', +} as const; + +/** + * Every email a fresh boot of this app turns into a real `sys_user` row. + * + * This is the allow-list a seed value must come from before it may be written + * to a field the notify path reads as a recipient — pinned by + * `test/inert-wirings.test.ts` §5, so re-introducing a made-up persona fails a + * test instead of shipping another unreadable inbox row. + */ +export const PROVISIONED_USER_EMAILS: readonly string[] = [ + ADMIN_EMAIL, + PHONE_DEMO_USER.email, + AUDITOR_DEMO_USER.email, +]; diff --git a/examples/app-showcase/src/security/seed-approval-demo.ts b/examples/app-showcase/src/security/seed-approval-demo.ts index 795fd7e946..134084ab5c 100644 --- a/examples/app-showcase/src/security/seed-approval-demo.ts +++ b/examples/app-showcase/src/security/seed-approval-demo.ts @@ -37,9 +37,16 @@ * which we swallow. */ -const SYS = { isSystem: true } as const; +/** + * The identities this bootstrap provisions now live in `demo-personas.ts`, + * because the SEED needs them too: the fields that feed the notify path + * (`showcase_task.assignee`, `showcase_project.owner`) must name identities + * 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'; -const ADMIN_EMAIL = 'admin@objectos.ai'; +const SYS = { isSystem: true } as const; /** * Positions the admin is granted so they resolve as an approver on the demos. @@ -52,26 +59,6 @@ const ADMIN_EMAIL = 'admin@objectos.ai'; */ const ADMIN_APPROVAL_POSITIONS = ['manager', 'finance', 'legal', 'exec'] as const; -/** A phone-based demo persona (§6 "phone sign-in surfaces"). */ -const PHONE_DEMO_USER = { - id: 'usr_showcase_phone_demo', - name: 'Mei Phone (demo)', - email: 'phone.demo@example.com', - phone_number: '+8613800138000', -} as const; - -/** - * A second persona holding ONLY `auditor`, which is the position behind the - * `finance` group of the per-group (会签) demo. It has to be a *different* user - * from the admin: with one user in both groups a single decision would satisfy - * both tallies at once, and "one approval per group" would never be observable. - */ -const AUDITOR_DEMO_USER = { - id: 'usr_showcase_auditor_demo', - name: 'Ada Auditor (demo)', - email: 'auditor.demo@example.com', -} as const; - interface ApprovalDemoContext { ql: { find: (object: string, query: unknown, options?: unknown) => Promise; diff --git a/examples/app-showcase/test/inert-wirings.test.ts b/examples/app-showcase/test/inert-wirings.test.ts index 0afabf39f8..b7aa966235 100644 --- a/examples/app-showcase/test/inert-wirings.test.ts +++ b/examples/app-showcase/test/inert-wirings.test.ts @@ -6,6 +6,12 @@ import stack from '../objectstack.config.js'; import { PLATFORM_CAPABILITY_NAMES } from '@objectstack/spec/security'; import { FILE_REFERENCE_TYPES, valueSchemaFor } from '@objectstack/spec/data'; import { healthFor, sweepProjectHealth, bindShowcaseJobRuntime } from '../src/automation/jobs/index.js'; +import { + ADMIN_EMAIL, + PHONE_DEMO_USER, + AUDITOR_DEMO_USER, + PROVISIONED_USER_EMAILS, +} from '../src/security/demo-personas.js'; /** * #4774 / #4888 / #4891 — the showcase's DECLARED-BUT-INERT wirings. @@ -402,3 +408,127 @@ describe('seed values satisfy the ADR-0104 stored contract (#4774 ④ / #4891)', expect(bound, 'no task view binds gallery.coverField to `cover`').toBe(true); }); }); + +// ─────────────────────────────────────────────────────────────────────────── +// 5. Seed values — a notify recipient must name a PROVISIONED identity (#7746) +// ─────────────────────────────────────────────────────────────────────────── +/** + * The same bug class as §4, one layer further out: a seed value that is + * shape-valid, silently accepted, and wrong only where something downstream + * consumes it. + * + * `RecipientResolver` (ADR-0030 P1) resolves an email-shaped recipient against + * `sys_user` and, on a MISS, keeps the string VERBATIM as the recipient id. So + * seeding `assignee: 'ada@example.com'` — an address that is no `sys_user` — + * does not fail: the stock reassignment demo persists a `sys_inbox_message` + * whose `user_id` is that literal text, a row no authenticated user can read. + * Nothing in the boot warning block says so; the QA run that found it (#7690) + * only noticed because it had to sign up its own personas to test notify. + * + * The recipient FIELDS are derived from the flows rather than listed here, so a + * new notify node pulls its object/field into this guard automatically. + */ +describe('seeded notify recipients resolve to a real user (#7746)', () => { + const objects = ((stack as { objects?: unknown[] }).objects ?? []) as Array<{ name: string }>; + const seeds = ((stack as { data?: unknown[] }).data ?? []) as Array<{ + object: string; + externalId?: string | string[]; + records?: Array>; + }>; + const flows = ((stack as { flows?: unknown[] }).flows ?? []) as Array>; + + /** + * `showcase_invoice.owner` is the ONE recipient field left addressed to + * non-users, knowingly. It is the fixture for the ADR-0055 + * controlled-by-parent isolation demo, where an operator SIGNS UP as + * `ada@example.com` to observe the row scoping (and + * `showcase-invoice-seed-isolation.dogfood.test.ts` pins those owners). The + * personas this guard allows hold no credential, so repointing invoices at + * them would delete that demo rather than fix it — see `demo-personas.ts`. + * Reported on #7746 as the remaining instance, not silently swept in. + */ + const KNOWN_EXEMPT = new Set(['showcase_invoice.owner']); + + /** `` → the fields its flows hand to a `notify` node as recipients. */ + function recipientFieldsByObject(): Map> { + const byObject = new Map>(); + for (const flow of flows) { + const nodes = (flow.nodes ?? []) as Array<{ type?: string; config?: Record }>; + const object = nodes.find((n) => n?.type === 'start')?.config?.objectName; + if (typeof object !== 'string') continue; + // Stringify the WHOLE flow: notify nodes also live nested inside branch + // bodies (`showcase_project_escalation`), which a top-level scan misses. + const json = JSON.stringify(flow); + for (const [, group] of json.matchAll(/"recipients":\s*(\[[^\]]*\]|"[^"]*")/g)) { + for (const [, field] of group.matchAll(/\{record\.(\w+)\}/g)) { + if (!byObject.has(object)) byObject.set(object, new Set()); + byObject.get(object)!.add(field); + } + } + } + return byObject; + } + + it('derives recipient fields from the flows (guard is not vacuous)', () => { + const byObject = recipientFieldsByObject(); + // The stock reassignment demo is the whole reason this guard exists — if it + // stops being discovered, the guard has gone blind rather than clean. + expect([...(byObject.get('showcase_task') ?? [])]).toContain('assignee'); + expect([...(byObject.get('showcase_project') ?? [])]).toContain('owner'); + }); + + it('every recipient field is a real field on its object', () => { + // A recipient naming a field that does not exist renders empty and notifies + // nobody — the inert-wiring shape this file exists to catch. + const offenders: string[] = []; + for (const [object, fields] of recipientFieldsByObject()) { + const declared = objects.find((o) => o.name === object) as + | { fields?: Record } + | undefined; + if (!declared?.fields) continue; + for (const field of fields) { + if (!(field in declared.fields)) offenders.push(`${object}.${field}`); + } + } + expect(offenders, `notify recipient names an undeclared field: ${offenders.join(', ')}`).toEqual([]); + }); + + it('no seeded recipient value is an email the app never provisions', () => { + const byObject = recipientFieldsByObject(); + const allowed = new Set(PROVISIONED_USER_EMAILS); + const offenders: string[] = []; + for (const seed of seeds) { + const fields = byObject.get(seed.object); + if (!fields) continue; + for (const record of seed.records ?? []) { + for (const field of fields) { + const value = record[field]; + // Only email-SHAPED values are judged. A bare id or an unset field is + // outside this rule: the resolver only does the lookup that can miss + // when the value looks like an address. + if (typeof value !== 'string' || !value.includes('@')) continue; + if (allowed.has(value)) continue; + if (KNOWN_EXEMPT.has(`${seed.object}.${field}`)) continue; + const idKey = Array.isArray(seed.externalId) ? seed.externalId[0] : seed.externalId; + offenders.push(`${seed.object}.${field}='${value}' ('${String(record[idKey ?? 'name'] ?? '?')}')`); + } + } + } + expect( + offenders, + `seeded notify recipient(s) are not a provisioned sys_user — the inbox row would be unreadable: ${offenders.join(', ')}`, + ).toEqual([]); + }); + + it('every allowed email is one the app actually provisions', () => { + // The allow-list is only as good as its agreement with the bootstrap that + // creates the rows. Both read the same registry; this pins that they do. + expect(PROVISIONED_USER_EMAILS).toContain(ADMIN_EMAIL); + expect(PROVISIONED_USER_EMAILS).toContain(PHONE_DEMO_USER.email); + expect(PROVISIONED_USER_EMAILS).toContain(AUDITOR_DEMO_USER.email); + const provisioner = readFileSync(`${SRC_ROOT}/security/seed-approval-demo.ts`, 'utf8'); + expect(provisioner, 'the approval demo no longer provisions the persona rows').toContain( + 'ensureDemoUser', + ); + }); +});