@k2b/cloud 0.31.0 → 0.33.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 (173) hide show
  1. package/package.json +8 -6
  2. package/src/_internal/define-app.ts +20 -9
  3. package/src/_internal/page-responses.ts +5 -1
  4. package/src/ai/app-skills.ts +1 -7
  5. package/src/ai/audio-tool.ts +2 -1
  6. package/src/ai/browser.ts +109 -0
  7. package/src/ai/capabilities.ts +6 -2
  8. package/src/ai/chart-block.ts +431 -81
  9. package/src/ai/chat/action-sentences.ts +13 -7
  10. package/src/ai/chat/blocks.tsx +11 -7
  11. package/src/ai/chat/builtin-tools.tsx +9 -7
  12. package/src/ai/chat/chart-block.tsx +218 -44
  13. package/src/ai/chat/explorer-block.tsx +276 -0
  14. package/src/ai/chat/file-tools.tsx +40 -1
  15. package/src/ai/chat/message-actions.tsx +6 -0
  16. package/src/ai/chat/message-utils.ts +7 -0
  17. package/src/ai/chat/messages.ts +16 -0
  18. package/src/ai/chat/presentation.tsx +13 -3
  19. package/src/ai/chat/reference-text.tsx +34 -0
  20. package/src/ai/chat/tool-groups.ts +1 -0
  21. package/src/ai/chat/tool-names.ts +14 -0
  22. package/src/ai/chat/turn-view.tsx +17 -7
  23. package/src/ai/chat/view-block.browser-harness.tsx +35 -0
  24. package/src/ai/chat/view-block.tsx +417 -0
  25. package/src/ai/chat/visual-tools.tsx +32 -53
  26. package/src/ai/client-tool-results.ts +9 -0
  27. package/src/ai/code-source-contracts.ts +17 -2
  28. package/src/ai/code-source-tools.ts +1 -0
  29. package/src/ai/default-tool-contracts.ts +334 -91
  30. package/src/ai/default-tools.ts +89 -13
  31. package/src/ai/executor.ts +173 -31
  32. package/src/ai/file-error.ts +2 -0
  33. package/src/ai/file-tools.ts +27 -7
  34. package/src/ai/files-store.ts +60 -0
  35. package/src/ai/html-pdf-tool.ts +3 -2
  36. package/src/ai/http.ts +37 -12
  37. package/src/ai/index.ts +90 -0
  38. package/src/ai/local-tool-contracts.ts +244 -0
  39. package/src/ai/malformed-tool-call-repair.ts +144 -0
  40. package/src/ai/markdown-pdf-tool.ts +2 -1
  41. package/src/ai/memories.ts +48 -1
  42. package/src/ai/memory-learning.ts +17 -6
  43. package/src/ai/memory-tool.ts +1 -0
  44. package/src/ai/migrate.ts +15 -0
  45. package/src/ai/openai-api-messages.ts +53 -0
  46. package/src/ai/openai-api-slots.ts +40 -0
  47. package/src/ai/openai-api.ts +383 -0
  48. package/src/ai/protocol.ts +6 -2
  49. package/src/ai/provider.ts +17 -4
  50. package/src/ai/request-context.ts +33 -0
  51. package/src/ai/routes.ts +96 -57
  52. package/src/ai/runtime.ts +48 -12
  53. package/src/ai/settings.ts +5 -0
  54. package/src/ai/skill-catalog.ts +20 -5
  55. package/src/ai/skill-format.ts +17 -7
  56. package/src/ai/skill-tool.ts +15 -5
  57. package/src/ai/skills-routes.ts +20 -6
  58. package/src/ai/skills.ts +26 -7
  59. package/src/ai/store.ts +25 -0
  60. package/src/ai/system-prompt.ts +69 -10
  61. package/src/ai/timeline.ts +3 -1
  62. package/src/ai/tools.ts +3 -0
  63. package/src/ai/types.ts +9 -0
  64. package/src/ai/view-block.ts +941 -0
  65. package/src/ai/vision-tool.ts +2 -1
  66. package/src/api/admin-identity.ts +6 -1
  67. package/src/api/app-approval-actions.ts +36 -0
  68. package/src/api/app-approval.ts +10 -2
  69. package/src/api/approvals-loader.ts +15 -0
  70. package/src/api/approvals.ts +120 -0
  71. package/src/api/index.ts +3 -0
  72. package/src/api/me.ts +12 -3
  73. package/src/approvals/Approvals.browser-harness.tsx +16 -0
  74. package/src/approvals/Approvals.tsx +621 -0
  75. package/src/approvals/ApprovalsNavStatus.tsx +20 -0
  76. package/src/approvals/messages.ts +95 -0
  77. package/src/approvals/ui.ts +8 -0
  78. package/src/browser/app-approval.ts +32 -0
  79. package/src/browser/choose-files.tsx +26 -13
  80. package/src/browser/commands.ts +13 -4
  81. package/src/browser/file-provider-error.ts +11 -0
  82. package/src/browser/file-providers.ts +3 -12
  83. package/src/browser/files.tsx +1 -2
  84. package/src/browser/live-connection.ts +2 -3
  85. package/src/browser/navigation-search.ts +4 -1
  86. package/src/browser/provider-list.ts +2 -1
  87. package/src/browser/save-files.tsx +13 -2
  88. package/src/cli/account.ts +21 -2
  89. package/src/cli/commands.ts +4 -2
  90. package/src/contracts/app-approval.ts +120 -0
  91. package/src/contracts/approval-paths.ts +4 -0
  92. package/src/contracts/approvals.ts +81 -0
  93. package/src/contracts/command-path.ts +17 -0
  94. package/src/contracts/commands.ts +5 -15
  95. package/src/contracts/shared.ts +5 -0
  96. package/src/events/live-protocol.ts +2 -1
  97. package/src/events/live-server-message.ts +40 -0
  98. package/src/events/testing.ts +88 -0
  99. package/src/server/index.ts +2 -0
  100. package/src/server/middleware/auth.ts +11 -1
  101. package/src/server/request-timeout.ts +22 -0
  102. package/src/server/services/access.ts +4 -3
  103. package/src/services/app-approval.ts +103 -15
  104. package/src/services/approvals/cursor.ts +15 -0
  105. package/src/services/approvals/device.ts +112 -0
  106. package/src/services/approvals/index.ts +27 -11
  107. package/src/services/approvals/list.ts +120 -0
  108. package/src/services/approvals/migrate.ts +9 -1
  109. package/src/services/identity/models-only-auth.ts +25 -0
  110. package/src/services/notifications/email-frame.ts +16 -3
  111. package/src/services/outbox.ts +13 -6
  112. package/src/services/service-account-credentials.ts +22 -10
  113. package/src/services/settings/core-settings.ts +27 -0
  114. package/src/shared/ai-platform-prompt.ts +29 -3
  115. package/src/shared/email-html.ts +4 -0
  116. package/src/shared/index.ts +6 -1
  117. package/src/shared/markdown/extensions/code.ts +14 -0
  118. package/src/shared/markdown/index.ts +4 -4
  119. package/src/shared/theme.ts +41 -20
  120. package/src/ssr/AnnouncementBanners.tsx +30 -0
  121. package/src/ssr/AppBadge.tsx +4 -4
  122. package/src/ssr/AppLaunchpadPanel.tsx +6 -6
  123. package/src/ssr/CloudShell.island.tsx +91 -0
  124. package/src/ssr/GlobalSearchTrigger.tsx +51 -0
  125. package/src/ssr/Layout.tsx +56 -85
  126. package/src/ssr/LayoutHeader.tsx +31 -77
  127. package/src/ssr/LayoutHelp.tsx +2 -57
  128. package/src/ssr/LayoutHelpTrigger.tsx +51 -0
  129. package/src/ssr/{LayoutPreferences.island.tsx → LayoutPreferences.tsx} +2 -2
  130. package/src/ssr/LayoutRail.tsx +15 -69
  131. package/src/ssr/MinimalLayout.tsx +2 -3
  132. package/src/ssr/MinimalLayoutPreferences.island.tsx +12 -15
  133. package/src/ssr/MobileNavigation.tsx +13 -8
  134. package/src/ssr/MobileProfileActions.tsx +10 -7
  135. package/src/ssr/PageTrailRow.tsx +14 -0
  136. package/src/ssr/{ProfilePreferences.island.tsx → ProfilePreferences.tsx} +12 -7
  137. package/src/ssr/PwaLayout.tsx +0 -2
  138. package/src/ssr/PwaRuntime.island.tsx +1 -1
  139. package/src/ssr/{RailApps.island.tsx → RailApps.tsx} +2 -2
  140. package/src/ssr/ShellRuntime.island.tsx +170 -0
  141. package/src/ssr/TimezoneCookie.island.tsx +3 -19
  142. package/src/ssr/WorkspaceNavigationProvider.tsx +20 -1
  143. package/src/ssr/announcements-dialog.tsx +66 -0
  144. package/src/ssr/app-badges.ts +50 -6
  145. package/src/ssr/{AppLaunchpad.island.tsx → app-launchpad.tsx} +42 -62
  146. package/src/ssr/busy-action.ts +19 -0
  147. package/src/ssr/islands/index.ts +9 -6
  148. package/src/ssr/launchpad-surface.tsx +15 -0
  149. package/src/ssr/layout-help-registry.tsx +60 -0
  150. package/src/ssr/mobile-navigation.browser-harness.tsx +20 -0
  151. package/src/ssr/page-trail.browser-harness.tsx +52 -3
  152. package/src/ssr/preference-controller.ts +30 -21
  153. package/src/ssr/profile-actions.ts +26 -15
  154. package/src/ssr/profile-preferences-messages.ts +8 -4
  155. package/src/ssr/rail-context.ts +8 -12
  156. package/src/ssr/rail-navigation.ts +5 -3
  157. package/src/ssr/runtime.ts +6 -1
  158. package/src/ssr/shell-data.tsx +48 -0
  159. package/src/ssr/timezone-cookie.ts +22 -0
  160. package/src/ssr/workspace-navigation.ts +25 -2
  161. package/src/styles/approvals.css +390 -0
  162. package/src/styles/global.css +22 -7
  163. package/src/styles/utilities-navigation.css +13 -0
  164. package/src/workflows/language/compiler.ts +1 -1
  165. package/src/ssr/AppWorkspaceController.island.tsx +0 -21
  166. package/src/ssr/BrowserPushRegistration.island.tsx +0 -12
  167. package/src/ssr/GlobalAnnouncements.island.tsx +0 -117
  168. package/src/ssr/GlobalSearchTrigger.island.tsx +0 -75
  169. package/src/ssr/LayoutHelpTrigger.island.tsx +0 -61
  170. package/src/ssr/PageTrailRow.island.tsx +0 -20
  171. package/src/ssr/PageTrailRuntime.island.tsx +0 -26
  172. package/src/ssr/RegisteredHelpDocuments.island.tsx +0 -3
  173. /package/src/ssr/{LayoutBreadcrumbs.island.tsx → LayoutBreadcrumbs.tsx} +0 -0
