@k2b/cloud 0.38.0 → 0.40.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 (244) hide show
  1. package/README.md +8 -0
  2. package/package.json +12 -5
  3. package/scripts/build-canvas-workers.ts +109 -29
  4. package/scripts/build.ts +3 -5
  5. package/src/_internal/app-favicon.ts +4 -2
  6. package/src/_internal/app-path.ts +25 -0
  7. package/src/_internal/app-presentation.ts +4 -0
  8. package/src/_internal/canvas-worker.ts +5 -3
  9. package/src/_internal/capabilities.ts +72 -48
  10. package/src/_internal/capability-reviews.ts +81 -0
  11. package/src/_internal/capability-schemas.ts +29 -0
  12. package/src/_internal/capability-sentences.ts +216 -1
  13. package/src/_internal/capability-streams.ts +7 -0
  14. package/src/_internal/define-app.ts +63 -16
  15. package/src/_internal/font-preloads.ts +2 -1
  16. package/src/_internal/panel-cookie.ts +39 -0
  17. package/src/_internal/registry-validation.ts +21 -26
  18. package/src/_internal/runtime-context.ts +1 -0
  19. package/src/_internal/runtime-watcher.ts +6 -0
  20. package/src/_internal/static-assets.ts +52 -3
  21. package/src/_internal/status-preserving-ssr.ts +2 -0
  22. package/src/access/PermissionEditor.browser-harness.tsx +57 -38
  23. package/src/access/PermissionEditor.tsx +32 -7
  24. package/src/access/ResourceApiKeys.tsx +5 -1
  25. package/src/account/EntitySearch.tsx +1 -1
  26. package/src/ai/action-review.ts +101 -0
  27. package/src/ai/approval-routes.ts +5 -2
  28. package/src/ai/approvals.ts +139 -28
  29. package/src/ai/background-review.ts +72 -0
  30. package/src/ai/browser.ts +10 -0
  31. package/src/ai/capabilities.ts +29 -11
  32. package/src/ai/capability-execution.ts +76 -43
  33. package/src/ai/capability-result.ts +95 -0
  34. package/src/ai/chat/action-card.ts +398 -0
  35. package/src/ai/chat/action-messages.ts +129 -0
  36. package/src/ai/chat/action-question.browser-harness.tsx +245 -0
  37. package/src/ai/chat/action-question.fixtures.ts +733 -0
  38. package/src/ai/chat/action-question.tsx +669 -0
  39. package/src/ai/chat/action-sentences.ts +1 -44
  40. package/src/ai/chat/blocks.tsx +19 -25
  41. package/src/ai/chat/message-actions.tsx +6 -0
  42. package/src/ai/chat/tool-action.tsx +481 -0
  43. package/src/ai/chat/turn-view.tsx +45 -163
  44. package/src/ai/chat/view-block.tsx +22 -7
  45. package/src/ai/chat-task-contracts.ts +31 -3
  46. package/src/ai/chat-task-reasons-migrate.ts +75 -0
  47. package/src/ai/chat-task-reasons.ts +92 -0
  48. package/src/ai/chat-tasks.ts +438 -78
  49. package/src/ai/client/controller.ts +59 -0
  50. package/src/ai/default-tools.ts +9 -1
  51. package/src/ai/executor.ts +190 -39
  52. package/src/ai/index.ts +14 -0
  53. package/src/ai/migrate.ts +39 -4
  54. package/src/ai/projects-routes.ts +3 -0
  55. package/src/ai/protocol.ts +14 -2
  56. package/src/ai/recent-resources.ts +240 -0
  57. package/src/ai/recent-sent-refs.ts +38 -0
  58. package/src/ai/request-context.ts +7 -1
  59. package/src/ai/routes.ts +172 -2
  60. package/src/ai/runtime.ts +13 -27
  61. package/src/ai/skills-routes.ts +3 -0
  62. package/src/ai/skills.ts +10 -1
  63. package/src/ai/store.ts +232 -27
  64. package/src/ai/timeline.ts +9 -1
  65. package/src/ai/tool-audit.ts +8 -0
  66. package/src/ai/tool-wording.ts +67 -0
  67. package/src/ai/turn-abort.ts +33 -0
  68. package/src/ai/types.ts +40 -4
  69. package/src/api/accounts-entities.ts +2 -1
  70. package/src/api/admin-ai-projects.ts +2 -0
  71. package/src/api/admin-ai-skills.ts +2 -0
  72. package/src/api/admin-ai-usage.ts +2 -0
  73. package/src/api/admin-core-settings.ts +190 -139
  74. package/src/api/admin-geoip.ts +80 -0
  75. package/src/api/admin-identity.ts +3 -0
  76. package/src/api/admin-lifecycle.ts +3 -0
  77. package/src/api/admin-outgoing-mail.ts +6 -2
  78. package/src/api/app-approval.ts +54 -6
  79. package/src/api/approvals-loader.ts +23 -1
  80. package/src/api/approvals.ts +3 -1
  81. package/src/api/auth.ts +25 -6
  82. package/src/api/capabilities.ts +44 -11
  83. package/src/api/capability-streams.ts +12 -0
  84. package/src/api/help.ts +4 -28
  85. package/src/api/index.ts +7 -1
  86. package/src/api/lasting-access-messages.ts +17 -0
  87. package/src/api/mcp.ts +3 -0
  88. package/src/api/me-panel-consent.ts +71 -0
  89. package/src/api/me.ts +80 -33
  90. package/src/api/search.ts +3 -1
  91. package/src/api/sync-ops.ts +2 -0
  92. package/src/api/widgets.ts +3 -0
  93. package/src/approvals/Approvals.tsx +180 -238
  94. package/src/approvals/messages.ts +12 -24
  95. package/src/browser/CloudResourceSearch.browser-harness.tsx +10 -2
  96. package/src/browser/CloudResourceSearch.tsx +10 -6
  97. package/src/browser/app-approval.ts +60 -6
  98. package/src/browser/commands.ts +22 -6
  99. package/src/browser/composer-catalog.ts +145 -0
  100. package/src/browser/composer-insert-messages.ts +31 -0
  101. package/src/browser/composer-insert.ts +160 -0
  102. package/src/browser/composer-open-messages.ts +17 -0
  103. package/src/browser/composer-open.ts +110 -0
  104. package/src/browser/discoverable-command.ts +6 -0
  105. package/src/browser/hints.ts +6 -0
  106. package/src/browser/live-connection.ts +26 -2
  107. package/src/browser/navigation-apps.ts +7 -0
  108. package/src/browser/panel-context-bridge.ts +30 -0
  109. package/src/browser/panel-context-receiver.ts +53 -0
  110. package/src/browser/panel-menu.tsx +56 -0
  111. package/src/browser/panel-messages.ts +35 -0
  112. package/src/browser/panel-shortcuts.ts +31 -0
  113. package/src/browser/panel-slot.ts +756 -0
  114. package/src/browser/panel.ts +65 -0
  115. package/src/browser/resource-picker.tsx +12 -1
  116. package/src/browser/search-commands.ts +6 -3
  117. package/src/capabilities/client.ts +8 -2
  118. package/src/capabilities/reviews.ts +205 -0
  119. package/src/capabilities/server.ts +10 -3
  120. package/src/capabilities/testing.ts +47 -0
  121. package/src/capabilities/types.ts +2 -2
  122. package/src/cli/account.ts +13 -3
  123. package/src/cli/admin/geoip.ts +115 -0
  124. package/src/cli/admin/index.ts +3 -0
  125. package/src/contracts/app-approval.ts +159 -12
  126. package/src/contracts/app.ts +14 -0
  127. package/src/contracts/approvals.ts +2 -2
  128. package/src/contracts/capabilities.ts +410 -18
  129. package/src/contracts/geoip.ts +42 -0
  130. package/src/contracts/index.ts +3 -0
  131. package/src/contracts/panel-context-plain.ts +99 -0
  132. package/src/contracts/panel-context.ts +93 -0
  133. package/src/contracts/registry.ts +3 -1
  134. package/src/contracts/seen-hints.ts +35 -0
  135. package/src/contracts/shared.ts +2 -0
  136. package/src/server/client-ip.ts +5 -0
  137. package/src/server/help-payload.ts +38 -0
  138. package/src/server/middleware/auth.ts +11 -1
  139. package/src/server/middleware/middleware.ts +9 -0
  140. package/src/server/middleware/no-transform.ts +19 -0
  141. package/src/server/middleware/rate-limit.ts +4 -6
  142. package/src/server/request-context/address.ts +45 -0
  143. package/src/server/request-context/geoip.ts +380 -0
  144. package/src/server/request-context/index.ts +33 -0
  145. package/src/server/request-context/user-agent.ts +95 -0
  146. package/src/server/services/access.ts +5 -1
  147. package/src/services/accounts/posix.ts +2 -1
  148. package/src/services/app-approval.ts +533 -90
  149. package/src/services/approvals/device.ts +9 -5
  150. package/src/services/approvals/index.ts +6 -5
  151. package/src/services/approvals/list.ts +26 -5
  152. package/src/services/auth-flows/notification-sender.ts +3 -0
  153. package/src/services/geoip/index.ts +164 -0
  154. package/src/services/geoip/store.ts +30 -0
  155. package/src/services/geoip/upload.ts +150 -0
  156. package/src/services/identity/routes.ts +5 -1
  157. package/src/services/index.ts +32 -3
  158. package/src/services/known-browsers.ts +312 -0
  159. package/src/services/login-address-store.ts +51 -0
  160. package/src/services/login-context.ts +118 -0
  161. package/src/services/mandates/index.ts +3 -0
  162. package/src/services/mandates/policy.ts +15 -3
  163. package/src/services/notifications/browser.ts +16 -5
  164. package/src/services/notifications/email-frame.ts +1 -1
  165. package/src/services/oauth-tokens.ts +16 -2
  166. package/src/services/panel-context-consent.ts +58 -0
  167. package/src/services/pdf/image-protocol.ts +135 -0
  168. package/src/services/pdf/image-variants.ts +124 -0
  169. package/src/services/pdf/image-worker.ts +31 -0
  170. package/src/services/pdf/image.ts +109 -0
  171. package/src/services/pdf/index.ts +9 -0
  172. package/src/services/service-account-credentials.ts +21 -0
  173. package/src/services/session/index.ts +97 -16
  174. package/src/services/session/recent.ts +13 -0
  175. package/src/services/settings/api.ts +11 -6
  176. package/src/services/settings/app.ts +5 -4
  177. package/src/services/settings/core-settings.ts +4 -3
  178. package/src/services/settings/index.ts +19 -17
  179. package/src/services/settings/namespace.ts +2 -1
  180. package/src/services/settings/store.ts +83 -11
  181. package/src/services/webauthn.ts +12 -0
  182. package/src/shared/ai-platform-prompt.ts +1 -1
  183. package/src/shared/app-presentation.ts +1 -0
  184. package/src/shared/email-html.ts +2 -0
  185. package/src/shared/help.ts +3 -0
  186. package/src/shared/redirect.ts +12 -0
  187. package/src/shared-text/cursor.ts +22 -0
  188. package/src/shared-text/documents.ts +142 -0
  189. package/src/shared-text/events.ts +43 -0
  190. package/src/shared-text/index.ts +12 -0
  191. package/src/shared-text/log.ts +48 -0
  192. package/src/shared-text/text-diff.ts +23 -0
  193. package/src/shared-text/types.ts +41 -0
  194. package/src/ssr/AdminLayout.tsx +2 -0
  195. package/src/ssr/AppLaunchpadPanel.tsx +2 -2
  196. package/src/ssr/CloudShell.island.tsx +95 -10
  197. package/src/ssr/Layout.tsx +66 -10
  198. package/src/ssr/LayoutHeader.tsx +15 -9
  199. package/src/ssr/LayoutHelp.browser-harness.tsx +2 -0
  200. package/src/ssr/LayoutHelp.tsx +435 -274
  201. package/src/ssr/LayoutHelpPanel.browser-harness.tsx +136 -0
  202. package/src/ssr/LayoutHelpTrigger.tsx +27 -2
  203. package/src/ssr/LayoutRail.tsx +2 -2
  204. package/src/ssr/LayoutTabBar.tsx +29 -0
  205. package/src/ssr/MobileNavigation.tsx +2 -2
  206. package/src/ssr/PanelLayout.tsx +35 -0
  207. package/src/ssr/PanelRuntime.island.tsx +94 -0
  208. package/src/ssr/PanelSlot.tsx +158 -0
  209. package/src/ssr/PanelTrigger.tsx +49 -0
  210. package/src/ssr/ShellRuntime.island.tsx +69 -5
  211. package/src/ssr/app-appearance.ts +3 -2
  212. package/src/ssr/app-color.ts +105 -0
  213. package/src/ssr/app-launchpad.tsx +18 -1
  214. package/src/ssr/app-navigation.ts +28 -0
  215. package/src/ssr/compact-shell.baseline.json +5270 -0
  216. package/src/ssr/compact-shell.browser-harness.ts +439 -0
  217. package/src/ssr/dialog-app-colors.browser-harness.tsx +43 -0
  218. package/src/ssr/help-messages.ts +4 -0
  219. package/src/ssr/help.ts +20 -0
  220. package/src/ssr/hints.ts +25 -0
  221. package/src/ssr/index.ts +5 -0
  222. package/src/ssr/islands/index.ts +4 -3
  223. package/src/ssr/layout-context.ts +2 -0
  224. package/src/ssr/layout-help-article.server.ts +50 -0
  225. package/src/ssr/layout-help-start.ts +33 -7
  226. package/src/ssr/panel-consent.ts +29 -0
  227. package/src/ssr/panel-context.ts +10 -0
  228. package/src/ssr/panel-slot.browser-harness.tsx +159 -0
  229. package/src/ssr/platform-messages.ts +48 -0
  230. package/src/ssr/rail-navigation.ts +44 -0
  231. package/src/ssr/shell-data.tsx +5 -0
  232. package/src/ssr/shell-shortcuts.ts +13 -2
  233. package/src/styles/effects.css +201 -11
  234. package/src/styles/file-chooser.css +2 -1
  235. package/src/styles/global.css +314 -63
  236. package/src/styles/panel.css +416 -0
  237. package/src/styles/resource-search.css +6 -2
  238. package/src/styles/tokens.css +36 -13
  239. package/src/styles/utilities-buttons.css +3 -2
  240. package/src/styles/utilities-data.css +2 -1
  241. package/src/styles/utilities-feedback.css +2 -2
  242. package/src/styles/utilities-layout.css +3 -13
  243. package/src/styles/utilities-markdown-editor.css +6 -6
  244. package/src/styles/utilities-navigation.css +22 -43
