@cosmicdrift/kumiko-bundled-features 0.165.0 → 2.0.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 (152) hide show
  1. package/package.json +10 -6
  2. package/src/__tests__/access-invalidation-consumer.integration.test.ts +279 -0
  3. package/src/__tests__/access-invalidation-stream.integration.test.ts +217 -0
  4. package/src/audit/__tests__/audit.integration.test.ts +50 -21
  5. package/src/audit/handlers/list.query.ts +1 -0
  6. package/src/auth-email-password/__tests__/auth.integration.test.ts +6 -0
  7. package/src/auth-email-password/__tests__/invite-flow.integration.test.ts +34 -0
  8. package/src/auth-email-password/__tests__/self-registration-toggle.integration.test.ts +9 -14
  9. package/src/auth-email-password/__tests__/session-callbacks.integration.test.ts +16 -5
  10. package/src/auth-email-password/__tests__/signup-flow.integration.test.ts +54 -0
  11. package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +2 -1
  12. package/src/auth-email-password/handlers/invite-signup-complete.write.ts +2 -1
  13. package/src/auth-email-password/handlers/login.write.ts +8 -0
  14. package/src/auth-email-password/handlers/self-registration-status.query.ts +6 -3
  15. package/src/auth-email-password/handlers/signup-request.write.ts +12 -1
  16. package/src/auth-email-password/i18n.ts +2 -0
  17. package/src/auth-email-password/seeding.ts +41 -23
  18. package/src/auth-email-password/web/__tests__/auth-client.test.ts +13 -0
  19. package/src/auth-email-password/web/__tests__/auth-gate.test.tsx +55 -8
  20. package/src/auth-email-password/web/__tests__/client-plugin.test.tsx +34 -0
  21. package/src/auth-email-password/web/__tests__/confirm-account-unlock-screen.test.tsx +8 -1
  22. package/src/auth-email-password/web/__tests__/forgot-password-screen.test.tsx +5 -1
  23. package/src/auth-email-password/web/__tests__/invite-accept-screen.test.tsx +3 -0
  24. package/src/auth-email-password/web/__tests__/request-account-unlock-screen.test.tsx +8 -2
  25. package/src/auth-email-password/web/__tests__/reset-password-screen.test.tsx +5 -1
  26. package/src/auth-email-password/web/__tests__/verify-email-screen.test.tsx +5 -1
  27. package/src/auth-email-password/web/auth-client.ts +32 -10
  28. package/src/auth-email-password/web/auth-gate.tsx +17 -21
  29. package/src/auth-email-password/web/client-plugin.ts +15 -2
  30. package/src/auth-email-password/web/index.ts +5 -1
  31. package/src/auth-email-password/web/login-screen.tsx +7 -3
  32. package/src/auth-email-password/web/request-account-unlock-screen.tsx +1 -1
  33. package/src/auth-foundation/__tests__/session-store.test.ts +2 -4
  34. package/src/auth-foundation/__tests__/tenant-providers.test.ts +24 -0
  35. package/src/auth-foundation/__tests__/token-verifier.test.ts +36 -3
  36. package/src/auth-foundation/boot-checks.ts +74 -69
  37. package/src/auth-foundation/resolve-anonymous-access.ts +13 -5
  38. package/src/auth-foundation/types.ts +3 -3
  39. package/src/auth-mfa/__tests__/enable-confirm-preauth.integration.test.ts +61 -1
  40. package/src/auth-mfa/__tests__/no-redis.integration.test.ts +209 -0
  41. package/src/auth-mfa/__tests__/reencrypt-job.integration.test.ts +79 -1
  42. package/src/auth-mfa/handlers/disable.write.ts +16 -1
  43. package/src/auth-mfa/handlers/enable-confirm-preauth.write.ts +72 -42
  44. package/src/auth-mfa/handlers/enable-confirm.write.ts +23 -12
  45. package/src/auth-mfa/handlers/reencrypt.job.ts +26 -17
  46. package/src/auth-mfa/handlers/regenerate-recovery.write.ts +15 -1
  47. package/src/auth-mfa/handlers/verify.write.ts +28 -24
  48. package/src/auth-mfa/web/__tests__/mfa-client.test.ts +5 -1
  49. package/src/auth-mfa/web/__tests__/mfa-enable-screen.test.tsx +10 -1
  50. package/src/auth-mfa/web/__tests__/mfa-setup-preauth-screen.test.tsx +104 -1
  51. package/src/auth-mfa/web/__tests__/mfa-verify-screen.test.tsx +5 -1
  52. package/src/auth-mfa/web/i18n.ts +4 -0
  53. package/src/auth-mfa/web/mfa-client.ts +13 -2
  54. package/src/auth-mfa/web/mfa-enable-screen.tsx +1 -1
  55. package/src/auth-mfa/web/mfa-setup-preauth-screen.tsx +25 -2
  56. package/src/billing-foundation/__tests__/subscription-provider-contract.ts +36 -12
  57. package/src/config/__tests__/backing-secrets.integration.test.ts +4 -0
  58. package/src/config/__tests__/cascade.integration.test.ts +13 -2
  59. package/src/config/__tests__/pii-encrypted.integration.test.ts +6 -6
  60. package/src/config/__tests__/reencrypt-job-kek-rotation.integration.test.ts +78 -5
  61. package/src/config/db/queries/resolver.ts +3 -3
  62. package/src/config/handlers/reencrypt.job.ts +36 -24
  63. package/src/custom-fields/__tests__/custom-fields.integration.test.ts +14 -0
  64. package/src/document-ingest-foundation/__tests__/feature.test.ts +38 -0
  65. package/src/document-ingest-foundation/__tests__/pages.test.ts +37 -0
  66. package/src/document-ingest-foundation/entity.ts +10 -3
  67. package/src/document-ingest-foundation/feature.ts +26 -8
  68. package/src/document-ingest-foundation/index.ts +1 -0
  69. package/src/document-ingest-foundation/pages.ts +34 -0
  70. package/src/feature-toggles/__tests__/compose-tier-resolver.test.ts +114 -11
  71. package/src/feature-toggles/compose-tier-resolver.ts +77 -8
  72. package/src/feature-toggles/global-feature-state-table.ts +15 -12
  73. package/src/files-provider-s3/s3-provider.ts +12 -1
  74. package/src/inbound-mail-foundation/__tests__/inbound-mail-foundation.integration.test.ts +78 -1
  75. package/src/inbound-mail-foundation/__tests__/inbound-mail-provider-contract.ts +58 -40
  76. package/src/inbound-mail-foundation/__tests__/watch-supervisor.integration.test.ts +9 -1
  77. package/src/inbound-mail-foundation/handlers/ingest-message.write.ts +22 -12
  78. package/src/inbound-provider-imap/__tests__/imap-live.integration.test.ts +1 -1
  79. package/src/inbound-provider-imap/__tests__/plugin-mocked.test.ts +35 -8
  80. package/src/inbound-provider-inmemory/__tests__/feature.test.ts +37 -33
  81. package/src/jobs/__tests__/job-instant.test.ts +15 -0
  82. package/src/jobs/__tests__/reindex-entity-job.integration.test.ts +19 -9
  83. package/src/jobs/feature.ts +6 -5
  84. package/src/jobs/job-instant.ts +9 -0
  85. package/src/mail-foundation/__tests__/mail-transport-contract.ts +27 -11
  86. package/src/mail-transport-inmemory/__tests__/feature.test.ts +10 -6
  87. package/src/mail-transport-smtp/__tests__/feature.test.ts +10 -5
  88. package/src/page-render/__tests__/markdown.test.ts +29 -0
  89. package/src/page-render/markdown.ts +1 -9
  90. package/src/personal-access-tokens/feature.ts +1 -1
  91. package/src/sessions/__tests__/rebuild-survival.integration.test.ts +3 -1
  92. package/src/sessions/__tests__/sessions-screens.boot.test.ts +5 -10
  93. package/src/sessions/__tests__/sessions.integration.test.ts +72 -1
  94. package/src/sessions/feature.ts +12 -0
  95. package/src/sessions/handlers/revoke-all-for-user.write.ts +51 -12
  96. package/src/sessions/handlers/revoke-all-others.write.ts +19 -1
  97. package/src/sessions/handlers/revoke.write.ts +13 -0
  98. package/src/sessions/index.ts +7 -0
  99. package/src/sessions/session-callbacks.ts +58 -5
  100. package/src/sessions/session-revoked-event.ts +29 -0
  101. package/src/shared/__tests__/chunked-entity-migration.test.ts +4 -4
  102. package/src/shared/chunked-entity-migration.ts +2 -2
  103. package/src/shared/grace-period.test.ts +26 -0
  104. package/src/shared/grace-period.ts +20 -0
  105. package/src/shared/index.ts +2 -0
  106. package/src/shared/map-with-concurrency.test.ts +104 -0
  107. package/src/shared/map-with-concurrency.ts +38 -0
  108. package/src/subscription-mollie/__tests__/feature.test.ts +16 -6
  109. package/src/subscription-mollie/__tests__/verify-webhook.test.ts +21 -0
  110. package/src/subscription-mollie/verify-webhook.ts +1 -0
  111. package/src/subscription-stripe/__tests__/feature.test.ts +14 -7
  112. package/src/subscription-stripe/__tests__/verify-webhook.test.ts +20 -0
  113. package/src/subscription-stripe/verify-webhook.ts +3 -2
  114. package/src/tenant/__tests__/members-query-kms.integration.test.ts +2 -2
  115. package/src/tenant/__tests__/multi-tenant.integration.test.ts +1 -1
  116. package/src/tenant/handlers/active-tenant-ids.query.ts +8 -2
  117. package/src/tenant/handlers/invitations.query.ts +13 -15
  118. package/src/tenant/handlers/members.query.ts +12 -12
  119. package/src/tenant/seeding.ts +110 -16
  120. package/src/tenant-lifecycle/boot-checks.ts +11 -4
  121. package/src/tenant-lifecycle/handlers/cancel-destruction.write.ts +4 -6
  122. package/src/tenant-settings/__tests__/tenant-settings.integration.test.ts +115 -0
  123. package/src/tenant-settings/config.ts +44 -0
  124. package/src/tenant-settings/constants.ts +16 -0
  125. package/src/tenant-settings/feature.ts +49 -0
  126. package/src/tenant-settings/index.ts +8 -0
  127. package/src/tenant-settings/tenant-defaults.ts +67 -0
  128. package/src/user/handlers/find-for-auth.query.ts +1 -1
  129. package/src/user/seeding.ts +8 -0
  130. package/src/user-data-rights/COMPLIANCE.md +1 -1
  131. package/src/user-data-rights/__tests__/audit-log.integration.test.ts +65 -0
  132. package/src/user-data-rights/__tests__/boot-checks.test.ts +15 -17
  133. package/src/user-data-rights/__tests__/download-by-token-resolver-trust.integration.test.ts +9 -5
  134. package/src/user-data-rights/__tests__/download.integration.test.ts +12 -41
  135. package/src/user-data-rights/__tests__/inspector-screens.boot.test.ts +7 -10
  136. package/src/user-data-rights/__tests__/mail-default-bridge.integration.test.ts +15 -1
  137. package/src/user-data-rights/__tests__/restriction-flow.integration.test.ts +120 -0
  138. package/src/user-data-rights/__tests__/run-export-jobs.integration.test.ts +9 -4
  139. package/src/user-data-rights/__tests__/run-forget-cleanup.integration.test.ts +74 -8
  140. package/src/user-data-rights/db/queries/__tests__/forget-cleanup.integration.test.ts +69 -0
  141. package/src/user-data-rights/db/queries/forget-cleanup.ts +9 -2
  142. package/src/user-data-rights/feature.ts +45 -45
  143. package/src/user-data-rights/handlers/cancel-deletion.write.ts +8 -11
  144. package/src/user-data-rights/handlers/download-by-job.query.ts +6 -6
  145. package/src/user-data-rights/handlers/download-by-token.query.ts +6 -6
  146. package/src/user-data-rights/handlers/lift-restriction.write.ts +33 -2
  147. package/src/user-data-rights/handlers/list-download-attempts.query.ts +1 -0
  148. package/src/user-data-rights/handlers/my-audit-log.query.ts +1 -0
  149. package/src/user-data-rights/handlers/restrict-account.write.ts +33 -4
  150. package/src/user-data-rights/handlers/run-forget-cleanup.write.ts +2 -0
  151. package/src/user-data-rights/lib/is-admin-actor.ts +19 -0
  152. package/src/user-data-rights/run-forget-cleanup.ts +27 -7
