@cosmicdrift/kumiko-bundled-features 0.221.0 → 0.223.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 (72) hide show
  1. package/package.json +10 -9
  2. package/src/audit/__tests__/audit-log-screen.test.tsx +5 -5
  3. package/src/audit/__tests__/audit-screens.boot.test.ts +2 -1
  4. package/src/audit/__tests__/audit.integration.test.ts +3 -1
  5. package/src/audit/__tests__/constants.test.ts +6 -1
  6. package/src/audit/constants.ts +10 -1
  7. package/src/audit/feature.ts +2 -0
  8. package/src/audit/i18n.ts +2 -0
  9. package/src/audit/web/audit-log-detail-screen.tsx +7 -3
  10. package/src/audit/web/audit-log-screen.tsx +27 -29
  11. package/src/auth-email-password/handlers/invite-create.write.ts +7 -1
  12. package/src/auth-email-password/seeding.ts +3 -1
  13. package/src/auth-mfa/hmac-token-codec.ts +75 -0
  14. package/src/auth-mfa/mfa-challenge-token.ts +10 -65
  15. package/src/auth-mfa/mfa-preauth-setup-token.ts +10 -61
  16. package/src/cap-counter/stock-cap-guard.ts +1 -1
  17. package/src/channel-email/__tests__/email-channel.test.ts +19 -0
  18. package/src/channel-email/__tests__/pii-guard.test.ts +11 -0
  19. package/src/channel-email/email-channel.ts +14 -7
  20. package/src/channel-email/pii-guard.ts +8 -0
  21. package/src/compliance-profiles/handlers/set-profile.write.ts +1 -4
  22. package/src/crypto-shredding/constants.ts +1 -0
  23. package/src/crypto-shredding/feature.ts +6 -1
  24. package/src/crypto-shredding/handlers/forget-subject.write.ts +25 -1
  25. package/src/crypto-shredding/index.ts +2 -0
  26. package/src/custom-fields/__tests__/audit-integration.integration.test.ts +9 -1
  27. package/src/file-derivatives/feature.ts +2 -2
  28. package/src/file-derivatives/handlers/public-variant.query.ts +4 -5
  29. package/src/files-provider-s3/__tests__/s3-provider.test.ts +58 -1
  30. package/src/files-provider-s3/s3-provider.ts +45 -0
  31. package/src/files-tenant-data/__tests__/hooks.integration.test.ts +271 -0
  32. package/src/files-tenant-data/__tests__/sweep-orphaned-derivatives.job.integration.test.ts +275 -0
  33. package/src/files-tenant-data/changes.json +1 -0
  34. package/src/files-tenant-data/handlers/sweep-orphaned-derivatives.job.ts +173 -0
  35. package/src/files-tenant-data/hooks.ts +93 -0
  36. package/src/files-tenant-data/index.ts +58 -0
  37. package/src/form-draft/db/queries/cleanup.ts +17 -0
  38. package/src/form-draft/handlers/cleanup.job.ts +20 -10
  39. package/src/form-draft/handlers/discard.write.ts +25 -7
  40. package/src/form-draft/handlers/get.query.ts +4 -2
  41. package/src/form-draft/handlers/list.query.ts +4 -1
  42. package/src/form-draft/lookup.ts +12 -5
  43. package/src/inbound-mail-foundation/watch-supervisor.ts +28 -4
  44. package/src/jobs/__tests__/jobs-pii-kms.integration.test.ts +128 -3
  45. package/src/jobs/constants.ts +1 -0
  46. package/src/jobs/handlers/retry.write.ts +26 -6
  47. package/src/jobs/i18n.ts +3 -0
  48. package/src/sessions/session-callbacks.ts +1 -0
  49. package/src/tags/__tests__/tag-screens.boot.test.ts +13 -0
  50. package/src/tenant/__tests__/tenant-timezone-boot.integration.test.ts +165 -0
  51. package/src/tenant/changes.json +1 -1
  52. package/src/tenant/constants.ts +5 -1
  53. package/src/tenant/handlers/remove-member.write.ts +12 -0
  54. package/src/tenant/handlers/update-member-roles.write.ts +10 -37
  55. package/src/tenant/last-tenant-admin.ts +39 -0
  56. package/src/tenant/screens.ts +2 -0
  57. package/src/tenant/seeding.ts +3 -1
  58. package/src/tenant-lifecycle/feature.ts +1 -0
  59. package/src/tenant-lifecycle/handlers/cancel-destruction.write.ts +1 -5
  60. package/src/tenant-lifecycle/run-tenant-destroy.ts +5 -0
  61. package/src/tenant-lifecycle/stages.ts +5 -0
  62. package/src/tier-engine/web/tier-admin-screen.tsx +1 -2
  63. package/src/user/__tests__/email-unique-blind-index.integration.test.ts +25 -13
  64. package/src/user/changes.json +12 -5
  65. package/src/user-data-rights/handlers/cancel-deletion.write.ts +3 -10
  66. package/src/user-data-rights/handlers/confirm-deletion-by-token.write.ts +2 -8
  67. package/src/user-data-rights/handlers/deletion-grace-period.ts +2 -2
  68. package/src/user-data-rights/handlers/lift-restriction.write.ts +2 -2
  69. package/src/user-data-rights/handlers/restrict-account.write.ts +3 -3
  70. package/src/user-data-rights/web/__tests__/privacy-center-screen.test.tsx +10 -6
  71. package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.test.ts +114 -1
  72. package/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +63 -12