@@ -0,0 +1,58 @@
1
+ import { type SQL, sql } from "bun";
2
+ import { CapabilityAppIdSchema } from "../contracts/capabilities";
3
+
4
+ type ConsentAnswer = { allowed: boolean; revision: number };
5
+ export class PanelConsentCancelled extends Error {
6
+ constructor() {
7
+ super("The request was cancelled before the panel consent was saved");
8
+ }
9
+ }
10
+
11
+ /**
12
+ * Core owns the table; platform requests read without a process-wide cache. Missing is distinct from denied. Reads
13
+ * take a share lock, so a page rendered while a save is in flight sees that save's outcome, not the answer before it.
14
+ */
15
+ export const createPanelContextConsentService = (db: SQL = sql) => ({
16
+ async get(userId: string, provider: string): Promise<ConsentAnswer | null> {
17
+ CapabilityAppIdSchema.parse(provider);
18
+ const [row] = await db<ConsentAnswer[]>`
19
+ SELECT allowed, revision FROM auth.panel_context_consent WHERE user_id = ${userId}::uuid AND provider = ${provider}
20
+ FOR SHARE
21
+ `;
22
+ return row ?? null;
23
+ },
24
+ async list(userId: string, providers: string[]): Promise<Record<string, ConsentAnswer>> {
25
+ if (!providers.length) return {};
26
+ for (const provider of providers) CapabilityAppIdSchema.parse(provider);
27
+ const rows = await db<({ provider: string } & ConsentAnswer)[]>`
28
+ SELECT provider, allowed, revision FROM auth.panel_context_consent
29
+ WHERE user_id = ${userId}::uuid AND provider IN ${db(providers)}
30
+ FOR SHARE
31
+ `;
32
+ return Object.fromEntries(rows.map(({ provider, allowed, revision }) => [provider, { allowed, revision }]));
33
+ },
34
+ /**
35
+ * Compare-and-set on the revision the client read. A save whose request was cancelled (the browser gave up or
36
+ * left) is rolled back before commit, so no answer lands after its page stopped waiting.
37
+ */
38
+ async set(userId: string, provider: string, allowed: boolean, revision: number, signal?: AbortSignal): Promise<ConsentAnswer | null> {
39
+ CapabilityAppIdSchema.parse(provider);
40
+ return db.begin(async (tx) => {
41
+ await tx`SET LOCAL statement_timeout = '5s'`;
42
+ const [row] = await tx<ConsentAnswer[]>`
43
+ INSERT INTO auth.panel_context_consent (user_id, provider, allowed, revision)
44
+ SELECT ${userId}::uuid, ${provider}, ${allowed}, 1
45
+ WHERE ${revision} = 0 OR EXISTS (
46
+ SELECT 1 FROM auth.panel_context_consent WHERE user_id = ${userId}::uuid AND provider = ${provider}
47
+ )
48
+ ON CONFLICT (user_id, provider) DO UPDATE SET
49
+ allowed = EXCLUDED.allowed, revision = auth.panel_context_consent.revision + 1, updated_at = now()
50
+ WHERE auth.panel_context_consent.revision = ${revision}
51
+ RETURNING allowed, revision
52
+ `;
53
+ if (signal?.aborted) throw new PanelConsentCancelled();
54
+ return row ?? null;
55
+ });
56
+ },
57
+ });
58
+ export const panelContextConsent = createPanelContextConsentService();
@@ -0,0 +1,135 @@
1
+ import { z } from "zod";
2
+
3
+ /**
4
+ * Bun already reserves about 270..296 MiB of the 512 MiB data limit. A single 32 MiB input plus 64 MiB RGBA
5
+ * leaves about 120..146 MiB for codecs and temporary buffers. Notebooks defaults to 10 MB attachments.
6
+ */
7
+ export const PDF_IMAGE_MAX_INPUT_BYTES = 32 * 1024 * 1024;
8
+ /** Bound the number of encoders and the output envelope per call. */
9
+ export const PDF_IMAGE_MAX_BOXES = 8;
10
+ export const ImageBox = z.object({ width: z.number().int().min(1).max(16383), height: z.number().int().min(1).max(16383) });
11
+ export const ImageRequest = z.object({
12
+ boxes: z.array(ImageBox).min(1).max(PDF_IMAGE_MAX_BOXES),
13
+ maxBytes: z.number().int().min(1).max(PDF_IMAGE_MAX_INPUT_BYTES),
14
+ // 240 s is the request idle-timeout ceiling; 30 s matches the PDF page decoder.
15
+ timeoutMs: z.number().int().min(1).max(240_000).default(30_000),
16
+ });
17
+ export const ImageErrorCode = z.enum(["unsupported_format", "unreadable", "too_large", "timed_out"]);
18
+ export const ImageVariantHeader = z.object({
19
+ type: z.enum(["image/jpeg", "image/png"]),
20
+ width: z.number().int().min(1).max(16383),
21
+ height: z.number().int().min(1).max(16383),
22
+ bytes: z.number().int().min(1).max(PDF_IMAGE_MAX_INPUT_BYTES),
23
+ });
24
+ export const ImageResponse = z.discriminatedUnion("ok", [
25
+ z.strictObject({ ok: z.literal(true) }),
26
+ z.strictObject({ ok: z.literal(false), code: ImageErrorCode }),
27
+ ]);
28
+
29
+ const ImageFrameHeader = ImageRequest.extend({ bytes: z.number().int().min(0).max(PDF_IMAGE_MAX_INPUT_BYTES) });
30
+
31
+ const ImageRecord = z.union([ImageVariantHeader.extend({ index: z.number().int().min(0) }).strict(), ImageResponse]);
32
+ type ImageVariant = { data: Blob; type: "image/jpeg" | "image/png"; width: number; height: number };
33
+
34
+ /** Read bounded variant records and their exact bodies, followed by one final status and EOF. */
35
+ export async function readImageRecords(
36
+ stream: ReadableStream<Uint8Array>,
37
+ request: Pick<z.infer<typeof ImageRequest>, "boxes" | "maxBytes">,
38
+ ) {
39
+ const reader = stream.getReader();
40
+ let chunk: Uint8Array = new Uint8Array();
41
+ let offset = 0;
42
+ const more = async () => {
43
+ while (offset === chunk.byteLength) {
44
+ const { done, value } = await reader.read();
45
+ if (done) return false;
46
+ chunk = value;
47
+ offset = 0;
48
+ }
49
+ return true;
50
+ };
51
+ const variants: (ImageVariant | null)[] = request.boxes.map(() => null);
52
+ let count = 0;
53
+ let total = 0;
54
+ try {
55
+ for (;;) {
56
+ const line: Uint8Array[] = [];
57
+ let length = 0;
58
+ for (;;) {
59
+ if (!(await more())) throw new Error("Missing image record.");
60
+ const newline = chunk.indexOf(10, offset);
61
+ const end = newline < 0 ? chunk.byteLength : newline;
62
+ length += end - offset;
63
+ if (length > 4096) throw new Error("Image record exceeds 4 KiB.");
64
+ line.push(chunk.subarray(offset, end));
65
+ offset = end + (newline < 0 ? 0 : 1);
66
+ if (newline >= 0) break;
67
+ }
68
+ const record = ImageRecord.parse(JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(Buffer.concat(line, length))));
69
+ if ("ok" in record) {
70
+ if (await more()) throw new Error("Unexpected data after final image record.");
71
+ return record.ok ? { ok: true as const, variants } : record;
72
+ }
73
+ const box = request.boxes[record.index];
74
+ total += record.bytes;
75
+ if (
76
+ ++count > request.boxes.length ||
77
+ !box ||
78
+ variants[record.index] !== null ||
79
+ record.bytes > request.maxBytes ||
80
+ record.width > box.width ||
81
+ record.height > box.height ||
82
+ total > request.boxes.length * request.maxBytes
83
+ ) {
84
+ throw new Error("Image variant exceeds its bounds or repeats an index.");
85
+ }
86
+ const body = new Uint8Array(record.bytes);
87
+ let written = 0;
88
+ while (written < body.byteLength) {
89
+ if (!(await more())) throw new Error("Truncated image variant.");
90
+ const size = Math.min(body.byteLength - written, chunk.byteLength - offset);
91
+ body.set(chunk.subarray(offset, offset + size), written);
92
+ written += size;
93
+ offset += size;
94
+ }
95
+ variants[record.index] = {
96
+ data: new Blob([body], { type: record.type }),
97
+ type: record.type,
98
+ width: record.width,
99
+ height: record.height,
100
+ };
101
+ }
102
+ } catch (error) {
103
+ await reader.cancel().catch(() => {});
104
+ throw error;
105
+ } finally {
106
+ reader.releaseLock();
107
+ }
108
+ }
109
+
110
+ /** Read a 4 KiB JSON header, then fill one allocation of exactly the declared input length. */
111
+ export function readImageFrame(read: (buffer: Uint8Array, offset: number, length: number) => number) {
112
+ // Async stdin chunks made worker memory nondeterministic under its data limit, so read synchronously.
113
+ const headerBuffer = new Uint8Array(4097);
114
+ let headerBytes = 0;
115
+ let newline = -1;
116
+ while (newline < 0) {
117
+ const size = read(headerBuffer, headerBytes, headerBuffer.byteLength - headerBytes);
118
+ if (size === 0) throw new Error("Missing image frame header.");
119
+ newline = headerBuffer.subarray(0, headerBytes + size).indexOf(10, headerBytes);
120
+ headerBytes += size;
121
+ if (newline < 0 && headerBytes === headerBuffer.byteLength) throw new Error("Image frame header exceeds its limit.");
122
+ }
123
+ const header = ImageFrameHeader.parse(JSON.parse(Buffer.from(headerBuffer.buffer, 0, newline).toString("utf8")));
124
+ const body = new Uint8Array(header.bytes);
125
+ let bodyBytes = headerBytes - newline - 1;
126
+ if (bodyBytes > body.byteLength) throw new Error("Image frame body exceeds its declared length.");
127
+ body.set(headerBuffer.subarray(newline + 1, headerBytes));
128
+ while (bodyBytes < body.byteLength) {
129
+ const size = read(body, bodyBytes, body.byteLength - bodyBytes);
130
+ if (size === 0) throw new Error("Truncated image frame body.");
131
+ bodyBytes += size;
132
+ }
133
+ if (read(headerBuffer, 0, 1) !== 0) throw new Error("Image frame body exceeds its declared length.");
134
+ return { header, body };
135
+ }
@@ -0,0 +1,124 @@
1
+ import sharp from "sharp";
2
+ import type { z } from "zod";
3
+ import type { ImageRequest, ImageResponse, ImageVariantHeader } from "./image-protocol";
4
+
5
+ sharp.concurrency(1);
6
+ sharp.cache(false);
7
+
8
+ /**
9
+ * 16 MiP is 64 MiB RGBA; A4 at 300 dpi needs about 5.8 MP. With 270..296 MiB for Bun and 32 MiB for input,
10
+ * about 120..146 MiB of the 512 MiB limit remains for codecs and temporary buffers. Some source decodes need more.
11
+ */
12
+ const MAX_DECODED_PIXELS = 16 * 1024 * 1024;
13
+ const MAX_SOURCE_PIXELS = 268402689; // sharp's unchanged default limitInputPixels.
14
+ // Measured RGBA WebP peaks are non-monotonic: 440..473 MiB at 16 MiP, 476..495 at 24 MiP, 429..457 at 32 MiP.
15
+ // Cap full-image decoders at 16 MiP to leave about 40 MiB below the worker's 512 MiB data limit.
16
+ const MAX_FULL_DECODE_SOURCE_PIXELS = 16 * 1024 * 1024;
17
+
18
+ const fit = (width: number, height: number, box: { width: number; height: number }) => {
19
+ const scale = Math.min(1, box.width / width, box.height / height);
20
+ return { width: Math.max(1, Math.round(width * scale)), height: Math.max(1, Math.round(height * scale)) };
21
+ };
22
+
23
+ /** Worker-only: validate pass-throughs, decode once for resized variants, then emit encodings sequentially. */
24
+ export async function prepareImageVariants(
25
+ source: Uint8Array | undefined,
26
+ request: z.infer<typeof ImageRequest>,
27
+ write: (index: number, header: z.infer<typeof ImageVariantHeader>, bytes: Uint8Array) => Promise<void>,
28
+ ): Promise<z.infer<typeof ImageResponse>> {
29
+ let identified = false;
30
+ try {
31
+ const metadata = await sharp(source).metadata();
32
+ identified = true;
33
+ if (
34
+ !["jpeg", "png", "webp", "gif", "tiff"].includes(metadata.format) &&
35
+ !(metadata.format === "heif" && metadata.compression === "av1")
36
+ ) {
37
+ return { ok: false, code: "unsupported_format" };
38
+ }
39
+ const width = metadata.width;
40
+ const height = metadata.pageHeight ?? metadata.height;
41
+ if (width * height > MAX_SOURCE_PIXELS) return { ok: false, code: "too_large" };
42
+ if (["webp", "gif", "tiff", "heif"].includes(metadata.format) && width * height > MAX_FULL_DECODE_SOURCE_PIXELS) {
43
+ return { ok: false, code: "too_large" };
44
+ }
45
+ const rotated = (metadata.orientation ?? 1) >= 5;
46
+ const upright = { width: rotated ? height : width, height: rotated ? width : height };
47
+ const sizes = request.boxes.map((box) => fit(upright.width, upright.height, box));
48
+ const passthrough = request.boxes.map(
49
+ (box) =>
50
+ metadata.format === "jpeg" &&
51
+ (metadata.orientation === undefined || metadata.orientation === 1) &&
52
+ (metadata.space === "srgb" || metadata.space === "b-w") &&
53
+ // `space` describes the pixels, not the profile: a Display P3 or Adobe RGB JPEG also reports srgb.
54
+ !metadata.hasProfile &&
55
+ width <= box.width &&
56
+ height <= box.height &&
57
+ source!.byteLength <= request.maxBytes,
58
+ );
59
+ // Metadata alone accepts truncated JPEGs. Validate once with default failOn before emitting any unchanged bytes.
60
+ if (passthrough.some(Boolean)) await sharp(source).resize(8, 8, { fit: "inside" }).toBuffer();
61
+ for (let index = 0; index < sizes.length; index++) {
62
+ if (!passthrough[index]) continue;
63
+ await write(index, { type: "image/jpeg", width, height, bytes: source!.byteLength }, source!);
64
+ }
65
+ const needed = sizes.flatMap((_, index) => (passthrough[index] ? [] : [index]));
66
+ if (!needed.length) return { ok: true };
67
+ const largestIndex = needed.reduce((a, b) => (sizes[a]!.width * sizes[a]!.height >= sizes[b]!.width * sizes[b]!.height ? a : b));
68
+ const largest = sizes[largestIndex]!;
69
+ if (largest.width * largest.height > MAX_DECODED_PIXELS) return { ok: false, code: "too_large" };
70
+ const decoded = await sharp(source)
71
+ // Fit the original box once: fitting already rounded dimensions can shrink an extra pixel.
72
+ .resize({ ...request.boxes[largestIndex]!, fit: "inside", withoutEnlargement: true })
73
+ // autoOrient before resize disables shrink-on-load.
74
+ .autoOrient()
75
+ .toColourspace("srgb")
76
+ .raw({ depth: "uchar" })
77
+ .toBuffer({ resolveWithObject: true });
78
+ source = undefined;
79
+ const { data, info } = decoded;
80
+ let transparent = false;
81
+ if (info.channels === 2 || info.channels === 4) {
82
+ for (let offset = info.channels - 1; offset < data.length; offset += info.channels) {
83
+ if (data[offset] !== 255) {
84
+ transparent = true;
85
+ break;
86
+ }
87
+ }
88
+ }
89
+ for (let index = 0; index < sizes.length; index++) {
90
+ if (passthrough[index]) continue;
91
+ const size = sizes[index]!;
92
+ let image = sharp(data, { raw: { width: info.width, height: info.height, channels: info.channels } });
93
+ if (size.width !== info.width || size.height !== info.height) image = image.resize(size.width, size.height, { fit: "fill" });
94
+ const type = transparent ? "image/png" : "image/jpeg";
95
+ let encoded: Buffer | undefined = await (transparent
96
+ ? image.png()
97
+ : image.removeAlpha().jpeg({ quality: 85, mozjpeg: true })
98
+ ).toBuffer();
99
+ if (encoded.byteLength <= request.maxBytes) {
100
+ await write(index, { ...size, type, bytes: encoded.byteLength }, encoded);
101
+ }
102
+ encoded = undefined;
103
+ }
104
+ return { ok: true };
105
+ } catch (error) {
106
+ const message = error instanceof Error ? error.message.toLowerCase() : "";
107
+ // Codec allocation errors can also mention a corrupt header or unsupported format. Memory takes precedence.
108
+ // Thread stacks count against the data limit.
109
+ // Unidentified byte streams are unreadable; a recognized but missing codec is unsupported.
110
+ const code = [
111
+ "out of memory",
112
+ "insufficient memory",
113
+ "memory allocation",
114
+ "cannot allocate",
115
+ "pixel limit",
116
+ "error creating thread",
117
+ ].some((part) => message.includes(part))
118
+ ? "too_large"
119
+ : (identified && message.includes("unsupported image format")) || message.includes("compression format has not been built in")
120
+ ? "unsupported_format"
121
+ : "unreadable";
122
+ return { ok: false, code };
123
+ }
124
+ }
@@ -0,0 +1,31 @@
1
+ // Standalone subprocess. No server imports or application credentials.
2
+ import { readSync } from "node:fs";
3
+ import { readImageFrame } from "./image-protocol";
4
+ import { prepareImageVariants } from "./image-variants";
5
+
6
+ const write = (chunk: string | Uint8Array) =>
7
+ new Promise<void>((resolve, reject) => {
8
+ process.stdout.write(chunk, (error) => (error ? reject(error) : resolve()));
9
+ });
10
+ // Timers bound processing. During blocking stdin reads, a crashed parent closes the pipe;
11
+ // a live parent enforces its deadline with SIGKILL.
12
+ const expire = () => process.exit(1);
13
+ let timer = setTimeout(expire, 240_000);
14
+ try {
15
+ let frame: ReturnType<typeof readImageFrame> | undefined = readImageFrame((buffer, offset, length) =>
16
+ readSync(0, buffer, offset, length, null),
17
+ );
18
+ const request = frame.header;
19
+ clearTimeout(timer);
20
+ timer = setTimeout(expire, request.timeoutMs);
21
+ const pending = prepareImageVariants(frame.body, request, async (index, header, bytes) => {
22
+ await write(`${JSON.stringify({ index, ...header })}\n`);
23
+ await write(bytes);
24
+ });
25
+ frame = undefined;
26
+ await write(`${JSON.stringify(await pending)}\n`);
27
+ } catch {
28
+ await write(`${JSON.stringify({ ok: false, code: "unreadable" })}\n`).catch(() => {});
29
+ } finally {
30
+ clearTimeout(timer);
31
+ }
@@ -0,0 +1,109 @@
1
+ import { z } from "zod";
2
+ import { spawnCanvasWorker } from "../../_internal/canvas-worker";
3
+ import { type ImageBox, type ImageErrorCode, ImageRequest, PDF_IMAGE_MAX_INPUT_BYTES, readImageRecords } from "./image-protocol";
4
+
5
+ export { PDF_IMAGE_MAX_BOXES, PDF_IMAGE_MAX_INPUT_BYTES } from "./image-protocol";
6
+ /** Integer pixels, 1..16383 per side: a square stays within sharp's default input pixel limit. */
7
+ export type PdfImageBox = z.infer<typeof ImageBox>;
8
+ export type PdfImageVariant = { data: Blob; type: "image/jpeg" | "image/png"; width: number; height: number };
9
+ export type PdfImageErrorCode = z.infer<typeof ImageErrorCode>;
10
+ export type PreparePdfImageInput = { data: Uint8Array | Blob; boxes: PdfImageBox[]; maxBytes: number };
11
+ /** The deadline includes queue time. Default 30 s matches PDF page decoding; 240 s is the request idle-timeout ceiling. */
12
+ export type PreparePdfImageOptions = { signal?: AbortSignal; timeoutMs?: number };
13
+ export type PreparePdfImageResult = { ok: true; variants: (PdfImageVariant | null)[] } | { ok: false; code: PdfImageErrorCode };
14
+
15
+ declare const __CLOUD_PDF_IMAGE_WORKER__: string | undefined;
16
+ let activeWorkers = 0;
17
+ const waiting: (() => void)[] = [];
18
+
19
+ async function acquire(signal: AbortSignal) {
20
+ if (signal.aborted) return false;
21
+ if (activeWorkers < 2) {
22
+ activeWorkers++;
23
+ return true;
24
+ }
25
+ return new Promise<boolean>((resolve) => {
26
+ const abort = () => {
27
+ waiting.splice(waiting.indexOf(start), 1);
28
+ resolve(false);
29
+ };
30
+ const start = () => {
31
+ signal.removeEventListener("abort", abort);
32
+ resolve(true);
33
+ };
34
+ waiting.push(start);
35
+ signal.addEventListener("abort", abort, { once: true });
36
+ });
37
+ }
38
+
39
+ function release() {
40
+ const next = waiting.shift();
41
+ if (next) next();
42
+ else activeWorkers--;
43
+ }
44
+
45
+ /**
46
+ * Fit raw image bytes into 1..8 pixel boxes, without enlargement, in one isolated Linux worker (at most two at once).
47
+ * Opaque images use sRGB JPEG q85; transparency uses PNG. Fitting upright sRGB JPEGs pass through unchanged after a validation decode.
48
+ * Results follow box order; null means that encoding exceeded maxBytes. Invalid arguments throw; image failures return codes.
49
+ * Input is capped at 32 MiB; the largest decoded variant at 16 MiP (64 MiB RGBA, versus about 5.8 MP for A4 at 300 dpi).
50
+ * WebP, GIF, TIFF and AVIF sources are capped at 16 MiP (about 16.8 MP).
51
+ * Bun itself uses about half the 512 MiB data limit; decodes that exhaust the remaining memory return too_large.
52
+ */
53
+ export async function preparePdfImage(input: PreparePdfImageInput, options: PreparePdfImageOptions = {}): Promise<PreparePdfImageResult> {
54
+ const request = ImageRequest.parse({ boxes: input.boxes, maxBytes: input.maxBytes, timeoutMs: options.timeoutMs });
55
+ const inputData = z.union([z.instanceof(Uint8Array), z.instanceof(Blob)]).parse(input.data);
56
+ if (process.platform !== "linux") throw new Error("PDF image preparation requires the Linux Cloud runtime with memory isolation.");
57
+ if (options.signal?.aborted) return { ok: false, code: "timed_out" };
58
+ if ((input.data instanceof Blob ? input.data.size : input.data.byteLength) > PDF_IMAGE_MAX_INPUT_BYTES) {
59
+ return { ok: false, code: "too_large" };
60
+ }
61
+ const deadline = Date.now() + request.timeoutMs;
62
+ const controller = new AbortController();
63
+ const timer = setTimeout(() => controller.abort(), request.timeoutMs);
64
+ const signal = options.signal ? AbortSignal.any([controller.signal, options.signal]) : controller.signal;
65
+ let acquired = false;
66
+ try {
67
+ acquired = await acquire(signal);
68
+ if (!acquired || signal.aborted || Date.now() >= deadline) return { ok: false, code: "timed_out" };
69
+ const worker =
70
+ typeof __CLOUD_PDF_IMAGE_WORKER__ === "string"
71
+ ? new URL(__CLOUD_PDF_IMAGE_WORKER__, import.meta.url)
72
+ : new URL("./image-worker.ts", import.meta.url);
73
+ // Bun's file-backed Blobs must be materialized before composing a multipart Blob. A slow read must not keep the
74
+ // slot past the deadline, so the abort settles the wait while the read finishes on its own.
75
+ const data =
76
+ inputData instanceof Blob
77
+ ? new Uint8Array(
78
+ await new Promise<ArrayBuffer>((resolve, reject) => {
79
+ signal.addEventListener("abort", () => reject(signal.reason), { once: true });
80
+ inputData.arrayBuffer().then(resolve, reject);
81
+ }),
82
+ )
83
+ : inputData;
84
+ if (signal.aborted || Date.now() >= deadline) return { ok: false, code: "timed_out" };
85
+ if (data.byteLength > PDF_IMAGE_MAX_INPUT_BYTES) return { ok: false, code: "too_large" };
86
+ const header = `${JSON.stringify({ ...request, bytes: data.byteLength, timeoutMs: Math.max(1, deadline - Date.now()) })}\n`;
87
+ const child = spawnCanvasWorker(worker, "pdf-image", new Blob([header, data]));
88
+ const stop = () => child.kill("SIGKILL");
89
+ signal.addEventListener("abort", stop, { once: true });
90
+ if (signal.aborted) stop();
91
+ try {
92
+ const result = await readImageRecords(child.stdout, request);
93
+ if (signal.aborted || Date.now() >= deadline) return { ok: false, code: "timed_out" };
94
+ const exitCode = await child.exited;
95
+ if (signal.aborted || Date.now() >= deadline) return { ok: false, code: "timed_out" };
96
+ if (exitCode !== 0) return { ok: false, code: "unreadable" };
97
+ return result;
98
+ } finally {
99
+ signal.removeEventListener("abort", stop);
100
+ stop();
101
+ await child.exited;
102
+ }
103
+ } catch {
104
+ return { ok: false, code: signal.aborted || Date.now() >= deadline ? "timed_out" : "unreadable" };
105
+ } finally {
106
+ clearTimeout(timer);
107
+ if (acquired) release();
108
+ }
109
+ }
@@ -21,6 +21,15 @@ export {
21
21
  renderHtmlToPdfWithConfig,
22
22
  testGotenberg,
23
23
  } from "./gotenberg";
