@cosmicdrift/kumiko-bundled-features 0.388.0 → 0.389.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 (136) hide show
  1. package/dist/agent-tools/index.d.ts +1 -1
  2. package/dist/agent-tools/index.js +1 -1
  3. package/dist/agent-tools/tool-dispatch.d.ts +8 -3
  4. package/dist/agent-tools/tool-dispatch.js +36 -25
  5. package/dist/audit/feature.js +1 -0
  6. package/dist/auth-email-password/handlers/request-account-unlock.write.d.ts +1 -0
  7. package/dist/auth-email-password/handlers/request-account-unlock.write.js +1 -0
  8. package/dist/auth-email-password/handlers/request-email-verification.write.d.ts +1 -0
  9. package/dist/auth-email-password/handlers/request-email-verification.write.js +1 -0
  10. package/dist/auth-email-password/handlers/request-password-reset.write.d.ts +1 -0
  11. package/dist/auth-email-password/handlers/request-password-reset.write.js +1 -0
  12. package/dist/auth-email-password/handlers/signup-request.write.d.ts +1 -0
  13. package/dist/auth-email-password/handlers/signup-request.write.js +4 -0
  14. package/dist/auth-email-password/handlers/token-request-handler.d.ts +2 -0
  15. package/dist/auth-email-password/handlers/token-request-handler.js +5 -0
  16. package/dist/auth-email-password/magic-link-mail.d.ts +2 -0
  17. package/dist/auth-email-password/magic-link-mail.js +7 -3
  18. package/dist/auth-email-password/signup-activation.d.ts +1 -0
  19. package/dist/auth-email-password/signup-activation.js +1 -0
  20. package/dist/auth-email-password/web/auth-client.d.ts +2 -2
  21. package/dist/auth-email-password/web/auth-client.js +8 -4
  22. package/dist/auth-email-password/web/auth-redirect.d.ts +5 -1
  23. package/dist/auth-email-password/web/auth-redirect.js +20 -25
  24. package/dist/auth-email-password/web/default-auth-pages-gate.js +6 -3
  25. package/dist/auth-email-password/web/forgot-password-screen.d.ts +4 -1
  26. package/dist/auth-email-password/web/forgot-password-screen.js +7 -2
  27. package/dist/auth-email-password/web/index.d.ts +1 -0
  28. package/dist/auth-email-password/web/index.js +1 -0
  29. package/dist/auth-email-password/web/login-screen.js +11 -6
  30. package/dist/auth-email-password/web/reset-password-screen.js +7 -1
  31. package/dist/auth-email-password/web/return-path.d.ts +4 -0
  32. package/dist/auth-email-password/web/return-path.js +34 -0
  33. package/dist/auth-email-password/web/session.js +6 -1
  34. package/dist/auth-email-password/web/signup-complete-screen.js +11 -3
  35. package/dist/auth-email-password/web/signup-screen.d.ts +4 -1
  36. package/dist/auth-email-password/web/signup-screen.js +8 -3
  37. package/dist/auth-mfa/config.d.ts +1 -1
  38. package/dist/auth-mfa/config.js +3 -7
  39. package/dist/auth-mfa/mfa-status-checker.js +5 -3
  40. package/dist/cap-counter/cap-reservation.js +15 -6
  41. package/dist/cap-counter/feature.js +1 -0
  42. package/dist/cap-overview/feature.js +1 -0
  43. package/dist/config/handlers/set.write.js +12 -2
  44. package/dist/crypto-shredding/handlers/forget-subject.write.js +1 -1
  45. package/dist/custom-fields/lib/parse-serialized-field.js +1 -1
  46. package/dist/delivery/feature.js +1 -0
  47. package/dist/delivery/index.d.ts +1 -0
  48. package/dist/delivery/index.js +1 -0
  49. package/dist/delivery/system-channel-context.d.ts +15 -0
  50. package/dist/delivery/system-channel-context.js +5 -0
  51. package/dist/document-ingest-foundation/append-missing-extract-event.d.ts +21 -0
  52. package/dist/document-ingest-foundation/append-missing-extract-event.js +42 -0
  53. package/dist/document-ingest-foundation/events.d.ts +11 -0
  54. package/dist/document-ingest-foundation/events.js +11 -0
  55. package/dist/document-ingest-foundation/index.d.ts +2 -1
  56. package/dist/document-ingest-foundation/index.js +2 -1
  57. package/dist/feature-toggles/feature.js +1 -0
  58. package/dist/host-routing/validate-options.js +3 -0
  59. package/dist/inbound-mail-foundation/feature.js +1 -0
  60. package/dist/jobs/feature.js +1 -0
  61. package/dist/ledger/web/index.js +1 -1
  62. package/dist/legal-pages/seed.d.ts +3 -1
  63. package/dist/legal-pages/seed.js +2 -2
  64. package/dist/mail-transport-smtp/config-keys.js +1 -1
  65. package/dist/mail-transport-smtp/feature.js +6 -5
  66. package/dist/managed-pages/constants.d.ts +1 -0
  67. package/dist/managed-pages/constants.js +1 -0
  68. package/dist/managed-pages/feature.js +3 -1
  69. package/dist/managed-pages/index.d.ts +1 -0
  70. package/dist/managed-pages/index.js +1 -0
  71. package/dist/notes-history/handlers/add-note.write.js +1 -1
  72. package/dist/personal-access-tokens/feature.js +1 -0
  73. package/dist/secrets/write-gate.js +8 -2
  74. package/dist/sessions/feature.js +1 -0
  75. package/dist/sessions/index.d.ts +1 -1
  76. package/dist/sessions/index.js +1 -1
  77. package/dist/sessions/session-callbacks.d.ts +0 -2
  78. package/dist/sessions/session-callbacks.js +1 -3
  79. package/dist/shared/purge-files-reaching-subject.d.ts +1 -0
  80. package/dist/shared/purge-files-reaching-subject.js +19 -2
  81. package/dist/shared/run-in-sub-transaction.js +5 -5
  82. package/dist/step-dispatcher/deliver-dispatch-request.js +16 -3
  83. package/dist/step-dispatcher/mail-runner.d.ts +1 -0
  84. package/dist/tags/feature.js +1 -0
  85. package/dist/template-resolver/document-ops.d.ts +5 -0
  86. package/dist/template-resolver/document-ops.js +18 -2
  87. package/dist/template-resolver/feature.js +2 -2
  88. package/dist/template-resolver/handlers/layout-source.query.d.ts +4 -0
  89. package/dist/template-resolver/handlers/layout-source.query.js +26 -6
  90. package/dist/template-resolver/handlers/overview.query.js +15 -7
  91. package/dist/template-resolver/handlers/patch-draft.write.js +1 -1
  92. package/dist/template-resolver/handlers/send-test-status.query.d.ts +2 -1
  93. package/dist/template-resolver/handlers/send-test-status.query.js +18 -15
  94. package/dist/template-resolver/seeding.d.ts +1 -0
  95. package/dist/template-resolver/seeding.js +5 -2
  96. package/dist/template-resolver/store-template-resource.js +46 -6
  97. package/dist/template-resolver/web/i18n.js +3 -0
  98. package/dist/template-resolver/web/qualified-names.d.ts +1 -0
  99. package/dist/template-resolver/web/qualified-names.js +1 -0
  100. package/dist/tenant/feature.js +1 -0
  101. package/dist/tenant/handlers/team-list.query.js +3 -3
  102. package/dist/tenant-lifecycle/stages.js +22 -1
  103. package/dist/user/feature.js +1 -0
  104. package/dist/user-data-rights/feature.js +1 -0
  105. package/dist/user-data-rights/index.d.ts +1 -1
  106. package/dist/user-data-rights/index.js +1 -1
  107. package/dist/user-data-rights/web/constants.d.ts +6 -0
  108. package/dist/user-data-rights/web/constants.js +6 -0
  109. package/dist/user-data-rights/web/index.d.ts +1 -0
  110. package/dist/user-data-rights/web/index.js +1 -0
  111. package/dist/waitlist/feature.js +1 -0
  112. package/dist/waitlist/handlers/submit.write.js +13 -9
  113. package/dist/waitlist/import.d.ts +2 -1
  114. package/dist/waitlist/import.js +22 -2
  115. package/dist/waitlist/legacy-migrate.js +2 -2
  116. package/dist/waitlist/options.d.ts +2 -1
  117. package/dist/workflow-runner/handlers/resume-run.write.js +1 -1
  118. package/package.json +11 -11
  119. package/src/agent-tools/changes.json +6 -0
  120. package/src/auth-email-password/changes.json +12 -0
  121. package/src/auth-mfa/changes.json +6 -0
  122. package/src/cap-counter/changes.json +12 -0
  123. package/src/config/changes.json +6 -0
  124. package/src/crypto-shredding/changes.json +12 -0
  125. package/src/delivery/changes.json +12 -0
  126. package/src/document-ingest-foundation/changes.json +6 -0
  127. package/src/host-routing/changes.json +6 -0
  128. package/src/mail-transport-smtp/changes.json +6 -0
  129. package/src/managed-pages/changes.json +6 -0
  130. package/src/secrets/changes.json +6 -0
  131. package/src/sessions/changes.json +7 -0
  132. package/src/step-dispatcher/changes.json +6 -0
  133. package/src/template-resolver/changes.json +36 -0
  134. package/src/tenant-lifecycle/changes.json +6 -0
  135. package/src/user-data-rights/changes.json +12 -0
  136. package/src/waitlist/changes.json +18 -0
