@k2b/cloud 0.38.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 (99) hide show
  1. package/package.json +6 -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/panel-cookie.ts +39 -0
  9. package/src/_internal/registry-validation.ts +21 -26
  10. package/src/_internal/runtime-context.ts +1 -0
  11. package/src/access/PermissionEditor.browser-harness.tsx +57 -38
  12. package/src/access/PermissionEditor.tsx +30 -5
  13. package/src/access/ResourceApiKeys.tsx +5 -1
  14. package/src/ai/approval-routes.ts +5 -2
  15. package/src/ai/approvals.ts +139 -28
  16. package/src/ai/browser.ts +10 -0
  17. package/src/ai/chat/action-sentences.ts +1 -44
  18. package/src/ai/chat/turn-view.tsx +35 -3
  19. package/src/ai/chat/view-block.tsx +7 -2
  20. package/src/ai/chat-task-contracts.ts +28 -3
  21. package/src/ai/chat-task-reasons-migrate.ts +68 -0
  22. package/src/ai/chat-task-reasons.ts +92 -0
  23. package/src/ai/chat-tasks.ts +396 -66
  24. package/src/ai/index.ts +12 -0
  25. package/src/ai/migrate.ts +28 -2
  26. package/src/ai/request-context.ts +7 -1
  27. package/src/ai/routes.ts +27 -1
  28. package/src/ai/runtime.ts +12 -27
  29. package/src/ai/store.ts +5 -3
  30. package/src/ai/timeline.ts +9 -1
  31. package/src/ai/tool-wording.ts +45 -0
  32. package/src/ai/turn-abort.ts +33 -0
  33. package/src/ai/types.ts +12 -1
  34. package/src/api/admin-core-settings.ts +190 -139
  35. package/src/api/admin-outgoing-mail.ts +4 -2
  36. package/src/api/app-approval.ts +9 -2
  37. package/src/api/approvals.ts +1 -1
  38. package/src/api/index.ts +2 -1
  39. package/src/browser/CloudResourceSearch.tsx +10 -6
  40. package/src/browser/app-approval.ts +7 -2
  41. package/src/browser/composer-catalog.ts +103 -0
  42. package/src/browser/composer-insert-messages.ts +31 -0
  43. package/src/browser/composer-insert.ts +150 -0
  44. package/src/browser/panel-messages.ts +24 -0
  45. package/src/browser/panel-slot.ts +354 -0
  46. package/src/browser/panel.ts +28 -0
  47. package/src/browser/resource-picker.tsx +12 -1
  48. package/src/contracts/app-approval.ts +49 -9
  49. package/src/contracts/app.ts +14 -0
  50. package/src/contracts/registry.ts +3 -1
  51. package/src/contracts/shared.ts +2 -0
  52. package/src/server/services/access.ts +5 -1
  53. package/src/services/accounts/posix.ts +2 -1
  54. package/src/services/app-approval.ts +158 -38
  55. package/src/services/approvals/device.ts +2 -0
  56. package/src/services/index.ts +21 -3
  57. package/src/services/notifications/browser.ts +16 -5
  58. package/src/services/pdf/image-protocol.ts +135 -0
  59. package/src/services/pdf/image-variants.ts +124 -0
  60. package/src/services/pdf/image-worker.ts +31 -0
  61. package/src/services/pdf/image.ts +109 -0
  62. package/src/services/pdf/index.ts +9 -0
  63. package/src/services/settings/api.ts +11 -6
  64. package/src/services/settings/app.ts +5 -4
  65. package/src/services/settings/core-settings.ts +4 -3
  66. package/src/services/settings/index.ts +19 -17
  67. package/src/services/settings/namespace.ts +2 -1
  68. package/src/services/settings/store.ts +83 -11
  69. package/src/shared/ai-platform-prompt.ts +1 -1
  70. package/src/shared/app-presentation.ts +1 -0
  71. package/src/shared/redirect.ts +12 -0
  72. package/src/ssr/CloudShell.island.tsx +86 -9
  73. package/src/ssr/Layout.tsx +42 -9
  74. package/src/ssr/LayoutHeader.tsx +15 -9
  75. package/src/ssr/LayoutHelp.tsx +1 -1
  76. package/src/ssr/LayoutRail.tsx +2 -2
  77. package/src/ssr/LayoutTabBar.tsx +29 -0
  78. package/src/ssr/PanelLayout.tsx +27 -0
  79. package/src/ssr/PanelRuntime.island.tsx +87 -0
  80. package/src/ssr/PanelSlot.tsx +108 -0
  81. package/src/ssr/PanelTrigger.tsx +49 -0
  82. package/src/ssr/ShellRuntime.island.tsx +60 -5
  83. package/src/ssr/app-appearance.ts +3 -2
  84. package/src/ssr/app-color.ts +100 -0
  85. package/src/ssr/app-launchpad.tsx +18 -1
  86. package/src/ssr/app-navigation.ts +15 -0
  87. package/src/ssr/compact-shell.baseline.json +5270 -0
  88. package/src/ssr/compact-shell.browser-harness.ts +431 -0
  89. package/src/ssr/index.ts +3 -0
  90. package/src/ssr/panel-slot.browser-harness.tsx +110 -0
  91. package/src/ssr/platform-messages.ts +30 -0
  92. package/src/ssr/rail-navigation.ts +44 -0
  93. package/src/ssr/shell-data.tsx +4 -0
  94. package/src/ssr/shell-shortcuts.ts +13 -2
  95. package/src/styles/effects.css +3 -2
  96. package/src/styles/global.css +269 -55
  97. package/src/styles/panel.css +373 -0
  98. package/src/styles/tokens.css +23 -6
  99. package/src/styles/utilities-navigation.css +5 -21
