@k2b/cloud 0.37.0 → 0.39.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 (149) hide show
  1. package/package.json +8 -3
  2. package/scripts/build-canvas-workers.ts +109 -29
  3. package/scripts/build.ts +3 -5
  4. package/src/_internal/app-path.ts +25 -0
  5. package/src/_internal/app-presentation.ts +4 -0
  6. package/src/_internal/canvas-worker.ts +5 -3
  7. package/src/_internal/define-app.ts +28 -2
  8. package/src/_internal/help-catalog.ts +123 -4
  9. package/src/_internal/help.ts +27 -6
  10. package/src/_internal/panel-cookie.ts +39 -0
  11. package/src/_internal/registry-validation.ts +21 -26
  12. package/src/_internal/runtime-context.ts +1 -0
  13. package/src/access/PermissionEditor.browser-harness.tsx +57 -38
  14. package/src/access/PermissionEditor.tsx +30 -5
  15. package/src/access/ResourceApiKeys.tsx +5 -1
  16. package/src/account/Avatar.tsx +20 -2
  17. package/src/account/ui.ts +1 -1
  18. package/src/ai/approval-routes.ts +5 -2
  19. package/src/ai/approvals.ts +139 -28
  20. package/src/ai/browser.ts +11 -0
  21. package/src/ai/chart-block.ts +44 -13
  22. package/src/ai/chat/action-sentences.ts +1 -44
  23. package/src/ai/chat/chart-block.tsx +2 -11
  24. package/src/ai/chat/messages.ts +10 -0
  25. package/src/ai/chat/turn-view.tsx +35 -3
  26. package/src/ai/chat/view-block.tsx +7 -2
  27. package/src/ai/chat/web-tools.tsx +23 -6
  28. package/src/ai/chat-task-contracts.ts +28 -3
  29. package/src/ai/chat-task-reasons-migrate.ts +68 -0
  30. package/src/ai/chat-task-reasons.ts +92 -0
  31. package/src/ai/chat-tasks.ts +460 -66
  32. package/src/ai/default-tool-contracts.ts +30 -13
  33. package/src/ai/fetch-file-tool.ts +37 -3
  34. package/src/ai/index.ts +12 -0
  35. package/src/ai/migrate.ts +46 -3
  36. package/src/ai/readable-html.ts +1057 -0
  37. package/src/ai/request-context.ts +7 -1
  38. package/src/ai/routes.ts +27 -1
  39. package/src/ai/runtime.ts +12 -27
  40. package/src/ai/store.ts +5 -3
  41. package/src/ai/timeline.ts +9 -1
  42. package/src/ai/tool-wording.ts +45 -0
  43. package/src/ai/turn-abort.ts +33 -0
  44. package/src/ai/types.ts +12 -1
  45. package/src/api/admin-core-settings.ts +190 -139
  46. package/src/api/admin-outgoing-mail.ts +4 -2
  47. package/src/api/app-approval.ts +9 -2
  48. package/src/api/approvals.ts +1 -1
  49. package/src/api/help.ts +23 -4
  50. package/src/api/index.ts +2 -1
  51. package/src/api/mcp.ts +8 -2
  52. package/src/browser/CloudResourceSearch.tsx +10 -6
  53. package/src/browser/FileChooser.tsx +11 -3
  54. package/src/browser/app-approval.ts +7 -2
  55. package/src/browser/choose-files.browser-harness.tsx +4 -1
  56. package/src/browser/composer-catalog.ts +103 -0
  57. package/src/browser/composer-insert-messages.ts +31 -0
  58. package/src/browser/composer-insert.ts +150 -0
  59. package/src/browser/file-chooser-messages.ts +4 -0
  60. package/src/browser/file-providers.ts +14 -1
  61. package/src/browser/panel-messages.ts +24 -0
  62. package/src/browser/panel-slot.ts +354 -0
  63. package/src/browser/panel.ts +28 -0
  64. package/src/browser/provider-folder.tsx +58 -5
  65. package/src/browser/resource-picker.tsx +17 -2
  66. package/src/cli/admin/workflows.ts +1 -0
  67. package/src/config/env.ts +14 -0
  68. package/src/contracts/app-approval.ts +49 -9
  69. package/src/contracts/app.ts +14 -0
  70. package/src/contracts/file-provider.ts +48 -1
  71. package/src/contracts/outgoing-mail.ts +8 -1
  72. package/src/contracts/registry.ts +3 -1
  73. package/src/contracts/shared.ts +2 -0
  74. package/src/server/help.ts +136 -10
  75. package/src/server/services/access.ts +5 -1
  76. package/src/server/services/geo.ts +21 -4
  77. package/src/services/accounts/posix.ts +2 -1
  78. package/src/services/allowed-network-fetch.ts +189 -0
  79. package/src/services/app-approval.ts +158 -38
  80. package/src/services/approvals/device.ts +2 -0
  81. package/src/services/help/index.ts +46 -5
  82. package/src/services/help/store.ts +26 -5
  83. package/src/services/help/types.ts +10 -2
  84. package/src/services/index.ts +22 -3
  85. package/src/services/notifications/browser.ts +16 -5
  86. package/src/services/outgoing-mail/dispatcher.ts +7 -2
  87. package/src/services/outgoing-mail/resolve-host.ts +39 -0
  88. package/src/services/outgoing-mail/test-send.ts +18 -3
  89. package/src/services/outgoing-mail/transport.ts +24 -13
  90. package/src/services/pdf/image-protocol.ts +135 -0
  91. package/src/services/pdf/image-variants.ts +124 -0
  92. package/src/services/pdf/image-worker.ts +31 -0
  93. package/src/services/pdf/image.ts +109 -0
  94. package/src/services/pdf/index.ts +9 -0
  95. package/src/services/settings/api.ts +11 -6
  96. package/src/services/settings/app.ts +5 -4
  97. package/src/services/settings/core-settings.ts +4 -3
  98. package/src/services/settings/index.ts +67 -43
  99. package/src/services/settings/namespace.ts +2 -1
  100. package/src/services/settings/store.ts +83 -11
  101. package/src/shared/ai-platform-prompt.ts +1 -1
  102. package/src/shared/app-presentation.ts +1 -0
  103. package/src/shared/help.ts +41 -0
  104. package/src/shared/index.ts +9 -1
  105. package/src/shared/markdown/extensions/guided-help.ts +208 -64
  106. package/src/shared/markdown/formula.ts +8 -8
  107. package/src/shared/markdown/index.ts +72 -11
  108. package/src/shared/redirect.ts +12 -0
  109. package/src/ssr/CloudShell.island.tsx +86 -9
  110. package/src/ssr/Layout.tsx +62 -11
  111. package/src/ssr/LayoutHeader.tsx +15 -9
  112. package/src/ssr/LayoutHelp.browser-harness.tsx +130 -27
  113. package/src/ssr/LayoutHelp.tsx +569 -410
  114. package/src/ssr/LayoutRail.tsx +2 -2
  115. package/src/ssr/LayoutTabBar.tsx +29 -0
  116. package/src/ssr/PanelLayout.tsx +27 -0
  117. package/src/ssr/PanelRuntime.island.tsx +87 -0
  118. package/src/ssr/PanelSlot.tsx +108 -0
  119. package/src/ssr/PanelTrigger.tsx +49 -0
  120. package/src/ssr/ShellRuntime.island.tsx +72 -25
  121. package/src/ssr/app-appearance.ts +3 -2
  122. package/src/ssr/app-color.ts +100 -0
  123. package/src/ssr/app-launchpad.tsx +18 -1
  124. package/src/ssr/app-navigation.ts +15 -0
  125. package/src/ssr/compact-shell.baseline.json +5270 -0
  126. package/src/ssr/compact-shell.browser-harness.ts +431 -0
  127. package/src/ssr/help-messages.ts +60 -38
  128. package/src/ssr/help.ts +3 -0
  129. package/src/ssr/index.ts +3 -0
  130. package/src/ssr/layout-help-article.ts +41 -0
  131. package/src/ssr/layout-help-navigation.ts +0 -12
  132. package/src/ssr/layout-help-registry.tsx +4 -11
  133. package/src/ssr/layout-help-search.ts +9 -12
  134. package/src/ssr/layout-help-start.ts +68 -0
  135. package/src/ssr/panel-slot.browser-harness.tsx +110 -0
  136. package/src/ssr/platform-messages.ts +34 -0
  137. package/src/ssr/rail-navigation.ts +44 -0
  138. package/src/ssr/shell-data.tsx +11 -0
  139. package/src/ssr/shell-shortcuts.ts +19 -0
  140. package/src/styles/effects.css +689 -185
  141. package/src/styles/global.css +269 -55
  142. package/src/styles/panel.css +373 -0
  143. package/src/styles/resource-search.css +25 -0
  144. package/src/styles/tokens.css +23 -6
  145. package/src/styles/utilities-navigation.css +5 -21
  146. package/src/workflows/store/events.ts +24 -0
  147. package/src/workflows/store/index.ts +8 -0
  148. package/src/workflows/store/observability.ts +6 -1
  149. package/src/workflows/store/retention.ts +157 -0
