diff --git a/.changeset/15205-email-template-org-door-closed.md b/.changeset/15205-email-template-org-door-closed.md new file mode 100644 index 0000000000..11a2659197 --- /dev/null +++ b/.changeset/15205-email-template-org-door-closed.md @@ -0,0 +1,28 @@ +--- +'@objectstack/plugin-email': minor +'@objectstack/plugin-auth': minor +'@objectstack/platform-objects': patch +--- + +feat(plugin-email,plugin-auth)!: an organization can no longer create or edit an email template row; the template provenance stamp and the auth SMS template seed retire + +Clause-②: no (narrowing) + + + +**BREAKING** (an accept-set narrowing and three retired exports), shipped as `minor` under the launch-window convention for breaking changes. It carries ADR-0131 D6 and the maintainer's ruling C on ADR-0131 §6 Q1: email templates are not overridden per organization. + +**What stops being accepted.** + +- **The `sys_email_template` organization door is closed.** An organization's create and update of a template row are refused with `403 PERMISSION_DENIED`, and the message names the closed door: every engine `insert` / `update` whose context names a caller and is not system-elevated. That covers `POST` / `PATCH /api/v1/data/sys_email_template`, the Studio record editor, batch and import routes, and scripts and flows that run as a user or a service principal. System-context writes still pass: the built-in seed, the declared-template boot sweep, the live projection of a Studio `email_template` save into its row, and the v18 migration ceremony's promotion. Delete is not part of this door. +- **The template provenance stamp is retired.** It marked a package- or platform-seeded row `customized: true` when a non-system caller updated it. With the door closed no such update reaches the engine's write, so nothing marks a row any more. Rows already marked keep their mark and are still never overwritten by the boot seeders; they are the population the v18 migration ceremony promotes to environment-level Studio templates. +- **Retired exports of `@objectstack/plugin-email`:** `bindEmailTemplateProvenanceStamp`, `unbindEmailTemplateProvenanceStamp` and `EMAIL_TEMPLATE_PROVENANCE_PACKAGE`. They have no replacement. `EmailServicePlugin` closes the door itself, and there is no stamp left to bind, so a direct call is deleted, not rewritten. +- **The auth SMS template seed is retired.** A boot with phone sign-in on no longer writes the built-in OTP and invitation texts into `sys_notification_template` as rows. It was internal to `@objectstack/plugin-auth` and exported nothing. + +**What renders unchanged.** + +- Every email template renders as before: the template loader still reads `sys_email_template`, and a Studio edit of an `email_template` still reaches the mail at once and survives a restart. +- Every auth SMS text renders byte for byte as the seeded store rendered it, for every built-in text and every recipient locale. Each locale rung renders an operator's active row when one exists, and the built-in text where no row exists at that locale. A deactivated or blank row passes its rung on, as it did before. +- A `sys_notification_template` row an operator already has still wins, and no existing row is touched. + +**What changes for you.** To change what an email template sends, edit the `email_template` in Studio. A script or integration that wrote `sys_email_template` rows through the data API now receives `403 PERMISSION_DENIED`. `@objectstack/platform-objects` corrects the `is_system` and `customized` field help on `sys_email_template`, which said an organization may edit a row. diff --git a/content/docs/automation/email-templates.mdx b/content/docs/automation/email-templates.mdx index 47760d55b1..d36eefb912 100644 --- a/content/docs/automation/email-templates.mdx +++ b/content/docs/automation/email-templates.mdx @@ -219,12 +219,17 @@ schema first, so a malformed template is a warning rather than a broken boot. Runtime saves are materialized on the same seam, so a Studio edit takes effect without a restart. +Change a template's wording in **Studio** — the `email_template` metadata. The +`sys_email_template` rows themselves are closed to organization writes: a create +or an edit through the data API is refused `403 PERMISSION_DENIED`, and only the +platform writes them, from the built-in templates and the `email_template` +metadata. + Materialization is **seed-not-clobber**. Declared templates carry package provenance and are re-seeded on every boot, but a row an administrator created -or edited is never overwritten. A reworded transactional mail survives your next -deploy — which is the intended behaviour, and also the reason a source change -that "does not take effect" is usually a customized row winning, not a failed -seed. +or edited before that door closed is never overwritten. That is also the reason +a source change that "does not take effect" is usually such a customized row +winning, not a failed seed. `bodyText` is optional: when you omit it the service derives a plain-text alternative by stripping tags from the rendered HTML. Authoring one explicitly diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index 91e839567b..94dd19225a 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -180,7 +180,7 @@ The largest single consumer — **17 of the 115 sites**. | 55 | Activation write / authoring refusals do not fire | runtime | Get: activation artifacts writable and authorable without the activation-authoring capability | `packages/runtime/src/domains/activation-gate.ts#refuseUngrantedActivationWrite`, `#refuseUngrantedActivationAuthoring` | | 56 | Automation run-state read, flow-authoring write, the paused-run caller gate (screen read and resume) and the two operator run-lifecycle writes all pass | runtime | Get: run state, flow writes, a paused run's screen and its resume with no grant and without being the run's starter — and cancelling or restoring a suspension without the platform-operator rung. The screen read and the resume ask one predicate, so this is one bypass for both. The lifecycle bypass is the in-process owner's door: plugin-approvals' revise-window recall (ADR-0044) cancels on behalf of a decision it already authorized and recorded | `packages/runtime/src/domains/automation.ts#mayReadRunState`, `#refuseUngrantedFlowWrite`, `#isRunStarterOrRunStateReader`, `#refuseUngrantedRunLifecycleWrite` | | 57 | Audience-binding suggestion recording skipped | plugin-security | Lose: install-time suggestions are not recorded for system callers | `packages/plugins/plugin-security/src/suggested-audience-bindings.ts#assertTenantAdmin` | -| 58 | Email-template / webhook provenance stamps skipped | plugin-email, plugin-webhooks | Lose: the row is not marked as an admin customization | `packages/plugins/plugin-email/src/email-template-provenance.ts#bindEmailTemplateProvenanceStamp`, `packages/plugins/plugin-webhooks/src/webhook-provenance.ts#bindWebhookProvenanceStamp` | +| 58 | Email-template organization door passes; webhook provenance stamp skipped | plugin-email, plugin-webhooks | Get (`sys_email_template`): a create or update of a template row. The door refuses every other write that names a caller, `403 PERMISSION_DENIED` (ADR-0131 D6), so the seeds, the boot sweep and the projection of a Studio save are the only writers. Lose (`sys_webhook`): the row is not marked as an admin customization | `packages/plugins/plugin-email/src/email-template-door.ts#isOrganizationWrite`, `packages/plugins/plugin-webhooks/src/webhook-provenance.ts#bindWebhookProvenanceStamp` | | 59 | **Automation flow data nodes re-add the `owner_id` stamp** (the one place row 2's gap is compensated inline) | service-automation | Get: a flow-authored INSERT under system elevation still lands owned, when the run resolved a user. Fill-only — flow-authored values win | `packages/services/service-automation/src/runtime-identity.ts#stampSystemInsertOwner`, called from `packages/services/service-automation/src/builtin/crud-nodes.ts#registerCrudNodes` | | 60 | Inbox caller refusal names `isSystem` as what was carried | service-messaging | Get: nothing — the refusal still fires. The flag only shapes the diagnostic, because privilege is not an authorization subject | `packages/services/service-messaging/src/inbox-caller.ts#resolveInboxRecipient` | | 61 | **`organization_id` is not auto-stamped on INSERT** — the organization-axis twin of the `owner_id` gap above | organizations | Get: an elevated write may name another organization deliberately, which is what the per-organization seed replay, the orphan-row claim, imports and migrations all rely on. Lose: the fill-only stamp, so an elevated insert that names no organization lands `organization_id = NULL` and the wall hides it. ⛔ Neither path rewrites a supplied `organization_id`: the non-elevated path fills only an absent one, and a supplied value meets the Layer 0 write wall, which refuses a forged one `403 PERMISSION_DENIED` exactly as it refuses an update re-pointing a row. Elevation skips the stamp and the wall alike, which is why it is the seam the legitimate cross-organization writers use | `packages/plugins/organizations/src/organizations-plugin.ts#start` | diff --git a/content/docs/permissions/tenant-audit-census.mdx b/content/docs/permissions/tenant-audit-census.mdx index 96cfa145fe..dee145faec 100644 --- a/content/docs/permissions/tenant-audit-census.mdx +++ b/content/docs/permissions/tenant-audit-census.mdx @@ -122,7 +122,7 @@ are reported as `undecidable` rather than assumed either way. The same holds twice over for the context. An options argument spelled as a literal can be read; one spelled `options`, `{ ...opts }`, or handed through a -forwarding shim cannot, and **54 of the 233 sites are spelled that way**. A +forwarding shim cannot, and **54 of the 232 sites are spelled that way**. A context resolved from an inline literal or a local `const` can be tested for `isSystem`; one arriving from a helper call cannot. @@ -187,10 +187,10 @@ reproduce them. Where it disagrees, it disagrees on the page: | carried figure | where it survives | this census | | :--- | :--- | ---: | -| 175 write call sites | quoted in the merged changeset | **233** | +| 175 write call sites | quoted in the merged changeset | **232** | | 24 carrying no tenant context | quoted in the merged changeset | **2** provable and tenancy-enabled; **31** more whose options argument is unreadable | -| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **155 of 233** decidable, **78** undecidable | -| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 123 decidably elevated, 0 decidably not, 102 undecidable | +| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **154 of 232** decidable, **78** undecidable | +| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 122 decidably elevated, 0 decidably not, 102 undecidable | | 141 and 132, two independent re-derivations | the card that filed this work | — | **The differences are not reconciled, and deliberately so.** The old census's @@ -201,17 +201,17 @@ at any commit. Two structural facts do plausibly widen this reading against any hand or regex one, and both are counted in the generated tables below: the 48 sites reached -through an erased (`any`) receiver, and the 51 that name their object through a +through an erased (`any`) receiver, and the 50 that name their object through a `const` rather than inline. An instrument that read either the way a person does would report a smaller number and would not say so. The fourth row is the one worth flagging to anyone citing it. **The 135 / 77% figure has no surviving corroboration anywhere in the tree.** This census reads -123 of 233 (53%) as decidably elevated, with 102 more whose elevation is a +122 of 232 (53%) as decidably elevated, with 102 more whose elevation is a run-time fact — so the claim is neither confirmed nor refuted, and the honest answer is that a static reading cannot settle it. -⇒ **Cite `2 / 233`, and say what it is**: the sites whose options argument was +⇒ **Cite `2 / 232`, and say what it is**: the sites whose options argument was READ and holds no tenant context, against a decidably tenancy-enabled object. That is the control's provable yield surface. ⛔ Do not cite it as "the sites without tenant context" — **31 further sites** have an options argument this @@ -223,29 +223,29 @@ cannot read, and they are neither in nor out. | what | count | | :--- | ---: | -| write call sites on the application surface | **233** | -| …whose object name is statically decidable | 155 | +| write call sites on the application surface | **232** | +| …whose object name is statically decidable | 154 | | …whose object name is chosen at run time | 78 | -| …against an object with tenancy ENABLED | 154 | +| …against an object with tenancy ENABLED | 153 | | …against an object that declares tenancy off | 1 | -| threading a tenant context | 171 | +| threading a tenant context | 170 | | PROVABLY carrying none (options read, no context key) | **8** | | …of those, against a decidably tenancy-enabled object | **2** | | options argument UNREADABLE — may or may not carry one | 54 | | …of those, against a decidably tenancy-enabled object | 31 | -| threading a decidably ELEVATED (`isSystem`) context | 123 | +| threading a decidably ELEVATED (`isSystem`) context | 122 | | threading a context that is decidably NOT elevated | 0 | | threading a context whose elevation is a run-time fact | 102 | | how the instrument reached the site | count | | :--- | ---: | -| receiver carried a readable engine type | 185 | +| receiver carried a readable engine type | 184 | | receiver erased, placed by the object NAME | 28 | | receiver erased, placed by an `object: string` PARAMETER | 15 | | receiver erased, placed by an `UNTYPED_RECEIVERS` row | 5 | | object name spelled inline | 104 | -| object name spelled through a `const` | 51 | +| object name spelled through a `const` | 50 | | object name is an `object: string` parameter | 19 | | object name is some other run-time expression | 59 | @@ -297,12 +297,12 @@ holds still. They are required to be HERE and to say WHEN they were true; their values are not compared. The reasoning, and the measurement behind it, are in `scripts/check-tenant-audit-census.mjs`. -Measured on 2026-10-07 at `e1171ca48`. +Measured on 2026-10-07 at `a93579f45`. | corpus scale (not enforced) | count | | :--- | ---: | | tracked non-test sources scanned | 612 | -| engine-shaped types recognised | 70 | +| engine-shaped types recognised | 69 | | declared objects in the registry | 116 | | same-named calls subtracted as non-engine | 160 | diff --git a/docs/adr/0131-total-organization-ownership-no-null-organization-id.md b/docs/adr/0131-total-organization-ownership-no-null-organization-id.md index 17207ef6ae..6186c71ac6 100644 --- a/docs/adr/0131-total-organization-ownership-no-null-organization-id.md +++ b/docs/adr/0131-total-organization-ownership-no-null-organization-id.md @@ -812,6 +812,19 @@ overlay axis is retired (D6). One remains: with a release note? Depends on whether any deployment relies on the feature — this record cannot see that; the maintainer rules it when the C4 card is cut. + **Ruled 2026-10-06: C** ([#22005](https://github.com/objectstack-ai/objectstack/issues/22005), ruling + record `6020178017`, the maintainer's 「同意」). At the v18 upgrade the migration ceremony (C7) promotes + every customer-edited email template to an environment-level Studio template — both populations: the + `sys_email_template` rows stamped `customized: true`, and the organization-scoped `email_template` + overlays Studio saved under `single`. Under `single` the meaning is unchanged; on a deployment with more + than one organization a name conflict is listed in the plan and the operator chooses per row (D10 fate 4, + never guessed). After a verified promotion the customized rows are mirrors (D10 fate 2) and the row table + retires under D13 with an ADR-0087 entry. Not taken: A (a kept `(organization, name, locale)` resolution, + the door D6 closed) and B (deleting customer rows, which D10 does not sanction). Email templates only: + notification templates have no metadata type, so their seed retires and no type is added. C4's retirement + list is rewritten for "promote to environment level" and gains `bootstrapEffectiveEmailTemplates` / + `EffectiveEmailTemplateSources`. + ## 7. Verification notes diff --git a/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md b/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md index abbf102cbb..d4c2cd5ffb 100644 --- a/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md +++ b/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md @@ -33,17 +33,17 @@ silent, and `node scripts/tenant-audit-census.mjs --write` is the resolution. | Measure | Value | |---|---:| -| Write call sites | 233 | -| Object name statically decidable | 155 | +| Write call sites | 232 | +| Object name statically decidable | 154 | | Object name chosen at run time | 78 | -| Against a tenancy-enabled object | 154 | +| Against a tenancy-enabled object | 153 | | Against an object declaring tenancy off | 1 | -| Threading a tenant context | 171 | +| Threading a tenant context | 170 | | Provably carrying none | 8 | | …and decidably tenancy-enabled | 2 | | Options argument unreadable | 54 | | …and decidably tenancy-enabled | 31 | -| Threading a decidably elevated context | 123 | +| Threading a decidably elevated context | 122 | | Threading a decidably non-elevated context | 0 | | Threading a context of undecidable elevation | 102 | @@ -90,12 +90,12 @@ holds still. They are required to be HERE and to say WHEN they were true; their values are not compared. The reasoning, and the measurement behind it, are in `scripts/check-tenant-audit-census.mjs`. -Measured on 2026-10-07 at `e1171ca48`. +Measured on 2026-10-07 at `a93579f45`. | corpus scale (not enforced) | count | | :--- | ---: | | tracked non-test sources scanned | 612 | -| engine-shaped types recognised | 70 | +| engine-shaped types recognised | 69 | | declared objects in the registry | 116 | | same-named calls subtracted as non-engine | 160 | @@ -143,7 +143,6 @@ Measured on 2026-10-07 at `e1171ca48`. | `packages/plugins/plugin-auth/src/objectql-adapter.ts` | `delete` | `objectName` | undecidable | PROVABLY NONE | 5 | | `packages/plugins/plugin-auth/src/objectql-adapter.ts` | `insert` | `objectName` | undecidable | options unreadable | 2 | | `packages/plugins/plugin-auth/src/objectql-adapter.ts` | `update` | `objectName` | undecidable | options unreadable | 5 | -| `packages/plugins/plugin-auth/src/phone-sms-texts.ts` | `insert` | `sys_notification_template` | enabled | elevated | 1 | | `packages/plugins/plugin-auth/src/reconcile-membership.ts` | `insert` | `sys_member` | enabled | context, elevation undecidable | 1 | | `packages/plugins/plugin-auth/src/scim-connection-service.ts` | `insert` | `sys_scim_connection_credential` | enabled | PROVABLY NONE | 1 | | `packages/plugins/plugin-auth/src/session-tombstone.ts` | `update` | `objectName` | undecidable | options unreadable | 1 | diff --git a/packages/platform-objects/src/apps/translations/en.objects.generated.ts b/packages/platform-objects/src/apps/translations/en.objects.generated.ts index 27893e5789..8761a43162 100644 --- a/packages/platform-objects/src/apps/translations/en.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/en.objects.generated.ts @@ -2602,7 +2602,7 @@ export const enObjects: NonNullable = { }, is_system: { label: "System Template", - help: "Provided by a plugin / platform; tenants may edit but should not delete" + help: "Provided by a plugin / platform" }, variables_json: { label: "Variables (JSON)", @@ -2619,7 +2619,7 @@ export const enObjects: NonNullable = { }, customized: { label: "Customized", - help: "Set when an admin edits a package-declared template; boot seeding will no longer overwrite the row (a reworded password-reset mail survives redeploys). Meaningless on admin rows." + help: "Set on a package-declared template an admin edited before organization-level template editing closed; boot seeding never overwrites such a row. Nothing sets it any more. Meaningless on admin rows." }, created_at: { label: "Created At" diff --git a/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts index f1d2a51e00..371b48793e 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts @@ -2602,7 +2602,7 @@ export const esESObjects: NonNullable = { }, is_system: { label: "Plantilla del sistema", - help: "La proporciona un plugin o la plataforma; los tenants pueden editarla, pero no deberían eliminarla." + help: "La proporciona un plugin o la plataforma." }, variables_json: { label: "Variables (JSON)", @@ -2619,7 +2619,7 @@ export const esESObjects: NonNullable = { }, customized: { label: "Personalizada", - help: "Se establece cuando un administrador edita una plantilla declarada por un paquete; la siembra al arrancar ya no sobrescribirá la fila (un correo de restablecimiento de contraseña reformulado se conserva tras los redespliegues). No tiene significado en las filas de administrador." + help: "Marca una plantilla declarada por un paquete que un administrador editó antes de que se cerrara la edición de plantillas por organización; la siembra al arrancar nunca sobrescribe esa fila. Ya nada la establece. No tiene significado en las filas de administrador." }, created_at: { label: "Creado el" diff --git a/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts index b6ad2c71b6..24024be4d3 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts @@ -2602,7 +2602,7 @@ export const jaJPObjects: NonNullable = { }, is_system: { label: "システムテンプレート", - help: "プラグイン/プラットフォームが提供。テナントは編集可能ですが削除は推奨しません" + help: "プラグイン/プラットフォームが提供" }, variables_json: { label: "変数(JSON)", @@ -2619,7 +2619,7 @@ export const jaJPObjects: NonNullable = { }, customized: { label: "カスタマイズ済み", - help: "管理者がパッケージで宣言されたテンプレートを編集すると設定されます。以後、起動時のシードはこの行を上書きしません(文面を変えたパスワードリセットメールは再デプロイ後も残ります)。admin 行では意味を持ちません。" + help: "組織レベルのテンプレート編集が閉じられる前に管理者が編集した、パッケージで宣言されたテンプレートに設定されています。起動時のシードはこの行を上書きしません。現在はこれを設定する操作はありません。admin 行では意味を持ちません。" }, created_at: { label: "作成日時" diff --git a/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts index 873863ae94..5e96b3926a 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts @@ -2602,7 +2602,7 @@ export const zhCNObjects: NonNullable = { }, is_system: { label: "系统模板", - help: "由插件 / 平台提供;租户可编辑但不应删除" + help: "由插件 / 平台提供" }, variables_json: { label: "变量(JSON)", @@ -2619,7 +2619,7 @@ export const zhCNObjects: NonNullable = { }, customized: { label: "已自定义", - help: "管理员编辑包声明的模板时设置;启动种子将不再覆盖该记录(改写过的密码重置邮件在重新部署后得以保留)。对 admin 记录无意义。" + help: "标记在组织级模板编辑关闭之前被管理员编辑过的包声明模板;启动种子从不覆盖此类记录。此后不再有任何操作设置该标记。对 admin 记录无意义。" }, created_at: { label: "创建时间" diff --git a/packages/platform-objects/src/audit/sys-email-template.object.ts b/packages/platform-objects/src/audit/sys-email-template.object.ts index 7d7fb97131..2ea2fa6fec 100644 --- a/packages/platform-objects/src/audit/sys-email-template.object.ts +++ b/packages/platform-objects/src/audit/sys-email-template.object.ts @@ -18,8 +18,10 @@ import { ObjectSchema, Field } from '@objectstack/spec/data'; * `SendTemplateInput.locale` for the ladder of record. * * Authoring: built-in templates are seeded by `EmailServicePlugin` - * on `kernel:ready`; administrators may edit subject/body in Studio - * and tenants may overlay specific rows. + * on `kernel:ready`, and an `email_template` edited in Studio is + * projected into its row. An organization does not create or edit a + * row here: `@objectstack/plugin-email` refuses a non-system create or + * update (ADR-0131 D6). * * @namespace sys */ @@ -135,7 +137,7 @@ export const SysEmailTemplate = ObjectSchema.create({ required: false, defaultValue: false, readonly: true, - description: 'Provided by a plugin / platform; tenants may edit but should not delete', + description: 'Provided by a plugin / platform', group: 'Lifecycle', }), @@ -156,11 +158,12 @@ export const SysEmailTemplate = ObjectSchema.create({ // Mirrors sys_webhook (#3461) / sys_sharing_rule (#2909). `is_system` // remains the built-in-auth-template axis; these two track the DECLARED // metadata door: bootstrapDeclaredEmailTemplates seeds `package` rows and - // re-seeds them every boot, while an admin's edit stamps `customized` and - // freezes the row. Both are `readonly` — the engine strips them from - // non-system payloads, and only the seeder / stamp hook (isSystem) write - // them. Deliberately NOT a write gate: editing a template in Studio is a - // first-class admin action, it just has to be remembered. + // re-seeds them every boot, and skips a row marked `customized`. Both are + // `readonly` — the engine strips them from non-system payloads. Since + // ADR-0131 ruling C closed the organization door (plugin-email refuses a + // non-system create or update), nothing marks a row `customized` any more: + // the rows already marked are the population the v18 migration ceremony + // promotes to environment-level Studio templates. managed_by: Field.select( ['platform', 'package', 'admin'], { @@ -181,8 +184,9 @@ export const SysEmailTemplate = ObjectSchema.create({ readonly: true, defaultValue: false, description: - 'Set when an admin edits a package-declared template; boot seeding will no longer ' + - 'overwrite the row (a reworded password-reset mail survives redeploys). Meaningless on admin rows.', + 'Set on a package-declared template an admin edited before organization-level template ' + + 'editing closed; boot seeding never overwrites such a row. Nothing sets it any more. ' + + 'Meaningless on admin rows.', group: 'System', }), diff --git a/packages/plugins/plugin-auth/src/auth-manager.ts b/packages/plugins/plugin-auth/src/auth-manager.ts index 028c8fa55c..0139548b7a 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.ts @@ -131,9 +131,8 @@ import { } from './user-ban-write.js'; import { PHONE_SMS_TOPICS, - builtinPhoneSmsBody, interpolatePhoneSms, - loadPhoneSmsTemplateBody, + resolvePhoneSmsTemplateBody, } from './phone-sms-texts.js'; // commit 35e94c96b — the stored rung of the ruled locale ladder reuses the messaging // seam's normalizer rather than growing a second one. `normalizeRecipientLocale` @@ -5821,9 +5820,7 @@ export class AuthManager { storedLocale?: string, ): Promise { const locale = storedLocale ?? this.smsLocale; - const template = - (await loadPhoneSmsTemplateBody(this.getDataEngine(), topic, locale)) ?? - builtinPhoneSmsBody(topic, locale); + const template = await resolvePhoneSmsTemplateBody(this.getDataEngine(), topic, locale); return interpolatePhoneSms(template, data); } diff --git a/packages/plugins/plugin-auth/src/auth-plugin.ts b/packages/plugins/plugin-auth/src/auth-plugin.ts index 14ed794d45..8a87ca389d 100644 --- a/packages/plugins/plugin-auth/src/auth-plugin.ts +++ b/packages/plugins/plugin-auth/src/auth-plugin.ts @@ -986,18 +986,11 @@ export class AuthPlugin implements Plugin { // settings service is optional — keep the configured appName. } - // #2815 — seed the built-in bilingual auth SMS templates into - // sys_notification_template (insert-if-missing; tenant edits are - // never overwritten). Only meaningful when phone sign-in is on; - // the table may not exist yet on a fresh env (messaging provisions - // it at kernel:ready), so failures log-and-continue. - if (this.authManager.isPhoneNumberEnabled()) { - const engine = this.authManager.getDataEngine(); - if (engine) { - const { seedPhoneSmsTemplates } = await import('./phone-sms-texts.js'); - await seedPhoneSmsTemplates(engine, ctx.logger); - } - } + // The built-in auth SMS texts are NOT seeded into + // sys_notification_template: a rung with no row there renders the + // built-in text itself (`resolvePhoneSmsTemplateBody`), and a row an + // operator authored still wins (ADR-0131 — a row exists only when an + // organization authored it). } }); diff --git a/packages/plugins/plugin-auth/src/find-envelope-limb-removal.test.ts b/packages/plugins/plugin-auth/src/find-envelope-limb-removal.test.ts index a361e9db31..f1a1bd9998 100644 --- a/packages/plugins/plugin-auth/src/find-envelope-limb-removal.test.ts +++ b/packages/plugins/plugin-auth/src/find-envelope-limb-removal.test.ts @@ -56,7 +56,7 @@ import { authIdentityObjects } from './manifest.js'; import { withSystemReadContext } from './objectql-adapter.js'; import { probeHumanUsersPresence, probeSignInAccountsPresence } from './boot-sign-in-reachability.js'; import { decideDevAdminSeedGate } from './dev-admin-seed-gate.js'; -import { loadPhoneSmsTemplateBody, seedPhoneSmsTemplates } from './phone-sms-texts.js'; +import { resolvePhoneSmsTemplateBody } from './phone-sms-texts.js'; import { resolveDefaultOrgId } from './tenancy-service.js'; import { probeAccountIdentityCollisions } from './account-identity-preflight.js'; import { canonicalizeStoredMemberRoles } from './member-role-canonical.js'; @@ -407,16 +407,14 @@ describe('#15597 — the blocks driven through their real production entry point }); }); - it('phone-sms template load + seed read the bare array (B14, the `data` limb)', async () => { + it('phone-sms template read reads the bare array (B14, the `data` limb)', async () => { const engine = await bootEngine(); await seedAll(engine); - expect(await loadPhoneSmsTemplateBody(engine as never, 'otp', 'en')).toBe('code {{code}}'); - expect(await loadPhoneSmsTemplateBody(engine as never, 'nosuchtopic', 'en')).toBeNull(); - // The seeder's existence read is the second `rowsOf` call site: the row - // above is already present, so it must not be duplicated. - await seedPhoneSmsTemplates(engine as never); - const rows = await engine.find('sys_notification_template', { where: { topic: 'otp', channel: 'sms', locale: 'en' }, limit: 100 }, SYSTEM); - expect((rows as unknown[]).length).toBe(1); + expect(await resolvePhoneSmsTemplateBody(engine as never, 'otp', 'en')).toBe('code {{code}}'); + // No row and no built-in text for the topic: the built-in walk's empty + // floor, not a row invented from a misread envelope. (The seeder's + // existence read, this site's second `rowsOf` call, retired with the seed.) + expect(await resolvePhoneSmsTemplateBody(engine as never, 'nosuchtopic', 'en')).toBe(''); }); it('tenancy resolveDefaultOrgId reads the bare array (B7)', async () => { diff --git a/packages/plugins/plugin-auth/src/phone-sms-seed-retired.test.ts b/packages/plugins/plugin-auth/src/phone-sms-seed-retired.test.ts new file mode 100644 index 0000000000..547ba4a6c5 --- /dev/null +++ b/packages/plugins/plugin-auth/src/phone-sms-seed-retired.test.ts @@ -0,0 +1,239 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The auth SMS seed is retired (ADR-0131: a row exists only when an + * organization authored it; a notification template has no metadata type, so + * its seed retires and no type is added). + * + * 1. A fresh boot with phone sign-in on writes no `sys_notification_template` + * row. + * 2. Every built-in text renders byte for byte what the SEEDED store rendered, + * for every built-in topic and every recipient locale — and so does every + * store an operator already has, whatever rows it holds. + * 3. A row an operator authored still wins. + * + * The SEEDED store is reproduced by an ORACLE below: the retired + * `seedPhoneSmsTemplates` and the retired `loadPhoneSmsTemplateBody ?? + * builtinPhoneSmsBody` composition, transcribed from the last revision that + * shipped them. It is a frozen copy on purpose: it is the "before" every + * reading here is compared against, so it must not move when the module does. + */ + +import { describe, it, expect, vi } from 'vitest'; +import type { PluginContext } from '@objectstack/core'; +import { assertEngineFindOnePredicate, assertEngineUpdateDispatch } from '@objectstack/objectql'; +import { AuthPlugin } from './auth-plugin'; +import { + BUILTIN_PHONE_SMS_TEMPLATES, + PHONE_SMS_TOPICS, + builtinPhoneSmsBody, + interpolatePhoneSms, + phoneSmsLocaleChain, + resolvePhoneSmsTemplateBody, +} from './phone-sms-texts.js'; + +const TEMPLATE_OBJECT = 'sys_notification_template'; +const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] } as const; +const SECRET = 'test-secret-at-least-32-chars-long'; + +type Row = Record; + +/** + * A row store answering `find` by every key in `where` (plain equality), + * honouring `limit`. It REFUSES a combinator it does not implement rather than + * reading `$and` / `$or` as a field name (`check:where-matcher`). + */ +function matchesWhere(row: Row, where: Record = {}): boolean { + return Object.entries(where).every(([k, v]) => { + if (k.startsWith('$') || (v !== null && typeof v === 'object')) { + throw new Error(`store(): unsupported where clause on '${k}' — this double implements plain equality only`); + } + return row[k] === v; + }); +} + +function store(rows: Row[]) { + const all = rows.map((r) => ({ ...r })); + return { + all, + async find(_object: string, q: any) { + const hit = all.filter((r) => matchesWhere(r, q?.where)); + return typeof q?.limit === 'number' ? hit.slice(0, q.limit) : hit; + }, + async insert(_object: string, row: Row) { all.push({ ...row }); return row; }, + }; +} + +// ── The oracle: the retired seed, then the retired read ────────────────────── + +/** The retired `seedPhoneSmsTemplates`: insert a built-in row where NO row exists. */ +async function retiredSeed(engine: ReturnType): Promise { + for (const tpl of BUILTIN_PHONE_SMS_TEMPLATES) { + const existing = await engine.find(TEMPLATE_OBJECT, { + where: { topic: tpl.topic, channel: tpl.channel, locale: tpl.locale }, + limit: 1, + context: SYSTEM_CTX, + }); + if (existing.length > 0) continue; + await engine.insert(TEMPLATE_OBJECT, { ...tpl }); + } +} + +/** The retired `loadPhoneSmsTemplateBody(…) ?? builtinPhoneSmsBody(…)`. */ +async function retiredRead(engine: ReturnType, topic: string, locale: string | undefined): Promise { + for (const loc of phoneSmsLocaleChain(locale)) { + const result = await engine.find(TEMPLATE_OBJECT, { + where: { topic, channel: 'sms', locale: loc, is_active: true }, + limit: 1, + context: SYSTEM_CTX, + }); + const body = result[0]?.body; + if (typeof body === 'string' && body.trim()) return body; + } + return builtinPhoneSmsBody(topic, locale); +} + +/** What the seeded store sent for `rows`: seed them as a boot did, then read. */ +async function seededWorld(rows: Row[], topic: string, locale: string | undefined): Promise { + const engine = store(rows); + await retiredSeed(engine); + return retiredRead(engine, topic, locale); +} + +// ── The matrix ────────────────────────────────────────────────────────────── + +const TOPICS = Object.values(PHONE_SMS_TOPICS); +/** Every rung shape the chain takes: none, the two built-in tags, regioned, unbundled. */ +const LOCALES: Array = [undefined, 'en', 'en-US', 'zh', 'zh-CN', 'zh-TW', 'ja-JP']; + +/** One operator row state at one `(topic, locale)`. */ +type RowState = 'none' | 'active' | 'inactive' | 'blank'; +const STATES: RowState[] = ['none', 'active', 'inactive', 'blank']; + +function rowFor(topic: string, locale: string, state: RowState): Row[] { + if (state === 'none') return []; + return [{ + topic, channel: 'sms', locale, format: 'text', + is_active: state !== 'inactive', + body: state === 'blank' ? ' ' : `operator ${state} ${topic} ${locale} {{code}}`, + }]; +} + +/** Every combination of states over the given `(topic, locale)` slots. */ +function* storesOver(slots: Array<[string, string]>): Generator { + const pick = (i: number, acc: Row[]): Row[][] => + i === slots.length ? [acc] : STATES.flatMap((st) => pick(i + 1, [...acc, ...rowFor(slots[i][0], slots[i][1], st)])); + yield* pick(0, []); +} + +describe('the retired auth SMS seed', () => { + it('a fresh store renders every built-in text, at every locale, byte for byte as the seeded store did', async () => { + let compared = 0; + for (const topic of TOPICS) { + for (const locale of LOCALES) { + const now = await resolvePhoneSmsTemplateBody(store([]), topic, locale); + expect(now, `${topic} @ ${String(locale)}`).toBe(await seededWorld([], topic, locale)); + // …and that text IS the built-in one for the rung, rendered identically. + expect(now).toBe(builtinPhoneSmsBody(topic, locale)); + const data = { code: '482913', appName: 'Acme', minutes: 5, loginUrl: 'https://acme.test/_console/login' }; + expect(interpolatePhoneSms(now, data)).toBe(interpolatePhoneSms(await seededWorld([], topic, locale), data)); + compared += 1; + } + } + // Every built-in text is reached: both topics in both bundled languages. + const reached = new Set(); + for (const topic of TOPICS) for (const locale of LOCALES) reached.add(await resolvePhoneSmsTemplateBody(store([]), topic, locale)); + expect([...reached].sort()).toEqual(BUILTIN_PHONE_SMS_TEMPLATES.map((t) => t.body).sort()); + expect(compared).toBe(TOPICS.length * LOCALES.length); + }); + + it('every store an operator may already have renders exactly what it rendered seeded', async () => { + // Each OTP slot in four states — the two built-in tags and the regioned and + // unbundled tags a recipient can carry — with the invite topic's built-in + // slots too: 4^6 stores, every locale, both topics. + const slots: Array<[string, string]> = [ + [PHONE_SMS_TOPICS.otp, 'en'], [PHONE_SMS_TOPICS.otp, 'zh'], + [PHONE_SMS_TOPICS.otp, 'zh-CN'], [PHONE_SMS_TOPICS.otp, 'ja-JP'], + [PHONE_SMS_TOPICS.invite, 'en'], [PHONE_SMS_TOPICS.invite, 'zh'], + ]; + let compared = 0; + const diverged: string[] = []; + for (const rows of storesOver(slots)) { + for (const topic of TOPICS) { + for (const locale of LOCALES) { + const now = await resolvePhoneSmsTemplateBody(store(rows), topic, locale); + const before = await seededWorld(rows, topic, locale); + if (now !== before) diverged.push(`${topic} @ ${String(locale)} over ${JSON.stringify(rows)}: ${now} != ${before}`); + compared += 1; + } + } + } + expect(diverged.slice(0, 3)).toEqual([]); + expect(compared).toBe(4 ** slots.length * TOPICS.length * LOCALES.length); + }); + + it('a row an operator authored still wins', async () => { + const custom = '【定制】验证码 {{code}}'; + const rows = [{ topic: PHONE_SMS_TOPICS.otp, channel: 'sms', locale: 'zh', is_active: true, body: custom }]; + expect(await resolvePhoneSmsTemplateBody(store(rows), PHONE_SMS_TOPICS.otp, 'zh-CN')).toBe(custom); + expect(await resolvePhoneSmsTemplateBody(store(rows), PHONE_SMS_TOPICS.otp, 'zh')).toBe(custom); + // The other topic, and the other language, keep their built-in texts. + expect(await resolvePhoneSmsTemplateBody(store(rows), PHONE_SMS_TOPICS.invite, 'zh')).toBe( + builtinPhoneSmsBody(PHONE_SMS_TOPICS.invite, 'zh'), + ); + expect(await resolvePhoneSmsTemplateBody(store(rows), PHONE_SMS_TOPICS.otp, 'en')).toBe( + builtinPhoneSmsBody(PHONE_SMS_TOPICS.otp, 'en'), + ); + }); +}); + +describe('a fresh boot with phone sign-in on', () => { + it('writes no sys_notification_template row', async () => { + const inserted: Array<{ object: string; row: Row }> = []; + const engine = { + registerHook: vi.fn(), + unregisterHooksByPackage: vi.fn(() => 0), + count: vi.fn(async () => 0), + find: vi.fn(async () => []), + findOne: vi.fn(async (object: string, query: unknown = {}) => { + assertEngineFindOnePredicate(object, query as never); + return null; + }), + update: vi.fn(async (_object: string, doc: Record, options?: unknown) => { + assertEngineUpdateDispatch(doc, options as never); + return {}; + }), + insert: vi.fn(async (object: string, row: Row) => { inserted.push({ object, row }); return row; }), + }; + const hooks: Array<() => Promise> = []; + const ctx = { + registerService: vi.fn(), + getService: vi.fn((name: string) => { + // `data` is the engine AuthManager is handed (`getDataEngine()`), the + // one the retired seed wrote through; `objectql` the hooks resolve. + if (name === 'objectql' || name === 'data') return engine; + if (name === 'manifest') return { register: vi.fn() }; + return undefined; + }), + getServices: vi.fn(() => new Map()), + hook: vi.fn((name: string, handler: () => Promise) => { + if (name === 'kernel:ready') hooks.push(handler); + }), + trigger: vi.fn(), + logger: { info: vi.fn(), error: vi.fn(), warn: vi.fn(), debug: vi.fn() }, + getKernel: vi.fn(), + } as never as PluginContext; + + const plugin = new AuthPlugin({ secret: SECRET, baseUrl: 'http://localhost:3000', plugins: { phoneNumber: true } } as never); + await plugin.init(ctx); + await plugin.start(ctx); + expect(hooks.length).toBeGreaterThan(0); + // Every kernel:ready handler runs; one this file is not about may throw on + // the minimal engine, and that must not decide this case either way. + for (const h of hooks) { + try { await h(); } catch { /* not this test's subject */ } + } + + expect(inserted.filter((w) => w.object === TEMPLATE_OBJECT)).toEqual([]); + }); +}); diff --git a/packages/plugins/plugin-auth/src/phone-sms-texts.test.ts b/packages/plugins/plugin-auth/src/phone-sms-texts.test.ts index fed325c2c9..2fa456f05e 100644 --- a/packages/plugins/plugin-auth/src/phone-sms-texts.test.ts +++ b/packages/plugins/plugin-auth/src/phone-sms-texts.test.ts @@ -2,13 +2,11 @@ import { describe, it, expect, vi } from 'vitest'; import { - BUILTIN_PHONE_SMS_TEMPLATES, PHONE_SMS_TOPICS, builtinPhoneSmsBody, interpolatePhoneSms, - loadPhoneSmsTemplateBody, phoneSmsLocaleChain, - seedPhoneSmsTemplates, + resolvePhoneSmsTemplateBody, } from './phone-sms-texts.js'; describe('phoneSmsLocaleChain', () => { @@ -52,70 +50,59 @@ describe('interpolatePhoneSms', () => { }); }); -describe('loadPhoneSmsTemplateBody', () => { +describe('resolvePhoneSmsTemplateBody', () => { + /** + * Rows answered as the engine answers `find`: by every key in `where`, plain + * equality only. A combinator is REFUSED, never read as a field name + * (`check:where-matcher`). + */ const engineWith = (rows: Array>) => ({ find: vi.fn(async (_obj: string, q: any) => - rows.filter( - (r) => - r.topic === q.where.topic && - r.channel === q.where.channel && - r.locale === q.where.locale && - r.is_active === true, - ), + rows.filter((r) => Object.entries(q.where).every(([k, v]) => { + if (k.startsWith('$') || (v !== null && typeof v === 'object')) { + throw new Error(`engineWith(): unsupported where clause on '${k}'`); + } + return r[k] === v; + })).slice(0, q.limit ?? Infinity), ), - insert: vi.fn(), }); it('returns the tenant row for the exact locale', async () => { const engine = engineWith([ { topic: 'auth.phone_otp', channel: 'sms', locale: 'zh-CN', is_active: true, body: '自定义 {{code}}' }, ]); - await expect(loadPhoneSmsTemplateBody(engine, 'auth.phone_otp', 'zh-CN')).resolves.toBe('自定义 {{code}}'); + await expect(resolvePhoneSmsTemplateBody(engine, 'auth.phone_otp', 'zh-CN')).resolves.toBe('自定义 {{code}}'); }); - it('walks the locale chain (zh-CN → zh)', async () => { + it('walks the locale chain (zh-CN → zh) to a row', async () => { const engine = engineWith([ { topic: 'auth.phone_otp', channel: 'sms', locale: 'zh', is_active: true, body: 'zh 行 {{code}}' }, ]); - await expect(loadPhoneSmsTemplateBody(engine, 'auth.phone_otp', 'zh-CN')).resolves.toBe('zh 行 {{code}}'); + await expect(resolvePhoneSmsTemplateBody(engine, 'auth.phone_otp', 'zh-CN')).resolves.toBe('zh 行 {{code}}'); }); - it('yields null with no matching row, no engine, or a broken lookup', async () => { - await expect(loadPhoneSmsTemplateBody(engineWith([]), 'auth.phone_otp', 'zh')).resolves.toBeNull(); - await expect(loadPhoneSmsTemplateBody(undefined, 'auth.phone_otp', 'zh')).resolves.toBeNull(); - const broken = { find: vi.fn(async () => { throw new Error('no such table'); }), insert: vi.fn() }; - await expect(loadPhoneSmsTemplateBody(broken, 'auth.phone_otp', 'zh')).resolves.toBeNull(); + it('renders the built-in text at the first rung that has one and no row', async () => { + const zh = builtinPhoneSmsBody(PHONE_SMS_TOPICS.otp, 'zh'); + // An `en` row is NOT reached for a zh-CN recipient: the `zh` rung has a + // built-in text and no row, which is what the retired seed's `zh` row was. + const engine = engineWith([ + { topic: 'auth.phone_otp', channel: 'sms', locale: 'en', is_active: true, body: 'en row {{code}}' }, + ]); + await expect(resolvePhoneSmsTemplateBody(engine, 'auth.phone_otp', 'zh-CN')).resolves.toBe(zh); }); -}); -describe('seedPhoneSmsTemplates', () => { - it('inserts missing rows and never overwrites existing ones', async () => { - const existing = [ - { topic: 'auth.phone_otp', channel: 'sms', locale: 'zh', body: '租户定制', is_active: false }, - ]; - const inserted: Array> = []; - const engine = { - find: vi.fn(async (_obj: string, q: any) => - existing.filter( - (r) => r.topic === q.where.topic && r.channel === q.where.channel && r.locale === q.where.locale, - ), - ), - insert: vi.fn(async (_obj: string, row: any) => { inserted.push(row); return row; }), - }; - await seedPhoneSmsTemplates(engine); - // 4 built-ins, 1 already present (even deactivated!) → 3 inserts. - expect(inserted).toHaveLength(BUILTIN_PHONE_SMS_TEMPLATES.length - 1); - expect(inserted.some((r) => r.topic === 'auth.phone_otp' && r.locale === 'zh')).toBe(false); + it('passes a deactivated row\'s rung on, as the seeded store did', async () => { + const engine = engineWith([ + { topic: 'auth.phone_otp', channel: 'sms', locale: 'zh', is_active: false, body: '停用 {{code}}' }, + { topic: 'auth.phone_otp', channel: 'sms', locale: 'en', is_active: true, body: 'en row {{code}}' }, + ]); + await expect(resolvePhoneSmsTemplateBody(engine, 'auth.phone_otp', 'zh-CN')).resolves.toBe('en row {{code}}'); }); - it('isolates per-row failures (missing table) via the logger', async () => { - const warn = vi.fn(); - const engine = { - find: vi.fn(async () => { throw new Error('no such table'); }), - insert: vi.fn(), - }; - await seedPhoneSmsTemplates(engine, { warn }); - expect(warn).toHaveBeenCalledTimes(BUILTIN_PHONE_SMS_TEMPLATES.length); - expect(engine.insert).not.toHaveBeenCalled(); + it('falls back to the built-in walk with no engine, or a broken lookup', async () => { + const zh = builtinPhoneSmsBody(PHONE_SMS_TOPICS.otp, 'zh'); + await expect(resolvePhoneSmsTemplateBody(undefined, 'auth.phone_otp', 'zh')).resolves.toBe(zh); + const broken = { find: vi.fn(async () => { throw new Error('no such table'); }) }; + await expect(resolvePhoneSmsTemplateBody(broken, 'auth.phone_otp', 'zh')).resolves.toBe(zh); }); }); diff --git a/packages/plugins/plugin-auth/src/phone-sms-texts.ts b/packages/plugins/plugin-auth/src/phone-sms-texts.ts index 42a2818cab..cb983c7076 100644 --- a/packages/plugins/plugin-auth/src/phone-sms-texts.ts +++ b/packages/plugins/plugin-auth/src/phone-sms-texts.ts @@ -4,14 +4,23 @@ * Localised auth SMS texts (#2815). * * The phone OTP / invitation SMS bodies were hard-coded English (#2780). - * They now resolve in two layers: + * They resolve one locale rung at a time — see + * {@link resolvePhoneSmsTemplateBody}: * - * 1. **Tenant-customisable templates** — a `sys_notification_template` row - * for `(topic, channel:'sms', locale)`, the same object the messaging - * `sms` channel renders. Operators edit them in Setup; the built-in - * rows below are seeded once and never overwrite an existing row. - * 2. **Built-in fallback** — the bundled texts here (en + zh), used when - * no template row resolves (fresh env, missing table, exotic locale). + * 1. **A tenant template row** — a `sys_notification_template` row for + * `(topic, channel:'sms', locale)`, the same object the messaging `sms` + * channel renders. Operators author them in Setup; a row at a rung + * decides that rung. + * 2. **The built-in text at that rung** — the bundled texts here (en + zh), + * when no row exists at that locale. + * + * The built-in texts used to be SEEDED into `sys_notification_template` as + * rows on every boot with phone sign-in on (insert-if-missing). That seed is + * retired (ADR-0131: a row exists only when an organization authored it; a + * notification template has no metadata type, so its seed retires and no type + * is added). The per-rung walk renders byte for byte what the seeded store + * rendered — the seeder's rows were the built-in texts at the built-in + * locales — and a row an operator already has keeps winning. * * The recipient locale reaching this module is resolved by the caller * (`AuthManager.renderPhoneSmsBody`), and commit 35e94c96b gave the OTP send a rung @@ -55,8 +64,8 @@ export interface PhoneSmsTemplateRow { } /** - * Built-in texts, seeded as template rows and used directly as the render - * fallback. Holes use the same `{{ path }}` syntax as the messaging + * Built-in texts — the text a rung renders when no template row exists at its + * locale. Holes use the same `{{ path }}` syntax as the messaging * template renderer (service-messaging/template-renderer.ts). * * The OTP text is deliberately purpose-neutral (no "sign-in" vs "reset" @@ -145,68 +154,66 @@ export function builtinPhoneSmsBody(topic: string, locale: string | undefined): return ''; } -/** Minimal engine surface the loader/seeder needs. */ +/** Minimal engine surface the template read needs. */ export interface PhoneSmsTemplateEngine { find(objectName: string, query?: unknown): Promise>>; - insert(objectName: string, data: unknown, options?: unknown): Promise; } const TEMPLATE_OBJECT = 'sys_notification_template'; const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] } as const; +/** The first stored row matching `where`, or `undefined`. */ +async function firstTemplateRow( + engine: PhoneSmsTemplateEngine, + where: Record, +): Promise | undefined> { + const result = await engine.find(TEMPLATE_OBJECT, { where, limit: 1, context: SYSTEM_CTX }); + return result[0]; +} + /** - * Load the tenant's template body for `(topic, 'sms', locale chain)`. - * Best-effort: any lookup error (missing table, no engine) yields `null` - * so the caller falls back to the built-in text — a template outage must - * never block an OTP send. + * The template body to send for `(topic, locale)` — never empty for a built-in + * topic, and never blocked by a template outage. + * + * Walks {@link phoneSmsLocaleChain} one rung at a time: + * + * 1. an ACTIVE row at that locale with a non-blank body → its body; + * 2. NO row at all at that locale → the built-in text at that locale, when + * one exists; + * 3. otherwise (a deactivated or blank row there, or no built-in text for that + * locale) → the next rung. + * + * If no rung answers, the built-in walk ({@link builtinPhoneSmsBody}) is the + * floor, as it is when the template read fails (missing table, no engine). + * + * This is exactly what the retired boot seed rendered. That seed inserted the + * built-in text at every built-in `(topic, locale)` that held NO row, active or + * not, and the read then took the first active, non-blank row along the chain, + * with the built-in walk as its floor. So "no row at this locale" read the + * built-in text there, a deactivated or blank row passed the rung on, and a row + * an operator wrote wins at its rung. `phone-sms-seed-retired.test.ts` holds + * the two equal against the seeded store, for every built-in text and locale. + * + * Best-effort: a failed template read yields the built-in text — a template + * outage must never block an OTP send. */ -export async function loadPhoneSmsTemplateBody( +export async function resolvePhoneSmsTemplateBody( engine: PhoneSmsTemplateEngine | undefined, topic: string, locale: string | undefined, -): Promise { - if (!engine) return null; - for (const loc of phoneSmsLocaleChain(locale)) { - try { - const result = await engine.find(TEMPLATE_OBJECT, { - where: { topic, channel: 'sms', locale: loc, is_active: true }, - limit: 1, - context: SYSTEM_CTX, - }); - const row = result[0]; - const body = row?.body; +): Promise { + if (!engine) return builtinPhoneSmsBody(topic, locale); + try { + for (const loc of phoneSmsLocaleChain(locale)) { + const where = { topic, channel: 'sms', locale: loc }; + const active = await firstTemplateRow(engine, { ...where, is_active: true }); + const body = active?.body; if (typeof body === 'string' && body.trim()) return body; - } catch { - return null; // best-effort — fall back to the built-in text - } - } - return null; -} - -/** - * Seed the built-in rows into `sys_notification_template`, one per - * `(topic, 'sms', locale)`, **only when absent** — a tenant-customised (or - * deactivated) row is never overwritten. Per-row failures are isolated; - * the table may not exist yet on a fresh env (messaging provisions it at - * kernel:ready), so callers log-and-continue. - */ -export async function seedPhoneSmsTemplates( - engine: PhoneSmsTemplateEngine, - logger?: { warn(msg: string): void }, -): Promise { - for (const tpl of BUILTIN_PHONE_SMS_TEMPLATES) { - try { - const existing = await engine.find(TEMPLATE_OBJECT, { - where: { topic: tpl.topic, channel: tpl.channel, locale: tpl.locale }, - limit: 1, - context: SYSTEM_CTX, - }); - if (existing.length > 0) continue; - await engine.insert(TEMPLATE_OBJECT, { ...tpl }, { context: SYSTEM_CTX }); - } catch (err) { - logger?.warn( - `[AuthPlugin] phone SMS template seed failed for ${tpl.topic}/${tpl.locale}: ${(err as Error)?.message ?? err}`, - ); + const builtin = BUILTIN_PHONE_SMS_TEMPLATES.find((t) => t.topic === topic && t.locale === loc); + if (builtin && !(await firstTemplateRow(engine, where))) return builtin.body; } + } catch { + // best-effort — fall back to the built-in text } + return builtinPhoneSmsBody(topic, locale); } diff --git a/packages/plugins/plugin-email/src/bootstrap-declared-email-templates.test.ts b/packages/plugins/plugin-email/src/bootstrap-declared-email-templates.test.ts index c51775ad49..e03e64c60a 100644 --- a/packages/plugins/plugin-email/src/bootstrap-declared-email-templates.test.ts +++ b/packages/plugins/plugin-email/src/bootstrap-declared-email-templates.test.ts @@ -19,10 +19,9 @@ import { SWEEP_NAMES_PER_READ, SWEEP_ROWS_PER_READ, } from './bootstrap-declared-email-templates.js'; -import { bindEmailTemplateProvenanceStamp } from './email-template-provenance.js'; // --------------------------------------------------------------------------- -// Fakes — mirrors the ObjectQL surface the bridge and the stamp hook touch. +// Fakes — mirrors the ObjectQL surface the bridge touches. // --------------------------------------------------------------------------- interface HookEntry { @@ -110,7 +109,6 @@ class FakeEngine { } } -const ADMIN_CTX = { isSystem: false, positions: [], permissions: [] }; const TABLE = 'sys_email_template'; function declaredTemplate(over: Record = {}): any { @@ -197,12 +195,11 @@ describe('bootstrapDeclaredEmailTemplates', () => { const engine = new FakeEngine({ declared: { email_template: [declaredTemplate()] } }); await bootstrapDeclaredEmailTemplates(engine as any, undefined); - // Admin edits the seeded row through a normal (non-system) write; the - // provenance stamp freezes it. - bindEmailTemplateProvenanceStamp(engine as any); - const seeded = rowsOf(engine)[0]; - await engine.update(TABLE, { id: seeded.id, subject: 'Admin wording' }, { context: ADMIN_CTX }); - expect(rowsOf(engine)[0].customized).toBe(true); + // A row an organization edited BEFORE the sys_email_template door closed + // (ADR-0131 ruling C): the retired provenance stamp left it marked + // `customized`, and nothing marks a row any more — written here as the + // stored state the v18 migration ceremony will find and promote. + Object.assign(rowsOf(engine)[0], { subject: 'Admin wording', customized: true }); (engine as any).declared = { email_template: [declaredTemplate({ subject: 'Redeploy wording' })], diff --git a/packages/plugins/plugin-email/src/email-plugin.template-runtime-write.test.ts b/packages/plugins/plugin-email/src/email-plugin.template-runtime-write.test.ts index 5f0cd9242f..cd20b00d8b 100644 --- a/packages/plugins/plugin-email/src/email-plugin.template-runtime-write.test.ts +++ b/packages/plugins/plugin-email/src/email-plugin.template-runtime-write.test.ts @@ -37,8 +37,8 @@ type AnyRecord = Record; // ── doubles ──────────────────────────────────────────────────────────────── /** - * Row store with the slice of ObjectQL the template bridge and the provenance - * stamp touch. `update` routes through `assertEngineUpdateDispatch` so this + * Row store with the slice of ObjectQL the template bridge and the organization + * door touch. `update` routes through `assertEngineUpdateDispatch` so this * double cannot accept a dispatch shape the real engine would refuse. */ function fakeEngine(seed: AnyRecord[] = []) { @@ -68,7 +68,7 @@ function fakeEngine(seed: AnyRecord[] = []) { if (target) Object.assign(target, data); return { affected: target ? 1 : 0 }; }, - registerHook() { /* provenance stamp */ }, + registerHook() { /* organization door */ }, unregisterHooksByPackage() { return 0; }, }; } diff --git a/packages/plugins/plugin-email/src/email-plugin.ts b/packages/plugins/plugin-email/src/email-plugin.ts index 348e0ef5c8..6c3041ff78 100644 --- a/packages/plugins/plugin-email/src/email-plugin.ts +++ b/packages/plugins/plugin-email/src/email-plugin.ts @@ -42,10 +42,7 @@ import { mapTemplateToRow, type EffectiveEmailTemplateSources, } from './bootstrap-declared-email-templates.js'; -import { - bindEmailTemplateProvenanceStamp, - unbindEmailTemplateProvenanceStamp, -} from './email-template-provenance.js'; +import { bindEmailTemplateDoor, unbindEmailTemplateDoor } from './email-template-door.js'; import { sweepStrandedOutbox, type OutboxSweepResult } from './outbox-sweep.js'; import { readInternalHeadersJson } from './internal-header-readback.js'; import { @@ -324,7 +321,7 @@ export class EmailServicePlugin implements Plugin { private readonly options: EmailServicePluginOptions; private service?: EmailService; - /** Engine carrying the template provenance hook — unbound in dispose(). */ + /** Engine carrying the closed `sys_email_template` door — unbound in destroy(). */ private boundEngine?: IDataEngine; /** Live `email_template` metadata subscription — detached in dispose(). */ private unsubscribeTemplates?: () => void; @@ -340,7 +337,7 @@ export class EmailServicePlugin implements Plugin { * by the materializer because the projector seam has no unregister verb: a * disposed plugin's projector stays in the protocol's per-type slot, and * without this it would keep writing `sys_email_template` rows through an - * engine whose provenance hook has already been unbound. + * engine whose organization door has already been unbound. */ private templateBridgeArmed = false; /** SMTP transport currently in use, if any — closed in dispose(). */ @@ -1036,8 +1033,9 @@ export class EmailServicePlugin implements Plugin { } /** - * [#4509] Materialize declared `email_template` metadata, bind the provenance - * stamp, and keep the rows live for runtime authoring. + * [#4509] Materialize declared `email_template` metadata, close the + * organization door on `sys_email_template`, and keep the rows live for + * runtime authoring. * * `email_template` is `allowRuntimeCreate: true` (unlike `webhook`), so a * boot-only sweep would leave a Studio save inert until the next restart — @@ -1081,12 +1079,20 @@ export class EmailServicePlugin implements Plugin { * lookup. */ private async bootDeclaredTemplates(ctx: PluginContext, engine: IDataEngine): Promise { - // Bind the provenance stamp so an admin edit freezes a seeded row. + // ADR-0131 D6, ruling C on §6 Q1: an organization does not create or edit + // a template row. Only system-context writes reach the table — the seeds, + // the boot sweep and the live projector below — see email-template-door.ts. + // This replaced the provenance stamp, which marked an organization's edit + // `customized` instead of refusing it. this.boundEngine = engine; this.templateBridgeArmed = true; - try { bindEmailTemplateProvenanceStamp(engine as any, ctx.logger as any); } + try { bindEmailTemplateDoor(engine as any, ctx.logger as any); } catch (err: any) { - ctx.logger.warn('EmailServicePlugin: template provenance stamp not bound: ' + (err?.message ?? err)); + ctx.logger.error( + 'EmailServicePlugin: the sys_email_template organization door was NOT closed — an organization\'s ' + + 'create and update of a template row are accepted for the life of this process, while every ' + + 'template it sends still comes from these rows. Cause: ' + (err?.message ?? err), + ); } let metadataService: IMetadataService | undefined; @@ -1361,7 +1367,7 @@ export class EmailServicePlugin implements Plugin { * `LiteKernel.destroy()` walk the plugins in reverse calling * `plugin.destroy()` — so after `await kernel.shutdown()` had RESOLVED, the * two metadata subscriptions were still live, the SMTP transport was still - * open and the provenance hook was still bound to the engine. `dispose()` + * open and the template hook was still bound to the engine. `dispose()` * had exactly ONE caller in the whole repo, a test in this package; the * kernel was never one of them. * @@ -1379,7 +1385,7 @@ export class EmailServicePlugin implements Plugin { this.liveSmtp = undefined; } if (this.boundEngine) { - try { unbindEmailTemplateProvenanceStamp(this.boundEngine as any); } catch { /* best effort */ } + try { unbindEmailTemplateDoor(this.boundEngine as any); } catch { /* best effort */ } this.boundEngine = undefined; } } diff --git a/packages/plugins/plugin-email/src/email-template-door.test.ts b/packages/plugins/plugin-email/src/email-template-door.test.ts new file mode 100644 index 0000000000..ac459b6d54 --- /dev/null +++ b/packages/plugins/plugin-email/src/email-template-door.test.ts @@ -0,0 +1,237 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The closed `sys_email_template` organization door (ADR-0131 D6, ruling C on + * ADR-0131 §6 Q1), pinned against the REAL engine. + * + * 1. An organization's create, update and predicate update are refused with + * `PERMISSION_DENIED` / 403, the refusal names the closed door, and nothing + * reaches the driver. + * 2. A system-context write still passes: the seeds, the boot sweep, the live + * projector of a Studio save, and the v18 migration ceremony's promotion. + * 3. No update marks a row `customized` any more. The provenance stamp that + * did is retired, and with the door closed no non-system update reaches the + * engine's write at all. + * + * ⚠️ The engine half resolves through `@objectstack/objectql`'s `exports` to + * `dist/` (this package aliases no objectql entry; the ledger in + * `scripts/check-test-source-alias.mjs` records that). The SUBJECT - + * `./email-template-door.js` - is a relative import read from source, which is + * what an ablation of the door mutates. + */ + +import { describe, it, expect } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { bindEmailTemplateDoor, unbindEmailTemplateDoor } from './email-template-door.js'; + +const OBJECT = 'sys_email_template'; +const silentLogger = { debug() {}, info() {}, warn() {}, error() {} }; + +/** An organization admin through the data door: a caller, not system-elevated. */ +const ORG_CTX = { userId: 'usr_admin', tenantId: 'org_default', positions: [], permissions: [] }; +/** The platform's own writers (seeds, sweep, projector, promotion). */ +const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] }; + +/** Minimal in-memory driver: what reached it is what the door let through. */ +function makeStubDriver(): any { + const store = new Map>(); + const matches = (row: any, where: any): boolean => { + if (!where || typeof where !== 'object') return true; + for (const [k, v] of Object.entries(where)) { + if (k.startsWith('$')) continue; + if (v && typeof v === 'object' && '$in' in (v as any)) { + if (!(v as any).$in.includes(row[k])) return false; + continue; + } + const expected = v && typeof v === 'object' && '$eq' in (v as any) ? (v as any).$eq : v; + if ((row[k] ?? null) !== (expected ?? null)) return false; + } + return true; + }; + const d: any = { + name: 'memory', version: '0.0.0', supports: {}, + store, + /** One entry per write that reached the driver. */ + writes: [] as Array<{ op: string; data: Record }>, + async connect() {}, async disconnect() {}, async checkHealth() { return true; }, + async execute() { return null; }, async syncSchema() {}, + async find(_o: string, ast: any) { + const rows = [...store.values()].filter((r) => matches(r, ast?.where)); + // Hold the caller's bound, AFTER the filter and by PRESENCE + // (`check:objectql-double-limit`). + return typeof ast?.limit === 'number' ? rows.slice(0, ast.limit) : rows; + }, + async findOne(_o: string, ast: any) { + for (const r of store.values()) if (matches(r, ast?.where)) return r; + return null; + }, + async create(_o: string, data: Record) { + d.writes.push({ op: 'create', data: { ...data } }); + const row = { ...data }; store.set(String(row.id), row); return row; + }, + async update(_o: string, id: string, data: Record) { + d.writes.push({ op: 'update', data: { ...data } }); + const cur = store.get(id); if (!cur) return null; + const u = { ...cur, ...data, id }; store.set(id, u); return u; + }, + async upsert(o: string, data: any) { return this.create(o, data); }, + async delete(_o: string, id: string) { return store.delete(id); }, + async count(_o: string, ast: any) { return [...store.values()].filter((r) => matches(r, ast?.where)).length; }, + async bulkCreate(o: string, rows: any[]) { return Promise.all(rows.map((r) => this.create(o, r))); }, + async bulkUpdate() { return []; }, async bulkDelete() {}, + async updateMany(_o: string, ast: any, data: Record) { + d.writes.push({ op: 'updateMany', data: { ...data } }); + const rows = [...store.values()].filter((r) => matches(r, ast?.where)); + for (const r of rows) store.set(String(r.id), { ...r, ...data, id: r.id }); + return rows.length; + }, + async deleteMany() { return 0; }, + async beginTransaction() { return { commit: async () => {}, rollback: async () => {} }; }, + async commit() {}, async rollback() {}, + }; + return d; +} + +const text = (name: string) => ({ name, label: name, type: 'text' as const }); + +async function boot() { + const engine: any = new ObjectQL(); + const driver = makeStubDriver(); + engine.registerDriver(driver, true); + await engine.init(); + engine.registry.registerObject({ + name: OBJECT, label: OBJECT, + fields: { + id: { ...text('id'), primaryKey: true }, + name: text('name'), + locale: text('locale'), + subject: text('subject'), + managed_by: text('managed_by'), + customized: { name: 'customized', label: 'customized', type: 'boolean' as const }, + }, + }); + bindEmailTemplateDoor(engine, silentLogger, OBJECT); + return { engine, driver }; +} + +/** A package-seeded and a platform-seeded row, as the boot seeders leave them. */ +const SEEDED = [ + { id: 'a', name: 'auth.password_reset', locale: 'en-US', subject: 'Reset', managed_by: 'package', customized: false }, + { id: 'b', name: 'auth.verify_email', locale: 'en-US', subject: 'Verify', managed_by: 'platform', customized: false }, +]; + +async function bootSeeded() { + const booted = await boot(); + await booted.engine.insert(OBJECT, SEEDED.map((r) => ({ ...r })), { context: SYSTEM_CTX }); + booted.driver.writes.length = 0; + return booted; +} + +/** The engine's answer to a write, as a value — a rejection is read by FIELD. */ +const settle = (p: Promise) => p.then((value) => ({ ok: true as const, value }), (err: any) => ({ ok: false as const, err })); + +const rows = (driver: any) => [...driver.store.values()].map((r: any) => ({ id: r.id, subject: r.subject, customized: r.customized })); + +/* ── 1. the organization door is closed ────────────────────────────────── */ + +describe('the sys_email_template organization door is closed', () => { + it('refuses an organization\'s create with PERMISSION_DENIED / 403 naming the door, and writes nothing', async () => { + const { engine, driver } = await boot(); + + const out = await settle(engine.insert(OBJECT, { + id: 'etpl_org', name: 'auth.password_reset', locale: 'en-US', subject: 'Ours', + }, { context: ORG_CTX })); + + expect(out.ok).toBe(false); + const err = (out as { err: any }).err; + expect(err.code).toBe('PERMISSION_DENIED'); + expect(err.status).toBe(403); + // The door is NAMED (ADR-0123 D4) — the one sentence a reader acts on. + expect(String(err.message)).toMatch(/^PERMISSION_DENIED: sys_email_template is closed to organization writes/); + expect(driver.writes).toEqual([]); + expect(driver.store.size).toBe(0); + }); + + it('refuses an organization\'s update by id, and the row keeps its bytes', async () => { + const { engine, driver } = await bootSeeded(); + + const out = await settle(engine.update(OBJECT, { id: 'a', subject: 'Reworded by the organization' }, { context: ORG_CTX })); + + expect(out.ok).toBe(false); + const err = (out as { err: any }).err; + expect([err.code, err.status]).toEqual(['PERMISSION_DENIED', 403]); + expect(String(err.message)).toMatch(/^PERMISSION_DENIED: sys_email_template is closed to organization writes/); + expect(driver.writes).toEqual([]); + expect(rows(driver)).toEqual([ + { id: 'a', subject: 'Reset', customized: false }, + { id: 'b', subject: 'Verify', customized: false }, + ]); + }); + + it('refuses an organization\'s predicate update, per matched row, and no SET clause reaches the driver', async () => { + const { engine, driver } = await bootSeeded(); + + const out = await settle(engine.update(OBJECT, { subject: 'Bulk edit' }, { + multi: true, where: { id: { $in: ['a', 'b'] } }, context: ORG_CTX, + } as any)); + + expect(out.ok).toBe(false); + expect([(out as { err: any }).err.code, (out as { err: any }).err.status]).toEqual(['PERMISSION_DENIED', 403]); + expect(driver.writes).toEqual([]); + expect(rows(driver).map((r) => r.subject)).toEqual(['Reset', 'Verify']); + }); +}); + +/* ── 2. system writes pass ─────────────────────────────────────────────── */ + +describe('a system-context write passes the closed door', () => { + it('creates and updates a template row (the seeds, the sweep, the projector, the promotion)', async () => { + const { engine, driver } = await boot(); + + await engine.insert(OBJECT, { + id: 'etpl_sys', name: 'auth.magic_link', locale: 'en-US', subject: 'Sign in', managed_by: 'package', customized: false, + }, { context: SYSTEM_CTX }); + await engine.update(OBJECT, { id: 'etpl_sys', subject: 'Projected from a Studio save' }, { context: SYSTEM_CTX }); + + expect(rows(driver)).toEqual([{ id: 'etpl_sys', subject: 'Projected from a Studio save', customized: false }]); + }); + + it('lets a write with no caller at all through — not an organization\'s write', async () => { + const { engine, driver } = await boot(); + + await engine.insert(OBJECT, { id: 'etpl_nc', name: 'n', locale: 'en-US', subject: 'No caller' }); + + expect(rows(driver)).toEqual([{ id: 'etpl_nc', subject: 'No caller', customized: undefined }]); + }); + + it('releases the door on unbind', async () => { + const { engine, driver } = await boot(); + unbindEmailTemplateDoor(engine); + + await engine.insert(OBJECT, { id: 'etpl_after', name: 'n', locale: 'en-US', subject: 'After teardown' }, { context: ORG_CTX }); + + expect(driver.store.size).toBe(1); + }); +}); + +/* ── 3. no update marks a row customized ───────────────────────────────── */ + +describe('no update marks a row `customized` any more', () => { + it('leaves `customized` false after an organization\'s refused edit AND after a system edit of a seeded row', async () => { + const { engine, driver } = await bootSeeded(); + + // The edit the retired provenance stamp used to mark: refused at the door. + await settle(engine.update(OBJECT, { id: 'a', subject: 'Org wording' }, { context: ORG_CTX })); + await settle(engine.update(OBJECT, { subject: 'Org bulk wording' }, { + multi: true, where: { id: { $in: ['a', 'b'] } }, context: ORG_CTX, + } as any)); + // The edit the platform makes: never marked, before or after. + await engine.update(OBJECT, { id: 'b', subject: 'Re-seeded' }, { context: SYSTEM_CTX }); + + expect(rows(driver)).toEqual([ + { id: 'a', subject: 'Reset', customized: false }, + { id: 'b', subject: 'Re-seeded', customized: false }, + ]); + expect(driver.writes.flatMap((w: any) => Object.keys(w.data))).not.toContain('customized'); + }); +}); diff --git a/packages/plugins/plugin-email/src/email-template-door.ts b/packages/plugins/plugin-email/src/email-template-door.ts new file mode 100644 index 0000000000..bdd2204e0b --- /dev/null +++ b/packages/plugins/plugin-email/src/email-template-door.ts @@ -0,0 +1,120 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The `sys_email_template` organization door, CLOSED (ADR-0131 D6; ruling C on + * ADR-0131 §6 Q1). + * + * ## What it refuses + * + * An organization's create and update of a `sys_email_template` row: every + * engine `insert` / `update` whose execution context names a caller and is not + * system-elevated. That is the generic data door (`POST` / `PATCH + * /api/v1/data/sys_email_template`, the Studio record editor behind it, batch + * and import routes, scripts and flows running as a user or a service + * principal), because every one of them reaches the engine with the caller's + * context. The refusal is `PERMISSION_DENIED` / 403 — the ADR-0112 catalog code, + * the same code and status ADR-0123 D2 put on a permission-class refusal — and + * its message names the closed door and the door that stays open (ADR-0123 D4: + * a refusal that cannot be told apart from an unrelated denial is a wall nobody + * can act on). + * + * ## What passes + * + * - **System-context writes** (`isSystem: true`): the built-in seed, the + * declared-template boot sweep, the live projector that writes a Studio save + * of an `email_template` into its sending row, and the v18 migration + * ceremony's promotion. Studio editing of a template is the METADATA door + * (`PUT /api/v1/meta/email_template/:name`), which stays open; its projection + * into this table is a system write. + * - **A write with no caller at all** — an engine call made with no execution + * context, whose hook session is `undefined` ("no caller", never "an + * anonymous caller"; see the engine's `buildSession`). It is not an + * organization's write, and the scope of this refusal is that and no wider, + * the same scope ADR-0123 D2 draws for its own write refusal. + * - **Delete.** It places nothing and is not part of this door; a row an + * organization customized before the door closed is the v18 migration + * ceremony's to promote (ruling C), never this module's to remove. + * + * ## What it replaced + * + * The provenance stamp (`email-template-provenance.ts`, retired here) marked a + * package- or platform-seeded row `customized: true` when a non-system caller + * updated it, so the boot seeders would skip that row from then on. With this + * door closed no non-system update reaches the engine's write, so there is + * nothing left to stamp: rows already marked keep their mark, and nothing marks + * a row again. Both hooks sat on the same seat — `beforeUpdate` on this object — + * which is why the stamp's own "deliberately NOT a write gate" rationale is the + * one this ruling reversed. + */ + +interface MinimalEngine { + registerHook(event: string, handler: (ctx: any) => any, options?: Record): void; + unregisterHooksByPackage(packageId: string): number; +} + +interface MinimalLogger { + info?: (msg: string, meta?: Record) => void; +} + +/** Package id the door's hooks are registered under — unbound by it on teardown. */ +export const EMAIL_TEMPLATE_DOOR_PACKAGE = 'plugin-email:template-organization-door'; + +/** The verb a refused write is named by in its message. */ +type DoorVerb = 'create' | 'edit'; + +/** + * The refusal: `PERMISSION_DENIED` / 403, naming the closed door, why it is + * closed and the door that stays open. + */ +export function emailTemplateDoorRefusal(object: string, verb: DoorVerb): Error { + const err: any = new Error( + `PERMISSION_DENIED: ${object} is closed to organization writes — an organization cannot ${verb} an ` + + 'email template row. Email templates are not overridden per organization: the rows are written by ' + + 'the platform alone, from the built-in templates and the email_template metadata. To change what a ' + + 'template sends, edit the email template in Studio.', + ); + err.code = 'PERMISSION_DENIED'; + err.status = 403; + err.object = object; + return err; +} + +/** + * A write an organization makes: the hook session names a caller and is not + * system-elevated. `undefined` is the engine's "no caller" — see the module doc. + */ +function isOrganizationWrite(session: unknown): boolean { + if (!session || typeof session !== 'object') return false; + return (session as Record).isSystem !== true; +} + +/** + * Bind the closed door on `object`'s `beforeInsert` and `beforeUpdate`. + * Re-binding replaces the previous binding rather than stacking a second one. + */ +export function bindEmailTemplateDoor( + engine: MinimalEngine, + logger?: MinimalLogger, + object = 'sys_email_template', +): void { + if (typeof engine?.registerHook !== 'function') return; + if (typeof engine.unregisterHooksByPackage === 'function') { + engine.unregisterHooksByPackage(EMAIL_TEMPLATE_DOOR_PACKAGE); + } + const guard = (verb: DoorVerb) => async (ctx: any) => { + if (isOrganizationWrite(ctx?.session)) throw emailTemplateDoorRefusal(object, verb); + }; + // Priority 10 — ahead of the default-100 hooks, so a refused write runs + // nothing else first (the identity write guard's slot, for the same reason). + const options = { object, packageId: EMAIL_TEMPLATE_DOOR_PACKAGE, priority: 10 }; + engine.registerHook('beforeInsert', guard('create'), options); + engine.registerHook('beforeUpdate', guard('edit'), options); + logger?.info?.('[email] sys_email_template organization door closed (system writes only)'); +} + +/** Remove the door's hooks from `engine`. */ +export function unbindEmailTemplateDoor(engine: MinimalEngine): void { + if (typeof engine?.unregisterHooksByPackage === 'function') { + engine.unregisterHooksByPackage(EMAIL_TEMPLATE_DOOR_PACKAGE); + } +} diff --git a/packages/plugins/plugin-email/src/email-template-provenance.per-row.test.ts b/packages/plugins/plugin-email/src/email-template-provenance.per-row.test.ts deleted file mode 100644 index d270c0e392..0000000000 --- a/packages/plugins/plugin-email/src/email-template-provenance.per-row.test.ts +++ /dev/null @@ -1,250 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * [#15302] The `sys_email_template` provenance stamp on a PREDICATE (`multi: true`) - * update, pinned against the REAL engine. - * - * This hook used to carry two comments that were assertions about runtime - * behaviour, and runtime measurement falsified both: - * - * 1. "multi-row updates (no single `input.id`) are not stamped" - false since - * #6966. Per-row `before*` dispatch binds `ctx.input.id` on EVERY context, - * so `if (!id) return` no longer detected a bulk write and the hook ran - * once per matched row regardless. - * 2. "`previous` is not resolved before beforeUpdate hooks run" - false since - * #5574 / #5846. The engine binds `previous` before dispatching - * `beforeUpdate` on both write shapes, so the hook's own `engine.find` was - * a second read of a row the engine had just read - once PER MATCHED ROW - * on a predicate write. - * - * A comment cannot be pinned, so what is pinned here is the behaviour each - * comment was wrong about. §3 is the read count, measured with a control that - * fires rather than asserted. - * - * ⚠️ The engine half of this suite resolves through `@objectstack/objectql`'s - * `exports` to `dist/` (this package aliases no objectql entry; the ledger in - * `scripts/check-test-source-alias.mjs` records that), so a stale objectql - * build makes these readings about the built artifact. The SUBJECT - - * `./email-template-provenance.js` - is a relative import read from source, which is what an - * ablation of this file's fix mutates. - */ - -import { describe, it, expect } from 'vitest'; -import { ObjectQL } from '@objectstack/objectql'; -import { bindEmailTemplateProvenanceStamp } from './email-template-provenance.js'; - -const OBJECT = 'sys_email_template'; -const silentLogger = { debug() {}, info() {}, warn() {}, error() {} }; - -/** Minimal in-memory driver. `findCalls` is the instrument §3 reads. */ -function makeStubDriver(): any { - const store = new Map>(); - const matches = (row: any, where: any): boolean => { - if (!where || typeof where !== 'object') return true; - for (const [k, v] of Object.entries(where)) { - if (k.startsWith('$')) continue; - const e: any = v && typeof v === 'object' && '$in' in (v as any) ? undefined : v; - if (v && typeof v === 'object' && '$in' in (v as any)) { - if (!(v as any).$in.includes(row[k])) return false; - continue; - } - const expected = e && typeof e === 'object' && '$eq' in (e as any) ? (e as any).$eq : e; - if ((row[k] ?? null) !== (expected ?? null)) return false; - } - return true; - }; - const d: any = { - name: 'memory', version: '0.0.0', supports: {}, - store, - /** Every `find` the engine (or a hook, through `engine.find`) issues. */ - findCalls: [] as unknown[], - /** One entry per `updateMany` - the ONE `SET` clause N rows share (D3). */ - updateManyPayloads: [] as Record[], - async connect() {}, async disconnect() {}, async checkHealth() { return true; }, - async execute() { return null; }, async syncSchema() {}, - async find(o: string, ast: any) { - d.findCalls.push({ object: o, where: ast?.where }); - const rows = [...store.values()].filter((r) => matches(r, ast?.where)); - // Hold the caller's bound, AFTER the filter and by PRESENCE - // (`check:objectql-double-limit`): §3's control passes `limit: 1`, so a - // limit-blind double would answer it with the whole table. - return typeof ast?.limit === 'number' ? rows.slice(0, ast.limit) : rows; - }, - async findOne(_o: string, ast: any) { - for (const r of store.values()) if (matches(r, ast?.where)) return r; - return null; - }, - async create(_o: string, data: Record) { - const row = { ...data }; store.set(String(row.id), row); return row; - }, - async update(_o: string, id: string, data: Record) { - const cur = store.get(id); if (!cur) return null; - const u = { ...cur, ...data, id }; store.set(id, u); return u; - }, - async upsert(o: string, data: any) { return this.create(o, data); }, - async delete(_o: string, id: string) { return store.delete(id); }, - async count(_o: string, ast: any) { return [...store.values()].filter((r) => matches(r, ast?.where)).length; }, - async bulkCreate(o: string, rows: any[]) { return Promise.all(rows.map((r) => this.create(o, r))); }, - async bulkUpdate() { return []; }, async bulkDelete() {}, - async updateMany(_o: string, ast: any, data: Record) { - d.updateManyPayloads.push({ ...data }); - const rows = [...store.values()].filter((r) => matches(r, ast?.where)); - for (const r of rows) store.set(String(r.id), { ...r, ...data, id: r.id }); - return rows.length; - }, - async deleteMany() { return 0; }, - async beginTransaction() { return { commit: async () => {}, rollback: async () => {} }; }, - async commit() {}, async rollback() {}, - }; - return d; -} - -const text = (name: string) => ({ name, label: name, type: 'text' as const }); - -/** - * @param extraHook registered on the SAME event/object/priority as the stamp, - * so the engine's own read pattern is identical across §3's three arms. - */ -async function boot(opts: { stamp: boolean; extraHook?: (engine: any) => void } = { stamp: true }) { - const engine: any = new ObjectQL(); - const driver = makeStubDriver(); - engine.registerDriver(driver, true); - await engine.init(); - engine.registry.registerObject({ - name: OBJECT, label: OBJECT, - fields: { - id: { ...text('id'), primaryKey: true }, - managed_by: text('managed_by'), - customized: { name: 'customized', label: 'customized', type: 'boolean' as const }, - subject: text('subject'), - }, - }); - if (opts.stamp) bindEmailTemplateProvenanceStamp(engine, silentLogger, OBJECT); - opts.extraHook?.(engine); - return { engine, driver }; -} - -const ROWS = (bManagedBy: string) => ([ - { id: 'a', managed_by: 'package', customized: false, subject: 'l' }, - { id: 'b', managed_by: bManagedBy, customized: false, subject: 'l' }, -]); - -const PAYLOAD = { subject: 'edited' }; -const WHERE = { multi: true, where: { id: { $in: ['a', 'b'] } } } as any; - -/* ── 1. every matched row is stamped ───────────────────────────────────── */ - -describe('[#15302] a predicate update stamps EVERY matched row', () => { - it('stamps both rows in ONE `SET` clause - the "no `input.id`" guard is gone', async () => { - const { engine, driver } = await boot(); - await engine.insert(OBJECT, ROWS('package')); - driver.updateManyPayloads.length = 0; - - await engine.update(OBJECT, { ...PAYLOAD }, WHERE); - - // Both matched rows carry the stamp: the documented "not stamped on - // multi-row updates" boundary never existed on this engine. - expect([...driver.store.values()].map((r: any) => [r.id, r.customized])) - .toEqual([['a', true], ['b', true]]); - // ADR-0058 Addendum II D3: N rows share ONE payload, hence one clause. - expect(driver.updateManyPayloads).toEqual([{ ...PAYLOAD, customized: true }]); - }); - - it('does not stamp when the pre-image is not package/platform managed', async () => { - // The control for the assertion above: the stamp is a decision about the - // ROW, so a run where no matched row qualifies must write no `customized`. - const { engine, driver } = await boot(); - await engine.insert(OBJECT, [ - { id: 'a', managed_by: 'admin', customized: false, subject: 'l' }, - { id: 'b', managed_by: 'user', customized: false, subject: 'l' }, - ]); - driver.updateManyPayloads.length = 0; - - await engine.update(OBJECT, { ...PAYLOAD }, WHERE); - - expect(driver.updateManyPayloads).toEqual([{ ...PAYLOAD }]); - expect([...driver.store.values()].map((r: any) => r.customized)).toEqual([false, false]); - }); - - it('does not stamp an isSystem write (the seeder door)', async () => { - const { engine, driver } = await boot(); - await engine.insert(OBJECT, ROWS('package')); - driver.updateManyPayloads.length = 0; - - await engine.update(OBJECT, { ...PAYLOAD }, { - ...WHERE, context: { isSystem: true, positions: [], permissions: [] }, - }); - - expect(driver.updateManyPayloads).toEqual([{ ...PAYLOAD }]); - }); -}); - -/* ── 2. divergent rows: the engine refuses, and nothing is written ─────── */ - -describe('[#15302] matched rows that disagree refuse the batch', () => { - it('refuses with the ADR-0112 envelope and writes nothing', async () => { - const { engine, driver } = await boot(); - await engine.insert(OBJECT, ROWS('user')); - driver.updateManyPayloads.length = 0; - - const err: any = await engine.update(OBJECT, { ...PAYLOAD }, WHERE).then( - () => { throw new Error('expected the batch to be refused'); }, - (e: any) => e, - ); - - // Read the envelope by FIELD (`code` + `status`, the minimum a rejection - // case asserts): a bare `toThrow()` would stay green against any error. - expect(err.code).toBe('MULTI_UPDATE_HOOK_KEY_DIVERGENCE'); - expect(err.status).toBe(400); - expect(err.keys).toEqual(['customized']); - expect(err.rows).toBe(2); - // The refusal is the SAFE side: no `SET` clause reached the driver and - // both rows are untouched. - expect(driver.updateManyPayloads).toEqual([]); - expect([...driver.store.values()].map((r: any) => [r.customized, r.subject])) - .toEqual([[false, 'l'], [false, 'l']]); - }); -}); - -/* ── 3. the read count, with a control that fires ─────────────────────── */ - -describe('[#15302] the stamp issues NO read of its own', () => { - /** Finds the engine issued on OBJECT during one predicate update. */ - async function findsDuringUpdate(opts: Parameters[0]): Promise { - const { engine, driver } = await boot(opts); - await engine.insert(OBJECT, ROWS('package')); - driver.findCalls.length = 0; - await engine.update(OBJECT, { ...PAYLOAD }, WHERE); - return driver.findCalls.filter((c: any) => c.object === OBJECT).length; - } - - it('adds zero finds, while the pre-#15302 shape adds one PER MATCHED ROW', async () => { - // Arm C - an inert hook, registered identically, so the engine's own reads - // (D7's single matched-row read) are held constant across the arms. - const inert = (engine: any) => engine.registerHook('beforeUpdate', async () => {}, - { object: OBJECT, packageId: 'pin:15302-inert', priority: 150 }); - const baseline = await findsDuringUpdate({ stamp: false, extraHook: inert }); - - // Arm A - the shipped stamp. - const shipped = await findsDuringUpdate({ stamp: true }); - - // Arm B - the CONTROL, and the "before" reading: a replica of the read - // this card deleted, one `engine.find` per dispatch keyed on the row's id. - const rereading = (engine: any) => engine.registerHook('beforeUpdate', - async (ctx: any) => { - await engine.find(OBJECT, { - where: { id: ctx?.input?.id }, - fields: ['id', 'managed_by', 'customized'], - limit: 1, - context: { isSystem: true, positions: [], permissions: [] }, - }); - }, { object: OBJECT, packageId: 'pin:15302-control', priority: 150 }); - const withControl = await findsDuringUpdate({ stamp: false, extraHook: rereading }); - - // The control FIRES: the instrument can see a per-row re-read, and it - // costs exactly one find per matched row (2 rows ⇒ +2). - expect(withControl - baseline).toBe(2); - // And the shipped stamp costs none of them. - expect(shipped).toBe(baseline); - }); -}); diff --git a/packages/plugins/plugin-email/src/email-template-provenance.ts b/packages/plugins/plugin-email/src/email-template-provenance.ts deleted file mode 100644 index 8baf2f64da..0000000000 --- a/packages/plugins/plugin-email/src/email-template-provenance.ts +++ /dev/null @@ -1,103 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * [#4509] Provenance stamp for `sys_email_template`. - * - * `sys_email_template` is RECORD-AUTHORITATIVE: a declared email template is a - * boot seed ({@link bootstrapDeclaredEmailTemplates}), and the row — including - * any admin rewording of a transactional mail — is the authority. The seeder - * skips rows marked `customized`, so this hook is the half that DETECTS the - * admin edit: any non-system update touching a `package`/`platform`-seeded row - * stamps `customized: true` onto the payload. - * - * Why a data hook (and not a write gate or the REST layer), verbatim to the - * sys_webhook / sys_sharing_rule rationale (#3461, #2909 T1): - * - admins edit templates through several doors (Studio metadata-admin, the - * generic data door, scripts) — an engine hook covers them all; - * - there is deliberately NO write gate here: templates are a first-class - * admin authoring surface, so edits are allowed — they just have to be - * remembered; - * - both provenance columns are `readonly`, and the engine's readonly strip - * exempts isSystem callers while snapshotting supplied keys BEFORE hooks run - * — so a caller can never forge/clear `customized`, while this hook's stamp - * survives. - * - * Multi-row updates ARE stamped, once per matched row. [#15302] Since #5574 - * the engine dispatches `beforeUpdate` PER MATCHED ROW of a predicate - * (`multi: true`) write, each context carrying that row's `id` and `previous` - * (the `HookContext` contract; ADR-0058 Addendum II D1-D7). The inference this - * hook used to draw - "no single `input.id` means a bulk write, decline" - - * therefore answered "single write" on every row of a batch and guarded - * nothing. It is deleted rather than re-expressed against the engine's - * `dispatch` marker: taking part in EVERY write shape is the intent, so there - * is no decision left for the marker to gate (#6966 asks it in - * `file-reference-lifecycle.ts` because that guard REFUSES; this one stamps). - * - * What an operator's bulk edit does, measured on the real engine rather than - * assumed: the payload is BATCH-scoped (D3 - `driver.updateMany` takes ONE - * `SET` clause for N rows). Matched rows that AGREE are all stamped in that one - * clause and the write lands. Matched rows that DISAGREE would stamp some and - * not others, and the engine refuses the whole batch - * (`MULTI_UPDATE_HOOK_KEY_DIVERGENCE`, 400) rather than widening one row's - * stamp to the rest - which is what makes a row-conditioned rewrite safe to - * leave here. - * - * Declining on a predicate write was weighed and REJECTED (#15302): the seeder - * skips only rows marked `customized`, so the rows left unstamped would be - * exactly the ones the next boot clobbers - discarding the operator edit this - * stamp exists to remember. - */ - -interface MinimalEngine { - registerHook(event: string, handler: (ctx: any) => any, options?: Record): void; - unregisterHooksByPackage(packageId: string): number; -} - -interface MinimalLogger { - info?: (msg: string, meta?: Record) => void; -} - -export const EMAIL_TEMPLATE_PROVENANCE_PACKAGE = 'plugin-email:template-provenance'; - -export function bindEmailTemplateProvenanceStamp( - engine: MinimalEngine, - logger?: MinimalLogger, - object = 'sys_email_template', -): void { - if (typeof engine?.registerHook !== 'function') return; - // Re-binding on a re-boot must not stack duplicate hooks. - if (typeof engine.unregisterHooksByPackage === 'function') { - engine.unregisterHooksByPackage(EMAIL_TEMPLATE_PROVENANCE_PACKAGE); - } - engine.registerHook( - 'beforeUpdate', - async (ctx: any) => { - // Seeder / boot reconcilers write with isSystem — the package door, not - // an admin customization. - if ((ctx?.session as any)?.isSystem) return; - const data = ctx?.input?.data; - if (!data || typeof data !== 'object') return; - // [#15302] The engine has ALREADY read this row. `ctx.previous` is - // its pre-image, bound before `beforeUpdate` runs on BOTH write shapes - // (#5574 / #5846: the by-id path reads it ahead of the dispatch, and - // every per-row context of a predicate write carries its own), and it - // is the published `HookContext` contract rather than an engine - // internal. This hook used to issue its own `engine.find` here - on a - // predicate write, one extra read PER MATCHED ROW of a row the engine - // had just read. - const previous = ctx?.previous as Record | undefined; - if (!previous || typeof previous !== 'object') return; - if ((previous.managed_by === 'package' || previous.managed_by === 'platform') && previous.customized !== true) { - (data as any).customized = true; - } - }, - { object, packageId: EMAIL_TEMPLATE_PROVENANCE_PACKAGE, priority: 150 }, - ); - logger?.info?.('[email] template provenance stamp hook bound'); -} - -export function unbindEmailTemplateProvenanceStamp(engine: MinimalEngine): void { - if (typeof engine?.unregisterHooksByPackage === 'function') { - engine.unregisterHooksByPackage(EMAIL_TEMPLATE_PROVENANCE_PACKAGE); - } -} diff --git a/packages/plugins/plugin-email/src/index.ts b/packages/plugins/plugin-email/src/index.ts index 50aca8df53..961be0479e 100644 --- a/packages/plugins/plugin-email/src/index.ts +++ b/packages/plugins/plugin-email/src/index.ts @@ -115,11 +115,6 @@ export { type OutboxSweepService, type SweepStrandedOutboxOptions, } from './outbox-sweep.js'; -export { - bindEmailTemplateProvenanceStamp, - unbindEmailTemplateProvenanceStamp, - EMAIL_TEMPLATE_PROVENANCE_PACKAGE, -} from './email-template-provenance.js'; // [#8149] The `internal: true` header readback seam. export { readInternalHeadersJson, diff --git a/packages/plugins/plugin-email/src/plugin-shutdown-detaches-template-bridge.test.ts b/packages/plugins/plugin-email/src/plugin-shutdown-detaches-template-bridge.test.ts index 0de0f91b60..b16015ec1e 100644 --- a/packages/plugins/plugin-email/src/plugin-shutdown-detaches-template-bridge.test.ts +++ b/packages/plugins/plugin-email/src/plugin-shutdown-detaches-template-bridge.test.ts @@ -49,7 +49,7 @@ const TABLE = 'sys_email_template'; type AnyRecord = Record; /** - * The slice of ObjectQL the template bridge and the provenance stamp touch — + * The slice of ObjectQL the template bridge and the organization door touch — * the same double `email-plugin.template-runtime-write.test.ts` uses, so this * file cannot accept a dispatch shape the real engine would refuse. */ @@ -80,7 +80,7 @@ function fakeEngine() { if (target) Object.assign(target, data); return { affected: target ? 1 : 0 }; }, - registerHook() { /* provenance stamp */ }, + registerHook() { /* organization door */ }, unregisterHooksByPackage() { return 0; }, }; } diff --git a/packages/qa/dogfood/test/email-template-overlay-survives-boot.dogfood.test.ts b/packages/qa/dogfood/test/email-template-overlay-survives-boot.dogfood.test.ts index 063fdce697..4a9e3aaf17 100644 --- a/packages/qa/dogfood/test/email-template-overlay-survives-boot.dogfood.test.ts +++ b/packages/qa/dogfood/test/email-template-overlay-survives-boot.dogfood.test.ts @@ -30,9 +30,12 @@ // - the metadata-door edit is org-scoped and projected at once (preconditions); // - after a cold boot the sending row, `GET /meta` and a real `sendTemplate` // all carry the admin's wording; -// - control: a data-door edit (`customized: true`) still survives the next -// cold boot — seed-not-clobber is unchanged, and the overlay projection does -// not override a row the admin edited directly. +// - control: the organization DATA door is closed (ADR-0131 D6, ruling C on +// §6 Q1) — an edit and a create through `/data/sys_email_template` are +// refused 403 `PERMISSION_DENIED`, nothing is marked `customized`, and the +// metadata-door wording still survives the next cold boot. Before the door +// closed this case pinned the opposite: a data-door edit stamped +// `customized: true` that survived the boot. import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import showcaseStack from '@objectstack/example-showcase'; @@ -150,17 +153,30 @@ describe('[#21785] a metadata-door email template edit survives a cold boot (sho expect(sent.at(-1)?.subject).toBe('Done (reworded by the admin): Ship it'); }, 180_000); - it('control: a data-door edit is stamped customized and survives the next cold boot', async () => { + it('control: the organization data door is closed — an edit and a create are refused, and the metadata-door wording survives the next cold boot', async () => { + const closedDoor = /^PERMISSION_DENIED: sys_email_template is closed to organization writes/; const [row] = await sendingRows(); const patched = await call('PATCH', `/data/sys_email_template/${row.id}`, { subject: DATA_DOOR_SUBJECT }); - expect(patched.status, JSON.stringify(patched.json)).toBe(200); - const [edited] = await sendingRows(); - expect({ subject: edited.subject, customized: edited.customized }) - .toEqual({ subject: DATA_DOOR_SUBJECT, customized: true }); + expect(patched.status, JSON.stringify(patched.json)).toBe(403); + expect(patched.json?.code).toBe('PERMISSION_DENIED'); + expect(String(patched.json?.error)).toMatch(closedDoor); + + const created = await call('POST', '/data/sys_email_template', { + name: 'org_owned_template', label: 'Org owned', category: 'custom', locale: 'en-US', + subject: 'Org owned', body_html: '

