@cosmicdrift/kumiko-samples 0.403.0 → 0.404.1

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 (77) hide show
  1. package/package.json +1 -1
  2. package/packages/bundled-features/package.json +25 -25
  3. package/packages/bundled-features/src/account-security/README.md +7 -5
  4. package/packages/bundled-features/src/audit/feature.ts +1 -1
  5. package/packages/bundled-features/src/auth-email-password/web/auth-gate.tsx +4 -0
  6. package/packages/bundled-features/src/data-retention/feature.ts +1 -1
  7. package/packages/bundled-features/src/delivery/feature.ts +1 -1
  8. package/packages/bundled-features/src/documents/changes.json +8 -0
  9. package/packages/bundled-features/src/feature-toggles/changes.json +6 -0
  10. package/packages/bundled-features/src/feature-toggles/toggle-sync-signal.ts +6 -2
  11. package/packages/bundled-features/src/form-draft/feature.ts +1 -1
  12. package/packages/bundled-features/src/host-routing/README.md +10 -10
  13. package/packages/bundled-features/src/inbound-mail-foundation/handlers/thread-rollup.ts +1 -0
  14. package/packages/bundled-features/src/inbound-mail-foundation/retention-job.ts +1 -1
  15. package/packages/bundled-features/src/jobs/changes.json +6 -0
  16. package/packages/bundled-features/src/jobs/feature.ts +6 -7
  17. package/packages/bundled-features/src/legal-pages/README.md +21 -13
  18. package/packages/bundled-features/src/renderer-foundation/README.md +6 -4
  19. package/packages/bundled-features/src/renderer-pdf/README.md +5 -1
  20. package/packages/bundled-features/src/template-resolver/README.md +29 -29
  21. package/packages/bundled-features/src/template-resolver/document-sanitizer.ts +3 -2
  22. package/packages/bundled-features/src/template-resolver/web/collection-entry-create-form.tsx +1 -0
  23. package/packages/bundled-features/src/tenant-lifecycle/stages.ts +1 -0
  24. package/packages/bundled-features/src/user-data-rights/COMPLIANCE.md +36 -36
  25. package/packages/bundled-features/src/user-data-rights/README.md +33 -33
  26. package/packages/bundled-features/src/user-data-rights/feature.ts +3 -3
  27. package/packages/bundled-features/src/user-data-rights/web/public-deletion-gate.tsx +1 -0
  28. package/samples/apps/admin-console/README.md +5 -5
  29. package/samples/apps/admin-console/package.json +3 -3
  30. package/samples/apps/cap-billing-demo/README.md +15 -15
  31. package/samples/apps/config-demo/README.md +7 -7
  32. package/samples/apps/config-demo/package.json +2 -2
  33. package/samples/apps/marketing-demo/README.md +2 -2
  34. package/samples/apps/marketing-demo/package.json +2 -2
  35. package/samples/apps/showcase/package.json +3 -3
  36. package/samples/apps/styleguide/package.json +2 -2
  37. package/samples/apps/ui-walkthrough/README.md +8 -9
  38. package/samples/apps/ui-walkthrough/package.json +2 -2
  39. package/samples/apps/use-all-bundled/README.md +2 -2
  40. package/samples/apps/use-all-bundled/package.json +2 -2
  41. package/samples/apps/use-all-bundled/scripts/gen-sample-index.ts +18 -1
  42. package/samples/apps/user-data-rights-demo/README.md +12 -12
  43. package/samples/apps/workspaces/package.json +2 -2
  44. package/samples/recipes/apex-landing/README.md +3 -3
  45. package/samples/recipes/apex-landing/package.json +1 -1
  46. package/samples/recipes/apex-surface-auth/README.md +13 -9
  47. package/samples/recipes/auth-mfa/README.md +2 -4
  48. package/samples/recipes/auth-mfa/package.json +2 -2
  49. package/samples/recipes/boot-check/README.md +2 -2
  50. package/samples/recipes/compliance-profiles-demo/README.md +5 -5
  51. package/samples/recipes/compose-stacks/README.md +8 -8
  52. package/samples/recipes/cross-feature-events/README.md +1 -0
  53. package/samples/recipes/crypto-shredding-hr/README.md +3 -3
  54. package/samples/recipes/custom-fields-basic/README.md +1 -1
  55. package/samples/recipes/documents/README.md +25 -0
  56. package/samples/recipes/embedded-entity-form/package.json +2 -2
  57. package/samples/recipes/encrypted-tenant-config/README.md +8 -8
  58. package/samples/recipes/env-composition/README.md +10 -10
  59. package/samples/recipes/error-contract/README.md +9 -9
  60. package/samples/recipes/event-sourcing/README.md +9 -9
  61. package/samples/recipes/lane-routing/README.md +1 -0
  62. package/samples/recipes/lane-routing/src/feature.ts +8 -0
  63. package/samples/recipes/ledger-invoicing/README.md +6 -6
  64. package/samples/recipes/legal-pages/README.md +16 -16
  65. package/samples/recipes/managed-config/README.md +15 -11
  66. package/samples/recipes/managed-pages/README.md +7 -7
  67. package/samples/recipes/pdf-audit-export/package.json +1 -1
  68. package/samples/recipes/pipeline-basics/README.md +13 -13
  69. package/samples/recipes/public-share-token/README.md +4 -4
  70. package/samples/recipes/record-detail-layout/package.json +3 -3
  71. package/samples/recipes/security-baseline/README.md +4 -1
  72. package/samples/recipes/session-revocation/README.md +1 -1
  73. package/samples/recipes/tenant-settings/README.md +4 -4
  74. package/samples/recipes/tier-admin/README.md +2 -2
  75. package/samples/recipes/user-data-rights/README.md +21 -19
  76. package/samples/recipes/wizard-form/package.json +3 -3
  77. package/samples/recipes/writeform-section/package.json +3 -3
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-samples",
3
- "version": "0.403.0",
3
+ "version": "0.404.1",
4
4
  "description": "Source trees of the Kumiko sample recipes, sample apps and bundled features in repo layout, for tooling such as few-shot corpus builds.",
