@cosmicdrift/kumiko-bundled-features 0.269.2 → 0.273.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 (116) hide show
  1. package/package.json +9 -9
  2. package/src/__tests__/account-security-composition.boot.test.ts +25 -7
  3. package/src/__tests__/account-security-composition.integration.test.ts +23 -1
  4. package/src/__tests__/account-security-fixture.ts +3 -0
  5. package/src/agent-tools/__tests__/agent-doc-lint.test.ts +3 -1
  6. package/src/agent-tools/__tests__/feature.test.ts +10 -4
  7. package/src/audit/__tests__/escape-hatch-audit.integration.test.ts +96 -0
  8. package/src/audit/changes.json +12 -0
  9. package/src/audit/escape-hatch-audit-sink.ts +52 -0
  10. package/src/audit/feature.ts +5 -1
  11. package/src/audit/index.ts +1 -0
  12. package/src/auth-email-password/__tests__/query-as-member.integration.test.ts +303 -0
  13. package/src/auth-email-password/handlers/invite-accept.write.ts +8 -1
  14. package/src/auth-mfa/handlers/disable.write.ts +7 -1
  15. package/src/auth-mfa/handlers/enable-confirm.write.ts +7 -1
  16. package/src/auth-mfa/handlers/enable-start.write.ts +7 -1
  17. package/src/auth-mfa/handlers/regenerate-recovery.write.ts +7 -1
  18. package/src/auth-mfa/handlers/status.query.ts +7 -1
  19. package/src/auth-mfa/screens.ts +26 -3
  20. package/src/cap-counter/feature.ts +5 -3
  21. package/src/cap-overview/screens.ts +6 -0
  22. package/src/channel-in-app/handlers/inbox.query.ts +7 -1
  23. package/src/channel-in-app/handlers/mark-all-read.write.ts +7 -1
  24. package/src/channel-in-app/handlers/mark-read.write.ts +7 -1
  25. package/src/channel-in-app/handlers/unread-count.query.ts +7 -1
  26. package/src/compliance-profiles/handlers/for-tenant.query.ts +7 -1
  27. package/src/compliance-profiles/handlers/list-profiles.query.ts +7 -1
  28. package/src/config/__tests__/config.integration.test.ts +1 -1
  29. package/src/config/__tests__/reencrypt-job-tenant-checks.integration.test.ts +2 -1
  30. package/src/config/__tests__/tz-resolution.integration.test.ts +1 -1
  31. package/src/config/changes.json +6 -0
  32. package/src/config/db/queries/__tests__/resolver.test.ts +43 -38
  33. package/src/config/db/queries/resolver.ts +22 -22
  34. package/src/config/handlers/cascade.query.ts +13 -1
  35. package/src/config/handlers/readiness.query.ts +13 -1
  36. package/src/config/handlers/reencrypt.job.ts +21 -2
  37. package/src/config/handlers/reset.write.ts +7 -1
  38. package/src/config/handlers/schema.query.ts +13 -1
  39. package/src/config/handlers/set.write.ts +7 -1
  40. package/src/config/handlers/values.query.ts +13 -1
  41. package/src/data-retention/handlers/policy-for.query.ts +8 -1
  42. package/src/delivery/__tests__/delivery-pii-kms.integration.test.ts +2 -2
  43. package/src/delivery/__tests__/delivery-screens.boot.test.ts +2 -4
  44. package/src/delivery/__tests__/delivery.integration.test.ts +39 -5
  45. package/src/delivery/changes.json +6 -0
  46. package/src/delivery/delivery-service.ts +25 -3
  47. package/src/delivery/handlers/preferences.query.ts +13 -1
  48. package/src/delivery/handlers/set-preference.write.ts +7 -1
  49. package/src/delivery/testing.ts +8 -1
  50. package/src/derivatives-sharp/__tests__/derivatives-sharp.integration.test.ts +1 -1
  51. package/src/folders/__tests__/feature.test.ts +14 -5
  52. package/src/folders/__tests__/folders-parent-visibility.integration.test.ts +6 -1
  53. package/src/folders/__tests__/folders-read-gate.integration.test.ts +6 -1
  54. package/src/folders/__tests__/folders.integration.test.ts +6 -1
  55. package/src/folders/feature.ts +2 -2
  56. package/src/form-draft/constants.ts +7 -1
  57. package/src/jobs/handlers/list.query.ts +3 -1
  58. package/src/ledger/__tests__/ledger.integration.test.ts +57 -0
  59. package/src/managed-pages/screens/branding-screen.ts +3 -0
  60. package/src/managed-pages/screens/page-screens.ts +3 -0
  61. package/src/notes-history/__tests__/notes-read-gate.integration.test.ts +10 -2
  62. package/src/notes-history/__tests__/notes-section.test.tsx +23 -0
  63. package/src/notes-history/feature.ts +2 -2
  64. package/src/notes-history/web/i18n.ts +3 -0
  65. package/src/notes-history/web/notes-section.tsx +61 -51
  66. package/src/personal-access-tokens/handlers/available-scopes.query.ts +7 -1
  67. package/src/personal-access-tokens/handlers/create.write.ts +7 -1
  68. package/src/personal-access-tokens/handlers/list.query.ts +7 -1
  69. package/src/personal-access-tokens/handlers/revoke.write.ts +7 -1
  70. package/src/personal-access-tokens/screens.ts +15 -2
  71. package/src/secrets/__tests__/access-options-precedence.test.ts +11 -4
  72. package/src/secrets/__tests__/access-options.integration.test.ts +5 -1
  73. package/src/secrets/feature.ts +2 -2
  74. package/src/sessions/handlers/mine.query.ts +7 -1
  75. package/src/sessions/handlers/revoke-all-others.write.ts +7 -1
  76. package/src/sessions/handlers/revoke.write.ts +7 -1
  77. package/src/sessions/screens.ts +9 -1
  78. package/src/tags/__tests__/feature.test.ts +25 -8
  79. package/src/tags/__tests__/tags-parent-visibility.integration.test.ts +6 -1
  80. package/src/tags/__tests__/tags-read-gate.integration.test.ts +7 -2
  81. package/src/tags/__tests__/tags.integration.test.ts +6 -1
  82. package/src/tags/constants.ts +1 -1
  83. package/src/tags/feature.ts +2 -2
  84. package/src/tags/screens.ts +3 -0
  85. package/src/tenant/__tests__/members-facet-i18n.test.ts +5 -5
  86. package/src/tenant/__tests__/tenant-timezone-boot.integration.test.ts +2 -2
  87. package/src/tenant/__tests__/tenant.integration.test.ts +5 -1
  88. package/src/tenant/changes.json +6 -0
  89. package/src/tenant/handlers/me.query.ts +13 -1
  90. package/src/tenant/handlers/update-member-roles.write.ts +10 -1
  91. package/src/tenant/screens.ts +3 -0
  92. package/src/tenant/seeding.ts +25 -19
  93. package/src/tier-engine/feature.ts +3 -0
  94. package/src/user/changes.json +6 -0
  95. package/src/user/handlers/me.query.ts +13 -1
  96. package/src/user/handlers/update-roles.ts +8 -5
  97. package/src/user/screens.ts +3 -0
  98. package/src/user-data-rights/__tests__/boot-checks.test.ts +16 -0
  99. package/src/user-data-rights/__tests__/export-job-list-cross-tenant.integration.test.ts +1 -1
  100. package/src/user-data-rights/__tests__/inspector-screens.boot.test.ts +6 -1
  101. package/src/user-data-rights/feature.ts +11 -6
  102. package/src/user-data-rights/handlers/cancel-deletion.write.ts +7 -1
  103. package/src/user-data-rights/handlers/download-attempt-list.query.ts +4 -1
  104. package/src/user-data-rights/handlers/download-by-job.query.ts +7 -1
  105. package/src/user-data-rights/handlers/export-job-detail.query.ts +4 -1
  106. package/src/user-data-rights/handlers/export-job-list.query.ts +4 -1
  107. package/src/user-data-rights/handlers/export-status.query.ts +7 -1
  108. package/src/user-data-rights/handlers/my-audit-log.query.ts +8 -1
  109. package/src/user-data-rights/handlers/request-deletion.write.ts +7 -1
  110. package/src/user-data-rights/handlers/request-export.write.ts +7 -1
  111. package/src/user-data-rights/handlers/restrict-account.write.ts +8 -1
  112. package/src/user-data-rights/lib/is-admin-actor.ts +1 -1
  113. package/src/user-data-rights/screens.ts +3 -0
  114. package/src/user-profile/__tests__/profile-screen.boot.test.ts +6 -1
  115. package/src/user-profile/feature.ts +11 -1
  116. package/src/workflow-runner/__tests__/pending-projection.integration.test.ts +1 -1
