@cosmicdrift/kumiko-samples 0.353.0 → 0.354.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 (137) hide show
  1. package/package.json +1 -1
  2. package/packages/bundled-features/package.json +18 -2
  3. package/packages/bundled-features/src/agent-tools/agent-doc-lint.ts +66 -0
  4. package/packages/bundled-features/src/agent-tools/agent-manifest.ts +11 -5
  5. package/packages/bundled-features/src/agent-tools/feature.ts +13 -2
  6. package/packages/bundled-features/src/agent-tools/index.ts +6 -1
  7. package/packages/bundled-features/src/agent-tools/tool-dispatch.ts +1 -1
  8. package/packages/bundled-features/src/auth-email-password/changes.json +24 -0
  9. package/packages/bundled-features/src/auth-email-password/handlers/change-password.write.ts +1 -0
  10. package/packages/bundled-features/src/auth-email-password/handlers/invite-accept-with-login.write.ts +3 -2
  11. package/packages/bundled-features/src/auth-email-password/handlers/invite-accept.write.ts +3 -2
  12. package/packages/bundled-features/src/auth-email-password/handlers/invite-create.write.ts +21 -0
  13. package/packages/bundled-features/src/auth-email-password/handlers/invite-signup-complete.write.ts +4 -1
  14. package/packages/bundled-features/src/auth-email-password/handlers/login.write.ts +1 -0
  15. package/packages/bundled-features/src/auth-email-password/handlers/reset-password.write.ts +1 -0
  16. package/packages/bundled-features/src/auth-email-password/handlers/signup-confirm.write.ts +5 -0
  17. package/packages/bundled-features/src/auth-email-password/handlers/signup-request.write.ts +53 -96
  18. package/packages/bundled-features/src/auth-email-password/handlers/token-request-handler.ts +1 -0
  19. package/packages/bundled-features/src/auth-email-password/handlers/verify-email.write.ts +1 -0
  20. package/packages/bundled-features/src/auth-email-password/index.ts +12 -0
  21. package/packages/bundled-features/src/auth-email-password/seeding.ts +38 -7
  22. package/packages/bundled-features/src/auth-email-password/signup-activation.ts +83 -0
  23. package/packages/bundled-features/src/auth-email-password/signup-token-store.ts +9 -3
  24. package/packages/bundled-features/src/auth-email-password/web/auth-gate.tsx +7 -7
  25. package/packages/bundled-features/src/auth-email-password/web/auth-redirect.ts +15 -0
  26. package/packages/bundled-features/src/auth-email-password/web/login-screen.tsx +2 -2
  27. package/packages/bundled-features/src/auth-email-password/web/user-menu.tsx +1 -1
  28. package/packages/bundled-features/src/auth-mfa/changes.json +7 -0
  29. package/packages/bundled-features/src/auth-mfa/feature.ts +5 -0
  30. package/packages/bundled-features/src/auth-mfa/handlers/enable-confirm-preauth.write.ts +1 -0
  31. package/packages/bundled-features/src/auth-mfa/handlers/verify.write.ts +1 -0
  32. package/packages/bundled-features/src/auth-mfa/web/mfa-recovery-codes-reveal.tsx +6 -8
  33. package/packages/bundled-features/src/auth-mfa/web/mfa-setup-preauth-screen.tsx +18 -21
  34. package/packages/bundled-features/src/auth-mfa/web/mfa-verify-screen.tsx +2 -2
  35. package/packages/bundled-features/src/billing-foundation/billing-info-query.ts +4 -4
  36. package/packages/bundled-features/src/billing-foundation/changes.json +6 -0
  37. package/packages/bundled-features/src/channel-in-app/changes.json +8 -1
  38. package/packages/bundled-features/src/channel-in-app/in-app-channel.ts +1 -1
  39. package/packages/bundled-features/src/channel-in-app/tables.ts +18 -12
  40. package/packages/bundled-features/src/crypto-shredding/changes.json +12 -0
  41. package/packages/bundled-features/src/crypto-shredding/handlers/forget-subject.write.ts +154 -69
  42. package/packages/bundled-features/src/delivery/changes.json +6 -0
  43. package/packages/bundled-features/src/delivery/db/queries/address-opt-outs.ts +18 -12
  44. package/packages/bundled-features/src/delivery/delivery-service.ts +103 -16
  45. package/packages/bundled-features/src/delivery/unsubscribe.ts +81 -13
  46. package/packages/bundled-features/src/feature-toggles/feature.ts +2 -3
  47. package/packages/bundled-features/src/feature-toggles/toggle-runtime.ts +34 -5
  48. package/packages/bundled-features/src/feature-toggles/toggle-sync-signal.ts +31 -8
  49. package/packages/bundled-features/src/file-derivatives/handlers/public-variant.query.ts +10 -1
  50. package/packages/bundled-features/src/files/changes.json +24 -0
  51. package/packages/bundled-features/src/files-tenant-data/changes.json +5 -0
  52. package/packages/bundled-features/src/files-tenant-data/handlers/sweep-orphaned-derivatives.job.ts +4 -0
  53. package/packages/bundled-features/src/files-tenant-data/hooks.ts +72 -23
  54. package/packages/bundled-features/src/folders/changes.json +6 -0
  55. package/packages/bundled-features/src/folders/handlers/delete-folder.write.ts +41 -9
  56. package/packages/bundled-features/src/foundation-shared/index.ts +1 -0
  57. package/packages/bundled-features/src/foundation-shared/mail-host-policy.ts +16 -4
  58. package/packages/bundled-features/src/inbound-provider-imap/changes.json +6 -0
  59. package/packages/bundled-features/src/inbound-provider-imap/feature.ts +2 -0
  60. package/packages/bundled-features/src/inbound-provider-imap/imap-client.ts +3 -4
  61. package/packages/bundled-features/src/jobs/changes.json +30 -0
  62. package/packages/bundled-features/src/jobs/constants.ts +1 -0
  63. package/packages/bundled-features/src/jobs/db/queries/retention.ts +12 -0
  64. package/packages/bundled-features/src/jobs/db/queries/stale-run-sweep.ts +26 -2
  65. package/packages/bundled-features/src/jobs/feature.ts +7 -1
  66. package/packages/bundled-features/src/jobs/handlers/list.query.ts +1 -1
  67. package/packages/bundled-features/src/jobs/handlers/tenant-failures.query.ts +1 -1
  68. package/packages/bundled-features/src/jobs/handlers/tenant-runs.query.ts +94 -0
  69. package/packages/bundled-features/src/jobs/index.ts +2 -0
  70. package/packages/bundled-features/src/jobs/job-run-logger.ts +177 -10
  71. package/packages/bundled-features/src/jobs/tenant-job-failure-table.ts +27 -8
  72. package/packages/bundled-features/src/jobs/tenant-job-run-table.ts +55 -0
  73. package/packages/bundled-features/src/ledger/changes.json +23 -0
  74. package/packages/bundled-features/src/ledger/entity.ts +13 -5
  75. package/packages/bundled-features/src/ledger/feature.ts +3 -0
  76. package/packages/bundled-features/src/ledger/handlers/confirm-schedule-period.write.ts +15 -3
  77. package/packages/bundled-features/src/ledger/handlers/create-transaction.write.ts +1 -0
  78. package/packages/bundled-features/src/ledger/handlers/reverse-transaction.write.ts +1 -0
  79. package/packages/bundled-features/src/ledger/schedule-subject-pair.ts +27 -0
  80. package/packages/bundled-features/src/mail-transport-smtp/feature.ts +4 -15
  81. package/packages/bundled-features/src/notes-history/changes.json +13 -0
  82. package/packages/bundled-features/src/notes-history/entity.ts +4 -0
  83. package/packages/bundled-features/src/personal-access-tokens/handlers/create.write.ts +1 -0
  84. package/packages/bundled-features/src/sessions/feature.ts +21 -8
  85. package/packages/bundled-features/src/sessions/handlers/detail.query.ts +34 -33
  86. package/packages/bundled-features/src/sessions/handlers/list.query.ts +68 -65
  87. package/packages/bundled-features/src/sessions/screens.ts +10 -8
  88. package/packages/bundled-features/src/shared/encrypt-for-direct-write.ts +15 -8
  89. package/packages/bundled-features/src/shared/index.ts +2 -1
  90. package/packages/bundled-features/src/shared/parent-visibility.ts +31 -1
  91. package/packages/bundled-features/src/shared/row-bound-grant.ts +18 -2
  92. package/packages/bundled-features/src/shared/session-locale-field.ts +7 -0
  93. package/packages/bundled-features/src/template-resolver/api.ts +3 -3
  94. package/packages/bundled-features/src/template-resolver/changes.json +5 -0
  95. package/packages/bundled-features/src/template-resolver/handlers/list.query.ts +2 -2
  96. package/packages/bundled-features/src/tenant/changes.json +12 -0
  97. package/packages/bundled-features/src/tenant/feature.ts +8 -1
  98. package/packages/bundled-features/src/tenant/handlers/toggle-enabled.write.ts +2 -0
  99. package/packages/bundled-features/src/tenant/handlers/update-member-roles.write.ts +1 -0
  100. package/packages/bundled-features/src/tenant/invitation-table.ts +6 -0
  101. package/packages/bundled-features/src/tenant/invited-membership.ts +45 -12
  102. package/packages/bundled-features/src/tenant-handover/changes.json +6 -0
  103. package/packages/bundled-features/src/tenant-handover/transfer-graph.ts +14 -96
  104. package/packages/bundled-features/src/tenant-lifecycle/boot-checks.ts +16 -0
  105. package/packages/bundled-features/src/tenant-lifecycle/changes.json +22 -0
  106. package/packages/bundled-features/src/tenant-lifecycle/constants.ts +8 -0
  107. package/packages/bundled-features/src/tenant-lifecycle/events.ts +6 -0
  108. package/packages/bundled-features/src/tenant-lifecycle/feature.ts +13 -1
  109. package/packages/bundled-features/src/tenant-lifecycle/run-tenant-destroy.ts +100 -29
  110. package/packages/bundled-features/src/tenant-lifecycle/stages.ts +41 -9
  111. package/packages/bundled-features/src/tenant-lifecycle/testing.ts +58 -0
  112. package/packages/bundled-features/src/tier-engine/changes.json +5 -0
  113. package/packages/bundled-features/src/tier-engine/handlers/set-tenant-tier.write.ts +1 -0
  114. package/packages/bundled-features/src/user/changes.json +15 -0
  115. package/packages/bundled-features/src/user/handlers/detail.query.ts +1 -0
  116. package/packages/bundled-features/src/user/handlers/list.query.ts +3 -0
  117. package/packages/bundled-features/src/user/handlers/update.write.ts +1 -0
  118. package/packages/bundled-features/src/user/seeding.ts +34 -0
  119. package/packages/bundled-features/src/user-data-rights/changes.json +6 -0
  120. package/packages/bundled-features/src/user-data-rights/handlers/request-deletion.write.ts +1 -0
  121. package/packages/bundled-features/src/user-data-rights/index.ts +1 -0
  122. package/packages/bundled-features/src/user-data-rights/schema/download-attempt.ts +17 -9
  123. package/packages/bundled-features/src/user-profile/handlers/change-email.write.ts +8 -2
  124. package/packages/bundled-features/src/workflow-runner/event-trigger.ts +72 -14
  125. package/packages/bundled-features/src/workflow-runner/handlers/resume-run.write.ts +6 -1
  126. package/packages/bundled-features/src/workflow-runner/runner.ts +8 -2
  127. package/samples/apps/showcase/src/features/items/feature.ts +2 -1
  128. package/samples/apps/styleguide/src/features/widgets/feature.ts +83 -0
  129. package/samples/apps/styleguide/src/features/widgets/i18n.ts +12 -0
  130. package/samples/apps/styleguide/src/features/widgets/web/Widgets.tsx +4 -0
  131. package/samples/apps/ui-walkthrough/package.json +3 -0
  132. package/samples/apps/use-all-bundled/README.md +1 -2
  133. package/samples/apps/use-all-bundled/feature-manifest.json +1 -1
  134. package/samples/apps/user-data-rights-demo/README.md +1 -3
  135. package/samples/recipes/legal-pages/README.md +3 -4
  136. package/samples/recipes/managed-pages/README.md +2 -2
  137. package/samples/recipes/seed-migration/README.md +0 -2