@@ -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/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)
@@ -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>
@@ -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
  },
@@ -0,0 +1,103 @@
1
+ import type { ChatCommandSearch } from "@k2b/ui";
2
+ import { createSignal } from "solid-js";
3
+ import { listCapabilityCatalog } from "../capabilities/client";
4
+ import { LOCALE_HEADER } from "../shared/locale";
5
+
6
+ export type CloudSearchTag = {
7
+ appId: string;
8
+ appName: string;
9
+ appIcon: string;
10
+ tag: string;
11
+ title: string;
12
+ description: string;
13
+ aliases: readonly string[];
14
+ };
15
+
16
+ /**
17
+ * What composers read from the page's one Capability catalog load. It leaves out the tags of apps without a resource
18
+ * reader: a picker that requires one can show none of their results. A composer that reads such a type itself adds its
19
+ * tag on its own and names the type in `cloudInsertMenu`'s `ownReaders`.
20
+ */
21
+ export type CloudComposerCatalog = { tags: readonly CloudSearchTag[] };
22
+
23
+ /** The budget of one Capability query (and of resolving a Command), applied to each catalog page. */
24
+ const CATALOG_PAGE_TIMEOUT_MS = 15_000;
25
+
26
+ const catalogs = new Map<string, Promise<CloudComposerCatalog>>();
27
+
28
+ const loadCatalog = async (locale: string): Promise<CloudComposerCatalog> => {
29
+ const tags: CloudSearchTag[] = [];
30
+ const seen = new Set<string>();
31
+ let cursor: string | undefined;
32
+ do {
33
+ // A page that never answers fails like any other, so the load is forgotten and the next opening asks again.
34
+ const result = await listCapabilityCatalog({
35
+ limit: 25,
36
+ cursor,
37
+ headers: { [LOCALE_HEADER]: locale },
38
+ signal: AbortSignal.timeout(CATALOG_PAGE_TIMEOUT_MS),
39
+ });
40
+ if (!result.ok) throw new Error(result.error.message);
41
+ for (const app of result.data.apps) {
42
+ if (!app.manifest.types.some((type) => type.reader)) continue;
43
+ for (const query of app.manifest.queries) {
44
+ for (const tag of query.universalSearch?.tags ?? []) {
45
+ const key = `${app.appId}\u0000${tag.tag}`;
46
+ if (seen.has(key)) continue;
47
+ seen.add(key);
48
+ tags.push({ appId: app.appId, appName: app.appName, appIcon: app.appIcon, ...tag, aliases: tag.aliases ?? [] });
49
+ }
50
+ }
51
+ }
52
+ const next = result.data.page.hasMore ? result.data.page.nextCursor : undefined;
53
+ if (next && cursor && next <= cursor) throw new Error("Invalid Composer catalog cursor");
54
+ cursor = next;
55
+ } while (cursor);
56
+ return { tags };
57
+ };
58
+
59
+ /**
60
+ * One catalog load per page and locale, shared by every composer on the page. A failed load is forgotten,
61
+ * so the next menu opening can try again; nothing polls or retries on its own.
62
+ */
63
+ export const loadCloudComposerCatalog = (locale: string): Promise<CloudComposerCatalog> => {
64
+ const cached = catalogs.get(locale);
65
+ if (cached) return cached;
66
+ const pending = loadCatalog(locale).catch((error: unknown) => {
67
+ if (catalogs.get(locale) === pending) catalogs.delete(locale);
68
+ throw error;
69
+ });
70
+ catalogs.set(locale, pending);
71
+ return pending;
72
+ };
73
+
74
+ /**
75
+ * The search tags of one composer. Call `ready` at the start of every `searchCommands` run: each menu opening starts
76
+ * the shared load once, so only the first opening, or one after a failure, sends a request, and no keystroke does.
77
+ * A search with typed text and no scope waits for that answer, so "/notiz" never settles as "nothing found" before its
78
+ * tag arrives. The overview and scoped searches do not wait, and after a failed load no search waits again; a later
79
+ * success still brings the tags in. A failure keeps the tags the composer had.
80
+ */
81
+ export function createCloudComposerCatalog(load: (locale: string) => Promise<CloudComposerCatalog> = loadCloudComposerCatalog) {
82
+ const [tags, setTags] = createSignal<readonly CloudSearchTag[]>([]);
83
+ let opening: number | undefined;
84
+ let settled: Promise<void> = Promise.resolve();
85
+ let failed = false;
86
+ return {
87
+ tags,
88
+ ready(query: string, search: ChatCommandSearch, locale: string): Promise<void> {
89
+ if (opening !== search.opening) {
90
+ opening = search.opening;
91
+ settled = load(locale).then(
92
+ (catalog) => {
93
+ setTags(catalog.tags);
94
+ },
95
+ () => {
96
+ failed = true;
97
+ },
98
+ );
99
+ }
100
+ return failed || search.scope !== undefined || !query.trim() ? Promise.resolve() : settled;
101
+ },
102
+ };
103
+ }