@@ -0,0 +1,112 @@
1
+ import {
2
+ APP_APPROVAL_LIMITS,
3
+ type AppActionSummary,
4
+ type AppActionView,
5
+ appActionDigest,
6
+ APP_ACTION_LIMITS as limits,
7
+ } from "../../contracts/app-approval";
8
+ import { CapabilityActionReviewSchema } from "../../contracts/capabilities";
9
+ import { DEFAULT_LOCALE } from "../../server/locale";
10
+ import type { PendingApprovalRow } from "./list";
11
+
12
+ const plain = (value: string): string =>
13
+ value
14
+ .replace(/[\p{Cc}\p{Zl}\p{Zp}]/gu, " ")
15
+ .replace(/\p{Cf}/gu, "")
16
+ .replace(/\s+/gu, " ")
17
+ .trim();
18
+ const cut = (value: string, limit: number): string => {
19
+ const points = Array.from(value);
20
+ if (limit < 1) return "";
21
+ return points.length > limit ? `${points.slice(0, limit - 1).join("")}…` : value;
22
+ };
23
+
24
+ /** A deterministic, plain-text snapshot; the digest binds the device's choice to everything it shows. */
25
+ export const appActionView = async (
26
+ row: PendingApprovalRow,
27
+ options: { issuer: string; appName: (appId: string, locale: string) => string },
28
+ ): Promise<AppActionView> => {
29
+ let truncated = false;
30
+ const text = (value: string, limit: number) => {
31
+ const cleaned = plain(value);
32
+ const result = cut(cleaned, limit);
33
+ if (result !== cleaned) truncated = true;
34
+ return result;
35
+ };
36
+ const locale = row.runConfig?.kind === "compact" ? DEFAULT_LOCALE : (row.runConfig?.locale ?? DEFAULT_LOCALE);
37
+ const appName = plain(options.appName(row.appId, locale));
38
+ const app = { id: text(row.appId, limits.nameChars), name: text(appName, limits.nameChars) };
39
+ const requester = {
40
+ app: { id: text(row.requesterAppId, limits.nameChars), name: text(options.appName(row.requesterAppId, locale), limits.nameChars) },
41
+ // The snapshot, so renaming the chat does not change what the device signs.
42
+ title: text(row.requesterTitle ?? "", limits.titleChars) || null,
43
+ };
44
+ const lines = row.sentence.split(/\r?\n/).map(plain).filter(Boolean);
45
+ const first = lines[0] ?? plain(row.action);
46
+ const prefix = `${appName}: `;
47
+ const sentence = first.startsWith(prefix) && first.length > prefix.length ? first.slice(prefix.length) : first;
48
+ const review = CapabilityActionReviewSchema.safeParse(row.details);
49
+ let message: string | null;
50
+ const details: AppActionView["details"] = [];
51
+ const detail = (label: string, value: string, format?: "date" | "date-time") => {
52
+ if (details.length >= limits.details) {
53
+ truncated = true;
54
+ return;
55
+ }
56
+ const cleanedLabel = text(label, limits.labelChars);
57
+ if (!cleanedLabel) return;
58
+ details.push({ label: cleanedLabel, value: text(value, limits.valueChars), ...(format ? { format } : {}) });
59
+ };
60
+ const messages: string[] = [];
61
+ const reviewLines = new Set<string>();
62
+ if (review.success) {
63
+ const cleaned = plain(review.data.message);
64
+ if (cleaned !== sentence) messages.push(cleaned);
65
+ reviewLines.add(cleaned);
66
+ for (const line of review.data.message.split(/\r?\n/)) reviewLines.add(plain(line));
67
+ for (const field of review.data.details ?? [])
68
+ detail(field.label, field.value, field.format === "date" || field.format === "date-time" ? field.format : undefined);
69
+ }
70
+ for (const line of lines.slice(1)) {
71
+ if (reviewLines.has(line)) continue;
72
+ const match = /^([^:]{1,40}):\s+(.+)$/.exec(line);
73
+ if (match) detail(match[1]!, match[2]!);
74
+ else messages.push(line);
75
+ }
76
+ message = text(messages.join(" "), limits.messageChars) || null;
77
+ const boundedSentence = text(sentence, limits.sentenceChars);
78
+ const view: AppActionView = {
79
+ actionId: row.id,
80
+ app,
81
+ requester,
82
+ assurance: row.assurance,
83
+ sentence: boundedSentence,
84
+ message,
85
+ details,
86
+ truncated,
87
+ argsDigest: row.argsDigest,
88
+ createdAt: row.createdAt.toISOString(),
89
+ expiresAt: row.expiresAt.toISOString(),
90
+ // Reserve the digest's bytes while bounding the final response.
91
+ digest: "A".repeat(43),
92
+ };
93
+ const bytes = () => new TextEncoder().encode(JSON.stringify(view)).length;
94
+ while (bytes() > APP_APPROVAL_LIMITS.bodyBytes) {
95
+ view.truncated = true;
96
+ if (view.details.length) view.details.pop();
97
+ else if (view.message) view.message = cut(view.message, Array.from(view.message).length - 1) || null;
98
+ else break;
99
+ }
100
+ view.digest = await appActionDigest(options.issuer, view);
101
+ return view;
102
+ };
103
+
104
+ export const appActionSummary = (view: AppActionView): AppActionSummary => ({
105
+ actionId: view.actionId,
106
+ app: view.app,
107
+ requester: view.requester,
108
+ assurance: view.assurance,
109
+ sentence: cut(view.sentence, limits.summaryChars),
110
+ createdAt: view.createdAt,
111
+ expiresAt: view.expiresAt,
112
+ });
@@ -1,16 +1,18 @@
1
1
  import { type SQL, sql } from "bun";
