@cosmicdrift/kumiko-bundled-features 0.364.0 → 0.365.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 (169) hide show
  1. package/dist/audit/feature.js +1 -1
  2. package/dist/audit/run-escape-hatch-retention.d.ts +1 -0
  3. package/dist/audit/run-escape-hatch-retention.js +39 -12
  4. package/dist/auth-email-password/bootstrap.d.ts +1 -3
  5. package/dist/auth-email-password/feature.js +2 -0
  6. package/dist/auth-email-password/handlers/invite-accept-with-login.write.js +2 -3
  7. package/dist/auth-email-password/handlers/invite-create.write.d.ts +2 -3
  8. package/dist/auth-email-password/handlers/invite-create.write.js +4 -1
  9. package/dist/auth-email-password/handlers/switch-tenant-mfa-gate.write.d.ts +1 -0
  10. package/dist/auth-email-password/handlers/switch-tenant-mfa-gate.write.js +1 -1
  11. package/dist/auth-email-password/magic-link-mail.js +6 -3
  12. package/dist/auth-email-password/mail-i18n.d.ts +3 -0
  13. package/dist/auth-email-password/mail-i18n.js +10 -0
  14. package/dist/billing-foundation/consumer-protection/termination-record.js +8 -1
  15. package/dist/billing-foundation/i18n.js +6 -0
  16. package/dist/billing-foundation/subscription-tier-sync.js +8 -2
  17. package/dist/cap-counter/book-cap-usage.js +8 -0
  18. package/dist/cap-counter/cap-reservation.js +7 -3
  19. package/dist/cap-counter/handlers/increment-rolling.write.d.ts +1 -0
  20. package/dist/cap-counter/handlers/increment-rolling.write.js +2 -0
  21. package/dist/cap-overview/handlers/tenant-caps-list.query.js +3 -1
  22. package/dist/cap-overview/handlers/tenant-options.query.js +3 -1
  23. package/dist/channel-discord/discord-channel.js +3 -2
  24. package/dist/channel-email/email-channel.js +24 -19
  25. package/dist/channel-email/smtp-transport.js +6 -1
  26. package/dist/channel-slack/slack-channel.js +3 -2
  27. package/dist/channel-teams/teams-channel.js +3 -2
  28. package/dist/crypto-shredding/handlers/forget-subject.write.js +2 -2
  29. package/dist/custom-fields/db/queries/retention.d.ts +1 -1
  30. package/dist/custom-fields/db/queries/retention.js +2 -1
  31. package/dist/data-retention/run-retention-cleanup.js +4 -5
  32. package/dist/delivery/attachment-ref.js +16 -1
  33. package/dist/delivery/delivery-service.js +71 -82
  34. package/dist/feature-toggles/toggle-runtime.d.ts +5 -3
  35. package/dist/feature-toggles/toggle-runtime.js +25 -12
  36. package/dist/files-tenant-data/index.d.ts +1 -0
  37. package/dist/files-tenant-data/index.js +2 -1
  38. package/dist/form-draft/db/queries/owned-file-refs.d.ts +1 -1
  39. package/dist/form-draft/db/queries/owned-file-refs.js +9 -2
  40. package/dist/form-draft/handlers/discard.write.js +1 -1
  41. package/dist/jobs/job-run-logger.js +5 -2
  42. package/dist/metrics/filters.d.ts +16 -1
  43. package/dist/metrics/filters.js +27 -11
  44. package/dist/personal-access-tokens/revoke-for-user.js +3 -7
  45. package/dist/secrets/handlers/delete.write.js +3 -1
  46. package/dist/secrets/write-gate.d.ts +3 -1
  47. package/dist/secrets/write-gate.js +6 -3
  48. package/dist/shared/index.d.ts +1 -0
  49. package/dist/shared/index.js +1 -0
  50. package/dist/shared/resource-image-formats.d.ts +23 -0
  51. package/dist/shared/resource-image-formats.js +21 -0
  52. package/dist/shared/sharp-input-limits.js +7 -8
  53. package/dist/step-dispatcher/constants.d.ts +10 -0
  54. package/dist/step-dispatcher/constants.js +17 -0
  55. package/dist/step-dispatcher/deliver-dispatch-request.d.ts +14 -0
  56. package/dist/step-dispatcher/deliver-dispatch-request.js +163 -0
  57. package/dist/step-dispatcher/feature.js +47 -100
  58. package/dist/step-dispatcher/webhook-runner.d.ts +7 -4
  59. package/dist/step-dispatcher/webhook-runner.js +26 -11
  60. package/dist/template-resolver/compile-document-body.d.ts +3 -0
  61. package/dist/template-resolver/compile-document-body.js +63 -12
  62. package/dist/template-resolver/compile-steps-block.d.ts +10 -0
  63. package/dist/template-resolver/compile-steps-block.js +45 -0
  64. package/dist/template-resolver/constants.d.ts +4 -1
  65. package/dist/template-resolver/constants.js +10 -1
  66. package/dist/template-resolver/document-sanitizer.js +3 -0
  67. package/dist/template-resolver/document-schema.d.ts +2 -1
  68. package/dist/template-resolver/document-schema.js +6 -1
  69. package/dist/template-resolver/document-steps.d.ts +17 -0
  70. package/dist/template-resolver/document-steps.js +93 -0
  71. package/dist/template-resolver/feature.js +9 -0
  72. package/dist/template-resolver/handlers/bundle-import.d.ts +7 -1
  73. package/dist/template-resolver/handlers/bundle-import.js +63 -38
  74. package/dist/template-resolver/handlers/collection-set.write.js +4 -3
  75. package/dist/template-resolver/handlers/copy-locale.write.d.ts +1 -1
  76. package/dist/template-resolver/handlers/copy-locale.write.js +83 -55
  77. package/dist/template-resolver/handlers/diff-to-system.query.js +1 -0
  78. package/dist/template-resolver/handlers/import-bundle-system.write.d.ts +1 -1
  79. package/dist/template-resolver/handlers/import-bundle-system.write.js +16 -5
  80. package/dist/template-resolver/handlers/import-bundle.write.d.ts +1 -1
  81. package/dist/template-resolver/handlers/import-bundle.write.js +11 -1
  82. package/dist/template-resolver/handlers/overview.query.d.ts +3 -0
  83. package/dist/template-resolver/handlers/overview.query.js +9 -1
  84. package/dist/template-resolver/handlers/resource-upload.write.d.ts +2 -2
  85. package/dist/template-resolver/handlers/resource-upload.write.js +35 -5
  86. package/dist/template-resolver/handlers/validate.query.js +4 -1
  87. package/dist/template-resolver/index.d.ts +3 -3
  88. package/dist/template-resolver/index.js +3 -3
  89. package/dist/template-resolver/layout-appearance.d.ts +1 -1
  90. package/dist/template-resolver/layout-appearance.js +19 -9
  91. package/dist/template-resolver/mail-constants.d.ts +2 -0
  92. package/dist/template-resolver/mail-constants.js +3 -0
  93. package/dist/template-resolver/resource-image.d.ts +2 -9
  94. package/dist/template-resolver/resource-image.js +2 -11
  95. package/dist/template-resolver/seeding.js +2 -0
  96. package/dist/template-resolver/store-template-resource.d.ts +53 -9
  97. package/dist/template-resolver/store-template-resource.js +250 -97
  98. package/dist/template-resolver/template-bundle.js +4 -0
  99. package/dist/template-resolver/template-draft.d.ts +3 -0
  100. package/dist/template-resolver/template-draft.js +1 -1
  101. package/dist/template-resolver/template-meta.d.ts +5 -1
  102. package/dist/template-resolver/template-meta.js +7 -0
  103. package/dist/template-resolver/template-preview.js +1 -2
  104. package/dist/template-resolver/template-resources.d.ts +0 -5
  105. package/dist/template-resolver/template-resources.js +2 -34
  106. package/dist/template-resolver/template-validation.js +12 -9
  107. package/dist/template-resolver/web/document-editor-extensions.d.ts +1 -0
  108. package/dist/template-resolver/web/document-editor-extensions.js +26 -1
  109. package/dist/template-resolver/web/document-editor.js +5 -1
  110. package/dist/template-resolver/web/document-tiptap-editor.js +30 -7
  111. package/dist/template-resolver/web/document-toolbar-blocks.d.ts +5 -5
  112. package/dist/template-resolver/web/document-toolbar-blocks.js +2 -0
  113. package/dist/template-resolver/web/i18n.js +5 -0
  114. package/dist/template-resolver/web/index.d.ts +1 -0
  115. package/dist/template-resolver/web/index.js +1 -0
  116. package/dist/template-resolver/web/placeholder-line-editor.d.ts +4 -2
  117. package/dist/template-resolver/web/placeholder-line-editor.js +8 -6
  118. package/dist/tenant/handlers/update-member-roles.write.js +3 -6
  119. package/dist/tenant/seeding.js +23 -24
  120. package/dist/tenant-handover/move-entity-graph.js +14 -4
  121. package/dist/tenant-lifecycle/run-tenant-destroy.d.ts +1 -0
  122. package/dist/tenant-lifecycle/run-tenant-destroy.js +8 -1
  123. package/dist/tenant-lifecycle/stages.js +7 -3
  124. package/dist/tier-engine/constants.d.ts +2 -0
  125. package/dist/tier-engine/constants.js +4 -0
  126. package/dist/tier-engine/entity.d.ts +15 -14
  127. package/dist/tier-engine/entity.js +4 -3
  128. package/dist/tier-engine/handlers/set-tenant-tier.write.d.ts +5 -0
  129. package/dist/tier-engine/handlers/set-tenant-tier.write.js +7 -4
  130. package/dist/tier-engine/index.d.ts +1 -1
  131. package/dist/tier-engine/index.js +1 -1
  132. package/dist/tier-engine/tier-resolver.d.ts +13 -13
  133. package/dist/user/constants.d.ts +1 -0
  134. package/dist/user/invited-global-roles.d.ts +2 -4
  135. package/dist/user-data-rights/email-templates.js +0 -1
  136. package/dist/user-data-rights/i18n.js +5 -0
  137. package/dist/user-data-rights/lib/update-user-lifecycle.d.ts +2 -0
  138. package/dist/user-data-rights/lib/update-user-lifecycle.js +6 -2
  139. package/dist/user-data-rights/run-forget-cleanup.js +2 -2
  140. package/dist/waitlist/feature.js +1 -1
  141. package/dist/waitlist/handlers/invite.write.js +21 -3
  142. package/dist/waitlist/handlers/reject.write.js +3 -16
  143. package/dist/waitlist/handlers/submit.write.js +29 -7
  144. package/dist/waitlist/i18n.js +9 -0
  145. package/dist/waitlist/lib.d.ts +2 -1
  146. package/dist/waitlist/lib.js +15 -0
  147. package/dist/workflow-runner/feature.js +1 -1
  148. package/package.json +10 -10
  149. package/src/audit/changes.json +12 -0
  150. package/src/auth-email-password/changes.json +18 -0
  151. package/src/billing-foundation/changes.json +12 -0
  152. package/src/cap-counter/changes.json +13 -0
  153. package/src/cap-overview/changes.json +6 -0
  154. package/src/channel-email/changes.json +18 -0
  155. package/src/crypto-shredding/changes.json +7 -0
  156. package/src/delivery/changes.json +12 -0
  157. package/src/document-ingest-foundation/changes.json +6 -0
  158. package/src/feature-toggles/changes.json +7 -0
  159. package/src/jobs/changes.json +12 -0
  160. package/src/metrics/changes.json +6 -0
  161. package/src/personal-access-tokens/changes.json +6 -0
  162. package/src/secrets/changes.json +6 -0
  163. package/src/step-dispatcher/changes.json +8 -1
  164. package/src/template-resolver/changes.json +81 -0
  165. package/src/tenant/changes.json +6 -0
  166. package/src/tenant-lifecycle/changes.json +6 -0
  167. package/src/tier-engine/changes.json +12 -0
  168. package/src/user-data-rights/changes.json +6 -0
  169. package/src/waitlist/changes.json +18 -0
