@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
|
@@ -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
|
+
]
|
|
@@ -25,14 +25,21 @@
|
|
|
25
25
|
// - `config` — für die Tenant-Config-Keys
|
|
26
26
|
// - `secrets` — für das verschlüsselte SMTP-Password
|
|
27
27
|
|
|
28
|
+
import type { lookup } from "node:dns/promises";
|
|
28
29
|
import {
|
|
29
30
|
createSmtpTransport,
|
|
30
31
|
type EmailTransport,
|
|
31
32
|
} from "@cosmicdrift/kumiko-bundled-features/channel-email";
|
|
32
33
|
import {
|
|
34
|
+
BlockedHostError,
|
|
35
|
+
HostResolutionError,
|
|
36
|
+
MAIL_ALLOWED_PRIVATE_HOSTS_ENV_VAR,
|
|
37
|
+
type MailConnectTarget,
|
|
38
|
+
readAllowedPrivateMailHostsFromEnv,
|
|
33
39
|
requireDefined,
|
|
34
40
|
requireNonEmpty,
|
|
35
41
|
requireSecretSet,
|
|
42
|
+
resolveMailConnectTarget,
|
|
36
43
|
} from "@cosmicdrift/kumiko-bundled-features/foundation-shared";
|
|
37
44
|
import {
|
|
38
45
|
MAIL_TRANSPORT_EXTENSION,
|
|
@@ -41,9 +48,41 @@ import {
|
|
|
41
48
|
} from "@cosmicdrift/kumiko-bundled-features/mail-foundation";
|
|
42
49
|
import { requireSecretsContext } from "@cosmicdrift/kumiko-bundled-features/secrets";
|
|
43
50
|
import { access, createTenantConfig, defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
51
|
+
import { UnconfiguredError } from "@cosmicdrift/kumiko-framework/errors";
|
|
52
|
+
import { createFallbackLogger } from "@cosmicdrift/kumiko-framework/logging";
|
|
53
|
+
import * as z from "zod";
|
|
44
54
|
|
|
45
55
|
const FEATURE_NAME = "mail-transport-smtp";
|
|
46
56
|
|
|
57
|
+
const log = createFallbackLogger(FEATURE_NAME);
|
|
58
|
+
|
|
59
|
+
// Operator escape hatch for an internal relay or a dev/test SMTP server
|
|
60
|
+
// (mailpit, MailHog): KUMIKO_MAIL_ALLOWED_PRIVATE_HOSTS, an operator env
|
|
61
|
+
// var never a tenant-config value, so a tenant can never grant themselves
|
|
62
|
+
// the private-host bypass. Declared here (not duplicated in
|
|
63
|
+
// inbound-provider-imap's envSchema) since both features read the same
|
|
64
|
+
// var and composeEnvSchema rejects two features declaring the same key —
|
|
65
|
+
// see foundation-shared/mail-host-policy.ts for the shared reader + the
|
|
66
|
+
// guard this feeds.
|
|
67
|
+
export const mailTransportSmtpEnvSchema = z.object({
|
|
68
|
+
[MAIL_ALLOWED_PRIVATE_HOSTS_ENV_VAR]: z
|
|
69
|
+
.string()
|
|
70
|
+
.optional()
|
|
71
|
+
.describe(
|
|
72
|
+
"Comma-separated operator allowlist of private/internal hosts (e.g. a local mailpit/greenmail dev server) that bypass the public-address check for SMTP and IMAP host config. Shared with inbound-provider-imap — never a tenant-config value.",
|
|
73
|
+
),
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// Test-only DNS seam — production never calls this, resolveMailConnectTarget
|
|
77
|
+
// defaults to the real resolver. Lets tests pin deterministic, network-free
|
|
78
|
+
// host resolutions instead of depending on real DNS for a placeholder host.
|
|
79
|
+
// Reset it in afterEach/afterAll — this is module-global state.
|
|
80
|
+
let mailHostLookup: typeof lookup | undefined;
|
|
81
|
+
|
|
82
|
+
export function setSmtpMailHostLookup(fn: typeof lookup | undefined): void {
|
|
83
|
+
mailHostLookup = fn;
|
|
84
|
+
}
|
|
85
|
+
|
|
47
86
|
// =============================================================================
|
|
48
87
|
// Feature-definition
|
|
49
88
|
// =============================================================================
|
|
@@ -60,6 +99,7 @@ export const mailTransportSmtpFeature = defineFeature(FEATURE_NAME, (r) => {
|
|
|
60
99
|
r.requires("config");
|
|
61
100
|
r.requires("secrets");
|
|
62
101
|
r.requires("mail-foundation");
|
|
102
|
+
r.envSchema(mailTransportSmtpEnvSchema);
|
|
63
103
|
|
|
64
104
|
// Provider-secret. Sensitive: redact-helper for admin-UI display.
|
|
65
105
|
const password = r.secret("smtp.password", {
|
|
@@ -134,6 +174,18 @@ export const SMTP_PASSWORD = mailTransportSmtpFeature.exports.password;
|
|
|
134
174
|
// Internal: build the EmailTransport from tenant config + secret
|
|
135
175
|
// =============================================================================
|
|
136
176
|
|
|
177
|
+
// Tenant-visible for both a blocked host and a DNS failure — must not
|
|
178
|
+
// reveal which one occurred, or the host itself. Built once so a
|
|
179
|
+
// HostResolutionError raised for the other branch can reuse the exact same
|
|
180
|
+
// `.message` string byte-for-byte instead of just a similar hint.
|
|
181
|
+
function mailHostUnreachableError(): UnconfiguredError {
|
|
182
|
+
return new UnconfiguredError({
|
|
183
|
+
feature: FEATURE_NAME,
|
|
184
|
+
key: "host",
|
|
185
|
+
hint: "host is not reachable or not allowed",
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
|
|
137
189
|
async function buildSmtpTransport(
|
|
138
190
|
ctx: MailTransportContext,
|
|
139
191
|
tenantId: string,
|
|
@@ -177,12 +229,35 @@ async function buildSmtpTransport(
|
|
|
177
229
|
|
|
178
230
|
const password = await readPassword(ctx, tenantId);
|
|
179
231
|
|
|
232
|
+
let target: MailConnectTarget;
|
|
233
|
+
try {
|
|
234
|
+
target = await resolveMailConnectTarget(host, {
|
|
235
|
+
allowedPrivateMailHosts: readAllowedPrivateMailHostsFromEnv(),
|
|
236
|
+
lookupFn: mailHostLookup,
|
|
237
|
+
});
|
|
238
|
+
} catch (err) {
|
|
239
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
240
|
+
// Both branches throw the exact same tenant-visible message text — the
|
|
241
|
+
// blocked-vs-unresolvable distinction and the host itself stay
|
|
242
|
+
// server-log-only. Class stays distinct: UnconfiguredError (422, a
|
|
243
|
+
// config problem, no retry) for a blocked host vs HostResolutionError
|
|
244
|
+
// (transient, retried) for a DNS failure — see mailHostUnreachableError().
|
|
245
|
+
const unreachable = mailHostUnreachableError();
|
|
246
|
+
if (err instanceof BlockedHostError) {
|
|
247
|
+
log.warn("rejected blocked host", { host, reason });
|
|
248
|
+
throw unreachable;
|
|
249
|
+
}
|
|
250
|
+
log.warn("host resolution failed", { host, reason });
|
|
251
|
+
throw new HostResolutionError(unreachable.message);
|
|
252
|
+
}
|
|
253
|
+
|
|
180
254
|
return createSmtpTransport({
|
|
181
|
-
host,
|
|
255
|
+
host: target.host,
|
|
182
256
|
port,
|
|
183
257
|
secure,
|
|
184
258
|
from,
|
|
185
259
|
auth: { user: authUser, pass: password },
|
|
260
|
+
...(target.servername && { servername: target.servername }),
|
|
186
261
|
});
|
|
187
262
|
}
|
|
188
263
|
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
isPiiCiphertext,
|
|
10
10
|
} from "@cosmicdrift/kumiko-framework/crypto";
|
|
11
11
|
import type { SessionUser, TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
12
|
+
import { createInMemoryFileProvider } from "@cosmicdrift/kumiko-framework/files";
|
|
12
13
|
import {
|
|
13
14
|
setupTestStack,
|
|
14
15
|
type TestStack,
|
|
@@ -124,6 +125,10 @@ beforeAll(async () => {
|
|
|
124
125
|
// ≤2 requests per (distinct) token, so this ceiling never trips them.
|
|
125
126
|
patRateLimiter: createInMemoryLoginRateLimiter(3, 60_000),
|
|
126
127
|
},
|
|
128
|
+
// Only exercised by the route-guard describe block below (POST/GET
|
|
129
|
+
// /api/files) — proves the guard blocks a PAT there even though the
|
|
130
|
+
// route is actually mounted and reachable for a JWT/cookie caller.
|
|
131
|
+
files: { storageProvider: createInMemoryFileProvider() },
|
|
127
132
|
});
|
|
128
133
|
h = makeSessionHelpers(stack, TENANT);
|
|
129
134
|
|
|
@@ -550,3 +555,57 @@ describe("PAT list projection (fw#2548 Teil B)", () => {
|
|
|
550
555
|
expect(expired.rows[0]?.status).toBe("expired");
|
|
551
556
|
});
|
|
552
557
|
});
|
|
558
|
+
|
|
559
|
+
describe("PAT route guard: dispatcher routes only (#security)", () => {
|
|
560
|
+
test("PAT → GET /api/sse → 403, even with a granted scope", async () => {
|
|
561
|
+
const actor = await actorFor("route-guard-sse@example.com");
|
|
562
|
+
const token = await mintToken(actor);
|
|
563
|
+
const res = await stack.http.raw("GET", "/api/sse", undefined, {
|
|
564
|
+
Authorization: `Bearer ${token}`,
|
|
565
|
+
});
|
|
566
|
+
expect(res.status).toBe(403);
|
|
567
|
+
const body = (await res.json()) as { error: { code: string } };
|
|
568
|
+
expect(body.error.code).toBe("access_denied");
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
test("PAT → POST /api/files → 403 (route is mounted — a JWT gets past the guard to the 400 validation)", async () => {
|
|
572
|
+
const actor = await actorFor("route-guard-files-post@example.com");
|
|
573
|
+
const token = await mintToken(actor);
|
|
574
|
+
const patRes = await h.authedPost("/api/files", token);
|
|
575
|
+
expect(patRes.status).toBe(403);
|
|
576
|
+
|
|
577
|
+
const jwt = await loginToken("route-guard-files-post@example.com");
|
|
578
|
+
const jwtRes = await h.authedPost("/api/files", jwt);
|
|
579
|
+
expect(jwtRes.status).toBe(400);
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
test("PAT → GET /api/files/:id → 403 (a JWT gets past the guard to the 404 not_found)", async () => {
|
|
583
|
+
const actor = await actorFor("route-guard-files-get@example.com");
|
|
584
|
+
const token = await mintToken(actor);
|
|
585
|
+
const patRes = await stack.http.raw("GET", "/api/files/does-not-exist", undefined, {
|
|
586
|
+
Authorization: `Bearer ${token}`,
|
|
587
|
+
});
|
|
588
|
+
expect(patRes.status).toBe(403);
|
|
589
|
+
|
|
590
|
+
const jwt = await loginToken("route-guard-files-get@example.com");
|
|
591
|
+
const jwtRes = await stack.http.raw("GET", "/api/files/does-not-exist", undefined, {
|
|
592
|
+
Authorization: `Bearer ${jwt}`,
|
|
593
|
+
});
|
|
594
|
+
expect(jwtRes.status).toBe(404);
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
test("PAT → GET /api/auth/tenants → 403 (a JWT reaches the real 200)", async () => {
|
|
598
|
+
const actor = await actorFor("route-guard-tenants@example.com");
|
|
599
|
+
const token = await mintToken(actor);
|
|
600
|
+
const patRes = await stack.http.raw("GET", "/api/auth/tenants", undefined, {
|
|
601
|
+
Authorization: `Bearer ${token}`,
|
|
602
|
+
});
|
|
603
|
+
expect(patRes.status).toBe(403);
|
|
604
|
+
|
|
605
|
+
const jwt = await loginToken("route-guard-tenants@example.com");
|
|
606
|
+
const jwtRes = await stack.http.raw("GET", "/api/auth/tenants", undefined, {
|
|
607
|
+
Authorization: `Bearer ${jwt}`,
|
|
608
|
+
});
|
|
609
|
+
expect(jwtRes.status).toBe(200);
|
|
610
|
+
});
|
|
611
|
+
});
|
|
@@ -100,6 +100,11 @@ beforeEach(async () => {
|
|
|
100
100
|
userSessionTable,
|
|
101
101
|
eventsTable,
|
|
102
102
|
]);
|
|
103
|
+
// login.write.ts is `per: "ip+handler"` rate-limited (20/60s). h.login()
|
|
104
|
+
// goes through stack.http.raw with no socket address, so every test in
|
|
105
|
+
// this file shares one "unknown" IP bucket — flush it per test so the
|
|
106
|
+
// file's cumulative login() calls can't push a later test over the cap.
|
|
107
|
+
await stack.redis.flushNamespace();
|
|
103
108
|
});
|
|
104
109
|
|
|
105
110
|
describe("sessions feature — login → check → revoke → rejected", () => {
|
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
import { hashPassword, verifyDummyPassword, verifyPassword } from "./password-hashing";
|
|
3
3
|
|
|
4
|
-
const
|
|
5
|
-
const
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
samples.sort((a, b) => a - b);
|
|
12
|
-
return
|
|
4
|
+
const timeMs = async (fn: () => Promise<unknown>): Promise<number> => {
|
|
5
|
+
const start = performance.now();
|
|
6
|
+
await fn();
|
|
7
|
+
return performance.now() - start;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
const median = (samples: readonly number[]): number => {
|
|
11
|
+
const sorted = [...samples].sort((a, b) => a - b);
|
|
12
|
+
return sorted[Math.floor(sorted.length / 2)] ?? 0;
|
|
13
13
|
};
|
|
14
14
|
|
|
15
15
|
// #774: the login no-user path must cost the same argon2 latency as a real
|
|
@@ -24,12 +24,20 @@ describe("verifyDummyPassword (anti-enumeration timing)", () => {
|
|
|
24
24
|
// warm the lazily-cached dummy hash so we time the verify, not the one-off hash
|
|
25
25
|
await verifyDummyPassword("warmup");
|
|
26
26
|
|
|
27
|
-
|
|
28
|
-
|
|
27
|
+
// Interleaved so both paths see the same machine load: two back-to-back
|
|
28
|
+
// median blocks drift apart on a busy CI runner and skew the ratio.
|
|
29
|
+
const realSamples: number[] = [];
|
|
30
|
+
const dummySamples: number[] = [];
|
|
31
|
+
for (let i = 0; i < 7; i++) {
|
|
32
|
+
realSamples.push(await timeMs(() => verifyPassword(realHash, "wrong-password")));
|
|
33
|
+
dummySamples.push(await timeMs(() => verifyDummyPassword("wrong-password")));
|
|
34
|
+
}
|
|
35
|
+
const realMs = median(realSamples);
|
|
36
|
+
const dummyMs = median(dummySamples);
|
|
29
37
|
|
|
30
38
|
// A skipped miss-path (the bug) is sub-millisecond; a real argon2 verify
|
|
31
|
-
// is ~20ms. A half-of-real floor
|
|
32
|
-
//
|
|
39
|
+
// is ~20ms. A half-of-real floor still fails hard if the dummy verify is
|
|
40
|
+
// ever removed.
|
|
33
41
|
expect(dummyMs).toBeGreaterThan(realMs * 0.5);
|
|
34
42
|
});
|
|
35
43
|
});
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
import { validateBoot } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
+
import { createSecretsFeature } from "../../secrets";
|
|
3
4
|
import { createStepDispatcherFeature } from "../feature";
|
|
4
5
|
|
|
5
6
|
describe("step-dispatcher (fw#2068)", () => {
|
|
@@ -7,7 +8,13 @@ describe("step-dispatcher (fw#2068)", () => {
|
|
|
7
8
|
expect(createStepDispatcherFeature().systemScope).toBeFalsy();
|
|
8
9
|
});
|
|
9
10
|
|
|
10
|
-
test("boot-validates
|
|
11
|
-
expect(() =>
|
|
11
|
+
test("boot-validates alongside the secrets feature it requires", () => {
|
|
12
|
+
expect(() =>
|
|
13
|
+
validateBoot([createStepDispatcherFeature(), createSecretsFeature()]),
|
|
14
|
+
).not.toThrow();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("boot-validation rejects step-dispatcher without secrets mounted", () => {
|
|
18
|
+
expect(() => validateBoot([createStepDispatcherFeature()])).toThrow(/secrets/);
|
|
12
19
|
});
|
|
13
20
|
});
|