@cosmicdrift/kumiko-bundled-features 0.158.2 → 0.159.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 (149) hide show
  1. package/package.json +7 -6
  2. package/src/auth-email-password/__tests__/account-unlock.integration.test.ts +283 -0
  3. package/src/auth-email-password/__tests__/auth-user-row.test.ts +36 -0
  4. package/src/auth-email-password/__tests__/auth.integration.test.ts +21 -0
  5. package/src/auth-email-password/__tests__/email-verification.integration.test.ts +1 -1
  6. package/src/auth-email-password/__tests__/password-policy.test.ts +15 -0
  7. package/src/auth-email-password/__tests__/password-reset.integration.test.ts +15 -1
  8. package/src/auth-email-password/__tests__/session-strict-mode.integration.test.ts +7 -14
  9. package/src/auth-email-password/__tests__/signup-flow.integration.test.ts +12 -0
  10. package/src/auth-email-password/constants.ts +15 -0
  11. package/src/auth-email-password/email-templates.ts +32 -0
  12. package/src/auth-email-password/errors.ts +9 -0
  13. package/src/auth-email-password/feature.ts +37 -5
  14. package/src/auth-email-password/handlers/change-password.write.ts +2 -1
  15. package/src/auth-email-password/handlers/confirm-account-unlock.write.ts +59 -0
  16. package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +1 -0
  17. package/src/auth-email-password/handlers/invite-accept.write.ts +6 -2
  18. package/src/auth-email-password/handlers/invite-signup-complete.write.ts +1 -0
  19. package/src/auth-email-password/handlers/request-account-unlock.write.ts +38 -0
  20. package/src/auth-email-password/handlers/reset-password.write.ts +2 -1
  21. package/src/auth-email-password/handlers/signup-confirm.write.ts +2 -1
  22. package/src/auth-email-password/i18n.ts +42 -0
  23. package/src/auth-email-password/index.ts +2 -0
  24. package/src/auth-email-password/lockout-store.ts +10 -5
  25. package/src/auth-email-password/password-policy.ts +2108 -0
  26. package/src/auth-email-password/signed-token.ts +1 -0
  27. package/src/auth-email-password/unlock-token.ts +28 -0
  28. package/src/auth-email-password/web/__tests__/auth-gate.test.tsx +65 -3
  29. package/src/auth-email-password/web/auth-client.ts +33 -0
  30. package/src/auth-email-password/web/confirm-account-unlock-screen.tsx +95 -0
  31. package/src/auth-email-password/web/index.ts +6 -0
  32. package/src/auth-email-password/web/login-screen.tsx +15 -0
  33. package/src/auth-email-password/web/request-account-unlock-screen.tsx +112 -0
  34. package/src/auth-foundation/__tests__/feature.test.ts +33 -0
  35. package/src/auth-foundation/__tests__/session-store.test.ts +78 -0
  36. package/src/auth-foundation/__tests__/token-verifier.test.ts +117 -0
  37. package/src/auth-foundation/boot-checks.ts +91 -0
  38. package/src/auth-foundation/feature.ts +103 -0
  39. package/src/auth-foundation/index.ts +23 -0
  40. package/src/auth-foundation/types.ts +107 -0
  41. package/src/auth-mfa/__tests__/errors-i18n-parity.test.ts +8 -0
  42. package/src/auth-mfa/__tests__/verify.integration.test.ts +20 -0
  43. package/src/auth-mfa/web/__tests__/mfa-client.test.ts +118 -0
  44. package/src/auth-mfa/web/__tests__/mfa-enable-screen.test.tsx +173 -0
  45. package/src/auth-mfa/web/__tests__/mfa-verify-screen.test.tsx +100 -0
  46. package/src/billing-foundation/__tests__/subscription-provider-contract.ts +69 -0
  47. package/src/channel-email/pii-guard.ts +6 -3
  48. package/src/config/__tests__/config.integration.test.ts +14 -0
  49. package/src/config/__tests__/pii-encrypted.integration.test.ts +3 -2
  50. package/src/config/__tests__/reencrypt-job-concurrent-write-race.integration.test.ts +20 -3
  51. package/src/config/__tests__/reencrypt-job-kek-rotation.integration.test.ts +2 -3
  52. package/src/config/feature.ts +2 -4
  53. package/src/config/handlers/set.write.ts +7 -1
  54. package/src/config/resolver.ts +1 -1
  55. package/src/custom-fields/__tests__/user-data-rights.integration.test.ts +2 -0
  56. package/src/delivery/handlers/log.query.ts +1 -1
  57. package/src/files-provider-s3/__tests__/s3-provider.integration.test.ts +7 -0
  58. package/src/folders/web/__tests__/folder-manager.test.tsx +27 -2
  59. package/src/inbound-mail-foundation/__tests__/account-state.test.ts +30 -0
  60. package/src/inbound-mail-foundation/__tests__/inbound-mail-foundation.integration.test.ts +57 -6
  61. package/src/inbound-mail-foundation/__tests__/inbound-mail-provider-contract.ts +93 -0
  62. package/src/inbound-mail-foundation/handlers/ingest-message.write.ts +60 -50
  63. package/src/inbound-provider-imap/__tests__/imap-live.integration.test.ts +12 -0
  64. package/src/inbound-provider-inmemory/__tests__/feature.test.ts +48 -0
  65. package/src/inbound-provider-inmemory/feature.ts +2 -2
  66. package/src/jobs/__tests__/reindex-entity-job.integration.test.ts +53 -1
  67. package/src/jobs/handlers/detail.query.ts +1 -1
  68. package/src/jobs/handlers/list.query.ts +1 -1
  69. package/src/mail-foundation/__tests__/mail-transport-contract.ts +42 -0
  70. package/src/mail-transport-inmemory/__tests__/feature.test.ts +19 -0
  71. package/src/mail-transport-smtp/__tests__/feature.test.ts +60 -0
  72. package/src/managed-pages/handlers/branding.query.ts +5 -1
  73. package/src/personal-access-tokens/__tests__/feature.test.ts +18 -0
  74. package/src/personal-access-tokens/__tests__/pat.integration.test.ts +8 -11
  75. package/src/personal-access-tokens/feature.ts +22 -7
  76. package/src/personal-access-tokens/handlers/list.query.ts +1 -1
  77. package/src/personal-access-tokens/index.ts +0 -14
  78. package/src/personal-access-tokens/resolver.ts +2 -2
  79. package/src/secrets/__tests__/rotate.integration.test.ts +31 -0
  80. package/src/sessions/__tests__/cleanup.integration.test.ts +2 -1
  81. package/src/sessions/__tests__/password-auto-revoke.integration.test.ts +5 -3
  82. package/src/sessions/__tests__/rebuild-survival.integration.test.ts +11 -2
  83. package/src/sessions/__tests__/session-store-registration.test.ts +21 -0
  84. package/src/sessions/__tests__/sessions-screens.boot.test.ts +10 -1
  85. package/src/sessions/__tests__/sessions.integration.test.ts +28 -17
  86. package/src/sessions/__tests__/test-helpers.ts +15 -2
  87. package/src/sessions/feature.ts +31 -3
  88. package/src/sessions/handlers/detail.query.ts +2 -2
  89. package/src/sessions/handlers/list.query.ts +4 -2
  90. package/src/sessions/handlers/mine.query.ts +4 -2
  91. package/src/sessions/testing.ts +14 -1
  92. package/src/shared/__tests__/chunked-entity-migration.test.ts +145 -0
  93. package/src/shared/decrypt-stored-pii.ts +12 -5
  94. package/src/subscription-mollie/__tests__/feature.test.ts +87 -0
  95. package/src/subscription-stripe/__tests__/feature.test.ts +120 -1
  96. package/src/tenant/__tests__/members-query-kms.integration.test.ts +98 -0
  97. package/src/tenant/handlers/invitations.query.ts +4 -2
  98. package/src/tenant/handlers/members.query.ts +2 -2
  99. package/src/tenant/seeding.ts +2 -2
  100. package/src/tenant-lifecycle/__tests__/tenant-lifecycle.integration.test.ts +2 -0
  101. package/src/tenant-lifecycle/boot-checks.ts +53 -0
  102. package/src/tenant-lifecycle/feature.ts +7 -0
  103. package/src/tenant-lifecycle/handlers/request-destruction.write.ts +1 -1
  104. package/src/tier-engine/feature.ts +2 -2
  105. package/src/user-data-rights/COMPLIANCE.md +20 -0
  106. package/src/user-data-rights/__tests__/anonymous-deletion-kms.integration.test.ts +2 -0
  107. package/src/user-data-rights/__tests__/anonymous-deletion.integration.test.ts +3 -0
  108. package/src/user-data-rights/__tests__/audit-log.integration.test.ts +2 -0
  109. package/src/user-data-rights/__tests__/boot-checks.test.ts +172 -0
  110. package/src/user-data-rights/__tests__/cross-data-matrix.integration.test.ts +2 -0
  111. package/src/user-data-rights/__tests__/download-by-token-resolver-trust.integration.test.ts +2 -0
  112. package/src/user-data-rights/__tests__/download.integration.test.ts +59 -4
  113. package/src/user-data-rights/__tests__/export-encrypted-fields.integration.test.ts +2 -0
  114. package/src/user-data-rights/__tests__/export-job-idempotency.integration.test.ts +2 -0
  115. package/src/user-data-rights/__tests__/export-job-list-cross-tenant.integration.test.ts +2 -0
  116. package/src/user-data-rights/__tests__/file-binary-forget-cleanup.integration.test.ts +2 -0
  117. package/src/user-data-rights/__tests__/file-binary-forget-failure.integration.test.ts +2 -0
  118. package/src/user-data-rights/__tests__/file-retention.integration.test.ts +2 -0
  119. package/src/user-data-rights/__tests__/file-storage-unification.integration.test.ts +2 -0
  120. package/src/user-data-rights/__tests__/forget-cleanup-hook-ordering.integration.test.ts +2 -0
  121. package/src/user-data-rights/__tests__/forget-hook-registry.integration.test.ts +2 -0
  122. package/src/user-data-rights/__tests__/inspector-screens.boot.test.ts +10 -1
  123. package/src/user-data-rights/__tests__/mail-default-bridge.integration.test.ts +2 -0
  124. package/src/user-data-rights/__tests__/read-users-rebuild-survives-lifecycle.integration.test.ts +15 -7
  125. package/src/user-data-rights/__tests__/request-cancel-deletion.integration.test.ts +2 -0
  126. package/src/user-data-rights/__tests__/request-deletion-callback.integration.test.ts +2 -0
  127. package/src/user-data-rights/__tests__/request-export.integration.test.ts +2 -0
  128. package/src/user-data-rights/__tests__/restriction-flow.integration.test.ts +48 -15
  129. package/src/user-data-rights/__tests__/run-export-jobs-cron-context.integration.test.ts +2 -0
  130. package/src/user-data-rights/__tests__/run-export-jobs.integration.test.ts +4 -1
  131. package/src/user-data-rights/__tests__/run-forget-cleanup.integration.test.ts +2 -0
  132. package/src/user-data-rights/__tests__/run-user-export.integration.test.ts +2 -0
  133. package/src/user-data-rights/__tests__/tenant-model-erasure.integration.test.ts +2 -0
  134. package/src/user-data-rights/__tests__/user-data-rights.integration.test.ts +9 -1
  135. package/src/user-data-rights/boot-checks.ts +86 -0
  136. package/src/user-data-rights/feature.ts +116 -6
  137. package/src/user-data-rights/handlers/deletion-grace-period.ts +1 -1
  138. package/src/user-data-rights/handlers/download-by-token.query.ts +1 -1
  139. package/src/user-data-rights/handlers/lift-restriction.write.ts +13 -19
  140. package/src/user-data-rights/handlers/restrict-account.write.ts +30 -8
  141. package/src/user-data-rights/run-export-jobs.ts +3 -3
  142. package/src/user-data-rights/run-forget-cleanup.ts +1 -1
  143. package/src/user-data-rights-defaults/__tests__/bundled-entities.userdata-hooks.integration.test.ts +3 -0
  144. package/src/user-data-rights-defaults/__tests__/feature-mounted.test.ts +24 -0
  145. package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.test.ts +2 -0
  146. package/src/user-data-rights-defaults/hooks/tenant-invitation.userdata-hook.ts +1 -1
  147. package/src/user-profile/__tests__/change-email.integration.test.ts +2 -0
  148. package/src/user-profile/handlers/change-email.write.ts +1 -1
  149. package/src/config/__tests__/encrypted-legacy-migration.integration.test.ts +0 -179