@@ -235,20 +235,30 @@ export const ingestMessageHandler: WriteHandlerDef = {
235
235
  // on the same thread (Watch-Push vs. Poll-Reconciliation overlap,
236
236
  // or two different messages of the same thread arriving at once)
237
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.
238
+ // tryAppendEvent's fresh version-read at append time makes the
239
+ // loser's append fail with a VersionConflictError ONLY when the
240
+ // other TX's commit lands between this reload and the append's own
241
+ // version check — so we retry: reload the thread stream, recompute
242
+ // the live row-count and lastMessageAt, and append again. If the
243
+ // other TX instead commits in the gap between the countWhere below
244
+ // and the append's version-read, the version check sees the
245
+ // already-bumped stream and appends without conflict — but with the
246
+ // stale row-count this countWhere captured before the concurrent
247
+ // insert. That write still lands (messageCount can undercount by
248
+ // one round), it is not eliminated by this retry loop; the next
249
+ // ingest on the thread re-counts and self-corrects. Unlike step 4,
250
+ // a lost version-conflict race here must NOT be treated as a no-op
251
+ // duplicate — skipping would leave this message's contribution to
252
+ // messageCount/lastMessageAt out of the thread forever. Exhausting
253
+ // all attempts throws, rolling back the whole TX (including the
254
+ // step-4 message append) for a clean re-ingest on the next poll —
255
+ // never return success with a stale snapshot.
248
256
  //
249
257
  // 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.
258
+ // advisory lock on threadAggId) would close the countWhere→append
259
+ // gap above and is deliberately not part of this fix — two writers
260
+ // (watch + poll) make self-correcting drift an acceptable trade for
261
+ // now.
252
262
  // ---------------------------------------------------------------
253
263
  const threadPlainPii = { tenantId, subject: payload.subject };
254
264
  const encryptedThreadFields = piiKms
@@ -202,5 +202,5 @@ describeInboundMailProviderContract(
202
202
  account,
203
203
  seed: (subject) => sendTestMail(subject, "contract seed"),
204
204
  }),
