@cosmicdrift/kumiko-bundled-features 0.319.0 → 0.321.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/package.json +9 -9
  2. package/src/auth-email-password/__tests__/auth-tenants-rate-limit.integration.test.ts +3 -0
  3. package/src/auth-email-password/__tests__/post-auth-landing.integration.test.ts +321 -0
  4. package/src/auth-email-password/__tests__/public-routes-rate-limit.integration.test.ts +4 -0
  5. package/src/auth-email-password/__tests__/session-callbacks.integration.test.ts +4 -0
  6. package/src/auth-email-password/__tests__/signup-flow.integration.test.ts +21 -1
  7. package/src/auth-email-password/__tests__/signup-handover.integration.test.ts +10 -0
  8. package/src/auth-email-password/auth-paths.ts +10 -6
  9. package/src/auth-email-password/changes.json +12 -0
  10. package/src/auth-email-password/index.ts +1 -1
  11. package/src/auth-email-password/web/__tests__/auth-form-logic.test.ts +34 -1
  12. package/src/auth-email-password/web/__tests__/invite-accept-screen.test.tsx +30 -0
  13. package/src/auth-email-password/web/__tests__/signup-complete-screen.test.tsx +26 -0
  14. package/src/auth-email-password/web/auth-client.ts +16 -1
  15. package/src/auth-email-password/web/auth-form-logic.ts +10 -0
  16. package/src/auth-email-password/web/invite-accept-screen.tsx +10 -5
  17. package/src/auth-email-password/web/signup-complete-screen.tsx +11 -7
  18. package/src/auth-mfa/web/mfa-client.ts +18 -2
  19. package/src/billing-foundation/__tests__/billing-plans.integration.test.ts +27 -0
  20. package/src/billing-foundation/__tests__/checkout-core.test.ts +35 -0
  21. package/src/billing-foundation/__tests__/sync-subscription.integration.test.ts +469 -0
  22. package/src/billing-foundation/changes.json +14 -0
  23. package/src/billing-foundation/checkout-core.ts +8 -4
  24. package/src/billing-foundation/constants.ts +6 -0
  25. package/src/billing-foundation/feature.ts +37 -2
  26. package/src/billing-foundation/handlers/process-event.write.ts +112 -106
  27. package/src/billing-foundation/handlers/switch-plan.write.ts +7 -0
  28. package/src/billing-foundation/handlers/sync-subscription.write.ts +164 -0
  29. package/src/billing-foundation/i18n.ts +9 -0
  30. package/src/billing-foundation/index.ts +1 -0
  31. package/src/billing-foundation/plan-catalog.ts +22 -5
  32. package/src/billing-foundation/types.ts +27 -0
  33. package/src/billing-foundation/web/__tests__/billing-plans-panel.test.tsx +28 -0
  34. package/src/billing-foundation/web/billing-plans-panel.tsx +8 -1
  35. package/src/channel-email/__tests__/email-channel.test.ts +92 -0
  36. package/src/channel-email/__tests__/smtp-transport-pinning.test.ts +42 -0
  37. package/src/channel-email/changes.json +9 -1
  38. package/src/channel-email/email-channel.ts +31 -6
  39. package/src/channel-email/smtp-transport.ts +7 -0
  40. package/src/delivery/__tests__/delivery.integration.test.ts +101 -26
  41. package/src/delivery/changes.json +7 -0
  42. package/src/delivery/feature.ts +1 -1
  43. package/src/delivery/handlers/unsubscribe-address.write.ts +1 -1
  44. package/src/delivery/handlers/unsubscribe-user.write.ts +1 -1
  45. package/src/delivery/index.ts +2 -1
  46. package/src/delivery/public-names.ts +1 -1
  47. package/src/delivery/unsubscribe.ts +167 -89
  48. package/src/file-derivatives/feature.ts +1 -1
  49. package/src/file-derivatives/handlers/public-variant.query.ts +5 -7
  50. package/src/foundation-shared/__tests__/mail-host-policy.test.ts +68 -0
  51. package/src/foundation-shared/index.ts +9 -0
  52. package/src/foundation-shared/mail-host-policy.ts +70 -0
  53. package/src/inbound-provider-imap/__tests__/imap-foundation.integration.test.ts +11 -0
  54. package/src/inbound-provider-imap/__tests__/imap-live.integration.test.ts +14 -1
  55. package/src/inbound-provider-imap/__tests__/plugin-mocked.test.ts +88 -3
  56. package/src/inbound-provider-imap/changes.json +14 -1
  57. package/src/inbound-provider-imap/feature.ts +6 -3
  58. package/src/inbound-provider-imap/imap-client.ts +60 -2
  59. package/src/inbound-provider-imap/index.ts +1 -1
  60. package/src/ledger/__tests__/ledger.integration.test.ts +80 -0
  61. package/src/ledger/changes.json +9 -1
  62. package/src/ledger/entity.ts +14 -1
  63. package/src/mail-foundation/__tests__/mail-foundation.integration.test.ts +86 -1
  64. package/src/mail-transport-smtp/__tests__/feature.test.ts +123 -3
  65. package/src/mail-transport-smtp/changes.json +14 -1
  66. package/src/mail-transport-smtp/feature.ts +76 -1
  67. package/src/mail-transport-smtp/index.ts +6 -1
  68. package/src/personal-access-tokens/__tests__/pat.integration.test.ts +59 -0
  69. package/src/sessions/__tests__/sessions.integration.test.ts +5 -0
  70. package/src/shared/password-hashing.test.ts +21 -13
  71. package/src/step-dispatcher/__tests__/feature.boot.test.ts +9 -2
  72. package/src/step-dispatcher/__tests__/webhook-runner.test.ts +283 -0
  73. package/src/step-dispatcher/changes.json +21 -1
  74. package/src/step-dispatcher/feature.ts +63 -15
  75. package/src/step-dispatcher/index.ts +11 -2
  76. package/src/step-dispatcher/webhook-runner.ts +157 -31
  77. package/src/subscription-stripe/__tests__/plugin-methods.test.ts +117 -0
  78. package/src/subscription-stripe/changes.json +6 -0
  79. package/src/subscription-stripe/feature.ts +5 -1
  80. package/src/subscription-stripe/plugin-methods.ts +53 -0
  81. package/src/subscription-stripe/verify-webhook.ts +52 -28
  82. package/src/tenant-handover/__tests__/claim.integration.test.ts +62 -4
  83. package/src/tenant-handover/changes.json +7 -0
  84. package/src/tenant-handover/handlers/claim.write.ts +1 -0
  85. package/src/tenant-handover/move-entity-graph.ts +30 -44
  86. package/src/user-data-rights/__tests__/anonymous-deletion.integration.test.ts +11 -0
  87. package/src/user-data-rights/__tests__/download-by-token-ip-bucket.integration.test.ts +170 -0
  88. package/src/user-data-rights/__tests__/download.integration.test.ts +12 -6
  89. package/src/user-data-rights/__tests__/extract-audit-meta.test.ts +36 -0
  90. package/src/user-data-rights/changes.json +6 -0
  91. package/src/user-data-rights/feature.ts +35 -37
