@cosmicdrift/kumiko-samples 0.391.1 → 0.392.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (137) hide show
  1. package/package.json +1 -1
  2. package/packages/bundled-features/package.json +1 -1
  3. package/packages/bundled-features/src/admin-shell/changes.json +6 -0
  4. package/packages/bundled-features/src/admin-shell/feature.ts +60 -31
  5. package/packages/bundled-features/src/auth-email-password/handlers/invite-accept-with-login.write.ts +2 -2
  6. package/packages/bundled-features/src/auth-email-password/handlers/invite-signup-complete.write.ts +2 -2
  7. package/packages/bundled-features/src/cap-overview/changes.json +12 -0
  8. package/packages/bundled-features/src/cap-overview/handlers/caps-usage.query.ts +1 -0
  9. package/packages/bundled-features/src/cap-overview/handlers/tenant-caps-list.query.ts +3 -2
  10. package/packages/bundled-features/src/cap-overview/i18n.ts +4 -0
  11. package/packages/bundled-features/src/cap-overview/types.ts +4 -1
  12. package/packages/bundled-features/src/cap-overview/web/cap-amount-format.ts +37 -0
  13. package/packages/bundled-features/src/cap-overview/web/cap-cards-panel.tsx +138 -54
  14. package/packages/bundled-features/src/cap-overview/web/cap-usage-bar.tsx +18 -4
  15. package/packages/bundled-features/src/cap-overview/web/cap-usage-cell.tsx +6 -3
  16. package/packages/bundled-features/src/cap-overview/web/usage-math.ts +5 -1
  17. package/packages/bundled-features/src/channel-email/app-transport.ts +37 -0
  18. package/packages/bundled-features/src/channel-email/changes.json +6 -0
  19. package/packages/bundled-features/src/channel-email/feature.ts +6 -0
  20. package/packages/bundled-features/src/channel-email/index.ts +5 -0
  21. package/packages/bundled-features/src/channel-email/smtp-transport.ts +2 -22
  22. package/packages/bundled-features/src/foundation-shared/changes.json +6 -0
  23. package/packages/bundled-features/src/foundation-shared/index.ts +1 -0
  24. package/packages/bundled-features/src/foundation-shared/mail-display-name.ts +20 -0
  25. package/packages/bundled-features/src/inbound-mail-foundation/account-record.ts +48 -0
  26. package/packages/bundled-features/src/inbound-mail-foundation/changes.json +18 -0
  27. package/packages/bundled-features/src/inbound-mail-foundation/feature.ts +1 -1
  28. package/packages/bundled-features/src/inbound-mail-foundation/i18n.ts +17 -0
  29. package/packages/bundled-features/src/inbound-mail-foundation/index.ts +18 -0
  30. package/packages/bundled-features/src/inbound-mail-foundation/outbound-mail.ts +66 -0
  31. package/packages/bundled-features/src/inbound-mail-foundation/probe.ts +8 -45
  32. package/packages/bundled-features/src/inbound-mail-foundation/screens.ts +14 -0
  33. package/packages/bundled-features/src/inbound-mail-foundation/send-from-account.ts +118 -0
  34. package/packages/bundled-features/src/inbound-mail-foundation/types.ts +8 -0
  35. package/packages/bundled-features/src/inbound-mail-foundation/watch-supervisor.ts +13 -8
  36. package/packages/bundled-features/src/inbound-provider-imap/changes.json +12 -0
  37. package/packages/bundled-features/src/inbound-provider-imap/connect-mailbox.ts +156 -0
  38. package/packages/bundled-features/src/inbound-provider-imap/constants.ts +42 -0
  39. package/packages/bundled-features/src/inbound-provider-imap/credential-document.ts +49 -1
  40. package/packages/bundled-features/src/inbound-provider-imap/feature.ts +12 -35
  41. package/packages/bundled-features/src/inbound-provider-imap/i18n.ts +48 -0
  42. package/packages/bundled-features/src/inbound-provider-imap/imap-client.ts +1 -1
  43. package/packages/bundled-features/src/inbound-provider-imap/screens.ts +44 -0
  44. package/packages/bundled-features/src/inbound-provider-imap/send-mail.ts +73 -0
  45. package/packages/bundled-features/src/inbound-provider-imap/sent-folder.ts +49 -0
  46. package/packages/bundled-features/src/inbound-provider-imap/smtp-client.ts +131 -0
  47. package/packages/bundled-features/src/legal-pages/README.md +5 -10
  48. package/packages/bundled-features/src/mail-foundation/changes.json +6 -0
  49. package/packages/bundled-features/src/mail-foundation/i18n.ts +9 -1
  50. package/packages/bundled-features/src/mail-foundation/probe.ts +43 -22
  51. package/packages/bundled-features/src/readiness/built-in-probes.ts +15 -2
  52. package/packages/bundled-features/src/readiness/changes.json +30 -0
  53. package/packages/bundled-features/src/readiness/constants.ts +2 -0
  54. package/packages/bundled-features/src/readiness/i18n.ts +7 -1
  55. package/packages/bundled-features/src/readiness/index.ts +6 -1
  56. package/packages/bundled-features/src/readiness/screens.ts +25 -2
  57. package/packages/bundled-features/src/readiness/visible-results.ts +2 -1
  58. package/packages/bundled-features/src/renderer-foundation/README.md +19 -21
  59. package/packages/bundled-features/src/template-resolver/README.md +144 -165
  60. package/packages/bundled-features/src/tier-engine/changes.json +6 -0
  61. package/packages/bundled-features/src/tier-engine/feature.ts +17 -6
  62. package/packages/bundled-features/src/user-data-rights/handlers/download-by-token.query.ts +3 -3
  63. package/packages/bundled-features/src/user-data-rights/schema/download-token.ts +5 -3
  64. package/packages/bundled-features/src/user-data-rights/token-helpers.ts +5 -4
  65. package/samples/apps/cap-billing-demo/bin/server.ts +7 -7
  66. package/samples/apps/cap-billing-demo/src/feature.ts +38 -38
  67. package/samples/apps/cap-billing-demo/src/run-config.ts +20 -20
  68. package/samples/apps/cap-billing-demo/src/tier-map.ts +14 -14
  69. package/samples/apps/config-demo/src/app/server.ts +4 -4
  70. package/samples/apps/config-demo/src/features/demo/feature.ts +4 -4
  71. package/samples/apps/marketing-demo/playwright.config.ts +3 -3
  72. package/samples/apps/marketing-demo/src/app/client.tsx +2 -2
  73. package/samples/apps/marketing-demo/src/app/seed-data.ts +9 -9
  74. package/samples/apps/marketing-demo/src/app/seed.ts +14 -14
  75. package/samples/apps/marketing-demo/src/app/server.ts +7 -7
  76. package/samples/apps/marketing-demo/src/app/shell.tsx +3 -3
  77. package/samples/apps/marketing-demo/src/features/assets/feature.ts +3 -3
  78. package/samples/apps/marketing-demo/src/features/assets/i18n.ts +7 -7
  79. package/samples/apps/marketing-demo/src/features/assets/web/index.ts +2 -2
  80. package/samples/apps/showcase/src/app/client.tsx +3 -3
  81. package/samples/apps/showcase/src/app/seed-items.ts +14 -14
  82. package/samples/apps/showcase/src/app/server.ts +7 -7
  83. package/samples/apps/showcase/src/features/demos/feature.ts +13 -13
  84. package/samples/apps/showcase/src/features/demos/i18n.ts +2 -2
  85. package/samples/apps/showcase/src/features/demos/web/components/page.tsx +5 -5
  86. package/samples/apps/showcase/src/features/demos/web/index.ts +4 -4
  87. package/samples/apps/showcase/src/features/demos/web/pages/dialog.tsx +1 -1
  88. package/samples/apps/showcase/src/features/demos/web/pages/inputs.tsx +8 -8
  89. package/samples/apps/showcase/src/features/demos/web/pages/layout.tsx +5 -5
  90. package/samples/apps/showcase/src/features/demos/web/pages/toast.tsx +3 -3
  91. package/samples/apps/showcase/src/features/items/feature.ts +8 -8
  92. package/samples/apps/showcase/src/features/items/i18n.ts +3 -3
  93. package/samples/apps/showcase/src/features/items/index.ts +3 -3
  94. package/samples/apps/showcase/src/features/items/schema/index.ts +4 -4
  95. package/samples/apps/showcase/src/features/items/schema/item.ts +27 -27
  96. package/samples/apps/showcase/src/features/items/web/index.ts +6 -6
  97. package/samples/apps/styleguide/src/app/seed.ts +3 -3
  98. package/samples/apps/styleguide/src/app/server.ts +2 -2
  99. package/samples/apps/styleguide/src/app/shell.tsx +4 -4
  100. package/samples/apps/styleguide/src/features/content/web.tsx +6 -6
  101. package/samples/apps/styleguide/src/features/examples/feature.ts +8 -8
  102. package/samples/apps/styleguide/src/features/gallery/feature.ts +9 -9
  103. package/samples/apps/styleguide/src/features/gallery/web/Gallery.tsx +12 -12
  104. package/samples/apps/styleguide/src/features/widgets/feature.ts +2 -2
  105. package/samples/apps/styleguide/src/features/widgets/web/DashboardFilterEcho.tsx +3 -3
  106. package/samples/apps/styleguide/src/features/widgets/web/DashboardKpiIcon.tsx +4 -4
  107. package/samples/apps/ui-walkthrough/playwright.config.ts +6 -6
  108. package/samples/apps/ui-walkthrough/scripts/emit-e2e-data.ts +5 -5
  109. package/samples/apps/ui-walkthrough/src/app/auth-constants.ts +13 -13
  110. package/samples/apps/ui-walkthrough/src/app/client.tsx +2 -2
  111. package/samples/apps/ui-walkthrough/src/app/server.ts +14 -14
  112. package/samples/apps/ui-walkthrough/src/app/shell.tsx +3 -3
  113. package/samples/apps/use-all-bundled/bin/main.ts +5 -5
  114. package/samples/apps/use-all-bundled/feature-manifest.json +10 -4
  115. package/samples/apps/use-all-bundled/kumiko/schema.ts +7 -7
  116. package/samples/apps/use-all-bundled/scripts/check-coverage.ts +20 -20
  117. package/samples/apps/use-all-bundled/src/app/auth-constants.ts +4 -4
  118. package/samples/apps/use-all-bundled/src/app/client.tsx +4 -4
  119. package/samples/apps/use-all-bundled/src/app/dev-app-options.ts +12 -12
  120. package/samples/apps/use-all-bundled/src/app/seed.ts +6 -6
  121. package/samples/apps/user-data-rights-demo/src/feature.ts +23 -23
  122. package/samples/apps/workspaces/playwright.config.ts +5 -5
  123. package/samples/apps/workspaces/src/app/auth-constants.ts +3 -3
  124. package/samples/apps/workspaces/src/app/server.ts +16 -16
  125. package/samples/apps/workspaces/src/app/shell.tsx +2 -2
  126. package/samples/apps/workspaces/src/features/demo/schema/order.ts +4 -4
  127. package/samples/apps/workspaces/src/features/demo-driver/feature.ts +5 -5
  128. package/samples/recipes/apex-surface-auth/src/feature.ts +31 -31
  129. package/samples/recipes/designer-demo/src/feature.ts +6 -6
  130. package/samples/recipes/encrypted-tenant-config/src/feature.ts +20 -20
  131. package/samples/recipes/lane-routing/src/feature.ts +5 -5
  132. package/samples/recipes/legal-pages/src/feature.ts +29 -29
  133. package/samples/recipes/record-detail-layout/src/feature.ts +21 -2
  134. package/samples/recipes/seed-migration/scripts/smoke.ts +20 -20
  135. package/samples/recipes/seed-migration/seeds/2026-01-01-initial-tenants.ts +11 -11
  136. package/samples/recipes/seed-migration/seeds/2026-02-01-fix-admin-roles.ts +13 -13
  137. package/samples/recipes/user-data-rights/src/feature.ts +13 -13
