@cosmicdrift/kumiko-samples 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 (129) hide show
  1. package/package.json +1 -1
  2. package/packages/bundled-features/package.json +1 -1
  3. package/packages/bundled-features/src/agent-tools/changes.json +6 -0
  4. package/packages/bundled-features/src/agent-tools/index.ts +1 -1
  5. package/packages/bundled-features/src/agent-tools/tool-dispatch.ts +42 -26
  6. package/packages/bundled-features/src/audit/feature.ts +1 -0
  7. package/packages/bundled-features/src/auth-email-password/changes.json +12 -0
  8. package/packages/bundled-features/src/auth-email-password/handlers/request-account-unlock.write.ts +1 -0
  9. package/packages/bundled-features/src/auth-email-password/handlers/request-email-verification.write.ts +1 -0
  10. package/packages/bundled-features/src/auth-email-password/handlers/request-password-reset.write.ts +1 -0
  11. package/packages/bundled-features/src/auth-email-password/handlers/signup-request.write.ts +4 -0
  12. package/packages/bundled-features/src/auth-email-password/handlers/token-request-handler.ts +8 -0
  13. package/packages/bundled-features/src/auth-email-password/magic-link-mail.ts +16 -3
  14. package/packages/bundled-features/src/auth-email-password/signup-activation.ts +2 -0
  15. package/packages/bundled-features/src/auth-email-password/web/auth-client.ts +8 -2
  16. package/packages/bundled-features/src/auth-email-password/web/auth-redirect.ts +23 -21
  17. package/packages/bundled-features/src/auth-email-password/web/default-auth-pages-gate.tsx +6 -3
  18. package/packages/bundled-features/src/auth-email-password/web/forgot-password-screen.tsx +11 -1
  19. package/packages/bundled-features/src/auth-email-password/web/index.ts +5 -0
  20. package/packages/bundled-features/src/auth-email-password/web/login-screen.tsx +16 -5
  21. package/packages/bundled-features/src/auth-email-password/web/reset-password-screen.tsx +7 -1
  22. package/packages/bundled-features/src/auth-email-password/web/return-path.ts +35 -0
  23. package/packages/bundled-features/src/auth-email-password/web/session.tsx +6 -1
  24. package/packages/bundled-features/src/auth-email-password/web/signup-complete-screen.tsx +11 -3
  25. package/packages/bundled-features/src/auth-email-password/web/signup-screen.tsx +12 -2
  26. package/packages/bundled-features/src/auth-mfa/changes.json +6 -0
  27. package/packages/bundled-features/src/auth-mfa/config.ts +3 -7
  28. package/packages/bundled-features/src/auth-mfa/mfa-status-checker.ts +8 -5
  29. package/packages/bundled-features/src/cap-counter/cap-reservation.ts +20 -7
  30. package/packages/bundled-features/src/cap-counter/changes.json +12 -0
  31. package/packages/bundled-features/src/cap-counter/feature.ts +1 -0
  32. package/packages/bundled-features/src/cap-overview/feature.ts +1 -0
  33. package/packages/bundled-features/src/config/changes.json +6 -0
  34. package/packages/bundled-features/src/config/handlers/set.write.ts +19 -2
  35. package/packages/bundled-features/src/crypto-shredding/changes.json +12 -0
  36. package/packages/bundled-features/src/crypto-shredding/handlers/forget-subject.write.ts +1 -1
  37. package/packages/bundled-features/src/custom-fields/lib/parse-serialized-field.ts +1 -1
  38. package/packages/bundled-features/src/delivery/changes.json +12 -0
  39. package/packages/bundled-features/src/delivery/feature.ts +1 -0
  40. package/packages/bundled-features/src/delivery/index.ts +5 -0
  41. package/packages/bundled-features/src/delivery/system-channel-context.ts +33 -0
  42. package/packages/bundled-features/src/document-ingest-foundation/append-missing-extract-event.ts +84 -0
  43. package/packages/bundled-features/src/document-ingest-foundation/changes.json +6 -0
  44. package/packages/bundled-features/src/document-ingest-foundation/events.ts +15 -0
  45. package/packages/bundled-features/src/document-ingest-foundation/index.ts +11 -0
  46. package/packages/bundled-features/src/feature-toggles/feature.ts +1 -0
  47. package/packages/bundled-features/src/host-routing/changes.json +6 -0
  48. package/packages/bundled-features/src/host-routing/validate-options.ts +5 -0
  49. package/packages/bundled-features/src/inbound-mail-foundation/feature.ts +1 -0
  50. package/packages/bundled-features/src/jobs/feature.ts +1 -0
  51. package/packages/bundled-features/src/ledger/web/index.ts +1 -1
  52. package/packages/bundled-features/src/legal-pages/seed.ts +5 -2
  53. package/packages/bundled-features/src/mail-transport-smtp/changes.json +6 -0
  54. package/packages/bundled-features/src/mail-transport-smtp/config-keys.ts +1 -1
  55. package/packages/bundled-features/src/mail-transport-smtp/feature.ts +9 -11
  56. package/packages/bundled-features/src/managed-pages/changes.json +6 -0
  57. package/packages/bundled-features/src/managed-pages/constants.ts +1 -0
  58. package/packages/bundled-features/src/managed-pages/feature.ts +3 -1
  59. package/packages/bundled-features/src/managed-pages/index.ts +1 -0
  60. package/packages/bundled-features/src/notes-history/handlers/add-note.write.ts +1 -1
  61. package/packages/bundled-features/src/personal-access-tokens/feature.ts +1 -0
  62. package/packages/bundled-features/src/secrets/changes.json +6 -0
  63. package/packages/bundled-features/src/secrets/write-gate.ts +9 -2
  64. package/packages/bundled-features/src/sessions/changes.json +7 -0
  65. package/packages/bundled-features/src/sessions/feature.ts +1 -0
  66. package/packages/bundled-features/src/sessions/index.ts +1 -1
  67. package/packages/bundled-features/src/sessions/session-callbacks.ts +1 -9
  68. package/packages/bundled-features/src/shared/purge-files-reaching-subject.ts +24 -2
  69. package/packages/bundled-features/src/shared/run-in-sub-transaction.ts +5 -5
  70. package/packages/bundled-features/src/step-dispatcher/changes.json +6 -0
  71. package/packages/bundled-features/src/step-dispatcher/deliver-dispatch-request.ts +17 -3
  72. package/packages/bundled-features/src/step-dispatcher/mail-runner.ts +2 -1
  73. package/packages/bundled-features/src/tags/feature.ts +1 -0
  74. package/packages/bundled-features/src/template-resolver/changes.json +36 -0
  75. package/packages/bundled-features/src/template-resolver/document-ops.ts +20 -1
  76. package/packages/bundled-features/src/template-resolver/feature.ts +2 -2
  77. package/packages/bundled-features/src/template-resolver/handlers/layout-source.query.ts +36 -11
  78. package/packages/bundled-features/src/template-resolver/handlers/overview.query.ts +30 -5
  79. package/packages/bundled-features/src/template-resolver/handlers/patch-draft.write.ts +1 -1
  80. package/packages/bundled-features/src/template-resolver/handlers/send-test-status.query.ts +22 -19
  81. package/packages/bundled-features/src/template-resolver/seeding.ts +10 -3
  82. package/packages/bundled-features/src/template-resolver/store-template-resource.ts +52 -8
  83. package/packages/bundled-features/src/template-resolver/web/i18n.ts +6 -0
  84. package/packages/bundled-features/src/template-resolver/web/qualified-names.ts +1 -0
  85. package/packages/bundled-features/src/tenant/feature.ts +1 -0
  86. package/packages/bundled-features/src/tenant/handlers/team-list.query.ts +3 -3
  87. package/packages/bundled-features/src/tenant-lifecycle/changes.json +6 -0
  88. package/packages/bundled-features/src/tenant-lifecycle/stages.ts +35 -1
  89. package/packages/bundled-features/src/user/feature.ts +1 -0
  90. package/packages/bundled-features/src/user-data-rights/changes.json +12 -0
  91. package/packages/bundled-features/src/user-data-rights/feature.ts +1 -0
  92. package/packages/bundled-features/src/user-data-rights/index.ts +6 -1
  93. package/packages/bundled-features/src/user-data-rights/web/constants.ts +6 -0
  94. package/packages/bundled-features/src/user-data-rights/web/index.ts +1 -0
  95. package/packages/bundled-features/src/waitlist/changes.json +18 -0
  96. package/packages/bundled-features/src/waitlist/feature.ts +1 -0
  97. package/packages/bundled-features/src/waitlist/handlers/submit.write.ts +27 -8
  98. package/packages/bundled-features/src/waitlist/import.ts +23 -3
  99. package/packages/bundled-features/src/waitlist/legacy-migrate.ts +2 -1
  100. package/packages/bundled-features/src/waitlist/options.ts +2 -1
  101. package/packages/bundled-features/src/workflow-runner/handlers/resume-run.write.ts +1 -1
  102. package/samples/apps/styleguide/src/app/client.tsx +2 -0
  103. package/samples/apps/styleguide/src/app/seed.ts +6 -4
  104. package/samples/apps/styleguide/src/app/server.ts +4 -1
  105. package/samples/apps/styleguide/src/features/widgets/feature.ts +15 -4
  106. package/samples/apps/styleguide/src/features/workboard/feature.ts +185 -0
  107. package/samples/apps/styleguide/src/features/workboard/i18n.ts +27 -0
  108. package/samples/apps/styleguide/src/features/workboard/seed.ts +121 -0
  109. package/samples/apps/styleguide/src/features/workboard/web.ts +13 -0
  110. package/samples/apps/ui-walkthrough/src/features/rental/i18n.ts +2 -0
  111. package/samples/apps/ui-walkthrough/src/features/rental/screens.ts +9 -0
  112. package/samples/apps/use-all-bundled/bin/main.ts +2 -0
  113. package/samples/apps/use-all-bundled/feature-manifest.json +16 -0
  114. package/samples/apps/use-all-bundled/kumiko/schema.ts +2 -0
  115. package/samples/apps/use-all-bundled/kumiko.config.ts +2 -0
  116. package/samples/apps/use-all-bundled/package.json +2 -1
  117. package/samples/apps/use-all-bundled/src/app/dev-app-options.ts +2 -0
  118. package/samples/apps/use-all-bundled/src/app/notes-feature.ts +2 -0
  119. package/samples/apps/use-all-bundled/src/bundled-lists.ts +15 -0
  120. package/samples/recipes/golden-path/src/feature.ts +2 -1
  121. package/samples/recipes/lifecycle-hooks/README.md +4 -0
  122. package/samples/recipes/lifecycle-hooks/src/feature.ts +26 -1
  123. package/samples/recipes/list-table-options/README.md +50 -0
  124. package/samples/recipes/list-table-options/package.json +11 -0
  125. package/samples/recipes/list-table-options/src/feature.ts +221 -0
  126. package/samples/recipes/pdf-audit-export/src/feature.ts +2 -1
  127. package/samples/recipes/state-machine/src/events.ts +3 -2
  128. package/samples/recipes/tenant-isolation/README.md +1 -0
  129. package/samples/recipes/tenant-isolation/src/feature.ts +36 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-samples",
