diff --git a/.changeset/22067-seed-claim-skip-automations.md b/.changeset/22067-seed-claim-skip-automations.md new file mode 100644 index 00000000000..468418855a7 --- /dev/null +++ b/.changeset/22067-seed-claim-skip-automations.md @@ -0,0 +1,16 @@ +--- +'@objectstack/plugin-security': patch +--- + +The seed ownership claim no longer fires app hooks or record-change flows + +Clause-②: no + +On a freshly seeded database, the first sign-up promotes the first user to platform admin, and `claimSeedOwnership` hands every seeded row to that admin inside the same request. That write ran under a bare system context, so every claimed row went through the full write pipeline: hooks bound from app metadata fired, record-change flows ran, approvals opened on seeded records and notifications went to the new admin. Measured on hotcrm `56d98f7e` (17.7.0, a 354-row seed), the first sign-up took about 45 s, and in that time the claim fired 1,254 app hooks, ran 8 flows, opened 2 approvals and handed 8 emails to the transport. + +The claim's write now runs with `{ isSystem: true, skipAutomations: true }`. The seed itself is end-state data written without automation, and the claim keeps that rule for the write that completes it. + +- **App hooks no longer fire for the seed ownership claim.** No hook bound from metadata (an app's `hooks`, sandboxed bodies included) runs on the claim's owner change, and no record-change flow is dispatched for it. So the claim opens no approval and sends no notification. +- **Which claims.** Every pass of the claim: the promotion pass inside the first sign-up, and the pass that runs on `app:seeded` on every boot, the first and every later one. +- **Still runs.** Hooks that plugins register in code still run, so the claim still writes one audit row per claimed record (plugin-audit) and plugin-sharing still recomputes the grants the owner change earns. Every claimed row's `owner_id` is the admin and its `updated_at` still advances, exactly as before. The per-row hook ceiling and the paged fallback for very large objects are unchanged. +- **If an app relied on it.** An app hook or flow that reacted to the claim's owner change no longer sees it, just as it never saw the seed's own writes. None of the hooks or flows in ObjectStack's own example apps reads it. diff --git a/packages/plugins/plugin-security/src/claim-seed-ownership-dispatch.pin.test.ts b/packages/plugins/plugin-security/src/claim-seed-ownership-dispatch.pin.test.ts new file mode 100644 index 00000000000..618d4a8cc8b --- /dev/null +++ b/packages/plugins/plugin-security/src/claim-seed-ownership-dispatch.pin.test.ts @@ -0,0 +1,263 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#22067] What the seed ownership claim's write DISPATCHES — measured on a REAL + * `ObjectQL` over a real `SqlDriver` (better-sqlite3 `:memory:`). + * + * The claim re-owns seed rows to the platform admin. That is attribution, the + * step that completes the seed, not a user event — so its write runs under + * `{ isSystem: true, skipAutomations: true }`. This file pins what that context + * reaches on the engine, because a flag that is silently dropped on the + * predicate-write path would read as green in every double: + * + * 1. **the metadata-hook filter** — a hook bound through the real binder + * (`bindHooksToEngine`, which is what stamps `entry.meta`) does not fire, + * before or after, on any claimed row; + * 2. **the per-row hook path** — code-registered hooks DO fire, once per + * matched row, and every per-row context carries the flag; + * 3. **the record-change trigger's reading point** — every one of those + * contexts has `session.skipTriggers === true`, the one field + * `RecordChangeTrigger`'s handler reads before it dispatches a flow + * (`skipAutomations` implies it, `engine.ts` `buildSession`). The booted-app + * half — the real trigger, approvals, notifications, audit rows and the + * sharing projection — is `packages/qa/dogfood/test/ + * seed-ownership-claim-dispatch.dogfood.test.ts`. + * + * And it measures the claim's one structural dependency on hook dispatch, the + * per-row hook ceiling: the claim's fallback page size is derived from it, so + * if the flag ever exempted a write from the ceiling the fallback would be dead + * code and the page size wrong. It does not — the engine asks whether ANY hook + * covers the object before it counts the matched rows, and code-registered + * hooks still do — so an over-ceiling claim is still refused whole and still + * pages to every row. + * + * Each case carries its positive control on the SAME engine: a plain + * `{ isSystem: true }` predicate write over the same rows fires the metadata + * hook per row. Without it, "the hook fired 0 times" could be a hook that was + * never bound. + */ + +import { describe, it, expect, afterEach } from 'vitest'; +import { ObjectQL, bindHooksToEngine } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { + BULK_PER_ROW_HOOK_LIMIT_ERROR_CODE, + MAX_BULK_PER_ROW_HOOK_ROWS, +} from '@objectstack/spec/data'; + +import { claimSeedOwnership } from './claim-seed-ownership.js'; + +const SYS = { context: { isSystem: true } } as const; +const ADMIN = 'usr_admin_human'; +const SEED_IDENTITY = 'usr_system'; +const OBJECT = 'probe_deal'; + +const PROBE_OBJECT: any = { + name: OBJECT, + label: 'Probe Deal', + fields: { + id: { type: 'text', label: 'Id', primary: true }, + name: { type: 'text', label: 'Name' }, + stage: { type: 'text', label: 'Stage' }, + owner_id: { type: 'text', label: 'Owner' }, + }, +}; + +/** One dispatch a hook saw: what the engine handed it, reduced to the flags. */ +interface Seen { + event: string; + mode: unknown; + isSystem: unknown; + skipAutomations: unknown; + skipTriggers: unknown; +} + +const engines: ObjectQL[] = []; +afterEach(async () => { + while (engines.length) { + try { await engines.pop()?.destroy(); } catch { /* noop */ } + } +}); + +/** + * A booted engine with ONE metadata-bound hook and ONE code-registered hook on + * the probe object, both on `beforeUpdate` and `afterUpdate`. + * + * The metadata hook is bound through `bindHooksToEngine` — the binder an app's + * `defineStack({ hooks })` goes through — so its entry carries `meta` exactly as + * a shipped app hook's does. The code hook is `registerHook` with no metadata, + * the shape of plugin-audit's writer, plugin-sharing's rule projection and + * `RecordChangeTrigger`'s per-flow handler. + */ +async function boot() { + const engine = new ObjectQL(); + engine.registerDriver( + new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }), + true, + ); + await engine.init(); + engine.registerApp({ + id: 'com.objectstack.claim-dispatch-22067', + name: 'Claim dispatch', + version: '1.0.0', + type: 'plugin', + scope: 'system', + objects: [PROBE_OBJECT], + } as any); + await engine.syncSchemas(); + engines.push(engine); + + const metadataHook: Seen[] = []; + const codeHook: Seen[] = []; + const record = (into: Seen[]) => async (ctx: any) => { + into.push({ + event: ctx.event, + mode: ctx.dispatch?.mode, + isSystem: ctx.session?.isSystem, + skipAutomations: ctx.session?.skipAutomations, + skipTriggers: ctx.session?.skipTriggers, + }); + }; + + const bound = bindHooksToEngine( + engine, + [ + { + name: 'probe_app_hook', + object: OBJECT, + events: ['beforeUpdate', 'afterUpdate'], + handler: record(metadataHook), + } as any, + ], + { packageId: 'com.objectstack.claim-dispatch-22067' }, + ); + expect(bound.registered, 'the metadata hook must be bound for its absence to mean anything').toBe(2); + + for (const event of ['beforeUpdate', 'afterUpdate'] as const) { + engine.registerHook(event, record(codeHook), { object: OBJECT, packageId: 'probe.code-hook' }); + } + + return { engine, metadataHook, codeHook }; +} + +/** + * Seed rows straight through the driver — the seed path is not the subject + * here, and the claim reads only what is stored. Chunked, so no single + * statement nears SQLite's bound-parameter limit. + */ +async function seed(engine: ObjectQL, rows: Record[]): Promise { + const driver = (engine as any).getDriverForObject(OBJECT); + for (let i = 0; i < rows.length; i += 500) { + await driver.bulkCreate(OBJECT, rows.slice(i, i + 500)); + } +} + +async function ownersOf(engine: ObjectQL): Promise> { + const rows = await (engine as any).find(OBJECT, { fields: ['id', 'owner_id'] }, SYS); + return new Map(rows.map((r: any) => [r.id, r.owner_id ?? null])); +} + +describe('[#22067] the seed ownership claim dispatches no metadata-bound automation (real engine)', () => { + it('the whole-set write: no metadata hook fires, code hooks fire per row with the flag and skipTriggers', async () => { + const { engine, metadataHook, codeHook } = await boot(); + const N = 40; + await seed(engine, Array.from({ length: N }, (_, i) => ({ + id: `d${i}`, + name: `Deal ${i}`, + stage: 'open', + owner_id: i % 2 === 0 ? null : SEED_IDENTITY, + }))); + + const result = await claimSeedOwnership(engine, ADMIN); + + expect(result).toEqual([{ object: OBJECT, count: N }]); + const owners = await ownersOf(engine); + expect(owners.size).toBe(N); + for (const owner of owners.values()) expect(owner).toBe(ADMIN); + + // ⭐ 1 — the metadata-hook filter: not one dispatch, before or after. + expect(metadataHook, 'a metadata-bound hook fired for the claim').toEqual([]); + + // ⭐ 2 — the per-row path still runs code-registered hooks, one context per + // matched row and phase, so audit and sharing keep their per-row input. + expect(codeHook.filter((s) => s.event === 'beforeUpdate')).toHaveLength(N); + expect(codeHook.filter((s) => s.event === 'afterUpdate')).toHaveLength(N); + // ⭐ 3 — every one of those contexts carries the flag AND `skipTriggers`, + // the field the record-change trigger reads before dispatching a flow. + for (const s of codeHook) { + expect(s).toEqual({ + event: s.event, + mode: 'per-row', + isSystem: true, + skipAutomations: true, + skipTriggers: true, + }); + } + + // Positive control, same engine, same rows: a plain system predicate write + // fires the metadata hook per row and phase, and carries no skip flag. + metadataHook.length = 0; + codeHook.length = 0; + await (engine as any).update(OBJECT, { stage: 'touched' }, { + where: { owner_id: ADMIN }, + multi: true, + context: { isSystem: true }, + }); + expect(metadataHook).toHaveLength(2 * N); + expect(codeHook).toHaveLength(2 * N); + for (const s of codeHook) { + expect(s.skipAutomations).toBeUndefined(); + expect(s.skipTriggers).toBeUndefined(); + } + }, 120_000); + + it('over the per-row hook ceiling: still refused whole under the flag, and the fallback pages to every row', async () => { + const { engine, metadataHook, codeHook } = await boot(); + const N = MAX_BULK_PER_ROW_HOOK_ROWS + 500; + await seed(engine, Array.from({ length: N }, (_, i) => ({ + id: `d${String(i).padStart(6, '0')}`, + name: `Deal ${i}`, + stage: 'open', + owner_id: null, + }))); + + // The measurement the fallback's page size rests on: with metadata hooks + // switched off, code-registered hooks still cover the object, so the + // ceiling still applies and refuses the whole write — nothing written, + // no hook run. + await expect( + (engine as any).update(OBJECT, { owner_id: ADMIN }, { + where: { owner_id: null }, + multi: true, + context: { isSystem: true, skipAutomations: true }, + }), + ).rejects.toMatchObject({ code: BULK_PER_ROW_HOOK_LIMIT_ERROR_CODE, limit: MAX_BULK_PER_ROW_HOOK_ROWS }); + expect([...(await ownersOf(engine)).values()].every((o) => o === null)).toBe(true); + expect(codeHook).toEqual([]); + + const result = await claimSeedOwnership(engine, ADMIN); + + // Every row claimed, through the fallback: more than one landed write, + // none of them over the ceiling. + expect(result).toEqual([{ object: OBJECT, count: N }]); + const owners = await ownersOf(engine); + expect(owners.size).toBe(N); + for (const owner of owners.values()) expect(owner).toBe(ADMIN); + const afterDispatches = codeHook.filter((s) => s.event === 'afterUpdate'); + expect(afterDispatches).toHaveLength(N); + expect(metadataHook, 'a metadata-bound hook fired on a fallback page').toEqual([]); + for (const s of codeHook) { + expect(s.skipAutomations).toBe(true); + expect(s.skipTriggers).toBe(true); + } + + // Positive control on this engine too, kept under the ceiling. + metadataHook.length = 0; + await (engine as any).update(OBJECT, { stage: 'touched' }, { + where: { id: { $in: ['d000000', 'd000001', 'd000002'] } }, + multi: true, + context: { isSystem: true }, + }); + expect(metadataHook).toHaveLength(6); + }, 180_000); +}); diff --git a/packages/plugins/plugin-security/src/claim-seed-ownership.test.ts b/packages/plugins/plugin-security/src/claim-seed-ownership.test.ts index a3c8697e48f..36e7c9f28d5 100644 --- a/packages/plugins/plugin-security/src/claim-seed-ownership.test.ts +++ b/packages/plugins/plugin-security/src/claim-seed-ownership.test.ts @@ -19,6 +19,8 @@ interface RecordedWrite { multi: boolean; /** Ids this write actually re-owned, in fixture order. */ matched: string[]; + /** The execution context the write carried — what the engine's hook dispatch reads. */ + context: any; } /** One recorded call to `ql.find` — the page request the writer issued. */ @@ -27,6 +29,8 @@ interface RecordedRead { where: any; limit: number; returned: number; + /** The execution context the read carried. */ + context: any; } /** @@ -90,11 +94,17 @@ function makeQL( const ceiling = opts.ceiling ?? MAX_BULK_PER_ROW_HOOK_ROWS; const ql: any = { registry: { getAllObjects: () => schemas }, - find: vi.fn(async (object: string, query: any) => { + find: vi.fn(async (object: string, query: any, options?: any) => { const all = rowsByObject[object] ?? []; const hits = all.filter((r) => rowMatches(r, query?.where ?? {})); const page = typeof query?.limit === 'number' ? hits.slice(0, query.limit) : hits; - reads.push({ object, where: query?.where, limit: query?.limit, returned: page.length }); + reads.push({ + object, + where: query?.where, + limit: query?.limit, + returned: page.length, + context: options?.context, + }); return page.map((r) => ({ id: r.id })); }), update: vi.fn(async (object: string, data: any, options: any) => { @@ -127,6 +137,7 @@ function makeQL( where, multi: options?.multi === true, matched: matched.map((r) => r.id), + context: options?.context, }); return matched.length; }), @@ -512,4 +523,41 @@ describe('claimSeedOwnership', () => { expect(warn).toHaveBeenCalledTimes(2); expect(warn.mock.calls[0][0]).toContain('those rows stay unowned'); }); + + // ── [#22067] the claim's write dispatches no metadata-bound automation ──── + + it('every reown write — whole-set attempt and fallback page alike — runs as isSystem with skipAutomations', async () => { + // Re-owning a seed row is attribution, not a user event, so the write runs + // with metadata-bound automation off: no app hook, no record-change flow, + // hence no approval and no notification. Code-registered hooks (audit, + // sharing) still run — the real-engine half of this pin is + // `claim-seed-ownership-dispatch.pin.test.ts`; this half pins that EVERY + // write this function issues carries the flag, including the page writes + // a large object takes and the whole-set attempts the engine refuses. + // + // 12 000 unowned rows: over the per-row hook ceiling, so one fixture drives + // a refused whole-set attempt, the page writes, and the closing whole-set + // write that lands. + const schemas = [{ name: 'crm_lead', fields: [{ name: 'owner_id' }] }]; + const rows = Array.from({ length: 12_000 }, (_, i) => ({ id: `l${i}`, owner_id: null })); + const { ql, writes, reads } = makeQL(schemas, { crm_lead: rows }); + + expect(await claimSeedOwnership(ql, ADMIN)).toEqual([{ object: 'crm_lead', count: 12_000 }]); + + // Both write shapes were exercised, so the assertion below covers both. + expect(writes.some((w) => (w.where as any)?.id)).toBe(true); + expect(writes.some((w) => !(w.where as any)?.id)).toBe(true); + expect(reads.length).toBeGreaterThan(0); + + // Every CALL, not only the writes that landed: a refused attempt is + // refused for its row count, and the context it asked with is the same. + const contexts = ql.update.mock.calls.map((call: any[]) => call[2]?.context); + expect(contexts.length).toBeGreaterThan(writes.length); + for (const context of contexts) { + expect(context).toEqual({ isSystem: true, skipAutomations: true }); + } + // The page read stays a plain system read — the flag is about dispatch, + // and a read dispatches no write automation. + for (const r of reads) expect(r.context?.isSystem).toBe(true); + }); }); diff --git a/packages/plugins/plugin-security/src/claim-seed-ownership.ts b/packages/plugins/plugin-security/src/claim-seed-ownership.ts index 78f3850d4af..fa63cffb378 100644 --- a/packages/plugins/plugin-security/src/claim-seed-ownership.ts +++ b/packages/plugins/plugin-security/src/claim-seed-ownership.ts @@ -48,6 +48,38 @@ * admin owns them a re-run is a no-op. `managedBy` and `sys_*` tables are * skipped (their ownership, if any, is platform-controlled). * + * ## [#22067] The claim dispatches no automation + * + * Re-owning a seed row is attribution — the step that completes the seed — not + * a user event. The seed itself is written under `SEED_WRITE_EXECUTION_CONTEXT` + * because a seed is end-state data, so firing "on update" automation for it is + * wrong; the claim keeps that principle for its own write. So every `reown` + * write runs under {@link CLAIM_WRITE_CTX}: `isSystem`, plus `skipAutomations`, + * which the engine reads in two places: + * + * - the hook dispatch skips every hook bound FROM METADATA — an app's + * lifecycle hooks, sandboxed bodies included; + * - it implies `skipTriggers`, so the record-change trigger dispatches no flow + * — and therefore opens no approval and sends no notification on the claim's + * account. + * + * Hooks that plugins register in code carry no metadata binding and still run + * on every claimed row: plugin-audit's writer, capability gates and + * plugin-sharing's rule projection. The opt-out can never bypass audit or + * sharing (#2922). ObjectQL's own `sys_stamp_audit_*` builtins are bound + * through the hook binder, so they carry metadata and are skipped as well; for + * this write that changes nothing — the claim has no user to stamp into + * `updated_by`, and the drivers stamp `updated_at` themselves. + * + * Measured before this on hotcrm `56d98f7e` (17.7.0, a 354-row seed): the + * first sign-up waited ~45 s while the claim fired 1 254 app hooks, ran 8 flows, + * opened 2 approvals and handed 8 emails to the transport. + * + * It reaches every caller, because they all write through this function: the + * promotion pass inside the first sign-up and the `app:seeded` settle pass on + * every boot, the first one and every later one. + * + * ## [#14530] PAGED predicate writes, never a write per row * * This used to scan each object twice at `limit: 10_000` and then issue one @@ -129,8 +161,31 @@ interface ClaimOwnershipOptions { seedSettlement?: SeedSettlementSnapshot | undefined; } +/** The context of the claim's one READ (`readPage`): system-elevated, nothing more. */ const SYSTEM_CTX = { isSystem: true }; +/** + * The context of the claim's WRITE (`reown`): system-elevated, with + * metadata-bound automation off — see "The claim dispatches no automation" + * above. + * + * `skipAutomations` alone, never a narrower flag: it is the spec's one spelling + * of "skip metadata hooks AND record-change flows, keep code-registered hooks" + * (`ExecutionContext`, `@objectstack/spec/kernel`), and `skipTriggers` alone + * would leave the app's metadata hooks firing on every claimed row. + * + * A plain literal, like {@link SYSTEM_CTX}: `pnpm check:tenant-audit-census` + * reads `isSystem` off it statically, and it does not see through a + * `satisfies` wrapper — spelled that way, this write would drop from + * "decidably elevated" to "elevation undecidable" in the census. + * + * It does NOT move the per-row hook ceiling, so {@link CLAIM_PAGE_ROWS} needs no + * change: the engine counts the matched rows against the ceiling whenever ANY + * hook covers the object, without consulting the flag — and code-registered + * hooks still run per row under it, so the bound still means what it did. + */ +const CLAIM_WRITE_CTX = { isSystem: true, skipAutomations: true }; + /** * Rows a single fallback page takes off the top of an over-ceiling predicate. * @@ -237,7 +292,10 @@ function idsFrom(rows: any): string[] { * paging fallback existed, with no ledger row degraded to buy a green gate. */ interface ObjectWriter { - /** Re-own every row a predicate matches; resolves the affected-row count. */ + /** + * Re-own every row a predicate matches, under {@link CLAIM_WRITE_CTX}; + * resolves the affected-row count. + */ reown: (predicate: Record) => Promise; /** At most one page of ids the predicate still matches. */ readPage: (predicate: Record) => Promise; @@ -435,7 +493,8 @@ function reportClaimPass( * (c) are not `external` (federated remote-table bindings — read-only, DDL * forbidden, and their `owner_id` is not ours to reassign), * (d) declare an `owner_id` field, - * and re-owns the unowned rows as `isSystem` with one predicate write per + * and re-owns the unowned rows as `isSystem`, with metadata-bound automation + * off ({@link CLAIM_WRITE_CTX}), in one predicate write per * {@link UNOWNED_PREDICATES} entry, paging that write only when the engine * refuses it for its per-row hook budget. Returns a per-object summary whose * `count` is the sum of every write's affected-row count. @@ -483,7 +542,7 @@ export async function claimSeedOwnership( reown: (predicate) => ql.update( schema.name, { owner_id: adminUserId }, - { where: predicate, multi: true, context: SYSTEM_CTX }, + { where: predicate, multi: true, context: CLAIM_WRITE_CTX }, ), readPage: (predicate) => ql.find( schema.name, diff --git a/packages/qa/dogfood/test/seed-ownership-claim-dispatch.dogfood.test.ts b/packages/qa/dogfood/test/seed-ownership-claim-dispatch.dogfood.test.ts new file mode 100644 index 00000000000..64e8bcbbcb5 --- /dev/null +++ b/packages/qa/dogfood/test/seed-ownership-claim-dispatch.dogfood.test.ts @@ -0,0 +1,311 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// The first sign-up's seed ownership claim dispatches no automation — and +// still writes its audit rows and runs the sharing projection. +// +// On a freshly seeded database the first human to sign up is promoted to +// platform admin, and `claimSeedOwnership` (plugin-security) re-owns every +// seeded row to them INSIDE that sign-up request. That re-own is attribution — +// the step that completes the seed — not a user event. It used to run under a +// bare `{ isSystem: true }`, so every claimed row went through the full write +// pipeline: app hooks bound from metadata fired, record-change flows ran, +// approvals opened on seeded records and notifications were sent to the new +// admin. Measured on hotcrm `56d98f7e` (17.7.0, a 354-row seed): a ~45 s first +// sign-up, 1 254 app hooks, 8 flow runs, 2 approvals opened, 8 emails. +// +// The claim's write now carries `skipAutomations` beside `isSystem` — the +// seed's own principle (seed loads end-state data, not user events) carried to +// the write that completes it. This pins, on a booted app with the real +// automation + record-change trigger + approvals + messaging + audit + sharing +// chain, and over the real REST sign-up door: +// +// ⭐ the claim fires no metadata-bound hook, runs no record-change flow, opens +// no approval and emits no notification, and every seeded row's +// `owner_id` is the admin; +// ⭐ the claim still writes one audit row per claimed record, and plugin- +// sharing's rule projection still materialises the grants the owner +// change earns — code-registered hooks always run, so the opt-out +// bypasses neither audit nor sharing (#2922). +// +// Every negative is armed by the fixture itself rather than assumed: the +// flow carries no start condition, so ANY update of a deal runs it; the hook +// has no condition either. The positive control at the end is an ordinary +// update by the admin over the same door, which fires both — so "zero during +// the claim" cannot be a hook or a flow that was never bound. +// +// The real-engine half (the flag reaching the hook filter, the per-row path +// and the trigger's reading point; the per-row hook ceiling and the paged +// fallback) is `plugin-security/src/claim-seed-ownership-dispatch.pin.test.ts`. + +import { describe, it, expect } from 'vitest'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { defineStack, defineFlow } from '@objectstack/spec'; +import { ObjectSchema, Field, defineSeed } from '@objectstack/spec/data'; +import { defineSharingRule } from '@objectstack/spec/security'; +import { AuditPlugin } from '@objectstack/plugin-audit'; +import { ApprovalsServicePlugin } from '@objectstack/plugin-approvals'; +import { RecordChangeTriggerPlugin } from '@objectstack/trigger-record-change'; +import { MessagingServicePlugin, NOTIFICATION_EVENT_OBJECT } from '@objectstack/service-messaging'; + +const OBJECT = 'scd_deal'; +const FLOW = 'scd_deal_updated'; +const ADMIN_EMAIL = 'scd-first-admin@example.com'; +const ADMIN_PASSWORD = 'First-Admin-Pass-123'; +const SYS = { isSystem: true } as const; + +/** Every dispatch the metadata-bound hook received, by event. */ +const appHookFires: string[] = []; + +const ScdDeal = ObjectSchema.create({ + name: OBJECT, + label: 'Seed Claim Deal', + pluralLabel: 'Seed Claim Deals', + fields: { + name: Field.text({ label: 'Name', required: true }), + stage: Field.text({ label: 'Stage' }), + }, +}); + +const SEEDED = ['Acme renewal', 'Globex rollout', 'Initech expansion', 'Umbrella pilot']; + +const ScdSeed = defineSeed(ScdDeal, { + mode: 'upsert', + externalId: 'name', + // No `owner_id`: an author leaves it unset, and the claim hands the rows to + // the first admin. + records: SEEDED.map((name) => ({ name, stage: 'open' })), +}); + +/** + * An app hook bound FROM METADATA, with no condition — before and after every + * update of a deal. It records each dispatch and changes nothing. + */ +const ScdAppHook = { + name: 'scd_count_updates', + label: 'Count deal updates', + object: OBJECT, + events: ['beforeUpdate', 'afterUpdate'] as ('beforeUpdate' | 'afterUpdate')[], + handler: async (ctx: any) => { + appHookFires.push(String(ctx?.event)); + }, +}; + +/** + * A record-change flow with NO start condition — every update of a deal runs + * it — that notifies the deal's owner and then opens an approval for the + * admin: the hotcrm shape (a notification and an approval per claimed row). + */ +const ScdFlow = defineFlow({ + name: FLOW, + label: 'Deal updated', + description: 'Fires on every deal update: notifies the owner, then asks the admin to review.', + type: 'autolaunched', + status: 'active', + nodes: [ + { + id: 'start', + type: 'start', + label: 'On Deal Update', + config: { objectName: OBJECT, triggerType: 'record-after-update' }, + }, + { + id: 'notify', + type: 'notify', + label: 'Notify the owner', + config: { + topic: 'scd.deal_updated', + recipients: '{record.owner_id}', + title: 'Deal updated: {record.name}', + message: '{record.name} was updated.', + channels: ['inbox'], + sourceObject: OBJECT, + sourceId: '{record.id}', + }, + }, + { + id: 'review', + type: 'approval', + label: 'Review', + config: { approvers: [{ type: 'user', value: ADMIN_EMAIL }], behavior: 'first_response' }, + }, + { id: 'approved', type: 'end', label: 'Approved' }, + { id: 'rejected', type: 'end', label: 'Rejected' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'notify' }, + { id: 'e2', source: 'notify', target: 'review' }, + { id: 'e3', source: 'review', target: 'approved', label: 'approve' }, + { id: 'e4', source: 'review', target: 'rejected', label: 'reject' }, + ], +}); + +/** + * A sharing rule whose grants exist only once a deal HAS an owner: each owned + * deal is shared with the user its `owner_id` names. Before the claim every + * seeded deal is ownerless and the rule grants nothing; the claim's owner + * change is what earns the grants, so they appear only if plugin-sharing's + * code-registered projection ran on the claim's write. + */ +const ScdOwnedRule = defineSharingRule({ + type: 'criteria', + name: 'scd_owned_deals', + label: 'Owned deals → their owner', + description: 'Share each owned deal with the user its owner_id names.', + object: OBJECT, + condition: 'record.owner_id != null', + accessLevel: 'read', + sharedWith: { type: 'field', value: 'owner_id' }, + active: true, +}); + +const scdStack = defineStack({ + manifest: { + id: 'com.dogfood.seed-ownership-claim-dispatch', + namespace: 'scd', + version: '0.0.0', + type: 'app', + name: 'Seed Ownership Claim Dispatch Fixture', + description: 'Seeded deals with an app hook, a record-change flow and an owner-keyed sharing rule.', + }, + requires: ['automation', 'triggers'], + objects: [ScdDeal], + hooks: [ScdAppHook], + flows: [ScdFlow], + sharingRules: [ScdOwnedRule], + data: [ScdSeed], +}); + +interface Window { + appHooks: number; + flowRuns: number; + approvals: number; + notifications: number; + auditUpdates: number; + ruleGrants: number; +} + +async function readWindow(stack: VerifyStack, ql: any): Promise { + const automation = await stack.kernel.getServiceAsync('automation'); + const runs = await automation.listRuns(FLOW, { limit: 1000 }); + const rows = async (object: string, where: Record = {}) => + ((await ql.find(object, { where, limit: 5000 }, { context: SYS })) ?? []) as any[]; + return { + appHooks: appHookFires.length, + flowRuns: runs.length, + approvals: (await rows('sys_approval_request')).length, + notifications: (await rows(NOTIFICATION_EVENT_OBJECT)).length, + auditUpdates: (await rows('sys_audit_log', { object_name: OBJECT, action: 'update' })).length, + ruleGrants: (await rows('sys_record_share', { object_name: OBJECT, source: 'rule' })).length, + }; +} + +const minus = (a: Window, b: Window): Window => ({ + appHooks: a.appHooks - b.appHooks, + flowRuns: a.flowRuns - b.flowRuns, + approvals: a.approvals - b.approvals, + notifications: a.notifications - b.notifications, + auditUpdates: a.auditUpdates - b.auditUpdates, + ruleGrants: a.ruleGrants - b.ruleGrants, +}); + +describe('the first sign-up claims the seed without dispatching automation', () => { + it( + 'no app hook, flow, approval or notification during the claim — audit rows and sharing grants still land', + async () => { + // The first human must arrive through the REST sign-up door, not the + // harness's in-process dev-admin seed, so the claim runs inside the + // request it slows down in production. + const prevSeedAdmin = process.env.OS_SEED_ADMIN; + process.env.OS_SEED_ADMIN = '0'; + let stack: VerifyStack | undefined; + try { + stack = await bootStack(scdStack as unknown as Parameters[0], { + automation: true, + extraPlugins: [ + new AuditPlugin(), + new MessagingServicePlugin({ reliableDelivery: false }), + new RecordChangeTriggerPlugin(), + new ApprovalsServicePlugin(), + ], + }); + const ql = await stack.kernel.getServiceAsync('objectql'); + + // The seed is in, and nobody owns it yet. + const seeded = (await ql.find(OBJECT, { where: {} }, { context: SYS })) as any[]; + expect(seeded.map((r) => r.name).sort()).toEqual([...SEEDED].sort()); + for (const row of seeded) expect(row.owner_id ?? null).toBeNull(); + + const before = await readWindow(stack, ql); + expect(before.ruleGrants, 'an ownerless deal earns no grant').toBe(0); + + const signUp = await stack.api('/auth/sign-up/email', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: ADMIN_EMAIL, password: ADMIN_PASSWORD, name: 'First Admin' }), + }); + expect(signUp.status, await signUp.clone().text()).toBe(200); + const body = (await signUp.json()) as { token?: string; user?: { id?: string } }; + const adminId = String(body.user?.id ?? ''); + expect(adminId).not.toBe(''); + expect(body.token).toBeTruthy(); + + const claim = minus(await readWindow(stack, ql), before); + + // Every seeded row now belongs to the admin. + const owned = (await ql.find(OBJECT, { where: {} }, { context: SYS })) as any[]; + expect(owned).toHaveLength(SEEDED.length); + for (const row of owned) expect(row.owner_id, `${row.name} owner`).toBe(adminId); + + // ⭐ The claim dispatched no automation. One assertion over all four, + // so a failure reports every count at once rather than the first. + expect( + { + appHooks: claim.appHooks, + flowRuns: claim.flowRuns, + approvals: claim.approvals, + notifications: claim.notifications, + }, + 'automation the claim dispatched (metadata hooks, flow runs, approvals opened, notifications emitted)', + ).toEqual({ appHooks: 0, flowRuns: 0, approvals: 0, notifications: 0 }); + + // ⭐ …and still ran the code-registered hooks: one audit row per + // claimed record, and the grants the owner change earns. + expect(claim.auditUpdates, 'audit rows written for the claim').toBe(SEEDED.length); + const auditRows = (await ql.find( + 'sys_audit_log', + { where: { object_name: OBJECT, action: 'update' } }, + { context: SYS }, + )) as any[]; + expect(new Set(auditRows.map((r) => r.record_id))).toEqual(new Set(owned.map((r) => r.id))); + for (const row of auditRows) { + expect(row.created_at, 'an audit row carries its timestamp').toBeTruthy(); + expect(String(row.new_value), 'the audit row records the new owner').toContain(adminId); + } + expect(claim.ruleGrants, 'sharing grants materialised by the claim').toBe(SEEDED.length); + const grants = (await ql.find( + 'sys_record_share', + { where: { object_name: OBJECT, source: 'rule' } }, + { context: SYS }, + )) as any[]; + expect(new Set(grants.map((g) => g.record_id))).toEqual(new Set(owned.map((r) => r.id))); + + // Positive control: an ordinary update of one deal by the admin, over + // the same REST door, fires the hook (before + after) and runs the + // flow — notification and approval included. + const target = owned[0]; + const edited = await stack.apiAs(body.token!, 'PATCH', `/data/${OBJECT}/${target.id}`, { stage: 'won' }); + expect(edited.status, await edited.clone().text()).toBe(200); + const control = minus(await readWindow(stack, ql), before); + expect(control.appHooks - claim.appHooks, 'the hook is bound and fires on a user update').toBe(2); + expect(control.flowRuns - claim.flowRuns, 'the flow is bound and runs on a user update').toBe(1); + expect(control.approvals - claim.approvals, 'the flow opens its approval').toBe(1); + expect(control.notifications - claim.notifications, 'the flow emits notifications').toBeGreaterThan(0); + } finally { + await stack?.stop(); + if (prevSeedAdmin === undefined) delete process.env.OS_SEED_ADMIN; + else process.env.OS_SEED_ADMIN = prevSeedAdmin; + } + }, + 180_000, + ); +});