@cosmicdrift/kumiko-bundled-features 0.334.0 → 0.335.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 (242) hide show
  1. package/dist/admin-shell/overview-panels.js +2 -2
  2. package/dist/agent-tools/agent-doc-lint.d.ts +1 -0
  3. package/dist/agent-tools/agent-doc-lint.js +33 -3
  4. package/dist/agent-tools/agent-manifest.js +47 -6
  5. package/dist/agent-tools/filter-ops.d.ts +2 -0
  6. package/dist/agent-tools/filter-ops.js +4 -0
  7. package/dist/agent-tools/tool-catalog.js +1 -4
  8. package/dist/agent-tools/tool-dispatch.js +1 -4
  9. package/dist/audit/constants.d.ts +1 -1
  10. package/dist/audit/constants.js +1 -1
  11. package/dist/auth-email-password/handlers/change-password.write.js +2 -0
  12. package/dist/auth-email-password/handlers/invite-create.write.js +12 -2
  13. package/dist/auth-email-password/handlers/login.write.js +2 -0
  14. package/dist/auth-email-password/handlers/self-registration-status.query.js +1 -1
  15. package/dist/auth-email-password/i18n.js +2 -0
  16. package/dist/auth-email-password/web/auth-client.d.ts +1 -0
  17. package/dist/auth-email-password/web/auth-client.js +1 -1
  18. package/dist/auth-email-password/web/auth-gate.js +6 -8
  19. package/dist/auth-email-password/web/index.d.ts +1 -1
  20. package/dist/auth-email-password/web/index.js +1 -1
  21. package/dist/auth-email-password/web/invite-accept-screen.js +16 -12
  22. package/dist/auth-email-password/web/login-screen.js +1 -1
  23. package/dist/auth-email-password/web/session-bootstrap-error.d.ts +5 -1
  24. package/dist/auth-email-password/web/session-bootstrap-error.js +2 -2
  25. package/dist/auth-email-password/web/session.d.ts +4 -0
  26. package/dist/auth-email-password/web/session.js +15 -0
  27. package/dist/auth-mfa/feature.js +3 -2
  28. package/dist/auth-mfa/handlers/enable-confirm-preauth.write.js +2 -2
  29. package/dist/auth-mfa/handlers/verify.write.js +2 -0
  30. package/dist/auth-mfa/hmac-token-codec.js +3 -0
  31. package/dist/auth-mfa/mfa-challenge-token.js +4 -0
  32. package/dist/auth-mfa/mfa-preauth-setup-token.js +3 -0
  33. package/dist/auth-mfa/web/mfa-client.d.ts +1 -1
  34. package/dist/auth-mfa/web/mfa-client.js +4 -10
  35. package/dist/billing-foundation/billing-info-query.js +1 -1
  36. package/dist/billing-foundation/checkout-core.d.ts +5 -5
  37. package/dist/billing-foundation/checkout-core.js +10 -6
  38. package/dist/billing-foundation/handlers/switch-plan.write.js +5 -8
  39. package/dist/cap-counter/book-cap-usage.js +15 -5
  40. package/dist/cap-counter/enforce-cap.js +9 -3
  41. package/dist/cap-counter/feature.js +3 -2
  42. package/dist/cap-counter/i18n.d.ts +1 -0
  43. package/dist/cap-counter/i18n.js +6 -5
  44. package/dist/cap-overview/handlers/caps-usage.query.js +9 -16
  45. package/dist/cap-overview/handlers/tenant-caps-list.query.js +41 -15
  46. package/dist/cap-overview/i18n.js +5 -0
  47. package/dist/cap-overview/web/cap-cards-panel.js +4 -1
  48. package/dist/cap-overview/web/cap-usage-bar.js +1 -1
  49. package/dist/channel-email/email-channel.js +12 -1
  50. package/dist/channel-in-app/tables.js +2 -6
  51. package/dist/compliance-profiles/handlers/sub-processors.query.js +1 -1
  52. package/dist/config/db/queries/resolver.js +3 -1
  53. package/dist/config/handlers/set.write.js +8 -4
  54. package/dist/config/web/i18n.js +1 -0
  55. package/dist/config/write-helpers.d.ts +1 -1
  56. package/dist/config/write-helpers.js +11 -5
  57. package/dist/crypto-shredding/handlers/forget-subject.write.js +15 -8
  58. package/dist/custom-fields/constants.js +1 -4
  59. package/dist/data-retention/resolve-for-tenant.d.ts +2 -3
  60. package/dist/data-retention/run-retention-cleanup.js +100 -44
  61. package/dist/delivery/types.d.ts +2 -1
  62. package/dist/delivery/types.js +7 -0
  63. package/dist/delivery/unsubscribe.js +5 -11
  64. package/dist/derivatives-sharp/render.js +27 -9
  65. package/dist/document-ingest-foundation/feature.js +5 -5
  66. package/dist/document-ingest-foundation/providers.d.ts +1 -0
  67. package/dist/document-ingest-foundation/providers.js +6 -0
  68. package/dist/document-ingest-foundation/write-document-extract.d.ts +2 -2
  69. package/dist/document-ingest-foundation/write-document-extract.js +10 -0
  70. package/dist/feature-toggles/global-feature-state-table.js +2 -7
  71. package/dist/feature-toggles/toggle-sync-signal.d.ts +2 -1
  72. package/dist/feature-toggles/toggle-sync-signal.js +3 -2
  73. package/dist/files-tenant-data/index.js +3 -2
  74. package/dist/folders/handlers/clear-folder.write.js +4 -11
  75. package/dist/folders/handlers/set-folder.write.js +4 -10
  76. package/dist/form-draft/feature.js +3 -2
  77. package/dist/form-draft/handlers/discard.write.js +7 -8
  78. package/dist/form-draft/handlers/get.query.js +7 -1
  79. package/dist/foundation-shared/host-allowlist.d.ts +4 -0
  80. package/dist/foundation-shared/host-allowlist.js +14 -0
  81. package/dist/foundation-shared/index.d.ts +1 -0
  82. package/dist/foundation-shared/index.js +1 -0
  83. package/dist/foundation-shared/mail-host-policy.d.ts +0 -2
  84. package/dist/foundation-shared/mail-host-policy.js +3 -13
  85. package/dist/inbound-mail-foundation/connect-routes.js +7 -5
  86. package/dist/inbound-mail-foundation/watch-supervisor.js +47 -43
  87. package/dist/jobs/handlers/list.query.d.ts +7 -2
  88. package/dist/jobs/handlers/list.query.js +9 -2
  89. package/dist/jobs/handlers/retry.write.js +3 -2
  90. package/dist/jobs/handlers/tenant-failures.query.js +3 -9
  91. package/dist/jobs/job-run-logger.js +18 -2
  92. package/dist/jobs/tenant-job-failure-table.js +2 -6
  93. package/dist/ledger/handlers/confirm-schedule-period.write.js +2 -2
  94. package/dist/ledger/handlers/reverse-transaction.write.js +2 -2
  95. package/dist/ledger/subject-reference.d.ts +4 -0
  96. package/dist/ledger/subject-reference.js +7 -0
  97. package/dist/legal-pages/feature.js +8 -1
  98. package/dist/managed-pages/handlers/branding.query.js +1 -1
  99. package/dist/managed-pages/handlers/by-slug.query.js +1 -1
  100. package/dist/managed-pages/handlers/by-tenant-published.query.js +1 -1
  101. package/dist/metrics/compute.js +7 -1
  102. package/dist/metrics/window.js +5 -0
  103. package/dist/notes-history/handlers/add-note.write.js +4 -9
  104. package/dist/notes-history-user-data/hooks.js +8 -1
  105. package/dist/personal-access-tokens/constants.d.ts +1 -0
  106. package/dist/personal-access-tokens/constants.js +1 -0
  107. package/dist/personal-access-tokens/feature.js +2 -2
  108. package/dist/personal-access-tokens/handlers/create.write.js +2 -2
  109. package/dist/personal-access-tokens/handlers/revoke.write.js +2 -2
  110. package/dist/personal-access-tokens/i18n.js +12 -13
  111. package/dist/personal-access-tokens/schema/api-token.js +2 -1
  112. package/dist/personal-access-tokens/screens.js +5 -1
  113. package/dist/rate-limiting/feature.js +2 -0
  114. package/dist/rate-limiting/handlers/bucket-access.js +15 -7
  115. package/dist/rate-limiting/i18n.d.ts +10 -0
  116. package/dist/rate-limiting/i18n.js +10 -0
  117. package/dist/secrets/feature.js +3 -2
  118. package/dist/seo/handlers/seo-config.query.js +1 -1
  119. package/dist/sessions/constants.d.ts +1 -0
  120. package/dist/sessions/constants.js +1 -0
  121. package/dist/sessions/feature.js +5 -3
  122. package/dist/sessions/handlers/revoke-all-for-user.write.d.ts +1 -0
  123. package/dist/sessions/handlers/revoke-all-for-user.write.js +12 -2
  124. package/dist/sessions/handlers/revoke.write.js +2 -2
  125. package/dist/sessions/schema/user-session.js +2 -1
  126. package/dist/sessions/screens.js +5 -1
  127. package/dist/sessions/session-callbacks.js +2 -2
  128. package/dist/shared/index.d.ts +1 -1
  129. package/dist/shared/index.js +1 -1
  130. package/dist/shared/lockout-counter.js +1 -1
  131. package/dist/shared/parent-visibility.d.ts +5 -0
  132. package/dist/shared/parent-visibility.js +17 -1
  133. package/dist/shared/row-bound-grant.js +20 -9
  134. package/dist/shared/single-use-token-store.js +5 -13
  135. package/dist/step-dispatcher/webhook-runner.d.ts +0 -2
  136. package/dist/step-dispatcher/webhook-runner.js +18 -17
  137. package/dist/subscription-mollie/plugin-methods.d.ts +3 -2
  138. package/dist/subscription-mollie/plugin-methods.js +7 -1
  139. package/dist/subscription-mollie/verify-webhook.js +6 -0
  140. package/dist/subscription-stripe/plugin-methods.js +25 -6
  141. package/dist/subscription-stripe/verify-webhook.js +13 -6
  142. package/dist/tags/handlers/assign-tag.write.js +4 -10
  143. package/dist/tags/handlers/remove-tag.write.js +4 -11
  144. package/dist/template-resolver/api.d.ts +2 -1
  145. package/dist/template-resolver/api.js +1 -1
  146. package/dist/template-resolver/handlers/by-slug.query.d.ts +1 -1
  147. package/dist/template-resolver/handlers/by-slug.query.js +2 -2
  148. package/dist/template-resolver/handlers/by-tenant.query.d.ts +3 -2
  149. package/dist/template-resolver/handlers/by-tenant.query.js +2 -2
  150. package/dist/template-resolver/handlers/collection-item.query.d.ts +1 -1
  151. package/dist/template-resolver/handlers/collection-list.query.d.ts +1 -1
  152. package/dist/template-resolver/handlers/collection-shared.d.ts +3 -2
  153. package/dist/template-resolver/handlers/collection-shared.js +1 -1
  154. package/dist/template-resolver/handlers/find-by-id.query.d.ts +1 -1
  155. package/dist/template-resolver/handlers/find-by-id.query.js +1 -1
  156. package/dist/template-resolver/handlers/list.query.d.ts +1 -1
  157. package/dist/template-resolver/handlers/list.query.js +1 -1
  158. package/dist/template-resolver/seeding.js +41 -39
  159. package/dist/template-resolver/table.d.ts +8 -5
  160. package/dist/template-resolver/testing.js +2 -2
  161. package/dist/template-resolver/user-content-table.d.ts +2 -5
  162. package/dist/template-resolver/web/client-plugin.d.ts +1 -1
  163. package/dist/template-resolver-user-data/hooks.js +1 -1
  164. package/dist/tenant/cancel-pending-invitation.d.ts +8 -0
  165. package/dist/tenant/cancel-pending-invitation.js +22 -0
  166. package/dist/tenant/feature.js +4 -2
  167. package/dist/tenant/handlers/cancel-invitation.write.js +6 -21
  168. package/dist/tenant/handlers/member-directory.query.d.ts +1 -0
  169. package/dist/tenant/handlers/member-directory.query.js +17 -4
  170. package/dist/tenant/handlers/remove-member.write.js +9 -14
  171. package/dist/tenant/handlers/team-list.query.js +2 -2
  172. package/dist/tenant/handlers/tenant-directory.query.d.ts +1 -0
  173. package/dist/tenant/handlers/tenant-directory.query.js +13 -3
  174. package/dist/tenant/handlers/update-member-roles.write.d.ts +1 -1
  175. package/dist/tenant/handlers/update-member-roles.write.js +28 -12
  176. package/dist/tenant/i18n.js +4 -0
  177. package/dist/tenant/index.d.ts +1 -1
  178. package/dist/tenant/index.js +1 -1
  179. package/dist/tenant/last-tenant-admin.d.ts +1 -1
  180. package/dist/tenant/last-tenant-admin.js +5 -3
  181. package/dist/tenant/seeding.d.ts +4 -0
  182. package/dist/tenant/seeding.js +9 -2
  183. package/dist/tenant-lifecycle/boot-checks.d.ts +1 -0
  184. package/dist/tenant-lifecycle/boot-checks.js +27 -1
  185. package/dist/tenant-lifecycle/feature.js +2 -1
  186. package/dist/tenant-lifecycle/stages.js +2 -2
  187. package/dist/tier-engine/feature.js +3 -2
  188. package/dist/user/command-schemas.d.ts +2 -2
  189. package/dist/user/handlers/create.write.d.ts +1 -1
  190. package/dist/user/handlers/create.write.js +8 -2
  191. package/dist/user/handlers/update.write.d.ts +1 -1
  192. package/dist/user/handlers/update.write.js +8 -2
  193. package/dist/user-data-rights/db/queries/export-jobs.d.ts +2 -0
  194. package/dist/user-data-rights/feature.js +22 -9
  195. package/dist/user-data-rights/handlers/confirm-deletion-by-token.write.js +4 -7
  196. package/dist/user-data-rights/handlers/deletion-grace-period.d.ts +3 -2
  197. package/dist/user-data-rights/handlers/deletion-grace-period.js +30 -8
  198. package/dist/user-data-rights/handlers/download-by-token.query.js +1 -1
  199. package/dist/user-data-rights/handlers/request-deletion-by-email.write.js +1 -1
  200. package/dist/user-data-rights/handlers/request-deletion.write.js +3 -6
  201. package/dist/user-data-rights/lib/update-user-lifecycle.js +7 -14
  202. package/dist/user-data-rights/run-export-jobs.d.ts +1 -0
  203. package/dist/user-data-rights/run-export-jobs.js +74 -41
  204. package/dist/user-data-rights/run-forget-cleanup.js +2 -2
  205. package/dist/user-data-rights/run-user-export.js +2 -2
  206. package/dist/user-data-rights-defaults/hooks/file-ref.userdata-hook.js +4 -1
  207. package/dist/user-profile/feature.js +6 -1
  208. package/dist/user-profile/handlers/change-email.write.js +2 -0
  209. package/dist/workflow-runner/db/queries/due-runs.js +6 -9
  210. package/dist/workflow-runner/event-subscriber.js +29 -9
  211. package/dist/workflow-runner/handlers/resume-run.write.js +8 -5
  212. package/dist/workflow-runner/pending-projection.js +8 -1
  213. package/package.json +11 -9
  214. package/src/agent-tools/changes.json +12 -0
  215. package/src/auth-email-password/changes.json +18 -0
  216. package/src/auth-mfa/changes.json +6 -0
  217. package/src/billing-foundation/changes.json +7 -0
  218. package/src/cap-counter/changes.json +7 -0
  219. package/src/cap-overview/changes.json +12 -0
  220. package/src/channel-email/changes.json +6 -0
  221. package/src/config/changes.json +6 -0
  222. package/src/crypto-shredding/changes.json +6 -0
  223. package/src/data-retention/changes.json +12 -0
  224. package/src/delivery/changes.json +7 -0
  225. package/src/derivatives-sharp/changes.json +11 -0
  226. package/src/document-ingest-foundation/changes.json +12 -0
  227. package/src/inbound-mail-foundation/changes.json +12 -0
  228. package/src/jobs/changes.json +13 -0
  229. package/src/legal-pages/changes.json +8 -1
  230. package/src/managed-pages/changes.json +5 -0
  231. package/src/metrics/changes.json +6 -0
  232. package/src/rate-limiting/changes.json +5 -0
  233. package/src/secrets/changes.json +7 -0
  234. package/src/sessions/changes.json +5 -0
  235. package/src/shared/changes.json +6 -0
  236. package/src/step-dispatcher/changes.json +5 -0
  237. package/src/subscription-mollie/changes.json +7 -0
  238. package/src/subscription-stripe/changes.json +11 -0
  239. package/src/template-resolver/changes.json +7 -0
  240. package/src/tenant/changes.json +29 -0
  241. package/src/user-data-rights/changes.json +22 -0
  242. package/src/workflow-runner/changes.json +6 -0