@@ -14,7 +14,7 @@ import {
14
14
  isPiiCiphertext,
15
15
  PII_ERASED_SENTINEL,
16
16
  } from "@cosmicdrift/kumiko-framework/crypto";
17
- import { createRegistry } from "@cosmicdrift/kumiko-framework/engine";
17
+ import { createRegistry, defineFeature } from "@cosmicdrift/kumiko-framework/engine";
18
18
  import { createEventsTable, eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
19
19
  import {
20
20
  createTestDb,
@@ -26,8 +26,12 @@ import {
26
26
  TestUsers,
27
27
  unsafePushTables,
28
28
  } from "@cosmicdrift/kumiko-framework/stack";
29
- import { resetPiiSubjectKmsForTests, resetTestTables } from "@cosmicdrift/kumiko-framework/testing";
30
- import { JobQueries } from "../constants";
29
+ import {
30
+ resetPiiSubjectKmsForTests,
31
+ resetTestTables,
32
+ sleep,
33
+ } from "@cosmicdrift/kumiko-framework/testing";
34
+ import { JobHandlers, JobQueries } from "../constants";
31
35
  import { createJobsFeature } from "../feature";
32
36
  import { createJobRunLogger } from "../job-run-logger";
33
37
  import { jobRunLogsTable, jobRunsTable } from "../job-run-table";
@@ -432,3 +436,124 @@ describe("jobs:query:details decrypts log messages and error end-to-end (#2247,
432
436
  expect(listed?.error).toBe(SECRET_LIST_ERROR);
433
437
  });
434
438
  });