2
2
  import { capabilityRequestHash } from "../../capabilities/claims";
3
+ import { APPROVAL_ACP_CLIENT } from "../../contracts/approvals";
3
4
  import type { CapabilityActionReview } from "../../contracts/capabilities";
4
5
  import { isAppSessionActor } from "../../server/actor";
5
6
  import type { RequestActor, RequestCredentialKind } from "../../server/middleware/auth";
6
7
  import { audit } from "../audit";
7
8
  import { toPgTextArray } from "../postgres";
8
9
 
10
+ export { countPending, listPending, presentPendingApproval } from "./list";
9
11
  export { migrateApprovals } from "./migrate";
10
12
 
11
13
  // Derived from chat's AI_ACTION_BUDGET_MS and Studio's one-day abandonment of pending calls.
12
14
  export const APPROVAL_MAX_WAIT_MS = 24 * 60 * 60_000;
13
- export type ApprovalDecisionChannel = "web_session" | "app_session" | "oauth" | "api_key" | "invocation";
15
+ export type ApprovalDecisionChannel = "web_session" | "app_session" | "oauth" | "api_key" | "invocation" | "acp_bridge" | "cloud_login";
14
16
  export type ApprovalRequesterKind = "assistant_turn" | "studio_app";
15
17
  export type ApprovalStatus = "pending" | "approved" | "rejected" | "expired" | "cancelled" | "consumed";
16
18
  export type ApprovalAssurance = "standard" | "confirm";
