@cosmicdrift/kumiko-samples 0.353.0 → 0.354.1

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 (137) hide show
  1. package/package.json +1 -1
  2. package/packages/bundled-features/package.json +18 -2
  3. package/packages/bundled-features/src/agent-tools/agent-doc-lint.ts +66 -0
  4. package/packages/bundled-features/src/agent-tools/agent-manifest.ts +11 -5
  5. package/packages/bundled-features/src/agent-tools/feature.ts +13 -2
  6. package/packages/bundled-features/src/agent-tools/index.ts +6 -1
  7. package/packages/bundled-features/src/agent-tools/tool-dispatch.ts +1 -1
  8. package/packages/bundled-features/src/auth-email-password/changes.json +24 -0
  9. package/packages/bundled-features/src/auth-email-password/handlers/change-password.write.ts +1 -0
  10. package/packages/bundled-features/src/auth-email-password/handlers/invite-accept-with-login.write.ts +3 -2
  11. package/packages/bundled-features/src/auth-email-password/handlers/invite-accept.write.ts +3 -2
  12. package/packages/bundled-features/src/auth-email-password/handlers/invite-create.write.ts +21 -0
  13. package/packages/bundled-features/src/auth-email-password/handlers/invite-signup-complete.write.ts +4 -1
  14. package/packages/bundled-features/src/auth-email-password/handlers/login.write.ts +1 -0
  15. package/packages/bundled-features/src/auth-email-password/handlers/reset-password.write.ts +1 -0
  16. package/packages/bundled-features/src/auth-email-password/handlers/signup-confirm.write.ts +5 -0
  17. package/packages/bundled-features/src/auth-email-password/handlers/signup-request.write.ts +53 -96
  18. package/packages/bundled-features/src/auth-email-password/handlers/token-request-handler.ts +1 -0
  19. package/packages/bundled-features/src/auth-email-password/handlers/verify-email.write.ts +1 -0
  20. package/packages/bundled-features/src/auth-email-password/index.ts +12 -0
  21. package/packages/bundled-features/src/auth-email-password/seeding.ts +38 -7
  22. package/packages/bundled-features/src/auth-email-password/signup-activation.ts +83 -0
  23. package/packages/bundled-features/src/auth-email-password/signup-token-store.ts +9 -3
  24. package/packages/bundled-features/src/auth-email-password/web/auth-gate.tsx +7 -7
  25. package/packages/bundled-features/src/auth-email-password/web/auth-redirect.ts +15 -0
  26. package/packages/bundled-features/src/auth-email-password/web/login-screen.tsx +2 -2
  27. package/packages/bundled-features/src/auth-email-password/web/user-menu.tsx +1 -1
  28. package/packages/bundled-features/src/auth-mfa/changes.json +7 -0
  29. package/packages/bundled-features/src/auth-mfa/feature.ts +5 -0
  30. package/packages/bundled-features/src/auth-mfa/handlers/enable-confirm-preauth.write.ts +1 -0
  31. package/packages/bundled-features/src/auth-mfa/handlers/verify.write.ts +1 -0
  32. package/packages/bundled-features/src/auth-mfa/web/mfa-recovery-codes-reveal.tsx +6 -8
  33. package/packages/bundled-features/src/auth-mfa/web/mfa-setup-preauth-screen.tsx +18 -21
  34. package/packages/bundled-features/src/auth-mfa/web/mfa-verify-screen.tsx +2 -2
  35. package/packages/bundled-features/src/billing-foundation/billing-info-query.ts +4 -4
  36. package/packages/bundled-features/src/billing-foundation/changes.json +6 -0
  37. package/packages/bundled-features/src/channel-in-app/changes.json +8 -1
  38. package/packages/bundled-features/src/channel-in-app/in-app-channel.ts +1 -1
  39. package/packages/bundled-features/src/channel-in-app/tables.ts +18 -12
  40. package/packages/bundled-features/src/crypto-shredding/changes.json +12 -0
  41. package/packages/bundled-features/src/crypto-shredding/handlers/forget-subject.write.ts +154 -69
  42. package/packages/bundled-features/src/delivery/changes.json +6 -0
  43. package/packages/bundled-features/src/delivery/db/queries/address-opt-outs.ts +18 -12
  44. package/packages/bundled-features/src/delivery/delivery-service.ts +103 -16
  45. package/packages/bundled-features/src/delivery/unsubscribe.ts +81 -13
  46. package/packages/bundled-features/src/feature-toggles/feature.ts +2 -3
  47. package/packages/bundled-features/src/feature-toggles/toggle-runtime.ts +34 -5
  48. package/packages/bundled-features/src/feature-toggles/toggle-sync-signal.ts +31 -8
  49. package/packages/bundled-features/src/file-derivatives/handlers/public-variant.query.ts +10 -1
  50. package/packages/bundled-features/src/files/changes.json +24 -0
  51. package/packages/bundled-features/src/files-tenant-data/changes.json +5 -0
  52. package/packages/bundled-features/src/files-tenant-data/handlers/sweep-orphaned-derivatives.job.ts +4 -0
  53. package/packages/bundled-features/src/files-tenant-data/hooks.ts +72 -23
  54. package/packages/bundled-features/src/folders/changes.json +6 -0
  55. package/packages/bundled-features/src/folders/handlers/delete-folder.write.ts +41 -9
  56. package/packages/bundled-features/src/foundation-shared/index.ts +1 -0
  57. package/packages/bundled-features/src/foundation-shared/mail-host-policy.ts +16 -4
  58. package/packages/bundled-features/src/inbound-provider-imap/changes.json +6 -0
  59. package/packages/bundled-features/src/inbound-provider-imap/feature.ts +2 -0
  60. package/packages/bundled-features/src/inbound-provider-imap/imap-client.ts +3 -4
  61. package/packages/bundled-features/src/jobs/changes.json +30 -0
  62. package/packages/bundled-features/src/jobs/constants.ts +1 -0
  63. package/packages/bundled-features/src/jobs/db/queries/retention.ts +12 -0
  64. package/packages/bundled-features/src/jobs/db/queries/stale-run-sweep.ts +26 -2
  65. package/packages/bundled-features/src/jobs/feature.ts +7 -1
  66. package/packages/bundled-features/src/jobs/handlers/list.query.ts +1 -1
  67. package/packages/bundled-features/src/jobs/handlers/tenant-failures.query.ts +1 -1
  68. package/packages/bundled-features/src/jobs/handlers/tenant-runs.query.ts +94 -0
  69. package/packages/bundled-features/src/jobs/index.ts +2 -0
  70. package/packages/bundled-features/src/jobs/job-run-logger.ts +177 -10
  71. package/packages/bundled-features/src/jobs/tenant-job-failure-table.ts +27 -8
  72. package/packages/bundled-features/src/jobs/tenant-job-run-table.ts +55 -0
  73. package/packages/bundled-features/src/ledger/changes.json +23 -0
  74. package/packages/bundled-features/src/ledger/entity.ts +13 -5
  75. package/packages/bundled-features/src/ledger/feature.ts +3 -0
  76. package/packages/bundled-features/src/ledger/handlers/confirm-schedule-period.write.ts +15 -3
  77. package/packages/bundled-features/src/ledger/handlers/create-transaction.write.ts +1 -0
  78. package/packages/bundled-features/src/ledger/handlers/reverse-transaction.write.ts +1 -0
  79. package/packages/bundled-features/src/ledger/schedule-subject-pair.ts +27 -0
  80. package/packages/bundled-features/src/mail-transport-smtp/feature.ts +4 -15
  81. package/packages/bundled-features/src/notes-history/changes.json +13 -0
  82. package/packages/bundled-features/src/notes-history/entity.ts +4 -0
  83. package/packages/bundled-features/src/personal-access-tokens/handlers/create.write.ts +1 -0
  84. package/packages/bundled-features/src/sessions/feature.ts +21 -8
  85. package/packages/bundled-features/src/sessions/handlers/detail.query.ts +34 -33
  86. package/packages/bundled-features/src/sessions/handlers/list.query.ts +68 -65
  87. package/packages/bundled-features/src/sessions/screens.ts +10 -8
  88. package/packages/bundled-features/src/shared/encrypt-for-direct-write.ts +15 -8
  89. package/packages/bundled-features/src/shared/index.ts +2 -1
  90. package/packages/bundled-features/src/shared/parent-visibility.ts +31 -1
  91. package/packages/bundled-features/src/shared/row-bound-grant.ts +18 -2
  92. package/packages/bundled-features/src/shared/session-locale-field.ts +7 -0
  93. package/packages/bundled-features/src/template-resolver/api.ts +3 -3
  94. package/packages/bundled-features/src/template-resolver/changes.json +5 -0
  95. package/packages/bundled-features/src/template-resolver/handlers/list.query.ts +2 -2
  96. package/packages/bundled-features/src/tenant/changes.json +12 -0
  97. package/packages/bundled-features/src/tenant/feature.ts +8 -1
  98. package/packages/bundled-features/src/tenant/handlers/toggle-enabled.write.ts +2 -0
  99. package/packages/bundled-features/src/tenant/handlers/update-member-roles.write.ts +1 -0
  100. package/packages/bundled-features/src/tenant/invitation-table.ts +6 -0
  101. package/packages/bundled-features/src/tenant/invited-membership.ts +45 -12
  102. package/packages/bundled-features/src/tenant-handover/changes.json +6 -0
  103. package/packages/bundled-features/src/tenant-handover/transfer-graph.ts +14 -96
  104. package/packages/bundled-features/src/tenant-lifecycle/boot-checks.ts +16 -0
  105. package/packages/bundled-features/src/tenant-lifecycle/changes.json +22 -0
  106. package/packages/bundled-features/src/tenant-lifecycle/constants.ts +8 -0
  107. package/packages/bundled-features/src/tenant-lifecycle/events.ts +6 -0
  108. package/packages/bundled-features/src/tenant-lifecycle/feature.ts +13 -1
  109. package/packages/bundled-features/src/tenant-lifecycle/run-tenant-destroy.ts +100 -29
  110. package/packages/bundled-features/src/tenant-lifecycle/stages.ts +41 -9
  111. package/packages/bundled-features/src/tenant-lifecycle/testing.ts +58 -0
  112. package/packages/bundled-features/src/tier-engine/changes.json +5 -0
  113. package/packages/bundled-features/src/tier-engine/handlers/set-tenant-tier.write.ts +1 -0
  114. package/packages/bundled-features/src/user/changes.json +15 -0
  115. package/packages/bundled-features/src/user/handlers/detail.query.ts +1 -0
  116. package/packages/bundled-features/src/user/handlers/list.query.ts +3 -0
  117. package/packages/bundled-features/src/user/handlers/update.write.ts +1 -0
  118. package/packages/bundled-features/src/user/seeding.ts +34 -0
  119. package/packages/bundled-features/src/user-data-rights/changes.json +6 -0
  120. package/packages/bundled-features/src/user-data-rights/handlers/request-deletion.write.ts +1 -0
  121. package/packages/bundled-features/src/user-data-rights/index.ts +1 -0
  122. package/packages/bundled-features/src/user-data-rights/schema/download-attempt.ts +17 -9
  123. package/packages/bundled-features/src/user-profile/handlers/change-email.write.ts +8 -2
  124. package/packages/bundled-features/src/workflow-runner/event-trigger.ts +72 -14
  125. package/packages/bundled-features/src/workflow-runner/handlers/resume-run.write.ts +6 -1
  126. package/packages/bundled-features/src/workflow-runner/runner.ts +8 -2
  127. package/samples/apps/showcase/src/features/items/feature.ts +2 -1
  128. package/samples/apps/styleguide/src/features/widgets/feature.ts +83 -0
  129. package/samples/apps/styleguide/src/features/widgets/i18n.ts +12 -0
  130. package/samples/apps/styleguide/src/features/widgets/web/Widgets.tsx +4 -0
  131. package/samples/apps/ui-walkthrough/package.json +3 -0
  132. package/samples/apps/use-all-bundled/README.md +1 -2
  133. package/samples/apps/use-all-bundled/feature-manifest.json +1 -1
  134. package/samples/apps/user-data-rights-demo/README.md +1 -3
  135. package/samples/recipes/legal-pages/README.md +3 -4
  136. package/samples/recipes/managed-pages/README.md +2 -2
  137. package/samples/recipes/seed-migration/README.md +0 -2
