@cosmicdrift/kumiko-bundled-features 0.319.0 → 0.321.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 (91) hide show
  1. package/package.json +9 -9
  2. package/src/auth-email-password/__tests__/auth-tenants-rate-limit.integration.test.ts +3 -0
  3. package/src/auth-email-password/__tests__/post-auth-landing.integration.test.ts +321 -0
  4. package/src/auth-email-password/__tests__/public-routes-rate-limit.integration.test.ts +4 -0
  5. package/src/auth-email-password/__tests__/session-callbacks.integration.test.ts +4 -0
  6. package/src/auth-email-password/__tests__/signup-flow.integration.test.ts +21 -1
  7. package/src/auth-email-password/__tests__/signup-handover.integration.test.ts +10 -0
  8. package/src/auth-email-password/auth-paths.ts +10 -6
  9. package/src/auth-email-password/changes.json +12 -0
  10. package/src/auth-email-password/index.ts +1 -1
  11. package/src/auth-email-password/web/__tests__/auth-form-logic.test.ts +34 -1
  12. package/src/auth-email-password/web/__tests__/invite-accept-screen.test.tsx +30 -0
  13. package/src/auth-email-password/web/__tests__/signup-complete-screen.test.tsx +26 -0
  14. package/src/auth-email-password/web/auth-client.ts +16 -1
  15. package/src/auth-email-password/web/auth-form-logic.ts +10 -0
  16. package/src/auth-email-password/web/invite-accept-screen.tsx +10 -5
  17. package/src/auth-email-password/web/signup-complete-screen.tsx +11 -7
  18. package/src/auth-mfa/web/mfa-client.ts +18 -2
  19. package/src/billing-foundation/__tests__/billing-plans.integration.test.ts +27 -0
  20. package/src/billing-foundation/__tests__/checkout-core.test.ts +35 -0
  21. package/src/billing-foundation/__tests__/sync-subscription.integration.test.ts +469 -0
  22. package/src/billing-foundation/changes.json +14 -0
  23. package/src/billing-foundation/checkout-core.ts +8 -4
  24. package/src/billing-foundation/constants.ts +6 -0
  25. package/src/billing-foundation/feature.ts +37 -2
  26. package/src/billing-foundation/handlers/process-event.write.ts +112 -106
  27. package/src/billing-foundation/handlers/switch-plan.write.ts +7 -0
  28. package/src/billing-foundation/handlers/sync-subscription.write.ts +164 -0
  29. package/src/billing-foundation/i18n.ts +9 -0
  30. package/src/billing-foundation/index.ts +1 -0
  31. package/src/billing-foundation/plan-catalog.ts +22 -5
  32. package/src/billing-foundation/types.ts +27 -0
  33. package/src/billing-foundation/web/__tests__/billing-plans-panel.test.tsx +28 -0
  34. package/src/billing-foundation/web/billing-plans-panel.tsx +8 -1
  35. package/src/channel-email/__tests__/email-channel.test.ts +92 -0
  36. package/src/channel-email/__tests__/smtp-transport-pinning.test.ts +42 -0
  37. package/src/channel-email/changes.json +9 -1
  38. package/src/channel-email/email-channel.ts +31 -6
  39. package/src/channel-email/smtp-transport.ts +7 -0
  40. package/src/delivery/__tests__/delivery.integration.test.ts +101 -26
  41. package/src/delivery/changes.json +7 -0
  42. package/src/delivery/feature.ts +1 -1
  43. package/src/delivery/handlers/unsubscribe-address.write.ts +1 -1
  44. package/src/delivery/handlers/unsubscribe-user.write.ts +1 -1
  45. package/src/delivery/index.ts +2 -1
  46. package/src/delivery/public-names.ts +1 -1
  47. package/src/delivery/unsubscribe.ts +167 -89
  48. package/src/file-derivatives/feature.ts +1 -1
  49. package/src/file-derivatives/handlers/public-variant.query.ts +5 -7
  50. package/src/foundation-shared/__tests__/mail-host-policy.test.ts +68 -0
  51. package/src/foundation-shared/index.ts +9 -0
  52. package/src/foundation-shared/mail-host-policy.ts +70 -0
  53. package/src/inbound-provider-imap/__tests__/imap-foundation.integration.test.ts +11 -0
  54. package/src/inbound-provider-imap/__tests__/imap-live.integration.test.ts +14 -1
  55. package/src/inbound-provider-imap/__tests__/plugin-mocked.test.ts +88 -3
  56. package/src/inbound-provider-imap/changes.json +14 -1
  57. package/src/inbound-provider-imap/feature.ts +6 -3
  58. package/src/inbound-provider-imap/imap-client.ts +60 -2
  59. package/src/inbound-provider-imap/index.ts +1 -1
  60. package/src/ledger/__tests__/ledger.integration.test.ts +80 -0
  61. package/src/ledger/changes.json +9 -1
  62. package/src/ledger/entity.ts +14 -1
  63. package/src/mail-foundation/__tests__/mail-foundation.integration.test.ts +86 -1
  64. package/src/mail-transport-smtp/__tests__/feature.test.ts +123 -3
  65. package/src/mail-transport-smtp/changes.json +14 -1
  66. package/src/mail-transport-smtp/feature.ts +76 -1
  67. package/src/mail-transport-smtp/index.ts +6 -1
  68. package/src/personal-access-tokens/__tests__/pat.integration.test.ts +59 -0
  69. package/src/sessions/__tests__/sessions.integration.test.ts +5 -0
  70. package/src/shared/password-hashing.test.ts +21 -13
  71. package/src/step-dispatcher/__tests__/feature.boot.test.ts +9 -2
  72. package/src/step-dispatcher/__tests__/webhook-runner.test.ts +283 -0
  73. package/src/step-dispatcher/changes.json +21 -1
  74. package/src/step-dispatcher/feature.ts +63 -15
  75. package/src/step-dispatcher/index.ts +11 -2
  76. package/src/step-dispatcher/webhook-runner.ts +157 -31
  77. package/src/subscription-stripe/__tests__/plugin-methods.test.ts +117 -0
  78. package/src/subscription-stripe/changes.json +6 -0
  79. package/src/subscription-stripe/feature.ts +5 -1
  80. package/src/subscription-stripe/plugin-methods.ts +53 -0
  81. package/src/subscription-stripe/verify-webhook.ts +52 -28
  82. package/src/tenant-handover/__tests__/claim.integration.test.ts +62 -4
  83. package/src/tenant-handover/changes.json +7 -0
  84. package/src/tenant-handover/handlers/claim.write.ts +1 -0
  85. package/src/tenant-handover/move-entity-graph.ts +30 -44
  86. package/src/user-data-rights/__tests__/anonymous-deletion.integration.test.ts +11 -0
  87. package/src/user-data-rights/__tests__/download-by-token-ip-bucket.integration.test.ts +170 -0
  88. package/src/user-data-rights/__tests__/download.integration.test.ts +12 -6
  89. package/src/user-data-rights/__tests__/extract-audit-meta.test.ts +36 -0
  90. package/src/user-data-rights/changes.json +6 -0
  91. package/src/user-data-rights/feature.ts +35 -37
