@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
@@ -114,7 +114,7 @@ export function platformOverviewPanels(metrics) {
114
114
  }),
115
115
  recentFailuresList(JobQueries.list, { status: "failed", sort: "startedAt", sortDirection: "desc", limit: 5 }, [
116
116
  { field: "jobName", label: `${I}.col.job` },
117
- { field: "startedAt", label: `${I}.col.startedAt` },
117
+ { field: "startedAt", label: `${I}.col.startedAt`, display: "datetime" },
118
118
  ], "full"),
119
119
  ];
120
120
  }
@@ -161,7 +161,7 @@ export function tenantOverviewPanels(metrics, options) {
161
161
  recentFailuresList(JobQueries.failures, { limit: 5 }, [
162
162
  { field: "jobName", label: `${I}.col.job` },
163
163
  { field: "messageKey", label: `${I}.col.reason` },
164
- { field: "failedAt", label: `${I}.col.failedAt` },
164
+ { field: "failedAt", label: `${I}.col.failedAt`, display: "datetime" },
165
165
  ], "half"),
166
166
  ];
167
167
  }
@@ -1,5 +1,6 @@
1
1
  import type { FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
2
2
  export declare const AgentDocGapKinds: {
3
+ readonly handlerSchemaNotExpressible: "handler-schema-not-expressible";
3
4
  readonly handlerWithoutDescription: "handler-without-description";
4
5
  readonly customScreenWithoutDescription: "custom-screen-without-description";
5
6
  readonly exposedEntityWithoutDescription: "exposed-entity-without-description";
@@ -1,5 +1,7 @@
1
1
  import { isAgentVisibleScreen, QnTypes, qn, resolveAgentExposure, toKebab, } from "@cosmicdrift/kumiko-framework/engine";
2
+ import * as z from "zod";
2
3
  export const AgentDocGapKinds = {
4
+ handlerSchemaNotExpressible: "handler-schema-not-expressible",
3
5
  handlerWithoutDescription: "handler-without-description",
4
6
  customScreenWithoutDescription: "custom-screen-without-description",
5
7
  exposedEntityWithoutDescription: "exposed-entity-without-description",
@@ -15,16 +17,44 @@ const ENTITY_QN_SEGMENT = "entity";
15
17
  function compareByCodePoint(a, b) {
16
18
  return a < b ? -1 : a > b ? 1 : 0;
17
19
  }
20
+ // agent-manifest.ts silently drops an exposed handler whose schema can't be
21
+ // converted; surface it here so the missing tool doesn't go unnoticed.
22
+ function schemaNotExpressibleGap(feature, handlerQn, def, handlerNoun) {
23
+ try {
24
+ z.toJSONSchema(def.schema, { io: "input" });
25
+ return undefined;
26
+ }
27
+ catch (error) {
28
+ return {
29
+ qn: handlerQn,
30
+ feature: feature.name,
31
+ kind: AgentDocGapKinds.handlerSchemaNotExpressible,
32
+ message: `This ${handlerNoun} is exposed to the AI agent but its input schema can't be converted to JSON Schema (${error instanceof Error ? error.message : String(error)}), so it is left out of the agent manifest.`,
33
+ };
34
+ }
35
+ }
18
36
  function handlerDocGaps(feature, handlers, handlerQnType, handlerNoun) {
19
37
  const gaps = [];
38
+ const handlerKind = handlerQnType === QnTypes.write ? "write" : "query";
20
39
  for (const [name, def] of Object.entries(handlers)) {
21
- if (def.description !== undefined || def.agent?.expose === false)
40
+ const handlerQn = qn(toKebab(feature.name), handlerQnType, toKebab(name));
41
+ const isExposed = resolveAgentExposure(def, handlerKind).expose;
42
+ if (isExposed) {
43
+ const schemaGap = schemaNotExpressibleGap(feature, handlerQn, def, handlerNoun);
44
+ if (schemaGap)
45
+ gaps.push(schemaGap);
46
+ }
47
+ if (def.agent?.expose === false)
48
+ continue;
49
+ if (def.description !== undefined && def.description.trim() !== "")
22
50
  continue;
23
51
  gaps.push({
24
- qn: qn(toKebab(feature.name), handlerQnType, toKebab(name)),
52
+ qn: handlerQn,
25
53
  feature: feature.name,
26
54
  kind: AgentDocGapKinds.handlerWithoutDescription,
27
- message: `This ${handlerNoun} has no description, so it stays invisible to the AI agent — set \`description\` to expose it, or \`agent: { expose: false }\` to opt out deliberately.`,
55
+ message: isExposed
56
+ ? `This ${handlerNoun} is exposed to the AI agent but has no usable description, so the agent sees an empty tool text — set a non-empty \`description\`, or \`agent: { expose: false }\` to opt out deliberately.`
57
+ : `This ${handlerNoun} has no description, so it stays invisible to the AI agent — set \`description\` to expose it, or \`agent: { expose: false }\` to opt out deliberately.`,
28
58
  });
29
59
  }
30
60
  return gaps;
@@ -1,4 +1,4 @@
1
- import { hasAccess, isAgentVisibleScreen, resolveAgentExposure, } from "@cosmicdrift/kumiko-framework/engine";
1
+ import { hasAccess, isAgentVisibleScreen, resolveAgentExposure, SYSTEM_ONLY_JSON_SCHEMA_KEY, } from "@cosmicdrift/kumiko-framework/engine";
2
2
  import { translationValueOtherText } from "@cosmicdrift/kumiko-framework/ui-types";
3
3
  import * as z from "zod";
4
4
  /** The manifest surfaces one string per locale — a plural entry reads as its
@@ -92,6 +92,24 @@ function buildFeatures(features) {
92
92
  }
93
93
  return result;
94
94
  }
95
+ // Agent dispatch never runs as a system identity, so properties the engine only
96
+ // honors for system callers would be silently dropped — hide them from the tool.
97
+ function withoutSystemOnlyProperties(jsonSchema) {
98
+ const properties = jsonSchema["properties"];
99
+ if (typeof properties !== "object" || properties === null)
100
+ return jsonSchema;
101
+ const hidden = Object.entries(properties)
102
+ .filter(([, property]) => property[SYSTEM_ONLY_JSON_SCHEMA_KEY] === true)
103
+ .map(([name]) => name);
104
+ if (hidden.length === 0)
105
+ return jsonSchema;
106
+ const required = jsonSchema["required"];
107
+ return {
108
+ ...jsonSchema,
109
+ properties: Object.fromEntries(Object.entries(properties).filter(([name]) => !hidden.includes(name))),
110
+ ...(Array.isArray(required) && { required: required.filter((name) => !hidden.includes(name)) }),
111
+ };
112
+ }
95
113
  function buildHandlerEntry(qn, kind, def, roles, getHandlerEntity, denyQns) {
96
114
  if (denyQns.has(qn))
97
115
  return undefined;
@@ -108,7 +126,7 @@ function buildHandlerEntry(qn, kind, def, roles, getHandlerEntity, denyQns) {
108
126
  // A handler whose schema can't be expressed as JSON Schema (transforms,
109
127
  // z.instanceof, ...) is unusable for an agent tool call — leave it out
110
128
  // rather than ship a broken tool.
111
- inputSchema = z.toJSONSchema(def.schema, { io: "input" });
129
+ inputSchema = withoutSystemOnlyProperties(z.toJSONSchema(def.schema, { io: "input" }));
112
130
  }
113
131
  catch {
114
132
  return undefined;
@@ -137,6 +155,16 @@ function buildHandlers(queryHandlers, writeHandlers, roles, getHandlerEntity, de
137
155
  }
138
156
  return result;
139
157
  }
158
+ // A typo or a QN renamed by a framework update would otherwise deny nothing
159
+ // and leave the handler exposed without anyone noticing.
160
+ function assertDenyQnsExist(denyQns, registry) {
161
+ const queryHandlers = registry.getAllQueryHandlers();
162
+ const writeHandlers = registry.getAllWriteHandlers();
163
+ const unknown = denyQns.filter((qn) => !queryHandlers.has(qn) && !writeHandlers.has(qn));
164
+ if (unknown.length > 0) {
165
+ throw new Error(`agent denyQns names handlers that are not registered: ${unknown.join(", ")}`);
166
+ }
167
+ }
140
168
  function buildNavs(navs, workspaces, translations, roles, screens) {
141
169
  const result = [];
142
170
  for (const [, nav] of navs) {
@@ -159,13 +187,25 @@ function buildNavs(navs, workspaces, translations, roles, screens) {
159
187
  workspaces: visibleWorkspaces,
160
188
  });
161
189
  }
162
- return result;
190
+ return withoutOrphanedNavs(result);
191
+ }
192
+ // A child under a parent hidden from this role would point at an id that
193
+ // isn't in the manifest; hiding cascades down the whole subtree.
194
+ function withoutOrphanedNavs(navs) {
195
+ let remaining = navs;
196
+ for (;;) {
197
+ const ids = new Set(remaining.map((nav) => nav.id));
198
+ const reachable = remaining.filter((nav) => nav.parent === undefined || ids.has(nav.parent));
199
+ if (reachable.length === remaining.length)
200
+ return remaining;
201
+ remaining = reachable;
202
+ }
163
203
  }
164
204
  function lastSegment(qualifiedId) {
165
205
  const idx = qualifiedId.lastIndexOf(":");
166
206
  return idx === -1 ? qualifiedId : qualifiedId.slice(idx + 1);
167
207
  }
168
- function buildScreens(screens, navs, workspaces, translations, roles) {
208
+ function buildScreens(screens, navs, workspaces, translations, roles, visibleNavIds) {
169
209
  const allNavs = [...navs.values()];
170
210
  const result = [];
171
211
  for (const [, screen] of screens) {
@@ -174,7 +214,7 @@ function buildScreens(screens, navs, workspaces, translations, roles) {
174
214
  if (!isAgentVisibleScreen(screen))
175
215
  continue;
176
216
  const matchingNavs = allNavs.filter((nav) => nav.screen === screen.id);
177
- const accessibleNavs = matchingNavs.filter((nav) => uiVisible(nav.access, roles));
217
+ const accessibleNavs = matchingNavs.filter((nav) => visibleNavIds.has(nav.id) && uiVisible(nav.access, roles));
178
218
  // A screen with no nav pointing at it is a detail view, reached only via
179
219
  // navigation from a list row — never hidden by nav/workspace gating.
180
220
  const reachable = matchingNavs.length === 0 ||
@@ -229,12 +269,13 @@ export function buildAgentManifest(registry, options) {
229
269
  const workspaceMap = registry.getAllWorkspaces();
230
270
  const navMap = registry.getAllNavs();
231
271
  const { roles } = options;
272
+ assertDenyQnsExist(options.denyQns ?? [], registry);
232
273
  const features = buildFeatures(registry.features);
233
274
  const entities = buildEntities(registry.getAllEntities(), translations, (name) => registry.getSearchableFields(name));
234
275
  const handlers = buildHandlers(registry.getAllQueryHandlers(), registry.getAllWriteHandlers(), roles, (qn) => registry.getHandlerEntity(qn), new Set(options.denyQns ?? []));
235
276
  const screenMap = registry.getAllScreens();
236
277
  const navs = buildNavs(navMap, workspaceMap, translations, roles, screenMap);
237
- const screens = buildScreens(screenMap, navMap, workspaceMap, translations, roles);
278
+ const screens = buildScreens(screenMap, navMap, workspaceMap, translations, roles, new Set(navs.map((nav) => nav.id)));
238
279
  const workspaces = buildWorkspaces(workspaceMap, translations, roles);
239
280
  return {
240
281
  builtForRoles: [...roles],
@@ -0,0 +1,2 @@
1
+ export declare const FILTER_OPS: readonly ["eq", "ne", "lt", "gt", "lte", "gte", "in"];
2
+ export declare function isRecord(value: unknown): value is Record<string, unknown>;
@@ -0,0 +1,4 @@
1
+ export const FILTER_OPS = ["eq", "ne", "lt", "gt", "lte", "gte", "in"];
2
+ export function isRecord(value) {
3
+ return typeof value === "object" && value !== null && !Array.isArray(value);
4
+ }
@@ -1,5 +1,5 @@
1
1
  import { hasAccess } from "@cosmicdrift/kumiko-framework/engine";
2
- const FILTER_OPS = ["eq", "ne", "lt", "gt", "lte", "gte", "in"];
2
+ import { FILTER_OPS, isRecord } from "./filter-ops.js";
3
3
  const MAX_TOOL_NAME_LENGTH = 64;
4
4
  /** Field types that declare `filterable` (per `packages/framework/src/engine/types/fields.ts`).
5
5
  * Mapped to the JSON-Schema type an LLM tool-call argument should use. `undefined` = field type
@@ -173,9 +173,6 @@ function filterableFieldsOf(entity) {
173
173
  function compareByCodePoint(a, b) {
174
174
  return a < b ? -1 : a > b ? 1 : 0;
175
175
  }
176
- function isRecord(value) {
177
- return typeof value === "object" && value !== null && !Array.isArray(value);
178
- }
179
176
  function schemaRequiresField(schema, field) {
180
177
  const required = schema["required"];
181
178
  return Array.isArray(required) && required.includes(field);
@@ -1,11 +1,8 @@
1
1
  import { runAsDirectCallEntry } from "@cosmicdrift/kumiko-framework/api";
2
+ import { FILTER_OPS, isRecord } from "./filter-ops.js";
2
3
  const SEARCH_RESULT_LIMIT = 10;
3
4
  const DEFAULT_LIST_LIMIT = 10;
4
5
  const MAX_LIST_LIMIT = 200;
5
- const FILTER_OPS = ["eq", "ne", "lt", "gt", "lte", "gte", "in"];
6
- function isRecord(value) {
7
- return typeof value === "object" && value !== null && !Array.isArray(value);
8
- }
9
6
  function isFilterOp(value) {
10
7
  return typeof value === "string" && FILTER_OPS.includes(value);
11
8
  }
@@ -1,7 +1,7 @@
1
1
  export declare const AUDIT_FEATURE: "audit";
2
2
  /** Literal `createdBy` written by hand-built system actors (secrets/jobs/…). */
3
3
  export declare const SYSTEM_ACTOR_ID: "system";
4
- /** Literal `createdBy` for anonymous / unauthenticated actors. */
4
+ /** Literal `createdBy` for anonymous / unauthenticated actors. Duplicated from `/engine` on purpose: a value import would pull the engine into the client bundle; constants.test.ts guards against drift. */
5
5
  export declare const ANONYMOUS_USER_ID: "anonymous";
6
6
  /** All `createdBy` values that mean "system" in the audit UI (nil-UUID from createSystemUser + literal). */
7
7
  export declare const SYSTEM_ACTOR_IDS: ReadonlySet<string>;
@@ -3,7 +3,7 @@
3
3
  export const AUDIT_FEATURE = "audit";
4
4
  /** Literal `createdBy` written by hand-built system actors (secrets/jobs/…). */
5
5
  export const SYSTEM_ACTOR_ID = "system";
6
- /** Literal `createdBy` for anonymous / unauthenticated actors. */
6
+ /** Literal `createdBy` for anonymous / unauthenticated actors. Duplicated from `/engine` on purpose: a value import would pull the engine into the client bundle; constants.test.ts guards against drift. */
7
7
  export const ANONYMOUS_USER_ID = "anonymous";
8
8
  /** All `createdBy` values that mean "system" in the audit UI (nil-UUID from createSystemUser + literal). */
9
9
  export const SYSTEM_ACTOR_IDS = new Set([
@@ -20,6 +20,8 @@ export const changePasswordWrite = defineWriteHandler({
20
20
  "and writes the new hash via ctx.writeAs(SYSTEM, user:update) — the caller's own " +
21
21
  "identity can't reach either, field-access on passwordHash is privileged-only.",
22
22
  },
23
+ // Payload carries the old and new password.
24
+ agent: { expose: false },
23
25
  description: "Replaces the signed-in caller's own password after re-checking the current one; use it for a self-service password change rather than the emailed reset flow.",
24
26
  handler: async (event, ctx) => {
25
27
  const systemUser = createSystemUser(event.user.tenantId);
@@ -18,7 +18,7 @@
18
18
  // invite-create.
19
19
  import { generateToken } from "@cosmicdrift/kumiko-framework/api";
20
20
  import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
21
- import { access, assignableAppRolesFromUsages, defineWriteHandler, EXT_ASSIGNABLE_ROLE, } from "@cosmicdrift/kumiko-framework/engine";
21
+ import { access, assignableAppRolesOf, defineWriteHandler, } from "@cosmicdrift/kumiko-framework/engine";
22
22
  import { InternalError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
23
23
  import { Temporal } from "temporal-polyfill";
24
24
  import * as z from "zod";
@@ -38,7 +38,17 @@ const InviteCreateSchema = z.object({
38
38
  const executor = createEventStoreExecutor(tenantInvitationsTable, tenantInvitationEntity, {
39
39
  entityName: "tenant-invitation",
40
40
  });
41
+ // A framework-ranked role is one the highest actor ("system") may assign without the
42
+ // unknown-role fail-closed rejection; allow-listing it would let a lower-ranked inviter
43
+ // bypass the elevation guard for it.
44
+ function findRankedRole(roles) {
45
+ return roles.find((role) => findForbiddenRoleAssignment(["system"], [role], []) === undefined);
46
+ }
41
47
  export function createInviteCreateHandler(opts) {
48
+ const rankedRole = findRankedRole(opts.additionalAssignableRoles ?? []);
49
+ if (rankedRole !== undefined) {
50
+ throw new Error(`[auth-email-password] invite.additionalAssignableRoles must list app-defined roles only; "${rankedRole}" is a framework-ranked role`);
51
+ }
42
52
  const ttlMinutes = opts.tokenTtlMinutes ?? AUTH_INVITE_DEFAULT_TTL_MINUTES;
43
53
  const ttlSeconds = ttlMinutes * 60;
44
54
  return defineWriteHandler({
@@ -54,7 +64,7 @@ export function createInviteCreateHandler(opts) {
54
64
  if (forbiddenRole !== undefined) {
55
65
  return writeFailure(reservedMembershipRoleError(forbiddenRole));
56
66
  }
57
- const elevationForbidden = findForbiddenRoleAssignment(event.user.roles, [event.payload.role], [], assignableAppRolesFromUsages(ctx.registry.getExtensionUsages(EXT_ASSIGNABLE_ROLE)));
67
+ const elevationForbidden = findForbiddenRoleAssignment(event.user.roles, [event.payload.role], [], assignableAppRolesOf(ctx.registry));
58
68
  if (elevationForbidden !== undefined &&
59
69
  !opts.additionalAssignableRoles?.includes(elevationForbidden)) {
60
70
  return writeFailure(unassignableMembershipRoleError(elevationForbidden));
@@ -157,6 +157,8 @@ export function createLoginHandler(opts = {}) {
157
157
  "enrollment of the tenant named in the signed login/setup token, not the guest " +
158
158
  "dispatch tenant, via the mfaStatusChecker callback.",
159
159
  },
160
+ // Anonymous credential intake (password) — must never enter an LLM transcript.
161
+ agent: { expose: false },
160
162
  description: "Signs a user in with email and password, running the lockout, email-verification, account-status, tenant-membership and MFA gates, and answering with a session or with an MFA challenge or setup requirement.",
161
163
  handler: async (event, ctx) => {
162
164
  const systemUser = createSystemUser(SYSTEM_USER_ID);
@@ -13,7 +13,7 @@ export const selfRegistrationStatusQuery = defineQueryHandler({
13
13
  name: "signup-registration-status",
14
14
  schema: z.object({}),
15
15
  access: { roles: ["anonymous", "User", "TenantAdmin", "SystemAdmin"] },
16
- rateLimit: { per: "ip", limit: 30, windowSeconds: 60 },
16
+ rateLimit: { per: "ip+handler", limit: 30, windowSeconds: 60 },
17
17
  description: "Reports whether self-registration is currently switched on, readable without signing in, so a signup page can hide its own form instead of collecting an address that would be silently ignored.",
18
18
  handler: async (_query, ctx) => {
19
19
  // Same fail-open reasoning as signup-request.write.ts (#1468): only
@@ -15,6 +15,7 @@ export const defaultTranslations = {
15
15
  "auth.login.submitting": "…",
16
16
  "auth.login.forgotPassword": "Forgot password?",
17
17
  "auth.login.unlockAccount": "Unlock account?",
18
+ "auth.login.sessionEnded": "Your session has ended. Please sign in again.",
18
19
  "auth.login.resendVerification": "Send verification email again",
19
20
  "auth.login.resendSuccess": "We've sent you a new verification email.",
20
21
  "auth.login.resendRateLimited": "Please wait a moment and try again.",
@@ -119,6 +120,7 @@ export const defaultTranslations = {
119
120
  "auth.sessionBootstrap.rateLimitedBody": "Too many requests in a short time. Please wait a moment and try again.",
120
121
  "auth.sessionBootstrap.retry": "Try again",
121
122
  "auth.sessionBootstrap.retrying": "Retrying…",
123
+ "auth.sessionBootstrap.signOut": "Sign out",
122
124
  "auth.user.menu.label": "Account",
123
125
  "auth.user.menu.logout": "Sign out",
124
126
  "auth.tenant.switcher.label": "Tenant",
@@ -45,6 +45,7 @@ export type LoginResult = {
45
45
  readonly kind: "failure";
46
46
  readonly error: LoginFailure;
47
47
  };
48
+ export declare function toLoginResponse(token: string, user: LoginResponse["user"], landingPath: unknown): LoginResponse;
48
49
  export declare function login(req: LoginRequest): Promise<LoginResult>;
49
50
  export declare function logout(): Promise<void>;
50
51
  export type AuthTokenFailure = {
@@ -33,7 +33,7 @@ export function parseRetryAfterSeconds(header) {
33
33
  return undefined;
34
34
  return Number(header);
35
35
  }
36
- function toLoginResponse(token, user, landingPath) {
36
+ export function toLoginResponse(token, user, landingPath) {
37
37
  return typeof landingPath === "string" ? { token, user, landingPath } : { token, user };
38
38
  }
39
39
  // POST /api/auth/login. Success → token + user; MFA-enrolled user → a
@@ -30,7 +30,7 @@ export function createLoginRoute(opts = {}) {
30
30
  const MfaVerifyComponent = opts.mfaVerifyScreen;
31
31
  const MfaSetupComponent = opts.mfaSetupScreen;
32
32
  function LoginRoute() {
33
- const { status, refresh, bootstrapFailure } = useSession();
33
+ const { status, refresh, bootstrapFailure, logout } = useSession();
34
34
  const { onAuthenticated } = opts;
35
35
  // Pending challenge-token from LoginScreen's onMfaChallenge. Lives here
36
36
  // (not in SessionState) because it's a UI-only transition — the server
@@ -46,7 +46,7 @@ export function createLoginRoute(opts = {}) {
46
46
  return (_jsx("div", { className: "min-h-screen flex items-center justify-center text-muted-foreground text-sm" }));
47
47
  }
48
48
  if (status === "error" && bootstrapFailure !== null) {
49
- return _jsx(SessionBootstrapErrorScreen, { failure: bootstrapFailure, onRetry: refresh });
49
+ return (_jsx(SessionBootstrapErrorScreen, { failure: bootstrapFailure, onRetry: refresh, onSignOut: logout }));
50
50
  }
51
51
  // A standalone mount (no parent gate, no onAuthenticated wired — e.g. an
52
52
  // apex/marketing surface that just places <LoginRoute /> directly) has no
@@ -65,12 +65,10 @@ export function createLoginRoute(opts = {}) {
65
65
  return (_jsx(MfaSetupComponent, { preauthSetupToken: setupRequest.preauthSetupToken, accountLabel: setupRequest.accountLabel, onSuccess: () => {
66
66
  // MfaSetupPreauthScreen has no session to refresh itself with
67
67
  // (it runs pre-auth) — the gate owns the session, so it refreshes.
68
- // Keep setupRequest until refresh settles: clearing first would
69
- // dump the user back on the login form with no retry if refresh
70
- // rejects (cookie+JWT already minted server-side).
71
- void refresh().then(() => setSetupRequest(null), () => {
72
- // leave setupRequest set so the user can cancel/retry
73
- });
68
+ // refresh() never rejects: a failed refresh surfaces as status
69
+ // "error" (bootstrap error screen with retry), so only the
70
+ // success path needs to clear setupRequest.
71
+ void refresh().then(() => setSetupRequest(null));
74
72
  }, onCancel: () => setSetupRequest(null) }));
75
73
  }
76
74
  return (_jsx(LoginComponent, { ...opts.loginScreenProps, onMfaChallenge: MfaVerifyComponent ? setChallengeToken : opts.loginScreenProps?.onMfaChallenge, onMfaSetupRequired: MfaSetupComponent
@@ -1,6 +1,6 @@
1
1
  export { defaultTranslations, mergeTranslations } from "../i18n.js";
2
2
  export type { AuthTokenFailure, CurrentUserProfile, LoginFailure, LoginRequest, LoginResponse, ResetPasswordFailure, SignupConfirmSuccess, TenantSummary, VerifyEmailFailure, } from "./auth-client.js";
3
- export { AuthRequestError, confirmAccountUnlock, confirmSignup, requestAccountUnlock, requestEmailVerification, requestPasswordReset, requestSignup, resetPassword, verifyEmail, } from "./auth-client.js";
3
+ export { AuthRequestError, confirmAccountUnlock, confirmSignup, requestAccountUnlock, requestEmailVerification, requestPasswordReset, requestSignup, resetPassword, toLoginResponse, verifyEmail, } from "./auth-client.js";
4
4
  export type { AuthCardProps, AuthShellRenderer } from "./auth-form-primitives.js";
5
5
  export { AuthCard, AuthShellProvider, useAuthShell } from "./auth-form-primitives.js";
6
6
  export type { LoginRouteOptions, MfaSetupComponentProps, MfaVerifyComponentProps, } from "./auth-gate.js";
@@ -5,7 +5,7 @@
5
5
  // `@cosmicdrift/kumiko-bundled-features/auth-email-password` und hat keine
6
6
  // React-/DOM-Deps. Trennung bleibt sauber so wie renderer vs renderer-web.
7
7
  export { defaultTranslations, mergeTranslations } from "../i18n.js";
8
- export { AuthRequestError, confirmAccountUnlock, confirmSignup, requestAccountUnlock, requestEmailVerification, requestPasswordReset, requestSignup, resetPassword, verifyEmail, } from "./auth-client.js";
8
+ export { AuthRequestError, confirmAccountUnlock, confirmSignup, requestAccountUnlock, requestEmailVerification, requestPasswordReset, requestSignup, resetPassword, toLoginResponse, verifyEmail, } from "./auth-client.js";
9
9
  export { AuthCard, AuthShellProvider, useAuthShell } from "./auth-form-primitives.js";
10
10
  export { createLoginRoute, makeAuthGate, makeSessionAuthGate } from "./auth-gate.js";
11
11
  export { emailPasswordClient } from "./client-plugin.js";
@@ -42,6 +42,19 @@ export function InviteAcceptScreen({ title, token: tokenProp, loggedInHref = "/"
42
42
  const [submitting, setSubmitting] = useState(false);
43
43
  const [error, setError] = useState(null);
44
44
  const effectiveTitle = title ?? t("auth.inviteAccept.title");
45
+ // The invitation is already consumed when the server answers with an MFA
46
+ // challenge / setup requirement, but no session cookie was minted: send the
47
+ // user to the login screen (now a member) instead of a tenant URL.
48
+ const finishAccept = (data) => {
49
+ if (data.mfaRequired === true || data.mfaSetupRequired === true) {
50
+ window.location.assign(loginHref);
51
+ return;
52
+ }
53
+ window.location.assign(resolvePostAuthHref(data.landingPath, loggedInHref, {
54
+ tenantId: data.tenantId,
55
+ roles: grantedRoles(data),
56
+ }));
57
+ };
45
58
  const acceptLoggedIn = async () => {
46
59
  setSubmitting(true);
47
60
  setError(null);
@@ -56,10 +69,7 @@ export function InviteAcceptScreen({ title, token: tokenProp, loggedInHref = "/"
56
69
  if (res.ok) {
57
70
  // @cast-boundary engine-payload — auth route JSON
58
71
  const data = (await res.json());
59
- window.location.assign(resolvePostAuthHref(data.landingPath, loggedInHref, {
60
- tenantId: data.tenantId,
61
- roles: grantedRoles(data),
62
- }));
72
+ finishAccept(data);
63
73
  return;
64
74
  }
65
75
  setError(t("auth.errors.invalidInviteToken"));
@@ -78,10 +88,7 @@ export function InviteAcceptScreen({ title, token: tokenProp, loggedInHref = "/"
78
88
  if (res.ok) {
79
89
  // @cast-boundary engine-payload — auth route JSON
80
90
  const data = (await res.json());
81
- window.location.assign(resolvePostAuthHref(data.landingPath, loggedInHref, {
82
- tenantId: data.tenantId,
83
- roles: grantedRoles(data),
84
- }));
91
+ finishAccept(data);
85
92
  return;
86
93
  }
87
94
  setError(t("auth.errors.invalidInviteToken"));
@@ -100,10 +107,7 @@ export function InviteAcceptScreen({ title, token: tokenProp, loggedInHref = "/"
100
107
  if (res.ok) {
101
108
  // @cast-boundary engine-payload — auth route JSON
102
109
  const data = (await res.json());
103
- window.location.assign(resolvePostAuthHref(data.landingPath, loggedInHref, {
104
- tenantId: data.tenantId,
105
- roles: grantedRoles(data),
106
- }));
110
+ finishAccept(data);
107
111
  return;
108
112
  }
109
113
  setError(t("auth.errors.invalidInviteToken"));
@@ -113,7 +113,7 @@ export function LoginScreen({ title, subtitle, submitLabel, forgotPasswordHref,
113
113
  };
114
114
  const effectiveTitle = title ?? t("auth.login.title");
115
115
  const effectiveSubmit = submitLabel ?? t("auth.login.submit");
116
- return (_jsx(AuthCard, { title: effectiveTitle, subtitle: subtitle, children: _jsxs("div", { className: "p-6 pt-0 flex flex-col gap-4", children: [_jsxs(Form, { onSubmit: onSubmit, children: [_jsx(Field, { id: "login-email", label: t("auth.login.email"), required: true, children: _jsx(Input, { kind: "email", id: "login-email", name: "login-email", value: email, onChange: setEmail, disabled: submitting, required: true, autoComplete: "email" }) }), _jsx(Field, { id: "login-password", label: t("auth.login.password"), required: true, children: _jsx(Input, { kind: "password", id: "login-password", name: "login-password", value: password, onChange: setPassword, disabled: submitting, required: true, autoComplete: "current-password" }) }), resendStatus.kind === "success" ? (_jsx(Banner, { variant: "info", children: t("auth.login.resendSuccess") })) : error !== null ? (_jsx(Banner, { variant: "error", children: _jsxs("div", { className: "flex flex-col gap-1", children: [_jsx("span", { children: (() => {
116
+ return (_jsx(AuthCard, { title: effectiveTitle, subtitle: subtitle, children: _jsxs("div", { className: "p-6 pt-0 flex flex-col gap-4", children: [_jsxs(Form, { onSubmit: onSubmit, children: [_jsx(Field, { id: "login-email", label: t("auth.login.email"), required: true, children: _jsx(Input, { kind: "email", id: "login-email", name: "login-email", value: email, onChange: setEmail, disabled: submitting, required: true, autoComplete: "email" }) }), _jsx(Field, { id: "login-password", label: t("auth.login.password"), required: true, children: _jsx(Input, { kind: "password", id: "login-password", name: "login-password", value: password, onChange: setPassword, disabled: submitting, required: true, autoComplete: "current-password" }) }), session.signedOutReason === "session-ended" && (_jsx(Banner, { variant: "info", children: t("auth.login.sessionEnded") })), resendStatus.kind === "success" ? (_jsx(Banner, { variant: "info", children: t("auth.login.resendSuccess") })) : error !== null ? (_jsx(Banner, { variant: "error", children: _jsxs("div", { className: "flex flex-col gap-1", children: [_jsx("span", { children: (() => {
117
117
  const { key, params } = reasonToKey(error);
118
118
  return t(key, params);
119
119
  })() }), error.reason === "email_not_verified" &&
@@ -3,7 +3,11 @@ import type { SessionBootstrapFailure } from "./session.js";
3
3
  export type SessionBootstrapErrorScreenProps = {
4
4
  readonly failure: SessionBootstrapFailure;
5
5
  readonly onRetry: () => Promise<void>;
6
+ /** A permanent bootstrap failure (e.g. 403/500 on the membership query) would
7
+ * otherwise trap the user: the HttpOnly session cookie can only be cleared
8
+ * server-side. */
9
+ readonly onSignOut?: () => Promise<void>;
6
10
  };
7
11
  /** Retry delay measured from failure time, not click time. */
8
12
  export declare function retryDelayMs(failure: SessionBootstrapFailure, nowEpochMs: number): number;
9
- export declare function SessionBootstrapErrorScreen({ failure, onRetry, }: SessionBootstrapErrorScreenProps): ReactNode;
13
+ export declare function SessionBootstrapErrorScreen({ failure, onRetry, onSignOut, }: SessionBootstrapErrorScreenProps): ReactNode;
@@ -14,7 +14,7 @@ function wait(ms) {
14
14
  return Promise.resolve();
15
15
  return new Promise((resolve) => setTimeout(resolve, ms));
16
16
  }
17
- export function SessionBootstrapErrorScreen({ failure, onRetry, }) {
17
+ export function SessionBootstrapErrorScreen({ failure, onRetry, onSignOut, }) {
18
18
  const t = useTranslation();
19
19
  const { Banner, Button } = usePrimitives();
20
20
  const [retrying, setRetrying] = useState(false);
@@ -26,5 +26,5 @@ export function SessionBootstrapErrorScreen({ failure, onRetry, }) {
26
26
  };
27
27
  return (_jsx("div", { "data-testid": "session-bootstrap-error", "data-http-status": failure.httpStatus ?? "network", children: _jsx(AuthCard, { title: t("auth.sessionBootstrap.errorTitle"), children: _jsxs("div", { className: "flex flex-col gap-4 px-6 pb-4", children: [_jsx(Banner, { variant: "error", children: failure.httpStatus === 429
28
28
  ? t("auth.sessionBootstrap.rateLimitedBody")
29
- : t("auth.sessionBootstrap.errorBody") }), _jsx(Button, { variant: "primary", onClick: handleRetry, disabled: retrying, children: retrying ? t("auth.sessionBootstrap.retrying") : t("auth.sessionBootstrap.retry") })] }) }) }));
29
+ : t("auth.sessionBootstrap.errorBody") }), _jsx(Button, { variant: "primary", onClick: handleRetry, disabled: retrying, children: retrying ? t("auth.sessionBootstrap.retrying") : t("auth.sessionBootstrap.retry") }), onSignOut !== undefined && (_jsx(Button, { variant: "secondary", onClick: () => void onSignOut(), disabled: retrying, children: t("auth.sessionBootstrap.signOut") }))] }) }) }));
30
30
  }
@@ -18,6 +18,10 @@ export type SessionState = {
18
18
  * tenants[active].roles damit nav-filtering greift. Dedupliziert. */
19
19
  readonly roles: readonly string[];
20
20
  readonly bootstrapFailure: SessionBootstrapFailure | null;
21
+ /** Set when an authenticated session was ended server-side (revoked,
22
+ * expired) so the login screen can explain why it appeared. Optional so
23
+ * SessionApi values apps build themselves (test doubles) stay valid. */
24
+ readonly signedOutReason?: "session-ended" | null;
21
25
  };
22
26
  export type SessionApi = SessionState & {
23
27
  readonly login: (req: LoginRequest) => Promise<LoginResult>;
@@ -10,6 +10,7 @@ import { jsx as _jsx } from "react/jsx-runtime";
10
10
  // unter `<SessionProvider>`; der `useSession()`-Hook liefert den State
11
11
  // und die Transitions.
12
12
  import { readCsrfToken } from "@cosmicdrift/kumiko-dispatcher-live";
13
+ import { useSessionEndedSignal } from "@cosmicdrift/kumiko-renderer";
13
14
  import { createContext, useCallback, useContext, useEffect, useState } from "react";
14
15
  import { AuthRequestError, fetchCurrentUser, fetchTenants, login as loginApi, logout as logoutApi, switchTenant as switchTenantApi, } from "./auth-client.js";
15
16
  // Exported so screens that render on BOTH authenticated and anonymous public
@@ -23,6 +24,7 @@ export const UNAUTHENTICATED = {
23
24
  tenants: [],
24
25
  roles: [],
25
26
  bootstrapFailure: null,
27
+ signedOutReason: null,
26
28
  };
27
29
  const INITIAL = {
28
30
  status: "loading",
@@ -31,6 +33,7 @@ const INITIAL = {
31
33
  tenants: [],
32
34
  roles: [],
33
35
  bootstrapFailure: null,
36
+ signedOutReason: null,
34
37
  };
35
38
  // kumiko_auth ist HttpOnly — kumiko_csrf wird beim Login gemeinsam gesetzt.
36
39
  export function hasLikelyAuthSession(cookieSource) {
@@ -75,6 +78,7 @@ async function refresh() {
75
78
  tenants: tenants.tenants,
76
79
  roles: computeActiveRoles(user, tenants.activeTenantId, tenants.tenants),
77
80
  bootstrapFailure: null,
81
+ signedOutReason: null,
78
82
  };
79
83
  }
80
84
  // Non-AuthRequestError throws (offline, DNS, CORS) have no status, so httpStatus stays null.
@@ -104,6 +108,17 @@ export function SessionProvider({ children }) {
104
108
  useEffect(() => {
105
109
  void doRefresh();
106
110
  }, [doRefresh]);
111
+ const sessionEndedSignal = useSessionEndedSignal();
112
+ // Only an authenticated session can "end": an anonymous 401 on a public
113
+ // surface must not show the hint.
114
+ // kumiko-lint-ignore no-raw-hooks Phase-3 conversion tracked in #2312
115
+ useEffect(() => {
116
+ return sessionEndedSignal?.subscribe(() => {
117
+ setState((prev) => prev.status === "authenticated"
118
+ ? { ...UNAUTHENTICATED, signedOutReason: "session-ended" }
119
+ : prev);
120
+ });
121
+ }, [sessionEndedSignal]);
107
122
  const login = useCallback(async (req) => {
108
123
  const res = await loginApi(req);
109
124
  if (res.kind === "success") {
@@ -14,6 +14,7 @@ import { createMfaCodeVerifier } from "./mfa-code-verifier.js";
14
14
  import { createMfaStatusChecker } from "./mfa-status-checker.js";
15
15
  import { userMfaEntity } from "./schema/user-mfa.js";
16
16
  import { mfaDisableScreen, mfaEnableScreen, mfaRegenerateRecoveryScreen } from "./screens.js";
17
+ const REENCRYPT_ESCAPE_HATCH_REASON = "re-encrypts MFA secrets of every tenant's users with the new master key";
17
18
  // Reads the late-bind setter off a mounted auth-mfa feature's exports —
18
19
  // mirrors sessions' own bindAutoRevokeFromFeature. run{Prod,Dev}App call
19
20
  // this once the sessions feature (if mounted) has produced a concrete
@@ -102,9 +103,9 @@ export function createAuthMfaFeature(opts) {
102
103
  name: "reencrypt",
103
104
  trigger: { manual: true },
104
105
  escapeHatch: {
105
- reason: "re-encrypts MFA secrets of every tenant's users with the new master key",
106
+ reason: REENCRYPT_ESCAPE_HATCH_REASON,
106
107
  },
107
- handler: (payload, ctx) => mfaReencryptJob(payload, ctx, ctx.db.unsafeRaw("re-encrypts MFA secrets of every tenant's users with the new master key")),
108
+ handler: (payload, ctx) => mfaReencryptJob(payload, ctx, ctx.db.unsafeRaw(REENCRYPT_ESCAPE_HATCH_REASON)),
108
109
  });
109
110
  r.entity("user-mfa", userMfaEntity);
110
111
  // Late-bound by run-prod-app once the sessions feature (if mounted) has
@@ -42,8 +42,8 @@ export function createEnableConfirmPreauthHandler(opts) {
42
42
  "guest dispatch tenant.",
43
43
  },
44
44
  description: "Completes the enrollment that unblocks a sign-in forced into two-factor setup: verifies the code against the pre-auth setup token, stores the factor and derives the session the blocked login never got.",
45
- // Changes the caller's authentication state and mints a session.
46
- agent: { risk: "high" },
45
+ // Payload carries the pre-auth setup token and the TOTP code; the call mints a session.
46
+ agent: { expose: false },
47
47
  handler: async (event, ctx) => {
48
48
  const verify = verifyMfaSetupToken(event.payload.setupToken, opts.setupTokenSecret);
49
49
  if (!verify.ok)
@@ -33,6 +33,8 @@ export function createMfaVerifyHandler(opts) {
33
33
  "Also reads the MFA enrollment of the tenant named in the signed login/setup token, not the " +
34
34
  "guest dispatch tenant.",
35
35
  },
36
+ // Payload carries the TOTP/recovery code and the challenge token.
37
+ agent: { expose: false },
36
38
  description: "Finishes a two-step sign-in by checking a TOTP or recovery code against the challenge token that login handed back, under a per-account attempt cap, and derives the resulting session.",
37
39
  handler: async (event, ctx) => {
38
40
  const verified = verifyMfaChallengeToken(event.payload.challengeToken, opts.challengeTokenSecret);