@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.
- package/package.json +1 -1
- package/packages/bundled-features/package.json +1 -1
- package/packages/bundled-features/src/admin-shell/changes.json +6 -0
- package/packages/bundled-features/src/admin-shell/feature.ts +60 -31
- package/packages/bundled-features/src/auth-email-password/handlers/invite-accept-with-login.write.ts +2 -2
- package/packages/bundled-features/src/auth-email-password/handlers/invite-signup-complete.write.ts +2 -2
- package/packages/bundled-features/src/cap-overview/changes.json +12 -0
- package/packages/bundled-features/src/cap-overview/handlers/caps-usage.query.ts +1 -0
- package/packages/bundled-features/src/cap-overview/handlers/tenant-caps-list.query.ts +3 -2
- package/packages/bundled-features/src/cap-overview/i18n.ts +4 -0
- package/packages/bundled-features/src/cap-overview/types.ts +4 -1
- package/packages/bundled-features/src/cap-overview/web/cap-amount-format.ts +37 -0
- package/packages/bundled-features/src/cap-overview/web/cap-cards-panel.tsx +138 -54
- package/packages/bundled-features/src/cap-overview/web/cap-usage-bar.tsx +18 -4
- package/packages/bundled-features/src/cap-overview/web/cap-usage-cell.tsx +6 -3
- package/packages/bundled-features/src/cap-overview/web/usage-math.ts +5 -1
- package/packages/bundled-features/src/channel-email/app-transport.ts +37 -0
- package/packages/bundled-features/src/channel-email/changes.json +6 -0
- package/packages/bundled-features/src/channel-email/feature.ts +6 -0
- package/packages/bundled-features/src/channel-email/index.ts +5 -0
- package/packages/bundled-features/src/channel-email/smtp-transport.ts +2 -22
- package/packages/bundled-features/src/foundation-shared/changes.json +6 -0
- package/packages/bundled-features/src/foundation-shared/index.ts +1 -0
- package/packages/bundled-features/src/foundation-shared/mail-display-name.ts +20 -0
- package/packages/bundled-features/src/inbound-mail-foundation/account-record.ts +48 -0
- package/packages/bundled-features/src/inbound-mail-foundation/changes.json +18 -0
- package/packages/bundled-features/src/inbound-mail-foundation/feature.ts +1 -1
- package/packages/bundled-features/src/inbound-mail-foundation/i18n.ts +17 -0
- package/packages/bundled-features/src/inbound-mail-foundation/index.ts +18 -0
- package/packages/bundled-features/src/inbound-mail-foundation/outbound-mail.ts +66 -0
- package/packages/bundled-features/src/inbound-mail-foundation/probe.ts +8 -45
- package/packages/bundled-features/src/inbound-mail-foundation/screens.ts +14 -0
- package/packages/bundled-features/src/inbound-mail-foundation/send-from-account.ts +118 -0
- package/packages/bundled-features/src/inbound-mail-foundation/types.ts +8 -0
- package/packages/bundled-features/src/inbound-mail-foundation/watch-supervisor.ts +13 -8
- package/packages/bundled-features/src/inbound-provider-imap/changes.json +12 -0
- package/packages/bundled-features/src/inbound-provider-imap/connect-mailbox.ts +156 -0
- package/packages/bundled-features/src/inbound-provider-imap/constants.ts +42 -0
- package/packages/bundled-features/src/inbound-provider-imap/credential-document.ts +49 -1
- package/packages/bundled-features/src/inbound-provider-imap/feature.ts +12 -35
- package/packages/bundled-features/src/inbound-provider-imap/i18n.ts +48 -0
- package/packages/bundled-features/src/inbound-provider-imap/imap-client.ts +1 -1
- package/packages/bundled-features/src/inbound-provider-imap/screens.ts +44 -0
- package/packages/bundled-features/src/inbound-provider-imap/send-mail.ts +73 -0
- package/packages/bundled-features/src/inbound-provider-imap/sent-folder.ts +49 -0
- package/packages/bundled-features/src/inbound-provider-imap/smtp-client.ts +131 -0
- package/packages/bundled-features/src/legal-pages/README.md +5 -10
- package/packages/bundled-features/src/mail-foundation/changes.json +6 -0
- package/packages/bundled-features/src/mail-foundation/i18n.ts +9 -1
- package/packages/bundled-features/src/mail-foundation/probe.ts +43 -22
- package/packages/bundled-features/src/readiness/built-in-probes.ts +15 -2
- package/packages/bundled-features/src/readiness/changes.json +30 -0
- package/packages/bundled-features/src/readiness/constants.ts +2 -0
- package/packages/bundled-features/src/readiness/i18n.ts +7 -1
- package/packages/bundled-features/src/readiness/index.ts +6 -1
- package/packages/bundled-features/src/readiness/screens.ts +25 -2
- package/packages/bundled-features/src/readiness/visible-results.ts +2 -1
- package/packages/bundled-features/src/renderer-foundation/README.md +19 -21
- package/packages/bundled-features/src/template-resolver/README.md +144 -165
- package/packages/bundled-features/src/tier-engine/changes.json +6 -0
- package/packages/bundled-features/src/tier-engine/feature.ts +17 -6
- package/packages/bundled-features/src/user-data-rights/handlers/download-by-token.query.ts +3 -3
- package/packages/bundled-features/src/user-data-rights/schema/download-token.ts +5 -3
- package/packages/bundled-features/src/user-data-rights/token-helpers.ts +5 -4
- package/samples/apps/cap-billing-demo/bin/server.ts +7 -7
- package/samples/apps/cap-billing-demo/src/feature.ts +38 -38
- package/samples/apps/cap-billing-demo/src/run-config.ts +20 -20
- package/samples/apps/cap-billing-demo/src/tier-map.ts +14 -14
- package/samples/apps/config-demo/src/app/server.ts +4 -4
- package/samples/apps/config-demo/src/features/demo/feature.ts +4 -4
- package/samples/apps/marketing-demo/playwright.config.ts +3 -3
- package/samples/apps/marketing-demo/src/app/client.tsx +2 -2
- package/samples/apps/marketing-demo/src/app/seed-data.ts +9 -9
- package/samples/apps/marketing-demo/src/app/seed.ts +14 -14
- package/samples/apps/marketing-demo/src/app/server.ts +7 -7
- package/samples/apps/marketing-demo/src/app/shell.tsx +3 -3
- package/samples/apps/marketing-demo/src/features/assets/feature.ts +3 -3
- package/samples/apps/marketing-demo/src/features/assets/i18n.ts +7 -7
- package/samples/apps/marketing-demo/src/features/assets/web/index.ts +2 -2
- package/samples/apps/showcase/src/app/client.tsx +3 -3
- package/samples/apps/showcase/src/app/seed-items.ts +14 -14
- package/samples/apps/showcase/src/app/server.ts +7 -7
- package/samples/apps/showcase/src/features/demos/feature.ts +13 -13
- package/samples/apps/showcase/src/features/demos/i18n.ts +2 -2
- package/samples/apps/showcase/src/features/demos/web/components/page.tsx +5 -5
- package/samples/apps/showcase/src/features/demos/web/index.ts +4 -4
- package/samples/apps/showcase/src/features/demos/web/pages/dialog.tsx +1 -1
- package/samples/apps/showcase/src/features/demos/web/pages/inputs.tsx +8 -8
- package/samples/apps/showcase/src/features/demos/web/pages/layout.tsx +5 -5
- package/samples/apps/showcase/src/features/demos/web/pages/toast.tsx +3 -3
- package/samples/apps/showcase/src/features/items/feature.ts +8 -8
- package/samples/apps/showcase/src/features/items/i18n.ts +3 -3
- package/samples/apps/showcase/src/features/items/index.ts +3 -3
- package/samples/apps/showcase/src/features/items/schema/index.ts +4 -4
- package/samples/apps/showcase/src/features/items/schema/item.ts +27 -27
- package/samples/apps/showcase/src/features/items/web/index.ts +6 -6
- package/samples/apps/styleguide/src/app/seed.ts +3 -3
- package/samples/apps/styleguide/src/app/server.ts +2 -2
- package/samples/apps/styleguide/src/app/shell.tsx +4 -4
- package/samples/apps/styleguide/src/features/content/web.tsx +6 -6
- package/samples/apps/styleguide/src/features/examples/feature.ts +8 -8
- package/samples/apps/styleguide/src/features/gallery/feature.ts +9 -9
- package/samples/apps/styleguide/src/features/gallery/web/Gallery.tsx +12 -12
- package/samples/apps/styleguide/src/features/widgets/feature.ts +2 -2
- package/samples/apps/styleguide/src/features/widgets/web/DashboardFilterEcho.tsx +3 -3
- package/samples/apps/styleguide/src/features/widgets/web/DashboardKpiIcon.tsx +4 -4
- package/samples/apps/ui-walkthrough/playwright.config.ts +6 -6
- package/samples/apps/ui-walkthrough/scripts/emit-e2e-data.ts +5 -5
- package/samples/apps/ui-walkthrough/src/app/auth-constants.ts +13 -13
- package/samples/apps/ui-walkthrough/src/app/client.tsx +2 -2
- package/samples/apps/ui-walkthrough/src/app/server.ts +14 -14
- package/samples/apps/ui-walkthrough/src/app/shell.tsx +3 -3
- package/samples/apps/use-all-bundled/bin/main.ts +5 -5
- package/samples/apps/use-all-bundled/feature-manifest.json +10 -4
- package/samples/apps/use-all-bundled/kumiko/schema.ts +7 -7
- package/samples/apps/use-all-bundled/scripts/check-coverage.ts +20 -20
- package/samples/apps/use-all-bundled/src/app/auth-constants.ts +4 -4
- package/samples/apps/use-all-bundled/src/app/client.tsx +4 -4
- package/samples/apps/use-all-bundled/src/app/dev-app-options.ts +12 -12
- package/samples/apps/use-all-bundled/src/app/seed.ts +6 -6
- package/samples/apps/user-data-rights-demo/src/feature.ts +23 -23
- package/samples/apps/workspaces/playwright.config.ts +5 -5
- package/samples/apps/workspaces/src/app/auth-constants.ts +3 -3
- package/samples/apps/workspaces/src/app/server.ts +16 -16
- package/samples/apps/workspaces/src/app/shell.tsx +2 -2
- package/samples/apps/workspaces/src/features/demo/schema/order.ts +4 -4
- package/samples/apps/workspaces/src/features/demo-driver/feature.ts +5 -5
- package/samples/recipes/apex-surface-auth/src/feature.ts +31 -31
- package/samples/recipes/designer-demo/src/feature.ts +6 -6
- package/samples/recipes/encrypted-tenant-config/src/feature.ts +20 -20
- package/samples/recipes/lane-routing/src/feature.ts +5 -5
- package/samples/recipes/legal-pages/src/feature.ts +29 -29
- package/samples/recipes/record-detail-layout/src/feature.ts +21 -2
- package/samples/recipes/seed-migration/scripts/smoke.ts +20 -20
- package/samples/recipes/seed-migration/seeds/2026-01-01-initial-tenants.ts +11 -11
- package/samples/recipes/seed-migration/seeds/2026-02-01-fix-admin-roles.ts +13 -13
- package/samples/recipes/user-data-rights/src/feature.ts +13 -13
|
@@ -1,15 +1,11 @@
|
|
|
1
1
|
# template-resolver
|
|
2
2
|
|
|
3
|
-
|
|
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
|
|
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
|
-
// ...
|
|
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
|
-
##
|
|
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
|
|
40
|
+
// use template.content + template.meta + template.variableSchema + template.linkedResources
|
|
45
41
|
// ...
|
|
46
42
|
}
|
|
47
43
|
```
|
|
48
44
|
|
|
49
|
-
## Kinds,
|
|
45
|
+
## Kinds, format, and meta
|
|
50
46
|
|
|
51
47
|
| kind | contentFormat | meta |
|
|
52
48
|
|---|---|---|
|
|
53
|
-
| `mail` | `html` (
|
|
54
|
-
| `mail-layout` | `mjml` |
|
|
55
|
-
| `pdf-layout` | `html` |
|
|
56
|
-
| `document-pdf` | `html` |
|
|
57
|
-
|
|
|
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`
|
|
60
|
-
`template-meta.ts`
|
|
61
|
-
|
|
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
|
|
65
|
-
|
|
59
|
+
Slugs are kebab-case segments joined with `:` (`templateSlugSchema`); notification templates
|
|
60
|
+
use their qualified name `feature:notify:name`.
|
|
66
61
|
|
|
67
|
-
##
|
|
62
|
+
## Placeholder validation (kind `mail`)
|
|
68
63
|
|
|
69
|
-
|
|
70
|
-
(`r.notification({ variables })`),
|
|
71
|
-
(`tenantName`, `signature`, `imprint`, `unsubscribeUrl`)
|
|
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`
|
|
74
|
-
- `upsertSystem`
|
|
75
|
-
- `publish`
|
|
76
|
-
- `seedSystemTemplate`
|
|
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
|
-
##
|
|
73
|
+
## Seeding notification templates
|
|
79
74
|
|
|
80
|
-
`r.notification({ variables, template, meta })`
|
|
81
|
-
`seedNotificationTemplates(db, registry)` (
|
|
82
|
-
|
|
83
|
-
|
|
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
|
|
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
|
-
|
|
87
|
+
If nothing is found, `TemplateNotFoundError`.
|
|
94
88
|
|
|
95
|
-
## Boot
|
|
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
|
|
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>
|
|
99
|
+
content: '<p>Welcome, <span data-variable="name"></span></p>',
|
|
106
100
|
contentFormat: "html",
|
|
107
101
|
});
|
|
108
102
|
```
|
|
109
103
|
|
|
110
|
-
`ifExists: "update"`
|
|
104
|
+
`ifExists: "update"` for authoritative re-seeds, for example after a template content release.
|
|
111
105
|
|
|
112
|
-
## Admin
|
|
106
|
+
## Admin workflows (write handlers + queries)
|
|
113
107
|
|
|
114
|
-
| Handler | QN |
|
|
108
|
+
| Handler | QN | Who | What |
|
|
115
109
|
|---|---|---|---|
|
|
116
|
-
| `TemplateResolverHandlers.upsertSystem` | `template-resolver:write:upsert-system` | SystemAdmin |
|
|
117
|
-
| `TemplateResolverHandlers.upsertTenant` | `template-resolver:write:upsert-tenant` | TenantAdmin (
|
|
118
|
-
| `TemplateResolverHandlers.publish` | `template-resolver:write:publish` | TenantAdmin (
|
|
119
|
-
| `TemplateResolverHandlers.archive` | `template-resolver:write:archive` | TenantAdmin (
|
|
120
|
-
| `TemplateResolverHandlers.publishSystem` | `template-resolver:write:publish-system` | SystemAdmin |
|
|
121
|
-
| `TemplateResolverHandlers.archiveSystem` | `template-resolver:write:archive-system` | SystemAdmin |
|
|
122
|
-
| `TemplateResolverHandlers.resetToSystem` | `template-resolver:write:reset-to-system` | TenantAdmin (
|
|
123
|
-
| `TemplateResolverQueries.findById` | `template-resolver:query:find-by-id` | TenantAdmin + User (
|
|
124
|
-
| `TemplateResolverQueries.list` | `template-resolver:query:list` |
|
|
125
|
-
| `TemplateResolverQueries.usage` | `template-resolver:query:usage` | TenantAdmin |
|
|
126
|
-
| `TemplateResolverQueries.overview` | `template-resolver:query:overview` | TenantAdmin + SystemAdmin |
|
|
127
|
-
| `TemplateResolverQueries.layoutSource` | `template-resolver:query:layout-source` | TenantAdmin + SystemAdmin |
|
|
128
|
-
| `TemplateResolverQueries.tenantOverrides` | `template-resolver:query:tenant-overrides` | SystemAdmin |
|
|
129
|
-
| `TemplateResolverQueries.diffToSystem` | `template-resolver:query:diff-to-system` | TenantAdmin (
|
|
130
|
-
|
|
131
|
-
**SystemAdmin-
|
|
132
|
-
|
|
133
|
-
## Status
|
|
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" (
|
|
138
|
-
upsertTenant ──┴──► status: "draft" (
|
|
131
|
+
├──► status: "active" (system default active immediately)
|
|
132
|
+
upsertTenant ──┴──► status: "draft" (default) | "active" (explicit)
|
|
139
133
|
|
|
140
|
-
publish ───────► status: "active" (
|
|
134
|
+
publish ───────► status: "active" (publishes an open draft)
|
|
141
135
|
archive ───────► status: "archived"
|
|
142
|
-
discardDraft ──►
|
|
136
|
+
discardDraft ──► discards the draft alongside the active version
|
|
143
137
|
```
|
|
144
138
|
|
|
145
|
-
|
|
146
|
-
|
|
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
|
|
162
|
+
The harness checks `TemplateNotFoundError` propagation, locale fallback, and, when `resolveResources` is provided, missing resource keys.
|
|
169
163
|
|
|
170
|
-
## Text
|
|
164
|
+
## Text blocks (`kind: "text-block"`)
|
|
171
165
|
|
|
172
|
-
Marketing
|
|
173
|
-
|
|
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 |
|
|
169
|
+
| Handler | QN | Who | What |
|
|
177
170
|
|---|---|---|---|
|
|
178
|
-
| `TemplateResolverHandlers.set` | `template-resolver:write:set` | TenantAdmin + SystemAdmin (via `tenantIdOverride
|
|
179
|
-
| `TemplateResolverQueries.bySlug` | `template-resolver:query:by-slug` | anonymous + User + Admins |
|
|
180
|
-
| `TemplateResolverQueries.byTenant` | `template-resolver:query:by-tenant` | anonymous + User + Admins |
|
|
181
|
-
| `<collection>-list` / `-item` / `-set` | `template-resolver:query:<id>-list` … |
|
|
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
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
-
|
|
180
|
+
Seeding helpers: `seedTextBlock` / `seedLegalContentFromJson` from
|
|
189
181
|
`@cosmicdrift/kumiko-bundled-features/template-resolver/seeding`.
|
|
190
182
|
|
|
191
|
-
Content
|
|
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
|
|
193
|
+
## Content collections
|
|
202
194
|
|
|
203
|
-
|
|
204
|
-
in
|
|
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`
|
|
227
|
-
|
|
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
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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
|
-
|
|
237
|
-
|
|
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()`
|
|
241
|
-
|
|
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`
|
|
245
|
-
|
|
246
|
-
|
|
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
|
-
|
|
251
|
-
|
|
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
|
-
|
|
255
|
-
|
|
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"` (
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
User-owned
|
|
265
|
-
(`read_user_content_entries`,
|
|
266
|
-
NULL`
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
-
|
|
298
|
-
- `
|
|
299
|
-
- `
|
|
300
|
-
-
|
|
301
|
-
- `
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
-
|
|
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>(
|
|
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>(
|
|
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>(
|
|
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 }>(
|
|
627
|
-
|
|
628
|
-
|
|
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
|
-
// -
|
|
17
|
-
//
|
|
18
|
-
//
|
|
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
|
-
//
|
|
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
|
|
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
|
|
9
|
-
//
|
|
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
|
|
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);
|