@@ -4,18 +4,26 @@ import {
4
4
  type SessionStoreProvider,
5
5
  } from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
6
6
  import { type DbConnection, deriveEntityTableMeta } from "@cosmicdrift/kumiko-framework/db";
7
- import { defineFeature, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
7
+ import {
8
+ access,
9
+ defineFeature,
10
+ type FeatureDefinition,
11
+ } from "@cosmicdrift/kumiko-framework/engine";
8
12
  import { USER_SESSION_ENTITY_NAME } from "./constants.js";
9
13
  import { cleanupJob } from "./handlers/cleanup.job.js";
10
- import { detailQuery } from "./handlers/detail.query.js";
11
- import { listQuery } from "./handlers/list.query.js";
14
+ import { createDetailQuery } from "./handlers/detail.query.js";
15
+ import { createListQuery } from "./handlers/list.query.js";
12
16
  import { mineQuery } from "./handlers/mine.query.js";
13
17
  import { revokeWrite } from "./handlers/revoke.write.js";
14
18
  import { revokeAllForUserWrite } from "./handlers/revoke-all-for-user.write.js";
15
19
  import { revokeAllOthersWrite } from "./handlers/revoke-all-others.write.js";
16
20
  import { SESSIONS_I18N } from "./i18n.js";
17
21
  import { userSessionEntity } from "./schema/user-session.js";
18
- import { sessionDetailScreen, sessionListScreen, sessionMineScreen } from "./screens.js";
22
+ import {
23
+ createSessionDetailScreen,
24
+ createSessionListScreen,
25
+ sessionMineScreen,
26
+ } from "./screens.js";
19
27
  import {
20
28
  createSessionCallbacks,
21
29
  type SessionAllOthersRevoker,
@@ -45,6 +53,10 @@ export type SessionsFeatureOptions = {
45
53
  // Session JWT / store_user_sessions TTL. Was previously auth.sessions.expiresInMs
46
54
  // (#1372) — lives on the sessions feature now that sessionStore is the wiring.
47
55
  readonly expiresInMs?: number;
56
+ // Access preset for the admin list/detail queries and screens; they expose decrypted
57
+ // IP/user-agent of every session in the tenant. A preset name, not a role array, because
58
+ // dedupeOptions must stay primitive.
59
+ readonly adminAccess?: "admin" | "systemAdmin";
48
60
  };
49
61
 
50
62
  export type BindAutoRevokeOnPasswordChange = (revoker: SessionMassRevoker) => void;
@@ -89,6 +101,7 @@ export function createSessionsFeature(options?: SessionsFeatureOptions): Feature
89
101
  return defineFeature(
90
102
  "sessions",
91
103
  (r) => {
104
+ const adminRoles = access[options?.adminAccess ?? "admin"];
92
105
  r.describe(
93
106
  "Tracks signed-in clients in the `store_user_sessions` table (one row per JWT, keyed by the `sid`/`jti` claim) and exposes handlers for `mine` (list your sessions), `revoke`, and `revokeAllOthers`. Session creation and revocation on the hot auth path are handled by `createSessionCallbacks()`, wired into `buildServer({ auth: { ... } })` outside the dispatcher; the same callbacks are also registered as an auth-foundation sessionStore provider, resolvable generically via `resolveSessionStore()`. The feature also ships a manual-trigger cleanup job for pruning expired rows and an optional `autoRevokeOnPasswordChange` hook that mass-revokes all sessions for a user whenever their `passwordHash` changes.",
94
107
  );
@@ -162,8 +175,8 @@ export function createSessionsFeature(options?: SessionsFeatureOptions): Feature
162
175
 
163
176
  const queries = {
164
177
  mine: r.queryHandler(mineQuery),
165
- list: r.queryHandler(listQuery),
166
- detail: r.queryHandler(detailQuery),
178
+ list: r.queryHandler(createListQuery(adminRoles)),
179
+ detail: r.queryHandler(createDetailQuery(adminRoles)),
167
180
  };
168
181
 
169
182
  // Retention: chunked DELETE of expired/revoked rows. Manual trigger
@@ -222,8 +235,8 @@ export function createSessionsFeature(options?: SessionsFeatureOptions): Feature
222
235
 
223
236
  r.translations({ keys: SESSIONS_I18N });
224
237
 
225
- r.screen(sessionListScreen);
226
- r.screen(sessionDetailScreen);
238
+ r.screen(createSessionListScreen(adminRoles));
239
+ r.screen(createSessionDetailScreen(adminRoles));
227
240
  r.screen(sessionMineScreen);
228
241
  r.nav({
229
242
  id: "session-list",
@@ -16,36 +16,37 @@ const sessionRowSchema = z.object({
16
16
  userAgent: z.string().nullable(),
17
17
  });
18
18
 
19
- export const detailQuery = defineQueryHandler({
20
- name: "user-session:detail",
21
- schema: z.object({ id: z.uuid() }),
22
- access: { roles: access.admin },
23
- description:
24
- "Loads one session of the active tenant by its id with owner, timestamps and decrypted IP/user-agent; use it when an admin investigates a specific session found through the session list.",
25
- // Decrypted IP and user-agent.
26
- agent: { risk: "high" },
27
- outputSchema: sessionRowSchema.nullable(),
28
- handler: async (query, ctx) => {
29
- const row = await fetchOne<{
30
- id: string;
31
- userId: string;
32
- createdAt: unknown;
33
- expiresAt: unknown;
34
- revokedAt: unknown;
35
- ip: string | null;
36
- userAgent: string | null;
37
- }>(ctx.db, userSessionTable, { id: query.payload.id });
38
- if (!row) return null;
39
- return {
40
- id: row.id,
41
- userId: row.userId,
42
- createdAt: row.createdAt,
43
- expiresAt: row.expiresAt,
44
- revokedAt: row.revokedAt,
45
- ip: row.ip ? await decryptStoredPii(row.ip, "ip", "sessions:detail") : row.ip,
46
- userAgent: row.userAgent
47
- ? await decryptStoredPii(row.userAgent, "userAgent", "sessions:detail")
48
- : row.userAgent,
49
- };
50
- },
51
- });
19
+ export const createDetailQuery = (adminRoles: readonly string[] = access.admin) =>
20
+ defineQueryHandler({
21
+ name: "user-session:detail",
22
+ schema: z.object({ id: z.uuid() }),
23
+ access: { roles: adminRoles },
24
+ description:
25
+ "Loads one session of the active tenant by its id with owner, timestamps and decrypted IP/user-agent; use it when an admin investigates a specific session found through the session list.",
26
+ // Decrypted IP and user-agent.
27
+ agent: { risk: "high" },
28
+ outputSchema: sessionRowSchema.nullable(),
29
+ handler: async (query, ctx) => {
30
+ const row = await fetchOne<{
31
+ id: string;
32
+ userId: string;
33
+ createdAt: unknown;
34
+ expiresAt: unknown;
35
+ revokedAt: unknown;
36
+ ip: string | null;
37
+ userAgent: string | null;
38
+ }>(ctx.db, userSessionTable, { id: query.payload.id });
39
+ if (!row) return null;
40
+ return {
41
+ id: row.id,
42
+ userId: row.userId,
43
+ createdAt: row.createdAt,
44
+ expiresAt: row.expiresAt,
45
+ revokedAt: row.revokedAt,
46
+ ip: row.ip ? await decryptStoredPii(row.ip, "ip", "sessions:detail") : row.ip,
47
+ userAgent: row.userAgent
48
+ ? await decryptStoredPii(row.userAgent, "userAgent", "sessions:detail")
49
+ : row.userAgent,
50
+ };
51
+ },
52
+ });
@@ -25,68 +25,71 @@ function isSortableColumn(value: string): value is SortableColumn {
25
25
  // tenantId column. Includes revoked rows; UI shows revokedAt distinct.
26
26
  // No cursor — nextCursor is always null; `limit` only caps the page size,
27
27
  // it doesn't offset into a further one.
28
- export const listQuery = definePagedQueryHandler({
29
- name: "user-session:list",
30
- schema: z.object({
31
- limit: z.number().int().nonnegative().max(MAX_LIST_LIMIT).optional(),
32
- sort: z.string().optional(),
33
- sortDirection: z.enum(["asc", "desc"]).optional(),
34
- }),
35
- access: { roles: access.admin },
36
- description:
37
- "Lists every session in the active tenant, live and revoked, sortable by id, user, creation, expiry or revocation, for an admin auditing who is signed in.",
38
- outputSchema: z.object({
39
- rows: z.array(
40
- z.object({
41
- id: z.string(),
42
- userId: z.string(),
43
- createdAt: z.unknown(),
44
- expiresAt: z.unknown(),
45
- revokedAt: z.unknown(),
46
- ip: z.string().nullable(),
47
- userAgent: z.string().nullable(),
48
- }),
49
- ),
50
- nextCursor: z.string().nullable(),
51
- }),
52
- handler: async (query, ctx) => {
53
- const requestedSort = query.payload.sort;
54
- const sortColumn: SortableColumn =
55
- requestedSort !== undefined && isSortableColumn(requestedSort) ? requestedSort : "createdAt";
56
- const rows = await selectMany<{
57
- id: string;
58
- userId: string;
59
- createdAt: unknown;
60
- expiresAt: unknown;
61
- revokedAt: unknown;
62
- ip: string | null;
63
- userAgent: string | null;
64
- }>(ctx.db, userSessionTable, undefined, {
65
- // `id` as a tie-breaker keeps row order (and, with `limit` set, row
66
- // selection) deterministic across identical requests — sortColumn
67
- // alone isn't unique (e.g. many NULL revokedAt, or equal timestamps).
68
- orderBy:
69
- sortColumn === "id"
70
- ? { col: "id", direction: query.payload.sortDirection ?? "desc" }
71
- : [
72
- { col: sortColumn, direction: query.payload.sortDirection ?? "desc" },
73
- { col: "id", direction: "asc" },
74
- ],
75
- ...(query.payload.limit !== undefined && { limit: query.payload.limit }),
76
- });
77
- const decryptedRows = await Promise.all(
78
- rows.map(async (r) => ({
79
- id: r.id,
80
- userId: r.userId,
81
- createdAt: r.createdAt,
82
- expiresAt: r.expiresAt,
83
- revokedAt: r.revokedAt,
84
- ip: r.ip ? await decryptStoredPii(r.ip, "ip", "sessions:list") : r.ip,
85
- userAgent: r.userAgent
86
- ? await decryptStoredPii(r.userAgent, "userAgent", "sessions:list")
87
- : r.userAgent,
88
- })),
89
- );
90
- return { rows: decryptedRows, nextCursor: null };
91
- },
92
- });
28
+ export const createListQuery = (adminRoles: readonly string[] = access.admin) =>
29
+ definePagedQueryHandler({
30
+ name: "user-session:list",
31
+ schema: z.object({
32
+ limit: z.number().int().nonnegative().max(MAX_LIST_LIMIT).optional(),
33
+ sort: z.string().optional(),
34
+ sortDirection: z.enum(["asc", "desc"]).optional(),
35
+ }),
36
+ access: { roles: adminRoles },
37
+ description:
38
+ "Lists every session in the active tenant, live and revoked, sortable by id, user, creation, expiry or revocation, for an admin auditing who is signed in.",
39
+ outputSchema: z.object({
40
+ rows: z.array(
41
+ z.object({
42
+ id: z.string(),
43
+ userId: z.string(),
44
+ createdAt: z.unknown(),
45
+ expiresAt: z.unknown(),
46
+ revokedAt: z.unknown(),
47
+ ip: z.string().nullable(),
48
+ userAgent: z.string().nullable(),
49
+ }),
50
+ ),
51
+ nextCursor: z.string().nullable(),
52
+ }),
53
+ handler: async (query, ctx) => {
54
+ const requestedSort = query.payload.sort;
55
+ const sortColumn: SortableColumn =
56
+ requestedSort !== undefined && isSortableColumn(requestedSort)
57
+ ? requestedSort
58
+ : "createdAt";
59
+ const rows = await selectMany<{
60
+ id: string;
61
+ userId: string;
62
+ createdAt: unknown;
63
+ expiresAt: unknown;
64
+ revokedAt: unknown;
65
+ ip: string | null;
66
+ userAgent: string | null;
67
+ }>(ctx.db, userSessionTable, undefined, {
68
+ // `id` as a tie-breaker keeps row order (and, with `limit` set, row
69
+ // selection) deterministic across identical requests — sortColumn
70
+ // alone isn't unique (e.g. many NULL revokedAt, or equal timestamps).
71
+ orderBy:
72
+ sortColumn === "id"
73
+ ? { col: "id", direction: query.payload.sortDirection ?? "desc" }
74
+ : [
75
+ { col: sortColumn, direction: query.payload.sortDirection ?? "desc" },
76
+ { col: "id", direction: "asc" },
77
+ ],
78
+ ...(query.payload.limit !== undefined && { limit: query.payload.limit }),
79
+ });
80
+ const decryptedRows = await Promise.all(
81
+ rows.map(async (r) => ({
82
+ id: r.id,
83
+ userId: r.userId,
84
+ createdAt: r.createdAt,
85
+ expiresAt: r.expiresAt,
86
+ revokedAt: r.revokedAt,
87
+ ip: r.ip ? await decryptStoredPii(r.ip, "ip", "sessions:list") : r.ip,
88
+ userAgent: r.userAgent
89
+ ? await decryptStoredPii(r.userAgent, "userAgent", "sessions:list")
90
+ : r.userAgent,
91
+ })),
92
+ );
93
+ return { rows: decryptedRows, nextCursor: null };
94
+ },
95
+ });
@@ -13,9 +13,9 @@ import {
13
13
  SessionQueries,
14
14
  } from "./constants.js";
15
15
 
16
- const listAccess = { roles: access.admin };
17
-
18
- export const sessionListScreen: ProjectionListScreenDefinition = {
16
+ export const createSessionListScreen = (
17
+ adminRoles: readonly string[] = access.admin,
18
+ ): ProjectionListScreenDefinition => ({
19
19
  id: SESSION_LIST_SCREEN_ID,
20
20
  type: "projectionList",
21
21
  query: SessionQueries.list,
@@ -61,10 +61,12 @@ export const sessionListScreen: ProjectionListScreenDefinition = {
61
61
  rowClick: true,
62
62
  },
63
63
  ],
64
- access: listAccess,
65
- };
64
+ access: { roles: adminRoles },
65
+ });
66
66
 
67
- export const sessionDetailScreen: ProjectionDetailScreenDefinition = {
67
+ export const createSessionDetailScreen = (
68
+ adminRoles: readonly string[] = access.admin,
69
+ ): ProjectionDetailScreenDefinition => ({
68
70
  id: SESSION_DETAIL_SCREEN_ID,
69
71
  type: "projectionDetail",
70
72
  query: SessionQueries.detail,
@@ -97,8 +99,8 @@ export const sessionDetailScreen: ProjectionDetailScreenDefinition = {
97
99
  ip: i18nKey("sessions.detail.field.ip"),
98
100
  userAgent: i18nKey("sessions.detail.field.userAgent"),
99
101
  },
100
- access: listAccess,
101
- };
102
+ access: { roles: adminRoles },
103
+ });
102
104
 
103
105
  // Revoke is hidden on the current session — it would sign the user out mid-click; logout covers it.
104
106
  export const sessionMineScreen: ProjectionListScreenDefinition = {
@@ -3,8 +3,10 @@ import {
3
3
  collectPiiSubjectFields,
4
4
  configuredPiiSubjectKms,
5
5
  encryptPiiFieldValues,
6
+ KeyErasedError,
6
7
  } from "@cosmicdrift/kumiko-framework/crypto";
7
8
  import type { EntityDefinition } from "@cosmicdrift/kumiko-framework/engine";
9
+ import { subjectErasedConflict } from "@cosmicdrift/kumiko-framework/errors";
8
10
 
9
11
  // Unmanaged direct-write stores (r.unmanagedTable) skip the executor, so its
10
12
  // PII encryption never runs — every insert of subject-annotated fields must
@@ -18,12 +20,17 @@ export async function encryptForDirectWrite(
18
20
  ): Promise<Record<string, unknown>> {
19
21
  const kms = configuredPiiSubjectKms();
20
22
  if (!kms) return row;
21
- return encryptPiiFieldValues(
22
- row,
23
- entity,
24
- collectPiiSubjectFields(entity),
25
- kms,
26
- { requestId: requestContext.get()?.requestId ?? fallbackRequestId },
27
- { entityName },
28
- );
23
+ try {
24
+ return await encryptPiiFieldValues(
25
+ row,
26
+ entity,
27
+ collectPiiSubjectFields(entity),
28
+ kms,
29
+ { requestId: requestContext.get()?.requestId ?? fallbackRequestId },
30
+ { entityName },
31
+ );
32
+ } catch (e) {
33
+ if (e instanceof KeyErasedError) throw subjectErasedConflict(e);
34
+ throw e;
35
+ }
29
36
  }
@@ -21,6 +21,7 @@ export { createLockoutCounter, type LockoutCounterState } from "./lockout-counte
21
21
  export { mapWithConcurrency } from "./map-with-concurrency.js";
22
22
  export {
23
23
  denyUnlessJoinRowParentVisible,
24
+ hostRowIsGone,
24
25
  joinRowParentIsVisible,
25
26
  parentRowIsVisible,
26
27
  } from "./parent-visibility.js";
@@ -38,7 +39,7 @@ export {
38
39
  signRowBoundGrant,
39
40
  } from "./row-bound-grant.js";
40
41
  export { runInSubTransaction } from "./run-in-sub-transaction.js";
41
- export { sessionLocaleField } from "./session-locale-field.js";
42
+ export { registrationLocaleFromRequest, sessionLocaleField } from "./session-locale-field.js";
42
43
  export { sessionTimezoneField } from "./session-timezone-field.js";
43
44
  export {
44
45
  peekTokenSubject,
@@ -1,4 +1,8 @@
1
- import type { EventStoreExecutor, TenantDb } from "@cosmicdrift/kumiko-framework/db";
1
+ import {
2
+ buildEntityTable,
3
+ type EventStoreExecutor,
4
+ type TenantDb,
5
+ } from "@cosmicdrift/kumiko-framework/db";
2
6
  import {
3
7
  createEntityExecutor,
4
8
  type EntityDefinition,
@@ -61,6 +65,32 @@ export async function parentRowIsVisible(
61
65
  return (await executor.detail({ id: entityId }, user, db)) !== null;
62
66
  }
63
67
 
68
+ const hostTablesByEntity = new WeakMap<EntityDefinition, ReturnType<typeof buildEntityTable>>();
69
+
70
+ // True when the host row can never be read again by anyone: its entityType no
71
+ // longer names a registered entity, or the row is hard-deleted / soft-deleted.
72
+ // Deliberately skips the caller's `access.read` ownership (unlike
73
+ // parentRowIsVisible): a row hidden from this caller but alive for another team
74
+ // is NOT gone, and its join row must stay.
75
+ export async function hostRowIsGone(
76
+ registry: Registry,
77
+ entityType: string,
78
+ entityId: string,
79
+ db: TenantDb,
80
+ ): Promise<boolean> {
81
+ const entity = registry.getEntity(entityType);
82
+ if (!entity) return true;
83
+ if (!idShapeMatchesEntity(entity, entityId)) return true;
84
+
85
+ let table = hostTablesByEntity.get(entity);
86
+ if (!table) {
87
+ table = buildEntityTable(entityType, entity, { relations: registry.getRelations(entityType) });
88
+ hostTablesByEntity.set(entity, table);
89
+ }
90
+ const row = await db.fetchOne(table, { id: entityId });
91
+ return row === undefined || row["isDeleted"] === true;
92
+ }
93
+
64
94
  // Write-path half of the parentRef gate (fw#2766). Resolves the join entity's
65
95
  // own `parentRef` declaration from the registry — the same object the read
66
96
  // path's SQL gate reads — so the field names and the allowedTypes allowlist
@@ -54,6 +54,19 @@ function anchoredPurpose(purpose: string, anchor: string): string {
54
54
  return `${purpose}:${anchor}`;
55
55
  }
56
56
 
57
+ // The HMAC input is `${purpose}:${anchor}:${subject}.${expiresAtMs}` with no
58
+ // length prefixes. Colon-free anchor and subject make the split unambiguous
59
+ // from the right, so two different (purpose, anchor, subject) triples can
60
+ // never share a signature. The purpose may keep colons (tenant-handover
61
+ // namespaces its purpose by entity type).
62
+ function hasDelimiter(value: string): boolean {
63
+ return value.includes(":");
64
+ }
65
+
66
+ function isUnambiguousPart(value: string | null | undefined): value is string {
67
+ return !!value && !hasDelimiter(value);
68
+ }
69
+
57
70
  export function signRowBoundGrant(args: {
58
71
  readonly subject: string;
59
72
  readonly purpose: string;
@@ -62,6 +75,9 @@ export function signRowBoundGrant(args: {
62
75
  readonly secret: string;
63
76
  readonly now?: Temporal.Instant;
64
77
  }): { readonly token: string; readonly expiresAt: Temporal.Instant } {
78
+ if (hasDelimiter(args.anchor) || hasDelimiter(args.subject)) {
79
+ throw new Error("row-bound grant: subject and anchor must not contain ':'");
80
+ }
65
81
  return signToken(
66
82
  args.subject,
67
83
  anchoredPurpose(args.purpose, args.anchor),
@@ -90,7 +106,7 @@ export async function redeemRowBoundGrant(args: {
90
106
  if (!args.secret) return FAILED;
91
107
 
92
108
  const subject = peekTokenSubject(args.token);
93
- if (!subject) return FAILED;
109
+ if (!isUnambiguousPart(subject)) return FAILED;
94
110
 
95
111
  // The subject is unverified attacker input at this point, so a lookup that
96
112
  // throws on it (e.g. a non-uuid value against a uuid column) must not
@@ -107,7 +123,7 @@ export async function redeemRowBoundGrant(args: {
107
123
  });
108
124
  return FAILED;
109
125
  }
110
- if (!anchor) return FAILED;
126
+ if (!isUnambiguousPart(anchor)) return FAILED;
111
127
 
112
128
  const verified = verifyToken(
113
129
  args.token,
@@ -1,3 +1,4 @@
1
+ import { requestContext } from "@cosmicdrift/kumiko-framework/api";
1
2
  import type { SessionUser } from "@cosmicdrift/kumiko-framework/engine";
2
3
  import { canonicalizeLocaleTag, isValidLocaleTag } from "@cosmicdrift/kumiko-framework/i18n";
3
4
 
@@ -7,3 +8,9 @@ export function sessionLocaleField(
7
8
  if (locale === null || locale === undefined || !isValidLocaleTag(locale)) return {};
8
9
  return { locale: canonicalizeLocaleTag(locale) };
9
10
  }
11
+
12
+ // Only an explicit request signal (X-Locale) counts: the boot default is not a
13
+ // choice the new user made.
14
+ export function registrationLocaleFromRequest(): ReturnType<typeof sessionLocaleField> {
15
+ return sessionLocaleField(requestContext.get()?.locale);
16
+ }
@@ -3,7 +3,7 @@
3
3
  // ctx.templateResolver at runtime.
4
4
 
5
5
  import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
6
- import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
6
+ import type { DbRunner } from "@cosmicdrift/kumiko-framework/db";
7
7
  import type { SessionUser, TenantId } from "@cosmicdrift/kumiko-framework/engine";
8
8
  import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
9
9
  import type { Temporal } from "@cosmicdrift/kumiko-types/temporal";
@@ -76,7 +76,7 @@ export type TemplateResolverApi = {
76
76
  readonly resolveTemplate: (args: ResolveRequest) => Promise<TemplateResource>;
77
77
  };
78
78
 
79
- export function createTemplateResolverApi(db: DbConnection): TemplateResolverApi {
79
+ export function createTemplateResolverApi(db: DbRunner): TemplateResolverApi {
80
80
  return {
81
81
  findExact: async ({ tenantId, slug, kind, locale, scope }) => {
82
82
  const effectiveTenantId = scope === "system" ? SYSTEM_TENANT_ID : tenantId;
@@ -106,7 +106,7 @@ export function createTemplateResolverApi(db: DbConnection): TemplateResolverApi
106
106
  }
107
107
 
108
108
  async function fetchTemplate(
109
- db: DbConnection,
109
+ db: DbRunner,
110
110
  tenantId: string,
111
111
  slug: string,
112
112
  kind: TemplateKind,
@@ -1,4 +1,9 @@
1
1
  [
2
+ {
3
+ "version": "0.354.0",
4
+ "type": "fix",
5
+ "title": "template-resolver list filters by every template kind including text-block and ai-prompt"
6
+ },
2
7
  {
3
8
  "version": "0.335.0",
4
9
  "type": "breaking",
@@ -1,7 +1,7 @@
1
1
  import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
2
2
  import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
3
3
  import * as z from "zod";
4
- import { RENDER_KINDS, TEMPLATE_STATUSES } from "../constants.js";
4
+ import { TEMPLATE_KINDS, TEMPLATE_STATUSES } from "../constants.js";
5
5
  import { type TemplateResourceRow, templateResourcesTable } from "../table.js";
6
6
 
7
7
  // List für Admin-UI: filterbar nach kind / locale / status. Liefert
@@ -10,7 +10,7 @@ import { type TemplateResourceRow, templateResourcesTable } from "../table.js";
10
10
  export const listQuery = defineQueryHandler({
11
11
  name: "list",
12
12
  schema: z.object({
13
- kind: z.enum(RENDER_KINDS).optional(),
13
+ kind: z.enum(TEMPLATE_KINDS).optional(),
14
14
  locale: z.string().min(2).max(8).optional(),
15
15
  status: z.enum(TEMPLATE_STATUSES).optional(),
16
16
  includeSystem: z.boolean().default(true),
@@ -1,4 +1,16 @@
1
1
  [
2
+ {
3
+ "version": "0.354.0",
4
+ "type": "breaking",
5
+ "title": "createTenantFeature fails the boot when a declared assignable app role is missing from its assignableAppRoles option",
6
+ "detail": "Final review batch E1: bundled features\n`createTenantFeature` now fails the boot when an `assignableRole` declaration is missing from its `assignableAppRoles` option, because the members and invite screens would otherwise omit the role and a save of the member-roles form would silently strip it. `composeFeatures(includeBundled)` already passes the option. Agent-callable handlers that change access or books now resolve risk `high`: `tenant:write:disable`, `tenant:write:updateMemberRoles`, `user:write:user:update`, `tier-engine:write:set-tenant-tier`, `ledger:write:create-transaction` and `ledger:write:reverse-transaction`. `in_app_messages` gets a `(tenant_id, user_id, created_at)` index. `GET /files/:id/download-url` no longer puts a personal `fileName` into the signed URL; the hint carries a neutral name derived from the MIME type, while `GET /files/:id` still sends the real name in its response header. The forget-subject denial audit event goes through the shared `appendDomainEventCore`, which the pipeline barrel now exports.",
7
+ "migration": "Pass assignableAppRoles: collectAssignableAppRoles(appFeatures) to createTenantFeature when you mount it by hand; composeFeatures(includeBundled) already does."
8
+ },
9
+ {
10
+ "version": "0.354.0",
11
+ "type": "fix",
12
+ "title": "tenant:write:disable and tenant:write:updateMemberRoles resolve agent risk high"
13
+ },
2
14
  {
3
15
  "version": "0.353.0",
4
16
  "type": "breaking",
@@ -81,8 +81,15 @@ export function createTenantFeature(options?: TenantFeatureOptions): FeatureDefi
81
81
  r.systemScope();
82
82
  r.requires("config");
83
83
  r.extendsRegistrar(EXT_ASSIGNABLE_ROLE, {});
84
+ // The elevation guards read the registry, the screens only these options: a role the
85
+ // server accepts but the screen omits is silently stripped on the next member-roles save.
84
86
  r.bootCheck(({ features }) => {
85
- collectAssignableAppRoles(features);
87
+ for (const [role, assignableFrom] of collectAssignableAppRoles(features)) {
88
+ if (options?.assignableAppRoles?.get(role) === assignableFrom) continue;
89
+ throw new Error(
90
+ `[tenant] assignable app role "${role}" (assignableFrom "${assignableFrom}") is declared but missing from the tenant screens' options; pass assignableAppRoles: collectAssignableAppRoles(appFeatures) to createTenantFeature.`,
91
+ );
92
+ }
86
93
  });
87
94
  r.entity("tenant", tenantEntity);
88
95
  r.entity("tenant-membership", tenantMembershipEntity);
@@ -14,6 +14,8 @@ function createToggleTenantHandler(enable: boolean) {
14
14
  name: verbName,
15
15
  schema: z.object({ id: z.uuid() }),
16
16
  access: { roles: ["SystemAdmin"] },
17
+ // Disabling locks every member out; enabling is its undo.
18
+ ...(enable ? {} : { agent: { risk: "high" as const } }),
17
19
  description: enable
18
20
  ? "Re-enables a disabled tenant so its members can sign in and switch to it again; this is the undo for tenant:write:disable."
19
21
  : "Marks a tenant disabled so it stops counting as a membership — its members can no longer sign in to it or switch to it — while leaving all of its data in place.",
@@ -72,6 +72,7 @@ export const updateMemberRolesWrite = defineWriteHandler({
72
72
  access: { roles: ["system", ...access.admin] },
73
73
  description:
74
74
  "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.",
75
+ agent: { risk: "high" },
75
76
  handler: async (event, ctx) => {
76
77
  if (!ctx.systemDb) {
77
78
  throw new InternalError({
@@ -26,6 +26,7 @@ import { buildEntityTable } from "@cosmicdrift/kumiko-framework/db";
26
26
  import {
27
27
  createEntity,
28
28
  createMultiSelectField,
29
+ createNumberField,
29
30
  createSelectField,
30
31
  createTextField,
31
32
  createTimestampField,
@@ -94,6 +95,11 @@ export const tenantInvitationEntity = createEntity({
94
95
  personal: { of: "invitedBy" },
95
96
  find: "exact",
96
97
  }),
98
+ // Version of the invitee's membership row when the invitation was issued
99
+ // (0 = no membership yet). Accept compares it against the current version
100
+ // to detect a membership decision made after the invite. NULL only on rows
101
+ // issued before this field existed.
102
+ membershipVersion: createNumberField({ integer: true, min: 0 }),
97
103
  // UI-Anzeige — Wahrheit liegt in Redis-TTL.
98
104
  expiresAt: createTimestampField({ required: true }),
99
105
  },