@@ -1,15 +1,11 @@
1
1
  # template-resolver
2
2
 
3
- Strukturierter Content-Storage mit Tenant-Override-Hierarchie, Locale-Fallback und Resource-Linking via `file-foundation`. Eine Entity (`template-resource`) für alle Content-Arten — Render-Templates (`mail`, `document-pdf`, `notification`, `image-snapshot`), Rahmen (`mail-layout`, `pdf-layout`), Markdown-Textblöcke (`text-block`, ex `text-content`-Feature) und AI-Prompts (`ai-prompt`).
4
-
5
- **Plan-Doc:** [`kumiko-platform/docs/plans/features/template-resolver.md`](../../../../../../kumiko-platform/docs/plans/features/template-resolver.md)
6
-
7
- **Status (2026-05-19):** 45 Integration-Tests grün, typecheck grün, self+advisor-reviewed. Implementierungs-Erkenntnisse im Plan-Doc.
3
+ Structured content storage with tenant override hierarchy, locale fallback, and resource linking via `file-foundation`. One entity (`template-resource`) for all content types: render templates (`mail`, `document-pdf`, `notification`, `image-snapshot`), layouts (`mail-layout`, `pdf-layout`), Markdown text blocks (`text-block`, formerly the `text-content` feature), and AI prompts (`ai-prompt`).
8
4
 
9
5
  ## Mount
10
6
 
11
7
  ```typescript
12
- // App-Bootstrap
8
+ // App bootstrap
13
9
  import {
14
10
  createTemplateResolverApi,
15
11
  createTemplateResolverFeature,
@@ -17,7 +13,7 @@ import {
17
13
 
18
14
  const features = [
19
15
  createTemplateResolverFeature(),
20
- // ... weitere Features
16
+ // ... additional features
21
17
  ];
22
18
 
23
19
  const app = createKumikoApp({
@@ -28,7 +24,7 @@ const app = createKumikoApp({
28
24
  });
29
25
  ```
30
26
 
31
- ## Konsumtion (in Feature-Handlern)
27
+ ## Consumption (in feature handlers)
32
28
 