Org owned

', active: true, + }); + expect(created.status, JSON.stringify(created.json)).toBe(403); + expect(created.json?.code).toBe('PERMISSION_DENIED'); + expect(String(created.json?.error)).toMatch(closedDoor); + + const ql: any = await stack!.kernel.getServiceAsync('objectql'); + expect(await ql.find('sys_email_template', { where: { name: 'org_owned_template' }, context: SYS })).toEqual([]); + expect((await sendingRows()).map((r: any) => ({ subject: r.subject, customized: r.customized }))) + .toEqual([{ subject: ADMIN_SUBJECT, customized: false }]); await restart(); expect((await sendingRows()).map((r: any) => ({ subject: r.subject, customized: r.customized }))) - .toEqual([{ subject: DATA_DOOR_SUBJECT, customized: true }]); + .toEqual([{ subject: ADMIN_SUBJECT, customized: false }]); }, 180_000); }); diff --git a/scripts/audits/14744-before-update-per-row-value-probe.mjs b/scripts/audits/14744-before-update-per-row-value-probe.mjs index 0a3a9a594a..dd32e77ecd 100644 --- a/scripts/audits/14744-before-update-per-row-value-probe.mjs +++ b/scripts/audits/14744-before-update-per-row-value-probe.mjs @@ -54,7 +54,6 @@ import { writeFileSync } from 'node:fs'; import { ObjectQL } from '../../packages/objectql/src/engine.ts'; import { MultiUpdateHookKeyDivergenceError } from '../../packages/objectql/src/multi-update-hook-key-divergence.ts'; -import { bindEmailTemplateProvenanceStamp } from '../../packages/plugins/plugin-email/src/email-template-provenance.ts'; import { bindRuleProvenanceStamp } from '../../packages/plugins/plugin-sharing/src/sharing-rule-provenance.ts'; import { bindWebhookProvenanceStamp } from '../../packages/plugins/plugin-webhooks/src/webhook-provenance.ts'; import taskHookImported from '../../examples/app-todo/src/objects/task.hook.ts'; @@ -304,17 +303,6 @@ const SUBJECTS = [ uniform: [{ id: 'a', status: 'in_progress', completed_date: null, subject: 's', priority: 'normal' }, { id: 'b', status: 'in_progress', completed_date: null, subject: 's', priority: 'normal' }], }, - { - id: 'REAL: plugin-email template provenance stamp', - real: true, note: 'bindEmailTemplateProvenanceStamp, unmodified.', - object: 'sys_email_template', fields: ['managed_by', 'customized', 'subject'], - payload: { subject: 'edited' }, - bind: (t) => bindEmailTemplateProvenanceStamp(t, silentLogger, 'sys_email_template'), - divergent: [{ id: 'a', managed_by: 'package', customized: false, subject: 's' }, - { id: 'b', managed_by: 'user', customized: false, subject: 's' }], - uniform: [{ id: 'a', managed_by: 'package', customized: false, subject: 's' }, - { id: 'b', managed_by: 'package', customized: false, subject: 's' }], - }, { id: 'REAL: plugin-sharing rule provenance stamp', real: true, note: 'bindRuleProvenanceStamp, unmodified.', diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index 1388536ef2..bbbb4a55c3 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -2891,6 +2891,16 @@ "verb": "update", "pinned": 1 }, + { + "file": "packages/plugins/plugin-auth/src/phone-sms-seed-retired.test.ts", + "verb": "findOne", + "pinned": 1 + }, + { + "file": "packages/plugins/plugin-auth/src/phone-sms-seed-retired.test.ts", + "verb": "update", + "pinned": 1 + }, { "file": "packages/plugins/plugin-auth/src/remove-member-permission-guard.test.ts", "verb": "delete",