439
+
440
+ // #2465: jobs:write:retry read run.payload straight off the row and
441
+ // JSON.parse'd it without decrypting first — broken on main for any retry
442
+ // of a run whose payload was encrypted under the triggering user's DEK
443
+ // (#799). This dispatches the real jobs:write:retry HTTP handler so a
444
+ // regression (missing decrypt, wrong AAD field name, sentinel mishandled)
445
+ // fails a test instead of shipping a crash to the retry button.
446
+ describe("jobs:write:retry decrypts payload before dispatch (#2465)", () => {
447
+ let retryStack: TestStack;
448
+ let retryLogger: ReturnType<typeof createJobRunLogger>;
449
+ let retryKms: InMemoryKmsAdapter;
450
+
451
+ const RETRY_USER_ID = "u-pii-retry-1";
452
+ const RETRY_JOB_NAME = "retryapp:job:capture-import";
453
+ const capturedPayloads: Record<string, unknown>[] = [];
454
+
455
+ const retryAppFeature = defineFeature("retryapp", (r) => {
456
+ r.job("captureImport", { trigger: { manual: true } }, async (payload) => {
457
+ capturedPayloads.push(payload);
458
+ });
459
+ });
460
+
461
+ beforeAll(async () => {
462
+ retryStack = await setupTestStack({
463
+ features: [retryAppFeature, createJobsFeature()],
464
+ jobs: {
465
+ consumerLane: "worker",
466
+ queueNamePrefix: `kumiko-jobs-retry-pii-test-${Date.now()}`,
467
+ },
468
+ });
469
+ await unsafePushTables(retryStack.db, { jobRunsTable, jobRunLogsTable });
470
+ retryLogger = createJobRunLogger({ db: retryStack.db, registry: retryStack.registry });
471
+ });
472
+
473
+ afterAll(async () => {
474
+ await retryStack.cleanup();
475
+ });
476
+
477
+ beforeEach(() => {
478
+ capturedPayloads.length = 0;
479
+ retryKms = new InMemoryKmsAdapter();
480
+ configurePiiSubjectKms(retryKms);
481
+ });
482
+
483
+ afterEach(() => {
484
+ resetPiiSubjectKmsForTests();
485
+ });
486
+
487
+ test("retry decrypts an encrypted run payload and dispatches the plaintext", async () => {
488
+ await retryLogger.onJobStart?.(RETRY_JOB_NAME, "bull-retry-1", {
489
+ triggeredById: RETRY_USER_ID,
490
+ payload: SECRET_PAYLOAD,
491
+ });
492
+ await retryLogger.onJobFailed?.(RETRY_JOB_NAME, "bull-retry-1", "boom", []);
493
+
494
+ const row = await fetchOne(retryStack.db, jobRunsTable, { bullJobId: "bull-retry-1" });
495
+ expect(isPiiCiphertext(row?.["payload"])).toBe(true);
496
+ expect(row?.["status"]).toBe("failed");
497
+
498
+ const result = await retryStack.http.writeOk<{
499
+ jobName: string;
500
+ bullJobId: string;
501
+ retriedFromRunId: string;
502
+ }>(JobHandlers.retry, { runId: row?.["id"] }, TestUsers.systemAdmin);
503
+ expect(result.jobName).toBe(RETRY_JOB_NAME);
504
+
505
+ await sleep(1000);
506
+
507
+ expect(capturedPayloads).toHaveLength(1);
508
+ expect(capturedPayloads[0]).toEqual(JSON.parse(SECRET_PAYLOAD));
509
+ });
510
+
511
+ test("retry on a run whose payload key was erased is rejected instead of crashing", async () => {
512
+ await retryLogger.onJobStart?.(RETRY_JOB_NAME, "bull-retry-2", {
513
+ triggeredById: RETRY_USER_ID,
514
+ payload: SECRET_PAYLOAD,
515
+ });
516
+ await retryLogger.onJobFailed?.(RETRY_JOB_NAME, "bull-retry-2", "boom", []);
517
+
518
+ const row = await fetchOne(retryStack.db, jobRunsTable, { bullJobId: "bull-retry-2" });
519
+ await retryKms.eraseKey({ kind: "user", userId: RETRY_USER_ID });
520
+
521
+ const errInfo = await retryStack.http.writeErr(
522
+ JobHandlers.retry,
523
+ { runId: row?.["id"] },
524
+ TestUsers.systemAdmin,
525
+ );
526
+ expect(errInfo.code).toBe("unprocessable");
527
+ expect(errInfo.details).toMatchObject({ reason: "job_payload_erased" });
528
+ });
529
+
530
+ // The other producer of PII_ERASED_SENTINEL: job-run-logger writes the
531
+ // literal "[[erased]]" string (not ciphertext) when the key is already
532
+ // gone at onJobStart time (see the mirrored case above this describe
533
+ // block). retry.write.ts's isPiiCiphertext/decrypt path must reject this
534
+ // stored sentinel the same way it rejects a decrypt-time one.
535
+ test("retry on a run whose payload was stored as the erased sentinel is rejected", async () => {
536
+ await retryLogger.onJobStart?.(RETRY_JOB_NAME, "bull-retry-3", {
537
+ triggeredById: RETRY_USER_ID,
538
+ payload: SECRET_PAYLOAD,
539
+ });
540
+ await retryKms.eraseKey({ kind: "user", userId: RETRY_USER_ID });
541
+
542
+ await retryLogger.onJobStart?.(RETRY_JOB_NAME, "bull-retry-4", {
543
+ triggeredById: RETRY_USER_ID,
544
+ payload: SECRET_PAYLOAD,
545
+ });
546
+ await retryLogger.onJobFailed?.(RETRY_JOB_NAME, "bull-retry-4", "boom", []);
547
+
548
+ const row = await fetchOne(retryStack.db, jobRunsTable, { bullJobId: "bull-retry-4" });
549
+ expect(row?.["payload"]).toBe(PII_ERASED_SENTINEL);
550
+
551
+ const errInfo = await retryStack.http.writeErr(
552
+ JobHandlers.retry,
553
+ { runId: row?.["id"] },
554
+ TestUsers.systemAdmin,
555
+ );
556
+ expect(errInfo.code).toBe("unprocessable");
557
+ expect(errInfo.details).toMatchObject({ reason: "job_payload_erased" });
558
+ });
559
+ });
@@ -21,6 +21,7 @@ export const JobErrors = {
21
21
  notFound: "not_found",
22
22
  onlyFailedCanRetry: "only_failed_jobs_can_be_retried",
23
23
  notManual: "job_not_manual",
24
+ payloadErased: "job_payload_erased",
24
25
  } as const;
25
26
 
26
27
  /** SystemAdmin job-runs list. Nav: `jobs:screen:job-runs`. */
@@ -1,4 +1,5 @@
1
1
  import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
2
+ import { PII_ERASED_SENTINEL } from "@cosmicdrift/kumiko-framework/crypto";
2
3
  import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
