@cosmicdrift/kumiko-bundled-features 0.365.1 → 0.367.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (148) hide show
  1. package/dist/admin-shell/feature.js +9 -9
  2. package/dist/agent-tools/tool-dispatch.d.ts +9 -1
  3. package/dist/agent-tools/tool-dispatch.js +55 -5
  4. package/dist/audit/feature.js +1 -1
  5. package/dist/auth-email-password/constants.d.ts +0 -1
  6. package/dist/auth-email-password/constants.js +0 -2
  7. package/dist/auth-email-password/feature.js +3 -3
  8. package/dist/auth-email-password/handlers/invite-create.write.js +1 -0
  9. package/dist/auth-mfa/feature.js +4 -4
  10. package/dist/auth-mfa-user-data/index.js +1 -1
  11. package/dist/billing-foundation/consumer-protection/register-consent.js +1 -1
  12. package/dist/billing-foundation/feature.js +1 -1
  13. package/dist/cap-counter/cap-reservation.js +73 -25
  14. package/dist/cap-counter/constants.d.ts +1 -0
  15. package/dist/cap-counter/constants.js +2 -0
  16. package/dist/cap-counter/feature.js +2 -2
  17. package/dist/cap-counter/tables.js +5 -2
  18. package/dist/cap-overview/feature.js +3 -3
  19. package/dist/channel-discord/feature.js +2 -2
  20. package/dist/channel-email/email-channel.d.ts +5 -1
  21. package/dist/channel-email/email-channel.js +8 -2
  22. package/dist/channel-email/feature.js +2 -2
  23. package/dist/channel-in-app/feature.js +1 -1
  24. package/dist/channel-push/feature.js +1 -1
  25. package/dist/channel-slack/feature.js +2 -2
  26. package/dist/channel-teams/feature.js +2 -2
  27. package/dist/channel-telegram/feature.js +2 -2
  28. package/dist/compliance-profiles/feature.js +1 -1
  29. package/dist/compliance-profiles-ops/index.js +3 -3
  30. package/dist/config/feature.js +1 -1
  31. package/dist/custom-fields/feature.js +2 -2
  32. package/dist/custom-fields/wire-for-entity.js +17 -13
  33. package/dist/data-retention/feature.js +1 -1
  34. package/dist/data-retention/presets.d.ts +4 -4
  35. package/dist/delivery/channel-context.js +1 -0
  36. package/dist/delivery/db/queries/address-opt-outs.d.ts +1 -1
  37. package/dist/delivery/db/queries/address-opt-outs.js +6 -2
  38. package/dist/delivery/db/queries/preferences.d.ts +1 -1
  39. package/dist/delivery/db/queries/preferences.js +4 -4
  40. package/dist/delivery/delivery-service.d.ts +2 -0
  41. package/dist/delivery/delivery-service.js +39 -6
  42. package/dist/delivery/feature.js +10 -6
  43. package/dist/delivery/handlers/resubscribe-address.write.js +13 -9
  44. package/dist/delivery/handlers/resubscribe-user.write.js +3 -1
  45. package/dist/delivery/handlers/set-preference.write.js +3 -1
  46. package/dist/delivery/handlers/unsubscribe-address.write.d.ts +6 -0
  47. package/dist/delivery/handlers/unsubscribe-address.write.js +11 -6
  48. package/dist/delivery/handlers/unsubscribe-user.write.js +3 -1
  49. package/dist/delivery/index.d.ts +3 -0
  50. package/dist/delivery/index.js +2 -0
  51. package/dist/delivery/notification-type-aliases.d.ts +3 -0
  52. package/dist/delivery/notification-type-aliases.js +11 -0
  53. package/dist/delivery/testing.d.ts +2 -0
  54. package/dist/delivery/types.d.ts +2 -1
  55. package/dist/delivery/unsubscribe-link.d.ts +25 -0
  56. package/dist/delivery/unsubscribe-link.js +40 -0
  57. package/dist/delivery/unsubscribe-page-strings.d.ts +11 -0
  58. package/dist/delivery/unsubscribe-page-strings.js +15 -0
  59. package/dist/delivery/unsubscribe.d.ts +3 -0
  60. package/dist/delivery/unsubscribe.js +65 -29
  61. package/dist/derivatives-sharp/feature.js +1 -1
  62. package/dist/document-ingest-foundation/feature.js +2 -2
  63. package/dist/file-derivatives/feature.js +3 -3
  64. package/dist/file-foundation/feature.js +1 -1
  65. package/dist/file-provider-inmemory/feature.js +2 -2
  66. package/dist/file-provider-s3/feature.js +3 -3
  67. package/dist/file-provider-s3-env/feature.js +2 -2
  68. package/dist/files-tenant-data/index.js +1 -1
  69. package/dist/folders/feature.js +2 -2
  70. package/dist/folders-user-data/index.js +1 -1
  71. package/dist/form-draft/feature.js +2 -2
  72. package/dist/form-draft-user-data/index.js +1 -1
  73. package/dist/inbound-mail-foundation/connect-routes.d.ts +3 -0
  74. package/dist/inbound-mail-foundation/connect-routes.js +6 -1
  75. package/dist/inbound-mail-foundation/feature.js +1 -1
  76. package/dist/inbound-mail-foundation/types.d.ts +17 -7
  77. package/dist/inbound-mail-foundation/watch-supervisor.js +16 -2
  78. package/dist/inbound-provider-imap/feature.js +2 -2
  79. package/dist/inbound-provider-inmemory/feature.js +1 -1
  80. package/dist/ledger/feature.js +5 -5
  81. package/dist/legal-pages/feature.js +1 -1
  82. package/dist/mail-foundation/feature.d.ts +16 -1
  83. package/dist/mail-foundation/feature.js +30 -9
  84. package/dist/mail-foundation/index.d.ts +1 -1
  85. package/dist/mail-foundation/index.js +1 -1
  86. package/dist/mail-transport-inmemory/feature.js +2 -2
  87. package/dist/mail-transport-smtp/feature.js +3 -3
  88. package/dist/managed-pages/css-gate.js +1 -1
  89. package/dist/managed-pages/feature.js +2 -2
  90. package/dist/metrics/feature.js +2 -2
  91. package/dist/notes-history/feature.js +2 -2
  92. package/dist/notes-history-user-data/index.js +1 -1
  93. package/dist/personal-access-tokens/feature.js +17 -13
  94. package/dist/readiness/feature.js +2 -2
  95. package/dist/renderer-foundation/feature.js +1 -1
  96. package/dist/renderer-pdf/feature.js +1 -1
  97. package/dist/secrets/feature.js +1 -1
  98. package/dist/seo/feature.js +1 -1
  99. package/dist/sessions/feature.js +20 -16
  100. package/dist/step-dispatcher/feature.js +1 -1
  101. package/dist/subscription-mollie/feature.js +1 -1
  102. package/dist/subscription-stripe/feature.js +3 -3
  103. package/dist/tags/feature.js +2 -2
  104. package/dist/template-resolver/compile-document-body.js +47 -11
  105. package/dist/template-resolver/document-sanitizer.d.ts +2 -0
  106. package/dist/template-resolver/document-sanitizer.js +16 -1
  107. package/dist/template-resolver/document-schema.d.ts +2 -0
  108. package/dist/template-resolver/document-schema.js +31 -0
  109. package/dist/template-resolver/feature.js +2 -2
  110. package/dist/template-resolver/handlers/bundle-import.js +3 -2
  111. package/dist/template-resolver/handlers/copy-locale.write.js +3 -2
  112. package/dist/template-resolver/handlers/find-by-id.query.js +5 -1
  113. package/dist/template-resolver/handlers/list.query.js +3 -1
  114. package/dist/template-resolver/handlers/overview.query.js +4 -2
  115. package/dist/template-resolver/handlers/template-write.d.ts +2 -1
  116. package/dist/template-resolver/handlers/template-write.js +12 -1
  117. package/dist/template-resolver/index.d.ts +3 -2
  118. package/dist/template-resolver/index.js +3 -2
  119. package/dist/template-resolver/qualified-names.d.ts +1 -0
  120. package/dist/template-resolver/qualified-names.js +1 -0
  121. package/dist/template-resolver/seeding.d.ts +4 -0
  122. package/dist/template-resolver/seeding.js +40 -5
  123. package/dist/template-resolver/template-preview.js +2 -0
  124. package/dist/template-resolver/template-system-only.d.ts +8 -0
  125. package/dist/template-resolver/template-system-only.js +11 -0
  126. package/dist/template-resolver/template-validation.js +42 -28
  127. package/dist/template-resolver/web/i18n.js +1 -0
  128. package/dist/template-resolver-user-data/index.js +1 -1
  129. package/dist/tenant/feature.js +4 -4
  130. package/dist/tenant/seeding.js +1 -1
  131. package/dist/tenant-lifecycle/feature.js +2 -3
  132. package/dist/tenant-settings/feature.js +1 -1
  133. package/dist/tier-engine/feature.js +86 -72
  134. package/dist/user/feature.js +1 -1
  135. package/dist/user-data-rights/feature.js +4 -4
  136. package/dist/user-data-rights-defaults/feature.js +2 -2
  137. package/dist/user-profile/feature.js +4 -4
  138. package/dist/waitlist/feature.js +2 -2
  139. package/package.json +10 -10
  140. package/src/agent-tools/changes.json +6 -0
  141. package/src/auth-email-password/changes.json +13 -0
  142. package/src/cap-counter/changes.json +7 -0
  143. package/src/channel-email/changes.json +6 -0
  144. package/src/crypto-shredding/changes.json +6 -0
  145. package/src/delivery/changes.json +18 -0
  146. package/src/inbound-mail-foundation/changes.json +13 -0
  147. package/src/template-resolver/changes.json +25 -0
  148. package/src/user-data-rights/changes.json +6 -0