205
- { skip: !available },
205
+ { skip: !available, hasWatch: Boolean(imapInboundMailPlugin.watch), timeout: 20_000 },
206
206
  );
@@ -2,7 +2,7 @@
2
2
  // Greenmail suites skip in CI when the container is down — this file mocks
3
3
  // imapflow so the plugin body stays on the coverage badge without Docker.
4
4
 
5
- import { beforeEach, describe, expect, mock, test } from "bun:test";
5
+ import { afterAll, beforeEach, describe, expect, mock, test } from "bun:test";
6
6
  import { EventEmitter } from "node:events";
7
7
  import { createSecret } from "@cosmicdrift/kumiko-framework/secrets";
8
8
  import {
@@ -110,17 +110,27 @@ class FakeImapFlow extends EventEmitter {
110
110
  }
111
111
  }
112
112
 
113
+ // Restore hygiene: Bun runs a test-run's files in one process, so a
114
+ // top-level mock.module leaks into every file that runs after this one. The
115
+ // real module is captured before mocking so afterAll can put it back — a
116
+ // later file that needs the real imapflow (or a filtered/reordered run via
117
+ // `-t`) would otherwise silently see FakeImapFlow instead.
118
+ const realImapflow = await import("imapflow");
113
119
  mock.module("imapflow", () => ({ ImapFlow: FakeImapFlow }));