@@ -4,5 +4,5 @@ export { buildAgentManifest } from "./agent-manifest.ts";
4
4
  export { AGENT_TOOLS_FEATURE_NAME, createAgentToolsFeature } from "./feature.ts";
5
5
  export { buildToolCatalog, OPEN_FORM_TOOL_NAME, toolNameForQn } from "./tool-catalog.ts";
6
6
  export type { ToolCallRequest, ToolCallResult, ToolDispatcher } from "./tool-dispatch.ts";
7
- export { dispatchToolCall } from "./tool-dispatch.ts";
7
+ export { dispatchToolCall, TOOL_CALL_ERROR_CODES } from "./tool-dispatch.ts";
8
8
  export type { AgentManifest, AgentManifestEntity, AgentManifestFeature, AgentManifestField, AgentManifestHandler, AgentManifestNav, AgentManifestOptions, AgentManifestScreen, AgentManifestWorkspace, AgentToolMode, RegistryManifestView, RegistrySearchView, ToolCatalog, ToolCatalogOptions, ToolDefinition, ToolDispatchDescriptor, } from "./types.ts";
@@ -2,4 +2,4 @@ export { AgentDocGapKinds, findAgentDocGaps, findHandlerTranslationGaps, formatA
2
2
  export { buildAgentManifest } from "./agent-manifest.js";
3
3
  export { AGENT_TOOLS_FEATURE_NAME, createAgentToolsFeature } from "./feature.js";
4
4
  export { buildToolCatalog, OPEN_FORM_TOOL_NAME, toolNameForQn } from "./tool-catalog.js";
5
- export { dispatchToolCall } from "./tool-dispatch.js";
5
+ export { dispatchToolCall, TOOL_CALL_ERROR_CODES } from "./tool-dispatch.js";
@@ -37,11 +37,16 @@ export type ToolCallRequest = {
37
37
  /** Receives the cause of unexpected (5xx) failures, which the tool result never carries. */
38
38
  readonly log?: Pick<Logger, "error">;
39
39
  };
40
+ export declare const TOOL_CALL_ERROR_CODES: {
41
+ readonly invalidToolCall: "invalid_tool_call";
42
+ readonly unknownTool: "unknown_tool";
43
+ };
40
44
  /** Executes one LLM-issued tool call. Never throws: every failure comes back as `{ ok: false }`
41
45
  * so the agent loop can feed the error back to the model instead of crashing.
42
- * Handler and dispatcher failures carry a `code` (429 adds `retryable`); client errors (< 500)
43
- * keep their message, server failures (>= 500, unknown throws) are logged and returned as a
44
- * generic message. Local validation errors (unknown tool, bad arguments) have no `code`.
46
+ * Every failure carries a `code`: the handler's own code for handler and dispatcher failures
47
+ * (429 adds `retryable`), `invalid_tool_call` for bad arguments and `unknown_tool` for a tool
48
+ * that is not in the table. Client errors (< 500) keep their message, server failures (>= 500,
49
+ * unknown throws) are logged and returned as a generic message.
45
50
  * `request.user` must be the caller's real identity: server-kind tools run through the same
46
51
  * permission-checked pipeline as an HTTP request. */
47
52
  export declare function dispatchToolCall(request: ToolCallRequest): Promise<ToolCallResult>;
@@ -3,6 +3,16 @@ import { runAsAgentToolCallEntry } from "@cosmicdrift/kumiko-framework/internal/
3
3
  import { createFallbackLogger } from "@cosmicdrift/kumiko-framework/logging";
4
4
  import { isOneOf } from "@cosmicdrift/kumiko-types/type-guards";
5
5
  import { FILTER_OPS, isRecord } from "./filter-ops.js";
6
+ export const TOOL_CALL_ERROR_CODES = {
7
+ invalidToolCall: "invalid_tool_call",
8
+ unknownTool: "unknown_tool",
9
+ };
10
+ function invalidToolCall(error) {
11
+ return { ok: false, error, code: TOOL_CALL_ERROR_CODES.invalidToolCall };
12
+ }
13
+ function unknownTool(error) {
14
+ return { ok: false, error, code: TOOL_CALL_ERROR_CODES.unknownTool };
15
+ }
6
16
  const HTTP_TOO_MANY_REQUESTS = 429;
7
17
  const HTTP_SERVER_ERROR = 500;
8
18
  const UNEXPECTED_FAILURE_MESSAGE = "Tool failed unexpectedly";
@@ -52,14 +62,14 @@ function isFilterOp(value) {
52
62
  function buildSearchPayload(toolName, toolInput) {
53
63
  const query = toolInput["query"];
54
64
  if (typeof query !== "string" || query.length === 0) {
55
- return { ok: false, error: `Tool "${toolName}" requires a non-empty string "query" argument` };
65
+ return invalidToolCall(`Tool "${toolName}" requires a non-empty string "query" argument`);
56
66
  }
57
67
  return { ok: true, value: { search: query, limit: SEARCH_RESULT_LIMIT } };
58
68
  }
59
69
  function buildFindByPayload(toolName, fieldName, toolInput) {
60
70
  const value = toolInput[fieldName];
61
71
  if (value === undefined) {
62
- return { ok: false, error: `Tool "${toolName}" requires a "${fieldName}" argument` };
72
+ return invalidToolCall(`Tool "${toolName}" requires a "${fieldName}" argument`);
63
73
  }
64
74
  return {
65
75
  ok: true,
@@ -75,28 +85,28 @@ function buildEntityListPayload(list, input) {
75
85
  const search = input["search"];
76
86
  if (search !== undefined) {
77
87
  if (typeof search !== "string") {
78
- return { ok: false, error: `"search" must be a string` };
88
+ return invalidToolCall(`"search" must be a string`);
79
89
  }
80
90
  if (list.searchableFields.length === 0) {
81
- return { ok: false, error: "This entity has no searchable fields" };
91
+ return invalidToolCall("This entity has no searchable fields");
82
92
  }
83
93
  payload["search"] = search;
84
94
  }
85
95
  const filters = input["filters"];
86
96
  if (filters !== undefined) {
87
97
  if (!Array.isArray(filters)) {
88
- return { ok: false, error: `"filters" must be an array` };
98
+ return invalidToolCall(`"filters" must be an array`);
89
99
  }
90
100
  const validated = [];
91
101
  for (const entry of filters) {
92
102
  if (!isRecord(entry) || typeof entry["field"] !== "string" || !("value" in entry)) {
93
- return { ok: false, error: `Each filter must be { field, op, value }` };
103
+ return invalidToolCall(`Each filter must be { field, op, value }`);
94
104
  }
95
105
  if (!list.filterableFields.includes(entry["field"])) {
96
- return { ok: false, error: `Field "${entry["field"]}" is not filterable on this entity` };
106
+ return invalidToolCall(`Field "${entry["field"]}" is not filterable on this entity`);
97
107
  }
98
108
  if (!isFilterOp(entry["op"])) {
99
- return { ok: false, error: `Unsupported filter operator "${String(entry["op"])}"` };
109
+ return invalidToolCall(`Unsupported filter operator "${String(entry["op"])}"`);
100
110
  }
101
111
  validated.push({ field: entry["field"], op: entry["op"], value: entry["value"] });
102
112
  }
@@ -147,7 +157,7 @@ async function dispatchServerQuery(descriptor, dispatcher, user, input) {
147
157
  if (descriptor.detail) {
148
158
  const id = input["id"];
149
159
  if (typeof id !== "string" || id.length === 0) {
150
- return { ok: false, error: `This tool requires a non-empty string "id" argument` };
160
+ return invalidToolCall(`This tool requires a non-empty string "id" argument`);
151
161
  }
152
162
  const data = await dispatcher.query(descriptor.qn, { id }, user);
153
163
  return { ok: true, data };
@@ -171,15 +181,15 @@ async function dispatchServerWrite(descriptor, dispatcher, user, input, runId, t
171
181
  if (descriptor.injectsVersion) {
172
182
  const id = input["id"];
173
183
  if (typeof id !== "string" || id.length === 0) {
174
- return { ok: false, error: `This tool requires a non-empty string "id" argument` };
184
+ return invalidToolCall(`This tool requires a non-empty string "id" argument`);
175
185
  }
176
186
  if (!descriptor.detailQn) {
177
- return { ok: false, error: "Tool is misconfigured: no detail handler to read version from" };
187
+ return invalidToolCall("Tool is misconfigured: no detail handler to read version from");
178
188
  }
179
189
  const current = await dispatcher.query(descriptor.detailQn, { id }, user);
180
190
  const version = isRecord(current) && typeof current["version"] === "number" ? current["version"] : undefined;
181
191
  if (version === undefined) {
182
- return { ok: false, error: `Could not read the current "version" for id "${id}"` };
192
+ return invalidToolCall(`Could not read the current "version" for id "${id}"`);
183
193
  }
184
194
  payload = { ...input, version };
185
195
  }
@@ -211,37 +221,37 @@ function dispatchClientNavigate(descriptor, input) {
211
221
  if (typeof entity === "string") {
212
222
  const screenId = descriptor.entityScreens.get(entity);
213
223
  if (!screenId)
214
- return { ok: false, error: `Unknown entity "${entity}" for navigation` };
224
+ return invalidToolCall(`Unknown entity "${entity}" for navigation`);
215
225
  const id = input["id"];
216
226
  if (typeof id !== "string" || id.length === 0) {
217
- return { ok: false, error: `Navigating to "${entity}" requires a non-empty string "id"` };
227
+ return invalidToolCall(`Navigating to "${entity}" requires a non-empty string "id"`);
218
228
  }
219
229
  return { ok: true, data: { kind: "navigate", screenId, params: { id } } };
220
230
  }
221
231
  const screenId = input["screenId"];
222
232
  if (typeof screenId === "string") {
223
233
  if (!descriptor.screenIds.has(screenId)) {
224
- return { ok: false, error: `Unknown screen "${screenId}"` };
234
+ return invalidToolCall(`Unknown screen "${screenId}"`);
225
235
  }
226
236
  const params = input["params"];
227
237
  if (params !== undefined) {
228
238
  if (!isRecord(params) || Object.values(params).some((value) => typeof value !== "string")) {
229
- return { ok: false, error: `"params" must be an object of string values` };
239
+ return invalidToolCall(`"params" must be an object of string values`);
230
240
  }
231
241
  return { ok: true, data: { kind: "navigate", screenId, params } };
232
242
  }
233
243
  return { ok: true, data: { kind: "navigate", screenId, params: {} } };
234
244
  }
235
- return { ok: false, error: `navigate requires either { entity, id } or { screenId }` };
245
+ return invalidToolCall(`navigate requires either { entity, id } or { screenId }`);
236
246
  }
237
247
  function dispatchClientOpenForm(descriptor, input) {
238
248
  const handlerQn = input["handlerQn"];
239
249
  if (typeof handlerQn !== "string") {
240
- return { ok: false, error: `open_form requires a string "handlerQn" argument` };
250
+ return invalidToolCall(`open_form requires a string "handlerQn" argument`);
241
251
  }
242
252
  const screenId = descriptor.formScreens.get(handlerQn);
243
253
  if (!screenId)
244
- return { ok: false, error: `No form screen mounted for handler "${handlerQn}"` };
254
+ return invalidToolCall(`No form screen mounted for handler "${handlerQn}"`);
245
255
  return {
246
256
  ok: true,
247
257
  data: { kind: "open_form", screenId, handlerQn, prefill: input["prefill"] ?? {} },
@@ -253,14 +263,14 @@ function isStringArray(value) {
253
263
  function dispatchClientAskUser(input) {
254
264
  const question = input["question"];
255
265
  if (typeof question !== "string" || question.length === 0) {
256
- return { ok: false, error: `ask_user requires a non-empty string "question" argument` };
266
+ return invalidToolCall(`ask_user requires a non-empty string "question" argument`);
257
267
  }
258
268
  const options = input["options"];
259
269
  if (options === undefined) {
260
270
  return { ok: true, data: { kind: "ask_user", question } };
261
271
  }
262
272
  if (!isStringArray(options)) {
263
- return { ok: false, error: `ask_user "options" argument must be an array of strings` };
273
+ return invalidToolCall(`ask_user "options" argument must be an array of strings`);
264
274
  }
265
275
  return { ok: true, data: { kind: "ask_user", question, options } };
266
276
  }
@@ -276,9 +286,10 @@ function dispatchClient(descriptor, input) {
276
286
  }
277
287
  /** Executes one LLM-issued tool call. Never throws: every failure comes back as `{ ok: false }`
278
288
  * so the agent loop can feed the error back to the model instead of crashing.
279
- * Handler and dispatcher failures carry a `code` (429 adds `retryable`); client errors (< 500)
280
- * keep their message, server failures (>= 500, unknown throws) are logged and returned as a
281
- * generic message. Local validation errors (unknown tool, bad arguments) have no `code`.
289
+ * Every failure carries a `code`: the handler's own code for handler and dispatcher failures
290
+ * (429 adds `retryable`), `invalid_tool_call` for bad arguments and `unknown_tool` for a tool
291
+ * that is not in the table. Client errors (< 500) keep their message, server failures (>= 500,
292
+ * unknown throws) are logged and returned as a generic message.
282
293
  * `request.user` must be the caller's real identity: server-kind tools run through the same
283
294
  * permission-checked pipeline as an HTTP request. */
284
295
  export async function dispatchToolCall(request) {
@@ -286,7 +297,7 @@ export async function dispatchToolCall(request) {
286
297
  const classifyFailure = createFailureClassifier(toolName, request.log ?? fallbackLog);
287
298
  const descriptor = dispatchTable.get(toolName);
288
299
  if (!descriptor) {
289
- return { ok: false, error: `Unknown tool "${toolName}"` };
300
+ return unknownTool(`Unknown tool "${toolName}"`);
290
301
  }
291
302
  try {
292
303
  return await runAsAgentToolCallEntry(async () => {
@@ -23,6 +23,7 @@ import { AUDIT_I18N } from "./web/i18n.js";
23
23
  // framework).
24
24
  export function createAuditFeature() {
25
25
  return defineFeature("audit", (r) => {
26
+ r.adoptsBundledLists();
26
27
  r.describe("Exposes the framework's event store as a paginated, filterable audit log via the `audit:query:list` handler (accessible to `Admin` and `SystemAdmin` roles). No separate table or projection \u2014 the event store is the audit trail by construction: every entity write already records who, when, what entity, and the event payload with PII stripped. A SystemAdmin can pass `scope: \"system\"` to read the app-instance system events such as `app.started`, and reads the events of every tenant (system tenant excluded) through `audit:query:list-all-tenants` and the `audit-log-all-tenants` screen. Filter by `aggregateType`, `aggregateId`, `eventType`, `userId`, or time range. Also records `audit:event:escape-hatch-used` whenever a handler uses one of the framework's escape hatches (unsafeRaw, acknowledgeCrossTenant, db.global() writes, or a granted identity switch).");
27
28
  r.uiHints({
28
29
  displayLabel: "Audit Log",
@@ -3,4 +3,5 @@ export type RequestAccountUnlockOptions = TokenRequestOptions;
3
3
  export type RequestUnlockData = TokenRequestData<"unlock-requested">;
4
4
  export declare function createRequestAccountUnlockHandler(opts: RequestAccountUnlockOptions): import("@cosmicdrift/kumiko-types/define-handler").WriteHandlerDefinition<"request-account-unlock", import("zod").ZodObject<{
5
5
  email: import("zod").ZodEmail;
6
+ returnTo: import("zod").ZodCatch<import("zod").ZodOptional<import("zod").ZodString>>;
6
7
  }, import("zod/v4/core").$strip>, TokenRequestData<"unlock-requested">, import("@cosmicdrift/kumiko-types/event-type-map").KumikoEventTypeMap, undefined>;
@@ -18,5 +18,6 @@ export function createRequestAccountUnlockHandler(opts) {
18
18
  // empty) lockout state — harmless no-op if they weren't locked.
19
19
  extraSilentSkip: () => false,
20
20
  notificationType: AuthMailNotificationTypes.accountUnlock,
21
+ forwardsReturnTo: false,
21
22
  }, opts);
22
23
  }
@@ -3,4 +3,5 @@ export type RequestEmailVerificationOptions = TokenRequestOptions;
3
3
  export type RequestVerificationData = TokenRequestData<"verification-requested">;
4
4
  export declare function createRequestEmailVerificationHandler(opts: RequestEmailVerificationOptions): import("@cosmicdrift/kumiko-types/define-handler").WriteHandlerDefinition<"request-email-verification", import("zod").ZodObject<{
5
5
  email: import("zod").ZodEmail;
6
+ returnTo: import("zod").ZodCatch<import("zod").ZodOptional<import("zod").ZodString>>;
6
7
  }, import("zod/v4/core").$strip>, TokenRequestData<"verification-requested">, import("@cosmicdrift/kumiko-types/event-type-map").KumikoEventTypeMap, undefined>;
@@ -17,5 +17,6 @@ export function createRequestEmailVerificationHandler(opts) {
17
17
  // caller sees the same 200 regardless of whether a token was minted.
18
18
  extraSilentSkip: (user) => user.emailVerified === true,
19
19
  notificationType: AuthMailNotificationTypes.emailVerification,
20
+ forwardsReturnTo: false,
20
21
  }, opts);
21
22
  }
@@ -3,4 +3,5 @@ export type RequestPasswordResetOptions = TokenRequestOptions;
3
3
  export type RequestResetData = TokenRequestData<"reset-requested">;
4
4
  export declare function createRequestPasswordResetHandler(opts: RequestPasswordResetOptions): import("@cosmicdrift/kumiko-types/define-handler").WriteHandlerDefinition<"request-password-reset", import("zod").ZodObject<{
5
5
  email: import("zod").ZodEmail;
6
+ returnTo: import("zod").ZodCatch<import("zod").ZodOptional<import("zod").ZodString>>;
6
7
  }, import("zod/v4/core").$strip>, TokenRequestData<"reset-requested">, import("@cosmicdrift/kumiko-types/event-type-map").KumikoEventTypeMap, undefined>;
@@ -17,5 +17,6 @@ export function createRequestPasswordResetHandler(opts) {
17
17
  // state. The sessions feature handles the post-change revocation.
18
18
  extraSilentSkip: () => false,
19
19
  notificationType: AuthMailNotificationTypes.passwordReset,
20
+ forwardsReturnTo: true,
20
21
  }, opts);
21
22
  }
@@ -29,4 +29,5 @@ export declare function createSignupRequestHandler(opts: SignupRequestOptions):
29
29
  entityType: z.ZodString;
30
30
  token: z.ZodString;
31
31
  }, z.core.$strip>>;
32
+ returnTo: z.ZodCatch<z.ZodOptional<z.ZodString>>;
32
33
  }, z.core.$strip>, SignupRequestData, import("@cosmicdrift/kumiko-types/event-type-map").KumikoEventTypeMap, undefined>;
@@ -29,6 +29,7 @@ import { resolveHandlerMailLocale } from "../magic-link-mail.js";
29
29
  import { AUTH_SELF_REGISTRATION_FEATURE } from "../self-registration-toggle.js";
30
30
  import { issueSignupActivation } from "../signup-activation.js";
31
31
  import { storeSignupHandover, takeOverSignupHandoverForResend } from "../signup-token-store.js";
32
+ import { MAX_RETURN_PATH_LENGTH } from "../web/return-path.js";
32
33
  const SignupRequestSchema = z.object({
33
34
  email: z.email(),
34
35
  // Cross-device try-first handover: see shared/signup-handover.ts for why the
@@ -40,6 +41,8 @@ const SignupRequestSchema = z.object({
40
41
  token: z.string().min(1),
41
42
  })
42
43
  .optional(),
44
+ // A bad returnTo must not void the signup; the mail builder drops unsafe values.
45
+ returnTo: z.string().max(MAX_RETURN_PATH_LENGTH).optional().catch(undefined),
43
46
  });
44
47
  const HANDOVER_GRANT_VERIFY_REASON = "verifies a tenant-handover grant against its root row in the source tenant (read-only anchor lookup) before binding it to the signup token";
45
48
  export function createSignupRequestHandler(opts) {
@@ -95,6 +98,7 @@ export function createSignupRequestHandler(opts) {
95
98
  email,
96
99
  appUrl: opts.appUrl,
97
100
  timeZone: ctx.tz.user,
101
+ ...(event.payload.returnTo !== undefined && { returnTo: event.payload.returnTo }),
98
102
  ...(opts.tokenTtlMinutes !== undefined && { tokenTtlMinutes: opts.tokenTtlMinutes }),
99
103
  ...(opts.appName !== undefined && { appName: opts.appName }),
100
104
  ...(locale !== undefined && { locale }),
@@ -25,6 +25,7 @@ export type TokenRequestSpec<TName extends string, TSuccessKind extends string>
25
25
  readonly notConfiguredI18nKey: string;
26
26
  readonly extraSilentSkip: (user: AuthUserRow) => boolean;
27
27
  readonly notificationType: string;
28
+ readonly forwardsReturnTo: boolean;
28
29
  };
29
30
  export type TokenRequestOptions = {
30
31
  readonly hmacSecret: string;
@@ -41,4 +42,5 @@ export type TokenRequestOptions = {
41
42
  };
42
43
  export declare function createTokenRequestHandler<TName extends string, TSuccessKind extends string>(spec: TokenRequestSpec<TName, TSuccessKind>, opts: TokenRequestOptions): import("@cosmicdrift/kumiko-types/define-handler").WriteHandlerDefinition<TName, z.ZodObject<{
43
44
  email: z.ZodEmail;
45
+ returnTo: z.ZodCatch<z.ZodOptional<z.ZodString>>;
44
46
  }, z.core.$strip>, TokenRequestData<TSuccessKind>, import("@cosmicdrift/kumiko-types/event-type-map").KumikoEventTypeMap, undefined>;
@@ -19,8 +19,11 @@ import { UserQueries } from "../../user/index.js";
19
19
  import { parseAuthUserRow } from "../auth-user-row.js";
20
20
  import { AUTH_EMAIL_PAYLOAD_RATE_LIMITS } from "../email-rate-limits.js";
21
21
  import { dispatchMagicLinkMail, resolveHandlerMailLocale } from "../magic-link-mail.js";
22
+ import { MAX_RETURN_PATH_LENGTH } from "../web/return-path.js";
22
23
  const RequestTokenSchema = z.object({
23
24
  email: z.email(),
25
+ // A bad returnTo must not void the request; the mail builder drops unsafe values.
26
+ returnTo: z.string().max(MAX_RETURN_PATH_LENGTH).optional().catch(undefined),
24
27
  });
25
28
  // The requester is anonymous, so ctx.tz.user is only the tenant default; the recipient's
26
29
  // own profile zone is the one the expiry should be read in.
@@ -79,6 +82,8 @@ export function createTokenRequestHandler(spec, opts) {
79
82
  timeZone: recipientTimeZone(user, ctx.tz.user),
80
83
  ...(opts.appName !== undefined && { appName: opts.appName }),
81
84
  locale,
85
+ ...(spec.forwardsReturnTo &&
86
+ event.payload.returnTo !== undefined && { returnTo: event.payload.returnTo }),
82
87
  });
83
88
  const data = {
84
89
  kind: spec.successKind,
@@ -15,6 +15,8 @@ export type MagicLinkMailParams = {
15
15
  readonly issuedAt?: string;
16
16
  readonly timeZone?: string;
17
17
  readonly role?: string;
18
+ /** Page the landing page should lead to afterwards; an unsafe value is left out of the link. */
19
+ readonly returnTo?: string;
18
20
  };
19
21
  export declare function resolveHandlerMailLocale(ctx: {
20
22
  readonly locale?: string;
@@ -10,12 +10,16 @@ import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
10
10
  import { mailT, resolveMailLocale } from "@cosmicdrift/kumiko-framework/i18n";
11
11
  import { tokenMailExpiry } from "./email-templates.js";
12
12
  import { authMailTemplateLocale } from "./mail-notifications.js";
13
+ import { isSafeReturnPath, RETURN_TO_QUERY_PARAM } from "./web/return-path.js";
13
14
  function resolveAppUrl(appUrl, locale) {
14
15
  return typeof appUrl === "function" ? appUrl(locale) : appUrl;
15
16
  }
16
- function appendToken(appUrl, token) {
17
+ function appendTokenAndReturnTo(appUrl, token, returnTo) {
18
+ const query = new URLSearchParams({ token });
19
+ if (isSafeReturnPath(returnTo))
20
+ query.set(RETURN_TO_QUERY_PARAM, returnTo);
17
21
  const sep = appUrl.includes("?") ? "&" : "?";
18
- return `${appUrl}${sep}token=${encodeURIComponent(token)}`;
22
+ return `${appUrl}${sep}${query.toString()}`;
19
23
  }
20
24
  // X-Locale on the request wins over the handler's static opts.locale, then
21
25
  // ctx.locale's resolved default (always set — boot default / "en"). Reading
@@ -49,7 +53,7 @@ export async function dispatchMagicLinkMail(notify, spec, params) {
49
53
  route: { email: params.email },
50
54
  data: {
51
55
  appName: params.appName ?? spec.fallbackAppName ?? mailT(templateLocale, "auth.mail.appNameDefault"),
52
- actionUrl: appendToken(resolveAppUrl(params.appUrl, urlLocale), params.token),
56
+ actionUrl: appendTokenAndReturnTo(resolveAppUrl(params.appUrl, urlLocale), params.token, params.returnTo),
53
57
  validFor,
54
58
  validUntil,
55
59
  ...(params.role !== undefined && { role: params.role }),
@@ -10,6 +10,7 @@ export type IssueSignupActivationArgs = {
10
10
  readonly locale?: string;
11
11
  readonly timeZone?: string;
12
12
  readonly tokenTtlMinutes?: number;
13
+ readonly returnTo?: string;
13
14
  /** Runs after the token is stored and before the mail goes out. */
14
15
  readonly onTokenStored?: (stored: {
15
16
  readonly token: string;
@@ -35,6 +35,7 @@ export async function issueSignupActivation(args) {
35
35
  ...(args.timeZone !== undefined && { timeZone: args.timeZone }),
36
36
  ...(args.appName !== undefined && { appName: args.appName }),
37
37
  ...(args.locale !== undefined && { locale: args.locale }),
38
+ ...(args.returnTo !== undefined && { returnTo: args.returnTo }),
38
39
  });
39
40
  return { email, token, expiresAt };
40
41
  }
@@ -54,7 +54,7 @@ export type AuthTokenFailure = {
54
54
  };
55
55
  export type ResetPasswordFailure = AuthTokenFailure;
56
56
  export type VerifyEmailFailure = AuthTokenFailure;
57
- export declare function requestPasswordReset(email: string): Promise<{
57
+ export declare function requestPasswordReset(email: string, returnTo?: string): Promise<{
58
58
  ok: true;
59
59
  } | {
60
60
  ok: false;
@@ -93,7 +93,7 @@ export declare function confirmAccountUnlock(token: string): Promise<{
93
93
  export declare function requestSignup(email: string, handover?: {
94
94
  readonly entityType: string;
95
95
  readonly token: string;
96
- }): Promise<{
96
+ }, returnTo?: string): Promise<{
97
97
  ok: true;
98
98
  } | {
99
99
  ok: false;
@@ -115,12 +115,12 @@ async function parseTokenFailure(res) {
115
115
  // email doesn't exist, the caller sees `{ ok: true }` — no account
116
116
  // enumeration. Server only triggers the mail internally when a user is
117
117
  // found. 429 → typed rate-limit failure. 5xx → unknown-error.
118
- export async function requestPasswordReset(email) {
118
+ export async function requestPasswordReset(email, returnTo) {
119
119
  const res = await fetch("/api/auth/request-password-reset", {
120
120
  method: "POST",
121
121
  credentials: "same-origin",
122
122
  headers: { "Content-Type": "application/json", ...csrfHeader() },
123
- body: JSON.stringify({ email }),
123
+ body: JSON.stringify({ email, ...(returnTo !== undefined && { returnTo }) }),
124
124
  });
125
125
  if (res.ok)
126
126
  return { ok: true };
@@ -208,12 +208,16 @@ export async function requestSignup(email,
208
208
  // grant so the server can verify + rebind it to the signup token. The grant never
209
209
  // travels any further than this request — never in the URL, the mail,
210
210
  // or the confirm response.
211
- handover) {
211
+ handover, returnTo) {
212
212
  const res = await fetch("/api/auth/signup-request", {
213
213
  method: "POST",
214
214
  credentials: "same-origin",
215
215
  headers: { "Content-Type": "application/json", ...csrfHeader() },
216
- body: JSON.stringify({ email, ...(handover !== undefined && { handover }) }),
216
+ body: JSON.stringify({
217
+ email,
218
+ ...(handover !== undefined && { handover }),
219
+ ...(returnTo !== undefined && { returnTo }),
220
+ }),
217
221
  });
218
222
  if (res.ok)
219
223
  return { ok: true };
@@ -1,6 +1,10 @@
1
+ import { isSafeReturnPath } from "./return-path.ts";
1
2
  export declare const NEXT_QUERY_PARAM = "next";
2
- export declare function isSafeNextPath(candidate: unknown): candidate is string;
3
+ export declare const isSafeNextPath: typeof isSafeReturnPath;
3
4
  export declare function readNextFromSearch(search: string): string | null;
5
+ export declare function readReturnToFromWindow(): string | null;
6
+ export declare function appendReturnTo(href: string, returnTo: string | null): string;
7
+ export declare function readNextFromWindow(): string | null;
4
8
  export declare function assertNavigableUrl(url: string, optionName: string): void;
5
9
  export declare function buildLoginRedirectUrl(loginUrl: string, returnPath: string, currentOrigin: string): string;
6
10
  export declare function followNextAfterLogin(location?: Pick<Location, "search" | "pathname" | "replace">): boolean;
@@ -1,36 +1,31 @@
1
1
  // Redirect targets for external login pages. `next` travels through the query
2
2
  // string, so anything read back from it is untrusted: only same-origin
3
3
  // root-relative paths pass (no "//host", no "/\host", no scheme).
4
+ import { isSafeReturnPath, RETURN_TO_QUERY_PARAM, readReturnToFromSearch } from "./return-path.js";
4
5
  export const NEXT_QUERY_PARAM = "next";
5
- function hasControlOrWhitespace(candidate) {
6
- for (let i = 0; i < candidate.length; i++) {
7
- const code = candidate.charCodeAt(i);
8
- if (code <= 0x20 || code === 0x7f)
9
- return true;
10
- }
11
- return false;
12
- }
13
- // The URL parse is a backstop: browsers strip tabs/newlines, so "/\t/evil.com"
14
- // would otherwise normalize to "//evil.com" past the leading-slash check.
15
- export function isSafeNextPath(candidate) {
16
- if (typeof candidate !== "string" || !candidate.startsWith("/"))
17
- return false;
18
- if (candidate[1] === "/" || candidate.includes("\\"))
19
- return false;
20
- if (hasControlOrWhitespace(candidate))
21
- return false;
22
- try {
23
- const parsed = new URL(candidate, "https://next.invalid");
24
- return parsed.origin === "https://next.invalid" && !parsed.pathname.startsWith("//");
25
- }
26
- catch {
27
- return false;
28
- }
29
- }
6
+ export const isSafeNextPath = isSafeReturnPath;
30
7
  export function readNextFromSearch(search) {
31
8
  const candidate = new URLSearchParams(search).get(NEXT_QUERY_PARAM);
32
9
  return isSafeNextPath(candidate) ? candidate : null;
33
10
  }
11
+ // The returnTo a mail link or sibling auth page carried in this page's own URL; null on native, where there is no window.
12
+ export function readReturnToFromWindow() {
13
+ return typeof window === "undefined" ? null : readReturnToFromSearch(window.location.search);
14
+ }
15
+ // Adds returnTo to an href of a sibling auth page; a missing value leaves the href untouched.
16
+ export function appendReturnTo(href, returnTo) {
17
+ if (returnTo === null)
18
+ return href;
19
+ const hashAt = href.indexOf("#");
20
+ const base = hashAt === -1 ? href : href.slice(0, hashAt);
21
+ const hash = hashAt === -1 ? "" : href.slice(hashAt);
22
+ const separator = base.includes("?") ? "&" : "?";
23
+ return `${base}${separator}${RETURN_TO_QUERY_PARAM}=${encodeURIComponent(returnTo)}${hash}`;
24
+ }
25
+ // The safe `next` of this page's URL; null on native, where there is no window.
26
+ export function readNextFromWindow() {
27
+ return typeof window === "undefined" ? null : readNextFromSearch(window.location.search);
28
+ }
34
29
  // loginUrl/postLogoutUrl come from app code (trusted), but a typo like
35
30
  // "javascript:" must fail at startup instead of becoming a navigation target.
36
31
  export function assertNavigableUrl(url, optionName) {
@@ -1,4 +1,5 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx } from "react/jsx-runtime";
2
+ import { stripBasePath, useBasePath } from "@cosmicdrift/kumiko-renderer";
2
3
  import { DEFAULT_AUTH_PATHS, DEFAULT_FORGOT_PASSWORD_PATH } from "./auth-paths.js";
3
4
  import { ConfirmAccountUnlockScreen } from "./confirm-account-unlock-screen.js";
4
5
  import { ForgotPasswordScreen } from "./forgot-password-screen.js";
@@ -7,8 +8,9 @@ import { ResetPasswordScreen } from "./reset-password-screen.js";
7
8
  function hasToken(search) {
8
9
  return (new URLSearchParams(search).get("token") ?? "") !== "";
9
10
  }
10
- function pickDefaultAuthPage(pathname, search) {
11
- const path = pathname.length > 1 ? pathname.replace(/\/+$/, "") : pathname;
11
+ function pickDefaultAuthPage(pathname, search, basePath) {
12
+ const trimmed = pathname.length > 1 ? pathname.replace(/\/+$/, "") : pathname;
13
+ const path = stripBasePath(trimmed, basePath);
12
14
  switch (path) {
13
15
  case DEFAULT_FORGOT_PASSWORD_PATH:
14
16
  return _jsx(ForgotPasswordScreen, {});
@@ -24,5 +26,6 @@ function pickDefaultAuthPage(pathname, search) {
24
26
  // (outer) still win on the same path. The pages are public: no session needed.
25
27
  export function DefaultAuthPagesGate({ children }) {
26
28
  const { pathname, search } = window.location;
27
- return pickDefaultAuthPage(pathname, search) ?? _jsx(_Fragment, { children: children });
29
+ const basePath = useBasePath();
30
+ return pickDefaultAuthPage(pathname, search, basePath) ?? _jsx(_Fragment, { children: children });
28
31
  }
@@ -5,5 +5,8 @@ export type ForgotPasswordScreenProps = {
5
5
  /** href for the "Back to login" link in the success and form states. App-
6
6
  * specific — defaults to "/login". */
7
7
  readonly loginHref?: string;
8
+ /** Page the reset mail should lead back to after the new password is set; defaults to the
9
+ * `returnTo` query parameter of this page. Unsafe values are dropped. */
10
+ readonly returnTo?: string;
8
11
  };
9
- export declare function ForgotPasswordScreen({ title, subtitle, loginHref, }: ForgotPasswordScreenProps): ReactNode;
12
+ export declare function ForgotPasswordScreen({ title, subtitle, loginHref, returnTo, }: ForgotPasswordScreenProps): ReactNode;
@@ -12,7 +12,12 @@ import { usePrimitives, useTranslation } from "@cosmicdrift/kumiko-renderer";
12
12
  import { useState } from "react";
13
13
  import { requestPasswordReset } from "./auth-client.js";
14
14
  import { AuthCard } from "./auth-form-primitives.js";
15
- export function ForgotPasswordScreen({ title, subtitle, loginHref = "/login", }) {
15
+ import { readReturnToFromWindow } from "./auth-redirect.js";
16
+ import { isSafeReturnPath } from "./return-path.js";
17
+ function resolveReturnTo(prop) {
18
+ return (isSafeReturnPath(prop) ? prop : readReturnToFromWindow()) ?? undefined;
19
+ }
20
+ export function ForgotPasswordScreen({ title, subtitle, loginHref = "/login", returnTo, }) {
16
21
  const t = useTranslation();
17
22
  const { Form, Field, Input, Button, Banner, Link } = usePrimitives();
18
23
  const [email, setEmail] = useState("");
@@ -24,7 +29,7 @@ export function ForgotPasswordScreen({ title, subtitle, loginHref = "/login", })
24
29
  setSubmitting(true);
25
30
  setError(null);
26
31
  try {
27
- const res = await requestPasswordReset(email);
32
+ const res = await requestPasswordReset(email, resolveReturnTo(returnTo));
28
33
  if (res.ok) {
29
34
  setDone(true);
30
35
  }
@@ -22,6 +22,7 @@ export type { RequestAccountUnlockScreenProps } from "./request-account-unlock-s
22
22
  export { RequestAccountUnlockScreen } from "./request-account-unlock-screen.tsx";
23
23
  export type { ResetPasswordScreenProps } from "./reset-password-screen.tsx";
24
24
  export { ResetPasswordScreen } from "./reset-password-screen.tsx";
25
+ export { isSafeReturnPath, RETURN_TO_QUERY_PARAM, readReturnToFromSearch, } from "./return-path.ts";
25
26
  export type { SessionApi, SessionBootstrapFailure, SessionState, SessionStatus, } from "./session.tsx";
26
27
  export { hasLikelyAuthSession, SessionContext, SessionProvider, useSession } from "./session.tsx";
27
28
  export type { SessionBootstrapErrorScreenProps } from "./session-bootstrap-error.tsx";
@@ -16,6 +16,7 @@ export { InviteAcceptScreen } from "./invite-accept-screen.js";
16
16
  export { LoginScreen } from "./login-screen.js";
17
17
  export { RequestAccountUnlockScreen } from "./request-account-unlock-screen.js";
18
18
  export { ResetPasswordScreen } from "./reset-password-screen.js";
19
+ export { isSafeReturnPath, RETURN_TO_QUERY_PARAM, readReturnToFromSearch, } from "./return-path.js";
19
20
  export { hasLikelyAuthSession, SessionContext, SessionProvider, useSession } from "./session.js";
20
21
  export { retryDelayMs, SessionBootstrapErrorScreen } from "./session-bootstrap-error.js";
21
22
  export { SignupCompleteScreen } from "./signup-complete-screen.js";