@cosmicdrift/kumiko-bundled-features 0.277.0 → 0.280.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 (62) hide show
  1. package/package.json +9 -9
  2. package/src/auth-email-password/__tests__/invite-flow-kms.integration.test.ts +8 -2
  3. package/src/auth-mfa-user-data/__tests__/hooks.integration.test.ts +6 -6
  4. package/src/auth-mfa-user-data/hooks.ts +4 -6
  5. package/src/cap-counter/__tests__/enforce-cap.test.ts +7 -10
  6. package/src/cap-counter/__tests__/stock-cap-guard.integration.test.ts +123 -6
  7. package/src/cap-counter/__tests__/with-cap-enforcement.integration.test.ts +135 -1
  8. package/src/cap-counter/book-cap-usage.ts +158 -0
  9. package/src/cap-counter/changes.json +16 -1
  10. package/src/cap-counter/enforce-cap.ts +5 -37
  11. package/src/cap-counter/handlers/increment.write.ts +7 -47
  12. package/src/cap-counter/handlers/mark-soft-warned.write.ts +6 -33
  13. package/src/cap-counter/index.ts +8 -0
  14. package/src/cap-counter/stock-cap-guard.ts +14 -19
  15. package/src/cap-counter/with-cap-enforcement.ts +9 -11
  16. package/src/custom-fields/__tests__/user-data-rights.integration.test.ts +10 -3
  17. package/src/custom-fields/wire-user-data-rights.ts +40 -24
  18. package/src/data-retention/resolve-for-tenant.ts +18 -6
  19. package/src/delivery/db/queries/preferences.ts +1 -0
  20. package/src/files-tenant-data/hooks.ts +3 -5
  21. package/src/folders/__tests__/folders.integration.test.ts +4 -3
  22. package/src/folders-user-data/__tests__/hooks.integration.test.ts +2 -1
  23. package/src/folders-user-data/hooks.ts +5 -10
  24. package/src/form-draft-user-data/__tests__/hooks-delete-failure.integration.test.ts +7 -1
  25. package/src/form-draft-user-data/__tests__/hooks.integration.test.ts +16 -5
  26. package/src/form-draft-user-data/hooks.ts +3 -6
  27. package/src/notes-history-user-data/__tests__/hooks.integration.test.ts +9 -3
  28. package/src/notes-history-user-data/hooks.ts +3 -4
  29. package/src/personal-access-tokens/handlers/create.write.ts +1 -0
  30. package/src/template-resolver-user-data/hooks.ts +1 -2
  31. package/src/tenant-lifecycle/feature.ts +2 -0
  32. package/src/tenant-lifecycle/run-tenant-destroy.ts +9 -0
  33. package/src/tenant-lifecycle/stages.ts +26 -2
  34. package/src/tier-engine/__tests__/tier-resolver.integration.test.ts +138 -0
  35. package/src/tier-engine/changes.json +7 -0
  36. package/src/tier-engine/tier-resolver.ts +7 -6
  37. package/src/user-data-rights/__tests__/cross-data-matrix.integration.test.ts +5 -14
  38. package/src/user-data-rights/__tests__/escape-hatch-audit.integration.test.ts +133 -0
  39. package/src/user-data-rights/__tests__/escape-hatch-declaration.integration.test.ts +198 -0
  40. package/src/user-data-rights/__tests__/forget-cleanup-hook-ordering.integration.test.ts +15 -3
  41. package/src/user-data-rights/__tests__/forget-cleanup-search-purge-ordering.integration.test.ts +10 -2
  42. package/src/user-data-rights/__tests__/mail-default-bridge.integration.test.ts +3 -1
  43. package/src/user-data-rights/__tests__/run-export-jobs-cron-context.integration.test.ts +8 -3
  44. package/src/user-data-rights/__tests__/run-forget-cleanup.integration.test.ts +8 -2
  45. package/src/user-data-rights/__tests__/run-user-export.integration.test.ts +8 -4
  46. package/src/user-data-rights/__tests__/tenant-model-erasure.integration.test.ts +15 -5
  47. package/src/user-data-rights/feature.ts +4 -0
  48. package/src/user-data-rights/run-export-jobs.ts +16 -1
  49. package/src/user-data-rights/run-forget-cleanup.ts +44 -2
  50. package/src/user-data-rights/run-user-export.ts +43 -3
  51. package/src/user-data-rights-defaults/__tests__/bundled-entities.userdata-hooks.integration.test.ts +7 -6
  52. package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.test.ts +64 -18
  53. package/src/user-data-rights-defaults/hooks/api-token.userdata-hook.ts +2 -3
  54. package/src/user-data-rights-defaults/hooks/config-value.userdata-hook.ts +4 -6
  55. package/src/user-data-rights-defaults/hooks/delivery-attempt.userdata-hook.ts +1 -2
  56. package/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +5 -11
  57. package/src/user-data-rights-defaults/hooks/in-app-message.userdata-hook.ts +2 -3
  58. package/src/user-data-rights-defaults/hooks/job-run.userdata-hook.ts +1 -2
  59. package/src/user-data-rights-defaults/hooks/notification-preference.userdata-hook.ts +4 -6
  60. package/src/user-data-rights-defaults/hooks/tenant-invitation.userdata-hook.ts +8 -10
  61. package/src/user-data-rights-defaults/hooks/user-session.userdata-hook.ts +2 -3
  62. package/src/user-data-rights-defaults/hooks/user.userdata-hook.ts +9 -10