33
29
  ```typescript
34
30
  import { requireTemplateResolver } from "@cosmicdrift/kumiko-bundled-features/template-resolver";
@@ -41,109 +37,107 @@ async function someHandler(ctx) {
41
37
  kind: "mail",
42
38
  locale: "de",
43
39
  });
44
- // template.content + template.meta + template.variableSchema + template.linkedResources verwenden
40
+ // use template.content + template.meta + template.variableSchema + template.linkedResources
45
41
  // ...
46
42
  }
47
43
  ```
48
44
 
49
- ## Kinds, Format und Meta
45
+ ## Kinds, format, and meta
50
46
 
51
47
  | kind | contentFormat | meta |
52
48
  |---|---|---|
53
- | `mail` | `html` (Dokument-Schema, wird serverseitig neu aufgebaut) | Pflicht: `subject`; optional `previewText`, `layoutSlug`, `category` (`transactional`/`marketing`), `senderName`, `replyTo` |
54
- | `mail-layout` | `mjml` | leer (`{}`) oder fehlend |
55
- | `pdf-layout` | `html` | leer (`{}`) oder fehlend |
56
- | `document-pdf` | `html` | fehlt |
57
- | übrige | frei | fehlt |
49
+ | `mail` | `html` (document schema, rebuilt server-side) | Required: `subject`; optional `previewText`, `layoutSlug`, `category` (`transactional`/`marketing`), `senderName`, `replyTo` |
50
+ | `mail-layout` | `mjml` | empty (`{}`) or missing |
51
+ | `pdf-layout` | `html` | empty (`{}`) or missing |
52
+ | `document-pdf` | `html` | missing |
53
+ | remaining kinds | arbitrary | missing |
58
54
 
59
- `meta` liegt als JSON in der Spalte `meta` und wird beim Schreiben pro kind gegen
60
- `template-meta.ts` geprüft (Fehler als Field-Issues auf `meta.*`). Subject und
61
- Preview-Text erlauben nur einfache Platzhalter (`{{name}}`, `{{name.feld}}`), keine
62
- Zeilenumbrüche.
55
+ `meta` is stored as JSON in the `meta` column and is validated on write per kind against
56
+ `template-meta.ts` (errors as field issues on `meta.*`). Subject and preview text allow only simple
57
+ placeholders (`{{name}}`, `{{name.field}}`), with no line breaks.
63
58
 
64
- Slugs sind kebab-Segmente, mit `:` verbunden (`templateSlugSchema`); Notification-Templates
65
- liegen unter ihrem qualifizierten Namen `feature:notify:name`.
59
+ Slugs are kebab-case segments joined with `:` (`templateSlugSchema`); notification templates
60
+ use their qualified name `feature:notify:name`.
66
61
 
67
- ## Platzhalter-Validierung (kind `mail`)
62
+ ## Placeholder validation (kind `mail`)
68
63
 
69
- Die Variablen kommen aus der Notification mit gleichem qualifiziertem Namen
70
- (`r.notification({ variables })`), sonst aus `variableSchema` der Zeile. Systemplatzhalter
71
- (`tenantName`, `signature`, `imprint`, `unsubscribeUrl`) und der Resource-Key `logo` sind immer erlaubt.
64
+ Variables come from the notification with the same qualified name
65
+ (`r.notification({ variables })`), otherwise from the row's `variableSchema`. System placeholders
66
+ (`tenantName`, `signature`, `imprint`, `unsubscribeUrl`) and the resource key `logo` are always allowed.
72
67
 
73
- - `upsertSystem` und `upsertTenant` lehnen unbekannte Platzhalter ab (Field-Issue auf `content`).
74
- - `upsertSystem` und `upsertTenant` mit `status: "active"` verlangen zusätzlich, dass jede `required`-Variable benutzt wird. Ein Tenant-Draft darf unvollständig sein und liefert `validation` zurück.
75
- - `publish` prüft die gespeicherte Zeile erneut.
76
- - `seedSystemTemplate` wirft bei beiden Problemen.
68
+ - `upsertSystem` and `upsertTenant` reject unknown placeholders (field issue on `content`).
69
+ - `upsertSystem` and `upsertTenant` with `status: "active"` additionally require every `required` variable to be used. A tenant draft may be incomplete and returns `validation`.
70
+ - `publish` checks the stored row again.
71
+ - `seedSystemTemplate` throws for both problems.
77
72
 
78
- ## Notification-Templates seeden
73
+ ## Seeding notification templates
79
74
 
80
- `r.notification({ variables, template, meta })` bringt Text und Meta mit dem Code mit.
81
- `seedNotificationTemplates(db, registry)` (aus `.../template-resolver/seeding`) schreibt sie
82
- pro Locale als System-Template; `applyBootSeeds` ruft das beim Boot auf. Der Code ist
83
- maßgeblich: geänderte Texte aktualisieren die Zeile, unveränderte erzeugen kein Event.
84
- Tenant-Overrides bleiben unberührt.
75
+ `r.notification({ variables, template, meta })` includes text and meta with the code.
76
+ `seedNotificationTemplates(db, registry)` (from `.../template-resolver/seeding`) writes them
77
+ as system templates per locale; `applyBootSeeds` calls this at boot. The code is authoritative:
78
+ changed text updates the row, unchanged text produces no event. Tenant overrides remain untouched.
85
79
 
86
- ## Resolver-Reihenfolge (4-Stufen-Fallback)
80
+ ## Resolver order (four-stage fallback)
87
81
 
88
82
  1. `tenantId` + requested locale
89
83
  2. `SYSTEM_TENANT_ID` + requested locale
90
84
  3. `tenantId` + `FALLBACK_LOCALE` (default "de")
91
85
  4. `SYSTEM_TENANT_ID` + `FALLBACK_LOCALE`
92
86
 
93
- Wenn nichts gefunden → `TemplateNotFoundError`.
87
+ If nothing is found, `TemplateNotFoundError`.
94
88
 
95
- ## Boot-Seeding (System-Templates)
89
+ ## Boot seeding (system templates)
96
90
 
97
91
  ```typescript
98
92
  import { seedSystemTemplate } from "@cosmicdrift/kumiko-bundled-features/template-resolver/seeding";
99
93
 
100
- // runProdApp seeds — idempotent, default skip if slug exists
94
+ // runProdApp seeds, idempotent, skips by default if the slug exists
101
95
  await seedSystemTemplate(db, {
102
96
  slug: "welcome-email",
103
97
  kind: "mail",
104
98
  locale: "de",
105
- content: '<p>Willkommen, <span data-variable="name"></span></p>',
99
+ content: '<p>Welcome, <span data-variable="name"></span></p>',
106
100
  contentFormat: "html",
107
101
  });
108
102
  ```
109
103
 
110
- `ifExists: "update"` für autoritative Re-Seeds (z.B. nach Template-Content-Release).
104
+ `ifExists: "update"` for authoritative re-seeds, for example after a template content release.
111
105
 
112
- ## Admin-Workflows (Write-Handlers + Queries)
106
+ ## Admin workflows (write handlers + queries)
113
107
 
114
- | Handler | QN | Wer | Was |
108
+ | Handler | QN | Who | What |
115
109
  |---|---|---|---|