114
120
 
115
121
  const { imapInboundMailPlugin } = await import("../feature");
116
122
 
123
+ afterAll(() => {
124
+ mock.module("imapflow", () => realImapflow);
125
+ });
126
+
117
127
  function mime(subject: string, text: string): Buffer {
118
128
  return Buffer.from(
119
129
  [
120
130
  "From: Sender <sender@example.com>",
121
131
  "To: inbox@example.com",
122
132
  `Subject: ${subject}`,
123
- "Message-ID: <msg-1@example.com>",
133
+ `Message-ID: <${subject}-@example.com>`,
124
134
  "Date: Mon, 01 Jan 2024 12:00:00 +0000",
125
135
  "MIME-Version: 1.0",
126
136
  "Content-Type: text/plain; charset=utf-8",
@@ -142,6 +152,13 @@ const account: MailAccountRecord = {
142
152
  watchState: "idle",
143
153
  };
144
154
 
155
+ async function waitFor(predicate: () => boolean, timeoutMs = 2000): Promise<void> {
156
+ const t0 = Date.now();
157
+ while (!predicate() && Date.now() - t0 < timeoutMs) {
158
+ await Bun.sleep(5);
159
+ }
160
+ }
161
+
145
162
  function ctxWithDoc(doc: string | null): InboundMailContext {
146
163
  return {
147
164
  _userId: "imap-plugin-mocked",
@@ -227,6 +244,11 @@ describe("imapInboundMailPlugin — mocked imapflow", () => {
227
244
  expect(result.messages[0]?.subject).toBe("one");
228
245
  expect(result.nextCursor).toEqual({ uidValidity: "17", lastUid: 2 });
229
246
  expect(result.hasMore).toBe(false);
247
+ // Fixtures must carry distinct Message-ID headers — colliding fixtures
248
+ // would mask a mapping bug where toRawInboundMessage produces a
249
+ // wrong/constant messageIdHeader per batch (used for the References/
250
+ // threading chain and the self-heal dedup fallback).
251
+ expect(new Set(result.messages.map((m) => m.messageIdHeader)).size).toBe(2);
230
252
  });
231
253
 
232
254
  test("fetch: incremental cursor filters uid <= lastUid + hasMore via maxMessages", async () => {
@@ -254,10 +276,11 @@ describe("imapInboundMailPlugin — mocked imapflow", () => {
254
276
  { uidValidity: "17", lastUid: 1 },
255
277
  { backfillWindowDays: 7, maxMessages: 2 },
256
278
  );
257
- // search returns 2,3,4 (> lastUid=1); maxMessages=2 → hasMore
258
- expect(result.messages.length).toBeLessThanOrEqual(2);
279
+ // search returns 2,3,4 (> lastUid=1); maxMessages=2 → hasMore, uid 1
280
+ // filtered out and only the first 2 of the 3 remaining survive the cap.
281
+ expect(result.messages.map((m) => m.subject)).toEqual(["two", "three"]);
259
282
  expect(result.hasMore).toBe(true);
260
- expect(result.nextCursor["uidValidity"]).toBe("17");
283
+ expect(result.nextCursor).toEqual({ uidValidity: "17", lastUid: 3 });
261
284
  });
262
285
 
263
286
  test("fetch: UIDVALIDITY change → InboundCursorInvalidError", async () => {
@@ -307,7 +330,9 @@ describe("imapInboundMailPlugin — mocked imapflow", () => {
307
330
  ];
308
331
  lastIdleClient?.emit("exists");
309
332
 
310
- await Bun.sleep(50);
333
+ // drainNew() does MIME-parsing before onMessages fires — a fixed sleep
334
+ // flakes on a loaded CI runner; poll instead.
335
+ await waitFor(() => received.some((batch) => batch.some((m) => m.subject === "pushed")));
311
336
  expect(received.some((batch) => batch.some((m) => m.subject === "pushed"))).toBe(true);
312
337
 
313
338
  await stop();
@@ -322,9 +347,11 @@ describe("imapInboundMailPlugin — mocked imapflow", () => {
322
347
  },
323
348
  });
324
349
  lastIdleClient?.emit("error", new Error("socket hang up"));
325
- await Bun.sleep(20);
350
+ await waitFor(() => errors >= 1);
326
351
  lastIdleClient?.emit("error", new Error("second"));
327
- await Bun.sleep(20);
352
+ // Confirms onError stays unsubscribed after the first error — polls the
353
+ // same bounded window rather than betting on a fixed sleep outlasting it.
354
+ await waitFor(() => errors >= 2, 100);
328
355
  expect(errors).toBe(1);
329
356
  await stop().catch(() => {});
330
357
  });
@@ -13,36 +13,40 @@ describe("inboundProviderInMemoryFeature — shape", () => {
13
13
  });
14
14
  });
15
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
- });
16
+ describeInboundMailProviderContract(
17
+ "inmemory",
18
+ () => {
19
+ const accountId = crypto.randomUUID();
20
+ return {
21
+ plugin: inMemoryInboundMailPlugin,
22
+ ctx: {},
23
+ account: {
24
+ id: accountId,
25
+ tenantId: "00000000-0000-4000-8000-000000004242",
26
+ provider: "inmemory",
27
+ authMethod: "password",
28
+ ownerUserId: null,
29
+ address: "contract@example.com",
30
+ displayName: "Contract-Test",
31
+ status: "active",
32
+ watchState: "idle",
33
+ },
34
+ seed: (subject) =>
35
+ seedInboundMessage(accountId, {
36
+ providerMessageId: crypto.randomUUID(),
37
+ messageIdHeader: null,
38
+ providerThreadId: null,
39
+ references: [],
40
+ from: "sender@example.com",
41
+ to: ["contract@example.com"],
42
+ cc: [],
43
+ subject,
44
+ snippet: subject,
45
+ receivedAtIso: "2026-07-01T10:00:00Z",
46
+ rawMime: null,
47
+ scope: "inbox",
48
+ }),
49
+ };
50
+ },
51
+ { hasWatch: Boolean(inMemoryInboundMailPlugin.watch) },
52
+ );
@@ -0,0 +1,15 @@
1
+ // kumiko-framework#1525: parseJobInstant is the extracted seam for the five
2
+ // value-position Temporal.Instant.from call sites inside feature.ts's
3
+ // defineApply callbacks (no exported seam there) — must not rely on
4
+ // globalThis.Temporal.
5
+
6
+ import { describe, expect, test } from "bun:test";
7
+ import { withoutAmbientTemporal } from "@cosmicdrift/kumiko-framework/testing";
8
+ import { parseJobInstant } from "../job-instant";
9
+
10
+ describe("parseJobInstant — kumiko-framework#1525: no ambient Temporal global", () => {
11
+ test("parses an ISO instant without relying on globalThis.Temporal", async () => {
12
+ const instant = await withoutAmbientTemporal(() => parseJobInstant("2026-01-01T00:00:00Z"));
13
+ expect(instant.toString()).toBe("2026-01-01T00:00:00Z");
14
+ });
15
+ });
@@ -92,10 +92,16 @@ describe("reindexEntityJob", () => {
92
92
  });
93
93
  expect(preResults).toHaveLength(0);
94
94
 
95
- await reindexEntityJob(
96
- { entity: "widget" },
97
- { db: stack.db, registry: stack.registry, searchAdapter: stack.search, systemUser: admin },
98
- );
95
+ const ctx: JobContext = {
96
+ db: stack.db,
97
+ registry: stack.registry,
98
+ searchAdapter: stack.search,
99
+ systemUser: admin,
100
+ log: noopLogger,
101
+ triggeredBy: null,
102
+ ...bridgeStub(),
103
+ };
104
+ await reindexEntityJob({ entity: "widget" }, ctx);
99
105
 
100
106
  const postResults = await stack.search.search(admin.tenantId, "backfillable", {
101
107
  filterType: "widget",
@@ -103,11 +109,15 @@ describe("reindexEntityJob", () => {
103
109
  expect(postResults.some((r) => r.entityId === created.data.id)).toBe(true);
104
110
  });
105
111
 
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.
112
+ test("the job is registered under REINDEX_ENTITY_JOB and its handler indexes", async () => {
113
+ // Honest scope: this still hand-builds JobContext (same as
114
+ // retention-cleanup's handler test) rather than going through
115
+ // jobRunner.dispatch() — it does NOT cover the dispatch chain's own
116
+ // ctx-resolution/guard logic, only that registry.getJob(REINDEX_ENTITY_JOB)
117
+ // resolves to a handler and that handler indexes correctly when called
118
+ // with a well-formed ctx. A bug in dispatch()'s ctx-resolution itself
119
+ // (e.g. a systemUser that isn't wired through) would be invisible here,
120
+ // same as in the two direct-call tests above.
111
121
  const executor = createEventStoreExecutor(widgetTable, widgetEntity, { entityName: "widget" });
112
122
  const tenantDb = createTenantDb(stack.db, admin.tenantId, "system");
113
123
  const created = await executor.create({ name: "Registry-Resolved Widget" }, admin, tenantDb);
@@ -21,6 +21,7 @@ import { reindexEntityJob, reindexEntityPayloadSchema } from "./handlers/reindex
21
21
  import { retryWrite } from "./handlers/retry.write";
22
22
  import { triggerWrite } from "./handlers/trigger.write";
23
23
  import { JOBS_I18N } from "./i18n";
24
+ import { parseJobInstant } from "./job-instant";
24
25
  import {
25
26
  JOB_RUN_COMPLETED_EVENT,
26
27
  JOB_RUN_FAILED_EVENT,
@@ -78,7 +79,7 @@ export function createJobsFeature(): FeatureDefinition {
78
79
  status: p.status,
79
80
  payload: p.payload,
80
81
  attempt: p.attempt,
81
- startedAt: Temporal.Instant.from(p.startedAt),
82
+ startedAt: parseJobInstant(p.startedAt),
82
83
  triggeredById: p.triggeredById,
83
84
  });
84
85
  },
@@ -92,7 +93,7 @@ export function createJobsFeature(): FeatureDefinition {
92
93
  {
93
94
  status: "completed",
94
95
  duration: p.duration,
95
- finishedAt: Temporal.Instant.from(p.finishedAt),
96
+ finishedAt: parseJobInstant(p.finishedAt),
96
97
  version: event.version,
97
98
  modifiedAt: event.createdAt,
98
99
  modifiedById: event.metadata?.userId ?? "system",
@@ -111,7 +112,7 @@ export function createJobsFeature(): FeatureDefinition {
111
112
  status: "failed",
112
113
  error: p.error,
113
114
  duration: p.duration,
114
- finishedAt: Temporal.Instant.from(p.finishedAt),
115
+ finishedAt: parseJobInstant(p.finishedAt),
115
116
  version: event.version,
116
117
  modifiedAt: event.createdAt,
117
118
  modifiedById: event.metadata?.userId ?? "system",
@@ -145,7 +146,7 @@ export function createJobsFeature(): FeatureDefinition {
145
146
  runId: event.aggregateId,
146
147
  level: log.level,
147
148
  message: log.message,
148
- timestamp: Temporal.Instant.from(log.timestamp),
149
+ timestamp: parseJobInstant(log.timestamp),
149
150
  })),
150
151
  );
151
152
  },
@@ -161,7 +162,7 @@ export function createJobsFeature(): FeatureDefinition {
161
162
  runId: event.aggregateId,
162
163
  level: log.level,
163
164
  message: log.message,
164
- timestamp: Temporal.Instant.from(log.timestamp),
165
+ timestamp: parseJobInstant(log.timestamp),
165
166
  })),
166
167
  );
167
168
  }),