@@ -46,9 +46,10 @@ const BulkSaveSchema = z.union([
46
46
  .object({
47
47
  updates: z.record(z.string(), z.unknown()).optional().default({}),
48
48
  resets: z.array(z.string()).optional().default([]),
49
+ replaceUnreadable: z.array(z.string()).optional().default([]),
49
50
  })
50
51
  .strict(),
51
- z.record(z.string(), z.unknown()).transform((updates) => ({ updates, resets: [] as string[] })),
52
+ z.record(z.string(), z.unknown()).transform((updates) => ({ updates, resets: [] as string[], replaceUnreadable: [] as string[] })),
52
53
  ]);
53
54
  const LEGAL_DOCUMENTS = [
54
55
  { kind: "terms", path: "/legal/terms" },
@@ -58,6 +59,13 @@ const LEGAL_DOCUMENTS = [
58
59
 
59
60
  type FieldErrors = Record<string, string>;
60
61
 
62
+ const unreadableSettingMessage = (locale: string): string =>
63
+ locale.startsWith("de")
64
+ ? "Der gespeicherte Wert kann von dieser Anwendung nicht gelesen werden. Ersetze ihn ausdrücklich oder nutze den Standardwert."
65
+ : "The stored value cannot be read by this application. Replace it explicitly or use the default.";
66
+ const settingsNotSavedMessage = (locale: string): string =>
67
+ locale.startsWith("de") ? "Die Einstellungen wurden nicht gespeichert." : "The settings were not saved.";
68
+
61
69
  const isKnownSetting = (key: string): boolean => SETTINGS_MAP.has(key);
62
70
 
63
71
  const AI_PROFILES_KEY = "ai.model_profiles_json";
@@ -335,168 +343,211 @@ const app = new Hono<AuthContext>()
335
343
  if (result.ok) return c.json({ ok: true });
336
344
  return c.json({ message: result.error, kind: result.kind }, result.status);
337
345
  })