116
- | `TemplateResolverHandlers.upsertSystem` | `template-resolver:write:upsert-system` | SystemAdmin | Erstellt/Updated System-Default-Templates (`SYSTEM_TENANT_ID`, scope='system', status='active') |
117
- | `TemplateResolverHandlers.upsertTenant` | `template-resolver:write:upsert-tenant` | TenantAdmin (eigener Tenant) + SystemAdmin via `tenantIdOverride` | Erstellt/Updated Tenant-Overrides (scope='tenant'), default-status='draft' |
118
- | `TemplateResolverHandlers.publish` | `template-resolver:write:publish` | TenantAdmin (eigener Tenant) | Setzt status='active' |
119
- | `TemplateResolverHandlers.archive` | `template-resolver:write:archive` | TenantAdmin (eigener Tenant) | Setzt status='archived' (Resolver ignoriert es danach) |
120
- | `TemplateResolverHandlers.publishSystem` | `template-resolver:write:publish-system` | SystemAdmin | Setzt status='active' auf einem System-Default (gleiche Mail-Prüfung wie `publish`) |
121
- | `TemplateResolverHandlers.archiveSystem` | `template-resolver:write:archive-system` | SystemAdmin | Setzt status='archived' auf einem System-Default; Tenants ohne Override fallen durch |
122
- | `TemplateResolverHandlers.resetToSystem` | `template-resolver:write:reset-to-system` | TenantAdmin (eigener Tenant) | Archiviert den eigenen Override, sodass wieder die aktive System-Vorlage greift; ohne aktive System-Vorlage `no_system_template` |
123
- | `TemplateResolverQueries.findById` | `template-resolver:query:find-by-id` | TenantAdmin + User (eigener Tenant + system-templates sichtbar) | Raw-Lookup für Edit-UI |
124
- | `TemplateResolverQueries.list` | `template-resolver:query:list` | gleich | Filter nach kind/locale/status, optional includeSystem |
125
- | `TemplateResolverQueries.usage` | `template-resolver:query:usage` | TenantAdmin | Welche Notifications eine Mail-Vorlage bzw. ein Mail-Layout verwenden (aus der Registry) |
126
- | `TemplateResolverQueries.overview` | `template-resolver:query:overview` | TenantAdmin + SystemAdmin | Eine Zeile je Slug und Art (Mail, PDF, Layouts) mit Herkunft, Sprachen, Status, Version, Layout und Verwendung; durchsuch-, filter-, sortier- und seitenweise lesbar |
127
- | `TemplateResolverQueries.layoutSource` | `template-resolver:query:layout-source` | TenantAdmin + SystemAdmin | Eigene und System-Fassung eines Layouts, Kopf/Signatur/Fuß-Bereiche der wirksamen html-Fassung, Speicher-Locale; MJML-Quelle nur für SystemAdmin |
128
- | `TemplateResolverQueries.tenantOverrides` | `template-resolver:query:tenant-overrides` | SystemAdmin | Anzahl aktiver/Entwurf-Overrides einer System-Vorlage und aktive Tenants gesamt, nur Zahlen |
129
- | `TemplateResolverQueries.diffToSystem` | `template-resolver:query:diff-to-system` | TenantAdmin (eigener Tenant) | Block- und Meta-Vergleich eines Tenant-Overrides mit der System-Vorlage |
130
-
131
- **SystemAdmin-Cross-Tenant für publish/archive/findById:** deferred (Task 8) — `upsertTenant` hat `tenantIdOverride` bereits; publish/archive brauchen das erst wenn ein Sysadmin-Panel fremde Tenant-Templates kuratiert. `ctx.db` ist tenant-scoped, fremde IDs → NotFound ohne Override.
132
-
133
- ## Status-Lifecycle
110
+ | `TemplateResolverHandlers.upsertSystem` | `template-resolver:write:upsert-system` | SystemAdmin | Creates or updates system default templates (`SYSTEM_TENANT_ID`, scope='system', status='active') |
111
+ | `TemplateResolverHandlers.upsertTenant` | `template-resolver:write:upsert-tenant` | TenantAdmin (own tenant) + SystemAdmin via `tenantIdOverride` | Creates or updates tenant overrides (scope='tenant'), default status='draft' |
112
+ | `TemplateResolverHandlers.publish` | `template-resolver:write:publish` | TenantAdmin (own tenant) | Sets status='active' |
113
+ | `TemplateResolverHandlers.archive` | `template-resolver:write:archive` | TenantAdmin (own tenant) | Sets status='archived' (the resolver ignores it afterward) |
114
+ | `TemplateResolverHandlers.publishSystem` | `template-resolver:write:publish-system` | SystemAdmin | Sets status='active' on a system default, with the same mail validation as `publish` |
115
+ | `TemplateResolverHandlers.archiveSystem` | `template-resolver:write:archive-system` | SystemAdmin | Sets status='archived' on a system default; tenants without an override fall through |
116
+ | `TemplateResolverHandlers.resetToSystem` | `template-resolver:write:reset-to-system` | TenantAdmin (own tenant) | Archives the own override so that the active system template applies again; without an active system template, `no_system_template` |
117
+ | `TemplateResolverQueries.findById` | `template-resolver:query:find-by-id` | TenantAdmin + User (own tenant, system templates visible) | Raw lookup for the edit UI |
118
+ | `TemplateResolverQueries.list` | `template-resolver:query:list` | same | Filters by kind/locale/status, optionally includeSystem |
119
+ | `TemplateResolverQueries.usage` | `template-resolver:query:usage` | TenantAdmin | Which notifications use a mail template or mail layout, from the registry |
120
+ | `TemplateResolverQueries.overview` | `template-resolver:query:overview` | TenantAdmin + SystemAdmin | One row per slug and type, mail, PDF, layouts, with origin, languages, status, version, layout, and usage; searchable, filterable, sortable, and pageable |
121
+ | `TemplateResolverQueries.layoutSource` | `template-resolver:query:layout-source` | TenantAdmin + SystemAdmin | Own and system version of a layout, header/signature/footer regions of the effective HTML version, storage locale; MJML source only for SystemAdmin |
122
+ | `TemplateResolverQueries.tenantOverrides` | `template-resolver:query:tenant-overrides` | SystemAdmin | Number of active/draft overrides of a system template and total active tenants, numbers only |
123
+ | `TemplateResolverQueries.diffToSystem` | `template-resolver:query:diff-to-system` | TenantAdmin (own tenant) | Block and meta comparison of a tenant override with the system template |
124
+
125
+ **SystemAdmin cross-tenant access for publish/archive/findById:** deferred (Task 8), `upsertTenant` already has `tenantIdOverride`; publish/archive only need this when a system admin panel curates templates belonging to other tenants. `ctx.db` is tenant-scoped, foreign IDs result in NotFound without an override.
126
+
127
+ ## Status lifecycle
134
128
 
135
129
  ```
136
130
  upsertSystem ──┐
137
- ├──► status: "active" (System-Default sofort aktiv)
138
- upsertTenant ──┴──► status: "draft" (Default) | "active" (explizit)
131
+ ├──► status: "active" (system default active immediately)
132
+ upsertTenant ──┴──► status: "draft" (default) | "active" (explicit)
139
133
 
140
- publish ───────► status: "active" (übernimmt einen offenen Entwurf)
134
+ publish ───────► status: "active" (publishes an open draft)
141
135
  archive ───────► status: "archived"
142
- discardDraft ──► verwirft den Entwurf neben der aktiven Fassung
136
+ discardDraft ──► discards the draft alongside the active version
143
137
  ```