@@ -64,6 +64,13 @@ export async function seedSystemTemplate(db, opts) {
64
64
  parentTemplateId,
65
65
  status: "active",
66
66
  };
67
+ const applyUpdate = async (row) => {
68
+ const result = await executor.update({ id: row.id, version: row.version, changes: rowFields }, by, tdb);
69
+ if (!result.isSuccess) {
70
+ throw new Error(`seedSystemTemplate update failed: ${JSON.stringify(result)}`);
71
+ }
72
+ return { id: String(row.id) };
73
+ };
67
74
  return runEventStoreSeed({
68
75
  existing,
69
76
  ifExists: opts.ifExists,
@@ -83,11 +90,13 @@ export async function seedSystemTemplate(db, opts) {
83
90
  if (resolved == null) {
84
91
  throw new Error(`seedSystemTemplate create unique_violation but row missing`);
85
92
  }
86
- const upd = await executor.update({ id: resolved.id, version: resolved.version, changes: rowFields }, by, tdb);
87
- if (!upd.isSuccess) {
88
- throw new Error(`seedSystemTemplate race-update failed: ${JSON.stringify(upd)}`);
89
- }
90
- return { id: resolved.id };
93
+ // Race loser goes through the same skip/update contract as a normal re-seed.
94
+ return runEventStoreSeed({
95
+ existing: resolved,
96
+ ifExists: opts.ifExists,
97
+ create: () => Promise.reject(new Error("seedSystemTemplate: unreachable create")),
98
+ update: applyUpdate,
99
+ });
91
100
  }
92
101
  throw new Error(`seedSystemTemplate create failed: ${JSON.stringify(result)}`);
93
102
  }
@@ -97,13 +106,7 @@ export async function seedSystemTemplate(db, opts) {
97
106
  }
98
107
  return { id: String(data.id) };
99
108
  },