@@ -0,0 +1,9 @@
1
+ import { Temporal } from "temporal-polyfill";
2
+
3
+ // kumiko-framework#1525: the five value-position Temporal.Instant.from
4
+ // call sites in feature.ts live inside defineApply callbacks with no
5
+ // exported seam, so this thin wrapper gives the ambient-global-independence
6
+ // regression test something to import directly.
7
+ export function parseJobInstant(iso: string): Temporal.Instant {
8
+ return Temporal.Instant.from(iso);
9
+ }
@@ -11,9 +11,18 @@ export type MailTransportContractFixture = {
11
11
  readonly readBack?: (tenantId: string) => readonly EmailMessage[];
12
12
  };
13
13
 
14
+ export type MailTransportContractOptions = {
15
+ // Local providers (inmemory) can assert real delivery via readBack —
16
+ // remote providers (smtp) can't (send() needs a live server), so their
17
+ // delivery test must show as an explicit `skip` in test output, not a
18
+ // silently-passing green with zero assertions (#1334).
19
+ readonly verifiesDelivery: boolean;
20
+ };
21
+
14
22
  export function describeMailTransportContract(
15
23
  name: string,
16
24
  factory: () => MailTransportContractFixture | Promise<MailTransportContractFixture>,
25
+ options: MailTransportContractOptions,
17
26
  ): void {
18
27
  describe(`${name} — MailTransportPlugin contract`, () => {
19
28
  let fixture: MailTransportContractFixture;
@@ -27,16 +36,23 @@ export function describeMailTransportContract(
27
36
  expect(typeof transport.send).toBe("function");
28
37
  });
29
38
 
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
- });
39
+ (options.verifiesDelivery ? test : test.skip)(
40
+ "send delivers the message — verified via readBack",
41
+ async () => {
42
+ if (!fixture.readBack) {
43
+ throw new Error(
44
+ "describeMailTransportContract: verifiesDelivery:true but the fixture has no readBack",
45
+ );
46
+ }
47
+ const message: EmailMessage = {
48
+ to: "contract-recipient@example.test",
49
+ subject: "contract-test-subject",
50
+ html: "<p>contract-test-body</p>",
51
+ };
52
+ const transport = await fixture.plugin.build(fixture.ctx, fixture.tenantId);
53
+ await transport.send(message);
54
+ expect(fixture.readBack(fixture.tenantId)).toContainEqual(message);
55
+ },
56
+ );
41
57
  });