144
138
 
145
- Ein Entwurf über einer aktiven Zeile (`upsertTenant` ohne `status`, `copy-locale`, `import-bundle`) liegt als JSON in `draft` neben der Zeile; die Hauptfelder bleiben die ausgelieferte Fassung. `find-by-id` und `layout-source` liefern die Arbeitsfassung (Entwurf, sonst gespeichert) mit `active { version, since }`.
146
- Resolver returnt **nur** Templates mit `status: "active"`. draft/archived werden ignoriert.
139
+ A draft over an active row (`upsertTenant` without `status`, `copy-locale`, `import-bundle`) is stored as JSON in `draft` beside the row; the main fields remain the delivered version. `find-by-id` and `layout-source` return the working version, the draft if present, otherwise the stored version, with `active { version, since }`.
140
+ The resolver returns **only** templates with `status: "active"`. draft/archived are ignored.
147
141
 
148
142
  ## Consumer Conformance
149
143
 
@@ -165,30 +159,28 @@ describe("my-mail-renderer :: template-resolver conformance", () => {
165
159
  });
166
160
  ```
167
161
 
168
- The harness checks `TemplateNotFoundError` propagation, locale-fallback, and (when `resolveResources` is provided) missing resource keys.
162
+ The harness checks `TemplateNotFoundError` propagation, locale fallback, and, when `resolveResources` is provided, missing resource keys.
169
163
 
170
- ## Text-Blöcke (`kind: "text-block"`)
164
+ ## Text blocks (`kind: "text-block"`)
171
165
 
172
- Marketing-, FAQ- und Legal-Texte laufen über denselben Store, aber über einen
173
- eigenen Write-Pfad: `set.write` trägt `title` + `folder` (Content-Tree) und
174
- kennt keinen draft-Status — Speichern veröffentlicht.
166
+ Marketing, FAQ, and legal texts use the same store, but a separate write path: `set.write` carries
167
+ `title` + `folder` (content tree) and has no draft status, saving publishes.
175
168
 
176
- | Handler | QN | Wer | Was |
169
+ | Handler | QN | Who | What |
177
170
  |---|---|---|---|
178
- | `TemplateResolverHandlers.set` | `template-resolver:write:set` | TenantAdmin + SystemAdmin (via `tenantIdOverride` auch auf `SYSTEM_TENANT_ID`) | Upsert einer Ressource pro `(tenantId, slug, kind, locale)` |
179
- | `TemplateResolverQueries.bySlug` | `template-resolver:query:by-slug` | anonymous + User + Admins | Ein Text-Block — der Public-Read für Landing-/Legal-Pages |
180
- | `TemplateResolverQueries.byTenant` | `template-resolver:query:by-tenant` | anonymous + User + Admins | Alle Text-Blöcke eines Tenants für den Content-Tree |
181
- | `<collection>-list` / `-item` / `-set` | `template-resolver:query:<id>-list` … | wie deklariert | Pro Content-Collection, siehe unten |
171
+ | `TemplateResolverHandlers.set` | `template-resolver:write:set` | TenantAdmin + SystemAdmin (via `tenantIdOverride`, also on `SYSTEM_TENANT_ID`) | Upserts one resource per `(tenantId, slug, kind, locale)` |
172
+ | `TemplateResolverQueries.bySlug` | `template-resolver:query:by-slug` | anonymous + User + Admins | One text block, the public read for landing/legal pages |
173
+ | `TemplateResolverQueries.byTenant` | `template-resolver:query:by-tenant` | anonymous + User + Admins | All text blocks of a tenant for the content tree |
174
+ | `<collection>-list` / `-item` / `-set` | `template-resolver:query:<id>-list` … | as declared | Per content collection, see below |
182
175
 
183
- Die beiden Public-Queries sind fest auf `kind: "text-block"` verdrahtet und
184
- nehmen **keinen** kind-Parameter. Mail-Templates und AI-Prompts liegen in
185
- derselben Tabelle und laufen über die Handler ihrer Collection. So kann ein
186
- neuer kind nicht versehentlich öffentlich lesbar werden.
176
+ The two public queries are hardcoded to `kind: "text-block"` and accept **no** kind parameter. Mail
177
+ templates and AI prompts are stored in the same table and use the handlers of their collection. This
178
+ prevents a new kind from accidentally becoming publicly readable.
187
179
 
188
- Seed-Helper: `seedTextBlock` / `seedLegalContentFromJson` aus
180
+ Seeding helpers: `seedTextBlock` / `seedLegalContentFromJson` from
189
181
  `@cosmicdrift/kumiko-bundled-features/template-resolver/seeding`.
190
182
 
191
- Content-Tree + Editor (Desktop-Web):
183
+ Content tree + editor (desktop web):
192
184
 
193
185
  ```typescript
194
186
  import { textBlocksClient } from "@cosmicdrift/kumiko-bundled-features/template-resolver/web";
@@ -198,11 +190,10 @@ createKumikoApp({
198
190
  });
199
191
  ```
200
192
 
201
- ## Content-Collections
193
+ ## Content collections
202
194
 
203
- Eine Sammlung erscheint dort in der Nav, wo sie fachlich hingehört — statt alles
204
- in einen zentralen "Content"-Bereich zu kippen. Deklariert wird sie **beim
205
- Mounten**, nicht im Feature:
195
+ A collection appears in the navigation where it belongs functionally, instead of putting everything
196
+ in one central "Content" section. It is declared **when mounting**, not in the feature:
206
197
 
207
198
  ```typescript
