@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.
- package/package.json +6 -3
- package/scripts/build-canvas-workers.ts +109 -29
- package/scripts/build.ts +3 -5
- package/src/_internal/app-path.ts +25 -0
- package/src/_internal/app-presentation.ts +4 -0
- package/src/_internal/canvas-worker.ts +5 -3
- package/src/_internal/define-app.ts +28 -2
- package/src/_internal/panel-cookie.ts +39 -0
- package/src/_internal/registry-validation.ts +21 -26
- package/src/_internal/runtime-context.ts +1 -0
- package/src/access/PermissionEditor.browser-harness.tsx +57 -38
- package/src/access/PermissionEditor.tsx +30 -5
- package/src/access/ResourceApiKeys.tsx +5 -1
- package/src/ai/approval-routes.ts +5 -2
- package/src/ai/approvals.ts +139 -28
- package/src/ai/browser.ts +10 -0
- package/src/ai/chat/action-sentences.ts +1 -44
- package/src/ai/chat/turn-view.tsx +35 -3
- package/src/ai/chat/view-block.tsx +7 -2
- package/src/ai/chat-task-contracts.ts +28 -3
- package/src/ai/chat-task-reasons-migrate.ts +68 -0
- package/src/ai/chat-task-reasons.ts +92 -0
- package/src/ai/chat-tasks.ts +396 -66
- package/src/ai/index.ts +12 -0
- package/src/ai/migrate.ts +28 -2
- package/src/ai/request-context.ts +7 -1
- package/src/ai/routes.ts +27 -1
- package/src/ai/runtime.ts +12 -27
- package/src/ai/store.ts +5 -3
- package/src/ai/timeline.ts +9 -1
- package/src/ai/tool-wording.ts +45 -0
- package/src/ai/turn-abort.ts +33 -0
- package/src/ai/types.ts +12 -1
- package/src/api/admin-core-settings.ts +190 -139
- package/src/api/admin-outgoing-mail.ts +4 -2
- package/src/api/app-approval.ts +9 -2
- package/src/api/approvals.ts +1 -1
- package/src/api/index.ts +2 -1
- package/src/browser/CloudResourceSearch.tsx +10 -6
- package/src/browser/app-approval.ts +7 -2
- package/src/browser/composer-catalog.ts +103 -0
- package/src/browser/composer-insert-messages.ts +31 -0
- package/src/browser/composer-insert.ts +150 -0
- package/src/browser/panel-messages.ts +24 -0
- package/src/browser/panel-slot.ts +354 -0
- package/src/browser/panel.ts +28 -0
- package/src/browser/resource-picker.tsx +12 -1
- package/src/contracts/app-approval.ts +49 -9
- package/src/contracts/app.ts +14 -0
- package/src/contracts/registry.ts +3 -1
- package/src/contracts/shared.ts +2 -0
- package/src/server/services/access.ts +5 -1
- package/src/services/accounts/posix.ts +2 -1
- package/src/services/app-approval.ts +158 -38
- package/src/services/approvals/device.ts +2 -0
- package/src/services/index.ts +21 -3
- package/src/services/notifications/browser.ts +16 -5
- package/src/services/pdf/image-protocol.ts +135 -0
- package/src/services/pdf/image-variants.ts +124 -0
- package/src/services/pdf/image-worker.ts +31 -0
- package/src/services/pdf/image.ts +109 -0
- package/src/services/pdf/index.ts +9 -0
- package/src/services/settings/api.ts +11 -6
- package/src/services/settings/app.ts +5 -4
- package/src/services/settings/core-settings.ts +4 -3
- package/src/services/settings/index.ts +19 -17
- package/src/services/settings/namespace.ts +2 -1
- package/src/services/settings/store.ts +83 -11
- package/src/shared/ai-platform-prompt.ts +1 -1
- package/src/shared/app-presentation.ts +1 -0
- package/src/shared/redirect.ts +12 -0
- package/src/ssr/CloudShell.island.tsx +86 -9
- package/src/ssr/Layout.tsx +42 -9
- package/src/ssr/LayoutHeader.tsx +15 -9
- package/src/ssr/LayoutHelp.tsx +1 -1
- package/src/ssr/LayoutRail.tsx +2 -2
- package/src/ssr/LayoutTabBar.tsx +29 -0
- package/src/ssr/PanelLayout.tsx +27 -0
- package/src/ssr/PanelRuntime.island.tsx +87 -0
- package/src/ssr/PanelSlot.tsx +108 -0
- package/src/ssr/PanelTrigger.tsx +49 -0
- package/src/ssr/ShellRuntime.island.tsx +60 -5
- package/src/ssr/app-appearance.ts +3 -2
- package/src/ssr/app-color.ts +100 -0
- package/src/ssr/app-launchpad.tsx +18 -1
- package/src/ssr/app-navigation.ts +15 -0
- package/src/ssr/compact-shell.baseline.json +5270 -0
- package/src/ssr/compact-shell.browser-harness.ts +431 -0
- package/src/ssr/index.ts +3 -0
- package/src/ssr/panel-slot.browser-harness.tsx +110 -0
- package/src/ssr/platform-messages.ts +30 -0
- package/src/ssr/rail-navigation.ts +44 -0
- package/src/ssr/shell-data.tsx +4 -0
- package/src/ssr/shell-shortcuts.ts +13 -2
- package/src/styles/effects.css +3 -2
- package/src/styles/global.css +269 -55
- package/src/styles/panel.css +373 -0
- package/src/styles/tokens.css +23 -6
- 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(
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
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
|
-
|
|
344
|
-
|
|
345
|
-
|
|
369
|
+
if (keys.length === 0 && replace.size === 0) {
|
|
370
|
+
return c.body(null, 204);
|
|
371
|
+
}
|
|
346
372
|
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
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
|
-
|
|
354
|
-
|
|
355
|
-
|
|
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
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
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
|
-
|
|
380
|
-
|
|
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
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
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
|
-
|
|
411
|
-
|
|
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
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
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
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
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
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
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
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
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
|
-
|
|
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 (
|
|
472
|
-
if (
|
|
473
|
-
|
|
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
|
-
{
|
|
481
|
-
|
|
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
|
-
|
|
495
|
-
|
|
496
|
-
|
|
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
|
-
|
|
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
|
-
|
|
135
|
-
await settings.set(MAIL_RETENTION_KEYS[
|
|
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),
|
package/src/api/app-approval.ts
CHANGED
|
@@ -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 {
|
|
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
|
)
|
package/src/api/approvals.ts
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
81
|
-
|
|
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={
|
|
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
|
-
|
|
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
|
+
}
|