42
58
  }
@@ -15,12 +15,16 @@ function registeredPlugin(): MailTransportPlugin {
15
15
  return usage.options;
16
16
  }
17
17
 
18
- describeMailTransportContract("mail-transport-inmemory", () => ({
19
- plugin: registeredPlugin(),
20
- ctx: {},
21
- tenantId: "contract-test-tenant",
22
- readBack: getInbox,
23
- }));
18
+ describeMailTransportContract(
19
+ "mail-transport-inmemory",
20
+ () => ({
21
+ plugin: registeredPlugin(),
22
+ ctx: {},
23
+ tenantId: "contract-test-tenant",
24
+ readBack: getInbox,
25
+ }),
26
+ { verifiesDelivery: true },
27
+ );
24
28
 
25
29
  describe("mailTransportInMemoryFeature — shape", () => {
26
30
  test("has the expected name", () => {
@@ -49,11 +49,16 @@ function fakeSecrets(): SecretsContext {
49
49
  };
50
50
  }
51
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
- }));
52
+ describeMailTransportContract(
53
+ "mail-transport-smtp",
54
+ () => ({
55
+ plugin: registeredPlugin(),
56
+ ctx: { config: fakeConfig(), secrets: fakeSecrets(), _userId: "contract-test-user" },
57
+ tenantId: "contract-test-tenant",
58
+ }),
59
+ // send() needs a live SMTP server — can't assert real delivery here.
60
+ { verifiesDelivery: false },
61
+ );
57
62
 
