@cosmicdrift/kumiko-samples 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 (148) hide show
  1. package/LICENSE +1 -1
  2. package/package.json +1 -1
  3. package/packages/bundled-features/package.json +1 -1
  4. package/packages/bundled-features/src/audit/changes.json +12 -0
  5. package/packages/bundled-features/src/audit/feature.ts +1 -1
  6. package/packages/bundled-features/src/audit/run-escape-hatch-retention.ts +63 -11
  7. package/packages/bundled-features/src/auth-email-password/bootstrap.ts +1 -3
  8. package/packages/bundled-features/src/auth-email-password/changes.json +18 -0
  9. package/packages/bundled-features/src/auth-email-password/feature.ts +4 -7
  10. package/packages/bundled-features/src/auth-email-password/handlers/invite-accept-with-login.write.ts +2 -3
  11. package/packages/bundled-features/src/auth-email-password/handlers/invite-create.write.ts +6 -4
  12. package/packages/bundled-features/src/auth-email-password/handlers/switch-tenant-mfa-gate.write.ts +2 -2
  13. package/packages/bundled-features/src/auth-email-password/magic-link-mail.ts +7 -3
  14. package/packages/bundled-features/src/auth-email-password/mail-i18n.ts +10 -0
  15. package/packages/bundled-features/src/billing-foundation/changes.json +12 -0
  16. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-record.ts +8 -1
  17. package/packages/bundled-features/src/billing-foundation/i18n.ts +6 -0
  18. package/packages/bundled-features/src/billing-foundation/subscription-tier-sync.ts +10 -2
  19. package/packages/bundled-features/src/cap-counter/book-cap-usage.ts +20 -8
  20. package/packages/bundled-features/src/cap-counter/cap-reservation.ts +7 -2
  21. package/packages/bundled-features/src/cap-counter/changes.json +13 -0
  22. package/packages/bundled-features/src/cap-counter/handlers/increment-rolling.write.ts +2 -0
  23. package/packages/bundled-features/src/cap-overview/changes.json +6 -0
  24. package/packages/bundled-features/src/cap-overview/handlers/tenant-caps-list.query.ts +2 -1
  25. package/packages/bundled-features/src/cap-overview/handlers/tenant-options.query.ts +2 -1
  26. package/packages/bundled-features/src/channel-discord/discord-channel.ts +4 -2
  27. package/packages/bundled-features/src/channel-email/changes.json +18 -0
  28. package/packages/bundled-features/src/channel-email/email-channel.ts +30 -20
  29. package/packages/bundled-features/src/channel-email/smtp-transport.ts +7 -1
  30. package/packages/bundled-features/src/channel-slack/slack-channel.ts +4 -2
  31. package/packages/bundled-features/src/channel-teams/teams-channel.ts +4 -2
  32. package/packages/bundled-features/src/crypto-shredding/changes.json +7 -0
  33. package/packages/bundled-features/src/crypto-shredding/handlers/forget-subject.write.ts +7 -1
  34. package/packages/bundled-features/src/custom-fields/db/queries/retention.ts +2 -2
  35. package/packages/bundled-features/src/data-retention/run-retention-cleanup.ts +4 -5
  36. package/packages/bundled-features/src/delivery/attachment-ref.ts +18 -1
  37. package/packages/bundled-features/src/delivery/changes.json +12 -0
  38. package/packages/bundled-features/src/delivery/delivery-service.ts +93 -92
  39. package/packages/bundled-features/src/document-ingest-foundation/changes.json +6 -0
  40. package/packages/bundled-features/src/feature-toggles/changes.json +7 -0
  41. package/packages/bundled-features/src/feature-toggles/toggle-runtime.ts +26 -12
  42. package/packages/bundled-features/src/files-tenant-data/index.ts +3 -1
  43. package/packages/bundled-features/src/form-draft/db/queries/owned-file-refs.ts +12 -3
  44. package/packages/bundled-features/src/form-draft/handlers/discard.write.ts +1 -1
  45. package/packages/bundled-features/src/jobs/changes.json +12 -0
  46. package/packages/bundled-features/src/jobs/job-run-logger.ts +5 -2
  47. package/packages/bundled-features/src/metrics/changes.json +6 -0
  48. package/packages/bundled-features/src/metrics/filters.ts +28 -12
  49. package/packages/bundled-features/src/personal-access-tokens/changes.json +6 -0
  50. package/packages/bundled-features/src/personal-access-tokens/revoke-for-user.ts +7 -8
  51. package/packages/bundled-features/src/secrets/changes.json +6 -0
  52. package/packages/bundled-features/src/secrets/handlers/delete.write.ts +3 -1
  53. package/packages/bundled-features/src/secrets/write-gate.ts +8 -2
  54. package/packages/bundled-features/src/shared/index.ts +8 -0
  55. package/packages/bundled-features/src/shared/resource-image-formats.ts +31 -0
  56. package/packages/bundled-features/src/shared/sharp-input-limits.ts +7 -8
  57. package/packages/bundled-features/src/step-dispatcher/changes.json +8 -1
  58. package/packages/bundled-features/src/step-dispatcher/constants.ts +22 -0
  59. package/packages/bundled-features/src/step-dispatcher/deliver-dispatch-request.ts +236 -0
  60. package/packages/bundled-features/src/step-dispatcher/feature.ts +65 -118
  61. package/packages/bundled-features/src/step-dispatcher/webhook-runner.ts +40 -14
  62. package/packages/bundled-features/src/template-resolver/changes.json +81 -0
  63. package/packages/bundled-features/src/template-resolver/compile-document-body.ts +78 -12
  64. package/packages/bundled-features/src/template-resolver/compile-steps-block.ts +68 -0
  65. package/packages/bundled-features/src/template-resolver/constants.ts +11 -1
  66. package/packages/bundled-features/src/template-resolver/document-sanitizer.ts +3 -0
  67. package/packages/bundled-features/src/template-resolver/document-schema.ts +7 -1
  68. package/packages/bundled-features/src/template-resolver/document-steps.ts +114 -0
  69. package/packages/bundled-features/src/template-resolver/feature.ts +12 -0
  70. package/packages/bundled-features/src/template-resolver/handlers/bundle-import.ts +81 -39
  71. package/packages/bundled-features/src/template-resolver/handlers/collection-set.write.ts +4 -3
  72. package/packages/bundled-features/src/template-resolver/handlers/copy-locale.write.ts +111 -61
  73. package/packages/bundled-features/src/template-resolver/handlers/diff-to-system.query.ts +1 -0
  74. package/packages/bundled-features/src/template-resolver/handlers/import-bundle-system.write.ts +25 -15
  75. package/packages/bundled-features/src/template-resolver/handlers/import-bundle.write.ts +12 -1
  76. package/packages/bundled-features/src/template-resolver/handlers/overview.query.ts +12 -2
  77. package/packages/bundled-features/src/template-resolver/handlers/resource-upload.write.ts +48 -9
  78. package/packages/bundled-features/src/template-resolver/handlers/validate.query.ts +4 -1
  79. package/packages/bundled-features/src/template-resolver/index.ts +6 -1
  80. package/packages/bundled-features/src/template-resolver/layout-appearance.ts +25 -9
  81. package/packages/bundled-features/src/template-resolver/mail-constants.ts +4 -0
  82. package/packages/bundled-features/src/template-resolver/resource-image.ts +9 -16
  83. package/packages/bundled-features/src/template-resolver/seeding.ts +2 -0
  84. package/packages/bundled-features/src/template-resolver/store-template-resource.ts +390 -124
  85. package/packages/bundled-features/src/template-resolver/template-bundle.ts +4 -0
  86. package/packages/bundled-features/src/template-resolver/template-draft.ts +1 -1
  87. package/packages/bundled-features/src/template-resolver/template-meta.ts +8 -0
  88. package/packages/bundled-features/src/template-resolver/template-preview.ts +1 -1
  89. package/packages/bundled-features/src/template-resolver/template-resources.ts +2 -43
  90. package/packages/bundled-features/src/template-resolver/template-validation.ts +15 -9
  91. package/packages/bundled-features/src/template-resolver/web/document-editor-extensions.ts +34 -0
  92. package/packages/bundled-features/src/template-resolver/web/document-editor.tsx +3 -2
  93. package/packages/bundled-features/src/template-resolver/web/document-tiptap-editor.tsx +56 -11
  94. package/packages/bundled-features/src/template-resolver/web/document-toolbar-blocks.ts +6 -3
  95. package/packages/bundled-features/src/template-resolver/web/i18n.ts +7 -0
  96. package/packages/bundled-features/src/template-resolver/web/index.ts +1 -0
  97. package/packages/bundled-features/src/template-resolver/web/placeholder-line-editor.tsx +14 -5
  98. package/packages/bundled-features/src/tenant/changes.json +6 -0
  99. package/packages/bundled-features/src/tenant/handlers/update-member-roles.write.ts +3 -8
  100. package/packages/bundled-features/src/tenant/seeding.ts +33 -27
  101. package/packages/bundled-features/src/tenant-handover/move-entity-graph.ts +14 -3
  102. package/packages/bundled-features/src/tenant-lifecycle/changes.json +6 -0
  103. package/packages/bundled-features/src/tenant-lifecycle/run-tenant-destroy.ts +9 -1
  104. package/packages/bundled-features/src/tenant-lifecycle/stages.ts +9 -3
  105. package/packages/bundled-features/src/tier-engine/changes.json +12 -0
  106. package/packages/bundled-features/src/tier-engine/constants.ts +9 -0
  107. package/packages/bundled-features/src/tier-engine/entity.ts +9 -4
  108. package/packages/bundled-features/src/tier-engine/handlers/set-tenant-tier.write.ts +8 -5
  109. package/packages/bundled-features/src/tier-engine/index.ts +2 -0
  110. package/packages/bundled-features/src/user/constants.ts +1 -0
  111. package/packages/bundled-features/src/user/invited-global-roles.ts +2 -4
  112. package/packages/bundled-features/src/user-data-rights/changes.json +6 -0
  113. package/packages/bundled-features/src/user-data-rights/email-templates.ts +0 -1
  114. package/packages/bundled-features/src/user-data-rights/i18n.ts +5 -0
  115. package/packages/bundled-features/src/user-data-rights/lib/update-user-lifecycle.ts +13 -2
  116. package/packages/bundled-features/src/user-data-rights/run-forget-cleanup.ts +7 -1
  117. package/packages/bundled-features/src/waitlist/changes.json +18 -0
  118. package/packages/bundled-features/src/waitlist/feature.ts +1 -1
  119. package/packages/bundled-features/src/waitlist/handlers/invite.write.ts +35 -3
  120. package/packages/bundled-features/src/waitlist/handlers/reject.write.ts +2 -17
  121. package/packages/bundled-features/src/waitlist/handlers/submit.write.ts +33 -9
  122. package/packages/bundled-features/src/waitlist/i18n.ts +9 -0
  123. package/packages/bundled-features/src/waitlist/lib.ts +20 -0
  124. package/packages/bundled-features/src/workflow-runner/feature.ts +1 -1
  125. package/samples/apps/ui-walkthrough/src/run-config.ts +14 -1
  126. package/samples/apps/use-all-bundled/README.md +3 -13
  127. package/samples/apps/use-all-bundled/feature-manifest.json +4 -4
  128. package/samples/apps/use-all-bundled/scripts/check-coverage.ts +34 -118
  129. package/samples/apps/use-all-bundled/src/app/notes-feature.ts +1 -1
  130. package/samples/apps/use-all-bundled/src/run-config.ts +0 -3
  131. package/samples/recipes/auth-mfa/README.md +2 -1
  132. package/samples/recipes/auth-mfa/src/feature.ts +1 -0
  133. package/samples/recipes/custom-handlers/README.md +4 -3
  134. package/samples/recipes/custom-handlers/src/feature.ts +21 -12
  135. package/samples/recipes/dashboard-stats/README.md +38 -0
  136. package/samples/recipes/dashboard-stats/package.json +8 -0
  137. package/samples/recipes/dashboard-stats/src/feature.ts +104 -0
  138. package/samples/recipes/legal-pages/README.md +2 -2
  139. package/samples/recipes/managed-pages/README.md +2 -2
  140. package/samples/recipes/pdf-audit-export/README.md +2 -1
  141. package/samples/recipes/pdf-audit-export/package.json +1 -1
  142. package/samples/recipes/pdf-audit-export/src/feature.ts +68 -48
  143. package/samples/recipes/workflow-engine/src/db/queries/suspended-runs.ts +2 -2
  144. package/samples/apps/use-all-bundled/schema/generate.ts +0 -617
  145. package/samples/apps/use-all-bundled/schema/migrations/meta/0000_snapshot.json +0 -3055
  146. package/samples/apps/use-all-bundled/schema/migrations/meta/_journal.json +0 -13
  147. package/samples/apps/use-all-bundled/schema/schema.generated.ts +0 -94
  148. package/samples/apps/use-all-bundled/schema/schema.ts +0 -9