@@ -12,7 +12,13 @@ import { userSessionTable } from "../schema/user-session";
12
12
  export const mineQuery = definePagedQueryHandler({
13
13
  name: "user-session:mine",
14
14
  schema: z.object({}),
15
- access: { openToAll: true },
15
+ access: {
16
+ openToAll: {
17
+ reason:
18
+ "each signed-in user lists only their own live sessions; the query filters " +
19
+ "userSessionTable by the caller's own userId",
20
+ },
21
+ },
16
22
  description:
17
23
  "Lists the calling user's own still-live sessions, newest first, each flagged whether it is the one making the request; use it to show a user their signed-in devices.",
18
24
  outputSchema: z.object({
@@ -15,7 +15,13 @@ import { SESSION_REVOKED_AGGREGATE_TYPE, SESSION_REVOKED_EVENT_QN } from "../ses
15
15
  export const revokeAllOthersWrite = defineWriteHandler({
16
16
  name: "user-session:revoke-all-others",
17
17
  schema: z.object({}),
18
- access: { openToAll: true },
18
+ access: {
19
+ openToAll: {
20
+ reason:
21
+ "each signed-in user revokes only their own other sessions; the update is " +
22
+ "scoped to the caller's own userId, keeping only their current session (sid)",
23
+ },
24
+ },
19
25
  description:
20
26
  'Irreversibly signs the calling user out of every session except the one making the request and reports how many were dropped; use it for a "sign out everywhere else" action after a suspected compromise.',
21
27
  agent: { risk: "high" },
@@ -28,7 +28,13 @@ export const revokeWrite = defineWriteHandler({
28
28
  schema: z.object({
29
29
  id: z.uuid(),
30
30
  }),
31
- access: { openToAll: true },
31
+ access: {
32
+ openToAll: {
33
+ reason:
34
+ "each signed-in user revokes only their own session by id; the update is " +
35
+ "scoped to the caller's own userId",
36
+ },
37
+ },
32
38
  description:
33
39
  "Irreversibly signs one of the caller's own sessions out by id; use it when a user wants to drop a single device, and note that revoking their current session logs them out.",
34
40
  agent: { risk: "high" },
@@ -140,5 +140,13 @@ export const sessionMineScreen: ProjectionListScreenDefinition = {
140
140
  confirm: i18nKey("sessions.mine.revokeAllOthers.confirm"),
141
141
  },
142
142
  ],
143
- access: { openToAll: true },
143
+ access: {
144
+ openToAll: {
145
+ reason:
146
+ "each signed-in user views and revokes only their own sessions, mirroring the mine/revoke handlers",
147
+ },
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,
144
152
  };
@@ -105,7 +105,9 @@ describe("createTagsFeature access-options", () => {
105
105
  });
106
106
 
107
107
  test("access:{openToAll} applies to every write- and query-path", () => {
108
- const feature = createTagsFeature({ access: { openToAll: true } });
108
+ const feature = createTagsFeature({
109
+ access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
110
+ });
109
111
  for (const path of [
110
112
  "create-tag",
111
113
  "update-tag",
@@ -116,17 +118,28 @@ describe("createTagsFeature access-options", () => {
116
118
  "assign-tag",
117
119
  "remove-tag",
118
120
  ]) {
119
- expect(rawWriteAccess(feature, path)).toEqual({ openToAll: true });
121
+ expect(rawWriteAccess(feature, path)).toEqual({
122
+ openToAll: { reason: "test handler callable by any signed-in test user" },
123
+ });
120
124
  }
121
125
  for (const query of ["tag:list", "tag:detail", "tag-assignment:list"]) {
122
- expect(rawQueryAccess(feature, query)).toEqual({ openToAll: true });
126
+ expect(rawQueryAccess(feature, query)).toEqual({
127
+ openToAll: { reason: "test handler callable by any signed-in test user" },
128
+ });
123
129
  }
124
130
  });
125
131
 
126
132
  test("access takes precedence over the roles shorthand", () => {
127
- const feature = createTagsFeature({ access: { openToAll: true }, roles: ["Admin"] });
128
- expect(rawWriteAccess(feature, "create-tag")).toEqual({ openToAll: true });
129
- expect(rawQueryAccess(feature, "tag:list")).toEqual({ openToAll: true });
133
+ const feature = createTagsFeature({
134
+ access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
135
+ roles: ["Admin"],
136
+ });
137
+ expect(rawWriteAccess(feature, "create-tag")).toEqual({
138
+ openToAll: { reason: "test handler callable by any signed-in test user" },
139
+ });
140
+ expect(rawQueryAccess(feature, "tag:list")).toEqual({
141
+ openToAll: { reason: "test handler callable by any signed-in test user" },
142
+ });
130
143
  });
131
144
 
132
145
  test("access:{roles} threads through like the roles shorthand", () => {
@@ -139,12 +152,16 @@ describe("createTagsFeature access-options", () => {
139
152
  describe("createTagsFeature toggleable-option (tier-gating)", () => {
140
153
  test("without toggleable: feature is always-on (toggleableDefault undefined)", () => {
141
154
  expect(createTagsFeature().toggleableDefault).toBeUndefined();
142
- expect(createTagsFeature({ access: { openToAll: true } }).toggleableDefault).toBeUndefined();
155
+ expect(
156
+ createTagsFeature({
157
+ access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
158
+ }).toggleableDefault,
159
+ ).toBeUndefined();
143
160
  });
144
161
 
145
162
  test("toggleable:{default:false} makes the feature tier-gatable, fail-closed", () => {
146
163
  const feature = createTagsFeature({
147
- access: { openToAll: true },
164
+ access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
148
165
  toggleable: { default: false },
149
166
  });
150
167
  expect(feature.toggleableDefault).toBe(false);
@@ -93,7 +93,12 @@ let tagId: string;
93
93
 
94
94
  beforeAll(async () => {
95
95
  stack = await setupTestStack({
96
- features: [createTagsFeature({ access: { openToAll: true } }), hostFixturesFeature],
96
+ features: [
97
+ createTagsFeature({
98
+ access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
99
+ }),
100
+ hostFixturesFeature,
101
+ ],
97
102
  });
98
103
  await unsafeCreateEntityTable(stack.db, tagEntity);
99
104
  await unsafeCreateEntityTable(stack.db, tagAssignmentEntity);
@@ -99,7 +99,12 @@ let tagId: string;
99
99
  beforeAll(async () => {
100
100
  stack = await setupTestStack({
101
101
  // Deliberately no `ownership` — the gate must hold on a bare mount.
102
- features: [createTagsFeature({ access: { openToAll: true } }), hostFixturesFeature],
102
+ features: [
103
+ createTagsFeature({
104
+ access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
105
+ }),
106
+ hostFixturesFeature,
107
+ ],
103
108
  });
104
109
  await unsafeCreateEntityTable(stack.db, tagEntity);
105
110
  await unsafeCreateEntityTable(stack.db, tagAssignmentEntity);
@@ -303,7 +308,7 @@ describe("tags read-gate — ownership narrows further, it does not replace the
303
308
  ownedStack = await setupTestStack({
304
309
  features: [
305
310
  createTagsFeature({
306
- access: { openToAll: true },
311
+ access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
307
312
  // An extra row rule on the assignment row itself, unrelated to who
308
313
  // may see the host: the caller's claim must name the row's host type.
309
314
  ownership: {
@@ -457,7 +457,12 @@ describe("tags integration — openToAll access model", () => {
457
457
 
458
458
  beforeAll(async () => {
459
459
  openStack = await setupTestStack({
460
- features: [createTagsFeature({ access: { openToAll: true } }), hostFixturesFeature],
460
+ features: [
461
+ createTagsFeature({
462
+ access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
463
+ }),
464
+ hostFixturesFeature,
465
+ ],
461
466
  });
462
467
  await unsafeCreateEntityTable(openStack.db, tagEntity);
463
468
  await unsafeCreateEntityTable(openStack.db, tagAssignmentEntity);
@@ -55,7 +55,7 @@ export const TagsQueries = {
55
55
  export const DEFAULT_TAG_ROLES = ["TenantAdmin", "TenantMember"] as const;
56
56
 
57
57
  // The default access rule applied to every tag handler when the app passes
58
- // neither `access` nor `roles`. createTagsFeature({ access: { openToAll: true } })
58
+ // neither `access` nor `roles`. createTagsFeature({ access: { openToAll: { reason } } })
59
59
  // makes tagging reachable for any authenticated tenant user — matching apps
60
60
  // whose other handlers are openToAll rather than role-gated.
61
61
  export const DEFAULT_TAG_ACCESS: AccessRule = { roles: DEFAULT_TAG_ROLES };
@@ -54,7 +54,7 @@ function registerTags(
54
54
  parents: readonly string[] | undefined,
55
55
  ): void {
56
56
  r.describe(
57
- "Generic, host-agnostic tagging for any entity. Owns two event-sourced entities — the per-tenant `tag` catalog (`read_tags`, with optional `color` and `scope`) and `tag-assignment` join rows keyed by (entityType, entityId) (`read_tag_assignments`) — so tagging adds NO column to the host entity and needs no relational pivot or JOIN. Catalog screens are declarative (`entityList` + `entityEdit`) and use convention QNs `tag:{create,update,delete}`; TagManager/TagPicker keep `create-tag`/`update-tag`/`delete-tag`. Also: `assign-tag` (idempotent), `remove-tag` (idempotent) and list queries for the catalog and the assignments. Read which tags an entity has, or which entities carry a tag, by listing `tag-assignment` filtered on `entityId` or `tagId` and composing in the read-layer. A tag with empty `scope` is global; a `scope` of an entityType restricts it to that type in the picker. Every path uses one access rule — adopt the host's model with createTagsFeature({ access: { openToAll: true } }) or pin roles with createTagsFeature({ roles }). Pass { toggleable: { default: false } } to make the whole feature tier-gatable via the tier-engine (no host hook).",
57
+ "Generic, host-agnostic tagging for any entity. Owns two event-sourced entities — the per-tenant `tag` catalog (`read_tags`, with optional `color` and `scope`) and `tag-assignment` join rows keyed by (entityType, entityId) (`read_tag_assignments`) — so tagging adds NO column to the host entity and needs no relational pivot or JOIN. Catalog screens are declarative (`entityList` + `entityEdit`) and use convention QNs `tag:{create,update,delete}`; TagManager/TagPicker keep `create-tag`/`update-tag`/`delete-tag`. Also: `assign-tag` (idempotent), `remove-tag` (idempotent) and list queries for the catalog and the assignments. Read which tags an entity has, or which entities carry a tag, by listing `tag-assignment` filtered on `entityId` or `tagId` and composing in the read-layer. A tag with empty `scope` is global; a `scope` of an entityType restricts it to that type in the picker. Every path uses one access rule — adopt the host's model with createTagsFeature({ access: { openToAll: { reason } } }) or pin roles with createTagsFeature({ roles }). Pass { toggleable: { default: false } } to make the whole feature tier-gatable via the tier-engine (no host hook).",
58
58
  );
59
59
  r.uiHints({
60
60
  displayLabel: "Tags",
@@ -134,7 +134,7 @@ export const tagsFeature = defineFeature(TAGS_FEATURE_NAME, (r) =>
134
134
 
135
135
  export type TagsFeatureOptions = {
136
136
  /** Access rule for all tag write/read paths. Default { roles: ["TenantAdmin","TenantMember"] }.
137
- * Adopt the host's model — e.g. { openToAll: true } when the host lets any
137
+ * Adopt the host's model — e.g. { openToAll: { reason: "..." } } when the host lets any
138
138
  * authenticated tenant user tag (like the rest of its handlers), or
139
139
  * { roles: ["Admin"] } for a custom role vocabulary. Takes precedence over `roles`. */
140
140
  readonly access?: AccessRule;
@@ -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
@@ -25,7 +25,7 @@ const probeFeature = defineFeature("tz-probe", (r) => {
25
25
  "read-tz",
26
26
  z.object({}),
27
27
  async (_event, ctx) => ({ isSuccess: true, data: { tenant: ctx.tz.tenant } }),
28
- { access: { openToAll: true } },
28
+ { access: { openToAll: { reason: "test handler callable by any signed-in test user" } } },
29
29
  );
30
30
  });
31
31
 
@@ -35,7 +35,7 @@ const probeFeature = defineFeature("tz-probe", (r) => {
35
35
  const queryProbeFeature = defineFeature("tz-cache-probe", (r) => {
36
36
  r.requires("tenant");
37
37
  r.queryHandler("read-tz", z.object({}), async (_query, ctx) => ({ tenant: ctx.tz.tenant }), {
38
- access: { openToAll: true },
38
+ access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
39
39
  });
40
40
  });
41
41
 
@@ -401,7 +401,11 @@ describe("scenario 7: access rules on handlers", () => {
401
401
  "SystemAdmin",
402
402
  ]);
403
403
  expect(stack.registry.getQueryHandler(TenantQueries.me)?.access).toEqual({
404
- openToAll: true,
404
+ openToAll: {
405
+ reason:
406
+ "any signed-in user reads only their own active tenant; the query is scoped " +
407
+ "to the caller's own tenantId",
408
+ },
405
409
  });
406
410
  });
407
411
  });
@@ -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",
@@ -9,7 +9,19 @@ import { tenantTable } from "../schema/tenant";
9
9
  export const meQuery = defineQueryHandler({
10
10
  name: "me",
11
11
  schema: z.object({}),
12
- access: { openToAll: true },
12
+ access: {
13
+ openToAll: {
14
+ reason:
15
+ "any signed-in user reads only their own active tenant; the query is scoped " +
16
+ "to the caller's own tenantId",
17
+ },
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
+ },
13
25
  description:
14
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.",
15
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",
@@ -11,7 +11,19 @@ const crud = createEventStoreExecutor(userTable, userEntity, { entityName: "user
11
11
  export const meQuery = defineQueryHandler({
12
12
  name: "user:me",
13
13
  schema: z.object({}),
14
- access: { openToAll: true },
14
+ access: {
15
+ openToAll: {
16
+ reason:
17
+ "each signed-in user reads only their own identity record; the detail lookup " +
18
+ "is by the caller's own id",
19
+ },
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
+ },
15
27
  description:
16
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.",
17
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";
@@ -94,7 +94,12 @@ describe("privacy-center screen (fw#2312 projectionDetail conversion)", () => {
94
94
  expect(screen?.type).toBe("projectionDetail");
95
95
  if (screen?.type === "projectionDetail") {
96
96
  expect(screen.query).toBe("user:query:user:me");
97
- expect(screen.access).toEqual({ openToAll: true });
97
+ expect(screen.access).toEqual({
98
+ openToAll: {
99
+ reason:
100
+ "each signed-in user manages GDPR self-service actions (export, restriction, deletion) for only their own account; the bound query and every action handler operate on the caller's own userId, and no app role name is portable enough to gate this",
101
+ },
102
+ });
98
103
  }
99
104
  });
100
105
 
@@ -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
@@ -344,7 +341,15 @@ export function createUserDataRightsFeature(opts: UserDataRightsOptions = {}): F
344
341
  type: "projectionDetail",
345
342
  query: USER_ME_QUERY,
346
343
  singleton: true,
347
- access: { openToAll: true },
344
+ access: {
345
+ openToAll: {
346
+ reason:
347
+ "each signed-in user manages GDPR self-service actions (export, restriction, " +
348
+ "deletion) for only their own account; the bound query and every action handler " +
349
+ "operate on the caller's own userId, and no app role name is portable enough to gate this",
350
+ },
351
+ },
352
+ dormant: true,
348
353
  description:
349
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).",
350
355
  fieldLabels: {
@@ -22,7 +22,13 @@ const APPEND_LIFECYCLE_EVENT_REASON =
22
22
  export const cancelDeletionWrite = defineWriteHandler({
23
23
  name: "cancel-deletion",
24
24
  schema: z.object({}),
25
- access: { openToAll: true },
25
+ access: {
26
+ openToAll: {
27
+ reason:
28
+ "each signed-in user cancels only their own pending deletion request; the " +
29
+ "lookup and update are scoped to the caller's own id",
30
+ },
31
+ },
26
32
  description:
27
33
  "Withdraws the calling user's own pending account-deletion request and puts the account back to active, accepted only while the grace period is still running.",
28
34
  escapeHatch: {
@@ -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
  },
@@ -63,7 +63,13 @@ export const downloadByJobQuery = defineQueryHandler({
63
63
  schema: z.object({
64
64
  jobId: z.string().min(1, "jobId required"),
65
65
  }),
66
- access: { openToAll: true }, // openToAll = auth-required, kein anonymous
66
+ access: {
67
+ openToAll: {
68
+ reason:
69
+ "each signed-in user downloads only their own finished export job; the job " +
70
+ "lookup checks jobRow.userId against the caller's own id and 404s otherwise",
71
+ },
72
+ }, // openToAll = auth-required, kein anonymous
67
73
  description:
68
74
  "Returns a short-lived signed download URL for the calling user's own finished data-export job named by job id, backing the download button in the privacy center once export-status reports the job done.",
69
75
  escapeHatch: {