24
+ export type {
25
+ PdfImageBox,
26
+ PdfImageErrorCode,
27
+ PdfImageVariant,
28
+ PreparePdfImageInput,
29
+ PreparePdfImageOptions,
30
+ PreparePdfImageResult,
31
+ } from "./image";
32
+ export { PDF_IMAGE_MAX_BOXES, PDF_IMAGE_MAX_INPUT_BYTES, preparePdfImage } from "./image";
24
33
  export type {
25
34
  BuildPresetPdfHtmlInput,
26
35
  MarkdownPdfErrorCode,
@@ -11,6 +11,7 @@ import {
11
11
  type StandaloneServiceAccountKind,
12
12
  serviceAccounts,
13
13
  } from "./service-accounts";
14
+ import { ConfirmedBrowserSignInError, requireConfirmedBrowserSignIn } from "./session/recent";
14
15
 
15
16
  export type ServiceAccountCredentialStatus = "active" | "revoked";
16
17
  export type ServiceAccountCredentialKind = "api_token";
@@ -388,10 +389,27 @@ export const createApiToken = async (params: {
388
389
 
389
390
  export const createUserApiToken = async (params: {
390
391
  user: User;
392
+ browserSignIn: { userId: string; sid: string };
391
393
  access?: "full" | "models";
392
394
  name: string;
393
395
  expiresAt?: string | null;
394
396
  }): Promise<Result<{ credential: ServiceAccountCredential; token: string }>> => {
397
+ const confirmSignIn = async (db: SQL): Promise<ReturnType<typeof fail> | null> => {
398
+ if (params.browserSignIn.userId !== params.user.id) return fail(err.forbidden("The browser sign-in belongs to another account."));
399
+ try {
400
+ await requireConfirmedBrowserSignIn(db, params.browserSignIn, (code) => {
401
+ throw new ConfirmedBrowserSignInError(code);
402
+ });
403
+ return null;
404
+ } catch (error) {
405
+ if (error instanceof ConfirmedBrowserSignInError) return fail({ code: error.code, status: 403, message: error.message });
406
+ throw error;
407
+ }
408
+ };
409
+
410
+ // Checked before the key holder is created and again where the key is written.
411
+ const early = await confirmSignIn(sql);
412
+ if (early) return early;
395
413
  const serviceAccountResult = await getOrCreateUserDelegatedServiceAccount({
396
414
  userId: params.user.id,
397
415
  createdBy: params.user.id,
@@ -399,6 +417,9 @@ export const createUserApiToken = async (params: {
399
417
  if (!serviceAccountResult.ok) return fail(serviceAccountResult.error);
400
418
 
401
419
  return sql.begin(async (tx) => {
420
+ const rejected = await confirmSignIn(tx);
421
+ if (rejected) return rejected;
422
+
402
423
  const result = await insertApiToken(tx, {
403
424
  serviceAccountId: serviceAccountResult.data.id,
404
425
  name: params.name,