@@ -284,6 +284,36 @@ export const DocumentTextSize = Extension.create({
284
284
  ];
285
285
  },
286
286
  });
287
+ // A block shown only when the named variable has a value (data-if, the
288
+ // conditional counterpart of data-each). Table rows qualify so an invoice can
289
+ // drop a discount line; tiptap merges the attribute into paragraph, heading
290
+ // and row markup only.
291
+ export const DOCUMENT_CONDITION_ATTRIBUTE = "data-if";
292
+ export const DocumentCondition = Extension.create({
293
+ name: "documentCondition",
294
+ addGlobalAttributes() {
295
+ return [
296
+ {
297
+ types: ["paragraph", "heading", "tableRow"],
298
+ attributes: {
299
+ showIf: {
300
+ default: null,
301
+ parseHTML: (element) => {
302
+ const name = element.getAttribute(DOCUMENT_CONDITION_ATTRIBUTE);
303
+ return name !== null && isDocumentReferenceName(name) ? name : null;
304
+ },
305
+ renderHTML: (attributes) => {
306
+ const name = attributes["showIf"];
307
+ return typeof name === "string" && isDocumentReferenceName(name)
308
+ ? { [DOCUMENT_CONDITION_ATTRIBUTE]: name }
309
+ : {};
310
+ },
311
+ },
312
+ },
313
+ },
314
+ ];
315
+ },
316
+ });
287
317
  export const documentExtensions = [
288
318
  StarterKit.configure({
289
319
  blockquote: false,
@@ -318,4 +348,5 @@ export const documentExtensions = [
318
348
  DocumentSteps,
319
349
  DocumentTextAlign,
320
350
  DocumentTextSize,
351
+ DocumentCondition,
321
352
  ];
@@ -63,13 +63,13 @@ export function createTemplateResolverFeature(opts = {}) {
63
63
  resourceMaxBytes: createSystemConfig("number", { default: 2_097_152, bounds: { min: 1 } }),
64
64
  },
65
65
  });
66
- r.entity("template-resource", templateResourceEntity);
66
+ r.entity({ name: "template-resource", ...templateResourceEntity });
67
67
  // Only when the app actually mounts a user-owned collection: the entity's
68
68
  // `content` is `userOwned`, which makes it subject data — the boot guard
69
69
  // then requires an EXT_USER_DATA hook (mount `template-resolver-user-data`).
70
70
  // Registering it unconditionally would impose that on every app.
71
71
  if (hasUserOwned)
72
- r.entity("user-content-entry", userContentEntryEntity);
72
+ r.entity({ name: "user-content-entry", ...userContentEntryEntity });
73
73
  const handlers = {
74
74
  upsertSystem: r.writeHandler(upsertSystemWrite),
75
75
  upsertTenant: r.writeHandler(upsertTenantWrite),
@@ -7,7 +7,7 @@ import { attachTemplateResources } from "../template-resources.js";
7
7
  import { resolveTemplateVariables } from "../template-validation.js";
8
8
  import { MAX_FILE_ATTACHMENTS_PER_TEMPLATE } from "./resource-shared.js";
9
9
  import { evaluateTemplateWrite } from "./template-gates.js";
10
- import { refuseTenantScopeUnderSystemTenant, writeTemplateRow, } from "./template-write.js";
10
+ import { refuseSystemOnlyTenantOverride, refuseTenantScopeUnderSystemTenant, writeTemplateRow, } from "./template-write.js";
11
11
  export function targetExistsIssue(locale) {
12
12
  return {
13
13
  path: "locale",
@@ -61,7 +61,8 @@ function checkBundleAttachments(bundle) {
61
61
  // beforeTransaction half: validates the bundle and uploads every resource file to the target
62
62
  // tenant's storage. A failed or throwing upload releases the ones before it.
63
63
  export async function prepareTemplateBundleBeforeTransaction(args) {
64
- const refused = refuseTenantScopeUnderSystemTenant(args.scope, args.tenantId);
64
+ const refused = refuseTenantScopeUnderSystemTenant(args.scope, args.tenantId) ??
65
+ refuseSystemOnlyTenantOverride(args.ctx.registry, args.scope, args.bundle.slug, args.bundle.kind);
65
66
  if (refused !== null)
66
67
  return refused;
67
68
  const issues = await collectBundleIssues(args, args.status);
@@ -15,7 +15,7 @@ import { resolveTemplateVariables } from "../template-validation.js";
15
15
  import { targetExistsIssue } from "./bundle-import.js";
16
16
  import { kindSchema, localeSchema, templateSlugSchema } from "./shared.js";
17
17
  import { parseStoredJson, storedRecord } from "./template-gates.js";
18
- import { refuseTenantScopeUnderSystemTenant, writeTemplateRow } from "./template-write.js";
18
+ import { refuseSystemOnlyTenantOverride, refuseTenantScopeUnderSystemTenant, writeTemplateRow, } from "./template-write.js";
19
19
  function readDbFor(ctx, scope) {
20
20
  return scope === "system" ? requireForTenant(ctx, SYSTEM_TENANT_ID).db : ctx.db;
21
21
  }
@@ -59,7 +59,8 @@ async function checkCopyRequest(ctx, event) {
59
59
  }));
60
60
  }
61
61
  const tenantId = scope === "system" ? SYSTEM_TENANT_ID : event.user.tenantId;
62
- const refused = refuseTenantScopeUnderSystemTenant(scope, tenantId);
62
+ const refused = refuseTenantScopeUnderSystemTenant(scope, tenantId) ??
63
+ refuseSystemOnlyTenantOverride(ctx.registry, scope, slug, kind);
63
64
  if (refused !== null)
64
65
  return refused;
65
66
  const readDb = readDbFor(ctx, scope);
@@ -3,6 +3,7 @@ import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
3
3
  import * as z from "zod";
4
4
  import { templateResourcesTable } from "../table.js";
5
5
  import { draftViewerTenantId, isHiddenSystemDraft, toWorkingRow } from "../template-draft.js";
6
+ import { isSystemOnlyTemplateHiddenFrom } from "../template-system-only.js";
6
7
  // Admin-Lookup für UI-Edit-Flow. Returnt das raw Template inkl. draft/
7
8
  // archived Status. Tenant-Isolation: User sieht nur Templates des
8
9
  // eigenen Tenants oder SYSTEM_TENANT (system-defaults sind public-
@@ -17,8 +18,11 @@ export const findByIdQuery = defineQueryHandler({
17
18
  const stored = await fetchOne(ctx.db, templateResourcesTable, {
18
19
  id: query.payload.id,
19
20
  });
20
- if (!stored || isHiddenSystemDraft(query.user, stored))
21
+ if (!stored ||
22
+ isHiddenSystemDraft(query.user, stored) ||
23
+ isSystemOnlyTemplateHiddenFrom(query.user, ctx.registry, stored)) {
21
24
  return null;
25
+ }
22
26
  const row = toWorkingRow(stored, draftViewerTenantId(query.user, stored));
23
27
  const isOwnTenant = row.tenantId === query.user.tenantId;
24
28
  const isSystemTemplate = row.scope === "system";
@@ -4,6 +4,7 @@ import * as z from "zod";
4
4
  import { TEMPLATE_KINDS, TEMPLATE_STATUSES } from "../constants.js";
5
5
  import { templateResourcesTable } from "../table.js";
6
6
  import { isHiddenSystemDraft } from "../template-draft.js";
7
+ import { isSystemOnlyTemplateHiddenFrom } from "../template-system-only.js";
7
8
  // ctx.db is tenant-scoped even for SystemAdmin: every caller sees its own
8
9
  // tenant's rows plus the system defaults, never another tenant's rows.
9
10
  export const listQuery = defineQueryHandler({
@@ -39,7 +40,8 @@ export const listQuery = defineQueryHandler({
39
40
  limit: 500,
40
41
  }));
41
42
  return rows
42
- .filter((row) => !isHiddenSystemDraft(query.user, row))
43
+ .filter((row) => !isHiddenSystemDraft(query.user, row) &&
44
+ !isSystemOnlyTemplateHiddenFrom(query.user, ctx.registry, row))
43
45
  .map((row) => ({
44
46
  id: String(row.id),
45
47
  tenantId: row.tenantId,
@@ -4,6 +4,7 @@ import * as z from "zod";
4
4
  import { DEFAULT_MAIL_LAYOUT_SLUG, FALLBACK_LOCALE } from "../constants.js";
5
5
  import { templateResourcesTable } from "../table.js";
6
6
  import { activeSinceOf, activeVersionOf, draftViewerTenantId, isHiddenSystemDraft, parseTemplateDraft, servedVersionOf, } from "../template-draft.js";
7
+ import { isSystemOnlyTemplateHiddenFrom } from "../template-system-only.js";
7
8
  import { collectTemplateUsage } from "../template-usage.js";
8
9
  import { storedRecord } from "./template-gates.js";
9
10
  const OVERVIEW_KINDS = ["mail", "document-pdf", "mail-layout", "pdf-layout"];
@@ -183,7 +184,7 @@ export const overviewQuery = defineQueryHandler({
183
184
  totalCount: z.boolean().optional(),
184
185
  }),
185
186
  access: { roles: ["TenantAdmin", "SystemAdmin"] },
186
- description: "Lists mail, PDF and layout templates as one row per slug and kind for the caller's tenant (optionally only the mails and PDFs using one layout slug): where the effective version comes from (own override or system), its locales, status and version (the served one; `draftVersion` is set when an unpublished draft waits beside it, `activeSince` says since when the served version applies), the system version it overrides, the layout it uses and which notifications or templates use it; use it to browse and filter all templates at a glance.",
187
+ description: "Lists mail, PDF and layout templates as one row per slug and kind for the caller's tenant (optionally only the mails and PDFs using one layout slug): where the effective version comes from (own override or system), its locales, status and version (the served one; `draftVersion` is set when an unpublished draft waits beside it, `activeSince` says since when the served version applies), the system version it overrides, the layout it uses and which notifications or templates use it; templates of notifications registered with `systemOnly` are listed for SystemAdmins only; use it to browse and filter all templates at a glance.",
187
188
  handler: async (query, ctx) => {
188
189
  const payload = query.payload;
189
190
  const fetchedRows = await selectMany(ctx.db, templateResourcesTable, { kind: { in: [...OVERVIEW_KINDS] } }, { orderBy: { col: "id", direction: "asc" }, limit: OVERVIEW_ROW_LIMIT + 1 });
@@ -194,7 +195,8 @@ export const overviewQuery = defineQueryHandler({
194
195
  limit: OVERVIEW_ROW_LIMIT,
195
196
  });
196
197
  }
197
- const visibleRows = rows.filter((row) => !isHiddenSystemDraft(query.user, row));
198
+ const visibleRows = rows.filter((row) => !isHiddenSystemDraft(query.user, row) &&
199
+ !isSystemOnlyTemplateHiddenFrom(query.user, ctx.registry, row));
198
200
  const { drafts, layoutSlugByDocument } = buildDrafts(groupBySlugAndKind(visibleRows), query.user);
199
201
  const effectiveLayouts = effectiveMailLayoutSlugs(visibleRows);
200
202
  const overviewRows = drafts.map((draft) => withUsage(draft, layoutSlugByDocument, ctx.registry, effectiveLayouts));
@@ -1,4 +1,4 @@
1
- import { type HandlerContext, type SessionUser, type TemplateVariables, type TenantId, type WriteResult } from "@cosmicdrift/kumiko-framework/engine";
1
+ import { type HandlerContext, type Registry, type SessionUser, type TemplateVariables, type TenantId, type WriteResult } from "@cosmicdrift/kumiko-framework/engine";
2
2
  import { type WriteFailure } from "@cosmicdrift/kumiko-framework/errors";
3
3
  import type { ContentFormat, TemplateKind } from "../constants.js";
4
4
  import type { TemplateWriteScope } from "../layout-regions.js";
@@ -32,6 +32,7 @@ export type TemplateRowWrite = {
32
32
  readonly payload: TemplateRowPayload;
33
33
  };
34
34
  export declare function refuseTenantScopeUnderSystemTenant(scope: TemplateWriteScope, tenantId: string): WriteResult<never> | null;
35
+ export declare function refuseSystemOnlyTenantOverride(registry: Registry, scope: TemplateWriteScope, slug: string, kind: string): WriteFailure | null;
35
36
  export type GatedTemplateWrite = {
36
37
  readonly ok: true;
37
38
  readonly evaluated: Extract<TemplateWriteResult, {
@@ -2,8 +2,10 @@ import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
2
2
  import { SYSTEM_TENANT_ID, } from "@cosmicdrift/kumiko-framework/engine";
3
3
  import { AccessDeniedError, ValidationError, writeFailure, } from "@cosmicdrift/kumiko-framework/errors";
4
4
  import { requireForTenant } from "../../shared/index.js";
5
+ import { TemplateResolverErrors } from "../qualified-names.js";
5
6
  import { templateResourcesTable } from "../table.js";
6
7
  import { activationChanges, serializeTemplateDraft } from "../template-draft.js";
8
+ import { isSystemOnlyTemplate } from "../template-system-only.js";
7
9
  import { executor } from "./shared.js";
8
10
  import { evaluateTemplateWrite, } from "./template-gates.js";
9
11
  // The tenant-scope-under-system-tenant ban lives here so no write path can
@@ -16,6 +18,14 @@ export function refuseTenantScopeUnderSystemTenant(scope, tenantId) {
16
18
  details: { reason: "system_tenant_override_not_allowed_use_upsert_system" },
17
19
  }));
18
20
  }
21
+ export function refuseSystemOnlyTenantOverride(registry, scope, slug, kind) {
22
+ if (scope !== "tenant" || !isSystemOnlyTemplate(registry, slug, kind))
23
+ return null;
24
+ return writeFailure(new AccessDeniedError({
25
+ i18nKey: "templateResolver.errors.systemOnlyTemplate",
26
+ details: { reason: TemplateResolverErrors.systemOnlyTemplate },
27
+ }));
28
+ }
19
29
  // Evaluation plus the publish rule: a row that goes active must not carry
20
30
  // publish issues, a draft may.
21
31
  export function gateTemplateWrite(input, status) {
@@ -63,7 +73,8 @@ function updateChanges(existing, status, fields) {
63
73
  // separately.
64
74
  export async function writeTemplateRow(args) {
65
75
  const { ctx, user, tenantId, scope, status, payload } = args;
66
- const refused = refuseTenantScopeUnderSystemTenant(scope, tenantId);
76
+ const refused = refuseTenantScopeUnderSystemTenant(scope, tenantId) ??
77
+ refuseSystemOnlyTenantOverride(ctx.registry, scope, payload.slug, payload.kind);
67
78
  if (refused !== null)
68
79
  return refused;
69
80
  const { db, streamTenantId } = requireForTenant(ctx, tenantId);
@@ -1,8 +1,8 @@
1
1
  export { createTemplateResolverApi, type ResolveRequest, requireTemplateResolver, TemplateNotFoundError, type TemplateResolverApi, type TemplateResource, } from "./api.js";
2
2
  export { type CompileDocumentBodyOptions, collectDocumentResourceKeys, compileDocumentBodyToHandlebars, toResourceDataUri, } from "./compile-document-body.js";
3
3
  export { CONTENT_FORMATS, type ContentFormat, DEFAULT_MAIL_LAYOUT_SLUG, DEFAULT_PDF_LAYOUT_SLUG, DOCUMENT_HTML_KINDS, type DocumentHtmlKind, FALLBACK_LOCALE, NON_RENDER_KINDS, type NonRenderKind, PDF_DEFAULT_MARGIN_MM, PDF_FOOTER_MARGIN_MM, PDF_HEADER_MARGIN_MM, PDF_ORIENTATIONS, PDF_PAGE_FORMATS, type PdfOrientation, type PdfPageFormat, RENDER_KINDS, type RenderKind, SYSTEM_TENANT_ID, TEMPLATE_KINDS, TEMPLATE_SCOPES, TEMPLATE_STATUSES, TEXT_BLOCK_KIND, type TemplateKind, type TemplateScope, type TemplateStatus, UPSERT_KINDS, type UpsertKind, } from "./constants.js";
4
- export { type DocumentViolation, type DocumentViolationCode, documentViolationsToFieldIssues, type SanitizeBodyResult, type SanitizeHtmlResult, sanitizeDocumentHtml, sanitizeTemplateBody, } from "./document-sanitizer.js";
5
- export { DOCUMENT_NODE_NAMES, DOCUMENT_TEXT_ALIGNMENTS, DocumentButton, DocumentDivider, DocumentLink, DocumentPageBreak, DocumentResource, DocumentTable, DocumentTableCell, DocumentTableHeader, DocumentTableRow, DocumentTextAlign, type DocumentTextAlignment, DocumentVariable, documentExtensions, isAllowedDocumentLinkHref, isDocumentPlaceholderHref, isDocumentReferenceName, isDocumentResourceKey, } from "./document-schema.js";
4
+ export { type DocumentViolation, type DocumentViolationCode, documentViolationsToFieldIssues, flattenParagraphsToLineBreaks, type SanitizeBodyResult, type SanitizeHtmlResult, sanitizeDocumentHtml, sanitizeTemplateBody, } from "./document-sanitizer.js";
5
+ export { DOCUMENT_CONDITION_ATTRIBUTE, DOCUMENT_NODE_NAMES, DOCUMENT_TEXT_ALIGNMENTS, DocumentButton, DocumentCondition, DocumentDivider, DocumentLink, DocumentPageBreak, DocumentResource, DocumentTable, DocumentTableCell, DocumentTableHeader, DocumentTableRow, DocumentTextAlign, type DocumentTextAlignment, DocumentVariable, documentExtensions, isAllowedDocumentLinkHref, isDocumentPlaceholderHref, isDocumentReferenceName, isDocumentResourceKey, } from "./document-schema.js";
6
6
  export { createTemplateResolverFeature } from "./feature.js";
7
7
  export type { LayoutSourceResponse, LayoutSourceRow } from "./handlers/layout-source.query.js";
8
8
  export type { OverviewKindGroup, OverviewOrigin, OverviewResponse, OverviewRow, OverviewUsage, } from "./handlers/overview.query.js";
@@ -19,6 +19,7 @@ export { type TemplateBundle, templateBundleSchema } from "./template-bundle.js"
19
19
  export { isMailTemplateMeta, type LayoutTemplateMeta, MAIL_CATEGORIES, MAIL_TONES, type MailCategory, type MailLayoutMeta, type MailTemplateMeta, type MailTone, type PdfTemplateMeta, parseTemplateMeta, pdfAttachmentFileNamePatternSchema, readMailLayoutMeta, readPdfTemplateMeta, type TemplateMeta, type TemplateMetaIssue, type TemplateMetaResult, templateSlugSchema, } from "./template-meta.js";
20
20
  export { type DocumentDraftOutcome, evaluateDocumentDraft } from "./template-preview.js";
21
21
  export { readTemplateResource, type TemplateResourceFile } from "./template-resources.js";
22
+ export { isSystemOnlyTemplate } from "./template-system-only.js";
22
23
  export { collectTemplateUsage, type TemplateUsageEntry } from "./template-usage.js";
23
24
  export { hasPlaceholderProblems, resolveTemplateVariables, SYSTEM_PLACEHOLDERS, systemPlaceholdersFor, type TemplateValidation, validateTemplatePlaceholders, } from "./template-validation.js";
24
25
  export { type UserContentEntryRow, userContentEntriesTable, userContentEntryEntity, } from "./user-content-table.js";
@@ -1,8 +1,8 @@
1
1
  export { createTemplateResolverApi, requireTemplateResolver, TemplateNotFoundError, } from "./api.js";
2
2
  export { collectDocumentResourceKeys, compileDocumentBodyToHandlebars, toResourceDataUri, } from "./compile-document-body.js";
3
3
  export { CONTENT_FORMATS, DEFAULT_MAIL_LAYOUT_SLUG, DEFAULT_PDF_LAYOUT_SLUG, DOCUMENT_HTML_KINDS, FALLBACK_LOCALE, NON_RENDER_KINDS, PDF_DEFAULT_MARGIN_MM, PDF_FOOTER_MARGIN_MM, PDF_HEADER_MARGIN_MM, PDF_ORIENTATIONS, PDF_PAGE_FORMATS, RENDER_KINDS, SYSTEM_TENANT_ID, TEMPLATE_KINDS, TEMPLATE_SCOPES, TEMPLATE_STATUSES, TEXT_BLOCK_KIND, UPSERT_KINDS, } from "./constants.js";
4
- export { documentViolationsToFieldIssues, sanitizeDocumentHtml, sanitizeTemplateBody, } from "./document-sanitizer.js";
5
- export { DOCUMENT_NODE_NAMES, DOCUMENT_TEXT_ALIGNMENTS, DocumentButton, DocumentDivider, DocumentLink, DocumentPageBreak, DocumentResource, DocumentTable, DocumentTableCell, DocumentTableHeader, DocumentTableRow, DocumentTextAlign, DocumentVariable, documentExtensions, isAllowedDocumentLinkHref, isDocumentPlaceholderHref, isDocumentReferenceName, isDocumentResourceKey, } from "./document-schema.js";
4
+ export { documentViolationsToFieldIssues, flattenParagraphsToLineBreaks, sanitizeDocumentHtml, sanitizeTemplateBody, } from "./document-sanitizer.js";
5
+ export { DOCUMENT_CONDITION_ATTRIBUTE, DOCUMENT_NODE_NAMES, DOCUMENT_TEXT_ALIGNMENTS, DocumentButton, DocumentCondition, DocumentDivider, DocumentLink, DocumentPageBreak, DocumentResource, DocumentTable, DocumentTableCell, DocumentTableHeader, DocumentTableRow, DocumentTextAlign, DocumentVariable, documentExtensions, isAllowedDocumentLinkHref, isDocumentPlaceholderHref, isDocumentReferenceName, isDocumentResourceKey, } from "./document-schema.js";
6
6
  export { createTemplateResolverFeature } from "./feature.js";
7
7
  export { localeSchema } from "./handlers/shared.js";
8
8
  export { isHexColor, mailLayoutAppearance, } from "./layout-appearance.js";
@@ -17,6 +17,7 @@ export { templateBundleSchema } from "./template-bundle.js";
17
17
  export { isMailTemplateMeta, MAIL_CATEGORIES, MAIL_TONES, parseTemplateMeta, pdfAttachmentFileNamePatternSchema, readMailLayoutMeta, readPdfTemplateMeta, templateSlugSchema, } from "./template-meta.js";
18
18
  export { evaluateDocumentDraft } from "./template-preview.js";
19
19
  export { readTemplateResource } from "./template-resources.js";
20
+ export { isSystemOnlyTemplate } from "./template-system-only.js";
20
21
  export { collectTemplateUsage } from "./template-usage.js";
21
22
  export { hasPlaceholderProblems, resolveTemplateVariables, SYSTEM_PLACEHOLDERS, systemPlaceholdersFor, validateTemplatePlaceholders, } from "./template-validation.js";
22
23
  export { userContentEntriesTable, userContentEntryEntity, } from "./user-content-table.js";
@@ -44,6 +44,7 @@ export declare const TemplateResolverErrors: {
44
44
  readonly alreadyArchived: "template_already_archived";
45
45
  readonly targetExists: "target_exists";
46
46
  readonly systemResourcesNeedSystemTenant: "system_resources_need_system_tenant";
47
+ readonly systemOnlyTemplate: "system_only_template";
47
48
  readonly bundleLocalesDiffer: "bundle_locales_differ";
48
49
  readonly resourceTooLarge: "template_resource_too_large";
49
50
  readonly resourceUnsupportedImage: "template_resource_unsupported_image";
@@ -55,6 +55,7 @@ export const TemplateResolverErrors = {
55
55
  alreadyArchived: "template_already_archived",
56
56
  targetExists: "target_exists",
57
57
  systemResourcesNeedSystemTenant: "system_resources_need_system_tenant",
58
+ systemOnlyTemplate: "system_only_template",
58
59
  bundleLocalesDiffer: "bundle_locales_differ",
59
60
  resourceTooLarge: "template_resource_too_large",
60
61
  resourceUnsupportedImage: "template_resource_unsupported_image",
@@ -1,5 +1,6 @@
1
1
  import { type DbConnection } from "@cosmicdrift/kumiko-framework/db";
2
2
  import { type Registry, type SessionUser, type TenantId } from "@cosmicdrift/kumiko-framework/engine";
3
+ import type { Dispatcher } from "@cosmicdrift/kumiko-framework/pipeline";
3
4
  import { type SeedIfExists } from "@cosmicdrift/kumiko-framework/seeding";
4
5
  import { type ContentFormat, type UpsertKind } from "./constants.js";
5
6
  import type { TemplateBundle } from "./template-bundle.js";
@@ -24,6 +25,9 @@ export declare function seedSystemTemplate(db: DbConnection, opts: SeedSystemTem
24
25
  export type SeedSystemTemplateBundleOptions = {
25
26
  readonly by?: SessionUser;
26
27
  readonly ifExists?: SeedIfExists;
28
+ /** Required for bundles with resources (e.g. a `logo`): the files go through the
29
+ * import-bundle-system write, which needs the file provider of the system tenant. */
30
+ readonly dispatcher?: Dispatcher;
27
31
  };
28
32
  export declare function seedSystemTemplateBundle(db: DbConnection, bundle: TemplateBundle, opts?: SeedSystemTemplateBundleOptions): Promise<{
29
33
  ids: Record<string, string>;
@@ -13,10 +13,11 @@ import { createTenantDb, deleteMany, } from "@cosmicdrift/kumiko-framework/db";
13
13
  import { createSystemUser, SYSTEM_TENANT_ID, } from "@cosmicdrift/kumiko-framework/engine";
14
14
  import { getStreamVersion, getUnscopedStreamMaxVersionForSeed, } from "@cosmicdrift/kumiko-framework/event-store";
15
15
  import { runEventStoreSeed } from "@cosmicdrift/kumiko-framework/seeding";
16
+ import { isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
16
17
  import { TEXT_BLOCK_KIND } from "./constants.js";
17
18
  import { executor } from "./handlers/shared.js";
18
19
  import { evaluateTemplateWrite } from "./handlers/template-gates.js";
19
- import { TEMPLATE_RESOLVER_FEATURE } from "./qualified-names.js";
20
+ import { TEMPLATE_RESOLVER_FEATURE, TemplateResolverHandlers } from "./qualified-names.js";
20
21
  import { templateResourcesTable } from "./table.js";
21
22
  import { activeStampChanges, servedStampChanges } from "./template-draft.js";
22
23
  import { templateSlugSchema } from "./template-meta.js";
@@ -154,12 +155,12 @@ export async function seedSystemTemplate(db, opts) {
154
155
  update: (row) => applyUpdate(row, existingRow),
155
156
  });
156
157
  }
157
- // Seeds one system row per bundle locale. Bundled resources need a file
158
- // provider and the system tenant's file space, which a boot seed does not
159
- // have, so those go through the import-bundle-system write instead.
158
+ // Seeds one system row per bundle locale. Bundled resources need a file provider and
159
+ // the system tenant's file space, so those bundles are written through the
160
+ // import-bundle-system handler on the seed dispatcher instead of directly.
160
161
  export async function seedSystemTemplateBundle(db, bundle, opts = {}) {
161
162
  if (Object.keys(bundle.resources).length > 0) {
162
- throw new Error(`seedSystemTemplateBundle ${bundle.slug}/${bundle.kind}: bundles with resources cannot be seeded; use the import-bundle-system write`);
163
+ return seedSystemTemplateBundleWithResources(db, bundle, opts);
163
164
  }
164
165
  const ids = {};
165
166
  for (const [locale, entry] of Object.entries(bundle.locales)) {
@@ -178,6 +179,40 @@ export async function seedSystemTemplateBundle(db, bundle, opts = {}) {
178
179
  }
179
180
  return { ids };
180
181
  }
182
+ async function seedSystemTemplateBundleWithResources(db, bundle, opts) {
183
+ const label = `seedSystemTemplateBundle ${bundle.slug}/${bundle.kind}`;
184
+ const { dispatcher } = opts;
185
+ if (!dispatcher) {
186
+ throw new Error(`${label}: bundles with resources need a file provider; pass opts.dispatcher (the dispatcher of the seed deps) so the files are stored through the import-bundle-system write`);
187
+ }
188
+ if ((opts.ifExists ?? "skip") === "skip") {
189
+ const existingIds = {};
190
+ for (const locale of Object.keys(bundle.locales)) {
191
+ const row = await fetchOne(db, templateResourcesTable, {
192
+ tenantId: SYSTEM_TENANT_ID,
193
+ slug: bundle.slug,
194
+ kind: bundle.kind,
195
+ locale,
196
+ });
197
+ if (row)
198
+ existingIds[locale] = String(row.id);
199
+ }
200
+ if (Object.keys(existingIds).length === Object.keys(bundle.locales).length) {
201
+ return { ids: existingIds };
202
+ }
203
+ }
204
+ const result = await dispatcher.write(TemplateResolverHandlers.importBundleSystem, { bundle }, opts.by ?? createSystemUser(SYSTEM_TENANT_ID, ["SystemAdmin"]));
205
+ if (!result.isSuccess) {
206
+ throw new Error(`${label} failed: ${result.error.code}: ${result.error.message}`);
207
+ }
208
+ return { ids: importedIds(result.data) };
209
+ }
210
+ function importedIds(data) {
211
+ const ids = isPlainObject(data) ? data["ids"] : undefined;
212
+ if (!isPlainObject(ids))
213
+ throw new Error("seedSystemTemplateBundle: import returned no ids");
214
+ return Object.fromEntries(Object.entries(ids).map(([locale, id]) => [locale, String(id)]));
215
+ }
181
216
  export function hasNotificationTemplates(registry) {
182
217
  return Array.from(registry.getAllNotifications().values()).some((notification) => notification.template !== undefined);
183
218
  }
@@ -33,6 +33,8 @@ function sampleScalar(name, def) {
33
33
  return 0;
34
34
  case "date":
35
35
  return SAMPLE_DATE;
36
+ case "percent":
37
+ return 0;
36
38
  }
37
39
  }
38
40
  function sampleRow(item, given = {}) {
@@ -0,0 +1,8 @@
1
+ import type { Registry } from "@cosmicdrift/kumiko-framework/engine";
2
+ export declare function isSystemOnlyTemplate(registry: Registry, slug: string, kind: string): boolean;
3
+ export declare function isSystemOnlyTemplateHiddenFrom(user: {
4
+ readonly roles: readonly string[];
5
+ }, registry: Registry, row: {
6
+ readonly slug: string;
7
+ readonly kind: string;
8
+ }): boolean;
@@ -0,0 +1,11 @@
1
+ import { isSystemAdmin } from "./template-draft.js";
2
+ const NOTIFICATION_TEMPLATE_KIND = "mail";
3
+ // A notification registered with systemOnly keeps its mail template as the system default:
4
+ // tenants neither list nor override it, and overrides stored before the flag are ignored.
5
+ export function isSystemOnlyTemplate(registry, slug, kind) {
6
+ return (kind === NOTIFICATION_TEMPLATE_KIND &&
7
+ registry.getAllNotifications().get(slug)?.systemOnly === true);
8
+ }
9
+ export function isSystemOnlyTemplateHiddenFrom(user, registry, row) {
10
+ return !isSystemAdmin(user) && isSystemOnlyTemplate(registry, row.slug, row.kind);
11
+ }
@@ -1,6 +1,6 @@
1
1
  import * as z from "zod";
2
- import { withParsedDocument } from "./document-sanitizer.js";
3
- import { isDocumentPlaceholderHref, isDocumentReferenceName } from "./document-schema.js";
2
+ import { conditionScopeElement, withParsedDocument } from "./document-sanitizer.js";
3
+ import { DOCUMENT_CONDITION_ATTRIBUTE, isDocumentPlaceholderHref, isDocumentReferenceName, } from "./document-schema.js";
4
4
  export const SYSTEM_PLACEHOLDERS = new Set([
5
5
  "tenantName",
6
6
  "signature",
@@ -34,6 +34,7 @@ const scalarVariableSchema = z.discriminatedUnion("type", [
34
34
  .optional(),
35
35
  }),
36
36
  z.object({ ...variableCommon, type: z.literal("date"), example: textLikeExample }),
37
+ z.object({ ...variableCommon, type: z.literal("percent"), example: z.number().optional() }),
37
38
  ]);
38
39
  const variableDefSchema = z.union([
39
40
  scalarVariableSchema,
@@ -99,34 +100,47 @@ const NAME_ATTRIBUTE_BY_TAG = {
99
100
  span: "data-variable",
100
101
  div: "data-steps",
101
102
  };
102
- function scanBodyPlaceholders(body, scope) {
103
- withParsedDocument(body, (root) => {
104
- for (const element of Array.from(root.querySelectorAll("tr[data-each]"))) {
105
- const listName = element.getAttribute("data-each") ?? "";
106
- const def = Object.hasOwn(scope.variables, listName) ? scope.variables[listName] : undefined;
107
- if (def?.type === "list")
108
- scope.used.add(listName);
109
- else
110
- scope.unknown.add(listName);
103
+ function scanRepeatRows(root, scope) {
104
+ for (const element of Array.from(root.querySelectorAll("tr[data-each]"))) {
105
+ const listName = element.getAttribute("data-each") ?? "";
106
+ const def = Object.hasOwn(scope.variables, listName) ? scope.variables[listName] : undefined;
107
+ if (def?.type === "list")
108
+ scope.used.add(listName);
109
+ else
110
+ scope.unknown.add(listName);
111
+ }
112
+ }
113
+ function scanConditions(root, scope) {
114
+ for (const element of Array.from(root.querySelectorAll(`[${DOCUMENT_CONDITION_ATTRIBUTE}]`))) {
115
+ const itemFields = listItemFields(scope.variables, enclosingEachName(conditionScopeElement(element)));
116
+ recordName(element.getAttribute(DOCUMENT_CONDITION_ATTRIBUTE) ?? "", itemFields, scope);
117
+ }
118
+ }
119
+ function scanReferences(root, scope) {
120
+ for (const element of Array.from(root.querySelectorAll("span[data-variable], div[data-steps], a[href], img[data-resource]"))) {
121
+ const itemFields = listItemFields(scope.variables, enclosingEachName(element));
122
+ const tag = element.tagName.toLowerCase();
123
+ const nameAttribute = NAME_ATTRIBUTE_BY_TAG[tag];
124
+ if (nameAttribute !== undefined) {
125
+ recordName(element.getAttribute(nameAttribute) ?? "", itemFields, scope);
126
+ }
127
+ else if (tag === "a") {
128
+ const href = element.getAttribute("href") ?? "";
129
+ if (isDocumentPlaceholderHref(href))
130
+ recordName(href.slice(2, -2), itemFields, scope);
111
131
  }
112
- for (const element of Array.from(root.querySelectorAll("span[data-variable], div[data-steps], a[href], img[data-resource]"))) {
113
- const itemFields = listItemFields(scope.variables, enclosingEachName(element));
114
- const tag = element.tagName.toLowerCase();
115
- const nameAttribute = NAME_ATTRIBUTE_BY_TAG[tag];
116
- if (nameAttribute !== undefined) {
117
- recordName(element.getAttribute(nameAttribute) ?? "", itemFields, scope);
118
- }
119
- else if (tag === "a") {
120
- const href = element.getAttribute("href") ?? "";
121
- if (isDocumentPlaceholderHref(href))
122
- recordName(href.slice(2, -2), itemFields, scope);
123
- }
124
- else {
125
- const key = element.getAttribute("data-resource") ?? "";
126
- if (!scope.resourceKeys.has(key))
127
- scope.unknownResources.add(key);
128
- }
132
+ else {
133
+ const key = element.getAttribute("data-resource") ?? "";
134
+ if (!scope.resourceKeys.has(key))
135
+ scope.unknownResources.add(key);
129
136
  }
137
+ }
138
+ }
139
+ function scanBodyPlaceholders(body, scope) {
140
+ withParsedDocument(body, (root) => {
141
+ scanRepeatRows(root, scope);
142
+ scanConditions(root, scope);
143
+ scanReferences(root, scope);
130
144
  });
131
145
  }
132
146
  const HANDLEBARS_EXPRESSION = /\{\{([^{}]*)\}\}/g;
@@ -76,6 +76,7 @@ export const defaultTranslations = {
76
76
  "templateResolver.errors.collection.kindNotAllowed": "“{detail}” templates are written through their own editor, not through a collection.",
77
77
  "templateResolver.errors.noSystemTemplate": "There is no active system template to reset to.",
78
78
  "templateResolver.errors.noDraft": "There is no draft to discard.",
79
+ "templateResolver.errors.systemOnlyTemplate": "Only the platform maintains this template; it cannot be overridden per tenant.",
79
80
  "templateResolver.errors.template.invalidMeta": "The template settings are invalid.",
80
81
  "templateResolver.errors.template.meta.subject.required": "The subject is missing.",
81
82
  "templateResolver.errors.template.meta.subject.invalid": "The subject is invalid (at most 200 characters, no line breaks).",
@@ -12,7 +12,7 @@ export const templateResolverUserDataFeature = defineFeature("template-resolver-
12
12
  // template-resolver is optional for the same reason as in
13
13
  // notes-history-user-data: a toggleable mount would otherwise trip the
14
14
  // "effectively disabled" boot warning even though the entity is there.
15
- r.requires("user-data-rights");
15
+ r.requires({ features: ["user-data-rights"] });
16
16
  r.optionalRequires("template-resolver");
17
17
  r.useExtension(EXT_USER_DATA, "user-content-entry", {
18
18
  export: userContentExportHook,
@@ -38,7 +38,7 @@ export function createTenantFeature(options) {
38
38
  recommended: true,
39
39
  });
40
40
  r.systemScope();
41
- r.requires("config");
41
+ r.requires({ features: ["config"] });
42
42
  r.extendsRegistrar(EXT_ASSIGNABLE_ROLE, {});
43
43
  // The elevation guards read the registry, the screens only these options: a role the
44
44
  // server accepts but the screen omits is silently stripped on the next member-roles save.
@@ -49,9 +49,9 @@ export function createTenantFeature(options) {
49
49
  throw new Error(`[tenant] assignable app role "${role}" (assignableFrom "${assignableFrom}") is declared but missing from the tenant screens' options; pass assignableAppRoles: collectAssignableAppRoles(appFeatures) to createTenantFeature.`);
50
50
  }
51
51
  });
52
- r.entity("tenant", tenantEntity);
53
- r.entity("tenant-membership", tenantMembershipEntity);
54
- r.entity("tenant-invitation", tenantInvitationEntity);
52
+ r.entity({ name: "tenant", ...tenantEntity });
53
+ r.entity({ name: "tenant-membership", ...tenantMembershipEntity });
54
+ r.entity({ name: "tenant-invitation", ...tenantInvitationEntity });
55
55
  r.config({
56
56
  keys: {
57
57
  // Stammdaten-Settings — Tenant-Admin darf ändern, alle dürfen lesen.
@@ -185,7 +185,7 @@ export async function seedTenant(db, options, hooks) {
185
185
  }
186
186
  // "tenant:seed" matches no handler-scoped hook, only entity-scoped ones
187
187
  // (keyed by result.entityName === "tenant") — that's exactly what
188
- // tier-engine's `r.hook("postSave", { allOf: "tenant" }, ...)` needs.
188
+ // tier-engine's `r.hook({ type: "postSave", target: { allOf: "tenant" }, ... })` needs.
189
189
  await fireEntityPostSave(hooks, "tenant:seed", result.data, { tenantId: options.id, db });
190
190
  return { id: options.id };
191
191
  }
@@ -19,7 +19,7 @@ export function createTenantLifecycleFeature() {
19
19
  category: "compliance",
20
20
  recommended: false,
21
21
  });
22
- r.requires("tenant", "compliance-profiles");
22
+ r.requires({ features: ["tenant", "compliance-profiles"] });
23
23
  r.optionalRequires("sessions");
24
24
  r.usesApi("compliance.forTenant");
25
25
  r.extendsRegistrar(EXT_TENANT_DATA, {});
@@ -74,8 +74,7 @@ export function createTenantLifecycleFeature() {
74
74
  if (!ctx.registry) {
75
75
  throw new Error("run-tenant-destruction: ctx.registry required (JobContext incomplete)");
76
76
  }
77
- const T = (await import("@cosmicdrift/kumiko-framework/time")).getTemporal();
78
- const now = T.Now.instant();
77
+ const now = ctx.tz.now();
79
78
  // Without crossTenantReads (undeclared hatch) fall through to the sweep.
80
79
  if (ctx.crossTenantReads && !(await hasTenantDestructionWork(ctx.crossTenantReads, now))) {
81
80
  // skip: no tenant is due, so the sweep would not touch anything
@@ -16,7 +16,7 @@ import { buildTenantSettingsKeys } from "./config.js";
16
16
  import { TENANT_SETTINGS_FEATURE_NAME } from "./constants.js";
17
17
  export function createTenantSettingsFeature(opts = {}) {
18
18
  return defineFeature(TENANT_SETTINGS_FEATURE_NAME, (r) => {
19
- r.requires("config");
19
+ r.requires({ features: ["config"] });
20
20
  r.describe("Per-tenant Currency + Locale defaults, exposed as two config keys (tenant-settings:config:currency, tenant-settings:config:locale) that surface in the self-populating Settings-Hub via `mask`. Pair with defineCreateWithTenantDefaults to auto-fill a money field's currency or a locale field on create instead of hard-coding a literal per entity.");
21
21
  r.config({ keys: buildTenantSettingsKeys(opts) });
22
22
  r.translations({