@@ -26,6 +26,7 @@ import {
26
26
  encryptPiiFieldValues,
27
27
  } from "@cosmicdrift/kumiko-framework/crypto";
28
28
  import type { WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
29
+ import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
29
30
  import { Temporal } from "temporal-polyfill";
30
31
  import { z } from "zod";
31
32
  import { inboundMessageAggregateId, mailThreadAggregateId } from "../aggregate-id";
@@ -99,6 +100,7 @@ function buildThreadKey(p: IngestMessagePayload, effectiveMessageIdHeader: strin
99
100
  return `mid:${effectiveMessageIdHeader}`.slice(0, 500);
100
101
  }
101
102
 
103
+ const THREAD_ROLLUP_MAX_ATTEMPTS = 5;
102
104
  export const ingestMessageHandler: WriteHandlerDef = {
103
105
  name: "ingest-message",
104
106
  schema: ingestMessageSchema,
@@ -221,46 +223,33 @@ export const ingestMessageHandler: WriteHandlerDef = {
221
223
  }
222
224
 
223
225
  // ---------------------------------------------------------------
224
- // 5. Thread-Rollup. Der Handler berechnet den NEUEN Snapshot
225
- // (count via Live-COUNT, max(lastMessageAt)) — die apply ist ein
226
- // dummer UPSERT und bleibt replay-idempotent. lastMessageAtIso ist
227
- // kein PII (Zeitpunkt), damit plaintext-vergleichbar; subject
228
- // wird frisch encrypted (jede Thread-Version trägt das Subject
229
- // der jüngsten Mail).
226
+ // 5. Thread-Rollup. New snapshot (live COUNT + max(lastMessageAt)) via
227
+ // ctx.tryAppendEvent in a bounded retry loop — the apply stays a
228
+ // dumb, replay-idempotent UPSERT. lastMessageAtIso is not PII (a
229
+ // timestamp), so it stays plaintext-comparable; subject is
230
+ // encrypted once up front (constant across retries, doesn't
231
+ // depend on the thread's prior state).
230
232
  //
231
- // messageCount kommt bewusst NICHT aus einem frueher gelesenen
232
- // threadEvents-Snapshot (previousCount+1): getStreamVersion in
233
- // unsafeAppendEvent liest die Stream-Version FRISCH zum
234
- // Append-Zeitpunkt, unabhaengig vom Read hier — zwei parallele
235
- // ingests auf denselben Thread (Watch-Push + Poll-Reconciliation)
236
- // koennten beide denselben previousCount sehen und der Append der
237
- // zweiten TX wuerde trotzdem gelingen (kein VersionConflictError,
238
- // da der Version-Check nur die Stream-Version prueft), sodass
239
- // previousCount+1 dauerhaft nach unten drieftet.
233
+ // messageCount deliberately does NOT come from a previously loaded
234
+ // threadEvents snapshot (previousCount+1): two concurrent ingests
235
+ // on the same thread (Watch-Push vs. Poll-Reconciliation overlap,
236
+ // or two different messages of the same thread arriving at once)
237
+ // can both read the same stream version before either commits.
238
+ // tryAppendEvent's fresh version-read at append time then makes
239
+ // the loser's append fail with a VersionConflictError instead of
240
+ // silently succeeding — so we retry: reload the thread stream,
241
+ // recompute the live row-count and lastMessageAt, and append
242
+ // again. Unlike step 4, a lost race here must NOT be treated as a
243
+ // no-op duplicate — skipping would leave this message's
244
+ // contribution to messageCount/lastMessageAt out of the thread
245
+ // forever. Exhausting all attempts throws, rolling back the whole
246
+ // TX (including the step-4 message append) for a clean re-ingest
247
+ // on the next poll — never return success with a stale snapshot.
240
248
  //
241
- // Ein COUNT gegen read_inbound_messages SCHLIESST das Race-Fenster
242
- // NICHT (COUNT und Append sind weiterhin zwei getrennte Statements —
243
- // zwischen COUNT und Append kann die andere TX committen und derselbe
244
- // Drift theoretisch weiterhin auftreten). Was der COUNT aendert: er
245
- // macht den Fehler SELBSTKORRIGIEREND statt kumulativ — jeder
246
- // nachfolgende ingest liest den echten Row-Count neu, statt einen
247
- // frueher falsch geschriebenen previousCount fortzuschreiben. Eine
248
- // echte Race-freie Loesung braucht ein atomares Read+Append (z.B.
249
- // SELECT ... FOR UPDATE oder eine Advisory-Lock auf threadAggId) —
250
- // das ist bewusst nicht Teil dieses Fixes (siehe PR-Beschreibung).
249
+ // A true race-free read+append (e.g. SELECT ... FOR UPDATE or an
250
+ // advisory lock on threadAggId) is deliberately not part of this
251
+ // fix — two writers (watch + poll) make bounded retry sufficient.
251
252
  // ---------------------------------------------------------------
252
- const threadEvents = await ctx.loadAggregate(threadAggId);
253
- const lastThreadEvent = threadEvents[threadEvents.length - 1];
254
- // @cast-boundary engine-payload — eigene events, shape via defineEvent
255
- const previousThread = lastThreadEvent?.payload as MailThreadEventPayload | undefined;
256
- const previousLastAt = previousThread?.lastMessageAtIso ?? "";
257
- const newLastAt =
258
- payload.receivedAtIso > previousLastAt ? payload.receivedAtIso : previousLastAt;
259
- const messageCount = await countWhere(ctx.db.raw, inboundMessagesProjectionTable, {
260
- tenantId,
261
- threadKey,
262
- });
263
-
264
253
  const threadPlainPii = { tenantId, subject: payload.subject };
265
254
  const encryptedThreadFields = piiKms
266
255
  ? await encryptPiiFieldValues(
@@ -275,19 +264,40 @@ export const ingestMessageHandler: WriteHandlerDef = {
275
264
  )
276
265
  : threadPlainPii;
277
266
 
278
- const threadEventPayload: MailThreadEventPayload = {
279
- threadKey,
280
- subject: encryptedThreadFields["subject"] as string,
281
- lastMessageAtIso: newLastAt,
282
- messageCount,
283
- };
284
- await ctx.unsafeAppendEvent({
285
- aggregateId: threadAggId,
286
- aggregateType: MAIL_THREAD_AGGREGATE_TYPE,
287
- type: MAIL_THREAD_UPDATED_EVENT_QN,
288
- payload: threadEventPayload,
289
- headers: messageHeaders,
290
- });
267
+ let threadAppendOk = false;
268
+ for (let attempt = 1; attempt <= THREAD_ROLLUP_MAX_ATTEMPTS && !threadAppendOk; attempt++) {
269
+ const threadEvents = await ctx.loadAggregate(threadAggId);
270
+ const lastThreadEvent = threadEvents[threadEvents.length - 1];
271
+ // @cast-boundary engine-payload — eigene events, shape via defineEvent
272
+ const previousThread = lastThreadEvent?.payload as MailThreadEventPayload | undefined;
273
+ const previousLastAt = previousThread?.lastMessageAtIso ?? "";
274
+ const newLastAt =
275
+ payload.receivedAtIso > previousLastAt ? payload.receivedAtIso : previousLastAt;
276
+ const messageCount = await countWhere(ctx.db.raw, inboundMessagesProjectionTable, {
277
+ tenantId,
278
+ threadKey,
279
+ });
280
+
281
+ const threadEventPayload: MailThreadEventPayload = {
282
+ threadKey,
283
+ subject: encryptedThreadFields["subject"] as string,
284
+ lastMessageAtIso: newLastAt,
285
+ messageCount,
286
+ };
287
+ const threadAppend = await ctx.tryAppendEvent({
288
+ aggregateId: threadAggId,
289
+ aggregateType: MAIL_THREAD_AGGREGATE_TYPE,
290
+ type: MAIL_THREAD_UPDATED_EVENT_QN,
291
+ payload: threadEventPayload,
292
+ headers: messageHeaders,
293
+ });
294
+ threadAppendOk = threadAppend.ok;
295
+ }
296
+ if (!threadAppendOk) {
297
+ throw new InternalError({
298
+ message: `ingest-message: thread-rollup append for "${threadAggId}" lost the version-conflict race ${THREAD_ROLLUP_MAX_ATTEMPTS} times in a row — aborting for a clean re-ingest instead of persisting a stale messageCount.`,
299
+ });
300
+ }
291
301
 
292
302
  // ---------------------------------------------------------------
293
303
  // 6. Dedup-Anchor. Läuft in derselben TX wie die appends — stirbt
@@ -25,6 +25,7 @@ import {
25
25
  type MailAccountRecord,
26
26
  type RawInboundMessage,
27
27
  } from "../../inbound-mail-foundation";
28
+ import { describeInboundMailProviderContract } from "../../inbound-mail-foundation/__tests__/inbound-mail-provider-contract";
28
29
  import { imapInboundMailPlugin } from "../feature";
29
30
 
30
31
  const HOST = process.env["IMAP_LIVE_HOST"] ?? "127.0.0.1";
@@ -192,3 +193,14 @@ describe("imap-live — echter Server (greenmail)", () => {
192
193
  20_000,
193
194
  );
194
195
  });
196
+
197
+ describeInboundMailProviderContract(
198
+ "imap (greenmail)",
199
+ () => ({
200
+ plugin: imapInboundMailPlugin,
201
+ ctx: ctxWithCredentials(credentialDoc(PASSWORD)),
202
+ account,
203
+ seed: (subject) => sendTestMail(subject, "contract seed"),
204
+ }),
205
+ { skip: !available },
206
+ );
@@ -0,0 +1,48 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { describeInboundMailProviderContract } from "../../inbound-mail-foundation/__tests__/inbound-mail-provider-contract";
3
+ import {
4
+ inboundProviderInMemoryFeature,
5
+ inMemoryInboundMailPlugin,
6
+ seedInboundMessage,
7
+ } from "../feature";
8
+
9
+ describe("inboundProviderInMemoryFeature — shape", () => {
10
+ test("has the expected name + requirement", () => {
11
+ expect(inboundProviderInMemoryFeature.name).toBe("inbound-provider-inmemory");
12
+ expect(inboundProviderInMemoryFeature.requires).toContain("inbound-mail-foundation");
13
+ });
14
+ });
15
+
16
+ describeInboundMailProviderContract("inmemory", () => {
17
+ const accountId = crypto.randomUUID();
18
+ return {
19
+ plugin: inMemoryInboundMailPlugin,
20
+ ctx: {},
21
+ account: {
22
+ id: accountId,
23
+ tenantId: "00000000-0000-4000-8000-000000004242",
24
+ provider: "inmemory",
25
+ authMethod: "password",
26
+ ownerUserId: null,
27
+ address: "contract@example.com",
28
+ displayName: "Contract-Test",
29
+ status: "active",
30
+ watchState: "idle",
31
+ },
32
+ seed: (subject) =>
33
+ seedInboundMessage(accountId, {
34
+ providerMessageId: crypto.randomUUID(),
35
+ messageIdHeader: null,
36
+ providerThreadId: null,
37
+ references: [],
38
+ from: "sender@example.com",
39
+ to: ["contract@example.com"],
40
+ cc: [],
41
+ subject,
42
+ snippet: subject,
43
+ receivedAtIso: "2026-07-01T10:00:00Z",
44
+ rawMime: null,
45
+ scope: "inbox",
46
+ }),
47
+ };
48
+ });
@@ -102,7 +102,7 @@ function offsetOf(cursor: SyncCursorPayload | null): number {
102
102
  return typeof raw === "number" && Number.isInteger(raw) && raw >= 0 ? raw : 0;
103
103
  }
104
104
 
105
- const plugin: InboundMailProviderPlugin = {
105
+ export const inMemoryInboundMailPlugin: InboundMailProviderPlugin = {
106
106
  verify: async (_ctx, account: MailAccountRecord) => {
107
107
  const state = stateFor(account.id);
108
108
  if (state.nextVerifyError) {
@@ -150,5 +150,5 @@ export const inboundProviderInMemoryFeature = defineFeature(FEATURE_NAME, (r) =>
150
150
  recommended: false,
151
151
  });
152
152
  r.requires("inbound-mail-foundation");
153
- r.useExtension("inboundMailProvider", INMEMORY_PROVIDER_KEY, plugin);
153
+ r.useExtension("inboundMailProvider", INMEMORY_PROVIDER_KEY, inMemoryInboundMailPlugin);
154
154
  });
@@ -12,7 +12,12 @@ import {
12
12
  createEventStoreExecutor,
13
13
  createTenantDb,
14
14
  } from "@cosmicdrift/kumiko-framework/db";
15
- import { createEntity, createTextField, defineFeature } from "@cosmicdrift/kumiko-framework/engine";
15
+ import {
16
+ createEntity,
17
+ createTextField,
18
+ defineFeature,
19
+ type JobContext,
20
+ } from "@cosmicdrift/kumiko-framework/engine";
16
21
  import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
17
22
  import {
18
23
  setupTestStack,
@@ -20,6 +25,7 @@ import {
20
25
  TestUsers,
21
26
  unsafeCreateEntityTable,
22
27
  } from "@cosmicdrift/kumiko-framework/stack";
28
+ import { bridgeStub } from "@cosmicdrift/kumiko-framework/testing";
23
29
  import { createJobsFeature } from "../feature";
24
30
  import { reindexEntityJob } from "../handlers/reindex-entity.job";
25
31
 
@@ -43,6 +49,16 @@ const widgetFeature = defineFeature("reindex-job-test", (r) => {
43
49
  let stack: TestStack;
44
50
  const admin = TestUsers.admin;
45
51
 
52
+ const noopLogger: JobContext["log"] = {
53
+ info() {},
54
+ warn() {},
55
+ error() {},
56
+ debug() {},
57
+ child() {
58
+ return noopLogger;
59
+ },
60
+ };
61
+
46
62
  beforeAll(async () => {
47
63
  stack = await setupTestStack({ features: [widgetFeature, createJobsFeature()] });
48
64
  await unsafeCreateEntityTable(stack.db, widgetEntity);
@@ -87,6 +103,42 @@ describe("reindexEntityJob", () => {
87
103
  expect(postResults.some((r) => r.entityId === created.data.id)).toBe(true);
88
104
  });
89
105
 
106
+ test("the registered job handler resolves a systemUser-scoped fan-out run and actually indexes (not reindexEntityJob called directly)", async () => {
107
+ // The two direct-call tests above bypass jobRunner.dispatch()'s
108
+ // ctx-resolution/guard chain entirely — a bug there (e.g. a systemUser
109
+ // that isn't wired through) would be invisible to them. Invoke the
110
+ // ACTUAL registered handler, same as retention-cleanup's handler test.
111
+ const executor = createEventStoreExecutor(widgetTable, widgetEntity, { entityName: "widget" });
112
+ const tenantDb = createTenantDb(stack.db, admin.tenantId, "system");
113
+ const created = await executor.create({ name: "Registry-Resolved Widget" }, admin, tenantDb);
114
+ if (!created.isSuccess) throw new Error("seed failed");
115
+
116
+ const preResults = await stack.search.search(admin.tenantId, "registry-resolved", {
117
+ filterType: "widget",
118
+ });
119
+ expect(preResults).toHaveLength(0);
120
+
121
+ const job = stack.registry.getJob(REINDEX_ENTITY_JOB);
122
+ expect(job).toBeDefined();
123
+ if (!job) return;
124
+
125
+ const ctx: JobContext = {
126
+ db: stack.db,
127
+ registry: stack.registry,
128
+ searchAdapter: stack.search,
129
+ systemUser: admin,
130
+ log: noopLogger,
131
+ triggeredBy: null,
132
+ ...bridgeStub(),
133
+ };
134
+ await job.handler({ entity: "widget" }, ctx);
135
+
136
+ const postResults = await stack.search.search(admin.tenantId, "registry-resolved", {
137
+ filterType: "widget",
138
+ });
139
+ expect(postResults.some((r) => r.entityId === created.data.id)).toBe(true);
140
+ });
141
+
90
142
  test("skips silently when no tenant is resolvable (fan-out misfire)", async () => {
91
143
  await expect(
92
144
  reindexEntityJob(
@@ -21,7 +21,7 @@ export const detailQuery = defineQueryHandler({
21
21
 
22
22
  // payload is stored encrypted under the triggering user's DEK (#799).
23
23
  if (typeof row["payload"] === "string") {
24
- row["payload"] = await decryptStoredPii(row["payload"], "job-run-detail");
24
+ row["payload"] = await decryptStoredPii(row["payload"], "payload", "job-run-detail");
25
25
  }
26
26
 
27
27
  const logs = await selectMany(
@@ -6,7 +6,7 @@ import { jobRunsTable } from "../job-run-table";
6
6
 
7
7
  async function decryptRunPayload<T extends Record<string, unknown>>(row: T): Promise<T> {
8
8
  if (typeof row["payload"] !== "string") return row;
9
- return { ...row, payload: await decryptStoredPii(row["payload"], "job-runs") };
9
+ return { ...row, payload: await decryptStoredPii(row["payload"], "payload", "job-runs") };
10
10
  }
11
11
 
12
12
  export const listQuery = defineQueryHandler({
@@ -0,0 +1,42 @@
1
+ import { beforeEach, describe, expect, test } from "bun:test";
2
+ import type { EmailMessage } from "@cosmicdrift/kumiko-bundled-features/channel-email";
3
+ import type { MailTransportContext, MailTransportPlugin } from "../feature";
4
+
5
+ export type MailTransportContractFixture = {
6
+ readonly plugin: MailTransportPlugin;
7
+ readonly ctx: MailTransportContext;
8
+ readonly tenantId: string;
9
+ // Local providers (inmemory) expose readBack to assert real delivery;
10
+ // remote providers (smtp) omit it — send() needs a live server.
11
+ readonly readBack?: (tenantId: string) => readonly EmailMessage[];
12
+ };
13
+
14
+ export function describeMailTransportContract(
15
+ name: string,
16
+ factory: () => MailTransportContractFixture | Promise<MailTransportContractFixture>,
17
+ ): void {
18
+ describe(`${name} — MailTransportPlugin contract`, () => {
19
+ let fixture: MailTransportContractFixture;
20
+
21
+ beforeEach(async () => {
22
+ fixture = await factory();
23
+ });
24
+
25
+ test("build resolves to an EmailTransport with a send function", async () => {
26
+ const transport = await fixture.plugin.build(fixture.ctx, fixture.tenantId);
27
+ expect(typeof transport.send).toBe("function");
28
+ });
29
+
30
+ test("send delivers the message — verified via readBack", async () => {
31
+ if (!fixture.readBack) return;
32
+ const message: EmailMessage = {
33
+ to: "contract-recipient@example.test",
34
+ subject: "contract-test-subject",
35
+ html: "<p>contract-test-body</p>",
36
+ };
37
+ const transport = await fixture.plugin.build(fixture.ctx, fixture.tenantId);
38
+ await transport.send(message);
39
+ expect(fixture.readBack(fixture.tenantId)).toContainEqual(message);
40
+ });
41
+ });
42
+ }
@@ -1,8 +1,27 @@
1
1
  // feature.ts contract tests for mail-transport-inmemory.
2
2
 
3
3
  import { describe, expect, test } from "bun:test";
4
+ import { isMailTransportPlugin, type MailTransportPlugin } from "../../mail-foundation";
5
+ import { describeMailTransportContract } from "../../mail-foundation/__tests__/mail-transport-contract";
4
6
  import { clearInbox, getInbox, mailTransportInMemoryFeature } from "../feature";
5
7
 
8
+ function registeredPlugin(): MailTransportPlugin {
9
+ const usage = mailTransportInMemoryFeature.extensionUsages.find(
10
+ (u) => u.extensionName === "mailTransport" && u.entityName === "inmemory",
11
+ );
12
+ if (!usage || !isMailTransportPlugin(usage.options)) {
13
+ throw new Error("mail-transport-inmemory: plugin not registered under 'inmemory'");
14
+ }
15
+ return usage.options;
16
+ }
17
+
18
+ describeMailTransportContract("mail-transport-inmemory", () => ({
19
+ plugin: registeredPlugin(),
20
+ ctx: {},
21
+ tenantId: "contract-test-tenant",
22
+ readBack: getInbox,
23
+ }));
24
+
6
25
  describe("mailTransportInMemoryFeature — shape", () => {
7
26
  test("has the expected name", () => {
8
27
  expect(mailTransportInMemoryFeature.name).toBe("mail-transport-inmemory");
@@ -4,8 +4,68 @@
4
4
  // build-fn presence).
5
5
 
6
6
  import { describe, expect, test } from "bun:test";
7
+ import type { ConfigAccessor } from "@cosmicdrift/kumiko-framework/engine";
8
+ import type { SecretsContext } from "@cosmicdrift/kumiko-framework/secrets";
9
+ import { createSecret } from "@cosmicdrift/kumiko-framework/secrets";
10
+ import { isMailTransportPlugin, type MailTransportPlugin } from "../../mail-foundation";
11
+ import { describeMailTransportContract } from "../../mail-foundation/__tests__/mail-transport-contract";
7
12
  import { mailTransportSmtpFeature, SMTP_PASSWORD } from "../feature";
8
13
 
14
+ function registeredPlugin(): MailTransportPlugin {
15
+ const usage = mailTransportSmtpFeature.extensionUsages.find(
16
+ (u) => u.extensionName === "mailTransport" && u.entityName === "smtp",
17
+ );
18
+ if (!usage || !isMailTransportPlugin(usage.options)) {
19
+ throw new Error("mail-transport-smtp: plugin not registered under 'smtp'");
20
+ }
21
+ return usage.options;
22
+ }
23
+
24
+ function fakeConfig(
25
+ overrides: Partial<
26
+ Record<"host" | "port" | "secure" | "from" | "authUser", string | number | boolean>
27
+ > = {},
28
+ ): ConfigAccessor {
29
+ const keys = mailTransportSmtpFeature.exports.configKeys;
30
+ const values = new Map<string, string | number | boolean>([
31
+ [keys.host.name, overrides.host ?? "smtp.contract-test.invalid"],
32
+ [keys.port.name, overrides.port ?? 587],
33
+ [keys.secure.name, overrides.secure ?? false],
34
+ [keys.from.name, overrides.from ?? "noreply@contract-test.invalid"],
35
+ [keys.authUser.name, overrides.authUser ?? "contract-test-user"],
36
+ ]);
37
+ return (async (handle: string | { readonly name: string }) => {
38
+ const key = typeof handle === "string" ? handle : handle.name;
39
+ return values.get(key);
40
+ }) as ConfigAccessor;
41
+ }
42
+
43
+ function fakeSecrets(): SecretsContext {
44
+ return {
45
+ get: async () => createSecret("contract-test-password"),
46
+ has: async () => true,
47
+ set: async () => {},
48
+ delete: async () => true,
49
+ };
50
+ }
51
+
52
+ describeMailTransportContract("mail-transport-smtp", () => ({
53
+ plugin: registeredPlugin(),
54
+ ctx: { config: fakeConfig(), secrets: fakeSecrets(), _userId: "contract-test-user" },
55
+ tenantId: "contract-test-tenant",
56
+ }));
57
+
58
+ describe("mailTransportSmtpFeature — build error path", () => {
59
+ test("build rejects when a required config-key (host) is unset", async () => {
60
+ const ctx = {
61
+ config: fakeConfig({ host: "" }),
62
+ secrets: fakeSecrets(),
63
+ _userId: "contract-test-user",
64
+ };
65
+ await expect(registeredPlugin().build(ctx, "contract-test-tenant")).rejects.toThrow(/host/);
66
+ });
67
+ });
68
+
9
69
  describe("mailTransportSmtpFeature — shape", () => {
10
70
  test("has the expected name", () => {
11
71
  expect(mailTransportSmtpFeature.name).toBe("mail-transport-smtp");
@@ -21,7 +21,11 @@ export function createBrandingQuery(opts: { readonly allowCustomCss: boolean })
21
21
  access: { roles: ["anonymous", "User", "TenantAdmin", "SystemAdmin"] },
22
22
  handler: async (_query, ctx) => {
23
23
  const base = await readBranding(ctx.config);
24
- if (!opts.allowCustomCss || !ctx.config || !ctx.hasFeature(MANAGED_PAGES_CSS_FEATURE)) {
24
+ if (
25
+ !opts.allowCustomCss ||
26
+ !ctx.config ||
27
+ !(await ctx.hasFeature(MANAGED_PAGES_CSS_FEATURE))
28
+ ) {
25
29
  return base;
26
30
  }
27
31
  return { ...base, customCss: await readCustomCss(ctx.config) };
@@ -1,4 +1,6 @@
1
1
  import { describe, expect, test } from "bun:test";
2
+ import { PAT_TOKEN_PREFIX } from "@cosmicdrift/kumiko-framework/api";
3
+ import { EXT_TOKEN_VERIFIER } from "../../auth-foundation";
2
4
  import { createPersonalAccessTokensFeature } from "../feature";
3
5
 
4
6
  describe("createPersonalAccessTokensFeature toggleable-option (tier-gating)", () => {
@@ -22,3 +24,19 @@ describe("createPersonalAccessTokensFeature toggleable-option (tier-gating)", ()
22
24
  expect(feature.toggleableDefault).toBe(true);
23
25
  });
24
26
  });
27
+
28
+ describe("createPersonalAccessTokensFeature — tokenVerifier registration (#1369)", () => {
29
+ test('registers via r.useExtension(EXT_TOKEN_VERIFIER, "pat", ...) instead of a patResolver field', () => {
30
+ const feature = createPersonalAccessTokensFeature({ scopes: {} });
31
+ expect(feature.extensionUsages).toHaveLength(1);
32
+ const [usage] = feature.extensionUsages;
33
+ expect(usage?.extensionName).toBe(EXT_TOKEN_VERIFIER);
34
+ expect(usage?.entityName).toBe("pat");
35
+ expect(usage?.options).toMatchObject({ shape: { kind: "prefix", prefix: PAT_TOKEN_PREFIX } });
36
+ });
37
+
38
+ test("requires auth-foundation (owner of EXT_TOKEN_VERIFIER)", () => {
39
+ const feature = createPersonalAccessTokensFeature({ scopes: {} });
40
+ expect(feature.requires).toContain("auth-foundation");
41
+ });
42
+ });
@@ -1,9 +1,6 @@
1
1
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
2
  import { randomBytes } from "node:crypto";
3
- import {
4
- createInMemoryLoginRateLimiter,
5
- type PatResolver,
6
- } from "@cosmicdrift/kumiko-framework/api";
3
+ import { createInMemoryLoginRateLimiter } from "@cosmicdrift/kumiko-framework/api";
7
4
  import { selectMany, updateMany } from "@cosmicdrift/kumiko-framework/bun-db";
8
5
  import {
9
6
  configureBlindIndexKey,
@@ -25,6 +22,7 @@ import { createTestEnvelopeCipher, deleteRows } from "@cosmicdrift/kumiko-framew
25
22
  import { Temporal } from "temporal-polyfill";
26
23
  import { AuthHandlers } from "../../auth-email-password/constants";
27
24
  import { createAuthEmailPasswordFeature } from "../../auth-email-password/feature";
25
+ import { authFoundationFeature, resolveTokenVerifier } from "../../auth-foundation";
28
26
  import { createConfigFeature } from "../../config";
29
27
  import { createConfigResolver } from "../../config/resolver";
30
28
  import { configValuesTable } from "../../config/table";
@@ -39,7 +37,6 @@ import { createUserFeature } from "../../user/feature";
39
37
  import { userEntity } from "../../user/schema/user";
40
38
  import { PatHandlers, PatQueries } from "../constants";
41
39
  import { createPersonalAccessTokensFeature } from "../feature";
42
- import { createPatResolver } from "../resolver";
43
40
  import { apiTokenEntity, apiTokenTable } from "../schema/api-token";
44
41
  import type { PatScopeConfig } from "../scopes";
45
42
 
@@ -51,7 +48,6 @@ import type { PatScopeConfig } from "../scopes";
51
48
 
52
49
  let stack: TestStack;
53
50
  let h: ReturnType<typeof makeSessionHelpers>;
54
- let patResolver: PatResolver | undefined;
55
51
 
56
52
  const encryptionKey = randomBytes(32).toString("base64");
57
53
  const TENANT: TenantId = testTenantId(1);
@@ -91,21 +87,22 @@ beforeAll(async () => {
91
87
  createAuthEmailPasswordFeature(),
92
88
  createSessionsFeature(),
93
89
  createPersonalAccessTokensFeature({ scopes: SCOPES }),
90
+ authFoundationFeature,
94
91
  ],
95
92
  extraContext: { configResolver: resolver, configEncryption: encryption },
96
93
  authConfig: {
97
94
  membershipQuery: "tenant:query:memberships",
98
95
  loginHandler: AuthHandlers.login,
99
- patResolver: (raw: string) => {
100
- if (!patResolver) throw new Error("resolver not set");
101
- return patResolver(raw);
102
- },
96
+ // PAT registers itself as a tokenVerifier provider (auth-foundation)
97
+ // instead of the app wiring a dedicated patResolver — resolved
98
+ // generically here, same as run-prod-app/run-dev-app do.
99
+ tokenVerifier: (raw: string) =>
100
+ resolveTokenVerifier({ db: stack.db, registry: stack.registry }, raw),
103
101
  // Low per-token cap so the rate-limit test can exhaust it. Other tests do
104
102
  // ≤2 requests per (distinct) token, so this ceiling never trips them.
105
103
  patRateLimiter: createInMemoryLoginRateLimiter(3, 60_000),
106
104
  },
107
105
  });
108
- patResolver = createPatResolver({ db: stack.db, scopes: SCOPES });
109
106
  h = makeSessionHelpers(stack, TENANT);
110
107
 
111
108
  await unsafeCreateEntityTable(stack.db, userEntity);
@@ -1,3 +1,8 @@
1
+ import {
2
+ type AuthProviderPlugin,
3
+ EXT_TOKEN_VERIFIER,
4
+ } from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
5
+ import { PAT_TOKEN_PREFIX } from "@cosmicdrift/kumiko-framework/api";
1
6
  import { buildEntityTableMeta } from "@cosmicdrift/kumiko-framework/db";
2
7
  import { defineFeature, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
3
8
  import { PAT_DEFAULT_RATE_LIMIT, PAT_FEATURE, PAT_SCREEN_ID, type PatRateLimit } from "./constants";
@@ -6,6 +11,7 @@ import { createPatWrite } from "./handlers/create.write";
6
11
  import { listPatQuery } from "./handlers/list.query";
7
12
  import { revokePatWrite } from "./handlers/revoke.write";
8
13
  import { PAT_FEATURE_I18N } from "./i18n";
14
+ import { createPatResolver } from "./resolver";
9
15
  import { apiTokenEntity } from "./schema/api-token";
10
16
  import type { PatScopeConfig } from "./scopes";
11
17
 
@@ -25,14 +31,13 @@ export type PersonalAccessTokensOptions = {
25
31
  };
26
32
 
27
33
  export type PatFeatureExports = {
28
- readonly scopes: PatScopeConfig;
29
34
  readonly rateLimit: PatRateLimit;
30
35
  };
31
36
 
32
37
  // Personal Access Tokens — long-lived, revocable bearer credentials for the
33
38
  // HTTP API. Like `sessions`, the hot-path resolver is NOT a handler: it runs on
34
39
  // every PAT-authenticated request and does a direct-DB point-read (see
35
- // createPatResolver / run-prod-app wiring). The dispatcher-side handlers here
40
+ // createPatResolver / the tokenVerifier extension-point registration below). The dispatcher-side handlers here
36
41
  // only mint/list/revoke tokens and expose the scope catalog.
37
42
  export function createPersonalAccessTokensFeature(
38
43
  options: PersonalAccessTokensOptions,
@@ -40,7 +45,7 @@ export function createPersonalAccessTokensFeature(
40
45
  const { scopes } = options;
41
46
  return defineFeature(PAT_FEATURE, (r) => {
42
47
  r.describe(
43
- "Long-lived, revocable Personal Access Tokens for headless HTTP-API access. Stores SHA-256 token hashes in the `store_api_tokens` direct-write table; the plaintext is returned once at creation. `create`/`revoke`/`mine` manage a user's own tokens and `available-scopes` lists the app-declared scope catalog. Bearer tokens carrying the PAT prefix are resolved before jwt.verify (roles resolved live, granted scopes enforced fail-closed at the API boundary) — the resolver is wired via run-prod-app, not the dispatcher. Pass { toggleable: { default: false } } to tier-gate the whole feature.",
48
+ "Long-lived, revocable Personal Access Tokens for headless HTTP-API access. Stores SHA-256 token hashes in the `store_api_tokens` direct-write table; the plaintext is returned once at creation. `create`/`revoke`/`mine` manage a user's own tokens and `available-scopes` lists the app-declared scope catalog. Bearer tokens carrying the PAT prefix are resolved before jwt.verify (roles resolved live, granted scopes enforced fail-closed at the API boundary) — registered as an auth-foundation tokenVerifier provider, resolved generically by the middleware. Pass { toggleable: { default: false } } to tier-gate the whole feature.",
44
49
  );
45
50
  r.uiHints({ displayLabel: "Personal Access Tokens", category: "identity", recommended: false });
46
51
  // Opt-in tier-gating (mirrors ledger/tags): when set, the feature declares
@@ -49,7 +54,16 @@ export function createPersonalAccessTokensFeature(
49
54
  if (options.toggleable !== undefined) r.toggleable(options.toggleable);
50
55
  // Resolver reads memberships + users on every PAT request to build live
51
56
  // roles — make both boot-time deps so a mis-wiring fails validateBoot.
52
- r.requires("user", "tenant");
57
+ // auth-foundation owns EXT_TOKEN_VERIFIER, which the useExtension below
58
+ // registers against.
59
+ r.requires("user", "tenant", "auth-foundation");
60
+ // Self-registers as a bearer-auth provider instead of the app wiring a
61
+ // dedicated patResolver callback — the middleware finds it generically
62
+ // via resolveTokenVerifier(), shape-matched by the PAT token prefix.
63
+ r.useExtension(EXT_TOKEN_VERIFIER, "pat", {
64
+ shape: { kind: "prefix", prefix: PAT_TOKEN_PREFIX },
65
+ build: (deps) => createPatResolver({ db: deps.db, scopes }),
66
+ } satisfies AuthProviderPlugin);
53
67
  // Direct-write store like store_user_sessions: create/revoke write it, the
54
68
  // resolver point-reads it. r.entity would make it a rebuildable projection
55
69
  // whose replay (no token events) would wipe every live token (#498/#494).
@@ -78,12 +92,13 @@ export function createPersonalAccessTokensFeature(
78
92
  });
79
93
  r.translations({ keys: PAT_FEATURE_I18N });
80
94
 
81
- // scopes + rateLimit flow into feature.exports so run-prod-app builds the
82
- // resolver + limiter from the same declaration — single source of truth.
95
+ // rateLimit flows into feature.exports so run-prod-app builds the
96
+ // limiter from the same declaration — single source of truth. `scopes`
97
+ // is closed over directly by the useExtension build() above and by
98
+ // available-scopes, so it doesn't need to round-trip through exports.
83
99
  return {
84
100
  handlers,
85
101
  queries,
86
- scopes,
87
102
  rateLimit: options.rateLimit ?? PAT_DEFAULT_RATE_LIMIT,
88
103
  } satisfies { handlers: unknown; queries: unknown } & PatFeatureExports;
89
104
  });