@@ -2,10 +2,12 @@
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 { afterAll, beforeEach, describe, expect, mock, test } from "bun:test";
5
+ import { afterAll, afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
6
+ import type { lookup } from "node:dns/promises";
6
7
  import { EventEmitter } from "node:events";
7
8
  import { createSecret } from "@cosmicdrift/kumiko-framework/secrets";
8
9
  import { sleep, waitFor } from "@cosmicdrift/kumiko-framework/testing";
10
+ import { MAIL_ALLOWED_PRIVATE_HOSTS_ENV_VAR } from "../../foundation-shared";
9
11
  import {
10
12
  type InboundMailContext,
11
13
  isInboundAuthError,
@@ -36,10 +38,12 @@ let lastIdleClient: FakeImapFlow | undefined;
36
38
 
37
39
  class FakeImapFlow extends EventEmitter {
38
40
  mailbox: { uidValidity: bigint; uidNext: number } | false = false;
41
+ readonly opts: Record<string, unknown>;
39
42
  private idleReject: ((err: Error) => void) | undefined;
40
43
 
41
- constructor(_opts: unknown) {
44
+ constructor(opts: Record<string, unknown>) {
42
45
  super();
46
+ this.opts = opts;
43
47
  lastIdleClient = this;
44
48
  }
45
49
 
@@ -119,7 +123,28 @@ class FakeImapFlow extends EventEmitter {
119
123
  const realImapflow = await import("imapflow");
120
124
  mock.module("imapflow", () => ({ ImapFlow: FakeImapFlow }));
121
125
 
122
- const { imapInboundMailPlugin } = await import("../feature");
126
+ const { imapInboundMailPlugin, setImapMailHostLookup } = await import("../feature");
127
+
128
+ /** Host-aware fake resolver — an unmapped hostname behaves like a real ENOTFOUND. */
129
+ function fakeLookupFor(addressesByHost: Readonly<Record<string, string>>): typeof lookup {
130
+ return (async (hostname: string) => {
131
+ const address = addressesByHost[hostname];
132
+ if (!address) throw new Error(`ENOTFOUND ${hostname}`);
133
+ return [{ address, family: 4 }];
134
+ }) as unknown as typeof lookup;
135
+ }
136
+
137
+ // goodDoc's host is a placeholder that must still clear the mail-host guard —
138
+ // resolved to a public address so the pinned-connect path (host+servername)
139
+ // is exercised the same way a real tenant hostname would be.
140
+ beforeEach(() => {
141
+ setImapMailHostLookup(fakeLookupFor({ "imap.example.com": "203.0.113.5" }));
142
+ });
143
+
144
+ afterEach(() => {
145
+ setImapMailHostLookup(undefined);
146
+ delete process.env[MAIL_ALLOWED_PRIVATE_HOSTS_ENV_VAR];
147
+ });
123
148
 
124
149
  afterAll(() => {
125
150
  mock.module("imapflow", () => realImapflow);
@@ -198,6 +223,10 @@ describe("imapInboundMailPlugin — mocked imapflow", () => {
198
223
  await expect(
199
224
  imapInboundMailPlugin.verify(ctxWithDoc(goodDoc), account),
200
225
  ).resolves.toBeUndefined();
226
+ // Proves the connect path actually pins to the resolved IP with SNI set
227
+ // to the original hostname, not just that some connect happened.
228
+ expect(lastIdleClient?.opts["host"]).toBe("203.0.113.5");
229
+ expect(lastIdleClient?.opts["servername"]).toBe("imap.example.com");
201
230
  });
202
231
 
203
232
  test("verify: auth failure → InboundAuthError", async () => {
@@ -354,3 +383,59 @@ describe("imapInboundMailPlugin — mocked imapflow", () => {
354
383
  await stop().catch(() => {});
355
384
  });
356
385
  });
386
+
387
+ function docWithHost(host: string): string {
388
+ return JSON.stringify({ host, port: 993, secure: true, user: "u@example.com", password: "pw" });
389
+ }
390
+
391
+ describe("imapInboundMailPlugin — mail-host guard", () => {
392
+ test("a private IP-literal host is rejected before any connect attempt", async () => {
393
+ lastIdleClient = undefined;
394
+ try {
395
+ await imapInboundMailPlugin.verify(ctxWithDoc(docWithHost("10.0.0.5")), account);
396
+ expect.unreachable("expected verify to throw");
397
+ } catch (e) {
398
+ expect(isInboundAuthError(e)).toBe(true);
399
+ expect((e as Error).message).toBe("IMAP host is not reachable or not allowed");
400
+ expect((e as Error).message).not.toContain("10.0.0.5");
401
+ }
402
+ expect(lastIdleClient).toBeUndefined();
403
+ });
404
+
405
+ test("a hostname resolving to a private address is rejected before any connect attempt", async () => {
406
+ setImapMailHostLookup(fakeLookupFor({ "internal.example": "127.0.0.1" }));
407
+ lastIdleClient = undefined;
408
+ try {
409
+ await imapInboundMailPlugin.verify(ctxWithDoc(docWithHost("internal.example")), account);
410
+ expect.unreachable("expected verify to throw");
411
+ } catch (e) {
412
+ expect(isInboundAuthError(e)).toBe(true);
413
+ expect((e as Error).message).toBe("IMAP host is not reachable or not allowed");
414
+ expect((e as Error).message).not.toContain("internal.example");
415
+ expect((e as Error).message).not.toContain("127.0.0.1");
416
+ }
417
+ expect(lastIdleClient).toBeUndefined();
418
+ });
419
+
420
+ test("a DNS resolution failure surfaces as InboundTransientError before any connect attempt, with the same tenant-visible message as a blocked host", async () => {
421
+ setImapMailHostLookup(fakeLookupFor({}));
422
+ lastIdleClient = undefined;
423
+ try {
424
+ await imapInboundMailPlugin.verify(ctxWithDoc(docWithHost("nowhere.example")), account);
425
+ expect.unreachable("expected verify to throw");
426
+ } catch (e) {
427
+ expect(isInboundTransientError(e)).toBe(true);
428
+ expect(isInboundAuthError(e)).toBe(false);
429
+ expect((e as Error).message).toBe("IMAP host is not reachable or not allowed");
430
+ expect((e as Error).message).not.toContain("nowhere.example");
431
+ }
432
+ expect(lastIdleClient).toBeUndefined();
433
+ });
434
+
435
+ test("an operator-allowlisted private host bypasses resolution and keeps its raw host, without SNI", async () => {
436
+ process.env[MAIL_ALLOWED_PRIVATE_HOSTS_ENV_VAR] = "mailpit.internal";
437
+ await imapInboundMailPlugin.verify(ctxWithDoc(docWithHost("mailpit.internal")), account);
438
+ expect(lastIdleClient?.opts["host"]).toBe("mailpit.internal");
439
+ expect(lastIdleClient?.opts["servername"]).toBeUndefined();
440
+ });
441
+ });
@@ -1 +1,14 @@
1
- []
1
+ [
2
+ {
3
+ "version": "0.320.0",
4
+ "type": "breaking",
5
+ "title": "The tenant-configured IMAP host must resolve to a public address",
6
+ "migration": "An operator relying on an internal IMAP server or a local dev/test server\n(greenmail on localhost or a private IP) now gets an InboundAuthError\n(account marked auth_error) unless that host is explicitly allowed. Set\nthe same operator env var mail-transport-smtp reads (comma-separated, no\nboot-time code call needed) before starting the process:\n KUMIKO_MAIL_ALLOWED_PRIVATE_HOSTS=greenmail.internal\nThis is an operator env var, not a tenant config value — a tenant cannot\nadd their own host to this list. A DNS resolution failure for a\ngenuinely unreachable host now surfaces as InboundTransientError (job\nretry) instead of reaching imapflow at all."
7
+ },
8
+ {
9
+ "version": "0.320.0",
10
+ "type": "fix",
11
+ "title": "IMAP connect failures no longer distinguish a blocked host from a DNS failure in the thrown message",
12
+ "detail": "`createImapClient` throws the same generic message\n(\"IMAP host is not reachable or not allowed\") for both a blocked host\n(`InboundAuthError`) and a DNS resolution failure (`InboundTransientError`).\nThe configured host and the underlying error are now logged via the\nfeature's own logger instead of being embedded in the thrown message.\nError class (and therefore retry behavior) is unchanged."
13
+ }
14
+ ]
@@ -44,6 +44,7 @@ import {
44
44
  coerceDate,
45
45
  createImapClient,
46
46
  IMAP_MAILBOX,
47
+ imapMailHostGuard,
47
48
  mapImapError,
48
49
  parseImapCursor,
49
50
  toRawInboundMessage,
@@ -52,6 +53,8 @@ import {
52
53
  const FEATURE_NAME = "inbound-provider-imap";
53
54
  export const IMAP_PROVIDER_KEY = "imap";
54
55
 
56
+ export { setImapMailHostLookup } from "./imap-client";
57
+
55
58
  // =============================================================================
56
59
  // Credential-Read — per-Account-Slot, Worker-tauglich (slim ctx).
57
60
  // =============================================================================
@@ -88,7 +91,7 @@ async function fetchMessages(
88
91
  opts: { readonly backfillWindowDays: number; readonly maxMessages: number },
89
92
  ): Promise<InboundFetchResult> {
90
93
  const doc = await readCredentialDocument(ctx, account);
91
- const client = createImapClient(doc);
94
+ const client = await createImapClient(doc, imapMailHostGuard());
92
95
  let lock: Awaited<ReturnType<typeof client.getMailboxLock>> | undefined;
93
96
  try {
94
97
  await client.connect();
@@ -164,7 +167,7 @@ async function watchMailbox(
164
167
  },
165
168
  ): Promise<() => Promise<void>> {
166
169
  const doc = await readCredentialDocument(ctx, account);
167
- const client: ImapFlow = createImapClient(doc);
170
+ const client: ImapFlow = await createImapClient(doc, imapMailHostGuard());
168
171
  let stopped = false;
169
172
 
170
173
  try {
@@ -252,7 +255,7 @@ async function watchMailbox(
252
255
  export const imapInboundMailPlugin: InboundMailProviderPlugin = {
253
256
  verify: async (ctx, account) => {
254
257
  const doc = await readCredentialDocument(ctx, account);
255
- const client = createImapClient(doc);
258
+ const client = await createImapClient(doc, imapMailHostGuard());
256
259
  try {
257
260
  await client.connect();
258
261
  } catch (err) {
@@ -2,6 +2,12 @@
2
2
  // Referenz-Impl: /Users/marc/code/doc-o-mat (HEINZ) — aber inkrementeller
3
3
  // UIDVALIDITY:lastUid-Cursor statt '1:*'-Vollscan.
4
4
 
5
+ import {
6
+ BlockedHostError,
7
+ type MailHostGuardOptions,
8
+ readAllowedPrivateMailHostsFromEnv,
9
+ resolveMailConnectTarget,
10
+ } from "@cosmicdrift/kumiko-bundled-features/foundation-shared";
5
11
  import {
6
12
  InboundAuthError,
7
13
  InboundCursorInvalidError,
@@ -9,6 +15,7 @@ import {
9
15
  type RawInboundMessage,
10
16
  type SyncCursorPayload,
11
17
  } from "@cosmicdrift/kumiko-bundled-features/inbound-mail-foundation";
18
+ import { createFallbackLogger } from "@cosmicdrift/kumiko-framework/logging";
12
19
  import { legacyDateToInstant } from "@cosmicdrift/kumiko-framework/time";
13
20
  import { ImapFlow } from "imapflow";
14
21
  import { type AddressObject, type ParsedMail, simpleParser } from "mailparser";
@@ -18,15 +25,66 @@ import type { ImapCredentialDocument } from "./credential-document";
18
25
  export const IMAP_MAILBOX = "INBOX";
19
26
  const SNIPPET_MAX = 300;
20
27
 
28
+ const log = createFallbackLogger("inbound-provider-imap");
29
+
30
+ // Tenant-visible for both branches below — must not reveal whether the host
31
+ // was blocked (private/reserved range) or merely failed to resolve, and
32
+ // must never include the host or the underlying error text. Server-side
33
+ // detail goes to `log` only.
34
+ const IMAP_HOST_UNREACHABLE_MESSAGE = "IMAP host is not reachable or not allowed";
35
+
21
36
  // =============================================================================
22
37
  // Client-Factory + Fehler-Mapping
23
38
  // =============================================================================
24
39
 
25
- export function createImapClient(doc: ImapCredentialDocument): ImapFlow {
40
+ // Operator escape hatch for an internal relay or a dev/test IMAP server
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.
46
+ //
47
+ // mailHostLookup is a test-only DNS seam — production leaves it undefined,
48
+ // so resolveMailConnectTarget uses the real resolver. Module-global state:
49
+ // reset it in afterEach/afterAll.
50
+ let mailHostLookup: MailHostGuardOptions["lookupFn"];
51
+
52
+ export function setImapMailHostLookup(fn: MailHostGuardOptions["lookupFn"]): void {
53
+ mailHostLookup = fn;
54
+ }
55
+
56
+ export function imapMailHostGuard(): MailHostGuardOptions {
57
+ return {
58
+ allowedPrivateMailHosts: readAllowedPrivateMailHostsFromEnv(),
59
+ lookupFn: mailHostLookup,
60
+ };
61
+ }
62
+
63
+ // Resolves+pins doc.host before ever touching imapflow — a blocked host
64
+ // (private/reserved range) must never reach a connect attempt. Rejection is
65
+ // InboundAuthError (no retry, a config problem) vs InboundTransientError for
66
+ // a DNS failure (matches mapImapError's ENOTFOUND handling below).
67
+ export async function createImapClient(
68
+ doc: ImapCredentialDocument,
69
+ hostGuard: MailHostGuardOptions = {},
70
+ ): Promise<ImapFlow> {
71
+ let target: Awaited<ReturnType<typeof resolveMailConnectTarget>>;
72
+ try {
73
+ target = await resolveMailConnectTarget(doc.host, hostGuard);
74
+ } catch (err) {
75
+ const reason = err instanceof Error ? err.message : String(err);
76
+ if (err instanceof BlockedHostError) {
77
+ log.warn("rejected blocked host", { host: doc.host, reason });
78
+ throw new InboundAuthError(IMAP_HOST_UNREACHABLE_MESSAGE);
79
+ }
80
+ log.warn("host resolution failed", { host: doc.host, reason });
81
+ throw new InboundTransientError(IMAP_HOST_UNREACHABLE_MESSAGE);
82
+ }
26
83
  return new ImapFlow({
27
- host: doc.host,
84
+ host: target.host,
28
85
  port: doc.port,
29
86
  secure: doc.secure,
87
+ ...(target.servername && { servername: target.servername }),
30
88
  auth: doc.password
31
89
  ? { user: doc.user, pass: doc.password }
32
90
  : { user: doc.user, accessToken: doc.accessToken as string },
@@ -6,7 +6,7 @@ export {
6
6
  type ParseCredentialResult,
7
7
  parseImapCredentialDocument,
8
8
  } from "./credential-document";
9
- export { IMAP_PROVIDER_KEY, inboundProviderImapFeature } from "./feature";
9
+ export { IMAP_PROVIDER_KEY, inboundProviderImapFeature, setImapMailHostLookup } from "./feature";
10
10
  export {
11
11
  assertUidValidity,
12
12
  buildProviderMessageId,
@@ -599,6 +599,86 @@ describe("ledger integration — createSchedule with a caller-chosen id (idempot
599
599
  });
600
600
  });
601
601
 
602
+ describe("ledger integration — account code uniqueness", () => {
603
+ test("a second account with the same code in the same tenant is rejected", async () => {
604
+ await stack.http.writeOk(
605
+ LedgerHandlers.createAccount,
606
+ { name: "Bank", type: "asset", code: "1000" },
607
+ admin,
608
+ );
609
+
610
+ const err = await stack.http.writeErr(
611
+ LedgerHandlers.createAccount,
612
+ { name: "Bank 2", type: "asset", code: "1000" },
613
+ admin,
614
+ );
615
+ expect(err.code).toBe("unique_violation");
616
+
617
+ // The rejected create must not leave an event behind that a later rebuild
618
+ // would replay into a duplicate.
619
+ const accounts = await stack.http.queryOk<{ rows: { code?: string }[] }>(
620
+ LedgerQueries.accountList,
621
+ {},
622
+ admin,
623
+ );
624
+ expect(accounts.rows.filter((row) => row.code === "1000")).toHaveLength(1);
625
+ const [eventCount] = await asRawClient(stack.db).unsafe<{ count: number }>(
626
+ "SELECT count(*)::int AS count FROM kumiko_events WHERE tenant_id = $1",
627
+ [admin.tenantId],
628
+ );
629
+ expect(eventCount?.count).toBe(1);
630
+ });
631
+
632
+ test("two accounts without a code in the same tenant both succeed", async () => {
633
+ await stack.http.writeOk(LedgerHandlers.createAccount, { name: "Bank", type: "asset" }, admin);
634
+ const second = await stack.http.writeOk<{ id: string }>(
635
+ LedgerHandlers.createAccount,
636
+ { name: "Bank 2", type: "asset" },
637
+ admin,
638
+ );
639
+ expect(second.id).toBeDefined();
640
+ });
641
+
642
+ test("the same code in two different tenants both succeed", async () => {
643
+ const a = await stack.http.writeOk<{ id: string }>(
644
+ LedgerHandlers.createAccount,
645
+ { name: "Bank", type: "asset", code: "1000" },
646
+ admin,
647
+ );
648
+ const b = await stack.http.writeOk<{ id: string }>(
649
+ LedgerHandlers.createAccount,
650
+ { name: "Bank", type: "asset", code: "1000" },
651
+ otherTenant,
652
+ );
653
+ expect(a.id).not.toBe(b.id);
654
+ });
655
+
656
+ test("updating an account's code to an existing code in the same tenant is rejected", async () => {
657
+ await stack.http.writeOk(
658
+ LedgerHandlers.createAccount,
659
+ { name: "Bank", type: "asset", code: "1000" },
660
+ admin,
661
+ );
662
+ const other = await stack.http.writeOk<{ id: string }>(
663
+ LedgerHandlers.createAccount,
664
+ { name: "Rent", type: "income", code: "2000" },
665
+ admin,
666
+ );
667
+ const otherDetail = await stack.http.queryOk<{ version: number }>(
668
+ LedgerQueries.accountDetail,
669
+ { id: other.id },
670
+ admin,
671
+ );
672
+
673
+ const err = await stack.http.writeErr(
674
+ LedgerHandlers.updateAccount,
675
+ { id: other.id, version: otherDetail.version, changes: { code: "1000" } },
676
+ admin,
677
+ );
678
+ expect(err.code).toBe("unique_violation");
679
+ });
680
+ });
681
+
602
682
  describe("ledger integration — subject dimension (filterable business-object reference)", () => {
603
683
  test("a booking with subjectType/subjectId round-trips and is findable via an eq filter", async () => {
604
684
  const bank = await createAccount("Bank", "asset");
@@ -1 +1,9 @@
1
- []
1
+ [
2
+ {
3
+ "version": "0.320.0",
4
+ "type": "breaking",
5
+ "title": "Account code is unique per tenant (partial unique index read_ledger_accounts_tenant_id_code_uidx)",
6
+ "detail": "`accountEntity` declares `read_ledger_accounts_tenant_id_code_uidx`, a unique index on `(tenant_id, code)` that only covers accounts with a code. Two parallel find-then-create calls for the same code (two tabs, two devices) could each create an account and leave a tenant with duplicate codes. The second write now fails with `unique_violation` (HTTP 409). This applies to `createAccount` and to an `updateAccount` that changes the code. Accounts without a code are unaffected, and the same code in different tenants stays allowed.",
7
+ "migration": "Before you apply the migration, check for existing duplicates:\n\n SELECT tenant_id, code, count(*) FROM read_ledger_accounts\n WHERE code IS NOT NULL GROUP BY tenant_id, code HAVING count(*) > 1;\n\n`kumiko schema generate` treats a new unique index on a managed projection as destructive. It emits DROP TABLE + CREATE TABLE plus a `.rebuild.json`, which means a full event replay of `read_ledger_accounts`. A rebuild creates the table's indexes before it replays. So if a tenant's event history ever contained two accounts with the same code at the same time, the replay hits the unique index and fails, even after one of them was renamed. For an app with existing ledger data, hand-edit the generated migration before committing it. Replace the DROP/CREATE with an in-place\n\n CREATE UNIQUE INDEX IF NOT EXISTS \"read_ledger_accounts_tenant_id_code_uidx\"\n ON \"read_ledger_accounts\" (\"tenant_id\", \"code\") WHERE \"code\" IS NOT NULL;\n\nand delete the `.rebuild.json`. The migrations snapshot stays as generated. If the duplicate check found rows, resolve them first through real writes, for example an `updateAccount` that gives the extra account another code. Never edit the table directly. For such a tenant, a later full rebuild of `read_ledger_accounts` still replays the historical duplicate and fails. An app that already created this index by hand under the same name (money-horse migration 0024) gets a no-op from the in-place statement.\n\nCode that does find-then-create by code should expect `unique_violation` on create and re-read the account that won the race."
8
+ }
9
+ ]
@@ -1,3 +1,4 @@
1
+ import { sql } from "@cosmicdrift/kumiko-framework/db";
1
2
  import {
2
3
  createDateField,
3
4
  createEmbeddedListField,
@@ -26,11 +27,23 @@ export const accountEntity = createEntity({
26
27
  reason: "is_business_data",
27
28
  }),
28
29
  type: createSelectField({ options: ACCOUNT_TYPES, required: true }),
29
- // Optional account number (Kontonummer / SKR code) — free text in v1.
30
+ // Optional account number (Kontonummer / SKR code); unique per tenant when set.
30
31
  code: createTextField({ maxLength: 32, personal: false, reason: "technical_reference" }),
31
32
  // Parent account id, or absent for a root account. No FK (event-sourced).
32
33
  parentId: createTextField({ maxLength: 64, personal: false, reason: "technical_reference" }),
33
34
  },
35
+ // Find-then-create by code (solon, money-horse) races across tabs/devices;
36
+ // only the DB closes that gap. Partial because code is optional. The name is
37
+ // the one money-horse's hand-written migration 0024 already uses, so both
38
+ // declare the same Postgres object instead of two indexes.
39
+ indexes: [
40
+ {
41
+ columns: ["tenantId", "code"],
42
+ unique: true,
43
+ where: sql`"code" IS NOT NULL`,
44
+ name: "read_ledger_accounts_tenant_id_code_uidx",
45
+ },
46
+ ],
34
47
  });
35
48
 
36
49
  // transaction — a journal entry. The balanced posting lines live embedded as
@@ -10,6 +10,7 @@
10
10
 
11
11
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
12
12
  import { randomBytes } from "node:crypto";
13
+ import type { lookup } from "node:dns/promises";
13
14
  import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
14
15
  import { defineFeature, defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
15
16
  import { createEnvMasterKeyProvider } from "@cosmicdrift/kumiko-framework/secrets";
@@ -32,8 +33,13 @@ import { ConfigHandlers } from "../../config/constants";
32
33
  import { createConfigAccessorFactory } from "../../config/feature";
33
34
  import { type ConfigResolver, createConfigResolver } from "../../config/resolver";
34
35
  import { configValuesTable } from "../../config/table";
36
+ import { MAIL_ALLOWED_PRIVATE_HOSTS_ENV_VAR } from "../../foundation-shared";
35
37
  import { clearInbox, getInbox, mailTransportInMemoryFeature } from "../../mail-transport-inmemory";
36
- import { mailTransportSmtpFeature, SMTP_PASSWORD } from "../../mail-transport-smtp";
38
+ import {
39
+ mailTransportSmtpFeature,
40
+ SMTP_PASSWORD,
41
+ setSmtpMailHostLookup,
42
+ } from "../../mail-transport-smtp";
37
43
  import { createSecretsContext, createSecretsFeature, tenantSecretsTable } from "../../secrets";
38
44
  import { createTenantFeature } from "../../tenant/feature";
39
45
  import { tenantEntity } from "../../tenant/schema/tenant";
@@ -86,6 +92,25 @@ let providerRef: MutableMasterKeyProvider;
86
92
 
87
93
  const testEncryptionKey = randomBytes(32).toString("base64");
88
94
 
95
+ // Tenant-supplied SMTP hosts here are placeholder `.test`/`localhost`
96
+ // names that never touch a real server (createSmtpTransport allocates a
97
+ // nodemailer pool lazily, connecting only on send) — pin DNS to a fixed
98
+ // public address so the host-egress guard resolves deterministically
99
+ // instead of depending on real DNS for a name that's never meant to work.
100
+ // "internal-relay.test" is the one exception, mapped to a private address
101
+ // on purpose for scenario 5's resolves-privately rejection case.
102
+ beforeAll(() => {
103
+ const fakeLookup = (async (hostname: string) => {
104
+ if (hostname === "internal-relay.test") return [{ address: "127.0.0.1", family: 4 }];
105
+ return [{ address: "93.184.216.34", family: 4 }];
106
+ }) as unknown as typeof lookup;
107
+ setSmtpMailHostLookup(fakeLookup);
108
+ });
109
+
110
+ afterAll(() => {
111
+ setSmtpMailHostLookup(undefined);
112
+ });
113
+
89
114
  beforeAll(async () => {
90
115
  const encryption = createTestEnvelopeCipher(testEncryptionKey);
91
116
  resolver = createConfigResolver({ cipher: encryption });
@@ -283,3 +308,63 @@ describe("scenario 4: in-memory transport dispatch", () => {
283
308
  expect(getInbox(admin.tenantId)).toEqual([message]);
284
309
  });
285
310
  });
311
+
312
+ // --- Scenario 5: tenant-supplied SMTP host must resolve to a public address ---
313
+
314
+ describe("scenario 5: mail-host guard", () => {
315
+ async function configureSmtp(admin: ReturnType<typeof adminFor>, host: string) {
316
+ await selectSmtpProvider(admin);
317
+ await setConfig(admin, "mail-transport-smtp:config:host", host);
318
+ await setConfig(admin, "mail-transport-smtp:config:port", 587);
319
+ await setConfig(admin, "mail-transport-smtp:config:from", "noreply@test.local");
320
+ await setConfig(admin, "mail-transport-smtp:config:auth-user", "admin@test.local");
321
+ await stack.http.writeOk("secrets:write:set", { key: SMTP_PASSWORD.name, value: "pw" }, admin);
322
+ }
323
+
324
+ test("a private IP-literal host is rejected as unconfigured, naming 'host'", async () => {
325
+ const admin = adminFor(407);
326
+ await configureSmtp(admin, "10.0.0.5");
327
+
328
+ const error = await stack.http.writeErr(TEST_HANDLER_QN, {}, admin);
329
+ expect(error.httpStatus).toBe(422);
330
+ expect(error.code).toBe("unconfigured");
331
+ expect(error.details).toMatchObject({ feature: "mail-transport-smtp", key: "host" });
332
+ });
333
+
334
+ test("a hostname that resolves to a private address is rejected as unconfigured", async () => {
335
+ const admin = adminFor(408);
336
+ await configureSmtp(admin, "internal-relay.test");
337
+
338
+ const error = await stack.http.writeErr(TEST_HANDLER_QN, {}, admin);
339
+ expect(error.httpStatus).toBe(422);
340
+ expect(error.code).toBe("unconfigured");
341
+ expect(error.details).toMatchObject({ feature: "mail-transport-smtp", key: "host" });
342
+ });
343
+
344
+ test("a public tenant-supplied host builds successfully", async () => {
345
+ const admin = adminFor(409);
346
+ await configureSmtp(admin, "smtp.public-relay.test");
347
+
348
+ const result = (await stack.http.writeOk(TEST_HANDLER_QN, {}, admin)) as Record<
349
+ string,
350
+ unknown
351
+ >;
352
+ expect(result["hasSend"]).toBe(true);
353
+ });
354
+
355
+ test("an operator-allowlisted private relay bypasses the guard", async () => {
356
+ process.env[MAIL_ALLOWED_PRIVATE_HOSTS_ENV_VAR] = "ops-relay.internal";
357
+ try {
358
+ const admin = adminFor(410);
359
+ await configureSmtp(admin, "ops-relay.internal");
360
+
361
+ const result = (await stack.http.writeOk(TEST_HANDLER_QN, {}, admin)) as Record<
362
+ string,
363
+ unknown
364
+ >;
365
+ expect(result["hasSend"]).toBe(true);
366
+ } finally {
367
+ delete process.env[MAIL_ALLOWED_PRIVATE_HOSTS_ENV_VAR];
368
+ }
369
+ });
370
+ });