100
- update: async (row) => {
101
- const result = await executor.update({ id: row.id, version: row.version, changes: rowFields }, by, tdb);
102
- if (!result.isSuccess) {
103
- throw new Error(`seedSystemTemplate update failed: ${JSON.stringify(result)}`);
104
- }
105
- return { id: String(row.id) };
106
- },
109
+ update: applyUpdate,
107
110
  });
108
111
  }
109
112
  // Boot/test seed for kind=text-block. Same projection path as set.write,
@@ -132,6 +135,24 @@ export async function seedTextBlock(db, opts) {
132
135
  parentTemplateId: null,
133
136
  status: "active",
134
137
  };
138
+ const applyUpdate = async (row, current) => {
139
+ // Skip no-op updates so a legal re-seed on every pod boot does not bump
140
+ // version/modifiedAt (ETag, "last changed", event-store growth).
141
+ if (current?.title === fields.title &&
142
+ current?.content === fields.content &&
143
+ current?.folder === fields.folder) {
144
+ return { id: String(row.id) };
145
+ }
146
+ const result = await executor.update({
147
+ id: row.id,
148
+ version: row.version,
149
+ changes: { title: fields.title, content: fields.content, folder: fields.folder },
150
+ }, by, tdb);
151
+ if (!result.isSuccess) {
152
+ throw new Error(`seedTextBlock update failed: ${JSON.stringify(result)}`);
153
+ }
154
+ return { id: String(row.id) };
155
+ };
135
156
  return runEventStoreSeed({
136
157
  existing,
137
158
  ifExists: opts.ifExists,
@@ -149,15 +170,13 @@ export async function seedTextBlock(db, opts) {
149
170
  if (resolved == null) {
150
171
  throw new Error("seedTextBlock create unique_violation but row missing");
151
172
  }
152
- const upd = await executor.update({
153
- id: resolved.id,
154
- version: resolved.version,
155
- changes: { title: fields.title, content: fields.content, folder: fields.folder },
156
- }, by, tdb);
157
- if (!upd.isSuccess) {
158
- throw new Error(`seedTextBlock race-update failed: ${JSON.stringify(upd)}`);
159
- }
160
- return { id: resolved.id };
173
+ // Race loser goes through the same skip/no-op/update contract as a normal re-seed.
174
+ return runEventStoreSeed({
175
+ existing: resolved,
176
+ ifExists: opts.ifExists,
177
+ create: () => Promise.reject(new Error("seedTextBlock: unreachable create")),
178
+ update: (row) => applyUpdate(row, again),
179
+ });
161
180
  }
162
181
  throw new Error(`seedTextBlock create failed: ${JSON.stringify(result)}`);
163
182
  }
@@ -169,24 +188,7 @@ export async function seedTextBlock(db, opts) {
169
188
  }
170
189
  return { id: String(data.id) };
171
190
  },