3
4
  import {
4
5
  InternalError,
@@ -9,6 +10,7 @@ import {
9
10
  import type { JobRunner } from "@cosmicdrift/kumiko-framework/jobs";
10
11
  import { parseJsonOrThrow } from "@cosmicdrift/kumiko-framework/utils";
11
12
  import { z } from "zod";
13
+ import { decryptStoredPii } from "../../shared";
12
14
  import { JobErrors } from "../constants";
13
15
  import { jobRunsTable } from "../job-run-table";
14
16
 
@@ -53,12 +55,30 @@ export const retryWrite = defineWriteHandler({
53
55
  );
54
56
  }
55
57
 
56
- const payload = run.payload
57
- ? parseJsonOrThrow<Record<string, unknown>>(
58
- run.payload,
59
- `job run ${event.payload.runId} payload`,
60
- )
61
- : {};
58
+ // payload is stored encrypted under the triggering user's DEK (#799),
59
+ // same mechanism as detail.query.ts/list.query.ts — decrypt before
60
+ // parsing it as JSON. A key erased since the run finished decrypts to
61
+ // PII_ERASED_SENTINEL, which is not JSON; reject the retry rather than
62
+ // dispatch it with a silently emptied payload.
63
+ let payload: Record<string, unknown> = {};
64
+ if (run.payload) {
65
+ const decryptedPayload = await decryptStoredPii(
66
+ run.payload,
67
+ "payload",
68
+ `job run ${event.payload.runId} payload`,
69
+ );
70
+ if (decryptedPayload === PII_ERASED_SENTINEL) {
71
+ return writeFailure(
72
+ new UnprocessableError(JobErrors.payloadErased, {
73
+ i18nKey: "jobs.errors.payloadErased",
74
+ }),
75
+ );
76
+ }
77
+ payload = parseJsonOrThrow<Record<string, unknown>>(
78
+ decryptedPayload,
79
+ `job run ${event.payload.runId} payload`,
80
+ );
81
+ }
62
82
 
63
83
  const bullJobId = await jobRunner.dispatch(run.jobName, payload);
64
84
 
package/src/jobs/i18n.ts CHANGED
@@ -61,4 +61,7 @@ export const JOBS_I18N: Readonly<Record<string, LocalizedString>> = {
61
61
  "jobs.errors.onlyFailedCanRetry": {
62
62
  en: "Only failed runs can be retried.",
63
63
  },
64
+ "jobs.errors.payloadErased": {
65
+ en: "This run's payload can no longer be read — the triggering user's data was erased.",
66
+ },
64
67
  };
@@ -115,6 +115,7 @@ export function createSessionCallbacks(opts: SessionCallbacksOptions): SessionCa
115
115
  );
116
116
  },
117
117
 
118
+ // kumiko-lint-ignore complexity-budget session check + lastSeen refresh on hot path
118
119
  async sessionChecker(sid: string, expectedUserId: string): Promise<AuthSessionCheckResult> {
119
120
  const row = await fetchOne<{
120
121
  userId: string;
@@ -48,4 +48,17 @@ describe("tags catalog screens (entityList + entityEdit)", () => {
48
48
  expect(custom.screens[TAGS_EDIT_SCREEN_ID]?.access).toEqual({ roles: ["Admin"] });
49
49
  expect(custom.writeHandlers["tag:delete"]?.access).toEqual({ roles: ["Admin"] });
50
50
  });
51
+
52
+ test("convention write schemas accept entityEdit envelopes", () => {
53
+ const createSchema = feature.writeHandlers["tag:create"]?.schema;
54
+ const updateSchema = feature.writeHandlers["tag:update"]?.schema;
55
+ const deleteSchema = feature.writeHandlers["tag:delete"]?.schema;
56
+ expect(createSchema).toBeDefined();
57
+ expect(updateSchema).toBeDefined();
58
+ expect(deleteSchema).toBeDefined();
59
+ expect(createSchema!.safeParse({ name: "x" }).success).toBe(true);
60
+ const id = "00000000-0000-4000-8000-000000000001";
61
+ expect(updateSchema!.safeParse({ id, version: 0, changes: { name: "x" } }).success).toBe(true);
62
+ expect(deleteSchema!.safeParse({ id }).success).toBe(true);
63
+ });
51
64
  });
@@ -10,6 +10,7 @@ import {
10
10
  createTestUser,
11
11
  setupTestStack,
12
12
  type TestStack,
13
+ testTenantId,
13
14
  unsafePushTables,
14
15
  } from "@cosmicdrift/kumiko-framework/stack";
15
16
  import { z } from "zod";
@@ -28,6 +29,16 @@ const probeFeature = defineFeature("tz-probe", (r) => {
28
29
  );
29
30
  });
30
31
 
32
+ // Query (not write) probe for the caching tests below: dispatcher.query()
33
+ // never opens a transaction, which is the hot path fw#2462 targets and the
34
+ // only path buildHandlerContext is allowed to populate the cache from.
35
+ const queryProbeFeature = defineFeature("tz-cache-probe", (r) => {
36
+ r.requires("tenant");
37
+ r.queryHandler("read-tz", z.object({}), async (_query, ctx) => ({ tenant: ctx.tz.tenant }), {
38
+ access: { openToAll: true },
39
+ });
40
+ });
41
+
31
42
  describe("ctx.tz.tenant against the real tenant feature (fw#1648)", () => {
32
43
  let stack: TestStack;
33
44
 
@@ -58,3 +69,157 @@ describe("ctx.tz.tenant against the real tenant feature (fw#1648)", () => {
58
69
  expect(res.tenant).toBe("Asia/Tokyo");
59
70
  });
60
71
  });