58
63
  describe("mailTransportSmtpFeature — build error path", () => {
59
64
  test("build rejects when a required config-key (host) is unset", async () => {
@@ -20,6 +20,35 @@ describe("renderSafeMarkdown — XSS-Härtung", () => {
20
20
  expect(html).toContain('href="#"');
21
21
  });
22
22
 
23
+ // kumiko-framework#1522/#1523: CommonMark allows a tab inside a
24
+ // <...>-bracketed link destination; `.trim()` only strips leading/
25
+ // trailing whitespace, so "java\tscript:" survived isSafeHref's scheme
26
+ // regex (the tab breaks the character class before ":", the check falls
27
+ // through to `return true`) and the browser normalizes it back to
28
+ // "javascript:" on click. Mirrors xss-safety.test.tsx's UNSAFE_HREFS.
29
+ test("java\\tscript:-Link-href (tab-obfuscated scheme via <...> destination) wird neutralisiert", () => {
30
+ const html = renderSafeMarkdown("[klick](<java\tscript:alert(1)>)");
31
+ expect(html.toLowerCase()).not.toContain("javascript:");
32
+ expect(html).toContain('href="#"');
33
+ });
34
+
35
+ // kumiko-framework#1545: HTML character references decode client-side
36
+ // (the browser parses them before the URL parser ever sees the value),
37
+ // so "javascript&colon;", "java&Tab;script:" and "java&#9;script:" all
38
+ // normalize to an executable javascript: URL on click even though none
39
+ // contains a literal ":" for isSafeHref's pre-decode scheme check.
40
+ test("HTML-entity-obfuscated javascript: hrefs are neutralized", () => {
41
+ for (const href of [
42
+ "javascript&colon;alert(1)",
43
+ "java&Tab;script:alert(1)",
44
+ "java&#9;script:alert(1)",
45
+ ]) {
46
+ const html = renderSafeMarkdown(`[klick](<${href}>)`);
47
+ expect(html.toLowerCase()).not.toContain("javascript:");
48
+ expect(html).toContain('href="#"');
49
+ }
50
+ });
51
+
23
52
  test("data:-Image-src wird neutralisiert", () => {
24
53
  const html = renderSafeMarkdown("![x](data:text/html;base64,PHNjcmlwdD4=)");
25
54
  expect(html.toLowerCase()).not.toContain("data:");
@@ -1,4 +1,4 @@
1
- import { escapeHtml } from "@cosmicdrift/kumiko-headless";
1
+ import { escapeHtml, isSafeHref } from "@cosmicdrift/kumiko-headless";
2
2
  import { Marked } from "marked";
3
3
 
4
4
  // Geteilter, gehärteter Markdown→HTML-Kern für server-gerenderte Public-
@@ -24,14 +24,6 @@ safeRenderer.use({
24
24
  },
25
25
  });
26
26
 
27
- // http(s)/mailto oder schema-los (relativ/anchor) erlaubt; javascript:, data:,
28
- // vbscript: u.a. abgelehnt. Ein relativer href hat kein `scheme:`-Präfix.
29
- function isSafeHref(href: string): boolean {
30
- const trimmed = href.trim().toLowerCase();
31
- if (!/^[a-z][a-z0-9+.-]*:/.test(trimmed)) return true;
32
- return /^(?:https?|mailto):/.test(trimmed);
33
- }
34
-
35
27
  export function renderSafeMarkdown(markdown: string): string {
36
28
  // @cast-boundary marked.parse return-type ist `string | Promise<string>`;
37
29
  // `{ async: false }` garantiert sync (string) — Cast nur API-Vertragsfix.
@@ -47,7 +47,7 @@ export function createPersonalAccessTokensFeature(
47
47
  r.describe(
48
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.",
49
49
  );
50
- r.uiHints({ displayLabel: "Personal Access Tokens", category: "identity", recommended: true });
50
+ r.uiHints({ displayLabel: "Personal Access Tokens", category: "identity", recommended: false });
51
51
  // Opt-in tier-gating (mirrors ledger/tags): when set, the feature declares
52
52
  // itself r.toggleable so the dispatcher gate + tier-engine can switch PAT
53
53
  // on/off per tenant. { default: false } = fail-closed until a tier grants it.
@@ -57,7 +57,9 @@ beforeEach(async () => {
57
57
  // written directly on the hot path, then revoked — no events anywhere.
58
58
  const SID = "00000000-0000-0000-0000-000000000001";
59
59
 
60
- async function insertRevokedSession(db: DbConnection): Promise<void> {
60
+ async function insertRevokedSession(
61
+ db: DbConnection | ReturnType<typeof createTenantDb>,
62
+ ): Promise<void> {
61
63
  const now = Temporal.Now.instant();
62
64
  await seedRow(db, userSessionTable, {
63
65
  id: SID,
@@ -1,26 +1,21 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { defineFeature, validateBoot } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { validateBoot } from "@cosmicdrift/kumiko-framework/engine";
3
3
  import { rolesOf } from "@cosmicdrift/kumiko-framework/testing";
4
- import { EXT_SESSION_STORE } from "../../auth-foundation";
4
+ import { authFoundationFeature } from "../../auth-foundation";
5
5
  import { createConfigFeature } from "../../config/feature";
6
+ import { createPersonalAccessTokensFeature } from "../../personal-access-tokens";
6
7
  import { createTenantFeature } from "../../tenant";
7
8
  import { createUserFeature } from "../../user/feature";
8
9
  import { SESSION_DETAIL_SCREEN_ID, SESSION_LIST_SCREEN_ID } from "../constants";
9
10
  import { createSessionsFeature } from "../feature";
10
11
 
11
- // Stub instead of the real auth-foundation — this test is about screen/query
12
- // wiring, not auth; the real feature also mandates a tokenVerifier provider
13
- // which is out of scope here.
14
- const sessionStoreOwnerStub = defineFeature("auth-foundation", (r) => {
15
- r.extendsRegistrar(EXT_SESSION_STORE, {});
16
- });
17
-
18
12
  describe("sessions screens + query access alignment (kumiko-framework#255)", () => {
19
13
  const features = [
20
14
  createConfigFeature(),
21
15
  createUserFeature(),
22
16
  createTenantFeature(),
23
- sessionStoreOwnerStub,
17
+ authFoundationFeature,
18
+ createPersonalAccessTokensFeature({ scopes: {} }),
24
19
  createSessionsFeature(),
25
20
  ];
26
21