@cosmicdrift/kumiko-bundled-features 0.270.0 → 0.274.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 (56) hide show
  1. package/package.json +9 -9
  2. package/src/__tests__/account-security-fixture.ts +3 -0
  3. package/src/agent-tools/tool-catalog.ts +1 -1
  4. package/src/agent-tools/tool-dispatch.ts +1 -1
  5. package/src/audit/__tests__/escape-hatch-audit.integration.test.ts +96 -0
  6. package/src/audit/changes.json +12 -0
  7. package/src/audit/escape-hatch-audit-sink.ts +52 -0
  8. package/src/audit/feature.ts +5 -1
  9. package/src/audit/index.ts +1 -0
  10. package/src/auth-email-password/__tests__/query-as-member.integration.test.ts +303 -0
  11. package/src/auth-mfa/screens.ts +14 -10
  12. package/src/cap-counter/feature.ts +5 -3
  13. package/src/cap-overview/screens.ts +6 -0
  14. package/src/config/__tests__/reencrypt-job-tenant-checks.integration.test.ts +2 -1
  15. package/src/config/changes.json +6 -0
  16. package/src/config/db/queries/__tests__/resolver.test.ts +43 -38
  17. package/src/config/db/queries/resolver.ts +22 -22
  18. package/src/config/handlers/cascade.query.ts +6 -0
  19. package/src/config/handlers/readiness.query.ts +6 -0
  20. package/src/config/handlers/reencrypt.job.ts +21 -2
  21. package/src/config/handlers/schema.query.ts +6 -0
  22. package/src/config/handlers/values.query.ts +6 -0
  23. package/src/delivery/__tests__/delivery.integration.test.ts +34 -0
  24. package/src/delivery/changes.json +6 -0
  25. package/src/delivery/delivery-service.ts +25 -3
  26. package/src/delivery/handlers/preferences.query.ts +6 -0
  27. package/src/delivery/testing.ts +8 -1
  28. package/src/jobs/handlers/list.query.ts +3 -1
  29. package/src/ledger/__tests__/ledger.integration.test.ts +57 -0
  30. package/src/managed-pages/screens/branding-screen.ts +3 -0
  31. package/src/managed-pages/screens/page-screens.ts +3 -0
  32. package/src/notes-history/__tests__/notes-section.test.tsx +23 -0
  33. package/src/notes-history/web/i18n.ts +3 -0
  34. package/src/notes-history/web/notes-section.tsx +61 -51
  35. package/src/personal-access-tokens/screens.ts +7 -7
  36. package/src/sessions/screens.ts +5 -3
  37. package/src/tags/screens.ts +3 -0
  38. package/src/tenant/__tests__/members-facet-i18n.test.ts +5 -5
  39. package/src/tenant/changes.json +6 -0
  40. package/src/tenant/handlers/me.query.ts +6 -0
  41. package/src/tenant/handlers/update-member-roles.write.ts +10 -1
  42. package/src/tenant/screens.ts +3 -0
  43. package/src/tenant/seeding.ts +25 -19
  44. package/src/tier-engine/feature.ts +3 -0
  45. package/src/user/changes.json +6 -0
  46. package/src/user/handlers/me.query.ts +6 -0
  47. package/src/user/handlers/update-roles.ts +8 -5
  48. package/src/user/screens.ts +3 -0
  49. package/src/user-data-rights/__tests__/boot-checks.test.ts +16 -0
  50. package/src/user-data-rights/__tests__/export-job-list-cross-tenant.integration.test.ts +1 -1
  51. package/src/user-data-rights/feature.ts +3 -5
  52. package/src/user-data-rights/handlers/download-attempt-list.query.ts +4 -1
  53. package/src/user-data-rights/handlers/export-job-detail.query.ts +4 -1
  54. package/src/user-data-rights/handlers/export-job-list.query.ts +4 -1
  55. package/src/user-data-rights/screens.ts +3 -0
  56. package/src/user-profile/feature.ts +3 -0
@@ -11,6 +11,7 @@
11
11
 
12
12
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
13
13
  import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
14
+ import { createSystemUser } from "@cosmicdrift/kumiko-framework/engine";
14
15
  import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