3
- "version": "0.388.0",
3
+ "version": "0.389.0",
4
4
  "description": "Source trees of the Kumiko sample recipes, sample apps and bundled features in repo layout, for tooling such as few-shot corpus builds.",
5
5
  "license": "MIT",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.388.0",
3
+ "version": "0.389.0",
4
4
  "description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
5
5
  "license": "MIT",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.389.0",
4
+ "type": "improvement",
5
+ "title": "Local agent tool errors carry a code",
6
+ "detail": "dispatchToolCall returns code invalid_tool_call for validation errors and unknown_tool for an unknown tool name, instead of a result without code. TOOL_CALL_ERROR_CODES names both."
7
+ },
2
8
  {
3
9
  "version": "0.388.0",
4
10
  "type": "fix",
@@ -9,7 +9,7 @@ export { buildAgentManifest } from "./agent-manifest.ts";
9
9
  export { AGENT_TOOLS_FEATURE_NAME, createAgentToolsFeature } from "./feature.ts";
10
10
  export { buildToolCatalog, OPEN_FORM_TOOL_NAME, toolNameForQn } from "./tool-catalog.ts";
11
11
  export type { ToolCallRequest, ToolCallResult, ToolDispatcher } from "./tool-dispatch.ts";
12
- export { dispatchToolCall } from "./tool-dispatch.ts";
12
+ export { dispatchToolCall, TOOL_CALL_ERROR_CODES } from "./tool-dispatch.ts";
13
13
  export type {
14
14
  AgentManifest,
15
15
  AgentManifestEntity,
@@ -58,6 +58,21 @@ type ToolFailure = {
58
58
 
59
59
  type FailureClassifier = (failure: ToolFailure, cause: unknown) => ToolCallResult;
60
60
 
61
+ export const TOOL_CALL_ERROR_CODES = {
62
+ invalidToolCall: "invalid_tool_call",
63
+ unknownTool: "unknown_tool",
64
+ } as const;
65
+
66
+ type ToolCallFailure = Extract<ToolCallResult, { readonly ok: false }>;
67
+
68
+ function invalidToolCall(error: string): ToolCallFailure {
69
+ return { ok: false, error, code: TOOL_CALL_ERROR_CODES.invalidToolCall };
70
+ }
71
+
72
+ function unknownTool(error: string): ToolCallFailure {
73
+ return { ok: false, error, code: TOOL_CALL_ERROR_CODES.unknownTool };
74
+ }
75
+
61
76
  const HTTP_TOO_MANY_REQUESTS = 429;
62
77
  const HTTP_SERVER_ERROR = 500;
63
78
  const UNEXPECTED_FAILURE_MESSAGE = "Tool failed unexpectedly";
@@ -108,7 +123,7 @@ type FilterOp = (typeof FILTER_OPS)[number];
108
123
 
109
124
  type PayloadResult =
110
125
  | { readonly ok: true; readonly value: Readonly<Record<string, unknown>> }
111
- | { readonly ok: false; readonly error: string };
126
+ | ToolCallFailure;
112
127
 
113
128
  function isFilterOp(value: unknown): value is FilterOp {
114
129
  return isOneOf(FILTER_OPS, value);
@@ -120,7 +135,7 @@ function buildSearchPayload(
120
135
  ): PayloadResult {
121
136
  const query = toolInput["query"];
122
137
  if (typeof query !== "string" || query.length === 0) {
123
- return { ok: false, error: `Tool "${toolName}" requires a non-empty string "query" argument` };
138
+ return invalidToolCall(`Tool "${toolName}" requires a non-empty string "query" argument`);
124
139
  }
125
140
  return { ok: true, value: { search: query, limit: SEARCH_RESULT_LIMIT } };
126
141
  }
@@ -132,7 +147,7 @@ function buildFindByPayload(
132
147
  ): PayloadResult {
133
148
  const value = toolInput[fieldName];
134
149
  if (value === undefined) {
135
- return { ok: false, error: `Tool "${toolName}" requires a "${fieldName}" argument` };
150
+ return invalidToolCall(`Tool "${toolName}" requires a "${fieldName}" argument`);
136
151
  }
137
152
  return {
138
153
  ok: true,
@@ -156,10 +171,10 @@ function buildEntityListPayload(
156
171
  const search = input["search"];
157
172
  if (search !== undefined) {
158
173
  if (typeof search !== "string") {
159
- return { ok: false, error: `"search" must be a string` };
174
+ return invalidToolCall(`"search" must be a string`);
160
175
  }
161
176
  if (list.searchableFields.length === 0) {
162
- return { ok: false, error: "This entity has no searchable fields" };
177
+ return invalidToolCall("This entity has no searchable fields");
163
178
  }
164
179
  payload["search"] = search;
165
180
  }
@@ -167,18 +182,18 @@ function buildEntityListPayload(
167
182
  const filters = input["filters"];
168
183
  if (filters !== undefined) {
169
184
  if (!Array.isArray(filters)) {
170
- return { ok: false, error: `"filters" must be an array` };
185
+ return invalidToolCall(`"filters" must be an array`);
171
186
  }
172
187
  const validated: Array<{ field: string; op: FilterOp; value: unknown }> = [];
173
188
  for (const entry of filters) {
174
189
  if (!isRecord(entry) || typeof entry["field"] !== "string" || !("value" in entry)) {
175
- return { ok: false, error: `Each filter must be { field, op, value }` };
190
+ return invalidToolCall(`Each filter must be { field, op, value }`);
176
191
  }
177
192
  if (!list.filterableFields.includes(entry["field"])) {
178
- return { ok: false, error: `Field "${entry["field"]}" is not filterable on this entity` };
193
+ return invalidToolCall(`Field "${entry["field"]}" is not filterable on this entity`);
179
194
  }
180
195
  if (!isFilterOp(entry["op"])) {
181
- return { ok: false, error: `Unsupported filter operator "${String(entry["op"])}"` };
196
+ return invalidToolCall(`Unsupported filter operator "${String(entry["op"])}"`);
182
197
  }
183
198
  validated.push({ field: entry["field"], op: entry["op"], value: entry["value"] });
184
199
  }
@@ -250,7 +265,7 @@ async function dispatchServerQuery(
250
265
  if (descriptor.detail) {
251
266
  const id = input["id"];
252
267
  if (typeof id !== "string" || id.length === 0) {
253
- return { ok: false, error: `This tool requires a non-empty string "id" argument` };
268
+ return invalidToolCall(`This tool requires a non-empty string "id" argument`);
254
269
  }
255
270
  const data = await dispatcher.query(descriptor.qn, { id }, user);
256
271
  return { ok: true, data };
@@ -285,16 +300,16 @@ async function dispatchServerWrite(
285
300
  if (descriptor.injectsVersion) {
286
301
  const id = input["id"];
287
302
  if (typeof id !== "string" || id.length === 0) {
288
- return { ok: false, error: `This tool requires a non-empty string "id" argument` };
303
+ return invalidToolCall(`This tool requires a non-empty string "id" argument`);
289
304
  }
290
305
  if (!descriptor.detailQn) {
291
- return { ok: false, error: "Tool is misconfigured: no detail handler to read version from" };
306
+ return invalidToolCall("Tool is misconfigured: no detail handler to read version from");
292
307
  }
293
308
  const current = await dispatcher.query(descriptor.detailQn, { id }, user);
294
309
  const version =
295
310
  isRecord(current) && typeof current["version"] === "number" ? current["version"] : undefined;
296
311
  if (version === undefined) {
297
- return { ok: false, error: `Could not read the current "version" for id "${id}"` };
312
+ return invalidToolCall(`Could not read the current "version" for id "${id}"`);
298
313
  }
299
314
  payload = { ...input, version };
300
315
  }
@@ -336,10 +351,10 @@ function dispatchClientNavigate(
336
351
  const entity = input["entity"];
337
352
  if (typeof entity === "string") {
338
353
  const screenId = descriptor.entityScreens.get(entity);
339
- if (!screenId) return { ok: false, error: `Unknown entity "${entity}" for navigation` };
354
+ if (!screenId) return invalidToolCall(`Unknown entity "${entity}" for navigation`);
340
355
  const id = input["id"];
341
356
  if (typeof id !== "string" || id.length === 0) {
342
- return { ok: false, error: `Navigating to "${entity}" requires a non-empty string "id"` };
357
+ return invalidToolCall(`Navigating to "${entity}" requires a non-empty string "id"`);
343
358
  }
344
359
  return { ok: true, data: { kind: "navigate", screenId, params: { id } } };
345
360
  }
@@ -347,19 +362,19 @@ function dispatchClientNavigate(
347
362
  const screenId = input["screenId"];
348
363
  if (typeof screenId === "string") {
349
364
  if (!descriptor.screenIds.has(screenId)) {
350
- return { ok: false, error: `Unknown screen "${screenId}"` };
365
+ return invalidToolCall(`Unknown screen "${screenId}"`);
351
366
  }
352
367
  const params = input["params"];
353
368
  if (params !== undefined) {
354
369
  if (!isRecord(params) || Object.values(params).some((value) => typeof value !== "string")) {
355
- return { ok: false, error: `"params" must be an object of string values` };
370
+ return invalidToolCall(`"params" must be an object of string values`);
356
371
  }
357
372
  return { ok: true, data: { kind: "navigate", screenId, params } };
358
373
  }
359
374
  return { ok: true, data: { kind: "navigate", screenId, params: {} } };
360
375
  }
361
376
 
362
- return { ok: false, error: `navigate requires either { entity, id } or { screenId }` };
377
+ return invalidToolCall(`navigate requires either { entity, id } or { screenId }`);
363
378
  }
364
379
 
365
380
  function dispatchClientOpenForm(
@@ -368,10 +383,10 @@ function dispatchClientOpenForm(
368
383
  ): ToolCallResult {
369
384
  const handlerQn = input["handlerQn"];
370
385
  if (typeof handlerQn !== "string") {
371
- return { ok: false, error: `open_form requires a string "handlerQn" argument` };
386
+ return invalidToolCall(`open_form requires a string "handlerQn" argument`);
372
387
  }
373
388
  const screenId = descriptor.formScreens.get(handlerQn);
374
- if (!screenId) return { ok: false, error: `No form screen mounted for handler "${handlerQn}"` };
389
+ if (!screenId) return invalidToolCall(`No form screen mounted for handler "${handlerQn}"`);
375
390
  return {
376
391
  ok: true,
377
392
  data: { kind: "open_form", screenId, handlerQn, prefill: input["prefill"] ?? {} },
@@ -385,14 +400,14 @@ function isStringArray(value: unknown): value is readonly string[] {
385
400
  function dispatchClientAskUser(input: Readonly<Record<string, unknown>>): ToolCallResult {
386
401
  const question = input["question"];
387
402
  if (typeof question !== "string" || question.length === 0) {
388
- return { ok: false, error: `ask_user requires a non-empty string "question" argument` };
403
+ return invalidToolCall(`ask_user requires a non-empty string "question" argument`);
389
404
  }
390
405
  const options = input["options"];
391
406
  if (options === undefined) {
392
407
  return { ok: true, data: { kind: "ask_user", question } };
393
408
  }
394
409
  if (!isStringArray(options)) {
395
- return { ok: false, error: `ask_user "options" argument must be an array of strings` };
410
+ return invalidToolCall(`ask_user "options" argument must be an array of strings`);
396
411
  }
397
412
  return { ok: true, data: { kind: "ask_user", question, options } };
398
413
  }
@@ -413,9 +428,10 @@ function dispatchClient(
413
428
 
414
429
  /** Executes one LLM-issued tool call. Never throws: every failure comes back as `{ ok: false }`
415
430
  * so the agent loop can feed the error back to the model instead of crashing.
416
- * Handler and dispatcher failures carry a `code` (429 adds `retryable`); client errors (< 500)
417
- * keep their message, server failures (>= 500, unknown throws) are logged and returned as a
418
- * generic message. Local validation errors (unknown tool, bad arguments) have no `code`.
431
+ * Every failure carries a `code`: the handler's own code for handler and dispatcher failures
432
+ * (429 adds `retryable`), `invalid_tool_call` for bad arguments and `unknown_tool` for a tool
433
+ * that is not in the table. Client errors (< 500) keep their message, server failures (>= 500,
434
+ * unknown throws) are logged and returned as a generic message.
419
435
  * `request.user` must be the caller's real identity: server-kind tools run through the same
420
436
  * permission-checked pipeline as an HTTP request. */
421
437
  export async function dispatchToolCall(request: ToolCallRequest): Promise<ToolCallResult> {
@@ -423,7 +439,7 @@ export async function dispatchToolCall(request: ToolCallRequest): Promise<ToolCa
423
439
  const classifyFailure = createFailureClassifier(toolName, request.log ?? fallbackLog);
424
440
  const descriptor = dispatchTable.get(toolName);
425
441
  if (!descriptor) {
426
- return { ok: false, error: `Unknown tool "${toolName}"` };
442
+ return unknownTool(`Unknown tool "${toolName}"`);
427
443
  }
428
444
 
429
445
  try {
@@ -49,6 +49,7 @@ export function createAuditFeature(): FeatureDefinition {
49
49
  return defineFeature(
50
50
  "audit",
51
51
  (r) => {
52
+ r.adoptsBundledLists();
52
53
  r.describe(
53
54
  "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).",
54
55
  );
@@ -1,4 +1,16 @@
1
1
  [
2
+ {
3
+ "version": "0.389.0",
4
+ "type": "improvement",
5
+ "title": "Optional returnTo for password reset and signup",
6
+ "detail": "Password reset and signup accept an optional returnTo path. A safe value (same-origin path, no dot segments) is appended to the mailed link as a query parameter, the reset and signup-complete screens follow it, and the login screen forwards its next to the forgot-password and signup links. Invalid values are dropped silently and never block the mail. isSafeReturnPath and readReturnToFromSearch are exported from the web barrel. isSafeNextPath now uses the same check, so a next with a dot segment such as \"/a/../b\" is no longer followed."
7
+ },
8
+ {
9
+ "version": "0.389.0",
10
+ "type": "improvement",
11
+ "title": "CurrentUser carries tenantId, logout clears remembered list state",
12
+ "detail": "The session CurrentUser has an optional tenantId used to scope remembered list state; logout removes all kumiko.list.* storage entries."
13
+ },
2
14
  {
3
15
  "version": "0.385.0",
4
16
  "type": "improvement",
@@ -31,6 +31,7 @@ export function createRequestAccountUnlockHandler(opts: RequestAccountUnlockOpti
31
31
  // empty) lockout state — harmless no-op if they weren't locked.
32
32
  extraSilentSkip: () => false,
33
33
  notificationType: AuthMailNotificationTypes.accountUnlock,
34
+ forwardsReturnTo: false,
34
35
  },
35
36
  opts,
36
37
  );
@@ -28,6 +28,7 @@ export function createRequestEmailVerificationHandler(opts: RequestEmailVerifica
28
28
  // caller sees the same 200 regardless of whether a token was minted.
29
29
  extraSilentSkip: (user) => user.emailVerified === true,
30
30
  notificationType: AuthMailNotificationTypes.emailVerification,
31
+ forwardsReturnTo: false,
31
32
  },
32
33
  opts,
33
34
  );
@@ -30,6 +30,7 @@ export function createRequestPasswordResetHandler(opts: RequestPasswordResetOpti
30
30
  // state. The sessions feature handles the post-change revocation.
31
31
  extraSilentSkip: () => false,
32
32
  notificationType: AuthMailNotificationTypes.passwordReset,
33
+ forwardsReturnTo: true,
33
34
  },
34
35
  opts,
35
36
  );
@@ -31,6 +31,7 @@ import { resolveHandlerMailLocale } from "../magic-link-mail.ts";
31
31
  import { AUTH_SELF_REGISTRATION_FEATURE } from "../self-registration-toggle.ts";
32
32
  import { issueSignupActivation } from "../signup-activation.ts";
33
33
  import { storeSignupHandover, takeOverSignupHandoverForResend } from "../signup-token-store.ts";
34
+ import { MAX_RETURN_PATH_LENGTH } from "../web/return-path.ts";
34
35
 
35
36
  const SignupRequestSchema = z.object({
36
37
  email: z.email(),
@@ -43,6 +44,8 @@ const SignupRequestSchema = z.object({
43
44
  token: z.string().min(1),
44
45
  })
45
46
  .optional(),
47
+ // A bad returnTo must not void the signup; the mail builder drops unsafe values.
48
+ returnTo: z.string().max(MAX_RETURN_PATH_LENGTH).optional().catch(undefined),
46
49
  });
47
50
 
48
51
  const HANDOVER_GRANT_VERIFY_REASON =
@@ -133,6 +136,7 @@ export function createSignupRequestHandler(opts: SignupRequestOptions) {
133
136
  email,
134
137
  appUrl: opts.appUrl,
135
138
  timeZone: ctx.tz.user,
139
+ ...(event.payload.returnTo !== undefined && { returnTo: event.payload.returnTo }),
136
140
  ...(opts.tokenTtlMinutes !== undefined && { tokenTtlMinutes: opts.tokenTtlMinutes }),
137
141
  ...(opts.appName !== undefined && { appName: opts.appName }),
138
142
  ...(locale !== undefined && { locale }),
@@ -22,9 +22,12 @@ import { type AuthUserRow, parseAuthUserRow } from "../auth-user-row.ts";
22
22
  import { AUTH_EMAIL_PAYLOAD_RATE_LIMITS } from "../email-rate-limits.ts";
23
23
  import type { AuthMailLocale } from "../email-templates.ts";
24
24
  import { dispatchMagicLinkMail, resolveHandlerMailLocale } from "../magic-link-mail.ts";
25
+ import { MAX_RETURN_PATH_LENGTH } from "../web/return-path.ts";
25
26
 
26
27
  const RequestTokenSchema = z.object({
27
28
  email: z.email(),
29
+ // A bad returnTo must not void the request; the mail builder drops unsafe values.
30
+ returnTo: z.string().max(MAX_RETURN_PATH_LENGTH).optional().catch(undefined),
28
31
  });
29
32
 
30
33
  // What the route layer reads off `result.data` after dispatching. Identical
@@ -67,6 +70,9 @@ export type TokenRequestSpec<TName extends string, TSuccessKind extends string>
67
70
  // Trigger-less r.notification dispatched via ctx.notify on success; the
68
71
  // recipient is an anonymous email, so route:{email} delivers directly.
69
72
  readonly notificationType: string;
73
+ // Only flows whose landing page can continue to a page (password reset) put the request's
74
+ // returnTo into the mail link; verification and unlock ignore it.
75
+ readonly forwardsReturnTo: boolean;
70
76
  };
71
77
 
72
78
  export type TokenRequestOptions = {
@@ -159,6 +165,8 @@ export function createTokenRequestHandler<TName extends string, TSuccessKind ext
159
165
  timeZone: recipientTimeZone(user, ctx.tz.user),
160
166
  ...(opts.appName !== undefined && { appName: opts.appName }),
161
167
  locale,
168
+ ...(spec.forwardsReturnTo &&
169
+ event.payload.returnTo !== undefined && { returnTo: event.payload.returnTo }),
162
170
  },
163
171
  );
164
172
 
@@ -12,6 +12,7 @@ import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
12
12
  import { mailT, resolveMailLocale } from "@cosmicdrift/kumiko-framework/i18n";
13
13
  import { type AuthMailLocale, tokenMailExpiry } from "./email-templates.ts";
14
14
  import { authMailTemplateLocale } from "./mail-notifications.ts";
15
+ import { isSafeReturnPath, RETURN_TO_QUERY_PARAM } from "./web/return-path.ts";
15
16
 
16
17
  // Per-flow constants: which notification type to dispatch. fallbackAppName
17
18
  // replaces the translated default when the flow has no app name of its own.
@@ -38,15 +39,23 @@ export type MagicLinkMailParams = {
38
39
  readonly timeZone?: string;
39
40
  // Invite mails name the role the recipient is invited as.
40
41
  readonly role?: string;
42
+ /** Page the landing page should lead to afterwards; an unsafe value is left out of the link. */
43
+ readonly returnTo?: string;
41
44
  };
42
45
 
43
46
  function resolveAppUrl(appUrl: string | ((locale: string) => string), locale: string): string {
44
47
  return typeof appUrl === "function" ? appUrl(locale) : appUrl;
45
48
  }
46
49
 
47
- function appendToken(appUrl: string, token: string): string {
50
+ function appendTokenAndReturnTo(
51
+ appUrl: string,
52
+ token: string,
53
+ returnTo: string | undefined,
54
+ ): string {
55
+ const query = new URLSearchParams({ token });
56
+ if (isSafeReturnPath(returnTo)) query.set(RETURN_TO_QUERY_PARAM, returnTo);
48
57
  const sep = appUrl.includes("?") ? "&" : "?";
49
- return `${appUrl}${sep}token=${encodeURIComponent(token)}`;
58
+ return `${appUrl}${sep}${query.toString()}`;
50
59
  }
51
60
 
52
61
  // X-Locale on the request wins over the handler's static opts.locale, then
@@ -93,7 +102,11 @@ export async function dispatchMagicLinkMail(
93
102
  data: {
94
103
  appName:
95
104
  params.appName ?? spec.fallbackAppName ?? mailT(templateLocale, "auth.mail.appNameDefault"),
96
- actionUrl: appendToken(resolveAppUrl(params.appUrl, urlLocale), params.token),
105
+ actionUrl: appendTokenAndReturnTo(
106
+ resolveAppUrl(params.appUrl, urlLocale),
107
+ params.token,
108
+ params.returnTo,
109
+ ),
97
110
  validFor,
98
111
  validUntil,
99
112
  ...(params.role !== undefined && { role: params.role }),
@@ -28,6 +28,7 @@ export type IssueSignupActivationArgs = {
28
28
  readonly locale?: string;
29
29
  readonly timeZone?: string;
30
30
  readonly tokenTtlMinutes?: number;
31
+ readonly returnTo?: string;
31
32
  /** Runs after the token is stored and before the mail goes out. */
32
33
  readonly onTokenStored?: (stored: {
33
34
  readonly token: string;
@@ -75,6 +76,7 @@ export async function issueSignupActivation(
75
76
  ...(args.timeZone !== undefined && { timeZone: args.timeZone }),
76
77
  ...(args.appName !== undefined && { appName: args.appName }),
77
78
  ...(args.locale !== undefined && { locale: args.locale }),
79
+ ...(args.returnTo !== undefined && { returnTo: args.returnTo }),
78
80
  },
79
81
  );
80
82
 
@@ -226,12 +226,13 @@ async function parseTokenFailure(res: Response): Promise<AuthTokenFailure> {
226
226
  // found. 429 → typed rate-limit failure. 5xx → unknown-error.
227
227
  export async function requestPasswordReset(
228
228
  email: string,
229
+ returnTo?: string,
229
230
  ): Promise<{ ok: true } | { ok: false; error: AuthTokenFailure }> {
230
231
  const res = await fetch("/api/auth/request-password-reset", {
231
232
  method: "POST",
232
233
  credentials: "same-origin",
233
234
  headers: { "Content-Type": "application/json", ...csrfHeader() },
234
- body: JSON.stringify({ email }),
235
+ body: JSON.stringify({ email, ...(returnTo !== undefined && { returnTo }) }),
235
236
  });
236
237
  if (res.ok) return { ok: true };
237
238
  return { ok: false, error: await parseTokenFailure(res) };
@@ -332,12 +333,17 @@ export async function requestSignup(
332
333
  // travels any further than this request — never in the URL, the mail,
333
334
  // or the confirm response.
334
335
  handover?: { readonly entityType: string; readonly token: string },
336
+ returnTo?: string,
335
337
  ): Promise<{ ok: true } | { ok: false; error: AuthTokenFailure }> {
336
338
  const res = await fetch("/api/auth/signup-request", {
337
339
  method: "POST",
338
340
  credentials: "same-origin",
339
341
  headers: { "Content-Type": "application/json", ...csrfHeader() },
340
- body: JSON.stringify({ email, ...(handover !== undefined && { handover }) }),
342
+ body: JSON.stringify({
343
+ email,
344
+ ...(handover !== undefined && { handover }),
345
+ ...(returnTo !== undefined && { returnTo }),
346
+ }),
341
347
  });
342
348
  if (res.ok) return { ok: true };
343
349
  return { ok: false, error: await parseTokenFailure(res) };
@@ -2,35 +2,37 @@
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
4
 
5
- export const NEXT_QUERY_PARAM = "next";
5
+ import { isSafeReturnPath, RETURN_TO_QUERY_PARAM, readReturnToFromSearch } from "./return-path.ts";
6
6
 
7
- function hasControlOrWhitespace(candidate: string): boolean {
8
- for (let i = 0; i < candidate.length; i++) {
9
- const code = candidate.charCodeAt(i);
10
- if (code <= 0x20 || code === 0x7f) return true;
11
- }
12
- return false;
13
- }
7
+ export const NEXT_QUERY_PARAM = "next";
14
8
 
15
- // The URL parse is a backstop: browsers strip tabs/newlines, so "/\t/evil.com"
16
- // would otherwise normalize to "//evil.com" past the leading-slash check.
17
- export function isSafeNextPath(candidate: unknown): candidate is string {
18
- if (typeof candidate !== "string" || !candidate.startsWith("/")) return false;
19
- if (candidate[1] === "/" || candidate.includes("\\")) return false;
20
- if (hasControlOrWhitespace(candidate)) return false;
21
- try {
22
- const parsed = new URL(candidate, "https://next.invalid");
23
- return parsed.origin === "https://next.invalid" && !parsed.pathname.startsWith("//");
24
- } catch {
25
- return false;
26
- }
27
- }
9
+ export const isSafeNextPath = isSafeReturnPath;
28
10
 
29
11
  export function readNextFromSearch(search: string): string | null {
30
12
  const candidate = new URLSearchParams(search).get(NEXT_QUERY_PARAM);
31
13
  return isSafeNextPath(candidate) ? candidate : null;
32
14
  }
33
15
 
16
+ // The returnTo a mail link or sibling auth page carried in this page's own URL; null on native, where there is no window.
17
+ export function readReturnToFromWindow(): string | null {
18
+ return typeof window === "undefined" ? null : readReturnToFromSearch(window.location.search);
19
+ }
20
+
21
+ // Adds returnTo to an href of a sibling auth page; a missing value leaves the href untouched.
22
+ export function appendReturnTo(href: string, returnTo: string | null): string {
23
+ if (returnTo === null) return href;
24
+ const hashAt = href.indexOf("#");
25
+ const base = hashAt === -1 ? href : href.slice(0, hashAt);
26
+ const hash = hashAt === -1 ? "" : href.slice(hashAt);
27
+ const separator = base.includes("?") ? "&" : "?";
28
+ return `${base}${separator}${RETURN_TO_QUERY_PARAM}=${encodeURIComponent(returnTo)}${hash}`;
29
+ }
30
+
31
+ // The safe `next` of this page's URL; null on native, where there is no window.
32
+ export function readNextFromWindow(): string | null {
33
+ return typeof window === "undefined" ? null : readNextFromSearch(window.location.search);
34
+ }
35
+
34
36
  // loginUrl/postLogoutUrl come from app code (trusted), but a typo like
35
37
  // "javascript:" must fail at startup instead of becoming a navigation target.
36
38
  export function assertNavigableUrl(url: string, optionName: string): void {
@@ -1,3 +1,4 @@
1
+ import { stripBasePath, useBasePath } from "@cosmicdrift/kumiko-renderer";
1
2
  import type { ReactNode } from "react";
2
3
  import { DEFAULT_AUTH_PATHS, DEFAULT_FORGOT_PASSWORD_PATH } from "./auth-paths.ts";
3
4
  import { ConfirmAccountUnlockScreen } from "./confirm-account-unlock-screen.tsx";
@@ -9,8 +10,9 @@ function hasToken(search: string): boolean {
9
10
  return (new URLSearchParams(search).get("token") ?? "") !== "";
10
11
  }
11
12
 
12
- function pickDefaultAuthPage(pathname: string, search: string): ReactNode {
13
- const path = pathname.length > 1 ? pathname.replace(/\/+$/, "") : pathname;
13
+ function pickDefaultAuthPage(pathname: string, search: string, basePath: string): ReactNode {
14
+ const trimmed = pathname.length > 1 ? pathname.replace(/\/+$/, "") : pathname;
15
+ const path = stripBasePath(trimmed, basePath);
14
16
  switch (path) {
15
17
  case DEFAULT_FORGOT_PASSWORD_PATH:
16
18
  return <ForgotPasswordScreen />;
@@ -27,5 +29,6 @@ function pickDefaultAuthPage(pathname: string, search: string): ReactNode {
27
29
  // (outer) still win on the same path. The pages are public: no session needed.
28
30
  export function DefaultAuthPagesGate({ children }: { readonly children: ReactNode }): ReactNode {
29
31
  const { pathname, search } = window.location;
30
- return pickDefaultAuthPage(pathname, search) ?? <>{children}</>;
32
+ const basePath = useBasePath();
33
+ return pickDefaultAuthPage(pathname, search, basePath) ?? <>{children}</>;
31
34
  }
@@ -12,6 +12,8 @@ import { usePrimitives, useTranslation } from "@cosmicdrift/kumiko-renderer";
12
12
  import { type FormEvent, type ReactNode, useState } from "react";
13
13
  import { requestPasswordReset } from "./auth-client.ts";
14
14
  import { AuthCard } from "./auth-form-primitives.tsx";
15
+ import { readReturnToFromWindow } from "./auth-redirect.ts";
16
+ import { isSafeReturnPath } from "./return-path.ts";
15
17
 
16
18
  export type ForgotPasswordScreenProps = {
17
19
  readonly title?: string;
@@ -19,12 +21,20 @@ export type ForgotPasswordScreenProps = {
19
21
  /** href for the "Back to login" link in the success and form states. App-
20
22
  * specific — defaults to "/login". */
21
23
  readonly loginHref?: string;
24
+ /** Page the reset mail should lead back to after the new password is set; defaults to the
25
+ * `returnTo` query parameter of this page. Unsafe values are dropped. */
26
+ readonly returnTo?: string;
22
27
  };
23
28
 
29
+ function resolveReturnTo(prop: string | undefined): string | undefined {
30
+ return (isSafeReturnPath(prop) ? prop : readReturnToFromWindow()) ?? undefined;
31
+ }
32
+
24
33
  export function ForgotPasswordScreen({
25
34
  title,
26
35
  subtitle,
27
36
  loginHref = "/login",
37
+ returnTo,
28
38
  }: ForgotPasswordScreenProps): ReactNode {
29
39
  const t = useTranslation();
30
40
  const { Form, Field, Input, Button, Banner, Link } = usePrimitives();
@@ -38,7 +48,7 @@ export function ForgotPasswordScreen({
38
48
  setSubmitting(true);
39
49
  setError(null);
40
50
  try {
41
- const res = await requestPasswordReset(email);
51
+ const res = await requestPasswordReset(email, resolveReturnTo(returnTo));
42
52
  if (res.ok) {
43
53
  setDone(true);
44
54
  } else if (res.error.reason === "rate_limited") {
@@ -63,6 +63,11 @@ export type { RequestAccountUnlockScreenProps } from "./request-account-unlock-s
63
63
  export { RequestAccountUnlockScreen } from "./request-account-unlock-screen.tsx";
64
64
  export type { ResetPasswordScreenProps } from "./reset-password-screen.tsx";
65
65
  export { ResetPasswordScreen } from "./reset-password-screen.tsx";
66
+ export {
67
+ isSafeReturnPath,
68
+ RETURN_TO_QUERY_PARAM,
69
+ readReturnToFromSearch,
70
+ } from "./return-path.ts";
66
71
  export type {
67
72
  SessionApi,
68
73
  SessionBootstrapFailure,
@@ -2,6 +2,8 @@
2
2
  // reason code needs a bundle entry plus a line in that mapping.
3
3
 
4
4
  import {
5
+ prependBasePath,
6
+ useBasePath,
5
7
  useOptionalDispatcher,
6
8
  usePrimitives,
7
9
  useQuery,
@@ -15,7 +17,11 @@ import {
15
17
  } from "./auth-client.ts";
16
18
  import { AuthCard } from "./auth-form-primitives.tsx";
17
19
  import { DEFAULT_AUTH_PATHS, DEFAULT_FORGOT_PASSWORD_PATH } from "./auth-paths.ts";
18
- import { followNextOrLandingAfterLogin } from "./auth-redirect.ts";
20
+ import {
21
+ appendReturnTo,
22
+ followNextOrLandingAfterLogin,
23
+ readNextFromWindow,
24
+ } from "./auth-redirect.ts";
19
25
  import { AuthQueries } from "./constants.ts";
20
26
  import { useSession } from "./session.tsx";
21
27
 
@@ -128,16 +134,21 @@ function LoginScreenBody({
128
134
  // Hides the resend button once the email input changes; otherwise resend
129
135
  // would silently go to the edited, possibly mistyped address.
130
136
  const [failedLoginEmail, setFailedLoginEmail] = useState<string | null>(null);
137
+ const basePath = useBasePath();
138
+ // The page the user was sent away from rides along so the reset or signup mail leads back to it.
139
+ const returnTo = readNextFromWindow();
131
140
  const effectiveForgotHref =
132
141
  forgotPasswordHref === undefined
133
142
  ? loginOptions?.passwordReset === true
134
- ? DEFAULT_FORGOT_PASSWORD_PATH
143
+ ? appendReturnTo(prependBasePath(DEFAULT_FORGOT_PASSWORD_PATH, basePath), returnTo)
135
144
  : null
136
- : forgotPasswordHref;
145
+ : forgotPasswordHref === null
146
+ ? null
147
+ : appendReturnTo(forgotPasswordHref, returnTo);
137
148
  const effectiveUnlockHref =
138
149
  unlockAccountHref === undefined
139
150
  ? loginOptions?.accountUnlock === true
140
- ? DEFAULT_AUTH_PATHS.unlockAccount
151
+ ? prependBasePath(DEFAULT_AUTH_PATHS.unlockAccount, basePath)
141
152
  : null
142
153
  : unlockAccountHref;
143
154
 
@@ -281,7 +292,7 @@ function LoginScreenBody({
281
292
  </Link>
282
293
  )}
283
294
  {signupHref !== undefined && (
284
- <Link href={signupHref} variant="muted" className="self-center">
295
+ <Link href={appendReturnTo(signupHref, returnTo)} variant="muted" className="self-center">
285
296
  {t("auth.signup.title")}
286
297
  </Link>
287
298
  )}