@cosmicdrift/kumiko-bundled-features 0.289.0 → 0.291.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 (34) hide show
  1. package/package.json +9 -9
  2. package/src/auth-email-password/__tests__/query-as-member.integration.test.ts +15 -2
  3. package/src/auth-email-password/changes.json +6 -0
  4. package/src/auth-email-password/web/__tests__/auth-form-logic.test.ts +31 -2
  5. package/src/auth-email-password/web/__tests__/invite-accept-screen.test.tsx +120 -0
  6. package/src/auth-email-password/web/__tests__/signup-complete-screen.test.tsx +72 -0
  7. package/src/auth-email-password/web/auth-form-logic.ts +5 -5
  8. package/src/auth-email-password/web/invite-accept-screen.tsx +46 -21
  9. package/src/auth-email-password/web/signup-complete-screen.tsx +11 -3
  10. package/src/auth-mfa/__tests__/verify.integration.test.ts +6 -2
  11. package/src/billing-foundation/__tests__/billing-foundation.integration.test.ts +2 -0
  12. package/src/billing-foundation/__tests__/subscription-tier-sync.integration.test.ts +2 -0
  13. package/src/delivery/feature.ts +6 -1
  14. package/src/document-ingest-foundation/__tests__/feature.integration.test.ts +8 -1
  15. package/src/inbound-mail-foundation/__tests__/inbound-mail-foundation.integration.test.ts +2 -0
  16. package/src/inbound-mail-foundation/__tests__/retention.integration.test.ts +2 -0
  17. package/src/inbound-mail-foundation/__tests__/watch-supervisor.integration.test.ts +2 -0
  18. package/src/jobs/__tests__/tenant-job-failures.integration.test.ts +275 -0
  19. package/src/jobs/changes.json +7 -0
  20. package/src/jobs/constants.ts +1 -0
  21. package/src/jobs/db/queries/retention.ts +17 -1
  22. package/src/jobs/feature.ts +7 -1
  23. package/src/jobs/handlers/tenant-failures.query.ts +82 -0
  24. package/src/jobs/index.ts +1 -0
  25. package/src/jobs/job-run-logger.ts +73 -4
  26. package/src/jobs/tenant-job-failure-table.ts +39 -0
  27. package/src/rate-limiting/__tests__/rate-limiting.integration.test.ts +69 -1
  28. package/src/rate-limiting/changes.json +9 -1
  29. package/src/rate-limiting/constants.ts +1 -0
  30. package/src/rate-limiting/handlers/bucket-access.ts +39 -0
  31. package/src/rate-limiting/handlers/status.query.ts +8 -2
  32. package/src/subscription-mollie/__tests__/mollie-foundation.integration.test.ts +3 -0
  33. package/src/subscription-stripe/__tests__/stripe-foundation.integration.test.ts +4 -0
  34. package/src/tenant-lifecycle/__tests__/tenant-lifecycle.integration.test.ts +108 -0
@@ -7,7 +7,12 @@
7
7
 
8
8
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
9
9
  import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
10
- import { setupTestStack, type TestStack, TestUsers } from "@cosmicdrift/kumiko-framework/stack";
10
+ import {
11
+ setupTestStack,
12
+ type TestStack,
13
+ TestUsers,
14
+ testTenantId,
15
+ } from "@cosmicdrift/kumiko-framework/stack";
11
16
  import { z } from "zod";
12
17
  import { createRateLimitingFeature } from "../feature";
13
18
 
@@ -93,3 +98,66 @@ describe("rate-limiting feature — status query", () => {
93
98
  expect(res.status).toBe(403);
94
99
  });
95
100
  });