@@ -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 ?? "smtp.contract-test.invalid"],
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
 
@@ -1,3 +1,8 @@
1
1
  // Public API of the mail-transport-smtp bundled-feature.
2
2
 
3
- export { mailTransportSmtpFeature, SMTP_PASSWORD } from "./feature";
3
+ export {
4
+ mailTransportSmtpEnvSchema,
5
+ mailTransportSmtpFeature,
6
+ SMTP_PASSWORD,
7
+ setSmtpMailHostLookup,
8
+ } from "./feature";
@@ -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 medianMs = async (fn: () => Promise<unknown>, runs = 5): Promise<number> => {
5
- const samples: number[] = [];
6
- for (let i = 0; i < runs; i++) {
7
- const start = performance.now();
8
- await fn();
9
- samples.push(performance.now() - start);
10
- }
11
- samples.sort((a, b) => a - b);
12
- return samples[Math.floor(samples.length / 2)] ?? 0;
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
- const realMs = await medianMs(() => verifyPassword(realHash, "wrong-password"));
28
- const dummyMs = await medianMs(() => verifyDummyPassword("wrong-password"));
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 is a wide, non-flaky bound that still
32
- // fails hard if the dummy verify is ever removed.
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 standalone", () => {
11
- expect(() => validateBoot([createStepDispatcherFeature()])).not.toThrow();
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
  });