@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.
- 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/billing-foundation/__tests__/billing-plans.integration.test.ts +27 -0
- package/src/billing-foundation/__tests__/checkout-core.test.ts +35 -0
- package/src/billing-foundation/__tests__/sync-subscription.integration.test.ts +469 -0
- package/src/billing-foundation/changes.json +14 -0
- package/src/billing-foundation/checkout-core.ts +8 -4
- package/src/billing-foundation/constants.ts +6 -0
- package/src/billing-foundation/feature.ts +37 -2
- package/src/billing-foundation/handlers/process-event.write.ts +112 -106
- package/src/billing-foundation/handlers/switch-plan.write.ts +7 -0
- package/src/billing-foundation/handlers/sync-subscription.write.ts +164 -0
- package/src/billing-foundation/i18n.ts +9 -0
- package/src/billing-foundation/index.ts +1 -0
- package/src/billing-foundation/plan-catalog.ts +22 -5
- package/src/billing-foundation/types.ts +27 -0
- package/src/billing-foundation/web/__tests__/billing-plans-panel.test.tsx +28 -0
- package/src/billing-foundation/web/billing-plans-panel.tsx +8 -1
- package/src/channel-email/__tests__/email-channel.test.ts +92 -0
- package/src/channel-email/__tests__/smtp-transport-pinning.test.ts +42 -0
- package/src/channel-email/changes.json +9 -1
- package/src/channel-email/email-channel.ts +31 -6
- package/src/channel-email/smtp-transport.ts +7 -0
- package/src/delivery/__tests__/delivery.integration.test.ts +101 -26
- package/src/delivery/changes.json +7 -0
- package/src/delivery/feature.ts +1 -1
- package/src/delivery/handlers/unsubscribe-address.write.ts +1 -1
- package/src/delivery/handlers/unsubscribe-user.write.ts +1 -1
- package/src/delivery/index.ts +2 -1
- package/src/delivery/public-names.ts +1 -1
- package/src/delivery/unsubscribe.ts +167 -89
- 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__/feature.boot.test.ts +9 -2
- package/src/step-dispatcher/__tests__/webhook-runner.test.ts +283 -0
- package/src/step-dispatcher/changes.json +21 -1
- package/src/step-dispatcher/feature.ts +63 -15
- package/src/step-dispatcher/index.ts +11 -2
- package/src/step-dispatcher/webhook-runner.ts +157 -31
- package/src/subscription-stripe/__tests__/plugin-methods.test.ts +117 -0
- package/src/subscription-stripe/changes.json +6 -0
- package/src/subscription-stripe/feature.ts +5 -1
- package/src/subscription-stripe/plugin-methods.ts +53 -0
- package/src/subscription-stripe/verify-webhook.ts +52 -28
- package/src/tenant-handover/__tests__/claim.integration.test.ts +62 -4
- package/src/tenant-handover/changes.json +7 -0
- package/src/tenant-handover/handlers/claim.write.ts +1 -0
- package/src/tenant-handover/move-entity-graph.ts +30 -44
- 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
|
@@ -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(
|
|
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
|
-
|
|
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
|
+
});
|