72
+
73
+ // fw#2462: dispatch-shared.ts now caches the resolved tenant:config:timezone
74
+ // value per tenant instead of resolving it on every dispatch. These tests
75
+ // prove the cache is actually consulted (config() isn't re-invoked on a
76
+ // repeat read) and that config:write:set/reset correctly invalidate it —
77
+ // own stack per describe so cache state never leaks across test files.
78
+ describe("tenant:config:timezone dispatch cache (fw#2462)", () => {
79
+ let stack: TestStack;
80
+ let configCallCount: number;
81
+
82
+ beforeAll(async () => {
83
+ const resolver = createConfigResolver();
84
+ stack = await setupTestStack({
85
+ features: [createConfigFeature(), createTenantFeature(), queryProbeFeature],
86
+ extraContext: ({ registry }) => {
87
+ const realFactory = createConfigAccessorFactory(registry, resolver);
88
+ // Counts calls through the config accessor — a cache hit in
89
+ // dispatch-shared.ts's buildHandlerContext never reaches this
90
+ // wrapper, so the count is a direct proxy for "did we re-resolve
91
+ // tenant:config:timezone instead of serving it from cache".
92
+ const countingFactory: typeof realFactory = (args) => {
93
+ const accessor = realFactory(args);
94
+ // Probe only ever calls the qualified-string-key overload — the
95
+ // cast sidesteps re-declaring ConfigAccessor's generic handle
96
+ // overload for a test-only wrapper.
97
+ return ((key: string) => {
98
+ configCallCount++;
99
+ return accessor(key);
100
+ }) as typeof accessor; // @cast-boundary test-helper
101
+ };
102
+ return {
103
+ configResolver: resolver,
104
+ _configAccessorFactory: countingFactory,
105
+ };
106
+ },
107
+ });
108
+ await unsafePushTables(stack.db, { configValuesTable });
109
+ });
110
+
111
+ afterAll(async () => {
112
+ await stack.cleanup();
113
+ });
114
+
115
+ test("repeat reads for the same tenant are served from cache, not re-resolved", async () => {
116
+ configCallCount = 0;
117
+ const tenantId = testTenantId(101);
118
+ const admin = createTestUser({ id: 21, tenantId, roles: ["Admin"] });
119
+
120
+ await stack.http.queryOk("tz-cache-probe:query:read-tz", {}, admin);
121
+ expect(configCallCount).toBe(1);
122
+
123
+ await stack.http.queryOk("tz-cache-probe:query:read-tz", {}, admin);
124
+ await stack.http.queryOk("tz-cache-probe:query:read-tz", {}, admin);
125
+ expect(configCallCount).toBe(1);
126
+ });
127
+
128
+ test("config:write:set invalidates the cache for the written tenant", async () => {
129
+ configCallCount = 0;
130
+ const tenantId = testTenantId(102);
131
+ const admin = createTestUser({ id: 22, tenantId, roles: ["Admin"] });
132
+
133
+ const before = await stack.http.queryOk<{ tenant: string }>(
134
+ "tz-cache-probe:query:read-tz",
135
+ {},
136
+ admin,
137
+ );
138
+ expect(configCallCount).toBe(1);
139
+
140
+ await stack.http.writeOk(
141
+ "config:write:set",
142
+ { key: "tenant:config:timezone", value: "Asia/Tokyo" },
143
+ admin,
144
+ );
145
+ expect(before.tenant).not.toBe("Asia/Tokyo");
146
+
147
+ const after = await stack.http.queryOk<{ tenant: string }>(
148
+ "tz-cache-probe:query:read-tz",
149
+ {},
150
+ admin,
151
+ );
152
+ expect(after.tenant).toBe("Asia/Tokyo");
153
+ // The cache entry was dropped by the write, so the read after it is a
154
+ // fresh resolve — a second config() call, not a stale cache hit.
155
+ expect(configCallCount).toBe(2);
156
+ });
157
+
158
+ test("config:write:reset invalidates the cache for the written tenant", async () => {
159
+ configCallCount = 0;
160
+ const tenantId = testTenantId(103);
161
+ const admin = createTestUser({ id: 23, tenantId, roles: ["Admin"] });
162
+
163
+ await stack.http.writeOk(
164
+ "config:write:set",
165
+ { key: "tenant:config:timezone", value: "Asia/Singapore" },
166
+ admin,
167
+ );
168
+ const before = await stack.http.queryOk<{ tenant: string }>(
169
+ "tz-cache-probe:query:read-tz",
170
+ {},
171
+ admin,
172
+ );
173
+ expect(before.tenant).toBe("Asia/Singapore");
174
+
175
+ await stack.http.writeOk("config:write:reset", { key: "tenant:config:timezone" }, admin);
176
+
177
+ const after = await stack.http.queryOk<{ tenant: string }>(
178
+ "tz-cache-probe:query:read-tz",
179
+ {},
180
+ admin,
181
+ );
182
+ expect(after.tenant).not.toBe("Asia/Singapore");
183
+ });
184
+
185
+ test("a system-scope write invalidates every tenant's cached entry, not just the writer's", async () => {
186
+ const tenantA = testTenantId(104);
187
+ const tenantB = testTenantId(105);
188
+ const userA = createTestUser({ id: 24, tenantId: tenantA, roles: ["Admin"] });
189
+ const userB = createTestUser({ id: 25, tenantId: tenantB, roles: ["Admin"] });
190
+ const systemAdmin = createTestUser({ id: 26, tenantId: tenantA, roles: ["SystemAdmin"] });
191
+
192
+ // Warm both tenants' cache entries with neither having its own override.
193
+ const beforeA = await stack.http.queryOk<{ tenant: string }>(
194
+ "tz-cache-probe:query:read-tz",
195
+ {},
196
+ userA,
197
+ );
198
+ const beforeB = await stack.http.queryOk<{ tenant: string }>(
199
+ "tz-cache-probe:query:read-tz",
200
+ {},
201
+ userB,
202
+ );
203
+ expect(beforeA.tenant).not.toBe("Australia/Sydney");
204
+ expect(beforeB.tenant).not.toBe("Australia/Sydney");
205
+
206
+ await stack.http.writeOk(
207
+ "config:write:set",
208
+ { key: "tenant:config:timezone", value: "Australia/Sydney", scope: "system" },
209
+ systemAdmin,
210
+ );
211
+
212
+ const afterA = await stack.http.queryOk<{ tenant: string }>(
213
+ "tz-cache-probe:query:read-tz",
214
+ {},
215
+ userA,
216
+ );
217
+ const afterB = await stack.http.queryOk<{ tenant: string }>(
218
+ "tz-cache-probe:query:read-tz",
219
+ {},
220
+ userB,
221
+ );
222
+ expect(afterA.tenant).toBe("Australia/Sydney");
223
+ expect(afterB.tenant).toBe("Australia/Sydney");
224
+ });
225
+ });
@@ -3,7 +3,7 @@
3
3
  "version": "0.219.0",
