@cosmicdrift/kumiko-samples 0.404.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 (65) 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/auth-email-password/web/auth-gate.tsx +4 -0
  5. package/packages/bundled-features/src/documents/changes.json +8 -0
  6. package/packages/bundled-features/src/host-routing/README.md +10 -10
  7. package/packages/bundled-features/src/inbound-mail-foundation/handlers/thread-rollup.ts +1 -0
  8. package/packages/bundled-features/src/legal-pages/README.md +21 -13
  9. package/packages/bundled-features/src/renderer-foundation/README.md +6 -4
  10. package/packages/bundled-features/src/renderer-pdf/README.md +5 -1
  11. package/packages/bundled-features/src/template-resolver/README.md +29 -29
  12. package/packages/bundled-features/src/template-resolver/document-sanitizer.ts +3 -2
  13. package/packages/bundled-features/src/template-resolver/web/collection-entry-create-form.tsx +1 -0
  14. package/packages/bundled-features/src/tenant-lifecycle/stages.ts +1 -0
  15. package/packages/bundled-features/src/user-data-rights/COMPLIANCE.md +36 -36
  16. package/packages/bundled-features/src/user-data-rights/README.md +33 -33
  17. package/packages/bundled-features/src/user-data-rights/web/public-deletion-gate.tsx +1 -0
  18. package/samples/apps/admin-console/README.md +5 -5
  19. package/samples/apps/admin-console/package.json +3 -3
  20. package/samples/apps/cap-billing-demo/README.md +15 -15
  21. package/samples/apps/config-demo/README.md +7 -7
  22. package/samples/apps/config-demo/package.json +2 -2
  23. package/samples/apps/marketing-demo/README.md +2 -2
  24. package/samples/apps/marketing-demo/package.json +2 -2
  25. package/samples/apps/showcase/package.json +3 -3
  26. package/samples/apps/styleguide/package.json +2 -2
  27. package/samples/apps/ui-walkthrough/README.md +8 -9
  28. package/samples/apps/ui-walkthrough/package.json +2 -2
  29. package/samples/apps/use-all-bundled/README.md +2 -2
  30. package/samples/apps/use-all-bundled/package.json +2 -2
  31. package/samples/apps/use-all-bundled/scripts/gen-sample-index.ts +18 -1
  32. package/samples/apps/user-data-rights-demo/README.md +12 -12
  33. package/samples/apps/workspaces/package.json +2 -2
  34. package/samples/recipes/apex-landing/README.md +3 -3
  35. package/samples/recipes/apex-landing/package.json +1 -1
  36. package/samples/recipes/apex-surface-auth/README.md +13 -9
  37. package/samples/recipes/auth-mfa/README.md +2 -4
  38. package/samples/recipes/auth-mfa/package.json +2 -2
  39. package/samples/recipes/boot-check/README.md +2 -2
  40. package/samples/recipes/compliance-profiles-demo/README.md +5 -5
  41. package/samples/recipes/compose-stacks/README.md +8 -8
  42. package/samples/recipes/cross-feature-events/README.md +1 -0
  43. package/samples/recipes/crypto-shredding-hr/README.md +3 -3
  44. package/samples/recipes/custom-fields-basic/README.md +1 -1
  45. package/samples/recipes/documents/README.md +25 -0
  46. package/samples/recipes/embedded-entity-form/package.json +2 -2
  47. package/samples/recipes/encrypted-tenant-config/README.md +8 -8
  48. package/samples/recipes/env-composition/README.md +10 -10
  49. package/samples/recipes/error-contract/README.md +9 -9
  50. package/samples/recipes/event-sourcing/README.md +9 -9
  51. package/samples/recipes/ledger-invoicing/README.md +6 -6
  52. package/samples/recipes/legal-pages/README.md +16 -16
  53. package/samples/recipes/managed-config/README.md +15 -11
  54. package/samples/recipes/managed-pages/README.md +7 -7
  55. package/samples/recipes/pdf-audit-export/package.json +1 -1
  56. package/samples/recipes/pipeline-basics/README.md +13 -13
  57. package/samples/recipes/public-share-token/README.md +4 -4
  58. package/samples/recipes/record-detail-layout/package.json +3 -3
  59. package/samples/recipes/security-baseline/README.md +4 -1
  60. package/samples/recipes/session-revocation/README.md +1 -1
  61. package/samples/recipes/tenant-settings/README.md +4 -4
  62. package/samples/recipes/tier-admin/README.md +2 -2
  63. package/samples/recipes/user-data-rights/README.md +21 -19
  64. package/samples/recipes/wizard-form/package.json +3 -3
  65. 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.404.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.404.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`.
@@ -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}>
@@ -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
+ ]
@@ -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,
@@ -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
  } {
@@ -16,34 +16,34 @@ Dieses Dokument bündelt die **technischen Fakten** zur DSGVO-Pipeline
16
16
 
17
17
  ## 1. Endpoint → Artikel-Mapping
18
18
 
19
- | Artikel | Endpoint / Runner | Wer ruft auf | Was passiert |
20
- |---------|-------------------|--------------|--------------|
21
- | **Art. 15 (Auskunft, light)** | `user-data-rights:query:my-audit-log` | User | Liest eigene Framework-Events aus `kumiko_events` (account-weit über alle Memberships). Filterbar nach `eventType`, `aggregateType`, `from`/`to`. Domain-Entities ohne `ctx.events.append` erscheinen NICHT — die kommen im Export-Bundle (Art. 20). |
22
- | **Art. 15 + 20 (Auskunft + Portabilität)** | `user-data-rights:write:request-export` | User | Async Job → ZIP mit user-Profil + fileRefs + alle EXT_USER_DATA-Provider-Daten (cross-tenant) + signed Magic-Link per Email. Idempotent: nur 1 active Job pro User (`ACTIVE_JOB_CONSTRAINT`). |
23
- | **Art. 15 + 20** | `GET /user-export/by-token` (interstitial) + `POST /user-export/by-token` (exchange) | Anonym (Magic-Link-Pfad) | Email-Link trägt den Token als URL-Fragment (`#token=`, nie an den Server gesendet, issue #1271); Interstitial-Script liest das Fragment client-seitig und tauscht es per POST-Body gegen die signed Storage-URL. Kein `?token=`-Query-Fallback (#1512) — Query-Param landet unveränderlich in Proxy-/Access-Logs. Multi-use innerhalb TTL. Audit: `lastUsedAt`, `lastUsedFromIp`, `lastUsedUserAgent`. |
24
- | **Art. 15 + 20** | `GET /user-export/by-job/:jobId` | User (Session-Auth) | UI-Klick-Pfad. Cross-tenant-same-user: User in Tenant B kann Job aus Tenant A laden wenn er der Owner ist. |
25
- | **Art. 17 (Löschung)** | `user-data-rights:write:request-deletion` | User | Soft-Delete: `status=DeletionRequested` + `gracePeriodEnd = now + profile.gracePeriod`. Cron `run-forget-cleanup` führt nach Grace die Anonymisierung durch. |
26
- | **Art. 17** | `user-data-rights:write:cancel-deletion` | User | Während Grace: status zurück auf `Active`. |
27
- | **Art. 17** | `run-forget-cleanup` (Cron) | System | Findet User mit `status=DeletionRequested AND gracePeriodEnd < now`. Pro User Sub-TX über alle EXT_USER_DATA-Provider mit Strategy aus `retention.policyFor`. user-Hook anonymisiert (PII raus, Sentinel-Email). |
28
- | **Art. 18 (Restriction)** | `user-data-rights:write:restrict-account` | Admin / SystemAdmin | Status-Flip → Auth-Middleware-Guard blockt Logins. `sessions.revokeAllForUser` killt aktive Sessions. |
29
- | **Art. 18** | `user-data-rights:write:lift-restriction` | Admin / SystemAdmin | Restriction aufheben. |
30
- | **Operator (DPO)** | `user-data-rights:query:list-download-attempts` | Admin / SystemAdmin | Brute-Force-Detection: zeigt invalid Download-Versuche (notFound / expired / failed / signedUrlNotSupported) gefiltert nach Result, IP, Zeitraum. |
19
+ | Artikel | Endpoint / Runner | Wer ruft auf | Was passiert |
20
+ | ------------------------------------------ | ------------------------------------------------------------------------------------ | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
21
+ | **Art. 15 (Auskunft, light)** | `user-data-rights:query:my-audit-log` | User | Liest eigene Framework-Events aus `kumiko_events` (account-weit über alle Memberships). Filterbar nach `eventType`, `aggregateType`, `from`/`to`. Domain-Entities ohne `ctx.events.append` erscheinen NICHT — die kommen im Export-Bundle (Art. 20). |
22
+ | **Art. 15 + 20 (Auskunft + Portabilität)** | `user-data-rights:write:request-export` | User | Async Job → ZIP mit user-Profil + fileRefs + alle EXT_USER_DATA-Provider-Daten (cross-tenant) + signed Magic-Link per Email. Idempotent: nur 1 active Job pro User (`ACTIVE_JOB_CONSTRAINT`). |
23
+ | **Art. 15 + 20** | `GET /user-export/by-token` (interstitial) + `POST /user-export/by-token` (exchange) | Anonym (Magic-Link-Pfad) | Email-Link trägt den Token als URL-Fragment (`#token=`, nie an den Server gesendet, issue #1271); Interstitial-Script liest das Fragment client-seitig und tauscht es per POST-Body gegen die signed Storage-URL. Kein `?token=`-Query-Fallback (#1512) — Query-Param landet unveränderlich in Proxy-/Access-Logs. Multi-use innerhalb TTL. Audit: `lastUsedAt`, `lastUsedFromIp`, `lastUsedUserAgent`. |
24
+ | **Art. 15 + 20** | `GET /user-export/by-job/:jobId` | User (Session-Auth) | UI-Klick-Pfad. Cross-tenant-same-user: User in Tenant B kann Job aus Tenant A laden wenn er der Owner ist. |
25
+ | **Art. 17 (Löschung)** | `user-data-rights:write:request-deletion` | User | Soft-Delete: `status=DeletionRequested` + `gracePeriodEnd = now + profile.gracePeriod`. Cron `run-forget-cleanup` führt nach Grace die Anonymisierung durch. |
26
+ | **Art. 17** | `user-data-rights:write:cancel-deletion` | User | Während Grace: status zurück auf `Active`. |
27
+ | **Art. 17** | `run-forget-cleanup` (Cron) | System | Findet User mit `status=DeletionRequested AND gracePeriodEnd < now`. Pro User Sub-TX über alle EXT_USER_DATA-Provider mit Strategy aus `retention.policyFor`. user-Hook anonymisiert (PII raus, Sentinel-Email). |
28
+ | **Art. 18 (Restriction)** | `user-data-rights:write:restrict-account` | Admin / SystemAdmin | Status-Flip → Auth-Middleware-Guard blockt Logins. `sessions.revokeAllForUser` killt aktive Sessions. |
29
+ | **Art. 18** | `user-data-rights:write:lift-restriction` | Admin / SystemAdmin | Restriction aufheben. |
30
+ | **Operator (DPO)** | `user-data-rights:query:list-download-attempts` | Admin / SystemAdmin | Brute-Force-Detection: zeigt invalid Download-Versuche (notFound / expired / failed / signedUrlNotSupported) gefiltert nach Result, IP, Zeitraum. |
31
31
 
32
32
  ---
33
33
 
34
34
  ## 2. Speicherorte (Verarbeitungsverzeichnis Art. 30)
35
35
 
36
- | Tabelle | Inhalt | Personenbezug | Retention | Zweck |
37
- |---------|--------|---------------|-----------|-------|
38
- | `read_users` | User-Profil (email, displayName, passwordHash, locale, status, gracePeriodEnd, roles) | direkt | per Domain (typ. blockDelete bei Aufbewahrungspflicht, sonst hardDelete via Forget-Pipeline) | Authentifizierung, Nutzer-Profil |
39
- | `read_tenant_memberships` | User↔Tenant-Verknüpfung + Rollen | direkt (via userId) | per Tenant-Lifecycle | Mehrmandant-Zuordnung |
40
- | `kumiko_events` | Event-Store (alle write-Events) | direkt (`createdBy = userId`) | per Domain-Policy via `data-retention` | Audit-Trail (Art. 15-Selbstauskunft Quelle), Event-Sourcing |
41
- | `read_export_jobs` | Async Export-Status (queued/running/done/failed), userId, requestedAt, doneAt, storageKey | direkt (userId) | per `compliance-profiles` Profil-Default | Idempotenz + Status-Polling |
42
- | `read_export_download_tokens` | Magic-Link-Hash (SHA-256), TTL, useCount, lastUsedAt, lastUsedFromIp, lastUsedUserAgent | indirekt (Token→Job→User) | `compliance-profiles.userRights.exportDownloadTtl` (default 7d) | Magic-Link-Auth + Multi-Use-Audit |
43
- | `read_download_attempts` | Invalid Download-Versuche: result, via, tokenHash, ip, userAgent, attemptedAt | indirekt (IP) | **90d hardDelete** (Entity-Default, Disk-Bomb-Schutz) | DPO-Brute-Force-Detection |
44
- | `read_tenant_compliance_profiles` | Per-Tenant Profile-Wahl + Override | nein | unbounded (Konfiguration) | Region-/Branchen-Defaults |
45
- | `read_tenant_retention_overrides` | Per-Tenant Retention-Override pro Entity | nein | unbounded (Konfiguration) | Aufbewahrungspflicht-Edge-Cases |
46
- | Storage-Provider (Local / S3) | Export-ZIPs + File-Binaries | indirekt (Inhalt) | Local: per `exportDownloadTtl` Cleanup. S3: lifecycle-Policy (App-Author-Verantwortung) | Daten-Export-Auslieferung |
36
+ | Tabelle | Inhalt | Personenbezug | Retention | Zweck |
37
+ | --------------------------------- | ----------------------------------------------------------------------------------------- | ----------------------------- | -------------------------------------------------------------------------------------------- | ----------------------------------------------------------- |
38
+ | `read_users` | User-Profil (email, displayName, passwordHash, locale, status, gracePeriodEnd, roles) | direkt | per Domain (typ. blockDelete bei Aufbewahrungspflicht, sonst hardDelete via Forget-Pipeline) | Authentifizierung, Nutzer-Profil |
39
+ | `read_tenant_memberships` | User↔Tenant-Verknüpfung + Rollen | direkt (via userId) | per Tenant-Lifecycle | Mehrmandant-Zuordnung |
40
+ | `kumiko_events` | Event-Store (alle write-Events) | direkt (`createdBy = userId`) | per Domain-Policy via `data-retention` | Audit-Trail (Art. 15-Selbstauskunft Quelle), Event-Sourcing |
41
+ | `read_export_jobs` | Async Export-Status (queued/running/done/failed), userId, requestedAt, doneAt, storageKey | direkt (userId) | per `compliance-profiles` Profil-Default | Idempotenz + Status-Polling |
42
+ | `read_export_download_tokens` | Magic-Link-Hash (SHA-256), TTL, useCount, lastUsedAt, lastUsedFromIp, lastUsedUserAgent | indirekt (Token→Job→User) | `compliance-profiles.userRights.exportDownloadTtl` (default 7d) | Magic-Link-Auth + Multi-Use-Audit |
43
+ | `read_download_attempts` | Invalid Download-Versuche: result, via, tokenHash, ip, userAgent, attemptedAt | indirekt (IP) | **90d hardDelete** (Entity-Default, Disk-Bomb-Schutz) | DPO-Brute-Force-Detection |
44
+ | `read_tenant_compliance_profiles` | Per-Tenant Profile-Wahl + Override | nein | unbounded (Konfiguration) | Region-/Branchen-Defaults |
45
+ | `read_tenant_retention_overrides` | Per-Tenant Retention-Override pro Entity | nein | unbounded (Konfiguration) | Aufbewahrungspflicht-Edge-Cases |
46
+ | Storage-Provider (Local / S3) | Export-ZIPs + File-Binaries | indirekt (Inhalt) | Local: per `exportDownloadTtl` Cleanup. S3: lifecycle-Policy (App-Author-Verantwortung) | Daten-Export-Auslieferung |
47
47
 
48
48
  ### Bekanntes Residuum: `createdBy` / `metadata.userId` in `kumiko_events`
49
49
 
@@ -69,12 +69,12 @@ Retention-Policy irgendwann das Event selbst löscht.
69
69
 
70
70
  ## 3. Compliance-Profile
71
71
 
72
- | Profil | gracePeriod | exportDownloadTtl | Stale-After | Sub-Processors |
73
- |--------|-------------|-------------------|-------------|----------------|
74
- | `eu-dsgvo` | 30d | 7d | 30d | per Tenant konfigurierbar |
75
- | `swiss-dsg` | 30d | 7d | 30d | + EDÖB-Meldepfad |
76
- | `de-hr-dsgvo-hgb` | 30d | 7d | 30d | + HR-Aufbewahrung 10y für HR-Entities (anonymize statt delete) |
77
- | `minimal-no-region` | 30d | 7d | 30d | Migration-Edge-Case ohne Region |
72
+ | Profil | gracePeriod | exportDownloadTtl | Stale-After | Sub-Processors |
73
+ | ------------------- | ----------- | ----------------- | ----------- | -------------------------------------------------------------- |
74
+ | `eu-dsgvo` | 30d | 7d | 30d | per Tenant konfigurierbar |
75
+ | `swiss-dsg` | 30d | 7d | 30d | + EDÖB-Meldepfad |
76
+ | `de-hr-dsgvo-hgb` | 30d | 7d | 30d | + HR-Aufbewahrung 10y für HR-Entities (anonymize statt delete) |
77
+ | `minimal-no-region` | 30d | 7d | 30d | Migration-Edge-Case ohne Region |
78
78
 
79
79
  Tenant kann via `compliance-profiles:write:set-profile` + Override (`override.userRights.gracePeriod={ days: N }` etc.) eigene Werte setzen.
80
80
 
@@ -128,13 +128,13 @@ und Organisatorische Maßnahmen" übernommen werden:
128
128
 
129
129
  ## 5. Cron-Jobs / Operationale Trigger
130
130
 
131
- | Job | Trigger | Was passiert | Operator-Sichtbarkeit |
132
- |-----|---------|--------------|------------------------|
133
- | `run-forget-cleanup` | Cron (per App-Author konfigurierbar, typisch täglich) | Findet User mit abgelaufener Grace, ruft `EXT_USER_DATA.delete` pro Provider, anonymisiert User, sendet `sendDeletionExecutedEmail`-Callback | `read_job_runs` (success/fail), Hook-Errors als `errors[]` im Result |
134
- | `run-export-jobs` | Cron + Event-getriggert | Findet pending Export-Jobs, ruft `runUserExport` → ZIP-Bau → Storage-Upload → Magic-Link-Token → `sendExportReadyEmail` | `read_job_runs`, `read_export_jobs.status` |
135
- | `data-retention-cleanup` | Cron (in `data-retention`) | Cleant abgelaufene Rows pro Entity per `retention.keepFor` | `read_job_runs` |
136
- | Token-Cleanup (implizit) | Per `compliance-profiles.userRights.exportDownloadTtl` | Worker-Job entfernt expired Magic-Link-Tokens + Storage-Binaries | `read_export_download_tokens` |
137
- | Download-Attempt-Cleanup | Per Entity-Default 90d | Cleant `read_download_attempts` | — |
131
+ | Job | Trigger | Was passiert | Operator-Sichtbarkeit |
132
+ | ------------------------ | ------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- |
133
+ | `run-forget-cleanup` | Cron (per App-Author konfigurierbar, typisch täglich) | Findet User mit abgelaufener Grace, ruft `EXT_USER_DATA.delete` pro Provider, anonymisiert User, sendet `sendDeletionExecutedEmail`-Callback | `read_job_runs` (success/fail), Hook-Errors als `errors[]` im Result |
134
+ | `run-export-jobs` | Cron + Event-getriggert | Findet pending Export-Jobs, ruft `runUserExport` → ZIP-Bau → Storage-Upload → Magic-Link-Token → `sendExportReadyEmail` | `read_job_runs`, `read_export_jobs.status` |
135
+ | `data-retention-cleanup` | Cron (in `data-retention`) | Cleant abgelaufene Rows pro Entity per `retention.keepFor` | `read_job_runs` |
136
+ | Token-Cleanup (implizit) | Per `compliance-profiles.userRights.exportDownloadTtl` | Worker-Job entfernt expired Magic-Link-Tokens + Storage-Binaries | `read_export_download_tokens` |
137
+ | Download-Attempt-Cleanup | Per Entity-Default 90d | Cleant `read_download_attempts` | — |
138
138
 
139
139
  App-Author registriert die Cron-Trigger im run-config; `jobs`-Feature
140
140
  persistiert Run-Tracking + Retry-Pfad.