@@ -7,6 +7,7 @@
7
7
 
8
8
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
9
9
  import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
10
+ import { createTenantDb } from "@cosmicdrift/kumiko-framework/db";
10
11
  import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
11
12
  import {
12
13
  createTestUser,
@@ -50,7 +51,12 @@ describe("formDraftDeleteHook — delete failure handling (#review-batch-fw-1)",
50
51
 
51
52
  await expect(
52
53
  formDraftDeleteHook(
53
- { db: stack.db, registry: stack.registry, tenantId: bob.tenantId, userId: bob.id },
54
+ {
55
+ db: createTenantDb(stack.db, bob.tenantId, "tenant"),
56
+ registry: stack.registry,
57
+ tenantId: bob.tenantId,
58
+ userId: bob.id,
59
+ },
54
60
  "delete",
55
61
  ),
56
62
  ).resolves.toBeUndefined();
@@ -5,6 +5,7 @@
5
5
 
6
6
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
7
7
  import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
8
+ import { createTenantDb } from "@cosmicdrift/kumiko-framework/db";
8
9
  import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
9
10
  import {
10
11
  createTestUser,
@@ -52,7 +53,7 @@ describe("formDraftExportHook", () => {
52
53
  );
53
54
 
54
55
  const snippet = await formDraftExportHook({
55
- db: stack.db,
56
+ db: createTenantDb(stack.db, owner.tenantId, "tenant"),
56
57
  registry: stack.registry,
57
58
  tenantId: owner.tenantId,
58
59
  userId: owner.id,
@@ -88,7 +89,7 @@ describe("formDraftExportHook", () => {
88
89
  );
89
90
 
90
91
  const snippet = await formDraftExportHook({
91
- db: stack.db,
92
+ db: createTenantDb(stack.db, tenantATestTenantId, "tenant"),
92
93
  registry: stack.registry,
93
94
  tenantId: tenantATestTenantId,
94
95
  userId: sameIdTenantA.id,
@@ -101,7 +102,7 @@ describe("formDraftExportHook", () => {
101
102
  test("returns null when the user saved no drafts", async () => {
102
103
  const lurker = createTestUser({ id: 3, roles: ["TenantMember"] });
103
104
  const snippet = await formDraftExportHook({
104
- db: stack.db,
105
+ db: createTenantDb(stack.db, lurker.tenantId, "tenant"),
105
106
  registry: stack.registry,
106
107
  tenantId: lurker.tenantId,
107
108
  userId: lurker.id,
@@ -126,7 +127,12 @@ describe("formDraftDeleteHook", () => {
126
127
  );
127
128
 
128
129
  await formDraftDeleteHook(
129
- { db: stack.db, registry: stack.registry, tenantId: alice.tenantId, userId: alice.id },
130
+ {
131
+ db: createTenantDb(stack.db, alice.tenantId, "tenant"),
132
+ registry: stack.registry,
133
+ tenantId: alice.tenantId,
134
+ userId: alice.id,
135
+ },
130
136
  "delete",
131
137
  );
132
138
 
@@ -138,7 +144,12 @@ describe("formDraftDeleteHook", () => {
138
144
  const lurker = createTestUser({ id: 13, roles: ["TenantMember"] });
139
145
  await expect(
140
146
  formDraftDeleteHook(
141
- { db: stack.db, registry: stack.registry, tenantId: lurker.tenantId, userId: lurker.id },
147
+ {
148
+ db: createTenantDb(stack.db, lurker.tenantId, "tenant"),
149
+ registry: stack.registry,
150
+ tenantId: lurker.tenantId,
151
+ userId: lurker.id,
152
+ },
142
153
  "delete",
143
154
  ),
144
155
  ).resolves.toBeUndefined();
@@ -3,8 +3,6 @@
3
3
  // consumers without the user-data-rights pipeline don't pull a hard
4
4
  // dependency.
5
5
 
6
- import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
7
- import { createTenantDb } from "@cosmicdrift/kumiko-framework/db";
8
6
  import {
9
7
  createSystemUser,
10
8
  type UserDataDeleteHook,
@@ -13,7 +11,7 @@ import {
13
11
  import { formDraftExecutor, formDraftTable } from "../form-draft";
14
12
 
15
13
  export const formDraftExportHook: UserDataExportHook = async (ctx) => {
16
- const rows = await selectMany<Record<string, unknown>>(ctx.db, formDraftTable, {
14
+ const rows = await ctx.db.selectMany(formDraftTable, {
17
15
  tenantId: ctx.tenantId,
18
16
  ownerId: ctx.userId,
19
17
  });
@@ -37,15 +35,14 @@ export const formDraftExportHook: UserDataExportHook = async (ctx) => {
37
35
  // owners' drafts, tenant model doesn't matter here (unlike folders' single-
38
36
  // user gating: a form-draft row always has exactly one owner already).
39
37
  export const formDraftDeleteHook: UserDataDeleteHook = async (ctx) => {
40
- const rows = await selectMany<{ id: string }>(ctx.db, formDraftTable, {
38
+ const rows = await ctx.db.selectMany<{ id: string }>(formDraftTable, {
41
39
  tenantId: ctx.tenantId,
42
40
  ownerId: ctx.userId,
43
41
  });
44
42
  const systemUser = createSystemUser(ctx.tenantId);
45
- const tdb = createTenantDb(ctx.db, ctx.tenantId, "system");
46
43
  const failures: string[] = [];
47
44
  for (const row of rows) {
48
- const result = await formDraftExecutor.delete({ id: row.id }, systemUser, tdb);
45
+ const result = await formDraftExecutor.delete({ id: row.id }, systemUser, ctx.db);
49
46
  if (!result.isSuccess) {
50
47
  failures.push(`${row.id}: ${result.error.message}`);
51
48
  }
@@ -5,6 +5,7 @@
5
5
 
6
6
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
7
7
  import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
8
+ import { createTenantDb } from "@cosmicdrift/kumiko-framework/db";
8
9
  import { createEntity, createTextField, defineFeature } from "@cosmicdrift/kumiko-framework/engine";
9
10
  import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
10
11
  import {
@@ -78,7 +79,7 @@ describe("noteEntryExportHook", () => {
78
79
  );
79
80
 
80
81
  const snippet = await noteEntryExportHook({
81
- db: stack.db,
82
+ db: createTenantDb(stack.db, author.tenantId, "tenant"),
82
83
  registry: stack.registry,
83
84
  tenantId: author.tenantId,
84
85
  userId: author.id,
@@ -95,7 +96,7 @@ describe("noteEntryExportHook", () => {
95
96
  test("returns null when the user authored no notes", async () => {
96
97
  const lurker = createTestUser({ id: 3, roles: ["TenantMember"] });
97
98
  const snippet = await noteEntryExportHook({
98
- db: stack.db,
99
+ db: createTenantDb(stack.db, lurker.tenantId, "tenant"),
99
100
  registry: stack.registry,
100
101
  tenantId: lurker.tenantId,
101
102
  userId: lurker.id,
@@ -107,7 +108,12 @@ describe("noteEntryExportHook", () => {
107
108
  describe("noteEntryDeleteHook", () => {
108
109
  test("is a no-op — resolves without throwing", async () => {
109
110
  const result = await noteEntryDeleteHook(
110
- { db: stack.db, registry: stack.registry, tenantId: author.tenantId, userId: author.id },
111
+ {
112
+ db: createTenantDb(stack.db, author.tenantId, "tenant"),
113
+ registry: stack.registry,
114
+ tenantId: author.tenantId,
115
+ userId: author.id,
116
+ },
111
117
  "delete",
112
118
  );
113
119
  expect(result).toBeUndefined();
@@ -4,7 +4,6 @@
4
4
  // dependency. Mirrors job-run/delivery-attempt (user-data-rights-defaults):
5
5
  // export-only, erasure via crypto-shredding.
6
6
 
7
- import { fetchOne, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
8
7
  import { configuredPiiSubjectKms } from "@cosmicdrift/kumiko-framework/crypto";
9
8
  import type { UserDataDeleteHook, UserDataExportHook } from "@cosmicdrift/kumiko-framework/engine";
10
9
  import { resolveRetentionPolicyForTenant } from "../data-retention";
@@ -14,7 +13,7 @@ import { policyToStrategy } from "../user-data-rights";
14
13
  // note-entry has no per-tenant scope quirk (unlike folders) — it's genuinely
15
14
  // per-user content, so the export filters by authorId directly.
16
15
  export const noteEntryExportHook: UserDataExportHook = async (ctx) => {
17
- const rows = await selectMany<Record<string, unknown>>(ctx.db, noteEntryTable, {
16
+ const rows = await ctx.db.selectMany(noteEntryTable, {
18
17
  authorId: ctx.userId,
19
18
  });
20
19
  if (rows.length === 0) return null;
@@ -55,7 +54,7 @@ export const noteEntryDeleteHook: UserDataDeleteHook = async (ctx) => {
55
54
  // skip: no KMS adapter mounted — record-owned fields stay plaintext framework-wide, forget is a true no-op
56
55
  if (!kms) return;
57
56
 
58
- const mentions = await selectMany<{ noteId: string }>(ctx.db, noteMentionTable, {
57
+ const mentions = await ctx.db.selectMany<{ noteId: string }>(noteMentionTable, {
59
58
  subjectId: ctx.userId,
60
59
  });
61
60
  // skip: no note-mention rows for this user — nothing structurally reaches this user's data to shred
@@ -63,7 +62,7 @@ export const noteEntryDeleteHook: UserDataDeleteHook = async (ctx) => {
63
62
 
64
63
  const noteIds = new Set(mentions.map((m) => m.noteId));
65
64
  for (const noteId of noteIds) {
66
- const note = await fetchOne<{ entityType: string }>(ctx.db, noteEntryTable, { id: noteId });
65
+ const note = await ctx.db.fetchOne<{ entityType: string }>(noteEntryTable, { id: noteId });
67
66
  // Defensive: append-only rows are never hard-deleted, so this shouldn't
68
67
  // happen — but a missing host means no retention policy to consult.
69
68
  if (!note) continue;
@@ -30,6 +30,7 @@ export type CreatePatOptions = {
30
30
  ) => Promise<PatMfaVerifyResult>;
31
31
  };
32
32
 
33
+ // @wrapper-known error-helper
33
34
  function reauthFailed() {
34
35
  return writeFailure(
35
36
  new UnprocessableError(PatErrors.reauthRequired, {
@@ -3,7 +3,6 @@
3
3
  // so apps without the user-data-rights pipeline don't pull a hard dependency.
4
4
  // Mirrors notes-history-user-data: export-only, erasure via crypto-shredding.
5
5
 
6
- import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
7
6
  import type { UserDataDeleteHook, UserDataExportHook } from "@cosmicdrift/kumiko-framework/engine";
8
7
  import { userContentEntriesTable } from "../template-resolver";
9
8
 
@@ -11,7 +10,7 @@ import { userContentEntriesTable } from "../template-resolver";
11
10
  // scoping comes from the tenant-scoped ctx.db; a user who belongs to two
12
11
  // tenants gets each tenant's entries from that tenant's forget run.
13
12
  export const userContentExportHook: UserDataExportHook = async (ctx) => {
14
- const rows = await selectMany<Record<string, unknown>>(ctx.db, userContentEntriesTable, {
13
+ const rows = await ctx.db.selectMany(userContentEntriesTable, {
15
14
  ownerId: ctx.userId,
16
15
  });
17
16
  if (rows.length === 0) return null;
@@ -132,6 +132,8 @@ export function createTenantLifecycleFeature(): FeatureDefinition {
132
132
  now: T.Now.instant(),
133
133
  log: (message) => ctx.log?.warn(message),
134
134
  fileProviderResolver: ctx._fileProviderResolver,
135
+ escapeHatchAuditSink: ctx._escapeHatchAuditSink,
136
+ actor: ctx.systemUser.id,
135
137
  });
136
138
  },
137
139
  });
@@ -3,6 +3,7 @@ import type { DbRunner } from "@cosmicdrift/kumiko-framework/db";
3
3
  import { createEventStoreExecutor, createTenantDb } from "@cosmicdrift/kumiko-framework/db";
4
4
  import {
5
5
  createSystemUser,
6
+ type EscapeHatchAuditSink,
6
7
  type Registry,
7
8
  type TenantId,
8
9
  } from "@cosmicdrift/kumiko-framework/engine";
@@ -216,6 +217,8 @@ export async function runNextDestructionStage(args: {
216
217
  readonly tenantId: TenantId;
217
218
  readonly log?: (message: string) => void;
218
219
  readonly fileProviderResolver?: FileProviderResolver;
220
+ readonly escapeHatchAuditSink?: EscapeHatchAuditSink;
221
+ readonly actor?: string;
219
222
  }): Promise<{ readonly done: boolean; readonly error?: string; readonly halted?: boolean }> {
220
223
  const events = await loadAggregate(args.db, args.tenantId, args.tenantId);
221
224
  const { completed, abandoned, attemptsByStage } = replayStageState(events);
@@ -238,6 +241,8 @@ export async function runNextDestructionStage(args: {
238
241
  tenantId: args.tenantId,
239
242
  log: args.log,
240
243
  fileProviderResolver: args.fileProviderResolver,
244
+ escapeHatchAuditSink: args.escapeHatchAuditSink,
245
+ actor: args.actor,
241
246
  };
242
247
 
243
248
  let version = lastEventVersion(events);
@@ -304,6 +309,8 @@ export async function runTenantDestructionSweep(args: {
304
309
  readonly now?: Temporal.Instant;
305
310
  readonly log?: (message: string) => void;
306
311
  readonly fileProviderResolver?: FileProviderResolver;
312
+ readonly escapeHatchAuditSink?: EscapeHatchAuditSink;
313
+ readonly actor?: string;
307
314
  }): Promise<{ readonly triggered: number; readonly advanced: number }> {
308
315
  const T = getTemporal();
309
316
  const now = args.now ?? T.Now.instant();
@@ -353,6 +360,8 @@ export async function runTenantDestructionSweep(args: {
353
360
  tenantId: row.id as TenantId,
354
361
  log: args.log,
355
362
  fileProviderResolver: args.fileProviderResolver,
363
+ escapeHatchAuditSink: args.escapeHatchAuditSink,
364
+ actor: args.actor,
356
365
  });
357
366
  if (!result.error && !result.halted) advanced++;
358
367
  } catch (err) {
@@ -7,15 +7,19 @@ import {
7
7
  } from "@cosmicdrift/kumiko-framework/db";
8
8
  import {
9
9
  createSystemUser,
10
+ type EscapeHatchAuditSink,
10
11
  EXT_EXTERNAL_RESOURCE,
11
12
  EXT_INFRA_RESOURCE,
12
13
  EXT_SEARCH_ADAPTER,
13
14
  EXT_STORAGE_PROVIDER,
14
15
  EXT_TENANT_DATA,
16
+ extensionUsageEscapeHatchReason,
15
17
  type Registry,
18
+ type TenantDataHookCtx,
16
19
  type TenantId,
17
20
  } from "@cosmicdrift/kumiko-framework/engine";
18
21
  import type { FileProviderResolver } from "@cosmicdrift/kumiko-framework/files";
22
+ import { createEscapeHatchReporter } from "@cosmicdrift/kumiko-framework/pipeline";
19
23
  import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
20
24
  import {
21
25
  tenantEntity,
@@ -35,6 +39,11 @@ export type DestructionStageCtx = {
35
39
  // "destroyTenant" hooks) — undefined when no file-provider is wired,
36
40
  // which those hooks must treat as "nothing to clean up", not an error.
37
41
  readonly fileProviderResolver?: FileProviderResolver;
42
+ // fw#2914 — sourced from the owning job's ctx (_escapeHatchAuditSink,
43
+ // systemUser.id); runTenantDataHooks uses them to attribute+audit any
44
+ // EXT_TENANT_DATA usage's declared escapeHatch.
45
+ readonly escapeHatchAuditSink?: EscapeHatchAuditSink;
46
+ readonly actor?: string;
38
47
  };
39
48
 
40
49
  export type DestructionStage = {
@@ -73,10 +82,25 @@ async function runTenantDataHooks(ctx: DestructionStageCtx): Promise<void> {
73
82
  const usages = ctx.registry.getExtensionUsages(EXT_TENANT_DATA);
74
83
  for (const usage of usages) {
75
84
  const destroy = usage.options?.["destroy"] as
76
- | ((ctx: DestructionStageCtx) => Promise<void>)
85
+ | ((hookCtx: TenantDataHookCtx) => Promise<void>)
77
86
  | undefined;
78
87
  if (!destroy) continue;
79
- await destroy(ctx);
88
+ const reason = extensionUsageEscapeHatchReason(usage);
89
+ const report = createEscapeHatchReporter({
90
+ handler: `${EXT_TENANT_DATA}:${usage.entityName}`,
91
+ tenantId: ctx.tenantId,
92
+ actor: ctx.actor ?? "system",
93
+ sink: ctx.escapeHatchAuditSink,
94
+ });
95
+ const hookCtx: TenantDataHookCtx = {
96
+ db: createTenantDb(ctx.db, ctx.tenantId, "tenant", undefined, undefined, undefined, {
97
+ unsafeRaw: reason !== undefined ? { reason } : undefined,
98
+ report,
99
+ }),
100
+ registry: ctx.registry,
101
+ tenantId: ctx.tenantId,
102
+ };
103
+ await destroy(hookCtx);
80
104
  }
81
105
  }
82
106
 
@@ -0,0 +1,138 @@
1
+ // createTierResolver (fw#2854): resolveTier/resolveTierCaps take only a
2
+ // TenantDb — the tenant comes from db.tenantId, so a caller can't request a
3
+ // foreign tenant's tier. Proves defaultTier for an unassigned tenant, the
4
+ // assigned tier for an assigned one, and the same behavior end-to-end
5
+ // through a TenantAdmin-only query handler with no escapeHatch.
6
+
7
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
8
+ import { configValuesTable } from "@cosmicdrift/kumiko-bundled-features/config";
9
+ import { tenantSecretsTable } from "@cosmicdrift/kumiko-bundled-features/secrets";
10
+ import { tenantMembershipsTable, tenantTable } from "@cosmicdrift/kumiko-bundled-features/tenant";
11
+ import { userTable } from "@cosmicdrift/kumiko-bundled-features/user";
12
+ import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
13
+ import { buildEntityTable, createTenantDb } from "@cosmicdrift/kumiko-framework/db";
14
+ import { defineFeature, type TenantId } from "@cosmicdrift/kumiko-framework/engine";
15
+ import {
16
+ createTestUser,
17
+ setupTestStack,
18
+ type TestStack,
19
+ unsafePushTables,
20
+ } from "@cosmicdrift/kumiko-framework/stack";
21
+ import { composeFeatures } from "@cosmicdrift/kumiko-server-runtime/compose-features";
22
+ import { z } from "zod";
23
+ import { tierAssignmentEntity } from "../entity";
24
+ import { tierEngineFeature } from "../feature";
25
+ import { createTierResolver } from "../tier-resolver";
26
+
27
+ type TestCaps = { readonly maxItems: number };
28
+ type TestTier = "free" | "pro";
29
+
30
+ function isTestTierName(value: string): value is TestTier {
31
+ return value === "free" || value === "pro";
32
+ }
33
+
34
+ function capsForTier(tier: TestTier): TestCaps {
35
+ return tier === "pro" ? { maxItems: 5 } : { maxItems: 1 };
36
+ }
37
+
38
+ const { resolveTier, resolveTierCaps } = createTierResolver<TestTier, TestCaps>({
39
+ capsForTier,
40
+ isTierName: isTestTierName,
41
+ defaultTier: "free",
42
+ });
43
+
44
+ const RESOLVED_CAPS_QN = "resolver-probe:query:resolved-caps";
45
+ const resolverProbeFeature = defineFeature("resolver-probe", (r) => {
46
+ r.queryHandler({
47
+ name: "resolved-caps",
48
+ schema: z.object({}),
49
+ access: { roles: ["TenantAdmin"] },
50
+ handler: async (_query, ctx) => resolveTierCaps(ctx.db),
51
+ });
52
+ });
53
+
54
+ const features = composeFeatures([tierEngineFeature, resolverProbeFeature], {
55
+ includeBundled: true,
56
+ });
57
+
58
+ // Same underlying table createTierResolver builds internally — pushed here
59
+ // so the schema exists before the write-handler + resolver read it.
60
+ const tierAssignmentTable = buildEntityTable("tier-assignment", tierAssignmentEntity);
61
+
62
+ let stack: TestStack;
63
+ const tenantA = "00000000-0000-4000-8000-0000000000c1" as TenantId;
64
+ const tenantB = "00000000-0000-4000-8000-0000000000c2" as TenantId;
65
+
66
+ beforeAll(async () => {
67
+ stack = await setupTestStack({ features });
68
+ await unsafePushTables(stack.db, {
69
+ config_values: configValuesTable,
70
+ users: userTable,
71
+ tenants: tenantTable,
72
+ tenant_memberships: tenantMembershipsTable,
73
+ tenant_secrets: tenantSecretsTable,
74
+ tier_assignments: tierAssignmentTable,
75
+ });
76
+ });
77
+
78
+ afterAll(async () => stack?.cleanup());
79
+
80
+ beforeEach(async () => {
81
+ await asRawClient(stack.db).unsafe(`TRUNCATE read_tier_assignments RESTART IDENTITY CASCADE`);
82
+ });
83
+
84
+ function sysAdminFor(tenantId: TenantId) {
85
+ return createTestUser({ id: `sys-${tenantId}`, tenantId, roles: ["SystemAdmin", "TenantAdmin"] });
86
+ }
87
+
88
+ function tenantAdminOnlyFor(tenantId: TenantId) {
89
+ return createTestUser({ id: `admin-${tenantId}`, tenantId, roles: ["TenantAdmin"] });
90
+ }
91
+
92
+ async function assignTier(tenantId: TenantId, tier: TestTier): Promise<void> {
93
+ await stack.http.writeOk(
94
+ "tier-engine:write:tier-assignment:create",
95
+ { tier },
96
+ sysAdminFor(tenantId),
97
+ );
98
+ }
99
+
100
+ describe("createTierResolver — resolveTier/resolveTierCaps", () => {
101
+ test("unassigned tenant B resolves to defaultTier, not tenant A's assigned tier", async () => {
102
+ await assignTier(tenantA, "pro");
103
+
104
+ const tierA = await resolveTier(createTenantDb(stack.db, tenantA));
105
+ expect(tierA).toBe("pro");
106
+
107
+ const tierB = await resolveTier(createTenantDb(stack.db, tenantB));
108
+ expect(tierB).toBe("free");
109
+ });
110
+
111
+ test("resolveTierCaps maps the resolved tier via capsForTier", async () => {
112
+ await assignTier(tenantA, "pro");
113
+
114
+ const capsA = await resolveTierCaps(createTenantDb(stack.db, tenantA));
115
+ expect(capsA).toEqual({ maxItems: 5 });
116
+
117
+ const capsB = await resolveTierCaps(createTenantDb(stack.db, tenantB));
118
+ expect(capsB).toEqual({ maxItems: 1 });
119
+ });
120
+
121
+ test("end-to-end via a TenantAdmin-only query handler (no escapeHatch)", async () => {
122
+ await assignTier(tenantA, "pro");
123
+
124
+ const capsA = await stack.http.queryOk<TestCaps>(
125
+ RESOLVED_CAPS_QN,
126
+ {},
127
+ tenantAdminOnlyFor(tenantA),
128
+ );
129
+ expect(capsA).toEqual({ maxItems: 5 });
130
+
131
+ const capsB = await stack.http.queryOk<TestCaps>(
132
+ RESOLVED_CAPS_QN,
133
+ {},
134
+ tenantAdminOnlyFor(tenantB),
135
+ );
136
+ expect(capsB).toEqual({ maxItems: 1 });
137
+ });
138
+ });
@@ -1,4 +1,11 @@
1
1
  [
2
+ {
3
+ "version": "0.278.0",
4
+ "type": "breaking",
5
+ "title": "createTierResolver's resolveTier/resolveTierCaps take only a TenantDb — no separate tenantId argument (fw#2854).",
6
+ "detail": "`resolveTier(db)` and `resolveTierCaps(db)` (from `createTierResolver`) now read the tenant from `db.tenantId` instead of taking a second `tenantId` parameter, so a caller can no longer pass a `TenantDb` for one tenant alongside a different `tenantId` and resolve a foreign tenant's tier.",
7
+ "migration": "`resolveTier(ctx.db.raw|runner, tenantId)` → `resolveTier(ctx.db)` (same for `resolveTierCaps`). Outside a handler: `resolveTier(createTenantDb(runner, tenantId))`."
8
+ },
2
9
  {
3
10
  "version": "0.241.0",
4
11
  "type": "breaking",
@@ -3,8 +3,7 @@
3
3
  // only per-app variables were the TierName union and its caps/defaults, so
4
4
  // both are now factory parameters.
5
5
 
6
- import { buildEntityTable, type DbRunner, fetchOne } from "@cosmicdrift/kumiko-framework/db";
7
- import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
6
+ import { buildEntityTable, type TenantDb } from "@cosmicdrift/kumiko-framework/db";
8
7
  import { tierAssignmentEntity } from "./entity";
9
8
 
10
9
  export type TierResolverDeps<TTier extends string, TCaps> = {
@@ -18,14 +17,16 @@ export function createTierResolver<TTier extends string, TCaps>(
18
17
  ) {
19
18
  const tierAssignmentTable = buildEntityTable("tier-assignment", tierAssignmentEntity);
20
19
 
21
- async function resolveTier(db: DbRunner, tenantId: TenantId): Promise<TTier> {
22
- const row = await fetchOne<{ tier?: unknown }>(db, tierAssignmentTable, { tenantId });
20
+ async function resolveTier(db: TenantDb): Promise<TTier> {
21
+ const row = await db.fetchOne<{ tier?: unknown }>(tierAssignmentTable, {
22
+ tenantId: db.tenantId,
23
+ });
23
24
  const tier = row?.tier;
24
25
  return typeof tier === "string" && deps.isTierName(tier) ? tier : deps.defaultTier;
25
26
  }
26
27
 
27
- async function resolveTierCaps(db: DbRunner, tenantId: TenantId): Promise<TCaps> {
28
- return deps.capsForTier(await resolveTier(db, tenantId));
28
+ async function resolveTierCaps(db: TenantDb): Promise<TCaps> {
29
+ return deps.capsForTier(await resolveTier(db));
29
30
  }
30
31
 
31
32
  return { tierAssignmentTable, resolveTier, resolveTierCaps };
@@ -19,6 +19,7 @@ import { defineUnmanagedTable } from "@cosmicdrift/kumiko-framework/db";
19
19
  import {
20
20
  defineFeature,
21
21
  EXT_USER_DATA,
22
+ SYSTEM_TENANT_ID,
22
23
  type UserDataDeleteHook,
23
24
  type UserDataExportHook,
24
25
  } from "@cosmicdrift/kumiko-framework/engine";
@@ -49,7 +50,7 @@ let stack: TestStack;
49
50
 
50
51
  const TENANT_A = "00000000-0000-4000-8000-00000000000a";
51
52
  const TENANT_B = "00000000-0000-4000-8000-00000000000b";
52
- const TENANT_SYSTEM = "00000000-0000-4000-8000-000000000001";
53
+ const TENANT_SYSTEM = SYSTEM_TENANT_ID;
53
54
 
54
55
  function uuid(suffix: number): string {
55
56
  return `cccccccc-cccc-4ccc-8ccc-${suffix.toString(16).padStart(12, "0")}`;
@@ -77,20 +78,10 @@ const testNotesTable = defineUnmanagedTable({
77
78
  // Stellvertretend fuer App-spezifische Entities (Chat-Message, Blog-Post
78
79
  // etc.), die ueber EXT_USER_DATA sauber in die Pipeline integrieren.
79
80
  const exportNotes: UserDataExportHook = async (ctx) => {
80
- const result = await asRawClient(ctx.db).unsafe(
81
- `
82
- SELECT id, title, body
83
- FROM test_notes
84
- WHERE tenant_id = $1 AND author_id = $2
85
- `,
86
- [ctx.tenantId, ctx.userId],
81
+ const rows = await ctx.db.selectMany<{ id: string; title: string; body: string }>(
82
+ testNotesTable,
83
+ { tenantId: ctx.tenantId, authorId: ctx.userId },
87
84
  );
88
- // biome-ignore lint/suspicious/noExplicitAny: drizzle execute typing
89
- const rows = ((result as any).rows ?? result) as Array<{
90
- id: string;
91
- title: string;
92
- body: string;
93
- }>;
94
85
  if (rows.length === 0) return null;
95
86
  return {
96
87
  entity: "note",