@@ -51,7 +51,7 @@ export function createAuditFeature() {
51
51
  db: ctx.db.unsafeRaw(),
52
52
  registry: ctx.registry,
53
53
  configResolver: ctx.configResolver,
54
- userId: ctx._userId ?? SYSTEM_USER_ID,
54
+ userId: SYSTEM_USER_ID,
55
55
  });
56
56
  },
57
57
  });
@@ -6,6 +6,7 @@ export declare function runEscapeHatchRetention(args: {
6
6
  readonly registry: Registry;
7
7
  readonly configResolver: ConfigResolver | undefined;
8
8
  readonly userId: string;
9
+ readonly batchSize?: number;
9
10
  }): Promise<{
10
11
  readonly deletedCount: number;
11
12
  }>;
@@ -1,4 +1,4 @@
1
- import { aggregateWhere, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
1
+ import { aggregateWhere, runnerCanBeginTransaction, selectMany, } from "@cosmicdrift/kumiko-framework/bun-db";
2
2
  import { COMPLIANCE_PROFILES, subtractRetentionSpec, } from "@cosmicdrift/kumiko-framework/compliance";
3
3
  import { parseTenantId, SYSTEM_TENANT_ID, } from "@cosmicdrift/kumiko-framework/engine";
4
4
  import { eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
@@ -10,7 +10,7 @@ import { DEFAULT_ESCAPE_HATCH_RETENTION_DAYS, ESCAPE_HATCH_RETENTION_DAYS_KEY, }
10
10
  import { ESCAPE_HATCH_USE_AGGREGATE_TYPE, escapeHatchUsedSchema, } from "./escape-hatch-audit-sink.js";
11
11
  // pruneEvents opens its own transaction, so a runner that cannot begin one (a tx handle) is rejected instead of nested.
12
12
  export function isDbConnection(runner) {
13
- return "begin" in runner && typeof runner.begin === "function";
13
+ return runnerCanBeginTransaction(runner);
14
14
  }
15
15
  async function resolveRetentionDays(args) {
16
16
  if (!args.configResolver)
@@ -19,18 +19,29 @@ async function resolveRetentionDays(args) {
19
19
  const raw = await config(ESCAPE_HATCH_RETENTION_DAYS_KEY);
20
20
  return typeof raw === "number" && raw >= 1 ? raw : DEFAULT_ESCAPE_HATCH_RETENTION_DAYS;
21
21
  }
22
+ // Only an operator-set value counts: the key's declared default must not override the tenant profiles.
23
+ async function resolveExplicitRetentionDays(args) {
24
+ const keyDef = args.registry.getConfigKey(ESCAPE_HATCH_RETENTION_DAYS_KEY);
25
+ if (!args.configResolver || !keyDef)
26
+ return undefined;
27
+ const { value, source } = await args.configResolver.getWithSource(ESCAPE_HATCH_RETENTION_DAYS_KEY, keyDef, SYSTEM_TENANT_ID, args.userId, args.db);
28
+ const isOperatorSet = source !== "default" && source !== "missing";
29
+ return isOperatorSet && typeof value === "number" && value >= 1 ? value : undefined;
30
+ }
22
31
  // Soft dependency: audit must stay mountable without compliance-profiles, so we probe its entity.
23
32
  const COMPLIANCE_PROFILE_ENTITY = "tenant-compliance-profile";
24
33
  const PRUNE_CHUNK_SIZE = 1000;
34
+ const PRUNE_BATCH_SIZE = 5000;
25
35
  // Overrides may only lengthen retention: the earlier cutoff (same `now`) is the longer period.
26
36
  function earlierInstant(a, b) {
27
37
  return Temporal.Instant.compare(a, b) <= 0 ? a : b;
28
38
  }
29
- async function resolveTenantCutoff(db, tenantId, now) {
39
+ async function resolveTenantCutoff(db, tenantId, now, operatorFloor) {
30
40
  const { profile } = await resolveProfileForTenant({ db, tenantId });
31
41
  const effective = subtractRetentionSpec(now, profile.auditLog.retention);
32
42
  const base = subtractRetentionSpec(now, COMPLIANCE_PROFILES[profile.key].auditLog.retention);
33
- return earlierInstant(effective, base);
43
+ const profileCutoff = earlierInstant(effective, base);
44
+ return operatorFloor ? earlierInstant(profileCutoff, operatorFloor) : profileCutoff;
34
45
  }
35
46
  // A cross-tenant audit (e.g. identity-switch) lives under the actor's tenant but must also
36
47
  // satisfy the target tenant's retention. A malformed payload falls back to the storing tenant.
@@ -45,14 +56,17 @@ async function storingTenantIds(db) {
45
56
  return tenantId ? [tenantId] : [];
46
57
  });
47
58
  }
48
- async function pruneByTenantProfiles(db) {
59
+ async function pruneByTenantProfiles(db, operatorRetentionDays) {
49
60
  const now = getTemporal().Now.instant();
61
+ const operatorFloor = operatorRetentionDays === undefined
62
+ ? undefined
63
+ : now.subtract({ hours: operatorRetentionDays * 24 });
50
64
  const cutoffByTenant = new Map();
51
65
  const cutoffOf = async (tenantId) => {
52
66
  const cached = cutoffByTenant.get(tenantId);
53
67
  if (cached)
54
68
  return cached;
55
- const cutoff = await resolveTenantCutoff(db, tenantId, now);
69
+ const cutoff = await resolveTenantCutoff(db, tenantId, now, operatorFloor);
56
70
  cutoffByTenant.set(tenantId, cutoff);
57
71
  return cutoff;
58
72
  };
@@ -87,6 +101,22 @@ async function pruneByTenantProfiles(db) {
87
101
  }
88
102
  return deletedCount;
89
103
  }
104
+ async function pruneInBatches(db, olderThanDays, batchSize = PRUNE_BATCH_SIZE) {
105
+ const olderThan = getTemporal()
106
+ .Now.instant()
107
+ .subtract({ hours: olderThanDays * 24 });
108
+ let deletedCount = 0;
109
+ for (;;) {
110
+ const batch = await pruneEvents(db, {
111
+ aggregateTypes: [ESCAPE_HATCH_USE_AGGREGATE_TYPE],
112
+ olderThan,
113
+ batchSize,
114
+ });
115
+ deletedCount += batch.deletedCount;
116
+ if (batch.deletedCount < batchSize)
117
+ return deletedCount;
118
+ }
119
+ }
90
120
  export async function runEscapeHatchRetention(args) {
91
121
  const { db } = args;
92
122
  if (!isDbConnection(db)) {
@@ -94,11 +124,8 @@ export async function runEscapeHatchRetention(args) {
94
124
  }
95
125
  if (!args.registry.getEntity(COMPLIANCE_PROFILE_ENTITY)) {
96
126
  const olderThanDays = await resolveRetentionDays({ ...args, db });
97
- const { deletedCount } = await pruneEvents(db, {
98
- aggregateTypes: [ESCAPE_HATCH_USE_AGGREGATE_TYPE],
99
- olderThanDays,
100
- });
101
- return { deletedCount };
127
+ return { deletedCount: await pruneInBatches(db, olderThanDays, args.batchSize) };
102
128
  }
103
- return { deletedCount: await pruneByTenantProfiles(db) };
129
+ const operatorRetentionDays = await resolveExplicitRetentionDays({ ...args, db });
130
+ return { deletedCount: await pruneByTenantProfiles(db, operatorRetentionDays) };
104
131
  }
@@ -4,8 +4,7 @@ import { type Registry, type TenantId, type WriteResult } from "@cosmicdrift/kum
4
4
  import type { Dispatcher } from "@cosmicdrift/kumiko-framework/pipeline";
5
5
  import type { EnvelopeCipher } from "@cosmicdrift/kumiko-framework/secrets";
6
6
  import type { Redis } from "ioredis";
7
- import type { GLOBAL_ROLE_OPTIONS } from "../user/constants.js";
8
- type GlobalRole = (typeof GLOBAL_ROLE_OPTIONS)[number];
7
+ import type { GlobalRole } from "../user/constants.js";
9
8
  export type BootstrapInvite = {
10
9
  readonly email: string;
11
10
  /** Membership role in the tenant, validated like a tenant-admin invite
@@ -80,4 +79,3 @@ export declare class BootstrapPlanError extends Error {
80
79
  export declare class BootstrapWriteError extends Error {
81
80
  }
82
81
  export declare function bootstrapTenants(deps: BootstrapDeps, plan: BootstrapPlan): Promise<BootstrapReport>;
83
- export {};
@@ -19,6 +19,7 @@ import { createSignupConfirmHandler } from "./handlers/signup-confirm.write.js";
19
19
  import { createSignupRequestHandler, } from "./handlers/signup-request.write.js";
20
20
  import { createSwitchTenantMfaGateHandler } from "./handlers/switch-tenant-mfa-gate.write.js";
21
21
  import { createVerifyEmailHandler } from "./handlers/verify-email.write.js";
22
+ import { AUTH_EMAIL_PASSWORD_MAIL_I18N } from "./mail-i18n.js";
22
23
  import { AUTH_MAIL_VARIABLES, authMailLocalizedTemplate, INVITE_MAIL_VARIABLES, sendsMagicLinkMail, } from "./mail-notifications.js";
23
24
  /**
24
25
  * Env-vars contract for the `auth-email-password` feature.
@@ -89,6 +90,7 @@ export function createAuthEmailPasswordFeature(opts = {}) {
89
90
  if (sendsMagicLinkMail(opts))
90
91
  r.requires("delivery");
91
92
  r.envSchema(authEmailPasswordEnvSchema);
93
+ r.translations({ keys: AUTH_EMAIL_PASSWORD_MAIL_I18N });
92
94
  // Declared unconditionally: apps also issue the activation link from their
93
95
  // own flows (issueSignupActivation) without enabling the signup option.
94
96
  r.notification("password-reset", {
@@ -56,9 +56,8 @@ export function createInviteAcceptWithLoginHandler(opts = {}) {
56
56
  rateLimit: { per: "ip+handler", limit: 20, windowSeconds: 60 },
57
57
  escapeHatch: {
58
58
  grants: ["unsafeRaw"],
59
- reason: "Anonymous invite-accept has no session in the invited tenant yet — checks existing " +
60
- "membership via ctx.queryAs(SYSTEM, tenant:query:memberships) of the invitation's tenant. " +
61
- "Also reads the pending invitation by id (the invitee is not yet a member of the " +
59
+ reason: "Anonymous invite-accept has no session in the invited tenant yet — reads the pending " +
60
+ "invitation by id (the invitee is not yet a member of the " +
62
61
  "invitation's tenant), adds the membership and accepts the invitation in the " +
63
62
  "invitation's tenant, which differs from the caller's tenant, and reads the MFA " +
64
63
  "enrollment of that tenant via the mfaStatusChecker callback.",
@@ -1,7 +1,6 @@
1
1
  import * as z from "zod";
2
- import { GLOBAL_ROLE_OPTIONS } from "../../user/constants.js";
2
+ import { type GlobalRole } from "../../user/constants.js";
3
3
  import type { AuthMailLocale } from "../email-templates.js";
4
- type GlobalRole = (typeof GLOBAL_ROLE_OPTIONS)[number];
5
4
  export type InviteCreateData = {
6
5
  readonly kind: "invite-created";
7
6
  readonly invitationId: string;
@@ -40,5 +39,5 @@ export declare function createSystemInviteCreateHandler(opts: InviteCreateOption
40
39
  globalRoles: z.ZodDefault<z.ZodArray<z.ZodEnum<{
41
40
  SystemAdmin: "SystemAdmin";
42
41
  }>>>;
42
+ locale: z.ZodOptional<z.ZodString>;
43
43
  }, z.core.$strip>, InviteCreateData, import("@cosmicdrift/kumiko-types/event-type-map").KumikoEventTypeMap, undefined>;
44
- export {};
@@ -20,6 +20,7 @@ import { generateToken } from "@cosmicdrift/kumiko-framework/api";
20
20
  import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
21
21
  import { access, assignableAppRolesOf, defineWriteHandler, } from "@cosmicdrift/kumiko-framework/engine";
22
22
  import { InternalError, writeFailure, } from "@cosmicdrift/kumiko-framework/errors";
23
+ import { isValidLocaleTag } from "@cosmicdrift/kumiko-framework/i18n";
23
24
  import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
24
25
  import * as z from "zod";
25
26
  // kumiko-lint-ignore cross-feature-import invite-flow lebt in auth-email-password (Magic-Link-Pattern), DB-row-owner ist tenant-feature
@@ -133,7 +134,7 @@ async function issueInvitation(opts, ttlSeconds, request, inviter, ctx, refuseAc
133
134
  }
134
135
  const token = generateToken();
135
136
  await storeInviteToken(ctx.redis, { invitationId, token, ttlSeconds });
136
- const locale = resolveHandlerMailLocale(ctx, opts.locale);
137
+ const locale = request.locale ?? resolveHandlerMailLocale(ctx, opts.locale);
137
138
  await dispatchMagicLinkMail(ctx.notify, {
138
139
  handlerName: "invite-create",
139
140
  notificationType: AuthMailNotificationTypes.invite,
@@ -188,6 +189,8 @@ const SystemInviteCreateSchema = z.object({
188
189
  email: z.email(),
189
190
  role: z.string().min(1).max(50),
190
191
  globalRoles: z.array(z.enum(GLOBAL_ROLE_OPTIONS)).max(GLOBAL_ROLE_OPTIONS.length).default([]),
192
+ // Preferred over the request locale: provisioning callers know the invitee's language.
193
+ locale: z.string().max(35).refine(isValidLocaleTag, "invalid locale tag").optional(),
191
194
  });
192
195
  // System-only sibling of invite-create: the only path that can put global
193
196
  // roles (SystemAdmin) on an invitation. No session mint path yields the
@@ -2,6 +2,7 @@ import * as z from "zod";
2
2
  import { gateEnforceMfa, type LoginHandlerOptions } from "./login.write.js";
3
3
  export type SwitchTenantMfaGateData = {
4
4
  readonly kind: "mfa-gate-clear";
5
+ readonly roles: readonly string[];
5
6
  } | NonNullable<Awaited<ReturnType<typeof gateEnforceMfa>>>;
6
7
  export declare function createSwitchTenantMfaGateHandler(opts: Pick<LoginHandlerOptions, "mfaStatusChecker">): import("@cosmicdrift/kumiko-types/define-handler").WriteHandlerDefinition<"switch-tenant-mfa-gate", z.ZodObject<{
7
8
  userId: z.ZodString;
@@ -33,7 +33,7 @@ export function createSwitchTenantMfaGateHandler(opts) {
33
33
  const userRow = parseAuthUserRow(await ctx.queryAs(createSystemUser(tenantId), UserQueries.findForAuth, { id: userId }));
34
34
  const roles = buildSessionRoles(parseRoles(userRow?.roles ?? null), active.membership.roles);
35
35
  const mfaGate = await gateEnforceMfa(ctx, opts, userId, tenantId, roles);
36
- return { isSuccess: true, data: mfaGate ?? { kind: "mfa-gate-clear" } };
36
+ return { isSuccess: true, data: mfaGate ?? { kind: "mfa-gate-clear", roles } };
37
37
  },
38
38
  });
39
39
  }
@@ -37,21 +37,24 @@ export async function dispatchMagicLinkMail(notify, spec, params) {
37
37
  // mailT already root-falls-back; appUrl must use the same negotiated tag
38
38
  // or language-in-path apps 404 on e.g. /de-AT/activate.
39
39
  const urlLocale = resolveMailLocale(locale);
40
+ // Template, expiry formatting and app-name fallback must share one locale,
41
+ // otherwise an English template shows e.g. French "3 heures".
42
+ const templateLocale = authMailTemplateLocale(spec.notificationType, locale);
40
43
  const { validFor, validUntil } = tokenMailExpiry({
41
44
  expiresAt: params.expiresAt,
42
45
  ...(params.issuedAt !== undefined && { issuedAt: params.issuedAt }),
43
46
  ...(params.timeZone !== undefined && { timeZone: params.timeZone }),
44
- }, locale);
47
+ }, templateLocale);
45
48
  await notify(spec.notificationType, {
46
49
  route: { email: params.email },
47
50
  data: {
48
- appName: params.appName ?? spec.fallbackAppName ?? mailT(locale, "auth.mail.appNameDefault"),
51
+ appName: params.appName ?? spec.fallbackAppName ?? mailT(templateLocale, "auth.mail.appNameDefault"),
49
52
  actionUrl: appendToken(resolveAppUrl(params.appUrl, urlLocale), params.token),
50
53
  validFor,
51
54
  validUntil,
52
55
  ...(params.role !== undefined && { role: params.role }),
53
56
  },
54
- locale: authMailTemplateLocale(spec.notificationType, locale),
57
+ locale: templateLocale,
55
58
  priority: "critical",
56
59
  });
57
60
  }
@@ -0,0 +1,3 @@
1
+ export declare const AUTH_EMAIL_PASSWORD_MAIL_I18N: Readonly<Record<string, {
2
+ readonly en: string;
3
+ }>>;
@@ -0,0 +1,10 @@
1
+ // @runtime client
2
+ // Server-registered so the document designer names the seeded auth mails.
3
+ export const AUTH_EMAIL_PASSWORD_MAIL_I18N = {
4
+ "auth-email-password:feature.label": { en: "Sign-in" },
5
+ "auth-email-password:notify.password-reset.label": { en: "Password reset" },
6
+ "auth-email-password:notify.account-unlock.label": { en: "Account unlock" },
7
+ "auth-email-password:notify.email-verification.label": { en: "Email verification" },
8
+ "auth-email-password:notify.signup-activation.label": { en: "Sign-up activation" },
9
+ "auth-email-password:notify.invite.label": { en: "Invitation" },
10
+ };
@@ -166,7 +166,14 @@ export function createRecordContractTerminationHandler(options, consumerProtecti
166
166
  effectiveAtIso,
167
167
  providerCancel,
168
168
  };
169
- await stream.appendOne({
169
+ // Re-fetch after the external call: a webhook appended meanwhile would
170
+ // otherwise fail the append on a stale expectedVersion although the
171
+ // provider already cancelled.
172
+ const streamAfterProviderCall = await ctx.fetchForWriting({
173
+ aggregateId: subscriptionAggregateId(tenantId),
174
+ aggregateType: SUBSCRIPTION_AGGREGATE_TYPE,
175
+ });
176
+ await streamAfterProviderCall.appendOne({
170
177
  type: CONTRACT_TERMINATION_REQUESTED_EVENT_QN,
171
178
  payload: recorded,
172
179
  });
@@ -1,4 +1,10 @@
1
1
  export const BILLING_FOUNDATION_I18N = {
2
+ "billing-foundation:feature.label": { en: "Billing" },
3
+ "billing-foundation:notify.contract-confirmation.label": { en: "Contract confirmation" },
4
+ "billing-foundation:notify.contract-termination-receipt.label": { en: "Termination receipt" },
5
+ "billing-foundation:notify.contract-termination-operator-notice.label": {
6
+ en: "Termination notice to operator",
7
+ },
2
8
  "screen:billing-plans.title": { en: "Billing" },
3
9
  "billing-foundation.plans.billingDisabled": {
4
10
  en: "Billing is not active yet. Your current plan stays in place.",
@@ -2,7 +2,7 @@
2
2
  // near-identical webhook-route.ts app copies in show-pony and publicstatus
3
3
  // (infra#446) — the only per-app variables were the TierName union/default,
4
4
  // so it is now a factory parameter.
5
- import { TierAssignmentSources, TierEngineHandlers, TierEngineQueries, tierAssignmentAggregateId, } from "@cosmicdrift/kumiko-bundled-features/tier-engine";
5
+ import { isTierAssignmentSource, TierAssignmentSources, TierEngineHandlers, TierEngineQueries, tierAssignmentAggregateId, } from "@cosmicdrift/kumiko-bundled-features/tier-engine";
6
6
  import { isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
7
7
  import { subscriptionAggregateId } from "./aggregate-id.js";
8
8
  import { SubscriptionFoundationQueries, SubscriptionStatuses } from "./constants.js";
@@ -32,7 +32,13 @@ function findTierAssignment(rows, tenantId) {
32
32
  if (!row || typeof row["id"] !== "string" || typeof row["version"] !== "number") {
33
33
  return undefined;
34
34
  }
35
- return { id: row["id"], version: row["version"], tier: row["tier"], source: row["source"] };
35
+ const source = row["source"];
36
+ return {
37
+ id: row["id"],
38
+ version: row["version"],
39
+ tier: row["tier"],
40
+ source: isTierAssignmentSource(source) ? source : null,
41
+ };
36
42
  }
37
43
  export function createSubscriptionTierSync(deps) {
38
44
  async function syncTierFromSubscription(tenantId, routeDeps) {
@@ -173,6 +173,11 @@ export async function markCapSoftWarned(ctx, options) {
173
173
  }
174
174
  return retryCounterWriteOnVersionConflict(() => attemptMark(writeDb));
175
175
  }
176
+ // A release is written up to a reservation TTL after its increment; once the increment left the
177
+ // window the release must not subtract from other increments still inside it.
178
+ function isBefore(isoInstant, cutoff) {
179
+ return Temporal.Instant.compare(Temporal.Instant.from(isoInstant), cutoff) < 0;
180
+ }
176
181
  async function sumRollingEventAmounts(db, tenantId, options, type) {
177
182
  const aggregateId = rollingCapAggregateId(tenantId, options.capName);
178
183
  const cutoff = Temporal.Now.instant().subtract({ hours: options.windowDays * 24 });
@@ -189,6 +194,9 @@ async function sumRollingEventAmounts(db, tenantId, options, type) {
189
194
  // unknown by drizzle's $type<Record<string,unknown>>); narrowing the
190
195
  // shape here mirrors enforceRollingCap's own read-side contract.
191
196
  const payload = row["payload"];
197
+ if (typeof payload.incrementedAt === "string" && isBefore(payload.incrementedAt, cutoff)) {
198
+ continue;
199
+ }
192
200
  if (typeof payload.amount === "number") {
193
201
  total += payload.amount;
194
202
  }
@@ -61,13 +61,13 @@ export async function reserveCalendarCap(ctx, options, outsideTransaction = true
61
61
  counterValue: valueBeforeBooking + options.amount,
62
62
  };
63
63
  }
64
- function rollingEvent(user, capName, amount, expectedVersion, type) {
64
+ function rollingEvent(user, capName, amount, expectedVersion, type, extraPayload = {}) {
65
65
  return {
66
66
  aggregateId: rollingCapAggregateId(user.tenantId, capName),
67
67
  aggregateType: CAP_COUNTER_ROLLING_AGGREGATE_TYPE,
68
68
  expectedVersion,
69
69
  type,
70
- payload: { capName, amount },
70
+ payload: { capName, amount, ...extraPayload },
71
71
  metadata: { userId: user.id },
72
72
  };
73
73
  }
@@ -104,7 +104,11 @@ async function giveBackCalendar(txDb, user, row) {
104
104
  }
105
105
  async function giveBackRolling(txDb, user, row) {
106
106
  const version = await getStreamVersionInTenantDb(txDb, rollingCapAggregateId(user.tenantId, row.capName));
107
- await appendEventInTenantDb(txDb, rollingEvent(user, row.capName, row.amount, version, ROLLING_RELEASED_EVENT_QN));
107
+ await appendEventInTenantDb(txDb,
108
+ // The window filter must use the increment's time: a sweep releases up to a TTL later.
109
+ rollingEvent(user, row.capName, row.amount, version, ROLLING_RELEASED_EVENT_QN, {
110
+ incrementedAt: row.createdAt.toString(),
111
+ }));
108
112
  return NOTHING_TO_RELEASE;
109
113
  }
110
114
  async function giveBackReservation(txDb, user, id) {
@@ -10,5 +10,6 @@ export declare const rollingIncrementedSchema: z.ZodObject<{
10
10
  export declare const rollingReleasedSchema: z.ZodObject<{
11
11
  capName: z.ZodString;
12
12
  amount: z.ZodNumber;
13
+ incrementedAt: z.ZodOptional<z.ZodString>;
13
14
  }, z.core.$strip>;
14
15
  export declare const incrementRollingCapHandler: WriteHandlerDef;
@@ -34,6 +34,8 @@ export const rollingIncrementedSchema = incrementRollingSchema;
34
34
  /** Payload of the event that gives a reserved rolling amount back. */
35
35
  export const rollingReleasedSchema = incrementRollingSchema.extend({
36
36
  amount: z.number().int().positive(),
37
+ /** When the released increment was booked; absent on events written before it was recorded. */
38
+ incrementedAt: z.string().optional(),
37
39
  });
38
40
  // Rolling-Increment-Handler — append-only. Race-frei: zwei parallele
39
41
  // Increments für (tenant, cap) hängen sich am selben aggregate-stream
@@ -116,7 +116,9 @@ export function createTenantCapsListQuery(caps, listCaps) {
116
116
  });
117
117
  }
118
118
  const db = ctx.systemDb.acknowledgeCrossTenant("cap-overview:tenant-caps:list — SystemAdmin platform-wide tenant overview");
119
- const tenants = await decryptTenantNames(await selectMany(db, tenantTable, {}), "cap-overview:tenant-caps:list");
119
+ const tenants = await decryptTenantNames(
120
+ // Destroyed tenants decrypt to the erased sentinel, which a picker or list would show verbatim.
121
+ await selectMany(db, tenantTable, { destroyedAt: null }), "cap-overview:tenant-caps:list");
120
122
  const assignments = await selectMany(db, tierAssignmentTable, {});
121
123
  const subscriptions = await selectMany(db, subscriptionsProjectionTable, {});
122
124
  const assignmentByTenant = new Map(assignments.map((row) => [row.tenantId, row]));
@@ -15,7 +15,9 @@ export const tenantOptionsQuery = defineQueryHandler({
15
15
  });
16
16
  }
17
17
  const db = ctx.systemDb.acknowledgeCrossTenant("cap-overview:tenant-options — SystemAdmin dashboard tenant-filter options");
18
- const tenants = await decryptTenantNames(await selectMany(db, tenantTable, {}), "cap-overview:tenant-options");
18
+ const tenants = await decryptTenantNames(
19
+ // Destroyed tenants decrypt to the erased sentinel, which the picker would show verbatim.
20
+ await selectMany(db, tenantTable, { destroyedAt: null }), "cap-overview:tenant-options");
19
21
  const sorted = [...tenants].sort((a, b) => a.name.localeCompare(b.name));
20
22
  return { rows: sorted.map((tenant) => ({ value: tenant.id, label: tenant.name })) };
21
23
  },
@@ -1,7 +1,8 @@
1
1
  import { chatMessageText, createChatWebhookChannel, truncateChars, } from "../delivery/index.js";
2
+ const DISCORD_WEBHOOK_PREFIX = "channel-discord:webhooks.";
2
3
  export const DISCORD_SECRET_KEYS = {
3
- webhookPrefix: "channel-discord:webhooks.",
4
- webhookKeyFor: (connection) => `channel-discord:webhooks.${connection}`,
4
+ webhookPrefix: DISCORD_WEBHOOK_PREFIX,
5
+ webhookKeyFor: (connection) => `${DISCORD_WEBHOOK_PREFIX}${connection}`,
5
6
  };
6
7
  export const DISCORD_DEFAULT_ALLOWED_HOSTS = ["discord.com", "discordapp.com"];
7
8
  export const DISCORD_WEBHOOK_PATH_PREFIX = "/api/webhooks/";
@@ -1,7 +1,8 @@
1
1
  import { PII_ERASED_SENTINEL } from "@cosmicdrift/kumiko-framework/crypto";
2
+ import { createFallbackLogger } from "@cosmicdrift/kumiko-framework/logging";
2
3
  import * as z from "zod";
3
4
  import { DELIVERY_UNSUBSCRIBE_ONE_CLICK_HEADER_VALUE, DELIVERY_UNSUBSCRIBE_PATH, EMAIL_CHANNEL_NAME, fileAttachmentRefSchema, MAX_EMAIL_ATTACHMENTS, renderedPdfAttachmentRefSchema, } from "../delivery/index.js";
4
- import { decryptStoredPii } from "../shared/index.js";
5
+ import { decryptStoredPii, extensionForResourceContentType } from "../shared/index.js";
5
6
  import { guardEmailMessage } from "./pii-guard.js";
6
7
  // Envelope (From / Reply-To / threading headers) rides on the channel data —
7
8
  // the notification's email template echoes it out of the notify() call, since
@@ -22,7 +23,8 @@ const UNSUBSCRIBE_HEADER_NAMES = new Set([
22
23
  "list-unsubscribe",
23
24
  "list-unsubscribe-post",
24
25
  ]);
25
- // Only the framework's own unsubscribe route can honor a one-click POST.
26
+ // Only the framework's own unsubscribe route can honor a one-click POST; an app's
27
+ // own unsubscribe page still gets the plain List-Unsubscribe link (RFC 2369).
26
28
  function listUnsubscribeHeaders(data) {
27
29
  const rawUrl = data?.["unsubscribeUrl"];
28
30
  if (typeof rawUrl !== "string")
@@ -36,8 +38,9 @@ function listUnsubscribeHeaders(data) {
36
38
  }
37
39
  if (url.protocol !== "https:" && url.protocol !== "http:")
38
40
  return undefined;
39
- if (url.pathname !== DELIVERY_UNSUBSCRIBE_PATH)
40
- return undefined;
41
+ if (url.pathname !== DELIVERY_UNSUBSCRIBE_PATH) {
42
+ return { "List-Unsubscribe": `<${url.toString()}>` };
43
+ }
41
44
  return {
42
45
  "List-Unsubscribe": `<${url.toString()}>`,
43
46
  "List-Unsubscribe-Post": DELIVERY_UNSUBSCRIBE_ONE_CLICK_HEADER_VALUE,
@@ -81,15 +84,13 @@ export function fileAttachmentsFrom(data, messageLocale) {
81
84
  ? { ...ref, locale: messageLocale }
82
85
  : ref);
83
86
  }
84
- const INLINE_EXTENSION_BY_CONTENT_TYPE = {
85
- "image/png": "png",
86
- "image/jpeg": "jpg",
87
- "image/gif": "gif",
88
- "image/webp": "webp",
89
- };
87
+ function inlineFileName(cid, contentType) {
88
+ const extension = extensionForResourceContentType(contentType);
89
+ return extension === undefined ? cid : `${cid}.${extension}`;
90
+ }
90
91
  async function resolveAttachments(sources, ctx) {
91
92
  if (sources.length === 0)
92
- return [];
93
+ return { kind: "resolved", attachments: [] };
93
94
  if (ctx.readAttachment === undefined) {
94
95
  throw new Error("email channel: the message has attachments but the channel context cannot read files");
95
96
  }
@@ -113,16 +114,12 @@ async function resolveAttachments(sources, ctx) {
113
114
  // A missing inline image only degrades the look; a missing file is content the recipient expects.
114
115
  if (ref.kind === "inline")
115
116
  continue;
116
- throw new Error(`email channel: attachment file ${ref.fileRefId} was not found`);
117
+ return { kind: "missing-file", fileRefId: ref.fileRefId };
117
118
  }
118
- const fileName = ref.kind === "file"
119
- ? ref.fileName
120
- : INLINE_EXTENSION_BY_CONTENT_TYPE[found.contentType]
121
- ? `${ref.cid}.${INLINE_EXTENSION_BY_CONTENT_TYPE[found.contentType]}`
122
- : ref.cid;
119
+ const fileName = ref.kind === "file" ? ref.fileName : inlineFileName(ref.cid, found.contentType);
123
120
  resolved.push({ ...ref, fileName, content: found.content, contentType: found.contentType });
124
121
  }
125
- return resolved;
122
+ return { kind: "resolved", attachments: resolved };
126
123
  }
127
124
  export function createEmailChannel(options) {
128
125
  const { transport, resolveEmail } = options;
@@ -175,10 +172,18 @@ export function createEmailChannel(options) {
175
172
  ...(replyTo && { replyTo }),
176
173
  ...emailEnvelopeFrom(message.data),
177
174
  };
178
- const resolvedAttachments = await resolveAttachments([
175
+ const resolution = await resolveAttachments([
179
176
  ...(attachments ?? []).map((ref) => ({ ref, origin: "template" })),
180
177
  ...fileAttachmentsFrom(message.data, message.locale).map((ref) => ({ ref, origin: "message" })),
181
178
  ], ctx);
179
+ if (resolution.kind === "missing-file") {
180
+ // A missing file is permanent, a retry cannot bring it back.
181
+ createFallbackLogger("delivery").warn("mail attachment file not found, send failed", {
182
+ fileRefId: resolution.fileRefId,
183
+ });
184
+ return { status: "failed", error: "send_failed", address };
185
+ }
186
+ const resolvedAttachments = resolution.attachments;
182
187
  await transport.send(guardEmailMessage({
183
188
  to: address,
184
189
  subject,
@@ -21,11 +21,16 @@ function isControlCharacter(char) {
21
21
  const code = char.charCodeAt(0);
22
22
  return code <= 0x1f || code === 0x7f;
23
23
  }
24
+ // Mail clients often show only the display name: an embedded address ("<", ">", "@") would
25
+ // pass for a foreign sender on the platform's own SPF/DKIM-signed domain.
26
+ function isUnsafeDisplayNameCharacter(char) {
27
+ return isControlCharacter(char) || char === "<" || char === ">" || char === "@";
28
+ }
24
29
  // Control characters in a display name would let a caller inject headers.
25
30
  function sanitizedDisplayName(name) {
26
31
  const cleaned = name
27
32
  ?.split("")
28
- .map((char) => (isControlCharacter(char) ? " " : char))
33
+ .map((char) => (isUnsafeDisplayNameCharacter(char) ? " " : char))
29
34
  .join("")
30
35
  .trim();
31
36
  return cleaned === undefined || cleaned === "" ? undefined : cleaned;
@@ -1,7 +1,8 @@
1
1
  import { chatMessageText, createChatWebhookChannel, } from "../delivery/index.js";
2
+ const SLACK_WEBHOOK_PREFIX = "channel-slack:webhooks.";
2
3
  export const SLACK_SECRET_KEYS = {
3
- webhookPrefix: "channel-slack:webhooks.",
4
- webhookKeyFor: (connection) => `channel-slack:webhooks.${connection}`,
4
+ webhookPrefix: SLACK_WEBHOOK_PREFIX,
5
+ webhookKeyFor: (connection) => `${SLACK_WEBHOOK_PREFIX}${connection}`,
5
6
  };
6
7
  export const SLACK_DEFAULT_ALLOWED_HOSTS = ["hooks.slack.com"];
7
8
  // Slack mrkdwn treats & < > as control characters (links, @-mentions via <!channel>).
@@ -1,7 +1,8 @@
1
1
  import { createChatWebhookChannel, } from "../delivery/index.js";
2
+ const TEAMS_WEBHOOK_PREFIX = "channel-teams:webhooks.";
2
3
  export const TEAMS_SECRET_KEYS = {
3
- webhookPrefix: "channel-teams:webhooks.",
4
- webhookKeyFor: (connection) => `channel-teams:webhooks.${connection}`,
4
+ webhookPrefix: TEAMS_WEBHOOK_PREFIX,
5
+ webhookKeyFor: (connection) => `${TEAMS_WEBHOOK_PREFIX}${connection}`,
5
6
  };
6
7
  export const TEAMS_DEFAULT_ALLOWED_HOSTS = [
7
8
  ".webhook.office.com",
@@ -2,7 +2,7 @@ import { requestContext } from "@cosmicdrift/kumiko-framework/api";
2
2
  import { ROLES } from "@cosmicdrift/kumiko-framework/auth";
3
3
  import { fetchOne, runInSavepointIfSupported, runnerCanBeginTransaction, transaction, } from "@cosmicdrift/kumiko-framework/bun-db";
4
4
  import { computeBlindIndex, configuredBlindIndexKey, configuredPiiSubjectKms, subjectIdSchema, subjectIdToKey, } from "@cosmicdrift/kumiko-framework/crypto";
5
- import { nullBlindIndexesForSubject, recordEventsOwnedExclusivelyByTenant, recordRowExistsInTenant, recordRowOwningTenantId, subjectRowExistsInTenant, } from "@cosmicdrift/kumiko-framework/db";
5
+ import { nullBlindIndexesForSubject, recordEventsOwnedExclusivelyByTenant, recordRowExistsInTenant, recordRowOwningTenantId, subjectRowExistsInTenant, WritePurposes, } from "@cosmicdrift/kumiko-framework/db";
6
6
  import { defineWriteHandler, } from "@cosmicdrift/kumiko-framework/engine";
7
7
  import { AccessDeniedError, InternalError, writeFailure, } from "@cosmicdrift/kumiko-framework/errors";
8
8
  import { appendDomainEventCore, assertIrreversibleOperationAllowed, } from "@cosmicdrift/kumiko-framework/pipeline";
@@ -254,7 +254,7 @@ async function closeUserLoginDoor(ctx, userId, outsideRunner, handlerTxRunner) {
254
254
  if (!userRow)
255
255
  return;
256
256
  const closeDoor = async (runner) => {
257
- await updateUserLifecycle(runner, userId, { status: USER_STATUS.Deleted });
257
+ await updateUserLifecycle(runner, userId, { status: USER_STATUS.Deleted }, { writePurpose: WritePurposes.erasure });
258
258
  if (ctx.registry.features.has("personal-access-tokens")) {
259
259
  await revokeAllPatTokensForUser(runner, userId);
260
260
  }
@@ -1,3 +1,3 @@
1
- import type { DbRunner } from "@cosmicdrift/kumiko-framework/db";
1
+ import { type DbRunner } from "@cosmicdrift/kumiko-framework/db";
2
2
  export declare function selectHostRowsWithCustomFields(db: DbRunner, tableName: string, tenantId: string): Promise<readonly unknown[]>;
3
3
  export declare function applyRetentionRemovals(db: DbRunner, tableName: string, deleteKeys: readonly string[], anonymizeKeys: readonly string[], rowId: string): Promise<void>;
@@ -1,4 +1,5 @@
1
1
  import { asRawClient, unsafeReadRetrying } from "@cosmicdrift/kumiko-framework/bun-db";
2
+ import { toPostgresArrayLiteral } from "@cosmicdrift/kumiko-framework/db";
2
3
  export async function selectHostRowsWithCustomFields(db, tableName, tenantId) {
3
4
  const quoted = `"${tableName.replace(/"/g, '""')}"`;
4
5
  const rowsResult = await unsafeReadRetrying(db, `SELECT id, modified_at, custom_fields FROM ${quoted} WHERE tenant_id = $1 AND custom_fields IS NOT NULL`, [tenantId]);
@@ -19,5 +20,5 @@ export async function applyRetentionRemovals(db, tableName, deleteKeys, anonymiz
19
20
  )
20
21
  ELSE custom_fields
21
22
  END
22
- WHERE id = $3`, [deleteKeys, anonymizeKeys, rowId]);
23
+ WHERE id = $3`, [toPostgresArrayLiteral(deleteKeys), toPostgresArrayLiteral(anonymizeKeys), rowId]);
23
24
  }