@cosmicdrift/kumiko-bundled-features 0.319.0 → 0.320.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.
- package/package.json +9 -9
- package/src/auth-email-password/__tests__/auth-tenants-rate-limit.integration.test.ts +3 -0
- package/src/auth-email-password/__tests__/post-auth-landing.integration.test.ts +321 -0
- package/src/auth-email-password/__tests__/public-routes-rate-limit.integration.test.ts +4 -0
- package/src/auth-email-password/__tests__/session-callbacks.integration.test.ts +4 -0
- package/src/auth-email-password/__tests__/signup-flow.integration.test.ts +21 -1
- package/src/auth-email-password/__tests__/signup-handover.integration.test.ts +10 -0
- package/src/auth-email-password/auth-paths.ts +10 -6
- package/src/auth-email-password/changes.json +12 -0
- package/src/auth-email-password/index.ts +1 -1
- package/src/auth-email-password/web/__tests__/auth-form-logic.test.ts +34 -1
- package/src/auth-email-password/web/__tests__/invite-accept-screen.test.tsx +30 -0
- package/src/auth-email-password/web/__tests__/signup-complete-screen.test.tsx +26 -0
- package/src/auth-email-password/web/auth-client.ts +16 -1
- package/src/auth-email-password/web/auth-form-logic.ts +10 -0
- package/src/auth-email-password/web/invite-accept-screen.tsx +10 -5
- package/src/auth-email-password/web/signup-complete-screen.tsx +11 -7
- package/src/auth-mfa/web/mfa-client.ts +18 -2
- package/src/channel-email/__tests__/smtp-transport-pinning.test.ts +42 -0
- package/src/channel-email/smtp-transport.ts +7 -0
- package/src/file-derivatives/feature.ts +1 -1
- package/src/file-derivatives/handlers/public-variant.query.ts +5 -7
- package/src/foundation-shared/__tests__/mail-host-policy.test.ts +68 -0
- package/src/foundation-shared/index.ts +9 -0
- package/src/foundation-shared/mail-host-policy.ts +70 -0
- package/src/inbound-provider-imap/__tests__/imap-foundation.integration.test.ts +11 -0
- package/src/inbound-provider-imap/__tests__/imap-live.integration.test.ts +14 -1
- package/src/inbound-provider-imap/__tests__/plugin-mocked.test.ts +88 -3
- package/src/inbound-provider-imap/changes.json +14 -1
- package/src/inbound-provider-imap/feature.ts +6 -3
- package/src/inbound-provider-imap/imap-client.ts +60 -2
- package/src/inbound-provider-imap/index.ts +1 -1
- package/src/ledger/__tests__/ledger.integration.test.ts +80 -0
- package/src/ledger/changes.json +9 -1
- package/src/ledger/entity.ts +14 -1
- package/src/mail-foundation/__tests__/mail-foundation.integration.test.ts +86 -1
- package/src/mail-transport-smtp/__tests__/feature.test.ts +123 -3
- package/src/mail-transport-smtp/changes.json +14 -1
- package/src/mail-transport-smtp/feature.ts +76 -1
- package/src/mail-transport-smtp/index.ts +6 -1
- package/src/personal-access-tokens/__tests__/pat.integration.test.ts +59 -0
- package/src/sessions/__tests__/sessions.integration.test.ts +5 -0
- package/src/shared/password-hashing.test.ts +21 -13
- package/src/step-dispatcher/__tests__/webhook-runner.test.ts +131 -0
- package/src/step-dispatcher/changes.json +14 -1
- package/src/step-dispatcher/feature.ts +16 -1
- package/src/step-dispatcher/index.ts +8 -1
- package/src/step-dispatcher/webhook-runner.ts +104 -18
- package/src/subscription-stripe/changes.json +6 -0
- package/src/user-data-rights/__tests__/anonymous-deletion.integration.test.ts +11 -0
- package/src/user-data-rights/__tests__/download-by-token-ip-bucket.integration.test.ts +170 -0
- package/src/user-data-rights/__tests__/download.integration.test.ts +12 -6
- package/src/user-data-rights/__tests__/extract-audit-meta.test.ts +36 -0
- package/src/user-data-rights/changes.json +6 -0
- package/src/user-data-rights/feature.ts +35 -37
|
@@ -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
|
-
|
|
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:
|
|
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");
|
package/src/ledger/changes.json
CHANGED
|
@@ -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
|
+
]
|
package/src/ledger/entity.ts
CHANGED
|
@@ -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)
|
|
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 {
|
|
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
|
+
});
|
|
@@ -3,13 +3,34 @@
|
|
|
3
3
|
// Plugin-registration shape is also pinned (drift-pin: name "smtp",
|
|
4
4
|
// build-fn presence).
|
|
5
5
|
|
|
6
|
-
import { describe, expect, test } from "bun:test";
|
|
6
|
+
import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test";
|
|
7
|
+
import type { lookup } from "node:dns/promises";
|
|
7
8
|
import type { ConfigAccessor } from "@cosmicdrift/kumiko-framework/engine";
|
|
9
|
+
import { UnconfiguredError } from "@cosmicdrift/kumiko-framework/errors";
|
|
8
10
|
import type { SecretsContext } from "@cosmicdrift/kumiko-framework/secrets";
|
|
9
11
|
import { createSecret } from "@cosmicdrift/kumiko-framework/secrets";
|
|
12
|
+
import { HostResolutionError, MAIL_ALLOWED_PRIVATE_HOSTS_ENV_VAR } from "../../foundation-shared";
|
|
10
13
|
import { isMailTransportPlugin, type MailTransportPlugin } from "../../mail-foundation";
|
|
11
14
|
import { describeMailTransportContract } from "../../mail-foundation/__tests__/mail-transport-contract";
|
|
12
|
-
import { mailTransportSmtpFeature, SMTP_PASSWORD } from "../feature";
|
|
15
|
+
import { mailTransportSmtpFeature, SMTP_PASSWORD, setSmtpMailHostLookup } from "../feature";
|
|
16
|
+
|
|
17
|
+
// The contract fixture's host never actually connects (nodemailer's pool
|
|
18
|
+
// connects lazily on send, never on construction) — "localhost" only needs
|
|
19
|
+
// to pass the host-egress guard, so it goes through the operator escape
|
|
20
|
+
// hatch instead of a real DNS lookup for a placeholder hostname.
|
|
21
|
+
const originalAllowedPrivateHostsEnv = process.env[MAIL_ALLOWED_PRIVATE_HOSTS_ENV_VAR];
|
|
22
|
+
|
|
23
|
+
beforeAll(() => {
|
|
24
|
+
process.env[MAIL_ALLOWED_PRIVATE_HOSTS_ENV_VAR] = "localhost";
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
afterAll(() => {
|
|
28
|
+
if (originalAllowedPrivateHostsEnv === undefined) {
|
|
29
|
+
delete process.env[MAIL_ALLOWED_PRIVATE_HOSTS_ENV_VAR];
|
|
30
|
+
} else {
|
|
31
|
+
process.env[MAIL_ALLOWED_PRIVATE_HOSTS_ENV_VAR] = originalAllowedPrivateHostsEnv;
|
|
32
|
+
}
|
|
33
|
+
});
|
|
13
34
|
|
|
14
35
|
function registeredPlugin(): MailTransportPlugin {
|
|
15
36
|
const usage = mailTransportSmtpFeature.extensionUsages.find(
|
|
@@ -28,7 +49,7 @@ function fakeConfig(
|
|
|
28
49
|
): ConfigAccessor {
|
|
29
50
|
const keys = mailTransportSmtpFeature.exports.configKeys;
|
|
30
51
|
const values = new Map<string, string | number | boolean>([
|
|
31
|
-
[keys.host.name, overrides.host ?? "
|
|
52
|
+
[keys.host.name, overrides.host ?? "localhost"],
|
|
32
53
|
[keys.port.name, overrides.port ?? 587],
|
|
33
54
|
[keys.secure.name, overrides.secure ?? false],
|
|
34
55
|
[keys.from.name, overrides.from ?? "noreply@contract-test.invalid"],
|
|
@@ -71,6 +92,105 @@ describe("mailTransportSmtpFeature — build error path", () => {
|
|
|
71
92
|
});
|
|
72
93
|
});
|
|
73
94
|
|
|
95
|
+
describe("mailTransportSmtpFeature — mail-host guard", () => {
|
|
96
|
+
afterEach(() => {
|
|
97
|
+
setSmtpMailHostLookup(undefined);
|
|
98
|
+
delete process.env[MAIL_ALLOWED_PRIVATE_HOSTS_ENV_VAR];
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("a private IP-literal host is rejected as unconfigured, naming 'host'", async () => {
|
|
102
|
+
const ctx = { config: fakeConfig({ host: "10.0.0.5" }), secrets: fakeSecrets(), _userId: "x" };
|
|
103
|
+
try {
|
|
104
|
+
await registeredPlugin().build(ctx, "contract-test-tenant");
|
|
105
|
+
expect.unreachable("expected build to throw");
|
|
106
|
+
} catch (e) {
|
|
107
|
+
expect(e).toBeInstanceOf(UnconfiguredError);
|
|
108
|
+
expect((e as UnconfiguredError).details).toMatchObject({
|
|
109
|
+
feature: "mail-transport-smtp",
|
|
110
|
+
key: "host",
|
|
111
|
+
});
|
|
112
|
+
expect((e as Error).message).not.toContain("10.0.0.5");
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("a blocked host and a DNS resolution failure produce the exact same tenant-visible message", async () => {
|
|
117
|
+
const blockedCtx = {
|
|
118
|
+
config: fakeConfig({ host: "10.0.0.5" }),
|
|
119
|
+
secrets: fakeSecrets(),
|
|
120
|
+
_userId: "x",
|
|
121
|
+
};
|
|
122
|
+
let blockedMessage: string | undefined;
|
|
123
|
+
try {
|
|
124
|
+
await registeredPlugin().build(blockedCtx, "contract-test-tenant");
|
|
125
|
+
expect.unreachable("expected build to throw");
|
|
126
|
+
} catch (e) {
|
|
127
|
+
blockedMessage = (e as Error).message;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const failingLookup = (async () => {
|
|
131
|
+
throw new Error("ENOTFOUND");
|
|
132
|
+
}) as unknown as typeof lookup;
|
|
133
|
+
setSmtpMailHostLookup(failingLookup);
|
|
134
|
+
const unresolvableCtx = {
|
|
135
|
+
config: fakeConfig({ host: "nowhere.test" }),
|
|
136
|
+
secrets: fakeSecrets(),
|
|
137
|
+
_userId: "x",
|
|
138
|
+
};
|
|
139
|
+
let unresolvableMessage: string | undefined;
|
|
140
|
+
try {
|
|
141
|
+
await registeredPlugin().build(unresolvableCtx, "contract-test-tenant");
|
|
142
|
+
expect.unreachable("expected build to throw");
|
|
143
|
+
} catch (e) {
|
|
144
|
+
unresolvableMessage = (e as Error).message;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
expect(blockedMessage).toBe(unresolvableMessage);
|
|
148
|
+
expect(blockedMessage).not.toContain("10.0.0.5");
|
|
149
|
+
expect(unresolvableMessage).not.toContain("nowhere.test");
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("a hostname resolving to a private address is rejected as unconfigured", async () => {
|
|
153
|
+
const fakeLookup = (async () => [
|
|
154
|
+
{ address: "127.0.0.1", family: 4 },
|
|
155
|
+
]) as unknown as typeof lookup;
|
|
156
|
+
setSmtpMailHostLookup(fakeLookup);
|
|
157
|
+
const ctx = {
|
|
158
|
+
config: fakeConfig({ host: "internal-relay.test" }),
|
|
159
|
+
secrets: fakeSecrets(),
|
|
160
|
+
_userId: "x",
|
|
161
|
+
};
|
|
162
|
+
await expect(registeredPlugin().build(ctx, "contract-test-tenant")).rejects.toBeInstanceOf(
|
|
163
|
+
UnconfiguredError,
|
|
164
|
+
);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("a DNS resolution failure is not reclassified as unconfigured (transient, not a tenant config problem)", async () => {
|
|
168
|
+
const failingLookup = (async () => {
|
|
169
|
+
throw new Error("ENOTFOUND");
|
|
170
|
+
}) as unknown as typeof lookup;
|
|
171
|
+
setSmtpMailHostLookup(failingLookup);
|
|
172
|
+
const ctx = {
|
|
173
|
+
config: fakeConfig({ host: "nowhere.test" }),
|
|
174
|
+
secrets: fakeSecrets(),
|
|
175
|
+
_userId: "x",
|
|
176
|
+
};
|
|
177
|
+
await expect(registeredPlugin().build(ctx, "contract-test-tenant")).rejects.toBeInstanceOf(
|
|
178
|
+
HostResolutionError,
|
|
179
|
+
);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test("an operator-allowlisted private host builds successfully, bypassing resolution", async () => {
|
|
183
|
+
process.env[MAIL_ALLOWED_PRIVATE_HOSTS_ENV_VAR] = "relay.internal";
|
|
184
|
+
const ctx = {
|
|
185
|
+
config: fakeConfig({ host: "relay.internal" }),
|
|
186
|
+
secrets: fakeSecrets(),
|
|
187
|
+
_userId: "x",
|
|
188
|
+
};
|
|
189
|
+
const transport = await registeredPlugin().build(ctx, "contract-test-tenant");
|
|
190
|
+
expect(typeof transport.send).toBe("function");
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
74
194
|
describe("mailTransportSmtpFeature — shape", () => {
|
|
75
195
|
test("has the expected name", () => {
|
|
76
196
|
expect(mailTransportSmtpFeature.name).toBe("mail-transport-smtp");
|
|
@@ -1 +1,14 @@
|
|
|
1
|
-
[
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.320.0",
|
|
4
|
+
"type": "breaking",
|
|
5
|
+
"title": "The tenant-configured SMTP host must resolve to a public address",
|
|
6
|
+
"migration": "An operator relying on an internal SMTP relay or a local dev/test server\n(mailpit, MailHog on localhost or a private IP) now gets a build-time\n422 (code \"unconfigured\", naming the \"host\" config-key) unless that host\nis explicitly allowed. Set the operator env var (comma-separated, read\nat connect time — no boot-time code call needed) before starting the\nprocess:\n KUMIKO_MAIL_ALLOWED_PRIVATE_HOSTS=mailpit.internal\nThis is an operator env var, not a tenant config value — a tenant cannot\nadd their own host to this list. Shared with inbound-provider-imap: one\nenv var covers both SMTP and IMAP allowlisting. A DNS resolution failure\nfor a genuinely unreachable host now surfaces distinctly (not as\n\"unconfigured\") instead of only failing later at first-send time."
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"version": "0.320.0",
|
|
10
|
+
"type": "fix",
|
|
11
|
+
"title": "SMTP connect failures no longer distinguish a blocked host from a DNS failure in the thrown message",
|
|
12
|
+
"detail": "`buildSmtpTransport` throws an `UnconfiguredError` for a blocked host and\na `HostResolutionError` for a DNS failure — both classes unchanged for\nretry semantics — but the `.message` text is now byte-identical between\nthe two (\"... host is not reachable or not allowed\"). The configured\nhost and the underlying error are now logged via the feature's own\nlogger instead of being embedded in the thrown message."
|
|
13
|
+
}
|
|
14
|
+
]
|