@@ -25,12 +27,15 @@ export const approvalReviewDigest = (review: CapabilityActionReview | null | und
25
27
  export const approvalDecisionChannel = (input: {
26
28
  credentialKind?: RequestCredentialKind;
27
29
  actor?: RequestActor;
30
+ client?: string | null;
28
31
  }): ApprovalDecisionChannel =>
29
- input.credentialKind === "session"
30
- ? isAppSessionActor(input.actor)
31
- ? "app_session"
32
- : "web_session"
33
- : (input.credentialKind ?? "invocation");
32
+ input.client === APPROVAL_ACP_CLIENT
33
+ ? "acp_bridge"
34
+ : input.credentialKind === "session"
35
+ ? isAppSessionActor(input.actor)
36
+ ? "app_session"
37
+ : "web_session"
38
+ : (input.credentialKind ?? "invocation");
34
39
  export const approvalScopeFromToolName = (name: string): { appId: string; action: string } => {
35
40
  const dot = name.indexOf(".");
36
41
  return dot < 0 ? { appId: "assistant", action: name } : { appId: name.slice(0, dot), action: name.slice(dot + 1) };
@@ -123,20 +128,29 @@ export const get = async (id: string, db?: SQL): Promise<ApprovalRequest | null>
123
128
 
124
129
  export type ApprovalDecisionResult =
125
130
  | { ok: true; status: ApprovalStatus; replay: boolean }
126
- | { ok: false; code: "EXPIRED" | "NOT_FOUND" }
131
+ | { ok: false; code: "EXPIRED" | "NOT_FOUND" | "REJECT_ONLY" }
127
132
  | { ok: false; code: "ALREADY_DECIDED"; status: ApprovalStatus };
128
133
  export const decide = async (
129
134
  id: string,
130
135
  decision: { approved: boolean },
131
- actor: { userId: string; via: ApprovalDecisionChannel },
136
+ actor: { userId: string; via: ApprovalDecisionChannel; deviceId?: string },
132
137
  db?: SQL,
133
138
  ): Promise<ApprovalDecisionResult> => {
134
139
  const result = await transaction(db, async (tx): Promise<ApprovalDecisionResult> => {
140
+ // Lock the device before the request so revocation and the decision serialize.
141
+ if (actor.deviceId !== undefined) {
142
+ const [device] = await tx`SELECT 1 FROM auth.app_devices
143
+ WHERE id=${actor.deviceId}::uuid AND user_id=${actor.userId}::uuid AND revoked_at IS NULL FOR SHARE`;
144
+ if (!device) return { ok: false, code: "NOT_FOUND" };
145
+ }
135
146
  const [row] = await tx<ApprovalRequest[]>`SELECT ${columns()} FROM approvals.requests
136
147
  WHERE id=${id}::uuid AND approver_user_id=${actor.userId}::uuid FOR UPDATE`;
137
148
  if (!row) return { ok: false, code: "NOT_FOUND" };
149
+ const rejectOnly = decision.approved && row.assurance === "confirm" && actor.via === "acp_bridge";
138
150
  const status = decision.approved ? "approved" : "rejected";
139
- const [won] = await tx<ApprovalRequest[]>`
151
+ const [won] = rejectOnly
152
+ ? []
153
+ : await tx<ApprovalRequest[]>`
140
154
  UPDATE approvals.requests SET status=${status}, decided_via=${actor.via}, decided_at=now()
141
155
  WHERE id=${id}::uuid AND approver_user_id=${actor.userId}::uuid AND status='pending' AND expires_at > now()
142
156
  RETURNING ${columns()}
@@ -153,6 +167,7 @@ export const decide = async (
153
167
  action: won.action,
154
168
  assurance: won.assurance,
155
169
  decidedVia: actor.via,
170
+ ...(actor.deviceId !== undefined ? { deviceId: actor.deviceId } : {}),
156
171
  requesterKind: won.requesterKind,
157
172
  requesterAppId: won.requesterAppId,
158
173
  },
@@ -162,8 +177,9 @@ export const decide = async (
162
177
  return { ok: true, status, replay: false };
163
178
  }
164
179
  if (row.status === "pending") {
165
- await tx`UPDATE approvals.requests SET status='expired' WHERE id=${id}::uuid AND status='pending' AND expires_at <= now()`;
166
- return { ok: false, code: "EXPIRED" };
180
+ const [expired] = await tx<{ id: string }[]>`UPDATE approvals.requests SET status='expired'
181
+ WHERE id=${id}::uuid AND status='pending' AND expires_at <= now() RETURNING id`;
182
+ return { ok: false, code: expired || !rejectOnly ? "EXPIRED" : "REJECT_ONLY" };
167
183
  }
168
184
  if (
169
185
  (decision.approved && (row.status === "approved" || row.status === "consumed")) ||
@@ -0,0 +1,120 @@
1
+ import { sql } from "bun";
2
+ import { AI_WEBSITE_APPROVAL_TOOL, aiTurnAllowsRememberedApprovals } from "../../ai/approvals";
3
+ import type { AiTurnRunConfig } from "../../ai/types";
4
+ import type { RuntimeAppMeta } from "../../contracts/app";
5
+ import { APPROVALS_PAGE_SIZE, type ApprovalCounts, type PendingApproval } from "../../contracts/approvals";
6
+ import { CapabilityActionReviewSchema } from "../../contracts/capabilities";
7
+ import type { ApprovalAssurance } from ".";
8
+ import { decodeApprovalCursor, encodeApprovalCursor } from "./cursor";
9
+
10
+ export type PendingApprovalRow = {
11
+ id: string;
12
+ appId: string;
13
+ action: string;
14
+ argsDigest: string;
15
+ requesterAppId: string;
16
+ /** The chat title when Cloud asked; unlike `conversationTitle`, a later rename does not change it. */
17
+ requesterTitle: string | null;
18
+ assurance: ApprovalAssurance;
19
+ sentence: string;
20
+ details: unknown;
21
+ createdAt: Date;
22
+ expiresAt: Date;
23
+ cursorCreatedAt: string;
24
+ conversationShortId: string;
25
+ conversationTitle: string | null;
26
+ allowChat: boolean | null;
27
+ allowAlways: boolean;
28
+ rememberToolName: string | null;
29
+ toolName: string;
30
+ runConfig: AiTurnRunConfig | null;
31
+ };
32
+
33
+ // Start with this person's indexed pending rows; a Studio page alone cannot continue a chat.
34
+ const openRequests = (approverUserId: string, id?: string) => sql`SELECT r.* FROM approvals.requests r
35
+ WHERE r.approver_user_id=${approverUserId}::uuid AND r.status='pending' AND r.expires_at > now()
36
+ AND (${id ?? null}::uuid IS NULL OR r.id=${id ?? null}::uuid)
37
+ AND EXISTS (SELECT 1 FROM ai.pending_actions p WHERE p.approval_id=r.id AND p.status='pending')`;
38
+ const matches = (appId?: string, link?: string) => sql`
39
+ (${appId ?? null}::text IS NULL OR r.app_id=${appId ?? null} OR r.requester_app_id=${appId ?? null})
40
+ AND (${link ?? null}::text IS NULL OR COALESCE(r.details->'links' @> jsonb_build_array(jsonb_build_object('href', ${link ?? null}::text)),false))`;
41
+
42
+ export const listPending = async (input: {
43
+ approverUserId: string;
44
+ id?: string;
45
+ appId?: string;
46
+ link?: string;
47
+ cursor?: string;
48
+ limit?: number;
49
+ }): Promise<{ items: PendingApprovalRow[]; nextCursor: string | null; count: number; elsewhere: number }> => {
50
+ const limit = input.limit ?? APPROVALS_PAGE_SIZE;
51
+ if (!Number.isInteger(limit) || limit < 1 || limit > APPROVALS_PAGE_SIZE) throw new RangeError("Invalid approval page size.");
52
+ const cursor = input.cursor ? decodeApprovalCursor(input.cursor) : null;
53
+ if (input.cursor && !cursor) throw new RangeError("Invalid approval cursor.");
54
+ const [counts] = await sql<{ count: number; elsewhere: number }[]>`WITH open AS (${openRequests(input.approverUserId, input.id)})
55
+ SELECT count(*) FILTER (WHERE ${matches(input.appId, input.link)})::int AS count,
56
+ count(*) FILTER (WHERE NOT (${matches(input.appId, input.link)}))::int AS elsewhere FROM open r`;
57
+ const rows = await sql<PendingApprovalRow[]>`WITH open AS (${openRequests(input.approverUserId, input.id)})
58
+ SELECT r.id, r.app_id AS "appId", r.action, r.requester_app_id AS "requesterAppId", r.requester_title AS "requesterTitle", r.assurance, r.sentence, r.details, r.args_digest AS "argsDigest",
59
+ r.created_at AS "createdAt", r.expires_at AS "expiresAt",
60
+ to_char(r.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"') AS "cursorCreatedAt",
61
+ question.short_id AS "conversationShortId", question.title AS "conversationTitle",
62
+ question.allow_chat AS "allowChat", question.allow_always AS "allowAlways",
63
+ question.remember_tool_name AS "rememberToolName", question.tool_name AS "toolName", question.run_config AS "runConfig"
64
+ FROM open r JOIN LATERAL (
65
+ SELECT c.short_id, c.title, p.allow_chat, p.allow_always, p.remember_tool_name, p.tool_name, t.run_config
66
+ FROM ai.pending_actions p JOIN ai.conversations c ON c.id=p.conversation_id JOIN ai.turns t ON t.id=p.turn_id
67
+ WHERE p.approval_id=r.id AND p.status='pending' ORDER BY p.created_at DESC, p.id DESC LIMIT 1
68
+ ) question ON true
69
+ WHERE ${matches(input.appId, input.link)}
70
+ AND (${cursor?.id ?? null}::uuid IS NULL OR (r.created_at,r.id) < (${cursor?.createdAt ?? null}::timestamptz,${cursor?.id ?? null}::uuid))
71
+ ORDER BY r.created_at DESC,r.id DESC LIMIT ${limit + 1}`;
72
+ const items = rows.slice(0, limit);
73
+ const last = items.at(-1);
74
+ return {
75
+ items,
76
+ nextCursor: rows.length > limit && last ? encodeApprovalCursor({ createdAt: last.cursorCreatedAt, id: last.id }) : null,
77
+ count: counts?.count ?? 0,
78
+ elsewhere: counts?.elsewhere ?? 0,
79
+ };
80
+ };
81
+
82
+ export const countPending = async (input: { approverUserId: string }): Promise<ApprovalCounts> => {
83
+ const [row] = await sql<ApprovalCounts[]>`WITH open AS MATERIALIZED (${openRequests(input.approverUserId)}),
84
+ app_requests AS (SELECT DISTINCT r.id AS request_id, app.id AS app_id FROM open r
85
+ CROSS JOIN LATERAL unnest(ARRAY[r.app_id,r.requester_app_id]) app(id)),
86
+ app_counts AS (SELECT app_id AS id,count(*)::int AS count FROM app_requests GROUP BY app_id)
87
+ SELECT (SELECT count(*)::int FROM open) AS total,
88
+ COALESCE((SELECT jsonb_object_agg(id,count) FROM app_counts),'{}'::jsonb) AS apps`;
89
+ return row ?? { total: 0, apps: {} };
90
+ };
91
+
92
+ export const presentPendingApproval = (
93
+ row: PendingApprovalRow,
94
+ apps: readonly Pick<RuntimeAppMeta, "id" | "name" | "icon">[],
95
+ ): PendingApproval => {
96
+ const app = apps.find((entry) => entry.id === row.appId);
97
+ const review = CapabilityActionReviewSchema.nullable().safeParse(row.details);
98
+ const remember = row.assurance === "standard" && aiTurnAllowsRememberedApprovals(row.runConfig);
99
+ return {
100
+ id: row.id,
101
+ app: { id: row.appId, name: app?.name ?? row.appId, icon: app?.icon ?? null },
102
+ action: row.action,
103
+ assurance: row.assurance,
104
+ sentence: row.sentence,
105
+ review: review.success ? review.data : null,
106
+ requester: {
107
+ kind: "chat",
108
+ appId: row.requesterAppId,
109
+ title: row.conversationTitle,
110
+ href: `/app/assistant?conversation=${encodeURIComponent(row.conversationShortId)}`,
111
+ },
112
+ remember: {
113
+ chat: remember && (row.allowChat ?? row.allowAlways),
114
+ always: remember && row.allowAlways,
115
+ website: (row.rememberToolName ?? row.toolName) === AI_WEBSITE_APPROVAL_TOOL,
116
+ },
117
+ createdAt: row.createdAt.toISOString(),
118
+ expiresAt: row.expiresAt.toISOString(),
119
+ };
120
+ };
@@ -20,7 +20,7 @@ export const migrateApprovals = async (): Promise<void> => {
20
20
  args_digest TEXT NOT NULL,
21
21
  review_digest TEXT NOT NULL,
22
22
  status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','approved','rejected','expired','cancelled','consumed')),
23
- decided_via TEXT CHECK (decided_via IS NULL OR decided_via IN ('web_session','app_session','oauth','api_key','invocation')),
23
+ decided_via TEXT CHECK (decided_via IS NULL OR decided_via IN ('web_session','app_session','oauth','api_key','invocation','acp_bridge','cloud_login')),
24
24
  decided_at TIMESTAMPTZ,
25
25
  consumed_at TIMESTAMPTZ,
26
26
  created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
@@ -28,6 +28,14 @@ export const migrateApprovals = async (): Promise<void> => {
28
28
  UNIQUE (requester_kind, requester_ref)
29
29
  )
30
30
  `.simple();
31
+ await sql`DO $$ BEGIN
32
+ IF EXISTS (SELECT 1 FROM pg_constraint WHERE conrelid='approvals.requests'::regclass
33
+ AND conname='requests_decided_via_check' AND position('cloud_login' in pg_get_constraintdef(oid))=0) THEN
34
+ ALTER TABLE approvals.requests DROP CONSTRAINT requests_decided_via_check;
35
+ ALTER TABLE approvals.requests ADD CONSTRAINT requests_decided_via_check
36
+ CHECK (decided_via IS NULL OR decided_via IN ('web_session','app_session','oauth','api_key','invocation','acp_bridge','cloud_login'));
37
+ END IF;
38
+ END $$`.simple();
31
39
  await sql`CREATE INDEX IF NOT EXISTS approvals_requests_pending ON approvals.requests(approver_user_id, created_at) WHERE status='pending'`.simple();
32
40
  await sql`CREATE INDEX IF NOT EXISTS approvals_requests_expiry ON approvals.requests(expires_at)`.simple();
33
41
  };
@@ -0,0 +1,25 @@
1
+ import { AI_INFERENCE_SCOPE, type User } from "../../contracts/shared";
2
+ import { isAccountExpired } from "../account-model";
3
+ import { serviceAccountCredentials } from "../service-account-credentials";
4
+
5
+ export const authenticateModelsOnlyCredential = async (
6
+ token: string | null | undefined,
7
+ ): Promise<{ user: User; credentialId: string } | null> => {
8
+ if (!token || !serviceAccountCredentials.isApiToken(token)) return null;
9
+ const authenticated = await serviceAccountCredentials.authenticateApiToken(token);
10
+ if (!authenticated) return null;
11
+ const { credential, serviceAccount, delegatedUser } = authenticated;
12
+ if (
13
+ credential.kind !== "api_token" ||
14
+ credential.status !== "active" ||
15
+ credential.serviceAccountId !== serviceAccount.id ||
16
+ serviceAccount.status !== "active" ||
17
+ serviceAccount.kind !== "user_delegated" ||
18
+ !delegatedUser ||
19
+ serviceAccount.delegatedUserId !== delegatedUser.id ||
20
+ !credential.scopes.includes(AI_INFERENCE_SCOPE) ||
21
+ isAccountExpired(delegatedUser.accountExpires)
22
+ )
23
+ return null;
24
+ return { user: delegatedUser, credentialId: credential.id };
25
+ };
@@ -1,5 +1,5 @@
1
1
  import sanitizeHtml from "sanitize-html";
2
- import { sanitizeEmailHtml } from "../../shared/email-html";
2
+ import { EMAIL_HTML_SANITIZE_OPTIONS } from "../../shared/email-html";
3
3
  import * as settings from "../settings";
4
4
  import { coreSettings } from "../settings/api";
5
5
 
@@ -8,16 +8,29 @@ const sanitizeContent = (content: string): string => sanitizeHtml(content, { all
8
8
  // sanitize-html decodes entities before escaping &, < and > again. Decode only
9
9
  // those escapes once, so literal entity text is not recursively interpreted.
10
10
  const htmlText = (html: string): string =>
11
- sanitizeContent(html.replace(/<br\b[^>]*>|<\/(?:p|div|h[1-6]|li|tr|td|th|ul|ol|table)>/gi, " "))
11
+ sanitizeContent(html.replace(/<br\b[^>]*>|<\/(?:p|pre|blockquote|div|h[1-6]|li|tr|td|th|ul|ol|table)>/gi, " "))
12
12
  .replace(/&(amp|lt|gt);/g, (entity) => (entity === "&amp;" ? "&" : entity === "&lt;" ? "<" : ">"))
13
13
  .replace(/\s+/g, " ")
14
14
  .trim();
15
15
 
16
+ // The frame has no stylesheet, so a `pre` would keep the client default `white-space: pre` and push long
17
+ // lines past the frame. The sender's own style comes last and still wins.
18
+ const frameHtmlOptions = {
19
+ ...EMAIL_HTML_SANITIZE_OPTIONS,
20
+ transformTags: {
21
+ ...EMAIL_HTML_SANITIZE_OPTIONS.transformTags,
22
+ pre: (tagName, attribs) => ({
23
+ tagName,
24
+ attribs: { ...attribs, style: `white-space:pre-wrap;overflow-wrap:break-word;${attribs.style ?? ""}` },
25
+ }),
26
+ },
27
+ } satisfies sanitizeHtml.IOptions;
28
+
16
29
  export const prepareNotificationEmail = async (opts: { content?: string; rawHtml?: string }): Promise<{ html: string; text: string }> => {
17
30
  const rawAppUrl = await settings.get<string>("app.url");
18
31
  const appUrl = rawAppUrl.startsWith("http") ? rawAppUrl : `https://${rawAppUrl}`;
19
32
  const appName = await settings.get<string>("app.name");
20
- const body = opts.rawHtml ? sanitizeEmailHtml(opts.rawHtml) : opts.content ? `<p>${sanitizeContent(opts.content)}</p>` : "";
33
+ const body = opts.rawHtml ? sanitizeHtml(opts.rawHtml, frameHtmlOptions) : opts.content ? `<p>${sanitizeContent(opts.content)}</p>` : "";
21
34
  return { html: await buildHtml(appUrl, appName, body), text: opts.content ? sanitizeContent(opts.content) : htmlText(body) };
22
35
  };
23
36
 
@@ -14,9 +14,10 @@ import { logger } from "./logging";
14
14
  * the dispatcher publishes keys concurrently and the rows of one key in order,
15
15
  * then completes the batch in one statement. A batch starts no publish after
16
16
  * its claim has ended, because the next claim takes its rows. A row that never
17
- * failed is due at once. Claims stay proportional to the batch with an index on
18
- * the `where` columns and `sequence`, and one on the `where` columns and
19
- * `orderBy` limited to `claimed_until IS NOT NULL OR attempts > 0`.
17
+ * failed is due at once. Claims stay proportional to the batch, whatever the
18
+ * table's statistics say, with an index on the `where` columns and `sequence`,
19
+ * and one on the `where` columns and `orderBy` limited to
20
+ * `claimed_until IS NOT NULL OR attempts > 0`.
20
21
  *
21
22
  * Required columns: `id uuid`, `attempts int`, `next_attempt_at timestamptz`,
22
23
  * `claimed_until timestamptz`, `last_error text`, and the `orderBy` and
@@ -97,13 +98,20 @@ export const createPgOutbox = <Row extends OutboxRow>(config: PgOutboxConfig<Row
97
98
  const cap = Math.min(Math.max(limit, 1), batchSize);
98
99
  // Claims take turns: a concurrent claim would skip this one's locked rows and take later rows of the same keys.
99
100
  // A claim that outlives its claim period is worthless, so neither a slow statement nor a vanished client holds the turn longer.
101
+ // Without sequential and bitmap scans, statistics of an empty or claimed outbox cannot make a claim scan and sort
102
+ // the whole backlog instead of walking the sequence index up to the batch. A disabled scan still counts as very
103
+ // expensive, which would compile every claim (JIT, ~0.4 s), so JIT stays off.
100
104
  await tx`
101
105
  SELECT set_config('statement_timeout', ${String(claimMs)}, true),
102
106
  set_config('idle_in_transaction_session_timeout', ${String(claimMs)}, true),
107
+ set_config('enable_seqscan', 'off', true),
108
+ set_config('enable_bitmapscan', 'off', true),
109
+ set_config('jit', 'off', true),
103
110
  pg_advisory_xact_lock(hashtextextended(${claimLock}, 0))
104
111
  `;
105
112
  // The busy probe runs per candidate (OFFSET 0 keeps the planner from turning it into a join, which
106
- // stale statistics can make quadratic), against an index of claimed and retried rows per key.
113
+ // stale statistics can make quadratic), against an index of claimed and retried rows per key. The update looks
114
+ // its rows up by id for the same reason: as a join, it scanned the whole table per claim.
107
115
  return tx<Row[]>`
108
116
  WITH candidates AS MATERIALIZED (
109
117
  SELECT current.id
@@ -125,8 +133,7 @@ export const createPgOutbox = <Row extends OutboxRow>(config: PgOutboxConfig<Row
125
133
  ), claimed AS (
126
134
  UPDATE ${table} outbox
127
135
  SET claimed_until = now() + (${claimMs} * interval '1 millisecond')
128
- FROM candidates
129
- WHERE outbox.id = candidates.id
136
+ WHERE outbox.id = ANY(ARRAY(SELECT id FROM candidates))
130
137
  RETURNING outbox.*
131
138
  )
132
139
  SELECT * FROM claimed ORDER BY ${sequence}
@@ -1,7 +1,7 @@
1
1
  import { createHash, timingSafeEqual } from "node:crypto";
2
2
  import { crypto, err, fail, ok, type PageParams, type Paginated, type Result } from "@k2b/stdlib";
3
3
  import { type SQL, sql } from "bun";
4
- import type { User } from "../contracts/shared";
4
+ import { AI_INFERENCE_SCOPE, type User } from "../contracts/shared";
5
5
  import { accounts } from "./accounts";
6
6
  import { audit } from "./audit";
7
7
  import { isUniqueViolation, toPgTextArray } from "./postgres";
@@ -199,12 +199,16 @@ const generateTokenParts = (): { tokenPrefix: string; secret: string; token: str
199
199
  };
200
200
 
201
201
  // API token secrets carry 256 bits of entropy, so a fast digest is sufficient and avoids password-KDF cost per request.
202
- const hashApiTokenSecret = (secret: string): string => `sha256:${createHash("sha256").update(secret).digest("hex")}`;
202
+ // Domain-separate models-only keys so older platforms fail verification instead of granting full authority.
203
+ const hashApiTokenSecret = (secret: string, scopes: readonly string[]): string =>
204
+ `sha256:${createHash("sha256")
205
+ .update(scopes.includes(AI_INFERENCE_SCOPE) ? `${AI_INFERENCE_SCOPE}:${secret}` : secret)
206
+ .digest("hex")}`;
203
207
 
204
- const verifyApiTokenSecret = async (secret: string, storedHash: string): Promise<boolean> => {
208
+ const verifyApiTokenSecret = async (secret: string, storedHash: string, scopes: readonly string[]): Promise<boolean> => {
205
209
  const match = storedHash.match(SECRET_HASH_PATTERN);
206
210
  if (match) {
207
- const actual = Buffer.from(hashApiTokenSecret(secret).slice("sha256:".length), "hex");
211
+ const actual = Buffer.from(hashApiTokenSecret(secret, scopes).slice("sha256:".length), "hex");
208
212
  const expected = Buffer.from(match[1]!, "hex");
209
213
  return timingSafeEqual(actual, expected);
210
214
  }
@@ -338,7 +342,7 @@ const insertApiToken = async (
338
342
  }
339
343
 
340
344
  const parts = await generateUniqueTokenParts();
341
- const secretHash = hashApiTokenSecret(parts.secret);
345
+ const secretHash = hashApiTokenSecret(parts.secret, params.scopes ?? []);
342
346
 
343
347
  try {
344
348
  const [row] = await db<DbCredentialRow[]>`
@@ -371,16 +375,20 @@ const insertApiToken = async (
371
375
  }
372
376
  };
373
377
 
374
- export const createApiToken = (params: {
378
+ export const createApiToken = async (params: {
375
379
  serviceAccountId: string;
376
380
  name: string;
377
381
  createdBy?: string | null;
378
382
  expiresAt?: string | null;
379
383
  scopes?: string[];
380
- }): Promise<Result<{ credential: ServiceAccountCredential; token: string }>> => insertApiToken(sql, params);
384
+ }): Promise<Result<{ credential: ServiceAccountCredential; token: string }>> => {
385
+ if (params.scopes?.includes(AI_INFERENCE_SCOPE)) return fail(err.badInput("ai:inference is reserved for models-only personal API keys"));
386
+ return insertApiToken(sql, params);
387
+ };
381
388
 
382
389
  export const createUserApiToken = async (params: {
383
390
  user: User;
391
+ access?: "full" | "models";
384
392
  name: string;
385
393
  expiresAt?: string | null;
386
394
  }): Promise<Result<{ credential: ServiceAccountCredential; token: string }>> => {
@@ -396,6 +404,7 @@ export const createUserApiToken = async (params: {
396
404
  name: params.name,
397
405
  expiresAt: params.expiresAt,
398
406
  createdBy: params.user.id,
407
+ scopes: params.access === "models" ? [AI_INFERENCE_SCOPE] : [],
399
408
  });
400
409
 
401
410
  return audit.recordResult({
@@ -404,6 +413,7 @@ export const createUserApiToken = async (params: {
404
413
  target: { type: "service_account_credential", id: result.ok ? result.data.credential.id : null, label: params.name },
405
414
  metadata: {
406
415
  serviceAccountId: serviceAccountResult.data.id,
416
+ access: params.access ?? "full",
407
417
  kind: "api_token",
408
418
  expiresAt: params.expiresAt ?? null,
409
419
  },
@@ -420,6 +430,7 @@ export const createResourceApiToken = async (params: {
420
430
  expiresAt?: string | null;
421
431
  scopes?: string[];
422
432
  }): Promise<Result<{ credential: ServiceAccountCredential; token: string }>> => {
433
+ if (params.scopes?.includes(AI_INFERENCE_SCOPE)) return fail(err.badInput("ai:inference is reserved for models-only personal API keys"));
423
434
  const serviceAccount = await serviceAccounts.get({ id: params.serviceAccountId });
424
435
  if (!serviceAccount || serviceAccount.kind !== "resource_bound") return fail(err.notFound("Resource service account"));
425
436
  if (serviceAccount.status !== "active") return fail(err.badInput("Resource service account is disabled"));
@@ -460,6 +471,7 @@ export const createStandaloneApiToken = async (params: {
460
471
  expiresAt?: string | null;
461
472
  scopes?: string[];
462
473
  }): Promise<Result<{ credential: ServiceAccountCredential; token: string }>> => {
474
+ if (params.scopes?.includes(AI_INFERENCE_SCOPE)) return fail(err.badInput("ai:inference is reserved for models-only personal API keys"));
463
475
  const serviceAccount = await serviceAccounts.get({ id: params.serviceAccountId });
464
476
  if (!serviceAccount || !isStandaloneServiceAccountKind(serviceAccount.kind)) return fail(err.notFound("Service account"));
465
477
  if (serviceAccount.status !== "active") return fail(err.badInput("Service account is disabled"));
@@ -780,7 +792,7 @@ const recordSuccessfulAuthentication = async (params: {
780
792
  serviceAccount: ServiceAccount;
781
793
  delegatedUser: User | null;
782
794
  }): Promise<void> => {
783
- const upgradedSecretHash = SECRET_HASH_PATTERN.test(params.row.secret_hash) ? null : hashApiTokenSecret(params.secret);
795
+ const upgradedSecretHash = SECRET_HASH_PATTERN.test(params.row.secret_hash) ? null : hashApiTokenSecret(params.secret, params.row.scopes);
784
796
  if (!upgradedSecretHash && !params.row.activity_due) return;
785
797
 
786
798
  if (upgradedSecretHash) {
@@ -836,10 +848,10 @@ const recordSuccessfulAuthentication = async (params: {
836
848
  const pendingSecretVerifications = new Map<string, Promise<boolean>>();
837
849
 
838
850
  const verifyApiTokenSecretOnce = async (secret: string, row: DbCredentialWithSecretRow): Promise<boolean> => {
839
- const key = `${row.id}:${row.secret_hash}:${hashApiTokenSecret(secret)}`;
851
+ const key = `${row.id}:${row.secret_hash}:${hashApiTokenSecret(secret, row.scopes)}`;
840
852
  let pending = pendingSecretVerifications.get(key);
841
853
  if (!pending) {
842
- pending = verifyApiTokenSecret(secret, row.secret_hash);
854
+ pending = verifyApiTokenSecret(secret, row.secret_hash, row.scopes);
843
855
  pendingSecretVerifications.set(key, pending);
844
856
  }
845
857
  try {
@@ -296,6 +296,13 @@ export const CORE_SETTINGS = {
296
296
  placeholder: "Keep answers concise. The current user is {{ user.displayName }} and today is {{ today }}.",
297
297
  templateVars: ["user.displayName", "user.uid", "user.mail", "appId", "now", "today", "time"],
298
298
  },
299
+ "ai.local_client_instructions": {
300
+ kind: "text",
301
+ label: "Instructions for local clients",
302
+ default: "",
303
+ description:
304
+ "Added to turns from local clients such as cld after the organization instructions. Leave empty to use the built-in text, which improves with each release.",
305
+ },
299
306
  "ai.compaction_instructions": {
300
307
  kind: "text",
301
308
  label: "Additional Compaction Instructions",
@@ -328,6 +335,26 @@ export const CORE_SETTINGS = {
328
335
  description: "Firecrawl API key used by Cloud AI web_search and web_extract tools.",
329
336
  placeholder: "fc-...",
330
337
  },
338
+ "ai.openai_api_enabled": {
339
+ kind: "boolean",
340
+ label: "OpenAI-compatible API",
341
+ default: false,
342
+ description:
343
+ "Let people create API keys with the access “AI models only” and call the Assistant models they may use. Checked on every request: turning this off stops existing keys too. Models without reference prices have no usage limit; only the rate limit and the concurrent request limit protect them.",
344
+ },
345
+ // Default 4: one process runs at most 8 chat turns at once (AI_TURN_WORKER_CONCURRENCY in ai/runtime.ts), and this
346
+ // synchronous API bypasses that queue, so one person may hold at most half of it. Typical clients need 1-2 (one
347
+ // request after another plus a connection test); 4 leaves room for a second client without letting a script in a
348
+ // loop crowd out everyone else. Operators with more provider capacity raise it.
349
+ "ai.openai_api_max_concurrent_requests": {
350
+ kind: "number",
351
+ label: "OpenAI API concurrent requests per person",
352
+ integer: true,
353
+ min: 1,
354
+ max: 64,
355
+ default: 4,
356
+ description: "Maximum simultaneous OpenAI-compatible API requests per person across all Cloud instances.",
357
+ },
331
358
  // ── PDF rendering ─────────────────────────────────────────────────────
332
359
  "gotenberg.url": {
333
360
  kind: "url",