package/LICENSE CHANGED
@@ -4,7 +4,7 @@ Parameters
4
4
 
5
5
  Licensor: Marc Frost
6
6
 
7
- Licensed Work: @cosmicdrift/kumiko-testing
7
+ Licensed Work: @cosmicdrift/kumiko-samples
8
8
  The Licensed Work is © 2026 Marc Frost.
9
9
 
10
10
  Additional Use Grant:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-samples",
3
- "version": "0.364.0",
3
+ "version": "0.365.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": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.364.0",
3
+ "version": "0.365.1",
4
4
  "description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -1,4 +1,16 @@
1
1
  [
2
+ {
3
+ "version": "0.365.0",
4
+ "type": "fix",
5
+ "title": "Escape-hatch retention keeps the operator floor and deletes in bounded batches",
6
+ "detail": "An explicitly configured retention key now acts as a lower bound for tenant compliance-profile cutoffs, so a tenant profile can no longer delete events younger than the operator allows. A key left at its default does not count. Pruning runs in batches of 5000 events, each in its own transaction, until a batch comes back short."
7
+ },
8
+ {
9
+ "version": "0.365.0",
10
+ "type": "fix",
11
+ "title": "Audit retention events always use the system user",
12
+ "detail": "Events written by the audit feature no longer fall back to the calling user id and always record the system user."
13
+ },
2
14
  {
3
15
  "version": "0.364.0",
4
16
  "type": "fix",
@@ -76,7 +76,7 @@ export function createAuditFeature(): FeatureDefinition {
76
76
  db: ctx.db.unsafeRaw(),
77
77
  registry: ctx.registry,
78
78
  configResolver: ctx.configResolver,
79
- userId: ctx._userId ?? SYSTEM_USER_ID,
79
+ userId: SYSTEM_USER_ID,
80
80
  });
81
81
  },