5
5
  "license": "MIT",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.403.0",
3
+ "version": "0.404.1",
4
4
  "description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
5
5
  "license": "MIT",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -165,38 +165,38 @@
165
165
  "@cosmicdrift/kumiko-framework": "workspace:*",
166
166
  "@cosmicdrift/kumiko-renderer": "workspace:*",
167
167
  "@cosmicdrift/kumiko-types": "workspace:*",
168
- "@mollie/api-client": "^4.5.0",
169
- "@node-rs/argon2": "^2.0.2",
170
- "@tiptap/core": "^3.31.3",
171
- "@tiptap/extension-link": "^3.31.3",
172
- "@tiptap/extension-list": "^3.31.3",
173
- "@tiptap/extension-table": "^3.31.3",
174
- "@tiptap/html": "^3.31.3",
175
- "@tiptap/extensions": "^3.31.3",
176
- "@tiptap/pm": "^3.31.3",
177
- "@tiptap/react": "^3.31.3",
178
- "@tiptap/starter-kit": "^3.31.3",
179
- "@types/mailparser": "^3.4.6",
180
- "@types/qrcode": "^1.5.5",
168
+ "@mollie/api-client": "^4.6.0",
169
+ "@node-rs/argon2": "^2.2.2",
170
+ "@tiptap/core": "^3.32.1",
171
+ "@tiptap/extension-link": "^3.32.1",
172
+ "@tiptap/extension-list": "^3.32.1",
173
+ "@tiptap/extension-table": "^3.32.1",
174
+ "@tiptap/html": "^3.32.1",
175
+ "@tiptap/extensions": "^3.32.1",
176
+ "@tiptap/pm": "^3.32.1",
177
+ "@tiptap/react": "^3.32.1",
178
+ "@tiptap/starter-kit": "^3.32.1",
179
+ "@types/mailparser": "^3.9.0",
180
+ "@types/qrcode": "^1.5.6",
181
181
  "clsx": "^2.1.1",
182
182
  "happy-dom": "^21.0.4",
183
183
  "hono": "^4.13.14",
184
184
  "imapflow": "^1.7.8",
185
- "ioredis": "^5.10.1",
186
- "jose": "^6.2.13",
187
- "lucide-react": "^1.14.0",
188
- "mailparser": "^3.12.4",
189
- "marked": "^18.0.3",
190
- "nodemailer": "^10.2.1",
185
+ "ioredis": "^5.11.1",
186
+ "jose": "^6.2.14",
187
+ "lucide-react": "^1.65.0",
188
+ "mailparser": "^3.14.1",
189
+ "marked": "^18.1.2",
190
+ "nodemailer": "^10.3.1",
191
191
  "pdf-lib": "^1.17.1",