208
199
  createTemplateResolverFeature({
@@ -223,91 +214,79 @@ createTemplateResolverFeature({
223
214
  });
224
215
  ```
225
216
 
226
- `access` gehört an den Mount, weil ein bundled-feature das Rollenvokabular der
227
- App nicht kennen kann — dasselbe Muster wie die `access`-Option von
228
- `tags`/`folders`/`ledger`. Ohne Angabe gilt TenantAdmin + SystemAdmin.
217
+ `access` belongs on the mount because a bundled feature cannot know the app's role vocabulary, the same
218
+ pattern as the `access` option of `tags`/`folders`/`ledger`. Without an explicit value, TenantAdmin + SystemAdmin applies.
229
219
 
230
- Jede Collection bekommt **eigene Handler** — `<id>-list`, `<id>-item`,
231
- `<id>-set` — die je die Access-Regel ihrer Collection tragen. Deshalb setzt der
232
- Dispatcher die Trennung durch: wer Snippets pflegen darf, kommt an den
233
- AI-Prompts nicht vorbei. Ein gemeinsamer Handler mit `kind`-Parameter müsste
234
- die Vereinigung aller Rollen zulassen und im Body sortieren.
220
+ Each collection gets **its own handlers**, `<id>-list`, `<id>-item`, `<id>-set`, each carrying the
221
+ access rule of its collection. The dispatcher therefore enforces separation: anyone allowed to manage
222
+ snippets cannot access the AI prompts. A shared handler with a `kind` parameter would have to allow
223
+ the union of all roles and sort in the body.
235
224
 
236
- Der Payload trägt entsprechend **keinen** `kind` — `kind`, `ownership` und
237
- `access` stammen aus der Deklaration. Zwei Collections dürfen denselben `kind`
238
- mit unterschiedlichen Regeln führen.
225
+ Accordingly, the payload carries **no** `kind`; `kind`, `ownership` and `access` come from the
226
+ declaration. Two collections may use the same `kind` with different rules.
239
227
 
240
- `textBlocksClient()` bedient jede deklarierte Collection automatisch: ein
241
- Folder-Tree pro Collection inklusive SSE-Refresh, Editor-Ziel mit der
242
- `collectionId`. Die App reicht dafür nichts durch.
228
+ `textBlocksClient()` automatically serves every declared collection: one folder tree per collection,
229
+ including SSE refresh, and an editor target with the `collectionId`. The app passes nothing through for this.
243
230
 
244
- `nav.parent` darf auf ein fremdes Feature zeigen. Der Boot-Validator lehnt
245
- dangling Refs ab — eine Collection unter einem nicht gemounteten Feature lässt
246
- den Boot scheitern statt still aus der Sidebar zu verschwinden. Die
247
- Sichtbarkeit des Knotens folgt `access`, sofern `nav.access` nichts anderes
248
- sagt.
231
+ `nav.parent` may point to another feature. The boot validator rejects dangling references, a collection
232
+ under an unmounted feature causes boot to fail instead of silently disappearing from the sidebar. Node
233
+ visibility follows `access` unless `nav.access` specifies otherwise.
249
234
 
250
- Ein "+" am Knoten braucht ein explizites `nav.createAction` (Ziel ist
251
- üblicherweise `treeHandle.create`); ohne das listet die Collection nur, was
252
- schon existiert. `nav.actions` setzt Hover-Actions auf die Zeile.
235
+ A "+" on the node requires an explicit `nav.createAction`, whose target is usually
236
+ `treeHandle.create`; without it, the collection only lists existing items. `nav.actions` adds hover actions to the row.
253
237
 
254
- Speichern **veröffentlicht sofort** — Status `active`, leeres `variableSchema`.
255
- Wer eine Draft-Stufe oder ein Variablen-Schema braucht, nimmt
238
+ Saving **publishes immediately**, with status `active` and an empty `variableSchema`.
239
+ Anyone who needs a draft stage or a variable schema uses
256
240
  `upsertSystem`/`upsertTenant` + `publish`.
257
241
 
258
242
  ### ownership: user vs. tenant
259
243
 
260
- `ownership: "tenant"` (Default) heißt: ein Satz, den alle im Tenant teilen —
261
- kuratierte Bausteine, AI-Prompts. `ownership: "user"` heißt: jeder Enduser
262
- pflegt seine eigenen Einträge — Mail-Signaturen, persönliche Antwort-Bausteine.
263
-
264
- User-owned Collections liegen in einer **eigenen Tabelle**
265
- (`read_user_content_entries`, Entity `user-content-entry`) mit `ownerId NOT
266
- NULL` und Unique-Index `(tenantId, ownerId, slug, kind, locale)`. Zwei Agents
267
- können damit beide eine Signatur `standard` haben.
268
-
269
- Warum nicht eine Tabelle mit nullbarer `ownerId`: das `content`-Feld dort
270
- trägt `userOwned` (Name, Telefonnummer, Anschrift → crypto-shredding). Diese
271
- Annotation wird **pro Entity** aufgelöst, nicht pro Zeile, und
272
- `resolveSubjectForField` wirft, wenn die Owner-Spalte leer ist — jede
273
- tenant-weite Mail-Template-Zeile würde beim Schreiben scheitern.
274
-
275
- Beim Mounten einer user-owned Collection sind zwei Dinge Pflicht:
276
-
277
- 1. **Migration.** Die Entity kommt nur in den Schema-Diff, wenn mindestens eine
278
- user-owned Collection deklariert ist. Danach in der App
279
- `bunx kumiko-schema generate add-user-content` + `apply` — ohne die Tabelle
280
- antwortet der Handler in Prod mit 500.
281
- 2. **`template-resolver-user-data` mitmounten.** Die Entity trägt Subject-Daten;
282
- ohne den EXT_USER_DATA-Hook verweigert der Boot-Guard den Start. Das Feature
283
- liefert den Art.-20-Export; die Löschung (Art. 17) läuft über
284
- crypto-shredding, nicht über einen physischen DELETE — der wäre bei einer
285
- event-sourced Entity nicht replay-fest.
286
-
287
- Der Locale-Fallback von `resolveTemplate` gilt für user-owned Einträge **nicht**:
288
- sie werden gelistet und editiert, nie als Tenant/System-Override aufgelöst. Wer
289
- im Client eigene *und* geteilte Bausteine zeigen will, mountet zwei Collections
290
- nebeneinander und führt sie in der UI zusammen.
291
-
292
- Unter `tenantIdOverride` (SystemAdmin) bleibt die Zeile die **eigene** des
293
- Aufrufers im fremden Tenant — ein Admin editiert nie die Signatur eines anderen.
294
-
295
- ## Validierung, Layouts, Bundles
296
-
297
- - `validate` (Query) trocken-läuft die Schreib-Pipeline: `canSave`, `canPublish`, verwendete/unbekannte/fehlende Platzhalter, verfügbare Variablen. `mail`, `document-pdf`, `pdf-layout` und `mail-layout` laufen durch dasselbe Platzhalter-Gate; `page`/`pages` gibt es nur in `document-pdf` und `pdf-layout`.
298
- - `preview` (Query, TenantAdmin/SystemAdmin) rendert einen Entwurf oder, ohne `content`, die gespeicherte Vorlage mit Beispielwerten aus dem `example` jeder deklarierten Variable (Listen: zwei Zeilen, ohne `example` feste Ersatzwerte). Der Entwurf läuft durch dieselben Gates wie ein Speichern, eine Mail durch den echten Mail-Renderer. Antwort: Betreff, HTML, `displayHtml` (nur Mail: `cid:`-Bilder als Data-URI für den Editor, 5 MiB Budget), Text, Anhänge (`kind: "inline"` mit `cid` oder `kind: "file"`, jeweils mit Größe) und `checks` (`resources_resolved`, `text_present`, `size_under_cap` mit 10 MiB, `required_placeholders`). `document-pdf` liefert den bereinigten Body und die Layout-Regionen, kein PDF.
299
- - `send-test` (Write, TenantAdmin/SystemAdmin) schickt die gerenderte Mail nur an die eigene Adresse des Aufrufers, Betreff mit `[TEST] `, höchstens 10 pro Nutzer und Stunde. Es gibt keine Empfänger-Eingabe. Braucht `delivery`, `channel-email` und `renderer-mail-html`.
300
- - Layouts: Tenant-`mail-layout` und `pdf-layout` sind strukturiertes HTML mit `data-content`-Slot und Regionen (Header, Signatur, Footer). Rohes MJML mit `{{{content}}}` genau einmal darf nur das System schreiben.
301
- - `export-bundle`, `import-bundle`, `import-bundle-system`: ein Template mit allen Locales und Ressourcen-Dateien (base64) als eigenständiges Bundle; bestehende Ziele nur mit `overwrite`. Systemimport mit Ressourcen braucht einen Caller im System-Tenant.
302
- - `copy-locale`: Startpunkt für Übersetzungen, kopiert als Tenant-Draft. Mit `scope: "system"` (nur SystemAdmin) wird eine Systemvorlage als System-Draft kopiert; andere Aufrufer bekommen 403.
303
- - Mail-Anhänge: `resource-upload` mit `purpose: "attachment"` und `fileName` legt eine PDF-, PNG- oder JPEG-Datei (geprüft am Inhalt, Originalbytes, höchstens 10 pro Mail-Vorlage) neben den Inline-Bildern ab. `resource-remove`, `copy-locale` und die Bundles tragen sie mit (Bundle-Eintrag `purpose: "attachment"`). Der Mail-Renderer hängt sie nach den Inline-Bildern an.
304
- - `r.notification(..., { record: { entity, map } })` mappt eine Entity-Zeile auf die deklarierten Variablen (`mapRecordToVariables`); Boot prüft Entity, Variablen und Feldpfade.
305
-
306
- ## Out-of-Scope
307
-
308
- - Rendering (Markdown/MJML → HTML/PDF) — siehe `renderer-foundation`
309
- - Resource-URL-Substitution (signed-URL vs. data-URI) — Caller-Verantwortung je nach kind
310
- - Visual Template-Editor — `designer`-Bundle (geplant)
311
- - A/B-Testing — eigenes Bundle wenn Bedarf real
312
-
313
-
244
+ `ownership: "tenant"` (default) means one set shared by everyone in the tenant,
245
+ curated snippets and AI prompts. `ownership: "user"` means each end user maintains their own entries,
246
+ mail signatures and personal reply snippets.
247
+
248
+ User-owned collections are stored in a **separate table**
249
+ (`read_user_content_entries`, entity `user-content-entry`) with `ownerId NOT
250
+ NULL` and unique index `(tenantId, ownerId, slug, kind, locale)`. This allows two agents
251
+ to each have a `standard` signature.
252
+
253
+ Why not one table with a nullable `ownerId`: the `content` field there carries
254
+ `userOwned` data, name, phone number, address, for crypto-shredding. This annotation is resolved
255
+ **per entity**, not per row, and `resolveSubjectForField` throws when the owner column is empty,
256
+ so every tenant-wide mail template row would fail on write.
257
+
258
+ When mounting a user-owned collection, two things are required:
259
+
260
+ 1. **Migration.** The entity is included in the schema diff only if at least one
261
+ user-owned collection is declared. Then in the app run
262
+ `bunx kumiko-schema generate add-user-content` + `apply`, without the table
263
+ the handler returns 500 in production.
264
+ 2. **Mount `template-resolver-user-data` as well.** The entity carries subject data;
265
+ without the EXT_USER_DATA hook, the boot guard refuses to start. The feature provides the Art. 20
266
+ export; deletion (Art. 17) uses crypto-shredding, not a physical DELETE, which would not be
267
+ replay-safe for an event-sourced entity.
268
+
269
+ The locale fallback of `resolveTemplate` does **not** apply to user-owned entries:
270
+ they are listed and edited, never resolved as tenant/system overrides. Anyone who wants to show their own
271
+ and shared snippets in the client mounts two collections side by side and combines them in the UI.
272
+
273
+ With `tenantIdOverride` (SystemAdmin), the row remains the caller's **own** row in the foreign tenant,
274
+ an admin never edits another user's signature.
275
+
276
+ ## Validation, layouts, bundles
277
+
278
+ - `validate` (query) dry-runs the write pipeline: `canSave`, `canPublish`, used/unknown/missing placeholders, and available variables. `mail`, `document-pdf`, `pdf-layout`, and `mail-layout` pass through the same placeholder gate; `page`/`pages` exist only in `document-pdf` and `pdf-layout`.
279
+ - `preview` (query, TenantAdmin/SystemAdmin) renders a draft or, without `content`, the stored template with example values from the `example` of each declared variable, two rows for lists, fixed replacement values without `example`. The draft passes through the same gates as saving, and a mail through the actual mail renderer. Response: subject, HTML, `displayHtml` (mail only: `cid:` images as data URIs for the editor, 5 MiB budget), text, attachments (`kind: "inline"` with `cid` or `kind: "file"`, each with size), and `checks` (`resources_resolved`, `text_present`, `size_under_cap` with 10 MiB, `required_placeholders`). `document-pdf` returns the cleaned body and layout regions, not a PDF.
280
+ - `send-test` (write, TenantAdmin/SystemAdmin) sends the rendered mail only to the caller's own address, with `[TEST] ` in the subject, at most 10 per user and hour. There is no recipient input. Requires `delivery`, `channel-email`, and `renderer-mail-html`.
281
+ - Layouts: tenant `mail-layout` and `pdf-layout` are structured HTML with a `data-content` slot and regions, header, signature, footer. Raw MJML with `{{{content}}}` exactly once may only be written by the system.
282
+ - `export-bundle`, `import-bundle`, `import-bundle-system`: one template with all locales and resource files, base64 encoded, as a standalone bundle; existing targets only with `overwrite`. A system import with resources requires a caller in the system tenant.
283
+ - `copy-locale`: starting point for translations, copies as a tenant draft. With `scope: "system"` (SystemAdmin only), copies a system template as a system draft; other callers receive 403.
284
+ - Mail attachments: `resource-upload` with `purpose: "attachment"` and `fileName` stores a PDF, PNG, or JPEG file, validated by its contents and original bytes, at most 10 per mail template, alongside inline images. `resource-remove`, `copy-locale`, and the bundles include them, with bundle entry `purpose: "attachment"`. The mail renderer attaches them after the inline images.
285
+ - `r.notification(..., { record: { entity, map } })` maps an entity row to the declared variables (`mapRecordToVariables`); boot validates the entity, variables, and field paths.
286
+
287
+ ## Out of scope
288
+
289
+ - Rendering (Markdown/MJML to HTML/PDF), see `renderer-foundation`
290
+ - Resource URL substitution (signed URL vs. data URI), caller responsibility by kind
291
+ - Visual template editor, `designer` bundle (planned)
292
+ - A/B testing, separate bundle if there is real demand
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.392.0",
4
+ "type": "fix",
5
+ "title": "Tier fallback to the default tier under row-level security",
6
+ "detail": "With row-level security enabled (the default since 0.390) the boot preload, the bus-driven reloads and the trial gate of the tier-engine resolver ran on the enforcing pool without a tenant scope, read zero rows and dropped every tenant to the default tier, so toggleable features of paying tenants answered 403 feature_disabled. These reads now run in system scope; no app change is needed."
7
+ },
2
8
  {
3
9
  "version": "0.388.0",
4
10
  "type": "fix",
@@ -63,6 +63,7 @@ import {
63
63
  type TrialGate,
64
64
  } from "@cosmicdrift/kumiko-framework/engine";
65
65
  import { getUnscopedAggregateStreamMaxVersion } from "@cosmicdrift/kumiko-framework/event-store";
66
+ import { systemScopedRunner } from "@cosmicdrift/kumiko-framework/internal/row-level-security";
66
67
  import { createFallbackLogger } from "@cosmicdrift/kumiko-framework/logging";
67
68
  import {
68
69
  CACHE_SYNC_TOPICS,
@@ -497,6 +498,12 @@ export function createTierEngineFeature<
497
498
  // post-stack-setup, calls build(deps), wires effectiveFeatures.
498
499
  const plugin: TierResolverPlugin = {
499
500
  build: async (deps) => {
501
+ // Preloads and bus-driven reloads run outside any request scope and read every
502
+ // tenant's rows; the enforcing pool would return zero rows and lock paying tenants out.
503
+ const systemDb = systemScopedRunner(
504
+ deps.db,
505
+ "tier cache preloads and reloads assignments and tenant creation times of all tenants",
506
+ );
500
507
  // Always-on set: all non-toggleable features in the registry. Canonical
501
508
  // Kumiko pattern (matches the feature-toggles resolver): only features
502
509
  // that explicitly register `r.toggleable()` are tier-cuttable.
@@ -514,7 +521,7 @@ export function createTierEngineFeature<
514
521
  // have <100k tenants, so a single-pass scan is acceptable. The scaling
515
522
  // path (lazy load + LRU) is planned once a real need arises.
516
523
  type AssignmentRow = { tenantId: string; tier: string };
517
- const rows = await selectMany<AssignmentRow>(deps.db, tierAssignmentTable);
524
+ const rows = await selectMany<AssignmentRow>(systemDb, tierAssignmentTable);
518
525
  for (const row of rows) {
519
526
  cache.set(
520
527
  row.tenantId as TenantId,
@@ -526,7 +533,7 @@ export function createTierEngineFeature<
526
533
  // to the default tier, which would lock paying tenants out meanwhile.
527
534
  const reloadTenant = async (tenantId: TenantId): Promise<void> => {
528
535
  const ticket = takeTicket(tenantId);
529
- const row = await fetchOne<AssignmentRow>(deps.db, tierAssignmentTable, { tenantId });
536
+ const row = await fetchOne<AssignmentRow>(systemDb, tierAssignmentTable, { tenantId });
530
537
  // skip: a newer write or reload for this tenant started meanwhile and owns the entry
531
538
  if (latestTicket.get(tenantId) !== ticket) return;
532
539
  if (row) {
@@ -541,7 +548,7 @@ export function createTierEngineFeature<
541
548
  const reloadAllTenants = async (): Promise<void> => {
542
549
  ticketCounter += 1;
543
550
  const startedAtTicket = ticketCounter;
544
- const current = await selectMany<AssignmentRow>(deps.db, tierAssignmentTable);
551
+ const current = await selectMany<AssignmentRow>(systemDb, tierAssignmentTable);
545
552
  const loaded = new Map<TenantId, ReadonlySet<string>>();
546
553
  for (const row of current) {
547
554
  loaded.set(
@@ -623,9 +630,13 @@ export function createTierEngineFeature<
623
630
  if (!trialFeatures.has(featureName)) return false;
624
631
  let startedMsPromise = startedMemo.get(tenantId);
625
632
  if (startedMsPromise === undefined) {
626
- startedMsPromise = fetchOne<{ insertedAt?: Temporal.Instant }>(deps.db, tenantTable, {
627
- id: tenantId,
628
- }).then((row) => row?.insertedAt?.epochMilliseconds);
633
+ startedMsPromise = fetchOne<{ insertedAt?: Temporal.Instant }>(
634
+ systemDb,
635
+ tenantTable,
636
+ {
637
+ id: tenantId,
638
+ },
639
+ ).then((row) => row?.insertedAt?.epochMilliseconds);
629
640
  // Evict on rejection too — a transient DB error must not poison
630
641
  // every future call for this tenant with the same cached rejection.
631
642
  startedMsPromise.catch(() => startedMemo.delete(tenantId));
@@ -13,9 +13,9 @@
13
13
  // 7. Return {url, expiresAt}
14
14
  //
15
15
  // **Security:**
16
- // - Token-hash comparison via DB-fetchOne is not constant-time, but timing attacks
17
- // against SHA256 bytes require >>10k requests plus stable latency and are not
18
- // exploitable in a web-app context.
16
+ // - The hash is matched by a DB equality lookup, not a constant-time comparison. With
17
+ // 256 bits of token randomness a timing leak reveals at most hash prefixes, never
18
+ // a preimage.
19
19
  // - Invalid tokens return 404 without leaking existence, using the same code path
20
20
  // as a token that was not found.
21
21
  // - The token is tenant-agnostic: UUID + SHA256 makes it globally unique, so no
@@ -18,7 +18,10 @@ import {
18
18
  // 3. Plain token remains in worker memory + is passed via
19
19
  // RunExportJobsResult to the notification flow. NEVER plain in DB.
20
20
  // 4. The download endpoint hashes the incoming token + looks up the
21
- // hash entry. Constant-time comparison against timing attacks.
21
+ // row by tokenHash equality. This is a plain DB lookup,
22
+ // not a constant-time comparison: the token carries 256 bits of
23
+ // randomness, so a timing leak can reveal at most hash prefixes,
24
+ // never a preimage.
22
25
  //
23
26
  // **Multi-use within TTL**: Token is not "consumed" on download.
24
27
  // Multiple downloads until expiresAt are allowed — better UX for connection
@@ -48,8 +51,7 @@ export const exportDownloadTokenEntity = createEntity({
48
51
  reason: "technical_reference",
49
52
  }),
50
53
 
51
- // **Hash NICHT plain.** SHA256-hex (64 chars). Atom 4b verifiziert
52
- // via konstanter-Zeit-Vergleich.
54
+ // **Hash, never plain.** SHA256 hex (64 chars), looked up by equality.
53
55
  tokenHash: createTextField({
54
56
  required: true,
55
57
  maxLength: 64,
@@ -5,8 +5,10 @@
5
5
  // (SHA256 hex).
6
6
  // 2. The database stores ONLY the hash. The plain token remains in worker memory.
7
7
  // 3. The notification step sends the plain token to the user by email.
8
- // 4. The download endpoint hashes the incoming token and compares it with the
9
- // database hash using a constant-time comparison to prevent timing attacks.
8
+ // 4. The download endpoint hashes the incoming token and looks up the row
9
+ // by tokenHash. No constant-time comparison is needed: the token has
10
+ // 256 bits of randomness, so a timing leak reveals at most hash prefixes, never
11
+ // a preimage.
10
12
  //
11
13
  // **Multi-use within TTL:** The token is NOT "consumed" on download, so the user
12
14
  // can download repeatedly until expiresAt. This matches Google-Takeout (7d) and
@@ -36,8 +38,7 @@ export async function generateDownloadToken(): Promise<{
36
38
  /**
37
39
  * Hashes a plain token into its database representation. In the verification path,
38
40
  * the download endpoint hashes the incoming token and looks up the hash in the
39
- * database. A constant-time string comparison prevents timing attacks; the caller
40
- * performs it via `crypto.subtle` or a secure comparison helper.
41
+ * database (no constant-time comparison, see the header comment).
41
42
  */
42
43
  export async function hashDownloadToken(plain: string): Promise<string> {
43
44
  const encoded = new TextEncoder().encode(plain);