172
- update: async (row) => {
173
- // Skip no-op updates so a legal re-seed on every pod boot does not bump
174
- // version/updatedAt (ETag, "last changed", event-store growth).
175
- if (existingRow?.title === fields.title &&
176
- existingRow?.content === fields.content &&
177
- existingRow?.folder === fields.folder) {
178
- return { id: String(row.id) };
179
- }
180
- const result = await executor.update({
181
- id: row.id,
182
- version: row.version,
183
- changes: { title: fields.title, content: fields.content, folder: fields.folder },
184
- }, by, tdb);
185
- if (!result.isSuccess) {
186
- throw new Error(`seedTextBlock update failed: ${JSON.stringify(result)}`);
187
- }
188
- return { id: String(row.id) };
189
- },
191
+ update: (row) => applyUpdate(row, existingRow),
190
192
  });
191
193
  }
192
194
  // Boot seed for legal/marketing copy from a JSON template. `ifExists: "update"`
@@ -1,3 +1,4 @@
1
+ import type { Temporal } from "temporal-polyfill";
1
2
  export declare const templateResourceEntity: import("@cosmicdrift/kumiko-types/fields").EntityDefinition<{
2
3
  slug: {
3
4
  readonly type: "text";
@@ -482,6 +483,12 @@ export declare const templateResourcesTable: import("@cosmicdrift/kumiko-framewo
482
483
  required: true;
483
484
  };
484
485
  }, "tenant">>;
486
+ export type EntityAuditColumns = {
487
+ readonly insertedAt: Temporal.Instant;
488
+ readonly modifiedAt: Temporal.Instant | null;
489
+ readonly insertedById: string | null;
490
+ readonly modifiedById: string | null;
491
+ };
485
492
  export type TemplateResourceRow = {
486
493
  readonly id: string | number;
487
494
  readonly version: number;
@@ -498,8 +505,4 @@ export type TemplateResourceRow = {
498
505
  readonly scope: string;
499
506
  readonly parentTemplateId: string | null;
500
507
  readonly status: string;
501
- readonly createdAt: Date;
502
- readonly updatedAt: Date;
503
- readonly createdBy: string;
504
- readonly updatedBy: string;
505
- };
508
+ } & EntityAuditColumns;
@@ -24,8 +24,8 @@ async function seedTemplate(db, args) {
24
24
  variableSchema: JSON.stringify(args.variableSchema ?? {}),
25
25
  linkedResources: JSON.stringify(args.linkedResources ?? {}),
26
26
  parentTemplateId: args.parentTemplateId ?? null,
27
- createdBy: "conformance",
28
- updatedBy: "conformance",
27
+ insertedById: "conformance",
28
+ modifiedById: "conformance",
29
29
  });
30
30
  }
31
31
  export async function assertConsumerHandlesNotFound(consumer, opts) {
@@ -1,3 +1,4 @@
1
+ import type { EntityAuditColumns } from "./table.js";
1
2
  export declare const userContentEntryEntity: import("@cosmicdrift/kumiko-types/fields").EntityDefinition<{
2
3
  ownerId: {
3
4
  readonly type: "text";
@@ -348,8 +349,4 @@ export type UserContentEntryRow = {
348
349
  readonly folder: string | null;
349
350
  readonly content: string | null;
350
351
  readonly contentFormat: string;
351
- readonly createdAt: Date;
352
- readonly updatedAt: Date;
353
- readonly createdBy: string;
354
- readonly updatedBy: string;
355
- };
352
+ } & EntityAuditColumns;
@@ -6,7 +6,7 @@ export type BlockSummary = {
6
6
  readonly title: string | null;
7
7
  readonly content: string | null;
8
8
  readonly folder: string | null;
9
- readonly updatedAt: string;
9
+ readonly modifiedAt: string | null;
10
10
  };
11
11
  export declare function groupBlocksByFolder(blocks: readonly BlockSummary[], tenantIdOverride?: string, collectionId?: string): readonly TreeNode[];
12
12
  export declare function textBlocksClient(opts?: {
@@ -21,7 +21,7 @@ export const userContentExportHook = async (ctx) => {
21
21
  title: r["title"],
22
22
  content: r["content"],
23
23
  folder: r["folder"],
24
- updatedAt: String(r["updatedAt"] ?? ""),
24
+ modifiedAt: String(r["modifiedAt"] ?? ""),
25
25
  })),
26
26
  };
27
27
  };