192
192
  "qrcode": "^1.5.4",
193
- "react": "^19.2.6",
194
- "react-dom": "^19.2.6",
193
+ "react": "^19.3.1",
194
+ "react-dom": "^19.3.1",
195
195
  "sharp": "^0.35.5",
196
196
  "stripe": "^22.6.2",
197
- "tailwind-merge": "^3.6.0",
197
+ "tailwind-merge": "^3.7.0",
198
198
  "temporal-polyfill": "^0.3.2",
199
- "uuid": "^14.0.0",
199
+ "uuid": "^14.0.3",
200
200
  "zod": "^4.6.7"
201
201
  },
202
202
  "publishConfig": {
@@ -764,7 +764,7 @@
764
764
  "@cosmicdrift/kumiko-locale-it": "workspace:*",
765
765
  "@cosmicdrift/kumiko-renderer-mail-html": "workspace:*",
766
766
  "jsqr": "^1.4.0",
767
- "unpdf": "^1.8.1",
767
+ "unpdf": "^1.9.1",
768
768
  "@cosmicdrift/kumiko-testing": "workspace:*"
769
769
  },
770
770
  "peerDependencies": {
@@ -15,17 +15,19 @@ import { createSessionsFeature } from "@cosmicdrift/kumiko-bundled-features/sess
15
15
 
16
16
  const features = [
17
17
  createSessionsFeature(),
18
- createAuthMfaFeature({ /* secrets, issuer */ }),
18
+ createAuthMfaFeature({
19
+ /* secrets, issuer */
20
+ }),
19
21
  createAccountSecurityFeature(),
20
22
  ];
21
23
  ```
22
24
 
23
25
  ## Options
24
26
 
25
- | Option | Default | Effect |
26
- | --- | --- | --- |
27
- | `access` | `openToAll` | Who may open the dashboard, e.g. `{ roles: ["Admin", "Editor"] }` |
28
- | `nav` | none | `{ order?, parent?, icon? }` (icon default `"user"`) adds a sidebar entry. Without it the screen is dormant and apps link it from the user menu |
27
+ | Option | Default | Effect |
28
+ | -------- | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
29
+ | `access` | `openToAll` | Who may open the dashboard, e.g. `{ roles: ["Admin", "Editor"] }` |
30
+ | `nav` | none | `{ order?, parent?, icon? }` (icon default `"user"`) adds a sidebar entry. Without it the screen is dormant and apps link it from the user menu |
29
31
 
30
32
  The MFA panels switch on `auth-mfa:query:status` (`enabled`), exported as
31
33
  `accountSecurityMfaStatusVisibility`.
@@ -77,7 +77,7 @@ export function createAuditFeature(): FeatureDefinition {
77
77
 
78
78
  r.job({
79
79
  name: "escape-hatch-retention",
80
- trigger: { cron: "30 3 * * *" },
80
+ trigger: { maintenance: true },
81
81
  concurrency: "skip",
82
82
  // pruneEvents needs a transaction-capable connection, which only unsafeRaw() hands a job.
83
83
  escapeHatch: {
@@ -73,6 +73,7 @@ export function createLoginRoute(
73
73
  const MfaVerifyComponent = opts.mfaVerifyScreen;
74
74
  const MfaSetupComponent = opts.mfaSetupScreen;
75
75
 
76
+ // biome-ignore lint/correctness/noComponentHookFactories: setup-time factory (module scope or makeAuthGate); the route takes no props, so opts are bound here
76
77
  function LoginRoute(): ReactNode {
77
78
  const { status, refresh, bootstrapFailure, logout } = useSession();
78
79
  const { onAuthenticated } = opts;
@@ -212,6 +213,7 @@ function resolveLoginRedirectOrNull(
212
213
  try {
213
214
  return resolveLoginRedirect(loginUrl, locale, location);
214
215
  } catch (error) {
216
+ // biome-ignore lint/suspicious/noConsole: the browser console is the only place a broken loginUrl builder shows up, the visitor just sees the built-in login
215
217
  console.error("Auth gate: loginUrl could not be resolved, showing the built-in login", error);
216
218
  return null;
217
219
  }
@@ -223,6 +225,7 @@ export function makeAuthGate(opts: AuthGateOptions = {}): ComponentType<{
223
225
  const { loginUrl } = opts;
224
226
  if (typeof loginUrl === "string") assertNavigableUrl(loginUrl, "loginUrl");
225
227
  const LoginRoute = createLoginRoute(opts);
228
+ // biome-ignore lint/correctness/noComponentHookFactories: setup-time factory (makeSessionAuthGate or an app's mount function); gates only receive children, so opts are bound here
226
229
  function AuthGate({ children }: { readonly children: ReactNode }): ReactNode {
227
230
  const { status } = useSession();
228
231
  const locale = useLocale().locale();
@@ -255,6 +258,7 @@ export function makeSessionAuthGate(opts: SessionAuthGateOptions = {}): Componen
255
258
  const { postLogoutUrl } = opts;
256
259
  if (postLogoutUrl !== undefined) assertNavigableUrl(postLogoutUrl, "postLogoutUrl");
257
260
  const AuthGate = makeAuthGate(opts);
261
+ // biome-ignore lint/correctness/noComponentHookFactories: setup-time factory, called once per emailPasswordClient(); gates only receive children, so opts are bound here
258
262
  function SessionAuthGate({ children }: { readonly children: ReactNode }): ReactNode {
259
263
  return (
260
264
  <SessionProvider postLogoutUrl={postLogoutUrl}>
@@ -71,7 +71,7 @@ export function createDataRetentionFeature(): FeatureDefinition {
71
71
  // retention rules are configured but never enforced.
72
72
  r.job({
73
73
  name: "retention-cleanup",
74
- trigger: { cron: "0 3 * * *" },
74
+ trigger: { maintenance: true },
75
75
  perTenant: true,
76
76
  concurrency: "skip",
77
77
  escapeHatch: {
@@ -197,7 +197,7 @@ export function createDeliveryFeature(options?: DeliveryFeatureOptions): Feature
197
197
  if (attemptLogRetentionDays !== undefined) {
198
198
  r.job({
199
199
  name: DeliveryJobNames.attemptLogRetention,
200
- trigger: { cron: "15 3 * * *" },
200
+ trigger: { maintenance: true },
201
201
  concurrency: "skip",
202
202
  escapeHatch: { reason: ATTEMPT_LOG_RETENTION_ESCAPE_HATCH_REASON },
203
203
  handler: async (_payload, ctx) => {
@@ -0,0 +1,8 @@
1
+ [
2
+ {
3
+ "version": "0.404.1",
4
+ "type": "fix",
5
+ "title": "Documents recipe has a README and gen-sample-index rejects a primarySample without one",
6
+ "detail": "feature-reference linked to /en/samples/recipes-documents/, a page the docs site never generated because the recipe had no README.md. The README is added, and gen-sample-index aborts with the feature, the sample and the fix when a primarySample from sample-index.overrides.json has no README.md."
7
+ }
8
+ ]
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.404.0",
4
+ "type": "improvement",
5
+ "title": "Feature-toggle Redis signal follows KUMIKO_REDIS_PREFIX",
6
+ "detail": "createRedisToggleSyncSignal takes an optional channelSubscribePattern (default from KUMIKO_REDIS_PREFIX) and ignores messages from other channels, which a prefix-wide subscription delivers."
7
+ },
2
8
  {
3
9
  "version": "0.365.0",
4
10
  "type": "fix",
@@ -4,6 +4,7 @@ import {
4
4
  createRedisPubSubSignal,
5
5
  type RedisClientOptions,
6
6
  redisChannelPrefixFromEnv,
7
+ redisNamespaceFromEnv,
7
8
  } from "@cosmicdrift/kumiko-framework/redis";
8
9
  import { isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
9
10
  import type { ToggleSyncSignal } from "./toggle-runtime.ts";
@@ -36,11 +37,12 @@ export function createRedisToggleSyncSignal(
36
37
  redisUrl: string,
37
38
  clientOptions?: RedisClientOptions,
38
39
  channelPrefix: string = redisChannelPrefixFromEnv(),
40
+ channelSubscribePattern: string | undefined = redisNamespaceFromEnv().channelSubscribePattern,
39
41
  ): RedisToggleSyncSignal {
40
42
  const toggleSyncChannel = `${channelPrefix}${TOGGLE_SYNC_CHANNEL_NAME}`;
41
43
  const signal = createRedisPubSubSignal({
42
44
  redisUrl,
43
- channelPattern: toggleSyncChannel,
45
+ channelPattern: channelSubscribePattern ?? toggleSyncChannel,
44
46
  label: "feature-toggles",
45
47
  ...(clientOptions ? { clientOptions } : {}),
46
48
  });
@@ -50,7 +52,9 @@ export function createRedisToggleSyncSignal(
50
52
  signal.publish(toggleSyncChannel, { featureName, enabled });
51
53
  },
52
54
  onMessage(listener) {
53
- signal.onMessage((_channel, payload) => {
55
+ signal.onMessage((channel, payload) => {
56
+ // skip: a wide subscribe pattern also delivers other features' channels
57
+ if (channel !== toggleSyncChannel) return;
54
58
  if (!isTogglePayload(payload)) {
55
59
  // biome-ignore lint/suspicious/noConsole: ops-visible fallback — no ctx.obs.log in a raw PubSubSignal handler.
56
60
  console.error(
@@ -43,7 +43,7 @@ function registerFormDraft(r: FeatureRegistrar<typeof FORM_DRAFT_FEATURE_NAME>):
43
43
  r.config({ keys: { retentionDays: formDraftRetentionDaysConfig } });
44
44
  r.job({
45
45
  name: "cleanup",
46
- trigger: { cron: "0 3 * * *" },
46
+ trigger: { maintenance: true },
47
47
  concurrency: "skip",
48
48
  escapeHatch: { reason: CLEANUP_ESCAPE_HATCH_REASON },
49
49
  handler: (payload, ctx) =>
@@ -55,16 +55,16 @@ the app sets it.
55
55
 
56
56
  ## Options
57
57
 
58
- | Option | Meaning |
59
- |---|---|
60
- | `tenantBy` | `"host"` (`<key>.<baseDomain>`) or `"path"` with `tenantPathPrefix` (`/t/<key>/...` on the apex) |
61
- | `baseDomain` | Apex hostname, lowercase, no port. `www.<baseDomain>` counts as apex |
62
- | `apex` | `{ file, csp?, localeFiles? }` for the apex |
63
- | `tenant` | `{ file, csp? }` for a known tenant and for the bare path prefix |
64
- | `unknownTenant` | `{ file, csp? }` served with 404; omit for a bare 404 |
65
- | `hosts` | Further fixed hostnames with their own surface, checked first |
66
- | `apexAnonymousTenantId` | Tenant for anonymous API calls on the apex (e.g. `SYSTEM_TENANT_ID` for public signup); omit and the apex has none |
67
- | `locales` | `{ default, prefixed }`: `/<default>/...` answers 301 to the unprefixed path, `/<prefixed>/...` serves `apex.localeFiles[locale]` |
58
+ | Option | Meaning |
59
+ | ----------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
60
+ | `tenantBy` | `"host"` (`<key>.<baseDomain>`) or `"path"` with `tenantPathPrefix` (`/t/<key>/...` on the apex) |
61
+ | `baseDomain` | Apex hostname, lowercase, no port. `www.<baseDomain>` counts as apex |
62
+ | `apex` | `{ file, csp?, localeFiles? }` for the apex |
63
+ | `tenant` | `{ file, csp? }` for a known tenant and for the bare path prefix |
64
+ | `unknownTenant` | `{ file, csp? }` served with 404; omit for a bare 404 |
65
+ | `hosts` | Further fixed hostnames with their own surface, checked first |
66
+ | `apexAnonymousTenantId` | Tenant for anonymous API calls on the apex (e.g. `SYSTEM_TENANT_ID` for public signup); omit and the apex has none |
67
+ | `locales` | `{ default, prefixed }`: `/<default>/...` answers 301 to the unprefixed path, `/<prefixed>/...` serves `apex.localeFiles[locale]` |
68
68
 
69
69
  A `csp` replaces runProdApp's default CSP for that surface; without it the
70
70
  default applies. Options are validated when the feature or dispatch is built.
@@ -107,6 +107,7 @@ export async function appendThreadRollup(
107
107
  // @cast-boundary engine-payload — eigene events, shape via defineEvent
108
108
  const previousThread = lastThreadEvent?.payload as MailThreadEventPayload | undefined;
109
109
  const previousLastAt = previousThread?.lastMessageAtIso ?? "";
110
+ // biome-ignore lint/complexity/useMathMinMax: ISO strings compare lexicographically; Math.max would coerce them to NaN
110
111
  const newLastAt = receivedAtIso > previousLastAt ? receivedAtIso : previousLastAt;
111
112
  const messageCount = await countWhere(threadRollupRunner, inboundMessagesProjectionTable, {
112
113
  tenantId,
@@ -18,7 +18,7 @@ export function registerInboundMailRetentionJob(r: FeatureRegistrar<"inbound-mai
18
18
  "inbound-mail retention runs raw deletes; every statement filters by this run's tenantId";
19
19
  r.job({
20
20
  name: "inbound-mail-retention",
21
- trigger: { cron: "0 3 * * *" },
21
+ trigger: { maintenance: true },
22
22
  perTenant: true,
23
23
  concurrency: "skip",
24
24
  escapeHatch: { reason: INBOUND_MAIL_RETENTION_REASON },
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.404.0",
4
+ "type": "improvement",
5
+ "title": "Cleanup jobs run once a day in the maintenance window",
6
+ "detail": "jobs (stale-run-sweep, hourly before, and retention-cleanup), user-data-rights (export and forget), data-retention, audit escape-hatch-retention, inbound-mail retention, delivery attempt-log-retention and form-draft cleanup use trigger { maintenance: true } instead of 03:00 to 03:30 and hourly crons. Without KUMIKO_MAINTENANCE_WINDOW they all run at 03:00 UTC. A stuck job run is now marked failed at most a day after it crosses the stale timeout."
7
+ },
2
8
  {
3
9
  "version": "0.394.0",
4
10
  "type": "fix",
@@ -43,7 +43,7 @@ export type JobsFeatureOptions = {
43
43
  // store_job_run_logs before the daily retention-cleanup job deletes it. Also the age after
44
44
  // which a tenant-visible "queued" row counts as lost and is dropped by the stale-run sweep.
45
45
  readonly retentionDays?: number;
46
- // How long a run can sit at status "running" before the hourly
46
+ // How long a run can sit at status "running" before the daily
47
47
  // stale-run-sweep job marks it "failed" — a process kill mid-run
48
48
  // never fires onJobComplete/onJobFailed, so nothing else ever revisits
49
49
  // it). Must clear any legitimately long-running job (reindexEntity,
@@ -120,18 +120,17 @@ export function createJobsFeature(options: JobsFeatureOptions = {}): FeatureDefi
120
120
  // stores — nothing else purges them.
121
121
  r.job(
122
122
  "retention-cleanup",
123
- { trigger: { cron: "0 3 * * *" }, concurrency: "skip" },
123
+ { trigger: { maintenance: true }, concurrency: "skip" },
124
124
  createRetentionCleanupJob(retentionDays),
125
125
  );
126
126
 
127
127
  // Sweeps store_job_runs for rows stuck at status "running" past the
128
- // timeout. Hourly matches the timeout's own granularity —
129
- // frequent enough to catch a stuck run soon after it crosses the
130
- // threshold, cheap enough that an (almost always empty) result set
131
- // doesn't matter.
128
+ // timeout. Runs in the maintenance window: the timeout is
129
+ // hours, so a daily pass finds a stuck run at most a day late and a sleeping
130
+ // app is not woken every hour for it.
132
131
  r.job(
133
132
  "stale-run-sweep",
134
- { trigger: { cron: "0 * * * *" }, concurrency: "skip" },
133
+ { trigger: { maintenance: true }, concurrency: "skip" },
135
134
  createStaleRunSweepJob(staleRunTimeoutHours, retentionDays),
136
135
  );
137
136
 
@@ -25,7 +25,7 @@ import { SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
25
25
  runProdApp({
26
26
  envSchema, // composeEnvSchema({ core: frameworkCoreEnvSchema, features })
27
27
  features: [
28
- createTemplateResolverFeature(), // legal-pages requires template-resolver
28
+ createTemplateResolverFeature(), // legal-pages requires template-resolver
29
29
  createLegalPagesFeature(),
30
30
  /* ... */
31
31
  ],
@@ -60,12 +60,12 @@ See [template-resolver/README.md](../template-resolver/README.md).
60
60
 
61
61
  **Default** (DACH: DE required, EN optional):
62
62
 
63
- | Path | Slug + locale | Title fallback (when block empty) |
64
- |---|---|---|
65
- | `GET /legal/impressum` | `imprint` / `de` | "Impressum" |
66
- | `GET /legal/datenschutz` | `privacy` / `de` | "Datenschutzerklärung" |
67
- | `GET /legal/imprint` | `imprint` / `en` | "Imprint" |
68
- | `GET /legal/privacy` | `privacy` / `en` | "Privacy Policy" |
63
+ | Path | Slug + locale | Title fallback (when block empty) |
64
+ | ------------------------ | ---------------- | --------------------------------- |
65
+ | `GET /legal/impressum` | `imprint` / `de` | "Impressum" |
66
+ | `GET /legal/datenschutz` | `privacy` / `de` | "Datenschutzerklärung" |
67
+ | `GET /legal/imprint` | `imprint` / `en` | "Imprint" |
68
+ | `GET /legal/privacy` | `privacy` / `en` | "Privacy Policy" |
69
69
 
70
70
  Response:
71
71
  - `200 text/html` — block exists + has content. Cache header `public, max-age=300`.
@@ -112,11 +112,19 @@ that needs an additional page (terms/AGB), passes its own lists:
112
112
  createLegalPagesFeature({
113
113
  routes: [
114
114
  { path: "/legal/aviso-legal", slug: "imprint", lang: "es", titleFallback: "Aviso legal" },
115
- { path: "/legal/privacidad", slug: "privacy", lang: "es", titleFallback: "Política de privacidad" },
115
+ {
116
+ path: "/legal/privacidad",
117
+ slug: "privacy",
118
+ lang: "es",
119
+ titleFallback: "Política de privacidad",
120
+ },
116
121
  { path: "/legal/terminos", slug: "terms", lang: "es", titleFallback: "Términos y condiciones" },
117
122
  ],
118
- requiredBlocks: [{ slug: "imprint", lang: "es" }, { slug: "privacy", lang: "es" }],
119
- })
123
+ requiredBlocks: [
124
+ { slug: "imprint", lang: "es" },
125
+ { slug: "privacy", lang: "es" },
126
+ ],
127
+ });
120
128
  ```
121
129
 
122
130
  `slug` is free-form — it only has to match the `slug` you seed the
@@ -162,10 +170,10 @@ starts and checks whether the required blocks exist in SYSTEM_TENANT.
162
170
  A throw rejects the boot, so the process never reports ready.
163
171
  **Default** (DACH):
164
172
 
165
- | Slug + locale | What happens when missing |
166
- |---|---|
173
+ | Slug + locale | What happens when missing |
174
+ | ---------------- | ----------------------------------------------------------------------------------------- |
167
175
  | `imprint` / `de` | **Production:** `throw new Error(...)` blocks app start. **Dev:** `ctx.obs.log.warn(...)` |
168
- | `privacy` / `de` | as above |
176
+ | `privacy` / `de` | as above |
169
177
 
170
178
  EN versions are **not** boot-fail-relevant by default (`LEGAL_OPTIONAL_BLOCKS`).
171
179
  Routes return `404` if an EN block is missing.
@@ -21,9 +21,7 @@ const features = [
21
21
  const app = createKumikoApp({
22
22
  features,
23
23
  extraContext: ({ registry }) => ({
24
- rendererFoundation: createRendererFoundationApi(
25
- collectRendererPlugins(registry),
26
- ),
24
+ rendererFoundation: createRendererFoundationApi(collectRendererPlugins(registry)),
27
25
  }),
28
26
  });
29
27
  ```
@@ -39,7 +37,11 @@ async function sendMail(ctx, tenantId) {
39
37
  const result = await renderer.render(
40
38
  {
41
39
  kind: "mail",
42
- payload: { slug: "billing:notify:invoice-ready", locale: "de", variables: { name: "Frau Schmidt" } },
40
+ payload: {
41
+ slug: "billing:notify:invoice-ready",
42
+ locale: "de",
43
+ variables: { name: "Frau Schmidt" },
44
+ },
43
45
  },
44
46
  { db: ctx.db, registry: ctx.registry, tenantId, config: ctx.config },
45
47
  );
@@ -11,7 +11,11 @@ const { pdfBytes, pageCount } = await renderTablePdf(
11
11
  {
12
12
  title: "Prüfprotokoll",
13
13
  meta: [{ label: "Anlage", value: "Kessel 3" }],
14
- columns: [{ header: "Datum" }, { header: "Prüfer", weight: 2 }, { header: "Kosten", align: "right" }],
14
+ columns: [
15
+ { header: "Datum" },
16
+ { header: "Prüfer", weight: 2 },
17
+ { header: "Kosten", align: "right" },
18
+ ],
15
19
  rows: [["03.02.2026", "Müller", "120,00 €"]],
16
20
  footerText: "Kumiko GmbH",
17
21
  pageLabel: "Seite {page} von {pages}",
@@ -44,13 +44,13 @@ async function someHandler(ctx) {
44
44
 
45
45
  ## Kinds, format, and meta
46
46
 
47
- | kind | contentFormat | meta |
48
- |---|---|---|
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 |
47
+ | kind | contentFormat | meta |
48
+ | --------------- | --------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
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 |
54
54
 
55
55
  `meta` is stored as JSON in the `meta` column and is validated on write per kind against
56
56
  `template-meta.ts` (errors as field issues on `meta.*`). Subject and preview text allow only simple
@@ -105,22 +105,22 @@ await seedSystemTemplate(db, {
105
105
 
106
106
  ## Admin workflows (write handlers + queries)
107
107
 
108
- | Handler | QN | Who | What |
109
- |---|---|---|---|
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 |
108
+ | Handler | QN | Who | What |
109
+ | ----------------------------------------- | ------------------------------------------ | ------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
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
124
 
125
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
126
 
@@ -166,12 +166,12 @@ The harness checks `TemplateNotFoundError` propagation, locale fallback, and, wh
166
166
  Marketing, FAQ, and legal texts use the same store, but a separate write path: `set.write` carries
167
167
  `title` + `folder` (content tree) and has no draft status, saving publishes.
168
168
 
169
- | Handler | QN | Who | What |
170
- |---|---|---|---|
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 |
169
+ | Handler | QN | Who | What |
170
+ | -------------------------------------- | ------------------------------------- | ------------------------------------------------------------------------------ | --------------------------------------------------------- |
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 |
175
175
 
176
176
  The two public queries are hardcoded to `kind: "text-block"` and accept **no** kind parameter. Mail
177
177
  templates and AI prompts are stored in the same table and use the handlers of their collection. This
@@ -248,8 +248,9 @@ export function withParsedDocument<T>(html: string, read: (body: ScanElement) =>
248
248
  );
249
249
  return read(parsed.body);
250
250
  } finally {
251
- window.happyDOM.abort();
252
- window.happyDOM.close();
251
+ // The window is never reused, so this sync API need not await the teardown promises.
252
+ void window.happyDOM.abort();
253
+ void window.happyDOM.close();
253
254
  }
254
255
  }
255
256
 
@@ -17,6 +17,7 @@ const SLUG_INVALID_ISSUE: FieldIssue = {
17
17
  export function createCollectionEntryCreateForm(
18
18
  tenantId: string | undefined,
19
19
  ): (props: { readonly target: TargetRef; readonly onClose: () => void }) => ReactNode {
20
+ // biome-ignore lint/correctness/noComponentHookFactories: setup-time factory, called once per templateResolverClient(); the resolver slot only passes target/onClose, so tenantId is bound here
20
21
  return function BoundCollectionEntryCreateForm(props) {
21
22
  return <CollectionEntryCreateForm {...props} tenantId={tenantId} />;
22
23
  };
@@ -92,6 +92,7 @@ const tenantMembershipCrud = createEventStoreExecutor(
92
92
  // earlier hook that keeps reporting done:false can use up the budget, so later chunking
93
93
  // hooks time out with nothing processed until it finishes.
94
94
  function newOutcomeAccumulator(): {
95
+ // biome-ignore lint/suspicious/noConfusingVoidType: takes the awaited result of a destroy hook, whose public type allows void
95
96
  add: (result: TenantDestroyHookResult | void) => void;
96
97
  outcome: () => StageRunOutcome;
97
98
  } {