15
16
  import {
16
17
  createTestUser,
@@ -18,6 +19,7 @@ import {
18
19
  type TestStack,
19
20
  unsafeCreateEntityTable,
20
21
  } from "@cosmicdrift/kumiko-framework/stack";
22
+ import { generateDeterministicId } from "@cosmicdrift/kumiko-framework/utils";
21
23
  import { type AccountType, LedgerHandlers, LedgerQueries } from "../constants";
22
24
  import { accountEntity, scheduleEntity, transactionEntity } from "../entity";
23
25
  import { createLedgerFeature } from "../feature";
@@ -544,6 +546,61 @@ describe("ledger integration — confirm-schedule-period (recurring)", () => {
544
546
  });
545
547
  });
546
548
 
549
+ describe("ledger integration — createSchedule with a caller-chosen id (idempotent job creates)", () => {
550
+ // An event-triggered job derives the schedule id deterministically from
551
+ // its own contract line instead of matching on description text. It acts
552
+ // as createSystemUser(tenantId, extraRoles) — extraRoles clears the
553
+ // handler's normal TenantAdmin/TenantMember access gate while roles still
554
+ // carrying "system" is what lets the create handler honor the id at all.
555
+ const jobUser = createSystemUser(admin.tenantId, ["TenantAdmin"]);
556
+
557
+ test("the chosen id is honored; redelivering the same event is recognized, not duplicated", async () => {
558
+ const bank = await createAccount("Bank", "asset");
559
+ const rent = await createAccount("Mieterträge", "income");
560
+ const scheduleId = generateDeterministicId(`ledger:schedule:${admin.tenantId}`, "contract-42");
561
+ const payload = {
562
+ description: "Miete WE1",
563
+ startDate: "2026-01-01",
564
+ interval: "monthly",
565
+ amount: 50000,
566
+ debitAccountId: bank,
567
+ creditAccountId: rent,
568
+ id: scheduleId,
569
+ };
570
+
571
+ const created = await stack.http.writeOk<{ id: string }>(
572
+ LedgerHandlers.createSchedule,
573
+ payload,
574
+ jobUser,
575
+ );
576
+ expect(created.id).toBe(scheduleId);
577
+
578
+ const redelivered = await stack.http.writeErr(LedgerHandlers.createSchedule, payload, jobUser);
579
+ expect(redelivered.code).toBe("version_conflict");
580
+ });
581
+
582
+ test("an ordinary tenant user's chosen id is ignored — create still succeeds with a fresh id", async () => {
583
+ const bank = await createAccount("Bank", "asset");
584
+ const rent = await createAccount("Mieterträge", "income");
585
+ const chosenId = generateDeterministicId(`ledger:schedule:${admin.tenantId}`, "contract-99");
586
+
587
+ const created = await stack.http.writeOk<{ id: string }>(
588
+ LedgerHandlers.createSchedule,
589
+ {
590
+ description: "Miete WE2",
591
+ startDate: "2026-01-01",
592
+ interval: "monthly",
593
+ amount: 30000,
594
+ debitAccountId: bank,
595
+ creditAccountId: rent,
596
+ id: chosenId,
597
+ },
598
+ admin,
599
+ );
600
+ expect(created.id).not.toBe(chosenId);
601
+ });
602
+ });
603
+
547
604
  describe("ledger integration — subject dimension (filterable business-object reference)", () => {
548
605
  test("a booking with subjectType/subjectId round-trips and is findable via an eq filter", async () => {
549
606
  const bank = await createAccount("Bank", "asset");
@@ -81,6 +81,9 @@ export function createBrandingSettingsScreen(opts: {
81
81
  ],
82
82
  },
83
83
  access: { roles: ADMIN_ROLES },
84
+ // See file header: nav placement is app-specific, so this opts out of
85
+ // the boot-validator's nav-area check.
86
+ dormant: true,
84
87
  };
85
88
  if (!opts.allowCustomCss) return base;
86
89
  return {
@@ -52,6 +52,9 @@ export const pageListScreen: EntityListScreenDefinition = {
52
52
  },
53
53
  ],
54
54
  access: { roles: ADMIN_ROLES },
55
+ // Nav/placement is app-specific (see file header) — no nav area to
56
+ // resolve in isolation.
57
+ dormant: true,
55
58
  };
56
59
 
57
60
  export const pageEditScreen: EntityEditScreenDefinition = {
@@ -127,6 +127,29 @@ function renderSection(): ReturnType<typeof render> {
127
127
  return render(<NotesSection entityName="contact" entityId="contact-1" />, { wrapper });
128
128
  }
129
129
 
130
+ describe("NotesSection — two-card layout", () => {
131
+ test("renders a 'new note' card and a 'history' card as two separate cards", async () => {
132
+ const view = renderSection();
133
+ await waitFor(() => expect(view.getByTestId("notes-section-history")).toBeTruthy());
134
+
135
+ const newNoteCard = view.getByTestId("notes-section-new");
136
+ const historyCard = view.getByTestId("notes-section-history");
137
+ expect(newNoteCard).not.toBe(historyCard);
138
+ expect(newNoteCard.textContent).toContain("New note");
139
+ expect(historyCard.textContent).toContain("History");
140
+ });
141
+
142
+ test("history entries are separated by dividers, not individual borders", async () => {
143
+ const view = renderSection();
144
+ await waitFor(() => expect(view.getByTestId("notes-section-row-note-1")).toBeTruthy());
145
+
146
+ const row = view.getByTestId("notes-section-row-note-1");
147
+ expect(row.className).not.toContain("border");
148
+ const historyCard = view.getByTestId("notes-section-history");
149
+ expect(historyCard.querySelector(".divide-y")).not.toBeNull();
150
+ });
151
+ });
152
+
130
153
  describe("NotesSection — entry display", () => {
131
154
  test("note body and meta line render as two distinct nodes, not one run-on line", async () => {
132
155
  const view = renderSection();
@@ -15,5 +15,8 @@ export const defaultTranslations: TranslationsByLocale = {
15
15
  "notesHistory.section.add": "Add note",
16
16
  "notesHistory.section.meta": "{author} · {date}",
17
17
  "notesHistory.section.authorUnknown": "Unknown author",
18
+ "notesHistory.section.newNoteTitle": "New note",
19
+ "notesHistory.section.historyTitle": "History",
20
+ "notesHistory.section.shortcutHint": "Ctrl+Enter saves",
18
21
  },
19
22
  };
@@ -48,7 +48,7 @@ export function NotesSection({
48
48
  readonly entityName: string;
49
49
  readonly entityId: string | null;
50
50
  }): ReactNode {
51
- const { Banner, Button, Text, Input } = usePrimitives();
51
+ const { Banner, Button, Card, Grid, Text, Input } = usePrimitives();
52
52
  const t = useTranslation();
53
53
  const dispatcher = useDispatcher();
54
54
  const enabled = entityId !== null;
@@ -118,57 +118,67 @@ export function NotesSection({
118
118
  };
119
119
 
120
120
  return (
121
- <div data-testid="notes-section" className="flex flex-col gap-3">
122
- <div className="flex flex-col gap-2">
123
- <Input
124
- kind="textarea"
125
- id="notes-section-draft"
126
- name="draft"
127
- value={draft}
128
- onChange={setDraft}
129
- onSubmitShortcut={() => {
130
- if (!busy && draft.trim() !== "") addNote();
131
- }}
132
- />
133
- <div>
134
- <Button
135
- variant="primary"
136
- disabled={busy || draft.trim() === ""}
137
- onClick={() => addNote()}
138
- testId="notes-section-add"
139
- >
140
- {busy ? t("notesHistory.section.working") : t("notesHistory.section.add")}
141
- </Button>
142
- </div>
143
- </div>
144
-
145
- {rows.length === 0 ? (
146
- <Text variant="small">{t("notesHistory.section.empty")}</Text>
147
- ) : (
148
- <div className="flex flex-col gap-2">
149
- {rows.map((n) => (
150
- <div
151
- key={n.id}
152
- data-testid={`notes-section-row-${n.id}`}
153
- className="flex flex-col gap-1 rounded-md border p-2"
121
+ <Grid columns={1} testId="notes-section">
122
+ <Card slots={{ title: t("notesHistory.section.newNoteTitle") }} testId="notes-section-new">
123
+ <Grid columns={1}>
124
+ <Input
125
+ kind="textarea"
126
+ id="notes-section-draft"
127
+ name="draft"
128
+ value={draft}
129
+ onChange={setDraft}
130
+ onSubmitShortcut={() => {
131
+ if (!busy && draft.trim() !== "") addNote();
132
+ }}
133
+ />
134
+ <div className="flex items-center justify-between gap-2">
135
+ <Text variant="small">{t("notesHistory.section.shortcutHint")}</Text>
136
+ <Button
137
+ variant="primary"
138
+ disabled={busy || draft.trim() === ""}
139
+ onClick={() => addNote()}
140
+ testId="notes-section-add"
154
141
  >
155
- <Text testId={`notes-section-body-${n.id}`}>{n.body}</Text>
156
- <Text variant="small" testId={`notes-section-meta-${n.id}`}>
157
- {t("notesHistory.section.meta", {
158
- author: displayAuthorName(n.authorName, t("notesHistory.section.authorUnknown")),
159
- date: formatWhen(n.insertedAt),
160
- })}
161
- </Text>
162
- </div>
163
- ))}
164
- </div>
165
- )}
142
+ {busy ? t("notesHistory.section.working") : t("notesHistory.section.add")}
143
+ </Button>
144
+ </div>
145
+ {errorKey !== null && (
146
+ <Banner variant="error" testId="notes-section-action-error">
147
+ <Text>{t(errorKey)}</Text>
148
+ </Banner>
149
+ )}
150
+ </Grid>
151
+ </Card>
166
152
 
167
- {errorKey !== null && (
168
- <Banner variant="error" testId="notes-section-action-error">
169
- <Text>{t(errorKey)}</Text>
170
- </Banner>
171
- )}
172
- </div>
153
+ <Card
154
+ slots={{ title: t("notesHistory.section.historyTitle") }}
155
+ testId="notes-section-history"
156
+ >
157
+ {rows.length === 0 ? (
158
+ <Text variant="small">{t("notesHistory.section.empty")}</Text>
159
+ ) : (
160
+ <div className="flex flex-col divide-y">
161
+ {rows.map((n) => (
162
+ <div
163
+ key={n.id}
164
+ data-testid={`notes-section-row-${n.id}`}
165
+ className="flex flex-col gap-1 py-3 first:pt-0 last:pb-0"
166
+ >
167
+ <Text testId={`notes-section-body-${n.id}`}>{n.body}</Text>
168
+ <Text variant="small" testId={`notes-section-meta-${n.id}`}>
169
+ {t("notesHistory.section.meta", {
170
+ author: displayAuthorName(
171
+ n.authorName,
172
+ t("notesHistory.section.authorUnknown"),
173
+ ),
174
+ date: formatWhen(n.insertedAt),
175
+ })}
176
+ </Text>
177
+ </div>
178
+ ))}
179
+ </div>
180
+ )}
181
+ </Card>
182
+ </Grid>
173
183
  );
174
184
  }
@@ -6,11 +6,6 @@ import {
6
6
  import { PAT_MINT_SCREEN_ID, PAT_SCREEN_ID, PatHandlers, PatQueries } from "./constants";
7
7
  import type { PatScopeConfig } from "./scopes";
8
8
 
9
- const PAT_LIST_OPEN_REASON =
10
- "each signed-in user views only their own personal access tokens, mirroring the list handler";
11
- const PAT_MINT_OPEN_REASON =
12
- "each signed-in user mints a token for their own account only, mirroring the create handler";
13
-
14
9
  export const patListScreen: ProjectionListScreenDefinition = {
15
10
  id: PAT_SCREEN_ID,
16
11
  type: "projectionList",
@@ -54,9 +49,13 @@ export const patListScreen: ProjectionListScreenDefinition = {
54
49
  ],
55
50
  access: {
56
51
  openToAll: {
57
- reason: PAT_LIST_OPEN_REASON,
52
+ reason:
53
+ "each signed-in user views only their own personal access tokens, mirroring the list handler",
58
54
  },
59
55
  },
56
+ // Self-service settings-area screen, placed by the consuming app's own
57
+ // r.nav() — no nav area to resolve in isolation.
58
+ dormant: true,
60
59
  };
61
60
 
62
61
  // Grant-string vocabulary for a scope config's multiSelect field — a
@@ -113,7 +112,8 @@ export function createPatMintScreen(scopes: PatScopeConfig): SecretMintScreenDef
113
112
  cancelTarget: PAT_SCREEN_ID,
114
113
  access: {
115
114
  openToAll: {
116
- reason: PAT_MINT_OPEN_REASON,
115
+ reason:
116
+ "each signed-in user mints a token for their own account only, mirroring the create handler",
117
117
  },
118
118
  },
119
119
  };
@@ -13,8 +13,6 @@ import {
13
13
  } from "./constants";
14
14
 
15
15
  const listAccess = { roles: access.admin };
16
- const SESSION_MINE_OPEN_REASON =
17
- "each signed-in user views and revokes only their own sessions, mirroring the mine/revoke handlers";
18
16
 
19
17
  export const sessionListScreen: ProjectionListScreenDefinition = {
20
18
  id: SESSION_LIST_SCREEN_ID,
@@ -144,7 +142,11 @@ export const sessionMineScreen: ProjectionListScreenDefinition = {
144
142
  ],
145
143
  access: {
146
144
  openToAll: {
147
- reason: SESSION_MINE_OPEN_REASON,
145
+ reason:
146
+ "each signed-in user views and revokes only their own sessions, mirroring the mine/revoke handlers",
148
147
  },
149
148
  },
149
+ // Self-service settings-area screen, placed by the consuming app's own
150
+ // r.nav() — no nav area to resolve in isolation.
151
+ dormant: true,
150
152
  };
@@ -40,6 +40,9 @@ export function createTagListScreen(
40
40
  },
41
41
  ],
42
42
  access,
43
+ // App navs via r.nav("tags:screen:tag-list") — no nav area to resolve
44
+ // in isolation.
45
+ dormant: true,
43
46
  };
44
47
  }
45
48
 
@@ -16,11 +16,11 @@ function membersFacetLabelKeys(): readonly string[] {
16
16
  if (screen?.type !== "projectionList") {
17
17
  throw new Error("expected members screen to be projectionList");
18
18
  }
19
- return (screen.facets ?? []).flatMap((facet) =>
20
- facet.type === "select"
21
- ? [facet.label, ...facet.options.map((option) => option.label)]
22
- : [facet.label, facet.trueLabel, facet.falseLabel],
23
- );
19
+ return (screen.facets ?? []).flatMap((facet) => {
20
+ if (facet.type === "select") return [facet.label, ...facet.options.map((o) => o.label)];
21
+ if (facet.type === "boolean") return [facet.label, facet.trueLabel, facet.falseLabel];
22
+ return [facet.label];
23
+ });
24
24
  }
25
25
 
26
26
  // Column labels of the members screen itself — deliberately scoped to this
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.271.0",
4
+ "type": "improvement",
5
+ "title": "systemScope handlers get a default per-tenant+handler rate limit; tenant:me opts out (fw#2861).",
6
+ "detail": "`tenant` mounts under `r.systemScope()`, so its write/query handlers without their own `rateLimit` now default to `{ per: \"tenant+handler\", limit: 600, windowSeconds: 60 }` once an app has a `RateLimitResolver` configured (framework change, see kumiko-framework changes.json 0.271.0). `tenant:query:me` (`tenant:me.query.ts`) declares `rateLimit: { disabled: true, reason: \"...\" }` — it is a self-scoped read of the caller's own active tenant, hit on every page load, and a per-tenant bucket would throttle the whole tenant's page loads instead of just that one handler's traffic."
7
+ },
2
8
  {
3
9
  "version": "0.219.0",
4
10
  "type": "improvement",
@@ -16,6 +16,12 @@ export const meQuery = defineQueryHandler({
16
16
  "to the caller's own tenantId",
17
17
  },
18
18
  },
19
+ rateLimit: {
20
+ disabled: true,
21
+ reason:
22
+ "self-scoped read of the caller's own active tenant, hit on every page load; per-tenant " +
23
+ "bucket would throttle the whole tenant, L1 IP limit still applies",
24
+ },
19
25
  description:
20
26
  "Returns the record of the tenant the caller is currently signed in to, or null if it is gone; use it whenever the active tenant's own name, key or settings are needed.",
21
27
  handler: async (query, ctx) => {
@@ -122,7 +122,16 @@ export const updateMemberRolesWrite = defineWriteHandler({
122
122
  const willBeTenantAdmin = event.payload.roles.includes("TenantAdmin");
123
123
 
124
124
  if (targetIsTenantAdmin && !willBeTenantAdmin) {
125
- const lastAdmin = await assertNotLastTenantAdmin(db, targetTenantId, event.payload.userId);
125
+ // assertNotLastTenantAdmin issues raw SQL, so it needs the raw runner, not
126
+ // `db` — the count is already filtered by the explicit targetTenantId.
127
+ const lockRunner = ctx.systemDb.unsafeRaw(
128
+ "tenant:write:updateMemberRoles last-TenantAdmin advisory lock + membership count",
129
+ );
130
+ const lastAdmin = await assertNotLastTenantAdmin(
131
+ lockRunner,
132
+ targetTenantId,
133
+ event.payload.userId,
134
+ );
126
135
  if (lastAdmin !== undefined) return lastAdmin;
127
136
  }
128
137
 
@@ -44,6 +44,9 @@ export const tenantListScreen: EntityListScreenDefinition = {
44
44
  defaultSort: { field: "name", dir: "asc" },
45
45
  searchable: true,
46
46
  access: { roles: ["SystemAdmin"] },
47
+ // Inert until an app navs it (see file header) — no nav area to resolve
48
+ // in isolation.
49
+ dormant: true,
47
50
  };
48
51
 
49
52
  export const tenantEditScreen: EntityEditScreenDefinition = {
@@ -117,19 +117,22 @@ export type SeedTenantHooks = {
117
117
  // (HandlerContext from a live write), afterCommit hooks are queued onto the
118
118
  // dispatcher's sink and flush post-commit. Without a sink (fixture / plain
119
119
  // Connection) they still fire immediately — there is no outer TX to wait for.
120
- function resolveRawDb(db: AppContext["db"]): DbRunner | undefined {
121
- if (!db) return undefined;
122
- if (typeof db === "object" && "raw" in db) {
123
- return (db as { raw: DbRunner }).raw;
124
- }
125
- return db as DbRunner;
126
- }
127
-
128
- function scopeSeedHookContext(context: AppContext, targetTenantId: TenantId): AppContext {
129
- const raw = resolveRawDb(context.db);
130
- const db = raw
131
- ? createTenantDb(raw, targetTenantId, "system", context.tracer, context.meter, context.signal)
132
- : context.db;
120
+ //
121
+ // `runner` comes from the caller, which already holds a declared DbRunner —
122
+ // a TenantDb exposes no runner back out to feature code.
123
+ function scopeSeedHookContext(
124
+ context: AppContext,
125
+ targetTenantId: TenantId,
126
+ runner: DbRunner,
127
+ ): AppContext {
128
+ const db = createTenantDb(
129
+ runner,
130
+ targetTenantId,
131
+ "system",
132
+ context.tracer,
133
+ context.meter,
134
+ context.signal,
135
+ );
133
136
 
134
137
  // HandlerContext carries `user`; AppContext only `systemUser`. Prefer the
135
138
  // live handler user when present so notify/hasFeature keep a real SessionUser.
@@ -163,7 +166,7 @@ function scopeSeedHookContext(context: AppContext, targetTenantId: TenantId): Ap
163
166
  return {
164
167
  ...context,
165
168
  _tenantId: targetTenantId,
166
- ...(db !== undefined ? { db } : {}),
169
+ db,
167
170
  ...(notify !== undefined ? { notify } : {}),
168
171
  ...(scopedUser && "user" in context ? { user: scopedUser } : {}),
169
172
  ...(hasFeature ? { hasFeature } : {}),
@@ -176,16 +179,16 @@ export async function fireEntityPostSave(
176
179
  entityData: SaveContext,
177
180
  // Undefined for tenant-agnostic entities (user has no tenant_id column at
178
181
  // seed time) — keeps hooks.context._tenantId as the caller passed it.
179
- targetTenantId?: TenantId,
182
+ target?: { readonly tenantId: TenantId; readonly db: DbRunner },
180
183
  ): Promise<void> {
181
184
  // skip: caller opted out of hooks (existing fixture/test call-sites that
182
185
  // don't pass them keep today's hook-less behavior, see seedTenant's doc).
183
186
  if (!hooks) return;
184
187
  const lifecycle = createLifecycleHooks(hooks.registry);
185
188
  const scopedContext =
186
- targetTenantId === undefined
189
+ target === undefined
187
190
  ? hooks.context
188
- : scopeSeedHookContext(hooks.context, targetTenantId);
191
+ : scopeSeedHookContext(hooks.context, target.tenantId, target.db);
189
192
  await lifecycle.runPostSave(pseudoType, entityData, scopedContext, HookPhases.inTransaction);
190
193
  const runAfterCommit = () =>
191
194
  lifecycle.runPostSave(pseudoType, entityData, scopedContext, HookPhases.afterCommit);
@@ -239,7 +242,7 @@ export async function seedTenant(
239
242
  // "tenant:seed" matches no handler-scoped hook, only entity-scoped ones
240
243
  // (keyed by result.entityName === "tenant") — that's exactly what
241
244
  // tier-engine's `r.hook("postSave", { allOf: "tenant" }, ...)` needs.
242
- await fireEntityPostSave(hooks, "tenant:seed", result.data, options.id);
245
+ await fireEntityPostSave(hooks, "tenant:seed", result.data, { tenantId: options.id, db });
243
246
 
244
247
  return { id: options.id };
245
248
  }
@@ -301,7 +304,10 @@ export async function seedTenantMembership(
301
304
  `seedTenantMembership failed: ${result.error.code} — ${JSON.stringify(result.error.details ?? {})}`,
302
305
  );
303
306
  }
304
- await fireEntityPostSave(hooks, "tenant-membership:seed", result.data, options.tenantId);
307
+ await fireEntityPostSave(hooks, "tenant-membership:seed", result.data, {
308
+ tenantId: options.tenantId,
309
+ db,
310
+ });
305
311
  return { id: extractMembershipId(result.data) };
306
312
  }
307
313
 
@@ -290,6 +290,9 @@ export function createTierEngineFeature<
290
290
  description:
291
291
  "Operator form that assigns a tier to any tenant as a manual grant without a billing purchase.",
292
292
  access: { roles: ["SystemAdmin"] },
293
+ // Apps place it via r.nav() (see above) — no nav area to resolve in
294
+ // isolation.
295
+ dormant: true,
293
296
  });
294
297
 
295
298
  // ───────────────────────────────────────────────────────────────────
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.271.0",
4
+ "type": "improvement",
5
+ "title": "systemScope handlers get a default per-tenant+handler rate limit; user:me opts out (fw#2861).",
6
+ "detail": "`user` mounts under `r.systemScope()`, so its write/query handlers without their own `rateLimit` now default to `{ per: \"tenant+handler\", limit: 600, windowSeconds: 60 }` once an app has a `RateLimitResolver` configured (framework change, see kumiko-framework changes.json 0.271.0). `user:query:user:me` (`user:me.query.ts`) declares `rateLimit: { disabled: true, reason: \"...\" }` — it is a self-scoped read of the caller's own identity record, hit on every page load, and a per-tenant bucket would throttle the whole tenant's page loads instead of just that one handler's traffic."
7
+ },
2
8
  {
3
9
  "version": "0.218.1",
4
10
  "type": "improvement",
@@ -18,6 +18,12 @@ export const meQuery = defineQueryHandler({
18
18
  "is by the caller's own id",
19
19
  },
20
20
  },
21
+ rateLimit: {
22
+ disabled: true,
23
+ reason:
24
+ "self-scoped read of the caller's own user record, hit on every page load; per-tenant " +
25
+ "bucket would throttle the whole tenant, L1 IP limit still applies",
26
+ },
21
27
  description:
22
28
  "Returns the signed-in caller's own identity record, with the password hash stripped by field-level read access; use it whenever the current user's own profile data is needed.",
23
29
  handler: async (query, ctx) => {
@@ -126,14 +126,17 @@ export async function applyUserRolesUpdate(
126
126
  const willBeSystemAdmin = newRoles.includes("SystemAdmin");
127
127
 
128
128
  if (wasActiveSystemAdmin && !willBeSystemAdmin) {
129
- // Lock before count+update so two concurrent demotions cannot both
130
- // observe otherActiveSystemAdmins >= 1 and leave zero active SystemAdmins.
131
- await acquireNamespacedAdvisoryLock(db, LAST_SYSTEM_ADMIN_LOCK_NAMESPACE, "global");
132
- // user is r.systemScope() — the count spans every tenant's SystemAdmins,
133
- // so it needs the raw runner, not the (still tenant-shaped) `db` above.
129
+ // user is r.systemScope() — the lock + count span every tenant's SystemAdmins,
130
+ // so both need the raw runner, not the tenant-shaped `db` above.
134
131
  if (!ctx.systemDb) {
135
132
  throw new InternalError({ message: "user:update-roles requires ctx.systemDb" });
136
133
  }
134
+ const lockRunner = ctx.systemDb.unsafeRaw(
135
+ "advisory lock serializing concurrent last-SystemAdmin demotions",
136
+ );
137
+ // Lock before count+update so two concurrent demotions cannot both
138
+ // observe otherActiveSystemAdmins >= 1 and leave zero active SystemAdmins.
139
+ await acquireNamespacedAdvisoryLock(lockRunner, LAST_SYSTEM_ADMIN_LOCK_NAMESPACE, "global");
137
140
  const otherActiveSystemAdmins = await countOtherActiveSystemAdmins(
138
141
  ctx.systemDb.unsafeRaw(
139
142
  "jsonb @> prefilter over the global users table to count remaining active SystemAdmins",
@@ -32,6 +32,9 @@ export const userListScreen: EntityListScreenDefinition = {
32
32
  // Apps must boot a user search index on that tenant (offlot: bootWaitlistSearch).
33
33
  searchable: true,
34
34
  access: { roles: ["SystemAdmin"] },
35
+ // Inert until an app navs it (see file header) — no nav area to resolve
36
+ // in isolation.
37
+ dormant: true,
35
38
  };
36
39
 
37
40
  export const userEditScreen: EntityEditScreenDefinition = {
@@ -270,3 +270,19 @@ describe("GDPR-storage boot guards V2-V4 (via r.bootCheck)", () => {
270
270
  expect(() => validateBoot([...minimalTenantLifecycleFeatures(), hooked])).not.toThrow();
271
271
  });
272
272
  });
273
+
274
+ describe("nav-area boot check — bundled dormant screens survive a real app nav", () => {
275
+ test("baseFeatures() (privacy-center included) boots clean once the app registers its own nav area", () => {
276
+ const appShell = defineFeature("app-shell", (r) => {
277
+ r.screen({ id: "home", type: "custom", renderer: { react: "Home" } });
278
+ r.nav({ id: "home", label: "app-shell:nav.home", screen: "app-shell:screen:home" });
279
+ r.translations({
280
+ keys: {
281
+ "app-shell:nav.home": { de: "Start", en: "Home" },
282
+ "screen:home.title": { de: "Start", en: "Home" },
283
+ },
284
+ });
285
+ });
286
+ expect(() => validateBoot([...baseFeatures(), appShell])).not.toThrow();
287
+ });
288
+ });
@@ -2,7 +2,7 @@
2
2
  //
3
3
  // Regression: the changeset describes these as "a platform-operator
4
4
  // inspector over the GDPR read-models" (i.e. cross-tenant), but the handlers
5
- // registered no crossTenant/systemScope override — a SystemAdmin acting in
5
+ // registered no escapeHatch/systemScope override — a SystemAdmin acting in
6
6
  // tenant A saw only tenant A's export jobs, never tenant B's.
7
7
 
8
8
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
@@ -325,11 +325,8 @@ export function createUserDataRightsFeature(opts: UserDataRightsOptions = {}): F
325
325
 
326
326
  // Self-service screen (Art. 15/17/18/20): export, restriction, deletion
327
327
  // in one projectionDetail screen bound to the user's own `me` row. No
328
- // r.nav — the app places it in its logged-in area (createKumikoApp's
329
- // missing-client-plugin boot diagnostic (#2025/#2034) only scans
330
- // type: "custom" screens, so a non-navved projectionDetail never
331
- // triggers it — no `dormant` flag needed, that field doesn't exist on
332
- // this screen type). access is openToAll because no app role name is
328
+ // r.nav — the app places it in its logged-in area; `dormant: true` opts
329
+ // it out of the nav-area boot check. access is openToAll because no app role name is
333
330
  // portable; the per-user handlers enforce auth server-side.
334
331
  //
335
332
  // Restriction/Deletion are declarative fields + actions; Export stays a
@@ -352,6 +349,7 @@ export function createUserDataRightsFeature(opts: UserDataRightsOptions = {}): F
352
349
  "operate on the caller's own userId, and no app role name is portable enough to gate this",
353
350
  },
354
351
  },
352
+ dormant: true,
355
353
  description:
356
354
  "Logged-in GDPR self-service page where a user requests and downloads a data export (Art. 20), restricts processing of their account (Art. 18) and requests its deletion (Art. 17).",
357
355
  fieldLabels: {
@@ -9,7 +9,10 @@ export const downloadAttemptListQuery = defineEntityListHandler(
9
9
  downloadAttemptEntity,
10
10
  {
11
11
  access: { roles: access.systemAdmin },
12
- crossTenant: true,
12
+ escapeHatch: {
13
+ reason:
14
+ "SystemAdmin DPO triage of invalid export-download attempts needs every tenant's rows to spot brute-force patterns across tenants",
15
+ },
13
16
  description:
14
17
  "Lists invalid export-download attempts across every tenant for the system-admin inspector screen; list-download-attempts is the filterable view scoped to a single tenant.",
15
18
  },