4
4
  "type": "improvement",
5
5
  "title": "Invite/member-roles pickers include TenantAdmin",
6
- "detail": "DEFAULT_INVITE_ROLE_OPTIONS adds TenantAdmin so SystemAdmin/TenantAdmin can invite owners. SystemAdmin stays global-only (user.roles), never a membership invite option."
6
+ "detail": "DEFAULT_INVITE_ROLE_OPTIONS stays User/Editor/Admin so Admin actors never see an unassignable TenantAdmin option (fw#2414). OWNER_INVITE_ROLE_OPTIONS adds TenantAdmin for custom screens used by TenantAdmin/SystemAdmin. SystemAdmin stays global-only (user.roles), never a membership invite option."
7
7
  },
8
8
  {
9
9
  "version": "0.209.0",
@@ -23,7 +23,11 @@ export const MEMBER_STATUS_CELL_COMPONENT = "MemberStatusCell" as const;
23
23
  export const MEMBER_ROLES_CELL_COMPONENT = "MemberRolesCell" as const;
24
24
 
25
25
  /** Closed allowlist for invite-role picker — never free text (escalation guard). */
26
- export const DEFAULT_INVITE_ROLE_OPTIONS = ["User", "Editor", "Admin", "TenantAdmin"] as const;
26
+ // Admin (rank 2) shares these screens with TenantAdmin — TenantAdmin must not
27
+ // appear here or Admin sees an unassignable option (fw#2414). Apps that need
28
+ // owner invites compose OWNER_INVITE_ROLE_OPTIONS into a custom screen.
29
+ export const DEFAULT_INVITE_ROLE_OPTIONS = ["User", "Editor", "Admin"] as const;
30
+ export const OWNER_INVITE_ROLE_OPTIONS = ["User", "Editor", "Admin", "TenantAdmin"] as const;
27
31
 
28
32
  // Qualified write handler names (QN format: scope:type:name)
29
33
  export const TenantHandlers = {
@@ -6,7 +6,9 @@ import {
6
6
  withResponseData,
7
7
  } from "@cosmicdrift/kumiko-framework/engine";
8
8
  import { InternalError, NotFoundError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
9
+ import { parseRoles } from "@cosmicdrift/kumiko-framework/utils";
9
10
  import { z } from "zod";
11
+ import { assertNotLastTenantAdmin } from "../last-tenant-admin";
10
12
  import { tenantMembershipEntity, tenantMembershipsTable } from "../membership-table";
11
13
 
12
14
  const executor = createEventStoreExecutor(tenantMembershipsTable, tenantMembershipEntity, {
@@ -45,6 +47,16 @@ export const removeMemberWrite = defineWriteHandler({
45
47
  );
46
48
  }
47
49
 
50
+ const currentRoles = parseRoles((existing as DbRow)["roles"]);
51
+ if (currentRoles.includes("TenantAdmin")) {
52
+ const lastAdmin = await assertNotLastTenantAdmin(
53
+ db,
54
+ event.payload.tenantId,
55
+ event.payload.userId,
56
+ );
57
+ if (lastAdmin !== undefined) return lastAdmin;
58
+ }
59
+
48
60
  // Revoke THIS tenant's sessions BEFORE the delete — a removed member must
49
61
  // not keep a valid session for the window between the delete and a
50
62
  // post-delete revoke. Best-effort cross-feature call: sessions may not be
@@ -1,9 +1,5 @@
1
- import { fetchOne, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
2
- import {
3
- acquireNamespacedAdvisoryLock,
4
- createEventStoreExecutor,
5
- type DbRow,
6
- } from "@cosmicdrift/kumiko-framework/db";
1
+ import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
2
+ import { createEventStoreExecutor, type DbRow } from "@cosmicdrift/kumiko-framework/db";
7
3
  import {
8
4
  access,
9
5
  createSystemUser,
@@ -12,7 +8,6 @@ import {
12
8
  withResponseData,
13
9
  } from "@cosmicdrift/kumiko-framework/engine";
14
10
  import {
15
- ConflictError,
16
11
  InternalError,
17
12
  NotFoundError,
18
13
  ValidationError,
@@ -20,7 +15,7 @@ import {
20
15
  } from "@cosmicdrift/kumiko-framework/errors";
21
16
  import { parseRoles } from "@cosmicdrift/kumiko-framework/utils";
22
17
  import { z } from "zod";
23
- import { TenantErrors } from "../constants";
18
+ import { assertNotLastTenantAdmin } from "../last-tenant-admin";
24
19
  import {
25
20
  findForbiddenMembershipRole,
26
21
  reservedMembershipRoleError,
@@ -37,10 +32,6 @@ const executor = createEventStoreExecutor(tenantMembershipsTable, tenantMembersh
37
32
  // r.usesApi here, unlike user-data-rights:restrict-account's hard dep).
38
33
  const REVOKE_ALL_SESSIONS_QN = "sessions:write:user-session:revoke-all-for-user";
39
34
 
40
- // Serializes last-TenantAdmin demotion checks per tenant inside the write TX
41
- // (dispatcher batch wraps handlers in transaction — xact lock holds through update).
42
- const LAST_TENANT_ADMIN_LOCK_NAMESPACE = 0x7461646d; // 'tadm'
43
-
44
35
  /** SystemAdmin may pass payload.tenantId (cross-tenant); the members actionForm
45
36
  * only sends userId+roles, so fall back to the active session tenant. */
46
37
  function resolveTargetTenantId(
@@ -60,8 +51,8 @@ export const updateMemberRolesWrite = defineWriteHandler({
60
51
  }),
61
52
  // "system" + access.admin ("TenantAdmin", "Admin", "SystemAdmin").
62
53
  // The system user (createSystemUser, roles=["system"]) and SystemAdmin
63
- // manage memberships cross-tenant (payload.tenantId). TenantAdmin is strictly
64
- // session-scoped (event.user.tenantId).
54
+ // manage memberships cross-tenant (payload.tenantId). TenantAdmin and Admin
55
+ // are session-scoped (event.user.tenantId).
65
56
  access: { roles: ["system", ...access.admin] },
66
57
  handler: async (event, ctx) => {
67
58
  if (!ctx.systemDb) {
@@ -129,28 +120,8 @@ export const updateMemberRolesWrite = defineWriteHandler({
129
120
  const willBeTenantAdmin = event.payload.roles.includes("TenantAdmin");
130
121
 
131
122
  if (targetIsTenantAdmin && !willBeTenantAdmin) {
132
- // Lock before count+update so two concurrent demotions cannot both
133
- // observe adminCount === 2 and leave the tenant with zero TenantAdmins.
134
- await acquireNamespacedAdvisoryLock(db, LAST_TENANT_ADMIN_LOCK_NAMESPACE, targetTenantId);
135
- const allMemberships = await selectMany(db, tenantMembershipsTable, {
136
- tenantId: targetTenantId,
137
- });
138
- const adminCount = allMemberships.filter((m) =>
139
- parseRoles(m["roles"]).includes("TenantAdmin"),
140
- ).length;
141
- if (adminCount <= 1) {
142
- return writeFailure(
143
- new ConflictError({
144
- message: "cannot demote the last tenant admin",
145
- i18nKey: "tenant.errors.cannotDemoteLastTenantAdmin",
146
- details: {
147
- reason: TenantErrors.lastTenantAdmin,
148
- userId: event.payload.userId,
149
- tenantId: targetTenantId,
150
- },
151
- }),
152
- );
153
- }
123
+ const lastAdmin = await assertNotLastTenantAdmin(db, targetTenantId, event.payload.userId);
124
+ if (lastAdmin !== undefined) return lastAdmin;
154
125
  }
155
126
 
156
127
  // fetchOne already gave us the stream version — hand it to the executor
@@ -176,13 +147,15 @@ export const updateMemberRolesWrite = defineWriteHandler({
176
147
  // Stream tenant follows the actor via streamTenantFor — use the
177
148
  // membership tenant so SystemAdmin/cross-tenant and TenantAdmin paths
178
149
  // hit the same stream seedTenantMembership/invite-accept wrote.
150
+ // Keep the real actor for audit metadata; only override tenantId so
151
+ // streamTenantFor writes into the membership tenant (#2401).
179
152
  const result = await executor.update(
180
153
  {
181
154
  id: row["id"] as string, // @cast-boundary db-row
182
155
  version: row["version"] as number, // @cast-boundary db-row
183
156
  changes: { roles: JSON.stringify(event.payload.roles) },
184
157
  },
185
- createSystemUser(targetTenantId),
158
+ { ...event.user, tenantId: targetTenantId },
186
159
  db,
187
160
  );
188
161
 
@@ -0,0 +1,39 @@
1
+ import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
2
+ import { acquireNamespacedAdvisoryLock } from "@cosmicdrift/kumiko-framework/db";
3
+ import { ConflictError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
4
+ import { parseRoles } from "@cosmicdrift/kumiko-framework/utils";
5
+ import { TenantErrors } from "./constants";
6
+ import { tenantMembershipsTable } from "./membership-table";
7
+
8
+ // Serializes last-TenantAdmin demotion/removal checks per tenant inside the write TX
9
+ // (dispatcher batch wraps handlers in transaction — xact lock holds through update).
10
+ export const LAST_TENANT_ADMIN_LOCK_NAMESPACE = 0x7461646d; // 'tadm'
11
+
12
+ type TenantDb = Parameters<typeof acquireNamespacedAdvisoryLock>[0];
13
+
14
+ /** Refuse demoting/removing the last TenantAdmin for `tenantId`. */
15
+ export async function assertNotLastTenantAdmin(
16
+ db: TenantDb,
17
+ tenantId: string,
18
+ userId: string,
19
+ ): Promise<ReturnType<typeof writeFailure> | undefined> {
20
+ await acquireNamespacedAdvisoryLock(db, LAST_TENANT_ADMIN_LOCK_NAMESPACE, tenantId);
21
+ const allMemberships = await selectMany(db, tenantMembershipsTable, { tenantId });
22
+ const adminCount = allMemberships.filter((m) =>
23
+ parseRoles(m["roles"]).includes("TenantAdmin"),
24
+ ).length;
25
+ if (adminCount <= 1) {
26
+ return writeFailure(
27
+ new ConflictError({
28
+ message: "cannot demote the last tenant admin",
29
+ i18nKey: "tenant.errors.cannotDemoteLastTenantAdmin",
30
+ details: {
31
+ reason: TenantErrors.lastTenantAdmin,
32
+ userId,
33
+ tenantId,
34
+ },
35
+ }),
36
+ );
37
+ }
38
+ return undefined;
39
+ }
@@ -167,6 +167,8 @@ export const memberRolesEditScreen = {
167
167
  id: MEMBER_ROLES_EDIT_SCREEN_ID,
168
168
  type: "actionForm",
169
169
  handler: TenantHandlers.updateMemberRoles,
170
+ redirect: MEMBERS_SCREEN_ID,
171
+ cancelTarget: MEMBERS_SCREEN_ID,
170
172
  fields: {
171
173
  userId: { type: "text", required: true },
172
174
  roles: { type: "multiSelect", options: DEFAULT_INVITE_ROLE_OPTIONS, required: true },
@@ -285,13 +285,15 @@ export async function seedTenantMembership(
285
285
  return { id: extractMembershipId(existing) };
286
286
  }
287
287
 
288
+ // Keep by.id as audit actor, but force stream tenant to the membership tenant
289
+ // so a cross-tenant `by` override cannot diverge row tenant vs event stream.
288
290
  const result = await executor.create(
289
291
  {
290
292
  userId: options.userId,
291
293
  tenantId: options.tenantId,
292
294
  roles: JSON.stringify(options.roles),
293
295
  },
294
- by,
296
+ { ...by, tenantId: options.tenantId },
295
297
  tdb,
296
298
  );
297
299
  if (!result.isSuccess) {
@@ -97,6 +97,7 @@ export function createTenantLifecycleFeature(): FeatureDefinition {
97
97
  registry: ctx.registry,
98
98
  now: T.Now.instant(),
99
99
  log: (message) => ctx.log?.warn(message),
100
+ fileProviderResolver: ctx._fileProviderResolver,
100
101
  });
101
102
  },
102
103
  );
@@ -34,11 +34,7 @@ export const cancelDestructionWrite = defineWriteHandler({
34
34
  }
35
35
 
36
36
  if (!isWithinGracePeriod(row.gracePeriodEnd)) {
37
- return writeFailure(
38
- new UnprocessableError("grace_period_expired", {
39
- details: { reason: "grace_period_expired" },
40
- }),
41
- );
37
+ return writeFailure(new UnprocessableError("grace_period_expired"));
42
38
  }
43
39
 
44
40
  const update = await crud.update(