101
+
102
+ // The bucket key is caller-supplied, so the tenant boundary has to be drawn
103
+ // on it server-side. i18nKey is asserted alongside the code because a plain
104
+ // 403 could also come from tenant resolution and would pass either way.
105
+ const OUTSIDE_TENANT_KEY = "rateLimiting.errors.bucketOutsideTenant";
106
+
107
+ describe("rate-limiting feature — bucket tenant scope", () => {
108
+ test("denies an admin the bucket of another tenant", async () => {
109
+ const err = await stack.http.queryErr(
110
+ "rate-limiting:query:status",
111
+ { bucket: `tenant:${testTenantId(2)}`, limit: 5, windowSeconds: 60 },
112
+ admin,
113
+ );
114
+ expect(err.code).toBe("access_denied");
115
+ expect(err.i18nKey).toBe(OUTSIDE_TENANT_KEY);
116
+ expect(err.httpStatus).toBe(403);
117
+ });
118
+
119
+ test("denies a key that only starts with the caller's tenant id", async () => {
120
+ const err = await stack.http.queryErr(
121
+ "rate-limiting:query:status",
122
+ { bucket: `tenant:${admin.tenantId}-other`, limit: 5, windowSeconds: 60 },
123
+ admin,
124
+ );
125
+ expect(err.i18nKey).toBe(OUTSIDE_TENANT_KEY);
126
+ });
127
+
128
+ test("denies another user's bucket and the global IP buckets", async () => {
129
+ for (const bucket of [`user:${TestUsers.user.id}`, "l1:203.0.113.5", "ip:203.0.113.5"]) {
130
+ const err = await stack.http.queryErr(
131
+ "rate-limiting:query:status",
132
+ { bucket, limit: 5, windowSeconds: 60 },
133
+ admin,
134
+ );
135
+ expect(err.i18nKey).toBe(OUTSIDE_TENANT_KEY);
136
+ }
137
+ });
138
+
139
+ test("allows the caller's own tenant and handler-scoped buckets", async () => {
140
+ for (const bucket of [
141
+ `tenant:${admin.tenantId}`,
142
+ `tenant+handler:${admin.tenantId}:rl-probe:query:ping`,
143
+ `user+handler:${admin.id}:rl-probe:query:ping`,
144
+ ]) {
145
+ const status = await stack.http.queryOk<{ bucket: string; remaining: number }>(
146
+ "rate-limiting:query:status",
147
+ { bucket, limit: 5, windowSeconds: 60 },
148
+ admin,
149
+ );
150
+ expect(status.bucket).toBe(bucket);
151
+ expect(status.remaining).toBe(5);
152
+ }
153
+ });
154
+
155
+ test("leaves SystemAdmin access to foreign buckets untouched", async () => {
156
+ const status = await stack.http.queryOk<{ bucket: string }>(
157
+ "rate-limiting:query:status",
158
+ { bucket: `tenant:${testTenantId(2)}`, limit: 5, windowSeconds: 60 },
159
+ TestUsers.systemAdmin,
160
+ );
161
+ expect(status.bucket).toBe(`tenant:${testTenantId(2)}`);
162
+ });
163
+ });
@@ -1 +1,9 @@
1
- []
1
+ [
2
+ {
3
+ "version": "0.291.0",
4
+ "type": "breaking",
5
+ "title": "`rate-limiting:query:status` only peeks buckets of the calling tenant (fw#3076)",
6
+ "detail": "The handler took an arbitrary bucket key and peeked it, so a tenant Admin who knew or guessed another tenant's key could read that bucket's counter. The key is now matched segment-exact against the caller: `tenant:`/`tenant+handler:` must carry the caller's own tenant id, `user:`/`user+handler:` the caller's own user id. Everything else — other tenants, other users, the global `ip:`, `l1:` and `l2:` middleware buckets, malformed keys — is `access_denied` (`bucket_outside_tenant`) unless the caller is SystemAdmin, whose access is unchanged. The check runs before the resolver-wiring check, so a denied caller learns nothing about the mount either.",
7
+ "migration": "Only affects non-SystemAdmin callers of `rate-limiting:query:status`. A tenant\nAdmin keeps `tenant:<own>`, `tenant+handler:<own>:<handler>`, `user:<self>` and\n`user+handler:<self>:<handler>`. Two reads it had before now need SystemAdmin:\nanother user's bucket inside the same tenant (the tenant is not part of a\n`user:` key, so it cannot be verified without a membership lookup) and the\nglobal `ip:`/`l1:`/`l2:` buckets, which are not tenant-owned. Ops tooling that\npeeks those from a tenant Admin session has to run as SystemAdmin."
8
+ }
9
+ ]
@@ -6,4 +6,5 @@ export const RateLimitQueries = {
6
6
 
7
7
  export const RateLimitErrors = {
8
8
  resolverUnavailable: "rate_limit_resolver_unavailable",
9
+ bucketOutsideTenant: "bucket_outside_tenant",
9
10
  } as const;
@@ -0,0 +1,39 @@
1
+ import { AccessDeniedError } from "@cosmicdrift/kumiko-framework/errors";
2
+ import { RateLimitErrors } from "../constants";
3
+
4
+ // Bucket keys are `<dimension>:<subject>[:<handler>]` (framework
5
+ // rate-limit/bucket.ts) — only `tenant*` and `user*` carry a subject the
6
+ // caller can own. `l1:`/`l2:` (middleware.ts) and every `ip*` bucket are
7
+ // global, so they stay SystemAdmin-only.
8
+ type BucketCaller = {
9
+ readonly id: string;
10
+ readonly tenantId: string;
11
+ readonly roles: readonly string[];
12
+ };
13
+
14
+ function ownsBucket(bucket: string, caller: BucketCaller): boolean {
15
+ const [dimension, subject, ...handler] = bucket.split(":");
16
+ if (dimension === undefined) return false;
17
+ const owner =
18
+ dimension === "tenant" || dimension === "tenant+handler"
19
+ ? caller.tenantId
20
+ : dimension === "user" || dimension === "user+handler"
21
+ ? caller.id
22
+ : undefined;
23
+ if (owner === undefined) return false;
24
+ // Segment-exact: a startsWith check would pass `tenant:<own-id>-other`.
25
+ if (subject !== owner) return false;
26
+ return dimension.endsWith("+handler") ? handler.length > 0 : handler.length === 0;
27
+ }
28
+
29
+ export function bucketAccessDenied(
30
+ bucket: string,
31
+ caller: BucketCaller,
32
+ ): AccessDeniedError | undefined {
33
+ if (caller.roles.includes("SystemAdmin")) return undefined;
34
+ if (ownsBucket(bucket, caller)) return undefined;
35
+ return new AccessDeniedError({
36
+ i18nKey: "rateLimiting.errors.bucketOutsideTenant",
37
+ details: { reason: RateLimitErrors.bucketOutsideTenant },
38
+ });
39
+ }
@@ -2,6 +2,7 @@ import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
2
2
  import { UnprocessableError } from "@cosmicdrift/kumiko-framework/errors";
3
3
  import { z } from "zod";
4
4
  import { RateLimitErrors } from "../constants";
5
+ import { bucketAccessDenied } from "./bucket-access";
5
6
 
6
7
  // Ops-side bucket inspection. Pass the bucket key (e.g. "user:42",
7
8
  // "user+handler:42:orders:write:order:create") plus the limit/window the bucket
@@ -16,14 +17,15 @@ import { RateLimitErrors } from "../constants";
16
17
  // Bucket key format is owned by the framework (see rate-limit/bucket.ts);
17
18
  // callers pass the constructed key directly. We don't synthesize from
18
19
  // (per, user, handler) here — peeking is a low-level op, the lookup
19
- // surface stays small.
20
+ // surface stays small. Because the key is caller-supplied, bucket-access.ts
21
+ // restricts it to the caller's own tenant/user unless they are SystemAdmin.
20
22
  export const rateLimitStatus = defineQueryHandler({
21
23
  // Short name — the registry qualifies this to `rate-limiting:query:status`
22
24
  // when the feature is registered. Passing the qualified form here would
23
25
  // double-prefix it and the handler wouldn't be reachable.
24
26
  name: "status",
25
27
  description:
26
- "Peeks at one rate-limit bucket and returns its remaining tokens, window and next reset without consuming a token; use it to explain why a caller is being throttled.",
28
+ "Peeks at one rate-limit bucket and returns its remaining tokens, window and next reset without consuming a token; use it to explain why a caller is being throttled. Non-SystemAdmin callers may only peek their own tenant's buckets (`tenant:`/`tenant+handler:`) and their own user buckets (`user:`/`user+handler:`); every other key, including the global `ip:`/`l1:`/`l2:` buckets, requires SystemAdmin.",
27
29
  schema: z.object({
28
30
  bucket: z.string().min(1),
29
31
  limit: z.number().int().positive(),
@@ -31,6 +33,10 @@ export const rateLimitStatus = defineQueryHandler({
31
33
  }),
32
34
  access: { roles: ["Admin", "SystemAdmin"] },
33
35
  handler: async (query, ctx) => {
36
+ // Before the resolver check, so a foreign-tenant caller learns nothing
37
+ // about the wiring either.
38
+ const denied = bucketAccessDenied(query.payload.bucket, ctx.user);
39
+ if (denied) throw denied;
34
40
  if (!ctx.rateLimit) {
35
41
  throw new UnprocessableError(RateLimitErrors.resolverUnavailable, {
36
42
  i18nKey: "rateLimiting.errors.resolverUnavailable",
@@ -33,6 +33,7 @@ import {
33
33
  setupTestStack,
34
34
  type TestStack,
35
35
  testTenantId,
36
+ unsafeCreateEntityTable,
36
37
  } from "@cosmicdrift/kumiko-framework/stack";
37
38
  import type {
38
39
  Payment as MolliePayment,
@@ -42,6 +43,7 @@ import { Hono } from "hono";
42
43
  import { createComplianceProfilesFeature } from "../../compliance-profiles";
43
44
  import { createConfigFeature } from "../../config";
44
45
  import { createTenantFeature } from "../../tenant/feature";
46
+ import { tenantEntity } from "../../tenant/schema/tenant";
45
47
  import { createTenantLifecycleFeature } from "../../tenant-lifecycle";
46
48
  import { createSubscriptionMollieFeature } from "../feature";
47
49
  import type { MollieClientShape } from "../verify-webhook";
@@ -150,6 +152,7 @@ beforeAll(async () => {
150
152
  // subscriptionsProjectionTable wird von setupTestStack automatisch
151
153
  // gepusht (r.projection mit `table`-Property → auto-push).
152
154
  await createEventsTable(db);
155
+ await unsafeCreateEntityTable(db, tenantEntity);
153
156
 
154
157
  webhookApp = new Hono();
155
158
  webhookApp.post(
@@ -29,6 +29,7 @@ import {
29
29
  setupTestStack,
30
30
  type TestStack,
31
31
  testTenantId,
32
+ unsafeCreateEntityTable,
32
33
  unsafePushTables,
33
34
  } from "@cosmicdrift/kumiko-framework/stack";
34
35
  import { createTestEnvelopeCipher } from "@cosmicdrift/kumiko-framework/testing";
@@ -45,6 +46,7 @@ import {
45
46
  tenantSecretsTable,
46
47
  } from "../../secrets";
47
48
  import { createTenantFeature } from "../../tenant/feature";
49
+ import { tenantEntity } from "../../tenant/schema/tenant";
48
50
  import { createTenantLifecycleFeature } from "../../tenant-lifecycle";
49
51
  import { createSubscriptionStripeFeature } from "../feature";
50
52
 
@@ -115,6 +117,7 @@ beforeAll(async () => {
115
117
  // gepusht (r.projection mit `table`-Property → auto-push). config +
116
118
  // secrets brauchen ihre Tabellen explizit.
117
119
  await createEventsTable(db);
120
+ await unsafeCreateEntityTable(db, tenantEntity);
118
121
  await unsafePushTables(db, { configValuesTable, tenant_secrets: tenantSecretsTable });
119
122
  // Standalone-secrets-context (gleiche KEK) zum direkten Seeden +
120
123
  // als systemSecrets für die zweite webhook-app.
@@ -504,6 +507,7 @@ describe("scenario 6: billing-live gate end-to-end (#104)", () => {
504
507
  }),
505
508
  });
506
509
  await createEventsTable(gateStack.db);
510
+ await unsafeCreateEntityTable(gateStack.db, tenantEntity);
507
511
  await unsafePushTables(gateStack.db, {
508
512
  configValuesTable,
509
513
  tenant_secrets: tenantSecretsTable,
@@ -469,3 +469,111 @@ describe("tenant-lifecycle :: sweep isolates one tenant's failure from another's
469
469
  expect(eventsB.some((e) => e.type.endsWith("tenant-destruction-stage-succeeded"))).toBe(true);
470
470
  });
471
471
  });
472
+
473
+ // No authConfig here on purpose: buildServer derives the 410 gate from the
474
+ // mounted tenantLifecycleStatus provider. The stacks above keep the explicit
475
+ // auth.resolveTenantLifecycleStatus override covered.
476
+ describe("tenant-lifecycle :: 410 gate derived from the mounted provider", () => {
477
+ let derivedStack: TestStack;
478
+
479
+ beforeAll(async () => {
480
+ const encryption = createTestEnvelopeCipher(randomBytes(32).toString("base64"));
481
+ const resolver = createConfigResolver({ cipher: encryption });
482
+ derivedStack = await setupTestStack({
483
+ features: [
484
+ createConfigFeature(),
485
+ createUserFeature(),
486
+ createTenantFeature(),
487
+ createComplianceProfilesFeature(),
488
+ authFoundationFeature,
489
+ createSessionsFeature(),
490
+ createTenantLifecycleFeature(),
491
+ ],
492
+ extraContext: { configResolver: resolver, configEncryption: encryption },
493
+ });
494
+ await unsafeCreateEntityTable(derivedStack.db, tenantEntity);
495
+ await unsafeCreateEntityTable(derivedStack.db, userSessionEntity);
496
+ await unsafeCreateEntityTable(derivedStack.db, tenantComplianceProfileEntity);
497
+ await unsafeCreateEntityTable(derivedStack.db, tenantMembershipEntity);
498
+ await createEventsTable(derivedStack.db);
499
+ await unsafePushTables(derivedStack.db, { configValuesTable });
500
+ });
501
+
502
+ afterAll(async () => {
503
+ await derivedStack.cleanup();
504
+ });
505
+
506
+ beforeEach(async () => {
507
+ derivedStack.events.reset();
508
+ resetTenantLifecycleGateCacheForTests();
509
+ await resetTestTables(derivedStack.db, [
510
+ tenantTable,
511
+ tenantComplianceProfileTable,
512
+ userSessionTable,
513
+ tenantMembershipsTable,
514
+ eventsTable,
515
+ ]);
516
+ await derivedStack.http.writeOk(
517
+ TenantHandlers.create,
518
+ { id: tenantAdmin.tenantId, key: "acme", name: "ACME Corp" },
519
+ TestUsers.systemAdmin,
520
+ );
521
+ await derivedStack.http.writeOk(SET_PROFILE, { profileKey: "eu-dsgvo" }, tenantAdmin);
522
+ });
523
+
524
+ async function rejectionCode(res: Response): Promise<string | undefined> {
525
+ const body = (await res.json()) as { error?: { code?: string } };
526
+ return body.error?.code;
527
+ }
528
+
529
+ test("active tenant passes the gate", async () => {
530
+ const res = await derivedStack.http.query(TenantQueries.me, {}, tenantAdmin);
531
+ expect(res.status).toBe(200);
532
+ });
533
+
534
+ test("destroyRequested tenant gets 410 tenant_unavailable without any hand-wiring", async () => {
535
+ await derivedStack.http.writeOk(REQUEST, {}, tenantAdmin);
536
+ resetTenantLifecycleGateCacheForTests();
537
+
538
+ const res = await derivedStack.http.query(TenantQueries.me, {}, tenantAdmin);
539
+ expect(res.status).toBe(410);
540
+ expect(await rejectionCode(res)).toBe("tenant_unavailable");
541
+ });
542
+
543
+ test("cancel-destruction stays exempt while destroyRequested", async () => {
544
+ await derivedStack.http.writeOk(REQUEST, {}, tenantAdmin);
545
+ resetTenantLifecycleGateCacheForTests();
546
+
547
+ const cancelled = await derivedStack.http.writeOk<{ status: string }>(CANCEL, {}, tenantAdmin);
548
+ expect(cancelled.status).toBe("active");
549
+ });
550
+
551
+ test("batch mixing cancel-destruction with another write gets 410", async () => {
552
+ await derivedStack.http.writeOk(REQUEST, {}, tenantAdmin);
553
+ resetTenantLifecycleGateCacheForTests();
554
+
555
+ const res = await derivedStack.http.batch(
556
+ [
557
+ { type: CANCEL, payload: {} },
558
+ { type: SET_PROFILE, payload: { profileKey: "eu-dsgvo" } },
559
+ ],
560
+ tenantAdmin,
561
+ );
562
+ expect(res.status).toBe(410);
563
+ expect(await rejectionCode(res)).toBe("tenant_unavailable");
564
+ });
565
+
566
+ test("destroying tenant has no cancel exemption", async () => {
567
+ await updateRows(
568
+ derivedStack.db,
569
+ tenantTable,
570
+ { status: "destroying" },
571
+ { id: tenantAdmin.tenantId },
572
+ );
573
+ resetTenantLifecycleGateCacheForTests();
574
+
575
+ const res = await derivedStack.http.write(CANCEL, {}, tenantAdmin);
576
+ expect(res.status).toBe(410);
577
+ expect(await rejectionCode(res)).toBe("tenant_unavailable");
578
+ });
579
+ });