82
82
  });
@@ -1,4 +1,8 @@
1
- import { aggregateWhere, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
1
+ import {
2
+ aggregateWhere,
3
+ runnerCanBeginTransaction,
4
+ selectMany,
5
+ } from "@cosmicdrift/kumiko-framework/bun-db";
2
6
  import {
3
7
  COMPLIANCE_PROFILES,
4
8
  subtractRetentionSpec,
@@ -27,7 +31,7 @@ import {
27
31
 
28
32
  // pruneEvents opens its own transaction, so a runner that cannot begin one (a tx handle) is rejected instead of nested.
29
33
  export function isDbConnection(runner: DbRunner): runner is DbConnection {
30
- return "begin" in runner && typeof runner.begin === "function";
34
+ return runnerCanBeginTransaction(runner);
31
35
  }
32
36
 
33
37
  async function resolveRetentionDays(args: {
@@ -48,9 +52,30 @@ async function resolveRetentionDays(args: {
48
52
  return typeof raw === "number" && raw >= 1 ? raw : DEFAULT_ESCAPE_HATCH_RETENTION_DAYS;
49
53
  }
50
54
 
55
+ // Only an operator-set value counts: the key's declared default must not override the tenant profiles.
56
+ async function resolveExplicitRetentionDays(args: {
57
+ readonly registry: Registry;
58
+ readonly configResolver: ConfigResolver | undefined;
59
+ readonly db: DbConnection;
60
+ readonly userId: string;
61
+ }): Promise<number | undefined> {
62
+ const keyDef = args.registry.getConfigKey(ESCAPE_HATCH_RETENTION_DAYS_KEY);
63
+ if (!args.configResolver || !keyDef) return undefined;
64
+ const { value, source } = await args.configResolver.getWithSource(
65
+ ESCAPE_HATCH_RETENTION_DAYS_KEY,
66
+ keyDef,
67
+ SYSTEM_TENANT_ID,
68
+ args.userId,
69
+ args.db,
70
+ );
71
+ const isOperatorSet = source !== "default" && source !== "missing";
72
+ return isOperatorSet && typeof value === "number" && value >= 1 ? value : undefined;
73
+ }
74
+
51
75
  // Soft dependency: audit must stay mountable without compliance-profiles, so we probe its entity.
52
76
  const COMPLIANCE_PROFILE_ENTITY = "tenant-compliance-profile";
53
77
  const PRUNE_CHUNK_SIZE = 1000;
78
+ const PRUNE_BATCH_SIZE = 5000;
54
79
 
55
80
  type AuditEventRow = {
56
81
  readonly aggregateId: string;
@@ -68,11 +93,13 @@ async function resolveTenantCutoff(
68
93
  db: DbConnection,
69
94
  tenantId: TenantId,
70
95
  now: Temporal.Instant,
96
+ operatorFloor: Temporal.Instant | undefined,
71
97
  ): Promise<Temporal.Instant> {
72
98
  const { profile } = await resolveProfileForTenant({ db, tenantId });
73
99
  const effective = subtractRetentionSpec(now, profile.auditLog.retention);
74
100
  const base = subtractRetentionSpec(now, COMPLIANCE_PROFILES[profile.key].auditLog.retention);
75
- return earlierInstant(effective, base);
101
+ const profileCutoff = earlierInstant(effective, base);
102
+ return operatorFloor ? earlierInstant(profileCutoff, operatorFloor) : profileCutoff;
76
103
  }
77
104
 
78
105
  // A cross-tenant audit (e.g. identity-switch) lives under the actor's tenant but must also
@@ -95,13 +122,20 @@ async function storingTenantIds(db: DbConnection): Promise<readonly TenantId[]>
95
122
  });
96
123
  }
97
124
 
98
- async function pruneByTenantProfiles(db: DbConnection): Promise<number> {
125
+ async function pruneByTenantProfiles(
126
+ db: DbConnection,
127
+ operatorRetentionDays: number | undefined,
128
+ ): Promise<number> {
99
129
  const now = getTemporal().Now.instant();
130
+ const operatorFloor =
131
+ operatorRetentionDays === undefined
132
+ ? undefined
133
+ : now.subtract({ hours: operatorRetentionDays * 24 });
100
134
  const cutoffByTenant = new Map<TenantId, Temporal.Instant>();
101
135
  const cutoffOf = async (tenantId: TenantId): Promise<Temporal.Instant> => {
102
136
  const cached = cutoffByTenant.get(tenantId);
103
137
  if (cached) return cached;
104
- const cutoff = await resolveTenantCutoff(db, tenantId, now);
138
+ const cutoff = await resolveTenantCutoff(db, tenantId, now, operatorFloor);
105
139
  cutoffByTenant.set(tenantId, cutoff);
106
140
  return cutoff;
107
141
  };
@@ -139,11 +173,32 @@ async function pruneByTenantProfiles(db: DbConnection): Promise<number> {
139
173
  return deletedCount;
140
174
  }
141
175
 
176
+ async function pruneInBatches(
177
+ db: DbConnection,
178
+ olderThanDays: number,
179
+ batchSize = PRUNE_BATCH_SIZE,
180
+ ): Promise<number> {
181
+ const olderThan = getTemporal()
182
+ .Now.instant()
183
+ .subtract({ hours: olderThanDays * 24 });
184
+ let deletedCount = 0;
185
+ for (;;) {
186
+ const batch = await pruneEvents(db, {
187
+ aggregateTypes: [ESCAPE_HATCH_USE_AGGREGATE_TYPE],
188
+ olderThan,
189
+ batchSize,
190
+ });
191
+ deletedCount += batch.deletedCount;
192
+ if (batch.deletedCount < batchSize) return deletedCount;
193
+ }
194
+ }
195
+
142
196
  export async function runEscapeHatchRetention(args: {
143
197
  readonly db: DbRunner;
144
198
  readonly registry: Registry;
145
199
  readonly configResolver: ConfigResolver | undefined;
146
200
  readonly userId: string;
201
+ readonly batchSize?: number;
147
202
  }): Promise<{ readonly deletedCount: number }> {
148
203
  const { db } = args;
149
204
  if (!isDbConnection(db)) {
@@ -152,12 +207,9 @@ export async function runEscapeHatchRetention(args: {
152
207
 
153
208
  if (!args.registry.getEntity(COMPLIANCE_PROFILE_ENTITY)) {
154
209
  const olderThanDays = await resolveRetentionDays({ ...args, db });
155
- const { deletedCount } = await pruneEvents(db, {
156
- aggregateTypes: [ESCAPE_HATCH_USE_AGGREGATE_TYPE],
157
- olderThanDays,
158
- });
159
- return { deletedCount };
210
+ return { deletedCount: await pruneInBatches(db, olderThanDays, args.batchSize) };
160
211
  }
161
212
 
162
- return { deletedCount: await pruneByTenantProfiles(db) };
213
+ const operatorRetentionDays = await resolveExplicitRetentionDays({ ...args, db });
214
+ return { deletedCount: await pruneByTenantProfiles(db, operatorRetentionDays) };
163
215
  }
@@ -36,14 +36,12 @@ import { tenantMembershipsTable } from "../tenant/membership-table.js";
36
36
  // kumiko-lint-ignore cross-feature-import bootstrap checks for an existing tenant
37
37
  import { tenantTable } from "../tenant/schema/tenant.js";
38
38
  // kumiko-lint-ignore cross-feature-import global-role options are owned by the user feature
39
- import type { GLOBAL_ROLE_OPTIONS } from "../user/constants.js";
39
+ import type { GlobalRole } from "../user/constants.js";
40
40
  // kumiko-lint-ignore cross-feature-import bootstrap skips already activated users
41
41
  import { userTable } from "../user/schema/user.js";
42
42
  import { AuthHandlers } from "./constants.js";
43
43
  import { hasLiveInviteToken } from "./invite-token-store.js";
44
44
 
45
- type GlobalRole = (typeof GLOBAL_ROLE_OPTIONS)[number];
46
-
47
45
  export type BootstrapInvite = {
48
46
  readonly email: string;
49
47
  /** Membership role in the tenant, validated like a tenant-admin invite
@@ -1,4 +1,22 @@
1
1
  [
2
+ {
3
+ "version": "0.365.0",
4
+ "type": "fix",
5
+ "title": "Magic-link mail expiry and app name use the template locale",
6
+ "detail": "The expiry text, the app-name fallback and the notify locale follow the locale the auth mail template actually renders in, while the link keeps the request locale."
7
+ },
8
+ {
9
+ "version": "0.365.0",
10
+ "type": "improvement",
11
+ "title": "systemInviteCreate takes an optional locale tag",
12
+ "detail": "The payload accepts a locale (BCP 47 tag) that wins over the request locale when rendering the invitation mail, so system callers such as the waitlist can invite in the recipient's language."
13
+ },
14
+ {
15
+ "version": "0.365.0",
16
+ "type": "improvement",
17
+ "title": "Auth mails carry designer labels",
18
+ "detail": "The feature registers <feature>:feature.label and notify labels for password-reset, account-unlock, email-verification, signup-activation and invite, so the document designer lists them by name. German and Spanish ship in the locale packages."
19
+ },
2
20
  {
3
21
  "version": "0.364.0",
4
22
  "type": "fix",
@@ -30,6 +30,7 @@ import {
30
30
  } from "./handlers/signup-request.write.js";
31
31
  import { createSwitchTenantMfaGateHandler } from "./handlers/switch-tenant-mfa-gate.write.js";
32
32
  import { createVerifyEmailHandler } from "./handlers/verify-email.write.js";
33
+ import { AUTH_EMAIL_PASSWORD_MAIL_I18N } from "./mail-i18n.js";
33
34
  import {
34
35
  AUTH_MAIL_VARIABLES,
35
36
  authMailLocalizedTemplate,
@@ -113,13 +114,8 @@ export type AccountLockoutOptions = {
113
114
  readonly lockoutDurationMinutes?: number;
114
115
  };
115
116
 
116
- // Magic-Link Self-Signup. Wenn gesetzt, registriert das Feature die
117
- // signup-request + signup-confirm-Handler. Der Token-Store (Redis)
118
- // kommt aus ctx.redis — tokenTtlMinutes ist der einzige Knopf
119
- // (Token-Material ist generateToken() = 256 Bit randomBytes, fest;
120
- // Memory feedback_no_options_without_need: keine Knöpfe ohne Bedarf).
121
- // Anders als reset/verify gibt's kein hmacSecret hier, weil der Token
122
- // opaque random ist (Redis ist Source of Truth).
117
+ // Unlike reset/verify there is no hmacSecret: the signup token is opaque random
118
+ // (256 bit) and Redis is the source of truth, so tokenTtlMinutes is the only knob.
123
119
  export type SignupOptions = SignupRequestOptions;
124
120
 
125
121
  // Tenant-Invite Magic-Link. Wenn gesetzt, registriert das Feature die
@@ -207,6 +203,7 @@ export function createAuthEmailPasswordFeature(
207
203
  // of silently dropping the mail.
208
204
  if (sendsMagicLinkMail(opts)) r.requires("delivery");
209
205
  r.envSchema(authEmailPasswordEnvSchema);
206
+ r.translations({ keys: AUTH_EMAIL_PASSWORD_MAIL_I18N });
210
207
 
211
208
  // Declared unconditionally: apps also issue the activation link from their
212
209
  // own flows (issueSignupActivation) without enabling the signup option.
@@ -121,9 +121,8 @@ export function createInviteAcceptWithLoginHandler(opts: InviteAcceptWithLoginOp
121
121
  escapeHatch: {
122
122
  grants: ["unsafeRaw"],
123
123
  reason:
124
- "Anonymous invite-accept has no session in the invited tenant yet — checks existing " +
125
- "membership via ctx.queryAs(SYSTEM, tenant:query:memberships) of the invitation's tenant. " +
126
- "Also reads the pending invitation by id (the invitee is not yet a member of the " +
124
+ "Anonymous invite-accept has no session in the invited tenant yet — reads the pending " +
125
+ "invitation by id (the invitee is not yet a member of the " +
127
126
  "invitation's tenant), adds the membership and accepts the invitation in the " +
128
127
  "invitation's tenant, which differs from the caller's tenant, and reads the MFA " +
129
128
  "enrollment of that tenant via the mfaStatusChecker callback.",
@@ -32,6 +32,7 @@ import {
32
32
  type WriteFailure,
33
33
  writeFailure,
34
34
  } from "@cosmicdrift/kumiko-framework/errors";
35
+ import { isValidLocaleTag } from "@cosmicdrift/kumiko-framework/i18n";
35
36
  import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
36
37
  import * as z from "zod";
37
38
  // kumiko-lint-ignore cross-feature-import invite-flow lebt in auth-email-password (Magic-Link-Pattern), DB-row-owner ist tenant-feature
@@ -50,7 +51,7 @@ import {
50
51
  unassignableMembershipRoleError,
51
52
  } from "../../tenant/membership-roles.js";
52
53
  // kumiko-lint-ignore cross-feature-import global-role options are owned by the user feature
53
- import { GLOBAL_ROLE_OPTIONS } from "../../user/constants.js";
54
+ import { GLOBAL_ROLE_OPTIONS, type GlobalRole } from "../../user/constants.js";
54
55
  // kumiko-lint-ignore cross-feature-import invitee lookup by email reads the user row
55
56
  import { userTable } from "../../user/schema/user.js";
56
57
  import { AUTH_INVITE_DEFAULT_TTL_MINUTES } from "../constants.js";
@@ -60,8 +61,6 @@ import { invalidateExistingInviteToken, storeInviteToken } from "../invite-token
60
61
  import { dispatchMagicLinkMail, resolveHandlerMailLocale } from "../magic-link-mail.js";
61
62
  import { AuthMailNotificationTypes } from "../mail-notifications.js";
62
63
 
63
- type GlobalRole = (typeof GLOBAL_ROLE_OPTIONS)[number];
64
-
65
64
  const InviteCreateSchema = z.object({
66
65
  email: z.email(),
67
66
  role: z.string().min(1).max(50),
@@ -116,6 +115,7 @@ type InviteIssueRequest = {
116
115
  readonly email: string;
117
116
  readonly role: string;
118
117
  readonly globalRoles: readonly GlobalRole[];
118
+ readonly locale?: string | undefined;
119
119
  };
120
120
 
121
121
  function checkAssignableInviteRole(
@@ -239,7 +239,7 @@ async function issueInvitation(
239
239
  const token = generateToken();
240
240
  await storeInviteToken(ctx.redis, { invitationId, token, ttlSeconds });
241
241
 
242
- const locale = resolveHandlerMailLocale(ctx, opts.locale);
242
+ const locale = request.locale ?? resolveHandlerMailLocale(ctx, opts.locale);
243
243
 
244
244
  await dispatchMagicLinkMail(
245
245
  ctx.notify,
@@ -317,6 +317,8 @@ const SystemInviteCreateSchema = z.object({
317
317
  email: z.email(),
318
318
  role: z.string().min(1).max(50),
319
319
  globalRoles: z.array(z.enum(GLOBAL_ROLE_OPTIONS)).max(GLOBAL_ROLE_OPTIONS.length).default([]),
320
+ // Preferred over the request locale: provisioning callers know the invitee's language.
321
+ locale: z.string().max(35).refine(isValidLocaleTag, "invalid locale tag").optional(),
320
322
  });
321
323
 
322
324
  // System-only sibling of invite-create: the only path that can put global
@@ -21,7 +21,7 @@ const SwitchTenantMfaGateSchema = z.object({
21
21
  });
22
22
 
23
23
  export type SwitchTenantMfaGateData =
24
- | { readonly kind: "mfa-gate-clear" }
24
+ | { readonly kind: "mfa-gate-clear"; readonly roles: readonly string[] }
25
25
  | NonNullable<Awaited<ReturnType<typeof gateEnforceMfa>>>;
26
26
 
27
27
  // Dispatched by POST /auth/switch-tenant with the system identity. The handler
@@ -54,7 +54,7 @@ export function createSwitchTenantMfaGateHandler(
54
54
  );
55
55
  const roles = buildSessionRoles(parseRoles(userRow?.roles ?? null), active.membership.roles);
56
56
  const mfaGate = await gateEnforceMfa(ctx, opts, userId, tenantId, roles);
57
- return { isSuccess: true, data: mfaGate ?? { kind: "mfa-gate-clear" } };
57
+ return { isSuccess: true, data: mfaGate ?? { kind: "mfa-gate-clear", roles } };
58
58
  },
59
59
  });
60
60
  }
@@ -77,24 +77,28 @@ export async function dispatchMagicLinkMail(
77
77
  // mailT already root-falls-back; appUrl must use the same negotiated tag
78
78
  // or language-in-path apps 404 on e.g. /de-AT/activate.
79
79
  const urlLocale = resolveMailLocale(locale);
80
+ // Template, expiry formatting and app-name fallback must share one locale,
81
+ // otherwise an English template shows e.g. French "3 heures".
82
+ const templateLocale = authMailTemplateLocale(spec.notificationType, locale);
80
83
  const { validFor, validUntil } = tokenMailExpiry(
81
84
  {
82
85
  expiresAt: params.expiresAt,
83
86
  ...(params.issuedAt !== undefined && { issuedAt: params.issuedAt }),
84
87
  ...(params.timeZone !== undefined && { timeZone: params.timeZone }),
85
88
  },
86
- locale,
89
+ templateLocale,
87
90
  );
88
91
  await notify(spec.notificationType, {
89
92
  route: { email: params.email },
90
93
  data: {
91
- appName: params.appName ?? spec.fallbackAppName ?? mailT(locale, "auth.mail.appNameDefault"),
94
+ appName:
95
+ params.appName ?? spec.fallbackAppName ?? mailT(templateLocale, "auth.mail.appNameDefault"),
92
96
  actionUrl: appendToken(resolveAppUrl(params.appUrl, urlLocale), params.token),
93
97
  validFor,
94
98
  validUntil,
95
99
  ...(params.role !== undefined && { role: params.role }),
96
100
  },
97
- locale: authMailTemplateLocale(spec.notificationType, locale),
101
+ locale: templateLocale,
98
102
  priority: "critical",
99
103
  });
100
104
  }
@@ -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: Readonly<Record<string, { readonly en: string }>> = {
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
+ };
@@ -1,4 +1,16 @@
1
1
  [
2
+ {
3
+ "version": "0.365.0",
4
+ "type": "fix",
5
+ "title": "Termination record re-reads the stream after the provider call",
6
+ "detail": "The cancel-at-provider call can take long, so the termination event is appended with a freshly fetched stream version instead of the one read before it."
7
+ },
8
+ {
9
+ "version": "0.365.0",
10
+ "type": "improvement",
11
+ "title": "Contract mails carry designer labels",
12
+ "detail": "Feature and notify labels for contract-confirmation, contract-termination-receipt and contract-termination-operator-notice name the contract mails in the document designer."
13
+ },
2
14
  {
3
15
  "version": "0.364.0",
4
16
  "type": "fix",
@@ -238,7 +238,14 @@ export function createRecordContractTerminationHandler(
238
238
  effectiveAtIso,
239
239
  providerCancel,
240
240
  };
241
- await stream.appendOne({
241
+ // Re-fetch after the external call: a webhook appended meanwhile would
242
+ // otherwise fail the append on a stale expectedVersion although the
243
+ // provider already cancelled.
244
+ const streamAfterProviderCall = await ctx.fetchForWriting({
245
+ aggregateId: subscriptionAggregateId(tenantId),
246
+ aggregateType: SUBSCRIPTION_AGGREGATE_TYPE,
247
+ });
248
+ await streamAfterProviderCall.appendOne({
242
249
  type: CONTRACT_TERMINATION_REQUESTED_EVENT_QN,
243
250
  payload: recorded,
244
251
  });
@@ -4,6 +4,12 @@
4
4
  type LocalizedString = { readonly en: string };
5
5
 
6
6
  export const BILLING_FOUNDATION_I18N: Readonly<Record<string, LocalizedString>> = {
7
+ "billing-foundation:feature.label": { en: "Billing" },
8
+ "billing-foundation:notify.contract-confirmation.label": { en: "Contract confirmation" },
9
+ "billing-foundation:notify.contract-termination-receipt.label": { en: "Termination receipt" },
10
+ "billing-foundation:notify.contract-termination-operator-notice.label": {
11
+ en: "Termination notice to operator",
12
+ },
7
13
  "screen:billing-plans.title": { en: "Billing" },
8
14
  "billing-foundation.plans.billingDisabled": {
9
15
  en: "Billing is not active yet. Your current plan stays in place.",
@@ -4,6 +4,8 @@
4
4
  // so it is now a factory parameter.
5
5
 
6
6
  import {
7
+ isTierAssignmentSource,
8
+ type TierAssignmentSource,
7
9
  TierAssignmentSources,
8
10
  TierEngineHandlers,
9
11
  TierEngineQueries,
@@ -67,7 +69,7 @@ type ExistingTierAssignment = {
67
69
  readonly id: string;
68
70
  readonly version: number;
69
71
  readonly tier: unknown;
70
- readonly source: unknown;
72
+ readonly source: TierAssignmentSource | null;
71
73
  };
72
74
 
73
75
  function findTierAssignment(
@@ -78,7 +80,13 @@ function findTierAssignment(
78
80
  if (!row || typeof row["id"] !== "string" || typeof row["version"] !== "number") {
79
81
  return undefined;
80
82
  }
81
- return { id: row["id"], version: row["version"], tier: row["tier"], source: row["source"] };
83
+ const source = row["source"];
84
+ return {
85
+ id: row["id"],
86
+ version: row["version"],
87
+ tier: row["tier"],
88
+ source: isTierAssignmentSource(source) ? source : null,
89
+ };
82
90
  }
83
91
 
84
92
  export function createSubscriptionTierSync<TTier extends string>(
@@ -280,6 +280,12 @@ export type ReadRollingCapUsageOptions = {
280
280
  readonly windowDays: number;
281
281
  };
282
282
 
283
+ // A release is written up to a reservation TTL after its increment; once the increment left the
284
+ // window the release must not subtract from other increments still inside it.
285
+ function isBefore(isoInstant: string, cutoff: Temporal.Instant): boolean {
286
+ return Temporal.Instant.compare(Temporal.Instant.from(isoInstant), cutoff) < 0;
287
+ }
288
+
283
289
  async function sumRollingEventAmounts(
284
290
  db: TenantDb,
285
291
  tenantId: TenantId,
@@ -289,20 +295,26 @@ async function sumRollingEventAmounts(
289
295
  const aggregateId = rollingCapAggregateId(tenantId, options.capName);
290
296
  const cutoff = Temporal.Now.instant().subtract({ hours: options.windowDays * 24 });
291
297
 
292
- const rows = await db.selectMany<{ payload: { amount?: number } }>(eventsTable, {
293
- tenantId,
294
- aggregateType: CAP_COUNTER_ROLLING_AGGREGATE_TYPE,
295
- aggregateId,
296
- type,
297
- createdAt: { gte: cutoff },
298
- });
298
+ const rows = await db.selectMany<{ payload: { amount?: number; incrementedAt?: string } }>(
299
+ eventsTable,
300
+ {
301
+ tenantId,
302
+ aggregateType: CAP_COUNTER_ROLLING_AGGREGATE_TYPE,
303
+ aggregateId,
304
+ type,
305
+ createdAt: { gte: cutoff },
306
+ },
307
+ );
299
308
 
300
309
  let total = 0;
301
310
  for (const row of rows) {
302
311
  // @cast-boundary engine-payload — events.payload is jsonb (typed as
303
312
  // unknown by drizzle's $type<Record<string,unknown>>); narrowing the
304
313
  // shape here mirrors enforceRollingCap's own read-side contract.
305
- const payload = row["payload"] as { amount?: number };
314
+ const payload = row["payload"] as { amount?: number; incrementedAt?: string };
315
+ if (typeof payload.incrementedAt === "string" && isBefore(payload.incrementedAt, cutoff)) {
316
+ continue;
317
+ }
306
318
  if (typeof payload.amount === "number") {
307
319
  total += payload.amount;
308
320
  }
@@ -37,6 +37,7 @@ type CapReservationRow = {
37
37
  readonly periodStartIso: string | null;
38
38
  readonly amount: number;
39
39
  readonly expiresAt: Temporal.Instant;
40
+ readonly createdAt: Temporal.Instant;
40
41
  };
41
42
 
42
43
  const NOTHING_TO_RELEASE: WriteResult = { isSuccess: true, data: {} };
@@ -136,13 +137,14 @@ function rollingEvent(
136
137
  amount: number,
137
138
  expectedVersion: number,
138
139
  type: string,
140
+ extraPayload: { readonly incrementedAt?: string } = {},
139
141
  ) {
140
142
  return {
141
143
  aggregateId: rollingCapAggregateId(user.tenantId, capName),
142
144
  aggregateType: CAP_COUNTER_ROLLING_AGGREGATE_TYPE,
143
145
  expectedVersion,
144
146
  type,
145
- payload: { capName, amount },
147
+ payload: { capName, amount, ...extraPayload },
146
148
  metadata: { userId: user.id },
147
149
  };
148
150
  }
@@ -213,7 +215,10 @@ async function giveBackRolling(
213
215
  );
214
216
  await appendEventInTenantDb(
215
217
  txDb,
216
- rollingEvent(user, row.capName, row.amount, version, ROLLING_RELEASED_EVENT_QN),
218
+ // The window filter must use the increment's time: a sweep releases up to a TTL later.
219
+ rollingEvent(user, row.capName, row.amount, version, ROLLING_RELEASED_EVENT_QN, {
220
+ incrementedAt: row.createdAt.toString(),
221
+ }),
217
222
  );
218
223
  return NOTHING_TO_RELEASE;
219
224
  }
@@ -1,4 +1,17 @@
1
1
  [
2
+ {
3
+ "version": "0.365.0",
4
+ "type": "breaking",
5
+ "title": "Handlers wrapped with withCapEnforcement or withRollingCapEnforcement are rejected when called through a nested ctx.write",
6
+ "detail": "Since the wrappers reserve through reserveBeforeTransaction, the dispatcher rejects a nested ctx.write, ctx.writeAs or handler-to-handler call of such a handler with an internal error (\"has a pre-transaction phase ... cannot run as a nested write\") instead of running it. Before the reservation lifecycle change the wrapped handler ran normally when called nested.",
7
+ "migration": "Dispatch rolling- and calendar-capped handlers only as top-level commands (HTTP write or batch). A handler that calls one through ctx.write declares the cap on itself instead, or the call moves out of the handler into a client or job that dispatches a top-level command."
8
+ },
9
+ {
10
+ "version": "0.365.0",
11
+ "type": "fix",
12
+ "title": "Rolling cap release remembers the original booking time",
13
+ "detail": "rolling-released events now carry the incrementedAt of the booking they return, so readRollingCapUsage no longer subtracts a release whose increment already left the rolling window."
14
+ },
2
15
  {
3
16
  "version": "0.364.0",
4
17
  "type": "breaking",
@@ -40,6 +40,8 @@ export const rollingIncrementedSchema = incrementRollingSchema;
40
40
  /** Payload of the event that gives a reserved rolling amount back. */
41
41
  export const rollingReleasedSchema = incrementRollingSchema.extend({
42
42
  amount: z.number().int().positive(),
43
+ /** When the released increment was booked; absent on events written before it was recorded. */
44
+ incrementedAt: z.string().optional(),
43
45
  });
44
46
 
45
47
  // Rolling-Increment-Handler — append-only. Race-frei: zwei parallele
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.365.0",
4
+ "type": "fix",
5
+ "title": "Cap overview lists no destroyed tenants",
6
+ "detail": "tenant-options and tenant-caps-list now filter destroyed tenants and keep reading tenant names through the PII-aware path."
7
+ },
2
8
  {
3
9
  "version": "0.338.0",
4
10
  "type": "breaking",
@@ -176,7 +176,8 @@ export function createTenantCapsListQuery(caps: readonly CapSpec[], listCaps: re
176
176
  );
177
177
 
178
178
  const tenants = await decryptTenantNames(
179
- await selectMany<TenantRow>(db, tenantTable, {}),
179
+ // Destroyed tenants decrypt to the erased sentinel, which a picker or list would show verbatim.
180
+ await selectMany<TenantRow>(db, tenantTable, { destroyedAt: null }),
180
181
  "cap-overview:tenant-caps:list",
181
182
  );
182
183
  const assignments = await selectMany<TierAssignmentRow>(db, tierAssignmentTable, {});
@@ -23,7 +23,8 @@ export const tenantOptionsQuery = defineQueryHandler({
23
23
  "cap-overview:tenant-options — SystemAdmin dashboard tenant-filter options",
24
24
  );
25
25
  const tenants = await decryptTenantNames(
26
- await selectMany<TenantRow>(db, tenantTable, {}),
26
+ // Destroyed tenants decrypt to the erased sentinel, which the picker would show verbatim.
27
+ await selectMany<TenantRow>(db, tenantTable, { destroyedAt: null }),
27
28
  "cap-overview:tenant-options",
28
29
  );
29
30
  const sorted = [...tenants].sort((a, b) => a.name.localeCompare(b.name));
@@ -8,9 +8,11 @@ import {
8
8
 
9
9
  export type DiscordChannelOptions = ChatWebhookChannelOptions;
10
10
 
11
+ const DISCORD_WEBHOOK_PREFIX = "channel-discord:webhooks.";
12
+
11
13
  export const DISCORD_SECRET_KEYS = {
12
- webhookPrefix: "channel-discord:webhooks.",
13
- webhookKeyFor: (connection: string) => `channel-discord:webhooks.${connection}`,
14
+ webhookPrefix: DISCORD_WEBHOOK_PREFIX,
15
+ webhookKeyFor: (connection: string) => `${DISCORD_WEBHOOK_PREFIX}${connection}`,
14
16
  } as const;
15
17
 
16
18
  export const DISCORD_DEFAULT_ALLOWED_HOSTS = ["discord.com", "discordapp.com"] as const;