@@ -0,0 +1,8 @@
1
+ import { type TenantDb } from "@cosmicdrift/kumiko-framework/db";
2
+ import type { SessionUser } from "@cosmicdrift/kumiko-framework/engine";
3
+ import type { WriteFailure } from "@cosmicdrift/kumiko-framework/errors";
4
+ import type { Redis } from "ioredis";
5
+ export declare function cancelPendingInvitation(db: TenantDb, invitation: {
6
+ readonly id: string;
7
+ readonly version: number;
8
+ }, actor: SessionUser, redis: Redis | undefined): Promise<WriteFailure | undefined>;
@@ -0,0 +1,22 @@
1
+ import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
2
+ // kumiko-lint-ignore cross-feature-import cancel needs invite-token-store for Redis cleanup
3
+ import { invalidateExistingInviteToken } from "../auth-email-password/invite-token-store.js";
4
+ import { INVITATION_STATUS, tenantInvitationEntity, tenantInvitationsTable, } from "./invitation-table.js";
5
+ const invitationExecutor = createEventStoreExecutor(tenantInvitationsTable, tenantInvitationEntity, { entityName: "tenant-invitation" });
6
+ // Shared by cancel-invitation and remove-member so the cancel behavior
7
+ // (status transition + token invalidation) cannot drift between them.
8
+ export async function cancelPendingInvitation(db, invitation, actor, redis) {
9
+ const updateResult = await invitationExecutor.update({
10
+ id: invitation.id,
11
+ version: invitation.version,
12
+ changes: { status: INVITATION_STATUS.cancelled },
13
+ }, actor, db);
14
+ if (!updateResult.isSuccess)
15
+ return updateResult;
16
+ // Delete the token from Redis (if still there). If Redis is
17
+ // unavailable or the token already expired: not a problem, the DB
18
+ // row is the single source of truth for the UI.
19
+ if (redis)
20
+ await invalidateExistingInviteToken(redis, invitation.id);
21
+ return undefined;
22
+ }
@@ -121,7 +121,8 @@ export function createTenantFeature(options) {
121
121
  // for existing callers). Cross-tenant because the feature is systemScope.
122
122
  r.queryHandler(defineEntityListHandler("tenant", tenantEntity, {
123
123
  access: { roles: ["SystemAdmin"] },
124
- description: "Lists tenants under the entity-convention name the generated SystemAdmin tenant-list screen resolves; tenant:query:list is the hand-written equivalent.",
124
+ // Duplicate of tenant:query:list for agents.
125
+ agent: { expose: false },
125
126
  }));
126
127
  r.queryHandler(defineEntityDetailHandler("tenant", tenantEntity, {
127
128
  access: { roles: ["SystemAdmin"] },
@@ -129,7 +130,8 @@ export function createTenantFeature(options) {
129
130
  }));
130
131
  r.writeHandler(defineEntityUpdateHandler("tenant", tenantEntity, {
131
132
  access: { roles: ["SystemAdmin"] },
132
- description: "Saves edits to a tenant's own fields under the entity-convention name the generated SystemAdmin tenant-edit screen resolves; tenant:write:update is the hand-written equivalent.",
133
+ // Duplicate of tenant:write:update with weaker logic.
134
+ agent: { expose: false },
133
135
  }));
134
136
  r.screen(tenantListScreen);
135
137
  r.screen(tenantEditScreen);
@@ -10,19 +10,14 @@
10
10
  // invitation = no-op + 200. Cancelling a non-existent invitation
11
11
  // = invitation_not_found.
12
12
  import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
13
- import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
14
13
  import { access, defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
15
14
  import { InternalError, NotFoundError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
16
15
  import * as z from "zod";
17
- // kumiko-lint-ignore cross-feature-import cancel needs invite-token-store for Redis cleanup
18
- import { invalidateExistingInviteToken } from "../../auth-email-password/invite-token-store.js";
19
- import { INVITATION_STATUS, tenantInvitationEntity, tenantInvitationsTable, } from "../invitation-table.js";
16
+ import { cancelPendingInvitation } from "../cancel-pending-invitation.js";
17
+ import { INVITATION_STATUS, tenantInvitationsTable } from "../invitation-table.js";
20
18
  const CancelInvitationSchema = z.object({
21
19
  invitationId: z.string(),
22
20
  });
23
- const executor = createEventStoreExecutor(tenantInvitationsTable, tenantInvitationEntity, {
24
- entityName: "tenant-invitation",
25
- });
26
21
  export const cancelInvitationWrite = defineWriteHandler({
27
22
  name: "cancel-invitation",
28
23
  schema: CancelInvitationSchema,
@@ -51,20 +46,10 @@ export const cancelInvitationWrite = defineWriteHandler({
51
46
  if (invitation["status"] !== INVITATION_STATUS.pending) {
52
47
  return { isSuccess: true, data: { id: event.payload.invitationId, alreadyDone: true } };
53
48
  }
54
- // Status update via event-store
55
- const updateResult = await executor.update({
56
- id: event.payload.invitationId,
57
- version: invitation["version"], // @cast-boundary db-row
58
- changes: { status: INVITATION_STATUS.cancelled },
59
- }, event.user, db);
60
- if (!updateResult.isSuccess)
61
- return updateResult;
62
- // Delete the token from Redis (if still there). If Redis is
63
- // unavailable or the token already expired: not a problem, the DB
64
- // row is the single source of truth for the UI.
65
- if (ctx.redis) {
66
- await invalidateExistingInviteToken(ctx.redis, event.payload.invitationId);
67
- }
49
+ const cancelFailure = await cancelPendingInvitation(db, { id: event.payload.invitationId, version: invitation["version"] }, // @cast-boundary db-row
50
+ event.user, ctx.redis);
51
+ if (cancelFailure)
52
+ return cancelFailure;
68
53
  return { isSuccess: true, data: { id: event.payload.invitationId, alreadyDone: false } };
69
54
  },
70
55
  });
@@ -1,6 +1,7 @@
1
1
  import * as z from "zod";
2
2
  export declare const memberDirectoryQuery: import("@cosmicdrift/kumiko-framework/engine").PagedQueryHandlerDefinition<"memberDirectory", z.ZodObject<{
3
3
  limit: z.ZodDefault<z.ZodNumber>;
4
+ search: z.ZodOptional<z.ZodString>;
4
5
  }, z.core.$strip>, {
5
6
  id: string;
6
7
  label: string;
@@ -9,6 +9,10 @@ import { isSystemAdmin } from "./is-system-admin.js";
9
9
  // Shares the KMS adapter's small dedicated pool, same rationale as
10
10
  // members.query.ts.
11
11
  const KMS_POOL_CONCURRENCY = 4;
12
+ // Display names are encrypted PII, so a search cannot run in SQL: candidates
13
+ // are decrypted and filtered in memory. The cap keeps a SystemAdmin's global
14
+ // search from decrypting every user.
15
+ const SEARCH_SCAN_CAP = 1000;
12
16
  async function loadMemberUserIds(db, tenantId) {
13
17
  const memberships = await selectMany(db, tenantMembershipsTable, { tenantId });
14
18
  return [...new Set(memberships.map((row) => String(row["userId"])))];
@@ -29,6 +33,8 @@ export const memberDirectoryQuery = definePagedQueryHandler({
29
33
  name: "memberDirectory",
30
34
  schema: z.object({
31
35
  limit: z.number().int().min(1).max(MAX_LIST_LIMIT).default(MAX_LIST_LIMIT),
36
+ // Sent by the reference combobox while the user types.
37
+ search: z.string().trim().min(1).optional(),
32
38
  }),
33
39
  access: { roles: access.admin },
34
40
  description: "Lists users as id/label pairs for resolving user references to display names: the caller's own tenant members for an admin, every user for a SystemAdmin; carries no email or role data.",
@@ -39,24 +45,31 @@ export const memberDirectoryQuery = definePagedQueryHandler({
39
45
  message: "tenant:query:member-directory requires ctx.systemDb — is r.systemScope() still set on the tenant feature?",
40
46
  });
41
47
  }
42
- const { limit } = query.payload;
48
+ const { limit, search } = query.payload;
49
+ // Without a search the first `limit` rows are enough; with one, every
50
+ // candidate must be decrypted before the limit can apply.
51
+ const scanLimit = search === undefined ? limit : SEARCH_SCAN_CAP;
43
52
  let users;
44
53
  if (isSystemAdmin(query.user)) {
45
54
  const db = ctx.systemDb.acknowledgeCrossTenant("SystemAdmin reference labels span every tenant, as user:query:user:list did");
46
- users = await selectMany(db, userTable, undefined, { limit });
55
+ users = await selectMany(db, userTable, undefined, { limit: scanLimit });
47
56
  }
48
57
  else {
49
58
  const db = ctx.systemDb.assertTenantMatch(query.user.tenantId);
50
- const userIds = (await loadMemberUserIds(db, query.user.tenantId)).slice(0, limit);
59
+ const userIds = (await loadMemberUserIds(db, query.user.tenantId)).slice(0, scanLimit);
51
60
  users = userIds.length > 0 ? await selectMany(db, userTable, { id: [...userIds] }) : [];
52
61
  }
53
- const rows = await mapWithConcurrency(users, KMS_POOL_CONCURRENCY, async (user) => {
62
+ const resolved = await mapWithConcurrency(users, KMS_POOL_CONCURRENCY, async (user) => {
54
63
  const id = String(user.id);
55
64
  const label = typeof user.displayName === "string"
56
65
  ? await decryptStoredPii(user.displayName, "displayName", "tenant:member-directory")
57
66
  : null;
58
67
  return { id, label: label ?? id };
59
68
  });
69
+ const needle = search?.toLowerCase();
70
+ const rows = needle === undefined
71
+ ? resolved
72
+ : resolved.filter((row) => row.label.toLowerCase().includes(needle)).slice(0, limit);
60
73
  return { rows, nextCursor: null };
61
74
  },
62
75
  });
@@ -4,17 +4,15 @@ import { createSystemUser, defineWriteHandler, withResponseData, } from "@cosmic
4
4
  import { InternalError, NotFoundError, writeFailure, } from "@cosmicdrift/kumiko-framework/errors";
5
5
  import { parseRoles } from "@cosmicdrift/kumiko-framework/utils";
6
6
  import * as z from "zod";
7
- // kumiko-lint-ignore cross-feature-import cancel needs invite-token-store for Redis cleanup
8
- import { invalidateExistingInviteToken } from "../../auth-email-password/invite-token-store.js";
9
7
  import { decryptStoredPii } from "../../shared/index.js";
10
8
  import { userTable } from "../../user/index.js";
11
- import { INVITATION_STATUS, tenantInvitationEntity, tenantInvitationsTable, } from "../invitation-table.js";
9
+ import { cancelPendingInvitation } from "../cancel-pending-invitation.js";
10
+ import { INVITATION_STATUS, tenantInvitationsTable } from "../invitation-table.js";
12
11
  import { assertNotLastTenantAdmin } from "../last-tenant-admin.js";
13
12
  import { tenantMembershipEntity, tenantMembershipsTable } from "../membership-table.js";
14
13
  const executor = createEventStoreExecutor(tenantMembershipsTable, tenantMembershipEntity, {
15
14
  entityName: "tenant-membership",
16
15
  });
17
- const invitationExecutor = createEventStoreExecutor(tenantInvitationsTable, tenantInvitationEntity, { entityName: "tenant-invitation" });
18
16
  async function cancelPendingInvitationsOfUser(db, options) {
19
17
  const user = await fetchOne(db, userTable, { id: options.userId });
20
18
  if (!user?.email)
@@ -22,15 +20,9 @@ async function cancelPendingInvitationsOfUser(db, options) {
22
20
  const email = (await decryptStoredPii(user.email, "email", "tenant:remove-member")).toLowerCase();
23
21
  const pendingInvitations = await selectMany(db, tenantInvitationsTable, { tenantId: options.tenantId, email, status: INVITATION_STATUS.pending });
24
22
  for (const invitation of pendingInvitations) {
25
- const updateResult = await invitationExecutor.update({
26
- id: invitation.id,
27
- version: invitation.version,
28
- changes: { status: INVITATION_STATUS.cancelled },
29
- }, options.actor, db);
30
- if (!updateResult.isSuccess)
31
- return updateResult;
32
- if (options.redis)
33
- await invalidateExistingInviteToken(options.redis, invitation.id);
23
+ const failure = await cancelPendingInvitation(db, invitation, options.actor, options.redis);
24
+ if (failure)
25
+ return failure;
34
26
  }
35
27
  return undefined;
36
28
  }
@@ -63,7 +55,10 @@ export const removeMemberWrite = defineWriteHandler({
63
55
  }
64
56
  const currentRoles = parseRoles(existing["roles"]);
65
57
  if (currentRoles.includes("TenantAdmin")) {
66
- const lastAdmin = await assertNotLastTenantAdmin(db, event.payload.tenantId, event.payload.userId);
58
+ // assertNotLastTenantAdmin issues raw SQL, so it needs the raw runner, not
59
+ // the tenant-scoped `db` — the count is filtered by the explicit tenantId.
60
+ const lockRunner = ctx.systemDb.unsafeRaw("tenant:write:removeMember last-TenantAdmin advisory lock + membership count");
61
+ const lastAdmin = await assertNotLastTenantAdmin(lockRunner, event.payload.tenantId, event.payload.userId, "remove");
67
62
  if (lastAdmin !== undefined)
68
63
  return lastAdmin;
69
64
  }
@@ -130,7 +130,7 @@ export const teamListQuery = definePagedQueryHandler({
130
130
  const memberRows = membershipRows.map((row) => {
131
131
  const userId = String(row["userId"]);
132
132
  const decrypted = decryptedByUserId.get(userId);
133
- const createdAt = row["createdAt"];
133
+ const createdAt = row["insertedAt"];
134
134
  return {
135
135
  id: String(row["id"]),
136
136
  userId,
@@ -153,7 +153,7 @@ export const teamListQuery = definePagedQueryHandler({
153
153
  const decryptedEmail = typeof email === "string"
154
154
  ? await decryptStoredPii(email, "email", "tenant:team-list")
155
155
  : null;
156
- const createdAt = row["createdAt"];
156
+ const createdAt = row["insertedAt"];
157
157
  const expiresAt = row["expiresAt"];
158
158
  return {
159
159
  id: String(row["id"]),
@@ -1,6 +1,7 @@
1
1
  import * as z from "zod";
2
2
  export declare const tenantDirectoryQuery: import("@cosmicdrift/kumiko-framework/engine").PagedQueryHandlerDefinition<"tenantDirectory", z.ZodObject<{
3
3
  limit: z.ZodDefault<z.ZodNumber>;
4
+ search: z.ZodOptional<z.ZodString>;
4
5
  }, z.core.$strip>, {
5
6
  id: string;
6
7
  label: string;
@@ -11,10 +11,15 @@ import { isSystemAdmin } from "./is-system-admin.js";
11
11
  // them; a TenantAdmin only ever sees their own tenant.
12
12
  // ponytail: capped at `limit` (200 list lookup, 50 combobox) like every
13
13
  // reference lookup; beyond that the cell falls back to the raw id.
14
+ // A search filters in memory over a bounded scan so the combobox can reach
15
+ // tenants beyond the first `limit`; the cap keeps it from reading every tenant.
16
+ const SEARCH_SCAN_CAP = 1000;
14
17
  export const tenantDirectoryQuery = definePagedQueryHandler({
15
18
  name: "tenantDirectory",
16
19
  schema: z.object({
17
20
  limit: z.number().int().min(1).max(MAX_LIST_LIMIT).default(MAX_LIST_LIMIT),
21
+ // Sent by the reference combobox while the user types.
22
+ search: z.string().trim().min(1).optional(),
18
23
  }),
19
24
  access: { roles: access.admin },
20
25
  description: "Lists tenants as id/label pairs for resolving tenant references to display names: the caller's own tenant for an admin, every tenant for a SystemAdmin.",
@@ -25,21 +30,26 @@ export const tenantDirectoryQuery = definePagedQueryHandler({
25
30
  message: "tenant:query:tenant-directory requires ctx.systemDb — is r.systemScope() still set on the tenant feature?",
26
31
  });
27
32
  }
28
- const { limit } = query.payload;
33
+ const { limit, search } = query.payload;
34
+ const scanLimit = search === undefined ? limit : SEARCH_SCAN_CAP;
29
35
  let tenants;
30
36
  if (isSystemAdmin(query.user)) {
31
37
  const db = ctx.systemDb.acknowledgeCrossTenant("SystemAdmin reference labels span every tenant, as tenant:query:tenant:list did");
32
- tenants = await selectMany(db, tenantTable, undefined, { limit });
38
+ tenants = await selectMany(db, tenantTable, undefined, { limit: scanLimit });
33
39
  }
34
40
  else {
35
41
  const db = ctx.systemDb.assertTenantMatch(query.user.tenantId);
36
42
  const row = await fetchOne(db, tenantTable, { id: query.user.tenantId });
37
43
  tenants = row ? [row] : [];
38
44
  }
39
- const rows = tenants.map((tenant) => {
45
+ const labeled = tenants.map((tenant) => {
40
46
  const id = String(tenant.id);
41
47
  return { id, label: typeof tenant.name === "string" ? tenant.name : id };
42
48
  });
49
+ const needle = search?.toLowerCase();
50
+ const rows = needle === undefined
51
+ ? labeled
52
+ : labeled.filter((row) => row.label.toLowerCase().includes(needle)).slice(0, limit);
43
53
  return { rows, nextCursor: null };
44
54
  },
45
55
  });
@@ -5,6 +5,6 @@ export declare const updateMemberRolesWrite: import("@cosmicdrift/kumiko-types/d
5
5
  roles: z.ZodArray<z.ZodString>;
6
6
  }, z.core.$strip>, {
7
7
  userId: string;
8
- roles: string[];
8
+ roles: readonly string[];
9
9
  tenantId: string;
10
10
  }, import("@cosmicdrift/kumiko-types/event-type-map").KumikoEventTypeMap>;
@@ -1,6 +1,6 @@
1
1
  import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
2
2
  import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
3
- import { access, assignableAppRolesFromUsages, createSystemUser, defineWriteHandler, EXT_ASSIGNABLE_ROLE, findForbiddenRoleAssignment, withResponseData, } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { access, assignableAppRolesOf, createSystemUser, defineWriteHandler, findForbiddenRoleAssignment, mergeAssignedRoles, SYSTEM_ROLE, withResponseData, } from "@cosmicdrift/kumiko-framework/engine";
4
4
  import { InternalError, NotFoundError, ValidationError, writeFailure, } from "@cosmicdrift/kumiko-framework/errors";
5
5
  import { parseRoles } from "@cosmicdrift/kumiko-framework/utils";
6
6
  import * as z from "zod";
@@ -19,6 +19,15 @@ const REVOKE_ALL_SESSIONS_QN = "sessions:write:user-session:revoke-all-for-user"
19
19
  function resolveTargetTenantId(isSystem, payloadTenantId, sessionTenantId) {
20
20
  return isSystem ? (payloadTenantId ?? sessionTenantId) : sessionTenantId;
21
21
  }
22
+ // Editing your OWN roles keeps the current session: roles are re-derived per
23
+ // request, so the new roles apply at once without a re-login. Other sessions
24
+ // of that user and any foreign edit still revoke all.
25
+ function revokeSessionsPayload(targetUserId, targetTenantId, actor) {
26
+ const payload = { userId: targetUserId, tenantId: targetTenantId };
27
+ if (targetUserId !== actor.id || actor.sid === undefined)
28
+ return payload;
29
+ return { ...payload, exceptSessionId: actor.sid };
30
+ }
22
31
  export const updateMemberRolesWrite = defineWriteHandler({
23
32
  name: "updateMemberRoles",
24
33
  schema: z.object({
@@ -31,14 +40,16 @@ export const updateMemberRolesWrite = defineWriteHandler({
31
40
  // manage memberships cross-tenant (payload.tenantId). TenantAdmin and Admin
32
41
  // are session-scoped (event.user.tenantId).
33
42
  access: { roles: ["system", ...access.admin] },
34
- description: "Replaces the roles a user holds in a tenant, rejecting reserved role names and any grant that would raise the target above the caller; admins act on their own tenant, SystemAdmins on any.",
43
+ description: "Sets the roles a user holds in a tenant, rejecting reserved role names and any grant that would raise the target above the caller; roles the caller could not grant (platform roles such as DataProtectionOfficer or TenantOwner, undeclared or higher-tier app roles) are kept, only the system user replaces the full list; admins act on their own tenant, SystemAdmins on any.",
35
44
  handler: async (event, ctx) => {
36
45
  if (!ctx.systemDb) {
37
46
  throw new InternalError({
38
47
  message: "tenant:write:updateMemberRoles requires ctx.systemDb — is r.systemScope() still set on the tenant feature?",
39
48
  });
40
49
  }
41
- const isSystem = event.user.roles.includes("SystemAdmin") || event.user.roles.includes("system");
50
+ const isSystemActor = event.user.roles.includes(SYSTEM_ROLE);
51
+ const isSystem = event.user.roles.includes("SystemAdmin") || isSystemActor;
52
+ const assignableAppRoles = assignableAppRolesOf(ctx.registry);
42
53
  const targetTenantId = resolveTargetTenantId(isSystem, event.payload.tenantId, event.user.tenantId);
43
54
  if (!targetTenantId) {
44
55
  return writeFailure(new ValidationError({
@@ -69,14 +80,18 @@ export const updateMemberRolesWrite = defineWriteHandler({
69
80
  }
70
81
  const row = existing; // @cast-boundary generic-record
71
82
  const currentTargetRoles = parseRoles(row["roles"]);
72
- if (!event.user.roles.includes("system")) {
73
- const forbiddenElevation = findForbiddenRoleAssignment(event.user.roles, event.payload.roles, currentTargetRoles, assignableAppRolesFromUsages(ctx.registry.getExtensionUsages(EXT_ASSIGNABLE_ROLE)));
83
+ if (!isSystemActor) {
84
+ const forbiddenElevation = findForbiddenRoleAssignment(event.user.roles, event.payload.roles, currentTargetRoles, assignableAppRoles);
74
85
  if (forbiddenElevation !== undefined) {
75
86
  return writeFailure(unassignableMembershipRoleError(forbiddenElevation));
76
87
  }
77
88
  }
89
+ // The system user is programmatic and replaces; human actors keep what they cannot grant.
90
+ const nextRoles = isSystemActor
91
+ ? event.payload.roles
92
+ : mergeAssignedRoles(event.user.roles, event.payload.roles, currentTargetRoles, assignableAppRoles);
78
93
  const targetIsTenantAdmin = currentTargetRoles.includes("TenantAdmin");
79
- const willBeTenantAdmin = event.payload.roles.includes("TenantAdmin");
94
+ const willBeTenantAdmin = nextRoles.includes("TenantAdmin");
80
95
  if (targetIsTenantAdmin && !willBeTenantAdmin) {
81
96
  // assertNotLastTenantAdmin issues raw SQL, so it needs the raw runner, not
82
97
  // `db` — the count is already filtered by the explicit targetTenantId.
@@ -99,10 +114,7 @@ export const updateMemberRolesWrite = defineWriteHandler({
99
114
  // requires, see above).
100
115
  const revoker = ctx.registry.getWriteHandler(REVOKE_ALL_SESSIONS_QN);
101
116
  if (revoker) {
102
- await ctx.writeAs(createSystemUser(targetTenantId), REVOKE_ALL_SESSIONS_QN, {
103
- userId: event.payload.userId,
104
- tenantId: targetTenantId,
105
- });
117
+ await ctx.writeAs(createSystemUser(targetTenantId), REVOKE_ALL_SESSIONS_QN, revokeSessionsPayload(event.payload.userId, targetTenantId, event.user));
106
118
  }
107
119
  // Stream tenant follows the actor via streamTenantFor — use the
108
120
  // membership tenant so SystemAdmin/cross-tenant and TenantAdmin paths
@@ -112,8 +124,12 @@ export const updateMemberRolesWrite = defineWriteHandler({
112
124
  const result = await executor.update({
113
125
  id: row["id"], // @cast-boundary db-row
114
126
  version: row["version"], // @cast-boundary db-row
115
- changes: { roles: JSON.stringify(event.payload.roles) },
127
+ changes: { roles: JSON.stringify(nextRoles) },
116
128
  }, { ...event.user, tenantId: targetTenantId }, db);
117
- return withResponseData(result, { ...event.payload, tenantId: targetTenantId });
129
+ return withResponseData(result, {
130
+ ...event.payload,
131
+ roles: nextRoles,
132
+ tenantId: targetTenantId,
133
+ });
118
134
  },
119
135
  });
@@ -34,6 +34,10 @@ export const TENANT_I18N = {
34
34
  "tenant:entity:__action-form__:field:roles:option:Member": { en: "Member" },
35
35
  "tenant:entity:__action-form__:field:roles:option:Admin": { en: "Admin" },
36
36
  "tenant:entity:__action-form__:field:roles:option:TenantAdmin": { en: "Tenant Admin" },
37
+ "tenant:entity:__action-form__:field:roles:option:TenantOwner": { en: "Tenant Owner" },
38
+ "tenant:entity:__action-form__:field:roles:option:DataProtectionOfficer": {
39
+ en: "Data Protection Officer",
40
+ },
37
41
  "tenant:entity:__action-form__:field:roles:option:Editor": { en: "Editor" },
38
42
  "tenant:entity:tenant:field:key": { en: "Key" },
39
43
  "tenant:entity:tenant:field:name": { en: "Name" },
@@ -1,5 +1,5 @@
1
1
  export { TenantCommandSchemas } from "./command-schemas.js";
2
- export { TENANT_FEATURE, TenantErrors, TenantHandlers, TenantQueries } from "./constants.js";
2
+ export { OWNER_INVITE_ROLE_OPTIONS, TENANT_FEATURE, TenantErrors, TenantHandlers, TenantQueries, } from "./constants.js";
3
3
  export { collectAssignableAppRoles, createTenantFeature } from "./feature.js";
4
4
  export type { InvitationStatus } from "./invitation-table.js";
5
5
  export { INVITATION_STATUS, INVITATION_STATUSES, tenantInvitationEntity, tenantInvitationsTable, } from "./invitation-table.js";
@@ -1,5 +1,5 @@
1
1
  export { TenantCommandSchemas } from "./command-schemas.js";
2
- export { TENANT_FEATURE, TenantErrors, TenantHandlers, TenantQueries } from "./constants.js";
2
+ export { OWNER_INVITE_ROLE_OPTIONS, TENANT_FEATURE, TenantErrors, TenantHandlers, TenantQueries, } from "./constants.js";
3
3
  export { collectAssignableAppRoles, createTenantFeature } from "./feature.js";
4
4
  export { INVITATION_STATUS, INVITATION_STATUSES, tenantInvitationEntity, tenantInvitationsTable, } from "./invitation-table.js";
5
5
  export { isTenantServingPublicContent } from "./is-tenant-serving-public-content.js";
@@ -3,5 +3,5 @@ import { writeFailure } from "@cosmicdrift/kumiko-framework/errors";
3
3
  export declare const LAST_TENANT_ADMIN_LOCK_NAMESPACE = 1952539757;
4
4
  type TenantDb = Parameters<typeof acquireNamespacedAdvisoryLock>[0];
5
5
  /** Refuse demoting/removing the last TenantAdmin for `tenantId`. */
6
- export declare function assertNotLastTenantAdmin(db: TenantDb, tenantId: string, userId: string): Promise<ReturnType<typeof writeFailure> | undefined>;
6
+ export declare function assertNotLastTenantAdmin(db: TenantDb, tenantId: string, userId: string, action?: "demote" | "remove"): Promise<ReturnType<typeof writeFailure> | undefined>;
7
7
  export {};