@@ -55,6 +55,7 @@ import {
55
55
  type StorageProviderHookCtx,
56
56
  type TenantDataDestroyHook,
57
57
  type TenantDataHookCtx,
58
+ type TenantDestroyHookResult,
58
59
  type TenantId,
59
60
  } from "@cosmicdrift/kumiko-framework/engine";
60
61
  import {
@@ -64,6 +65,7 @@ import {
64
65
  storageKeyStemPrefix,
65
66
  tenantStoragePrefixes,
66
67
  } from "@cosmicdrift/kumiko-framework/files";
68
+ import { type ChunkedMigrationResult, runChunkedMigration } from "../shared/index.js";
67
69
 
68
70
  const crud = createEventStoreExecutor(fileRefsTable, fileRefEntity, { entityName: "fileRef" });
69
71
 
@@ -72,6 +74,8 @@ const crud = createEventStoreExecutor(fileRefsTable, fileRefEntity, { entityName
72
74
  // or a single unbounded array for a tenant with a huge prefix.
73
75
  const SURVIVOR_CHECK_BATCH_SIZE = 500;
74
76
 
77
+ const DESTROY_CHUNK_SIZE = 500;
78
+
75
79
  function chunk<T>(items: readonly T[], size: number): T[][] {
76
80
  const chunks: T[][] = [];
77
81
  for (let i = 0; i < items.length; i += size) chunks.push(items.slice(i, i + size));
@@ -82,27 +86,54 @@ function isUnderOwnPrefix(storageKey: string, prefixes: readonly string[]): bool
82
86
  return prefixes.some((prefix) => storageKey.startsWith(prefix));
83
87
  }
84
88
 
89
+ function toDestroyHookResult(
90
+ result: ChunkedMigrationResult,
91
+ failureMessage: () => string | undefined,
92
+ ): TenantDestroyHookResult {
93
+ // A failure on the last item ends the run as "done" (nothing left to batch),
94
+ // so the failed count, not the stop reason, decides fail-closed.
95
+ if (result.failed > 0) {
96
+ throw new Error(failureMessage() ?? "files-tenant-data: destroy chunk failed");
97
+ }
98
+ return { done: result.stoppedReason === "done", processed: result.migrated };
99
+ }
100
+
85
101
  export const fileRefTenantDestroyHook: TenantDataDestroyHook = async (ctx) => {
86
- const rows = await ctx.db.selectMany<{ id: string; storageKey: string }>(fileRefsTable, {
87
- tenantId: ctx.tenantId,
88
- });
89
102
  const ownPrefixes = tenantStoragePrefixes(ctx.tenantId);
90
103
  const user = createSystemUser(ctx.tenantId);
91
- for (const row of rows) {
92
- if (!isUnderOwnPrefix(row.storageKey, ownPrefixes)) {
93
- await deleteHandedOverBinary(ctx, row.storageKey);
94
- }
95
- const result = await crud.forget({ id: row.id }, user, ctx.db);
96
- // Executor writes return {isSuccess:false} instead of throwing — a
97
- // discarded result would report this destroy stage "succeeded" while the
98
- // row (and its PII fileName) survives. Throw so the pipeline's
99
- // retry/abandon handling sees it.
100
- if (!result.isSuccess) {
101
- throw new Error(
102
- `files-tenant-data: failed to forget fileRef ${row.id} for tenant ${ctx.tenantId}: ${result.error.message}`,
103
- );
104
- }
105
- }
104
+ let firstFailure: string | undefined;
105
+ // Converges without a cursor: forget() hard-deletes, so each batch query
106
+ // returns the next rows.
107
+ const result = await runChunkedMigration<{ id: string; storageKey: string }>({
108
+ nextBatch: () =>
109
+ ctx.db.selectMany<{ id: string; storageKey: string }>(
110
+ fileRefsTable,
111
+ { tenantId: ctx.tenantId },
112
+ { limit: DESTROY_CHUNK_SIZE },
113
+ ),
114
+ migrateRow: async (row) => {
115
+ if (!isUnderOwnPrefix(row.storageKey, ownPrefixes)) {
116
+ await deleteHandedOverBinary(ctx, row.storageKey);
117
+ }
118
+ const forgotten = await crud.forget({ id: row.id }, user, ctx.db);
119
+ // Executor writes return {isSuccess:false} instead of throwing — a
120
+ // discarded result would report this destroy stage "succeeded" while the
121
+ // row (and its PII fileName) survives. Throw so the pipeline's
122
+ // retry/abandon handling sees it.
123
+ if (!forgotten.isSuccess) {
124
+ throw new Error(
125
+ `files-tenant-data: failed to forget fileRef ${row.id} for tenant ${ctx.tenantId}: ${forgotten.error.message}`,
126
+ );
127
+ }
128
+ return "migrated";
129
+ },
130
+ maxFailures: 1,
131
+ deadlineAt: ctx.deadlineAt ?? Number.POSITIVE_INFINITY,
132
+ onRowError: (_row, err) => {
133
+ firstFailure = err instanceof Error ? err.message : String(err);
134
+ },
135
+ });
136
+ return toDestroyHookResult(result, () => firstFailure);
106
137
  };
107
138
 
108
139
  // The bytes live under the tenant that uploaded them (`<tenantId>/…` or
@@ -173,15 +204,33 @@ export const fileRefStorageDestroyHook: StorageProviderDestroyTenantHook = async
173
204
  // it and retries up to the stage's attempt cap before abandoning the destroy
174
205
  // (list+delete are idempotent, so a retry converges rather than
175
206
  // double-deleting or erroring on a missing key).
207
+ let processed = 0;
176
208
  for (const prefix of tenantStoragePrefixes(tenantId)) {
177
209
  const keys = await provider.list(prefix);
178
210
  const keptStemPrefixes = await survivorStemPrefixes(ctx.db, keys);
179
- for (const key of keys) {
180
- if (keptStemPrefixes.some((stemPrefix) => key.startsWith(stemPrefix))) continue;
181
- assertSafeStorageKey(key);
182
- await provider.delete(key);
183
- }
211
+ const deletableKeys = keys.filter(
212
+ (key) => !keptStemPrefixes.some((stemPrefix) => key.startsWith(stemPrefix)),
213
+ );
214
+ const pending = [...deletableKeys];
215
+ let firstFailure: string | undefined;
216
+ const result = await runChunkedMigration<string>({
217
+ nextBatch: async () => pending.splice(0, DESTROY_CHUNK_SIZE),
218
+ migrateRow: async (key) => {
219
+ assertSafeStorageKey(key);
220
+ await provider.delete(key);
221
+ return "migrated";
222
+ },
223
+ maxFailures: 1,
224
+ deadlineAt: ctx.deadlineAt ?? Number.POSITIVE_INFINITY,
225
+ onRowError: (_key, err) => {
226
+ firstFailure = err instanceof Error ? err.message : String(err);
227
+ },
228
+ });
229
+ const hookResult = toDestroyHookResult(result, () => firstFailure);
230
+ processed += result.migrated;
231
+ if (!hookResult.done) return { done: false, processed };
184
232
  }
233
+ return { done: true, processed };
185
234
  };
186
235
 
187
236
  // Any listed key that is itself a surviving row's exact storageKey means a
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.354.0",
4
+ "type": "fix",
5
+ "title": "folder:delete removes assignments whose host entity is gone instead of refusing forever",
6
+ "detail": "An assignment pointing at a hard-deleted, soft-deleted or unregistered host blocked folder:delete permanently with folder_has_assignments, because clear-folder needs a visible host and could not remove it. folder:delete now removes such assignments through the event-store executor (normal delete events) before the check, without the caller's read gate. Assignments of live hosts, including ones the caller cannot see, still block with folder_has_assignments. A refused delete removes nothing. The cleanup runs lazily at folder:delete, not in a host-delete hook, so restoring a soft-deleted host before then keeps its folder."
7
+ },
2
8
  {
3
9
  "version": "0.246.0",
4
10
  "type": "improvement",
@@ -1,6 +1,7 @@
1
1
  import type { AccessRule, WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
2
2
  import { UnprocessableError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
3
3
  import * as z from "zod";
4
+ import { hostRowIsGone } from "../../shared/index.js";
4
5
  import { DEFAULT_FOLDER_ACCESS } from "../constants.js";
5
6
  import { folderAssignmentExecutor, folderExecutor } from "../executor.js";
6
7
 
@@ -13,6 +14,12 @@ const deleteFolderPayloadSchema = z.object({ id: z.uuid() });
13
14
  // see 658/1). Block server-side instead: this is the same "referential
14
15
  // integrity has no FK here" reasoning set-folder.write.ts already applies to
15
16
  // folderId on assignment-creation.
17
+ //
18
+ // Assignments whose host is gone (hard-deleted, soft-deleted or unregistered)
19
+ // are cleaned up here instead of blocking forever: clear-folder can't reach
20
+ // them (it requires a visible host). A host-delete hook would break restore,
21
+ // so the cleanup runs lazily at folder:delete.
22
+ const ASSIGNMENT_PAGE_SIZE = 200;
16
23
  export function createDeleteFolderHandler(
17
24
  access: AccessRule = DEFAULT_FOLDER_ACCESS,
18
25
  ): WriteHandlerDef {
@@ -21,19 +28,44 @@ export function createDeleteFolderHandler(
21
28
  schema: deleteFolderPayloadSchema,
22
29
  access,
23
30
  description:
24
- "Deletes a folder from the tenant catalog and refuses while any entity is still filed in it; use it once that folder's contents have been unfiled or moved elsewhere.",
31
+ "Deletes a folder from the tenant catalog and refuses while any live entity is still filed in it (assignments of deleted hosts are cleaned up); use it once that folder's contents have been unfiled or moved elsewhere.",
25
32
  agent: { risk: "high" },
26
33
  handler: async (event, ctx) => {
27
34
  const payload = event.payload as { id: string }; // @cast-boundary engine-payload
28
- const assigned = await folderAssignmentExecutor.list(
29
- { filter: { field: "folderId", op: "eq", value: payload.id }, limit: 1 },
30
- event.user,
31
- ctx.db,
32
- );
33
- if (assigned.rows.length > 0) {
34
- return writeFailure(
35
- new UnprocessableError("folder_has_assignments", { details: { folderId: payload.id } }),
35
+ const orphanAssignmentIds: string[] = [];
36
+ let cursor: string | undefined;
37
+ do {
38
+ const page = await folderAssignmentExecutor.list(
39
+ {
40
+ filter: { field: "folderId", op: "eq", value: payload.id },
41
+ limit: ASSIGNMENT_PAGE_SIZE,
42
+ cursor,
43
+ },
44
+ event.user,
45
+ ctx.db,
36
46
  );
47
+ for (const row of page.rows) {
48
+ const gone = await hostRowIsGone(
49
+ ctx.registry,
50
+ String(row["entityType"]),
51
+ String(row["entityId"]),
52
+ ctx.db,
53
+ );
54
+ if (!gone) {
55
+ return writeFailure(
56
+ new UnprocessableError("folder_has_assignments", {
57
+ details: { folderId: payload.id },
58
+ }),
59
+ );
60
+ }
61
+ orphanAssignmentIds.push(String(row["id"]));
62
+ }
63
+ cursor = page.nextCursor ?? undefined;
64
+ } while (cursor !== undefined);
65
+
66
+ for (const id of orphanAssignmentIds) {
67
+ const removed = await folderAssignmentExecutor.delete({ id }, event.user, ctx.db);
68
+ if (!removed.isSuccess) return removed;
37
69
  }
38
70
  return folderExecutor.delete({ id: payload.id }, event.user, ctx.db);
39
71
  },
@@ -9,6 +9,7 @@ export {
9
9
  MAIL_ALLOWED_PRIVATE_HOSTS_ENV_VAR,
10
10
  type MailConnectTarget,
11
11
  type MailHostGuardOptions,
12
+ mailAllowedPrivateHostsEnvSchema,
12
13
  readAllowedPrivateMailHostsFromEnv,
13
14
  resolveMailConnectTarget,
14
15
  } from "./mail-host-policy.js";
@@ -10,18 +10,30 @@
10
10
  // internal relay or a dev/test server (mailpit, greenmail) — deliberately
11
11
  // an operator env var (KUMIKO_MAIL_ALLOWED_PRIVATE_HOSTS, read via
12
12
  // readAllowedPrivateMailHostsFromEnv), never a tenant-config key, so a
13
- // tenant can never grant themselves the bypass. Shared by both features
14
- // since a deploy commonly mounts both; declared once in mail-transport-
15
- // smtp's envSchema (see its feature.ts) to avoid the framework's env-var-
16
- // conflict check tripping when both features are mounted together.
13
+ // tenant can never grant themselves the bypass. Both features register the
14
+ // shared mailAllowedPrivateHostsEnvSchema below.
17
15
 
18
16
  import type { lookup } from "node:dns/promises";
19
17
  import { isIP } from "node:net";
20
18
  import { resolvePublicHostname } from "@cosmicdrift/kumiko-framework/http";
19
+ import * as z from "zod";
21
20
  import { isHostAllowlisted, readHostAllowlistFromEnv } from "./host-allowlist.js";
22
21
 
23
22
  export const MAIL_ALLOWED_PRIVATE_HOSTS_ENV_VAR = "KUMIKO_MAIL_ALLOWED_PRIVATE_HOSTS";
24
23
 
24
+ // One schema instance, registered by every feature that reads the variable:
25
+ // composeEnvSchema accepts the same field instance from several features, so
26
+ // an app mounting only one of them still gets the variable validated and
27
+ // documented.
28
+ export const mailAllowedPrivateHostsEnvSchema = z.object({
29
+ [MAIL_ALLOWED_PRIVATE_HOSTS_ENV_VAR]: z
30
+ .string()
31
+ .optional()
32
+ .describe(
33
+ "Comma-separated operator allowlist of private/internal hosts (e.g. a local mailpit/greenmail dev server) that bypass the public-address check for SMTP and IMAP host config. Shared by mail-transport-smtp and inbound-provider-imap — never a tenant-config value.",
34
+ ),
35
+ });
36
+
25
37
  export function readAllowedPrivateMailHostsFromEnv(
26
38
  env: Readonly<Record<string, string | undefined>> = process.env,
27
39
  ): readonly string[] {
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.354.0",
4
+ "type": "fix",
5
+ "title": "inbound-provider-imap declares the MAIL_ALLOWED_PRIVATE_HOSTS env schema",
6
+ "migration": "No action needed. An app that mounts both mail-transport-smtp and inbound-provider-imap now composes one shared field."
7
+ },
2
8
  {
3
9
  "version": "0.320.0",
4
10
  "type": "breaking",
@@ -23,6 +23,7 @@
23
23
  // (credential-document.ts) — kein Tenant-Config, weil mehrere Accounts
24
24
  // pro Tenant verschiedene Hosts haben.
25
25
 
26
+ import { mailAllowedPrivateHostsEnvSchema } from "@cosmicdrift/kumiko-bundled-features/foundation-shared";
26
27
  import {
27
28
  INBOUND_MAIL_PROVIDER_EXTENSION,
28
29
  InboundAuthError,
@@ -282,5 +283,6 @@ export const inboundProviderImapFeature = defineFeature(FEATURE_NAME, (r) => {
282
283
  });
283
284
  r.requires("inbound-mail-foundation");
284
285
  r.requires("secrets");
286
+ r.envSchema(mailAllowedPrivateHostsEnvSchema);
285
287
  r.useExtension(INBOUND_MAIL_PROVIDER_EXTENSION, IMAP_PROVIDER_KEY, imapInboundMailPlugin);
286
288
  });
@@ -39,10 +39,9 @@ const IMAP_HOST_UNREACHABLE_MESSAGE = "IMAP host is not reachable or not allowed
39
39
 
40
40
  // Operator escape hatch for an internal relay or a dev/test IMAP server
41
41
  // (greenmail): KUMIKO_MAIL_ALLOWED_PRIVATE_HOSTS, the same operator env
42
- // var mail-transport-smtp declares (see its envSchema — composeEnvSchema
43
- // rejects two features declaring the same key, so this feature reads it
44
- // without redeclaring it), never a tenant-config value, so a tenant can
45
- // never grant themselves the private-host bypass.
42
+ // var mail-transport-smtp registers (both share one schema instance, see
43
+ // foundation-shared), never a tenant-config value, so a tenant can never
44
+ // grant themselves the private-host bypass.
46
45
  //
47
46
  // mailHostLookup is a test-only DNS seam — production leaves it undefined,
48
47
  // so resolveMailConnectTarget uses the real resolver. Module-global state:
@@ -1,4 +1,34 @@
1
1
  [
2
+ {
3
+ "version": "0.354.0",
4
+ "type": "breaking",
5
+ "title": "jobs: store_tenant_job_failures holds at most one row per (tenant, job, subject)",
6
+ "migration": "store_tenant_job_failures now enforces one row per (tenant_id, job_name, subject) through two partial unique indexes; parallel final failures serialize on an advisory lock. kumiko schema generate does NOT produce the dedup, and the unique index would fail on existing duplicates, so hand-edit the generated migration to contain exactly: DELETE FROM \"store_tenant_job_failures\" a USING \"store_tenant_job_failures\" b WHERE a.\"tenant_id\" = b.\"tenant_id\" AND a.\"job_name\" = b.\"job_name\" AND a.\"subject\" IS NOT DISTINCT FROM b.\"subject\" AND (a.\"failed_at\", a.\"id\") < (b.\"failed_at\", b.\"id\"); CREATE UNIQUE INDEX IF NOT EXISTS \"store_tenant_job_failures_tenant_job_subject_uidx\" ON \"store_tenant_job_failures\" (\"tenant_id\", \"job_name\", \"subject\") WHERE subject IS NOT NULL; CREATE UNIQUE INDEX IF NOT EXISTS \"store_tenant_job_failures_tenant_job_null_subject_uidx\" ON \"store_tenant_job_failures\" (\"tenant_id\", \"job_name\") WHERE subject IS NULL; DROP INDEX IF EXISTS \"store_tenant_job_failures_tenant_job_subject_idx\"; The DELETE keeps the newest row per key (by failed_at, then id)."
7
+ },
8
+ {
9
+ "version": "0.354.0",
10
+ "type": "improvement",
11
+ "title": "Tenant-visible job run state: r.job({ tenantVisibleRun }) plus jobs:query:tenant-runs",
12
+ "detail": "A job that declares `tenantVisibleRun` keeps its queued, running, completed and failed state per tenant and subject in the new table `store_tenant_job_runs`, and the tenant reads it through `jobs:query:tenant-runs` (payload `{ jobName?, subject?, limit? }`, own tenant only). Each row is `{ jobName, subject, status, queuedAt, startedAt, finishedAt }`: every queued or running run plus the latest completed or failed one per job and subject, active runs first. No payload, error text or log ever reaches the tenant. A failed attempt that is retried shows as `queued` again. The stale-run sweep and `retention-cleanup` also cover the new table. `JobRunnerOptions` gains `onJobQueued` and `onJobDropped`.",
13
+ "migration": "New store table. Run `kumiko-schema generate <name>` and apply the migration with `kumiko-schema apply` - `store_tenant_job_runs` is created empty and stays empty until a job declares `tenantVisibleRun`. No change needed for apps that do not opt in."
14
+ },
15
+ {
16
+ "version": "0.354.0",
17
+ "type": "fix",
18
+ "title": "jobs:query:list and jobs:query:failures reject a fractional limit, and migration hints name `kumiko-schema generate`",
19
+ "detail": "Migration hints name the real `kumiko-schema generate` command, and the jobs list queries reject fractional `limit` values\nThe generated migration header and the recipe READMEs pointed at a non-existent `kumiko migrate generate`. `jobs:query:list` and `jobs:query:failures` now validate `limit` as an integer (at least 1), so `limit: 2.5` fails validation instead of reaching the database query."
20
+ },
21
+ {
22
+ "version": "0.354.0",
23
+ "type": "fix",
24
+ "title": "store_tenant_job_failures gets three indexes and jobs:query:failures lists one record per job and subject",
25
+ "migration": "Run `kumiko schema generate`; it emits in-place CREATE INDEX statements with no DROP and no rebuild."
26
+ },
27
+ {
28
+ "version": "0.354.0",
29
+ "type": "fix",
30
+ "title": "The job worker starts consuming only after the boot gates passed"
31
+ },
2
32
  {
3
33
  "version": "0.343.0",
4
34
  "type": "improvement",
@@ -14,6 +14,7 @@ export const JobQueries = {
14
14
  details: "jobs:query:details",
15
15
  catalog: "jobs:query:catalog",
16
16
  failures: "jobs:query:failures",
17
+ tenantRuns: "jobs:query:tenant-runs",
17
18
  } as const;
18
19
 
19
20
  // Error codes
@@ -14,6 +14,7 @@ import { deleteManyBatched } from "@cosmicdrift/kumiko-framework/bun-db";
14
14
  import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
15
15
  import { jobRunLogsTable, jobRunsTable } from "../../job-run-table.js";
16
16
  import { tenantJobFailuresTable } from "../../tenant-job-failure-table.js";
17
+ import { tenantJobRunsTable } from "../../tenant-job-run-table.js";
17
18
 
18
19
  const RETENTION_DELETE_BATCH_SIZE = 500;
19
20
 
@@ -21,6 +22,7 @@ export type JobRunRetentionResult = {
21
22
  readonly runsDeleted: number;
22
23
  readonly logsDeleted: number;
23
24
  readonly tenantFailuresDeleted: number;
25
+ readonly tenantRunsDeleted: number;
24
26
  };
25
27
 
26
28
  export async function deleteStaleJobRuns(
@@ -52,9 +54,19 @@ export async function deleteStaleJobRuns(
52
54
  { limit: RETENTION_DELETE_BATCH_SIZE },
53
55
  );
54
56
 
57
+ // Only finished rows: active ones are the stale sweep's business, and a
58
+ // still-running job older than the window must not vanish from the tenant.
59
+ const tenantRunsResult = await deleteManyBatched(
60
+ db,
61
+ tenantJobRunsTable,
62
+ { status: { in: ["completed", "failed"] }, updatedAt: { lt: cutoff } },
63
+ { limit: RETENTION_DELETE_BATCH_SIZE },
64
+ );
65
+
55
66
  return {
56
67
  runsDeleted: runsResult.deleted,
57
68
  logsDeleted: logsResult.deleted,
58
69
  tenantFailuresDeleted: tenantFailuresResult.deleted,
70
+ tenantRunsDeleted: tenantRunsResult.deleted,
59
71
  };
60
72
  }
@@ -12,19 +12,23 @@
12
12
  // the run becomes retriable via the existing jobs:write:retry gate
13
13
  // (retry.write.ts only allows retry from "failed").
14
14
 
15
- import { selectMany, updateMany } from "@cosmicdrift/kumiko-framework/bun-db";
15
+ import { deleteManyBatched, selectMany, updateMany } from "@cosmicdrift/kumiko-framework/bun-db";
16
16
  import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
17
17
  import { mapWithConcurrency } from "../../../shared/index.js";
18
18
  import { encryptFailureError } from "../../job-run-logger.js";
19
19
  import { jobRunsTable } from "../../job-run-table.js";
20
+ import { tenantJobRunsTable } from "../../tenant-job-run-table.js";
20
21
 
21
22
  const KMS_POOL_CONCURRENCY = 4;
23
+ const STALE_DELETE_BATCH_SIZE = 500;
22
24
 
23
25
  export const STALE_JOB_RUN_ERROR =
24
26
  "job run exceeded the stale-run timeout without a completion signal (likely a crashed worker process)";
25
27
 
26
28
  export type StaleRunSweepResult = {
27
29
  readonly runsMarkedFailed: number;
30
+ readonly tenantRunsMarkedFailed: number;
31
+ readonly tenantQueuedDropped: number;
28
32
  };
29
33
 
30
34
  export async function markStaleJobRunsFailed(
@@ -80,5 +84,25 @@ export async function markStaleJobRunsFailed(
80
84
  );
81
85
  const runsMarkedFailed = updatedPerSubject.reduce((sum, count) => sum + count, 0);
82
86
 
83
- return { runsMarkedFailed };
87
+ // Tenant-visible run state follows the same clock: a running row whose
88
+ // worker died is a failure, a queued row that never started (job lost from
89
+ // Redis, enqueue hook without a run) would otherwise show "waiting" forever.
90
+ const tenantRunsFailed = await updateMany(
91
+ db,
92
+ tenantJobRunsTable,
93
+ { status: "failed", finishedAt: now, updatedAt: now },
94
+ { status: "running", startedAt: { lt: cutoff } },
95
+ );
96
+ const tenantQueuedStale = await deleteManyBatched(
97
+ db,
98
+ tenantJobRunsTable,
99
+ { status: "queued", updatedAt: { lt: cutoff } },
100
+ { limit: STALE_DELETE_BATCH_SIZE },
101
+ );
102
+
103
+ return {
104
+ runsMarkedFailed,
105
+ tenantRunsMarkedFailed: tenantRunsFailed.length,
106
+ tenantQueuedDropped: tenantQueuedStale.deleted,
107
+ };
84
108
  }
@@ -27,10 +27,12 @@ import {
27
27
  DEFAULT_JOB_RUN_STALE_TIMEOUT_HOURS,
28
28
  } from "./handlers/stale-run-sweep.job.js";
29
29
  import { tenantFailuresQuery } from "./handlers/tenant-failures.query.js";
30
+ import { tenantRunsQuery } from "./handlers/tenant-runs.query.js";
30
31
  import { triggerWrite } from "./handlers/trigger.write.js";
31
32
  import { JOBS_I18N } from "./i18n.js";
32
33
  import { jobRunLogsTableMeta, jobRunsTableMeta } from "./job-run-table.js";
33
34
  import { tenantJobFailuresTableMeta } from "./tenant-job-failure-table.js";
35
+ import { tenantJobRunsTableMeta } from "./tenant-job-run-table.js";
34
36
 
35
37
  export type JobsFeatureOptions = {
36
38
  // How long a job run (and its logs) stays in store_job_runs/
@@ -49,7 +51,7 @@ export function createJobsFeature(options: JobsFeatureOptions = {}): FeatureDefi
49
51
  const staleRunTimeoutHours = options.staleRunTimeoutHours ?? DEFAULT_JOB_RUN_STALE_TIMEOUT_HOURS;
50
52
  return defineFeature("jobs", (r) => {
51
53
  r.describe(
52
- "Persistence and operator tooling for background jobs registered via `r.job(...)`. Every job execution writes directly into `store_job_runs` (current status + duration) and `store_job_run_logs` (per-line log rows) from the BullMQ callbacks — no event stream in between (#2243). A daily `retention-cleanup` job deletes runs (and their logs) older than `retentionDays`; an hourly `stale-run-sweep` job marks runs stuck at status `running` past `staleRunTimeoutHours` as `failed` (#2246 — a crashed worker never fires the completion callback, so nothing else ever revisits the row). Exposes `jobs:write:trigger` (manual run) and `jobs:write:retry` (operator retry of a failed run), plus `jobs:query:list`, `jobs:query:details`, and `jobs:query:catalog` (manual jobs) for the operator UI. A job that declares `tenantVisibleFailure` also records its last failed attempt per tenant and subject in `store_tenant_job_failures`, which the tenant itself reads through `jobs:query:failures` — a translation key only, never the provider's message (fw#3079).",
54
+ "Persistence and operator tooling for background jobs registered via `r.job(...)`. Every job execution writes directly into `store_job_runs` (current status + duration) and `store_job_run_logs` (per-line log rows) from the BullMQ callbacks — no event stream in between (#2243). A daily `retention-cleanup` job deletes runs (and their logs) older than `retentionDays`; an hourly `stale-run-sweep` job marks runs stuck at status `running` past `staleRunTimeoutHours` as `failed` (#2246 — a crashed worker never fires the completion callback, so nothing else ever revisits the row). Exposes `jobs:write:trigger` (manual run) and `jobs:write:retry` (operator retry of a failed run), plus `jobs:query:list`, `jobs:query:details`, and `jobs:query:catalog` (manual jobs) for the operator UI. A job that declares `tenantVisibleFailure` also records its last failed attempt per tenant and subject in `store_tenant_job_failures`, which the tenant itself reads through `jobs:query:failures` — a translation key only, never the provider's message (fw#3079). A job that declares `tenantVisibleRun` also keeps its run state per tenant and subject in `store_tenant_job_runs` (queued, running, plus the latest completed or failed run, no payload, error text or logs), which the tenant reads through `jobs:query:tenant-runs` (fw#3616); the stale-run sweep fails its stuck `running` rows and drops its stuck `queued` rows.",
53
55
  );
54
56
  r.uiHints({
55
57
  displayLabel: "Jobs · Audit & Operator UI",
@@ -71,6 +73,9 @@ export function createJobsFeature(options: JobsFeatureOptions = {}): FeatureDefi
71
73
  r.storeTable(tenantJobFailuresTableMeta, {
72
74
  reason: "direct_write.tenant_job_failures",
73
75
  });
76
+ r.storeTable(tenantJobRunsTableMeta, {
77
+ reason: "direct_write.tenant_job_runs",
78
+ });
74
79
 
75
80
  // Framework-provided rebuild job — available whenever `jobs` is composed; enqueueProjectionRebuild dispatches it.
76
81
  r.job(
@@ -123,6 +128,7 @@ export function createJobsFeature(options: JobsFeatureOptions = {}): FeatureDefi
123
128
  detail: r.queryHandler(detailQuery),
124
129
  catalog: r.queryHandler(catalogQuery),
125
130
  failures: r.queryHandler(tenantFailuresQuery),
131
+ tenantRuns: r.queryHandler(tenantRunsQuery),
126
132
  };
127
133
 
128
134
  const systemAdminAccess = { roles: ["SystemAdmin"] as const };
@@ -68,7 +68,7 @@ export const listQuery = defineQueryHandler({
68
68
  .optional(),
69
69
  sort: z.enum(["jobName", "status", "startedAt", "duration"]).optional(),
70
70
  sortDirection: z.enum(["asc", "desc"]).optional(),
71
- limit: z.number().optional(),
71
+ limit: z.number().int().min(1).optional(),
72
72
  cursor: z.string().optional(),
73
73
  totalCount: z.boolean().optional(),
74
74
  }),
@@ -37,7 +37,7 @@ export const tenantFailuresQuery = defineQueryHandler({
37
37
  schema: z.object({
38
38
  jobName: z.string().optional(),
39
39
  subject: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])).optional(),
40
- limit: z.number().min(1).max(200).optional(),
40
+ limit: z.number().int().min(1).max(200).optional(),
41
41
  }),
42
42
  // Every membership rank: a failure record carries a job name and a
43
43
  // translation key, nothing a team member of the tenant may not see.
@@ -0,0 +1,94 @@
1
+ import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
2
+ import { access, defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
4
+ import { serializeJobSubject } from "@cosmicdrift/kumiko-framework/jobs";
5
+ import { parseJsonSafe } from "@cosmicdrift/kumiko-framework/utils";
6
+ import * as z from "zod";
7
+ import { type TenantJobRunStatus, tenantJobRunsTable } from "../tenant-job-run-table.js";
8
+
9
+ type TenantJobRunRow = {
10
+ readonly tenantId: string;
11
+ readonly jobName: string;
12
+ readonly subject: string | null;
13
+ readonly status: TenantJobRunStatus;
14
+ readonly queuedAt: Temporal.Instant | null;
15
+ readonly startedAt: Temporal.Instant | null;
16
+ readonly finishedAt: Temporal.Instant | null;
17
+ readonly updatedAt: Temporal.Instant;
18
+ };
19
+
20
+ const DEFAULT_LIMIT = 50;
21
+
22
+ function isSubjectEntry(value: unknown): value is [string, unknown] {
23
+ return Array.isArray(value) && typeof value[0] === "string";
24
+ }
25
+
26
+ function parseSubject(stored: string | null): Record<string, unknown> | null {
27
+ if (stored === null) return null;
28
+ // A corrupt subject must not fail the whole list; parseJsonSafe only
29
+ // survives a SyntaxError, so the shape needs its own check.
30
+ const parsed = parseJsonSafe<unknown>(stored, null);
31
+ if (!Array.isArray(parsed)) return null;
32
+ return Object.fromEntries(parsed.filter(isSubjectEntry));
33
+ }
34
+
35
+ export const tenantRunsQuery = defineQueryHandler({
36
+ name: "tenant-runs",
37
+ description:
38
+ "Lists the calling tenant's own job runs that opted in via tenantVisibleRun — per job and subject every queued or running run plus the latest completed or failed one, active runs first, then newest first; each row carries only status and queuedAt/startedAt/finishedAt, never payloads, error text or logs; the optional subject filter matches exactly: all declared subjectFields with the same JSON types as the job payload; use it to show whether work for a subject is waiting, running or done, and to tell a failed run from a finished one.",
39
+ schema: z.object({
40
+ jobName: z.string().optional(),
41
+ subject: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])).optional(),
42
+ limit: z.number().int().min(1).max(200).optional(),
43
+ }),
44
+ // Every membership rank: a run state carries a job name, a status and
45
+ // times, nothing a team member of the tenant may not see.
46
+ access: { roles: access.roles("User", "Editor", ...access.admin) },
47
+ handler: async (query, ctx) => {
48
+ if (!ctx.systemDb) {
49
+ throw new InternalError({
50
+ message:
51
+ "jobs:query:tenant-runs requires ctx.systemDb (feature must declare r.systemScope())",
52
+ });
53
+ }
54
+ // assertTenantMatch returns the same unfiltered system-mode db — the
55
+ // explicit tenantId below is the filter, assertRowsTenant the second net.
56
+ const db = ctx.systemDb.assertTenantMatch(query.user.tenantId);
57
+ const where: Record<string, unknown> = { tenantId: [query.user.tenantId] };
58
+ if (query.payload.jobName) where["jobName"] = query.payload.jobName;
59
+ if (query.payload.subject) where["subject"] = serializeJobSubject(query.payload.subject);
60
+ const limit = query.payload.limit ?? DEFAULT_LIMIT;
61
+ const newestFirst = { col: "updatedAt", direction: "desc" } as const;
62
+ const activeRows = await selectMany<TenantJobRunRow>(
63
+ db,
64
+ tenantJobRunsTable,
65
+ { ...where, status: { in: ["queued", "running"] } },
66
+ { orderBy: newestFirst, limit },
67
+ );
68
+ const finishedRows = await selectMany<TenantJobRunRow>(
69
+ db,
70
+ tenantJobRunsTable,
71
+ { ...where, status: { in: ["completed", "failed"] } },
72
+ { orderBy: newestFirst, limit },
73
+ );
74
+ const seenFinishedKeys = new Set<string>();
75
+ const finishedNewestPerKey = finishedRows.filter((row) => {
76
+ const key = JSON.stringify([row.jobName, row.subject]);
77
+ if (seenFinishedKeys.has(key)) return false;
78
+ seenFinishedKeys.add(key);
79
+ return true;
80
+ });
81
+ const activeFirst = [...activeRows, ...finishedNewestPerKey];
82
+ return {
83
+ rows: ctx.systemDb.assertRowsTenant(activeFirst.slice(0, limit), "tenantId").map((row) => ({
84
+ jobName: row.jobName,
85
+ subject: parseSubject(row.subject),
86
+ status: row.status,
87
+ queuedAt: row.queuedAt,
88
+ startedAt: row.startedAt,
89
+ finishedAt: row.finishedAt,
90
+ })),
91
+ nextCursor: null,
92
+ };
93
+ },
94
+ });
@@ -5,3 +5,5 @@ export { createJobRunLogger } from "./job-run-logger.js";
5
5
  export type { JobLogLevel, JobRunStatus } from "./job-run-table.js";
6
6
  export { jobRunLogsTable, jobRunsTable } from "./job-run-table.js";
7
7
  export { tenantJobFailuresTable } from "./tenant-job-failure-table.js";
8
+ export type { TenantJobRunStatus } from "./tenant-job-run-table.js";
9
+ export { tenantJobRunsTable } from "./tenant-job-run-table.js";