338
- .put("/", auth.requireRole("admin"), v("json", BulkSaveSchema), async (c) => {
339
- const { updates, resets } = c.req.valid("json");
340
- const updateKeys = Object.keys(updates);
341
- const keys = [...updateKeys, ...resets];
346
+ .put(
347
+ "/",
348
+ auth.requireRole("admin"),
349
+ describeRoute({
350
+ tags: ["Administration"],
351
+ summary: "Save platform settings",
352
+ ...requiresAdmin,
353
+ responses: {
354
+ 204: { description: "Settings saved" },
355
+ 400: jsonResponse(z.object({ message: z.string(), errors: z.record(z.string(), z.string()).optional() }), "Invalid settings"),
356
+ 409: jsonResponse(
357
+ z.object({ message: z.string(), errors: z.record(z.string(), z.string()), code: z.literal("setting_unreadable").optional() }),
358
+ "Stored setting unreadable or model access conflict",
359
+ ),
360
+ },
361
+ }),
362
+ v("json", BulkSaveSchema),
363
+ async (c) => {
364
+ const { updates, resets, replaceUnreadable } = c.req.valid("json");
365
+ const replace = new Set(replaceUnreadable);
366
+ const updateKeys = Object.keys(updates);
367
+ const keys = [...updateKeys, ...resets];
342
368
 
343
- if (keys.length === 0) {
344
- return c.body(null, 204);
345
- }
369
+ if (keys.length === 0 && replace.size === 0) {
370
+ return c.body(null, 204);
371
+ }
346
372
 
347
- const ownership: FieldErrors = {};
348
- for (const key of keys) {
349
- if (!isKnownSetting(key)) {
350
- ownership[key] = `Unknown setting "${key}"`;
373
+ const ownership: FieldErrors = {};
374
+ for (const key of keys) {
375
+ if (!isKnownSetting(key)) {
376
+ ownership[key] = `Unknown setting "${key}"`;
377
+ }
351
378
  }
352
- }
353
- for (const key of resets) {
354
- if (key in updates) {
355
- ownership[key] = `Setting "${key}" cannot be updated and reset in the same save`;
379
+ for (const key of replace) {
380
+ if (!Object.hasOwn(updates, key)) ownership[key] = `Setting "${key}" must be included in updates to replace its unreadable value`;
381
+ }
382
+ for (const key of resets) {
383
+ if (key in updates) {
384
+ ownership[key] = `Setting "${key}" cannot be updated and reset in the same save`;
385
+ }
386
+ }
387
+ if (Object.keys(ownership).length > 0) {
388
+ return c.json({ message: "Invalid keys", errors: ownership }, 400);
356
389
  }
357
- }
358
- if (Object.keys(ownership).length > 0) {
359
- return c.json({ message: "Invalid keys", errors: ownership }, 400);
360
- }
361
390
 
362
- // Strip AI provider keys before anything is validated or written, so the
363
- // value that gets checked is the value that gets stored.
364
- const aiSplit = AI_PROFILES_KEY in updates ? splitAiProfileCredentials(updates[AI_PROFILES_KEY]) : null;
365
- if (AI_PROFILES_KEY in updates && !aiSplit) {
366
- // Unsplittable means the keys could not be separated out, and storing the
367
- // value as posted would put them back in a setting the browser reads.
368
- return c.json({ message: "Invalid values", errors: { [AI_PROFILES_KEY]: "Model profiles must be a JSON array" } }, 400);
369
- }
370
- let accessChanges: AiModelAccessChange[] = [];
371
- if (aiSplit) {
372
- try {
373
- aiSplit.profilesJson = prepareAiModelProfileImages(aiSplit.profilesJson);
374
- } catch (error) {
375
- if (error instanceof ImageMetadataError)
376
- return c.json({ message: error.message, code: error.code, errors: { [AI_PROFILES_KEY]: error.message } }, 422);
377
- throw error;
391
+ const states = await settings.readStates(updateKeys);
392
+ const unreadable: FieldErrors = {};
393
+ for (const key of updateKeys) {
394
+ if (states.get(key)?.stored === "unreadable" && !replace.has(key)) unreadable[key] = unreadableSettingMessage(getLocale(c));
378
395
  }
379
- try {
380
- const split = splitAiModelAccess(aiSplit.profilesJson);
381
- aiSplit.profilesJson = split.profilesJson;
382
- accessChanges = split.changes;
383
- } catch {
384
- return c.json(
385
- {
386
- message: "Invalid model permissions",
387
- errors: { [AI_PROFILES_KEY]: "Invalid Assistant model permissions. Reload the settings and try again." },
388
- },
389
- 400,
390
- );
396
+ if (Object.keys(unreadable).length > 0) {
397
+ return c.json({ message: settingsNotSavedMessage(getLocale(c)), errors: unreadable, code: "setting_unreadable" }, 409);
391
398
  }
392
- }
393
- const finalValues: Record<string, unknown> = aiSplit ? { ...updates, [AI_PROFILES_KEY]: aiSplit.profilesJson } : updates;
394
399
 
395
- // Validate everything up front. settings.set validates as it writes, so a
396
- // single bad field used to leave the earlier keys already saved — with the
397
- // AI profiles among them, whose save also prunes credentials.
398
- const invalid: FieldErrors = {};
399
- const validatedValues: Record<string, unknown> = {};
400
- for (const [key, value] of Object.entries(finalValues)) {
401
- const def = SETTINGS_MAP.get(key);
402
- if (!def) continue;
403
- const validated = validateSettingValue(def, value);
404
- if (validated.ok) validatedValues[key] = validated.value;
405
- else invalid[key] = validated.error;
406
- if (key === "freeipa.ca_cert") {
407
- const certificate = validateFreeIpaCaCert(value);
408
- if (!certificate.ok) invalid[key] = certificate.error;
400
+ // Strip AI provider keys before anything is validated or written, so the
401
+ // value that gets checked is the value that gets stored.
402
+ const aiSplit = AI_PROFILES_KEY in updates ? splitAiProfileCredentials(updates[AI_PROFILES_KEY]) : null;
403
+ if (AI_PROFILES_KEY in updates && !aiSplit) {
404
+ // Unsplittable means the keys could not be separated out, and storing the
405
+ // value as posted would put them back in a setting the browser reads.
406
+ return c.json({ message: "Invalid values", errors: { [AI_PROFILES_KEY]: "Model profiles must be a JSON array" } }, 400);
409
407
  }
410
- if (INTEGER_FREEIPA_SETTINGS.has(key) && (typeof value !== "number" || !Number.isInteger(value))) {
411
- invalid[key] = "Value must be a whole number";
408
+ let accessChanges: AiModelAccessChange[] = [];
409
+ if (aiSplit) {
410
+ try {
411
+ aiSplit.profilesJson = prepareAiModelProfileImages(aiSplit.profilesJson);
412
+ } catch (error) {
413
+ if (error instanceof ImageMetadataError)
414
+ return c.json({ message: error.message, code: error.code, errors: { [AI_PROFILES_KEY]: error.message } }, 422);
415
+ throw error;
416
+ }
417
+ try {
418
+ const split = splitAiModelAccess(aiSplit.profilesJson);
419
+ aiSplit.profilesJson = split.profilesJson;
420
+ accessChanges = split.changes;
421
+ } catch {
422
+ return c.json(
423
+ {
424
+ message: "Invalid model permissions",
425
+ errors: { [AI_PROFILES_KEY]: "Invalid Assistant model permissions. Reload the settings and try again." },
426
+ },
427
+ 400,
428
+ );
429
+ }
412
430
  }
413
- }
414
- if (Object.keys(invalid).length > 0) {
415
- return c.json({ message: "Invalid values", errors: invalid }, 400);
416
- }
431
+ const finalValues: Record<string, unknown> = aiSplit ? { ...updates, [AI_PROFILES_KEY]: aiSplit.profilesJson } : updates;
417
432
 
418
- let aiPlan: AiSettingsMutationPlan;
419
- try {
420
- aiPlan = await prepareAiSettingsMutation(validatedValues, resets, aiSplit, getLocale(c));
421
- } catch (error) {
422
- console.error("[settings] failed to validate the prospective AI configuration", error);
423
- return c.json({ message: "Failed to validate AI settings" }, 500);
424
- }
425
- if (Object.keys(aiPlan.errors).length > 0) {
426
- return c.json({ message: aiSettingsNotSavedMessage(getLocale(c)), errors: aiPlan.errors, issues: aiPlan.issues ?? [] }, 400);
427
- }
433
+ // Validate everything up front. settings.set validates as it writes, so a
434
+ // single bad field used to leave the earlier keys already saved — with the
435
+ // AI profiles among them, whose save also prunes credentials.
436
+ const invalid: FieldErrors = {};
437
+ const validatedValues: Record<string, unknown> = {};
438
+ for (const [key, value] of Object.entries(finalValues)) {
439
+ const def = SETTINGS_MAP.get(key);
440
+ if (!def) continue;
441
+ const validated = validateSettingValue(def, value);
442
+ if (validated.ok) validatedValues[key] = validated.value;
443
+ else invalid[key] = validated.error;
444
+ if (key === "freeipa.ca_cert") {
445
+ const certificate = validateFreeIpaCaCert(value);
446
+ if (!certificate.ok) invalid[key] = certificate.error;
447
+ }
448
+ if (INTEGER_FREEIPA_SETTINGS.has(key) && (typeof value !== "number" || !Number.isInteger(value))) {
449
+ invalid[key] = "Value must be a whole number";
450
+ }
451
+ }
452
+ if (Object.keys(invalid).length > 0) {
453
+ return c.json({ message: "Invalid values", errors: invalid }, 400);
454
+ }
428
455
 
429
- const fieldErrors: FieldErrors = {};
430
- try {
431
- // Every settings and AI credential write runs on the transaction's own
432
- // connection, so a failure rolls the whole save back.
433
- await sql.begin(async (tx) => {
434
- for (const [key, value] of Object.entries(validatedValues)) {
435
- try {
436
- await settings.set(key, value, tx);
437
- } catch (error) {
438
- fieldErrors[key] = `Failed to update ${key}`;
439
- throw error;
456
+ let aiPlan: AiSettingsMutationPlan;
457
+ try {
458
+ aiPlan = await prepareAiSettingsMutation(validatedValues, resets, aiSplit, getLocale(c));
459
+ } catch (error) {
460
+ console.error("[settings] failed to validate the prospective AI configuration", error);
461
+ return c.json({ message: "Failed to validate AI settings" }, 500);
462
+ }
463
+ if (Object.keys(aiPlan.errors).length > 0) {
464
+ return c.json({ message: aiSettingsNotSavedMessage(getLocale(c)), errors: aiPlan.errors, issues: aiPlan.issues ?? [] }, 400);
465
+ }
466
+
467
+ const fieldErrors: FieldErrors = {};
468
+ try {
469
+ // Every settings and AI credential write runs on the transaction's own
470
+ // connection, so a failure rolls the whole save back.
471
+ await sql.begin(async (tx) => {
472
+ for (const [key, value] of Object.entries(validatedValues)) {
473
+ try {
474
+ await settings.set(key, value, tx, { replaceUnreadable: replace.has(key) });
475
+ } catch (error) {
476
+ fieldErrors[key] = `Failed to update ${key}`;
477
+ throw error;
478
+ }
440
479
  }
441
- }
442
- for (const key of resets) {
443
- try {
444
- await settings.remove(key, tx);
445
- } catch (error) {
446
- fieldErrors[key] = `Failed to reset ${key}`;
447
- throw error;
480
+ for (const key of resets) {
481
+ try {
482
+ await settings.remove(key, tx);
483
+ } catch (error) {
484
+ fieldErrors[key] = `Failed to reset ${key}`;
485
+ throw error;
486
+ }
448
487
  }
449
- }
450
- const categoryKeys = keys.filter((key) => key.startsWith("user.category."));
451
- if (categoryKeys.length > 0)
452
- await audit.record(
453
- {
454
- action: "accounts.categories.configure",
455
- outcome: "allowed",
456
- actor: { userId: c.get("user").id },
457
- target: { type: "settings", id: "user.category" },
458
- metadata: {
459
- keys: categoryKeys,
460
- updates: Object.fromEntries(categoryKeys.filter((key) => key in validatedValues).map((key) => [key, validatedValues[key]])),
461
- resets: resets.filter((key) => categoryKeys.includes(key)),
488
+ const categoryKeys = keys.filter((key) => key.startsWith("user.category."));
489
+ if (categoryKeys.length > 0)
490
+ await audit.record(
491
+ {
492
+ action: "accounts.categories.configure",
493
+ outcome: "allowed",
494
+ actor: { userId: c.get("user").id },
495
+ target: { type: "settings", id: "user.category" },
496
+ metadata: {
497
+ keys: categoryKeys,
498
+ updates: Object.fromEntries(
499
+ categoryKeys.filter((key) => key in validatedValues).map((key) => [key, validatedValues[key]]),
500
+ ),
501
+ resets: resets.filter((key) => categoryKeys.includes(key)),
502
+ },
462
503
  },
504
+ tx,
505
+ );
506
+ if (aiSplit && aiPlan.keepCredentialProfileIds) {
507
+ await storeAiCredentials(aiSplit, aiPlan.keepCredentialProfileIds, tx);
508
+ } else if (aiPlan.keepCredentialProfileIds) {
509
+ await pruneAiCredentials(aiPlan.keepCredentialProfileIds, tx);
510
+ }
511
+ if (aiPlan.headerPlan) await storeAiRequestHeaderPlan(aiPlan.headerPlan, tx);
512
+ if (aiPlan.modelProfileIds) {
513
+ await aiModelAccess.syncProfiles(aiPlan.modelProfileIds, accessChanges, tx);
514
+ }
515
+ });
516
+ } catch (error) {
517
+ if (error instanceof settings.SettingUnreadableError) {
518
+ return c.json(
519
+ {
520
+ message: settingsNotSavedMessage(getLocale(c)),
521
+ errors: { [error.key]: unreadableSettingMessage(getLocale(c)) },
522
+ code: "setting_unreadable",
463
523
  },
464
- tx,
524
+ 409,
465
525
  );
466
- if (aiSplit && aiPlan.keepCredentialProfileIds) {
467
- await storeAiCredentials(aiSplit, aiPlan.keepCredentialProfileIds, tx);
468
- } else if (aiPlan.keepCredentialProfileIds) {
469
- await pruneAiCredentials(aiPlan.keepCredentialProfileIds, tx);
470
526
  }
471
- if (aiPlan.headerPlan) await storeAiRequestHeaderPlan(aiPlan.headerPlan, tx);
472
- if (aiPlan.modelProfileIds) {
473
- await aiModelAccess.syncProfiles(aiPlan.modelProfileIds, accessChanges, tx);
527
+ if (error instanceof ImageMetadataError) return c.json({ message: error.message, code: error.code, errors: fieldErrors }, 422);
528
+ if (error instanceof AiModelAccessConflict || error instanceof AiModelAccessInvalid) {
529
+ return c.json(
530
+ { message: error.message, errors: { [AI_PROFILES_KEY]: error.message } },
531
+ error instanceof AiModelAccessConflict ? 409 : 400,
532
+ );
474
533
  }
475
- });
476
- } catch (error) {
477
- if (error instanceof ImageMetadataError) return c.json({ message: error.message, code: error.code, errors: fieldErrors }, 422);
478
- if (error instanceof AiModelAccessConflict || error instanceof AiModelAccessInvalid) {
534
+ console.error("[settings] failed to commit settings update", error);
479
535
  return c.json(
480
- { message: error.message, errors: { [AI_PROFILES_KEY]: error.message } },
481
- error instanceof AiModelAccessConflict ? 409 : 400,
536
+ {
537
+ message: "Save failed",
538
+ errors: Object.keys(fieldErrors).length > 0 ? fieldErrors : { _form: "The settings could not be saved." },
539
+ },
540
+ 500,
482
541
  );
483
542
  }
484
- console.error("[settings] failed to commit settings update", error);
485
- return c.json(
486
- {
487
- message: "Save failed",
488
- errors: Object.keys(fieldErrors).length > 0 ? fieldErrors : { _form: "The settings could not be saved." },
489
- },
490
- 500,
491
- );
492
- }
493
543
 
494
- // Only now: dropping the cache before the commit would let another
495
- // container miss, read the pre-commit row and cache it for the full TTL.
496
- await invalidateCommittedSettings(keys);
544
+ // Only now: dropping the cache before the commit would let another
545
+ // container miss, read the pre-commit row and cache it for the full TTL.
546
+ await invalidateCommittedSettings(keys);
497
547
 
498
- return c.body(null, 204);
499
- })
548
+ return c.body(null, 204);
549
+ },
550
+ )
500
551
  .delete("/:key{.+}", auth.requireRole("admin"), async (c) => {
501
552
  const key = c.req.param("key");
502
553
  if (!isKnownSetting(key)) {
@@ -131,8 +131,10 @@ export const createAdminOutgoingMailRoutes = (authenticate: MiddlewareHandler<Au
131
131
  const next = c.req.valid("json");
132
132
  const old = await readMailRetention();
133
133
  await sql.begin(async (tx) => {
134
- await settings.set(MAIL_RETENTION_KEYS[0], next.contentDays, tx);
135
- await settings.set(MAIL_RETENTION_KEYS[1], next.recordDays, tx);
134
+ // complete value from explicit admin input; nothing is merged over a read (#1303)
135
+ await settings.set(MAIL_RETENTION_KEYS[0], next.contentDays, tx, { replaceUnreadable: true });
136
+ // complete value from explicit admin input; nothing is merged over a read (#1303)
137
+ await settings.set(MAIL_RETENTION_KEYS[1], next.recordDays, tx, { replaceUnreadable: true });
136
138
  await audit.record(
137
139
  {
138
140
  ...context(c),
@@ -15,7 +15,13 @@ import {
15
15
  AppPairingStartSchema,
16
16
  } from "../contracts/app-approval";
17
17
  import { type AuthContext, auth, rateLimit, v } from "../server";
18
- import { type AppApprovalActor, AppApprovalError, appApproval, type createAppApprovalService } from "../services/app-approval";
18
+ import {
19
+ type AppApprovalActor,
20
+ AppApprovalError,
21
+ type AppTerminalLogins,
22
+ appApproval,
23
+ type createAppApprovalService,
24
+ } from "../services/app-approval";
19
25
  import { appNameIn } from "../ssr/runtime";
20
26
  import { createDeviceActions } from "./app-approval-actions";
21
27
 
@@ -24,7 +30,7 @@ const DevicePageSchema = z.object({ after: z.string().uuid().optional() }).stric
24
30
  type Service = ReturnType<typeof createAppApprovalService>;
25
31
 
26
32
  /** This is a separate credential surface, never a user API-key or OAuth scope. */
27
- export const createAppApprovalRoutes = (service: Service = appApproval, actions = createDeviceActions) => {
33
+ export const createAppApprovalRoutes = (service: Service = appApproval, actions = createDeviceActions, terminal?: AppTerminalLogins) => {
28
34
  const actor = async (c: Parameters<typeof auth.session.getToken>[0]): Promise<AppApprovalActor> => {
29
35
  // The web session (or an explicit bearer session), never the mobile app's session: a phone
30
36
  // that pairs sign-in devices would hand out authority that outlives its removal.
@@ -111,6 +117,7 @@ export const createAppApprovalRoutes = (service: Service = appApproval, actions
111
117
  await service.deviceCommand(
112
118
  c.req.valid("json"),
113
119
  actions((appId, locale) => appNameIn(c, appId, locale, appId)),
120
+ terminal,
114
121
  ),
115
122
  ),
116
123
  )
@@ -111,7 +111,7 @@ export const approvalRoutes = new Hono<AuthContext>()
111
111
  client: c.req.header(APPROVAL_CLIENT_HEADER),
112
112
  }),
113
113
  },
114
- toolApprovalContext: ctx.toolApprovalContext,
114
+ toolApprovalContext: ctx.toolApprovalChangeContext,
115
115
  signedInSession: ctx.signedInSession,
116
116
  });
117
117
  if (!result.ok) return toAiActionFailureResponse(c, result);
package/src/api/help.ts CHANGED
@@ -1,14 +1,16 @@
1
+ import { keyboardPlatform } from "@k2b/ui";
1
2
  import { Hono, type MiddlewareHandler } from "hono";
3
+ import { helpAgentMarkdown } from "../_internal/help-catalog";
2
4
  import { type AuthContext, auth, getLocale } from "../server";
3
5
  import { createHelpReader, type HelpReaderFactory } from "../services/help";
4
- import type { HelpDocumentPayload, HelpSearchPayload } from "../shared/help";
6
+ import { type HelpDocumentPayload, type HelpSearchPayload, parseHelpReference } from "../shared/help";
5
7
  import { renderHelpMarkdown } from "../shared/markdown";
6
8
  import { helpApiMessages } from "./help-messages";
7
9
 
8
10
  export type HelpRouteDependencies = {
9
11
  help?: HelpReaderFactory;
10
12
  authenticate?: MiddlewareHandler<AuthContext>;
11
- renderMarkdown?: (markdown: string, locale: string) => string;
13
+ renderMarkdown?: typeof renderHelpMarkdown;
12
14
  };
13
15
 
14
16
  export const createHelpRoutes = (dependencies: HelpRouteDependencies = {}) => {
@@ -34,12 +36,29 @@ export const createHelpRoutes = (dependencies: HelpRouteDependencies = {}) => {
34
36
  const { t } = helpApiMessages.resolve([locale]);
35
37
  const document = await reader(locale, c.get("actor")).read({ appId: c.req.param("appId"), documentId: c.req.param("documentId") });
36
38
  if (!document) return c.json({ code: "HELP_NOT_FOUND", message: t.notFound }, 404);
39
+ const links = (reference: { appId?: string; documentId: string }) =>
40
+ document.links[`${reference.appId ?? document.appId}/${reference.documentId}`];
41
+ const resolve = (reference: string) => {
42
+ const ref = parseHelpReference(reference);
43
+ return ref ? links(ref) : undefined;
44
+ };
37
45
  const payload: HelpDocumentPayload = {
38
46
  locale: document.locale,
39
47
  id: document.documentId,
40
48
  title: document.title,
41
- markdown: document.markdown,
42
- html: (dependencies.renderMarkdown ?? renderHelpMarkdown)(document.markdown, document.locale),
49
+ markdown: helpAgentMarkdown(document),
50
+ kind: document.kind,
51
+ needs: document.needs,
52
+ next: document.next ? resolve(document.next) : undefined,
53
+ related: document.related.flatMap((reference) => {
54
+ const target = resolve(reference);
55
+ return target ? [target] : [];
56
+ }),
57
+ html: (dependencies.renderMarkdown ?? renderHelpMarkdown)(document.markdown, {
58
+ locale: document.locale,
59
+ platform: keyboardPlatform(c.req.header("User-Agent") ?? ""),
60
+ links,
61
+ }),
43
62
  };
44
63
  return c.json(payload);
45
64
  });
package/src/api/index.ts CHANGED
@@ -56,6 +56,7 @@ export { createPwaPhoneRoutes } from "./pwa-phone";
56
56
  * the typed shape for the client.
57
57
  */
58
58
  export type CoreApiOptions = {
59
+ terminalLogins?: import("../services/app-approval").AppTerminalLogins;
59
60
  notifications: import("../services/auth-flows/notification-sender").AuthNotificationSender;
60
61
  };
61
62
 
@@ -68,7 +69,7 @@ const buildCoreApi = (options: CoreApiOptions) => {
68
69
  const widgetRoutes = createWidgetRoutes();
69
70
  return new Hono()
70
71
  .use(prettyJSON())
71
- .route("/auth/app-approval/v1", createAppApprovalRoutes())
72
+ .route("/auth/app-approval/v1", createAppApprovalRoutes(undefined, undefined, options.terminalLogins))
72
73
  .route("/auth/pwa/v1", createPwaRoutes())
73
74
  .route("/auth", createAuthRoutes(options.notifications))
74
75
  .route("/me", meRoutes)
package/src/api/mcp.ts CHANGED
@@ -15,7 +15,13 @@ import {
15
15
  import { Hono, type MiddlewareHandler } from "hono";
16
16
  import { z } from "zod";
17
17
  import { readBoundedJson } from "../_internal/bounded-json";
18
- import { HELP_SEARCH_MAX_LIMIT, helpResourceUri, parseHelpResourceUri, readHelpArticle } from "../_internal/help-catalog";
18
+ import {
19
+ HELP_SEARCH_MAX_LIMIT,
20
+ helpAgentMarkdown,
21
+ helpResourceUri,
22
+ parseHelpResourceUri,
23
+ readHelpArticle,
24
+ } from "../_internal/help-catalog";
19
25
  import { getCapability, listApps } from "../_internal/registry";
20
26
  import { addCloudResourceHrefs } from "../ai/resource-refs";
21
27
  import { capabilityValueMeta, recordCapabilityExecution } from "../capabilities/executions";
@@ -540,7 +546,7 @@ const createMcpServer = (
540
546
  {
541
547
  uri: message.params.uri,
542
548
  mimeType: "text/markdown",
543
- text: document.markdown,
549
+ text: helpAgentMarkdown(document),
544
550
  _meta: { "cloud/manifestHash": document.manifestHash, "cloud/locale": document.locale },
545
551
  },
546
552
  ],
@@ -44,6 +44,9 @@ export type CloudResourceSearchProps = {
44
44
  excludeRefs?: readonly CloudResourceRef[];
45
45
  };
46
46
 
47
+ /** What `/api/search` accepts as `q`; a prefilled query is cut to it as typing is. */
48
+ const MAX_QUERY_LENGTH = 500;
49
+
47
50
  const isLinkableCommand = (command: PaletteCommand) => typeof command.action !== "function" && "command" in command.action;
48
51
 
49
52
  const itemKey = (item: SearchItem) => `${item.ref.type}:${item.ref.id}`;
@@ -62,23 +65,24 @@ export default function CloudResourceSearch(props: CloudResourceSearchProps) {
62
65
  const locale = useLocale();
63
66
  const t = () => resourceSearchMessages.resolve([locale()]).t;
64
67
  const id = createUniqueId();
65
- const [input, setInput] = createSignal("");
68
+ const [input, setInput] = createSignal((props.request?.query ?? "").slice(0, MAX_QUERY_LENGTH));
66
69
  const [tags, setTags] = createSignal<string[]>([]);
67
70
  const [scope, setScope] = createSignal<SearchScope | undefined>(
68
71
  props.request?.scope ?? (props.initialAppId ? { appId: props.initialAppId, label: props.initialAppId } : undefined),
69
72
  );
70
73
  const appId = () => scope()?.appId ?? scope()?.ref?.type.split(".")[0];
71
74
  const scopeLabel = () =>
72
- !props.request && props.initialAppId
75
+ !props.request?.scope && props.initialAppId
73
76
  ? (catalogApps()?.find((app) => app.id === props.initialAppId)?.name ?? scope()?.label)
74
77
  : scope()?.label;
75
78
  createEffect(() => {
76
79
  const request = props.request;
77
80
  if (!request) return;
78
- setScope(request.scope);
81
+ setScope(request.scope ?? (props.initialAppId ? { appId: props.initialAppId, label: props.initialAppId } : undefined));
79
82
  if (request.query !== undefined) {
80
- setInput(request.query);
81
- setCaret(request.query.length);
83
+ const query = request.query.slice(0, MAX_QUERY_LENGTH);
84
+ setInput(query);
85
+ setCaret(query.length);
82
86
  }
83
87
  setTags([]);
84
88
  setBrowsingTags(false);
@@ -556,7 +560,7 @@ export default function CloudResourceSearch(props: CloudResourceSearchProps) {
556
560
  autocomplete="off"
557
561
  autocapitalize="off"
558
562
  spellcheck={false}
559
- maxLength={500}
563
+ maxLength={MAX_QUERY_LENGTH}
560
564
  />
561
565
  </div>
562
566
  </label>
@@ -14,7 +14,7 @@ import {
14
14
  } from "@k2b/ui";
15
15
  import { batch, createMemo, createSignal, For, type JSX, Match, onCleanup, Show, Switch } from "solid-js";
16
16
  import { createStore } from "solid-js/store";
17
- import type { FileProviderEntry, FileProviderTag } from "../contracts/file-provider";
17
+ import type { FileProviderContext, FileProviderEntry, FileProviderTag } from "../contracts/file-provider";
18
18
  import { fileChooserMessages } from "./file-chooser-messages";
19
19
  import {
20
20
  type EntryProblem,
@@ -39,6 +39,14 @@ export type ChooseFilesOptions = {
39
39
  * above it, so it also bounds what the browser holds. The provider's own read limit applies to each file as well.
40
40
  */
41
41
  maxBytes?: number;
42
+ /**
43
+ * What the person is working on, as resource refs, for providers that understand it. Only providers whose `list`
44
+ * accepts `context` receive it; others list as before. A function runs only when the person opens such a provider,
45
+ * at most once per chooser, and again after it failed. Refs are hints, never access.
46
+ */
47
+ context?: FileProviderContext | ((signal: AbortSignal) => Promise<FileProviderContext>);
48
+ /** The empty root title for a provider that received context. Defaults to the chooser's "No matching files". */
49
+ noMatchingFiles?: string;
42
50
  /** Closes the chooser, or stops waiting for the device's dialog, and resolves `[]` when aborted. */
43
51
  signal?: AbortSignal;
44
52
  };
@@ -95,7 +103,7 @@ export function FileChooser(props: {
95
103
  if (first) (location() ? selection : sourceSelection).focus(first);
96
104
  });
97
105
  };
98
- const folder = createProviderFolder({ caller: props.caller, t, settled: refocus });
106
+ const folder = createProviderFolder({ caller: props.caller, t, settled: refocus, context: props.options.context });
99
107
  const { location, items, openFolder } = folder;
100
108
  const [downloads, setDownloads] = createStore<Download[]>([]);
101
109
  const [downloading, setDownloading] = createSignal(false);
@@ -297,7 +305,7 @@ export function FileChooser(props: {
297
305
  </Switch>
298
306
  </Match>
299
307
  <Match when={true}>
300
- <FolderContents folder={folder} t={t}>
308
+ <FolderContents folder={folder} t={t} noMatchingFiles={props.options.noMatchingFiles}>
301
309
  <FileGrid
302
310
  rows={items()}
303
311
  getRowId={(entry) => entry.id}
@@ -10,6 +10,7 @@ import {
10
10
  APP_APPROVAL_PROTOCOL,
11
11
  type AppActionView,
12
12
  AppActionViewSchema,
13
+ type AppApprovalAccept,
13
14
  AppDeviceCommandSchema,
14
15
  AppDeviceProofSchema,
15
16
  type AppDevicePublicKey,
@@ -223,9 +224,13 @@ const connect = async (options: {
223
224
  },
224
225
  /** Whether this Cloud serves `inbox`, `action` and `act`. Older Clouds answer those commands with HTTP 400. */
225
226
  actions: info.features?.includes("actions") === true,
227
+ /** Whether this Cloud can include terminal consent in an explicitly opted-in inbox. */
228
+ terminalLogins: info.features?.includes("terminal-logins") === true,
226
229
  /** Pending sign-ins and the newest open Action approvals in one call; use it instead of `pending` where `actions` is true. */
227
- inbox: async (device: AppApprovalDevice, signal?: AbortSignal) => {
228
- const result = await command(device, { operation: "inbox" }, signal);
230
+ inbox: async (device: AppApprovalDevice, signal?: AbortSignal, options?: { accept?: AppApprovalAccept[] }) => {
231
+ // SDK upgrades alone must not expose consent the app cannot show. Old Clouds never see the new key.
232
+ const accept = options?.accept?.filter((entry) => info.features?.includes(entry));
233
+ const result = await command(device, { operation: "inbox", ...(accept?.length ? { accept } : {}) }, signal);
229
234
  if (!("actions" in result)) throw new AppApprovalClientError("INVALID_RESPONSE");
230
235
  return result;
231
236
  },