@aexol/spectral 0.9.212 → 0.9.216

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 (115) hide show
  1. package/dist/backend/generated-images.d.ts +180 -0
  2. package/dist/backend/generated-images.d.ts.map +1 -0
  3. package/dist/backend/generated-images.js +483 -0
  4. package/dist/backend/machine-routing.d.ts +25 -0
  5. package/dist/backend/machine-routing.d.ts.map +1 -0
  6. package/dist/backend/machine-routing.js +35 -0
  7. package/dist/commands/logout.d.ts.map +1 -1
  8. package/dist/commands/logout.js +4 -0
  9. package/dist/commands/serve.d.ts.map +1 -1
  10. package/dist/commands/serve.js +20 -2
  11. package/dist/extensions/browser/browser-service.d.ts +75 -1
  12. package/dist/extensions/browser/browser-service.d.ts.map +1 -1
  13. package/dist/extensions/browser/browser-service.js +600 -34
  14. package/dist/extensions/browser/index.d.ts +10 -4
  15. package/dist/extensions/browser/index.d.ts.map +1 -1
  16. package/dist/extensions/browser/index.js +44 -5
  17. package/dist/extensions/browser/stream/stream-tool.d.ts +10 -0
  18. package/dist/extensions/browser/stream/stream-tool.d.ts.map +1 -1
  19. package/dist/extensions/browser/stream/stream-tool.js +42 -14
  20. package/dist/extensions/browser/tools/core.d.ts.map +1 -1
  21. package/dist/extensions/browser/tools/core.js +63 -2
  22. package/dist/extensions/desktop-control/index.d.ts +55 -0
  23. package/dist/extensions/desktop-control/index.d.ts.map +1 -0
  24. package/dist/extensions/desktop-control/index.js +633 -0
  25. package/dist/extensions/desktop-control/keys.d.ts +33 -0
  26. package/dist/extensions/desktop-control/keys.d.ts.map +1 -0
  27. package/dist/extensions/desktop-control/keys.js +170 -0
  28. package/dist/extensions/desktop-control/linux.d.ts +26 -0
  29. package/dist/extensions/desktop-control/linux.d.ts.map +1 -0
  30. package/dist/extensions/desktop-control/linux.js +195 -0
  31. package/dist/extensions/desktop-control/macos.d.ts +53 -0
  32. package/dist/extensions/desktop-control/macos.d.ts.map +1 -0
  33. package/dist/extensions/desktop-control/macos.js +382 -0
  34. package/dist/extensions/desktop-control/safety.d.ts +89 -0
  35. package/dist/extensions/desktop-control/safety.d.ts.map +1 -0
  36. package/dist/extensions/desktop-control/safety.js +157 -0
  37. package/dist/extensions/desktop-control/types.d.ts +79 -0
  38. package/dist/extensions/desktop-control/types.d.ts.map +1 -0
  39. package/dist/extensions/desktop-control/types.js +10 -0
  40. package/dist/extensions/desktop-control/windows.d.ts +31 -0
  41. package/dist/extensions/desktop-control/windows.d.ts.map +1 -0
  42. package/dist/extensions/desktop-control/windows.js +276 -0
  43. package/dist/extensions/desktop-screenshot/index.d.ts +103 -0
  44. package/dist/extensions/desktop-screenshot/index.d.ts.map +1 -1
  45. package/dist/extensions/desktop-screenshot/index.js +363 -17
  46. package/dist/extensions/image-generation/index.d.ts.map +1 -1
  47. package/dist/extensions/image-generation/index.js +133 -34
  48. package/dist/extensions/seo/index.d.ts.map +1 -1
  49. package/dist/extensions/seo/index.js +4 -0
  50. package/dist/extensions/seo/tools/backend-bridge.d.ts +4 -0
  51. package/dist/extensions/seo/tools/backend-bridge.d.ts.map +1 -0
  52. package/dist/extensions/seo/tools/backend-bridge.js +378 -0
  53. package/dist/extensions/seo/tools/full-audit.d.ts +2 -0
  54. package/dist/extensions/seo/tools/full-audit.d.ts.map +1 -1
  55. package/dist/extensions/seo/tools/full-audit.js +1 -1
  56. package/dist/extensions/seo/tools/report.d.ts +10 -0
  57. package/dist/extensions/seo/tools/report.d.ts.map +1 -1
  58. package/dist/extensions/seo/tools/report.js +1 -1
  59. package/dist/extensions/spectral-vision-fallback.d.ts.map +1 -1
  60. package/dist/extensions/spectral-vision-fallback.js +11 -1
  61. package/dist/generated/zeus/const.d.ts.map +1 -1
  62. package/dist/generated/zeus/const.js +121 -4
  63. package/dist/generated/zeus/index.d.ts +527 -0
  64. package/dist/generated/zeus/index.d.ts.map +1 -1
  65. package/dist/mcp/tool-registrar.d.ts.map +1 -1
  66. package/dist/mcp/tool-registrar.js +5 -2
  67. package/dist/mcp/types.d.ts +5 -0
  68. package/dist/mcp/types.d.ts.map +1 -1
  69. package/dist/relay/dispatcher.d.ts +12 -1
  70. package/dist/relay/dispatcher.d.ts.map +1 -1
  71. package/dist/relay/dispatcher.js +44 -42
  72. package/dist/sdk/ai/providers/faux.js +1 -1
  73. package/dist/sdk/ai/providers/openai-completions.d.ts.map +1 -1
  74. package/dist/sdk/ai/providers/openai-completions.js +69 -16
  75. package/dist/sdk/ai/providers/openrouter-images.d.ts.map +1 -1
  76. package/dist/sdk/ai/providers/openrouter-images.js +58 -9
  77. package/dist/sdk/ai/providers/simple-options.d.ts.map +1 -1
  78. package/dist/sdk/ai/providers/simple-options.js +2 -0
  79. package/dist/sdk/ai/types.d.ts +37 -1
  80. package/dist/sdk/ai/types.d.ts.map +1 -1
  81. package/dist/sdk/ai/utils/hosted-image-resolver.d.ts +60 -0
  82. package/dist/sdk/ai/utils/hosted-image-resolver.d.ts.map +1 -0
  83. package/dist/sdk/ai/utils/hosted-image-resolver.js +75 -0
  84. package/dist/sdk/ai/utils/image-source.d.ts +168 -0
  85. package/dist/sdk/ai/utils/image-source.d.ts.map +1 -0
  86. package/dist/sdk/ai/utils/image-source.js +487 -0
  87. package/dist/sdk/coding-agent/core/extensions/native-extensions.d.ts.map +1 -1
  88. package/dist/sdk/coding-agent/core/extensions/native-extensions.js +14 -0
  89. package/dist/sdk/coding-agent/core/extensions/types.d.ts +9 -0
  90. package/dist/sdk/coding-agent/core/extensions/types.d.ts.map +1 -1
  91. package/dist/sdk/coding-agent/core/system-prompt.d.ts.map +1 -1
  92. package/dist/sdk/coding-agent/core/system-prompt.js +15 -9
  93. package/dist/sdk/coding-agent/modes/rpc/rpc-mode.d.ts.map +1 -1
  94. package/dist/sdk/coding-agent/modes/rpc/rpc-mode.js +16 -3
  95. package/dist/sdk/coding-agent/utils/image-resize.d.ts.map +1 -1
  96. package/dist/sdk/coding-agent/utils/image-resize.js +7 -3
  97. package/dist/server/agent-bridge.d.ts +14 -0
  98. package/dist/server/agent-bridge.d.ts.map +1 -1
  99. package/dist/server/agent-bridge.js +105 -26
  100. package/dist/server/handlers/queue.d.ts.map +1 -1
  101. package/dist/server/handlers/queue.js +11 -34
  102. package/dist/server/image-attachments.d.ts +109 -0
  103. package/dist/server/image-attachments.d.ts.map +1 -0
  104. package/dist/server/image-attachments.js +195 -0
  105. package/dist/server/session-stream.d.ts +67 -0
  106. package/dist/server/session-stream.d.ts.map +1 -1
  107. package/dist/server/session-stream.js +222 -23
  108. package/dist/server/storage.d.ts.map +1 -1
  109. package/dist/server/storage.js +10 -31
  110. package/dist/server/ui/terminal-confirm.d.ts +145 -0
  111. package/dist/server/ui/terminal-confirm.d.ts.map +1 -0
  112. package/dist/server/ui/terminal-confirm.js +333 -0
  113. package/dist/server/wire.d.ts +30 -5
  114. package/dist/server/wire.d.ts.map +1 -1
  115. package/package.json +1 -1
@@ -0,0 +1,180 @@
1
+ /**
2
+ * Backend-hosted image storage client.
3
+ *
4
+ * Uploads raw image bytes to the Aexol backend so that sessions only ever
5
+ * carry a stable URL (`/generated-images/<imageId>`) instead of a base64
6
+ * payload. The three-step contract is:
7
+ *
8
+ * 1. `POST {backendUrl}/generated-images/upload-url`
9
+ * -> `{ imageId, uploadUrl, url, expiresIn, requiredHeaders? }`
10
+ * 2. `PUT <uploadUrl>` (presigned, ~600s)
11
+ * with `Content-Type` + every header from `requiredHeaders`
12
+ * 3. `POST {backendUrl}/generated-images/<imageId>/confirm`
13
+ * -> `{ id, size, url }`
14
+ *
15
+ * Downloading works the other way round and has two flavours:
16
+ *
17
+ * A. `GET {backendUrl}/generated-images/<imageId>` (authenticated)
18
+ * -> 302 redirect to a short-lived presigned S3 GET.
19
+ * B. `GET {backendUrl}/generated-images/<imageId>/url`
20
+ * (authenticated) -> `{ url, expiresIn }` where `url` is a presigned
21
+ * S3 GET that needs NO headers at all.
22
+ *
23
+ * Flavour B is the one to hand to a third party that cannot authenticate
24
+ * against our backend (an LLM provider fetching an image, an `<img src>`,
25
+ * ...). See `resolveHostedImageUrl`.
26
+ *
27
+ * Errors from the backend have the shape
28
+ * `{ "error": { "message": "...", "type": "..." } }` and are surfaced as a
29
+ * `GeneratedImageUploadError` carrying the HTTP status and the step that
30
+ * failed, so callers can decide whether to degrade gracefully.
31
+ *
32
+ * Shared by `image_generate` (Phase 1) and — in a later phase — user
33
+ * screenshots/attachments, hence `kind` is a parameter rather than a constant.
34
+ *
35
+ * @module @spectral/backend/generated-images
36
+ */
37
+ export type GeneratedImageKind = "generated" | "attachment";
38
+ export type UploadStep = "upload-url" | "put" | "confirm";
39
+ export interface UploadImageOptions {
40
+ /** Backend base url, e.g. `https://api.aexol.ai` (no trailing slash needed). */
41
+ backendUrl: string;
42
+ /** Machine JWT (`Authorization: Bearer <token>`). */
43
+ token: string;
44
+ /** Raw image bytes. */
45
+ bytes: Uint8Array;
46
+ /** MIME type of the bytes, e.g. `image/png`. */
47
+ contentType: string;
48
+ /** Storage bucket/kind on the backend. Defaults to `"generated"`. */
49
+ kind?: GeneratedImageKind;
50
+ /** Intrinsic pixel width, when known (optional metadata). */
51
+ width?: number;
52
+ /** Intrinsic pixel height, when known (optional metadata). */
53
+ height?: number;
54
+ /** Injectable for tests / alternative runtimes. Defaults to global `fetch`. */
55
+ fetchImpl?: typeof fetch;
56
+ /** Abort the whole sequence (e.g. tool cancellation). */
57
+ signal?: AbortSignal;
58
+ /** Per-request timeout. Defaults to 60s. */
59
+ timeoutMs?: number;
60
+ }
61
+ export interface UploadImageResult {
62
+ imageId: string;
63
+ /** Backend-relative url (`/generated-images/<imageId>`) — safe to persist in a session. */
64
+ url: string;
65
+ size: number;
66
+ }
67
+ export interface UploadUrlResponse {
68
+ imageId: string;
69
+ uploadUrl: string;
70
+ url: string;
71
+ expiresIn?: number;
72
+ requiredHeaders?: Record<string, string>;
73
+ }
74
+ export interface ConfirmResponse {
75
+ id: string;
76
+ size: number;
77
+ url: string;
78
+ }
79
+ export interface ResolveHostedImageUrlOptions {
80
+ /** Backend base url, e.g. `https://api.aexol.ai`. */
81
+ backendUrl: string;
82
+ /** Machine JWT (`Authorization: Bearer <token>`). */
83
+ token: string;
84
+ /** Image id. Optional when `url` is given (it is parsed from there). */
85
+ imageId?: string;
86
+ /** Backend-relative (`/generated-images/<id>`) or absolute hosted url. */
87
+ url?: string;
88
+ /** Injectable for tests / alternative runtimes. Defaults to global `fetch`. */
89
+ fetchImpl?: typeof fetch;
90
+ signal?: AbortSignal;
91
+ /** Per-request timeout. Defaults to 60s. */
92
+ timeoutMs?: number;
93
+ /** Diagnostics sink — failures are silent unless this is provided. */
94
+ onWarn?: (message: string) => void;
95
+ }
96
+ export interface HostedImageUrlResult {
97
+ /**
98
+ * Presigned S3 GET url that is fetchable WITHOUT any Authorization header.
99
+ * Valid for `expiresIn` seconds (typically 3600).
100
+ */
101
+ url: string;
102
+ expiresIn: number;
103
+ }
104
+ /**
105
+ * Extract the hosted image id from a backend-relative path
106
+ * (`/generated-images/<id>`) or from an absolute url
107
+ * (`https://api.aexol.ai/generated-images/<id>?x=1`).
108
+ *
109
+ * Only the PATH is inspected: a query/fragment that happens to contain
110
+ * `/generated-images/<id>` does not make the url one of ours.
111
+ *
112
+ * Returns `null` when the value is not a hosted backend image reference, so
113
+ * callers can use it as a cheap "is this ours?" predicate.
114
+ */
115
+ export declare function extractHostedImageId(urlOrPath: string): string | null;
116
+ /**
117
+ * The hosted image id carried by `url`, or `null` when `url` is not a hosted
118
+ * backend image reference. Alias of `extractHostedImageId` kept for callers
119
+ * that read better with a url-oriented name.
120
+ */
121
+ export declare function hostedImageIdFromUrl(url: string): string | null;
122
+ /**
123
+ * True when `url` points at a backend-hosted image
124
+ * (`/generated-images/<id>`), either backend-relative or absolute.
125
+ *
126
+ * SECURITY: an absolute url is only "ours" when it actually points at OUR
127
+ * backend. `backendUrl` is therefore REQUIRED for absolute urls — without it
128
+ * there is nothing to compare against and the answer is `false`, so callers
129
+ * never attach the machine JWT to a host they cannot vouch for. Relative urls
130
+ * always match (they can only ever be resolved against the backend base url).
131
+ */
132
+ export declare function isHostedImageUrl(url: string, backendUrl?: string): boolean;
133
+ /**
134
+ * Drop cached presigned urls. Omit `imageId` to clear the whole cache (used
135
+ * by tests and after a logout/credential change).
136
+ */
137
+ export declare function invalidateHostedImageUrl(imageId?: string, backendUrl?: string): void;
138
+ /**
139
+ * Turn a hosted image reference into a presigned url that a third party (LLM
140
+ * provider, browser) can fetch WITHOUT authenticating against our backend.
141
+ *
142
+ * `GET {backendUrl}/generated-images/<id>/url` with the machine JWT returns
143
+ * `{ url, expiresIn }`; the plain `GET /generated-images/<id>` endpoint is
144
+ * auth-gated and 302-redirects, so handing it to a provider only produces a
145
+ * 401 and a silently dropped image.
146
+ *
147
+ * Results are cached per `<backendUrl>|<imageId>` until
148
+ * `PRESIGNED_REFRESH_MARGIN_MS` before their expiry (see
149
+ * `invalidateHostedImageUrl`), so repeated requests for the same image do not
150
+ * each pay a round trip.
151
+ *
152
+ * NEVER throws and never logs: returns `null` on any failure (missing token,
153
+ * non-2xx, network error, malformed body) so callers can fall back to their
154
+ * previous behaviour. Pass `onWarn` to see why.
155
+ */
156
+ export declare function resolveHostedImageUrl(options: ResolveHostedImageUrlOptions): Promise<HostedImageUrlResult | null>;
157
+ /**
158
+ * Thrown for every failure of the upload sequence (including presigned PUT
159
+ * failures). `status` is `undefined` for network-level errors.
160
+ */
161
+ export declare class GeneratedImageUploadError extends Error {
162
+ readonly step: UploadStep;
163
+ readonly status?: number;
164
+ readonly type?: string;
165
+ constructor(message: string, options: {
166
+ step: UploadStep;
167
+ status?: number;
168
+ type?: string;
169
+ cause?: unknown;
170
+ });
171
+ }
172
+ /**
173
+ * Upload raw image bytes to backend storage and confirm them.
174
+ *
175
+ * @returns the image id, the backend-relative url and the confirmed byte size.
176
+ * @throws {GeneratedImageUploadError} on any HTTP or network failure, with a
177
+ * human-readable message taken from the backend `error.message` when present.
178
+ */
179
+ export declare function uploadImage(options: UploadImageOptions): Promise<UploadImageResult>;
180
+ //# sourceMappingURL=generated-images.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"generated-images.d.ts","sourceRoot":"","sources":["../../src/backend/generated-images.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,483 @@
1
+ /**
2
+ * Backend-hosted image storage client.
3
+ *
4
+ * Uploads raw image bytes to the Aexol backend so that sessions only ever
5
+ * carry a stable URL (`/generated-images/<imageId>`) instead of a base64
6
+ * payload. The three-step contract is:
7
+ *
8
+ * 1. `POST {backendUrl}/generated-images/upload-url`
9
+ * -> `{ imageId, uploadUrl, url, expiresIn, requiredHeaders? }`
10
+ * 2. `PUT <uploadUrl>` (presigned, ~600s)
11
+ * with `Content-Type` + every header from `requiredHeaders`
12
+ * 3. `POST {backendUrl}/generated-images/<imageId>/confirm`
13
+ * -> `{ id, size, url }`
14
+ *
15
+ * Downloading works the other way round and has two flavours:
16
+ *
17
+ * A. `GET {backendUrl}/generated-images/<imageId>` (authenticated)
18
+ * -> 302 redirect to a short-lived presigned S3 GET.
19
+ * B. `GET {backendUrl}/generated-images/<imageId>/url`
20
+ * (authenticated) -> `{ url, expiresIn }` where `url` is a presigned
21
+ * S3 GET that needs NO headers at all.
22
+ *
23
+ * Flavour B is the one to hand to a third party that cannot authenticate
24
+ * against our backend (an LLM provider fetching an image, an `<img src>`,
25
+ * ...). See `resolveHostedImageUrl`.
26
+ *
27
+ * Errors from the backend have the shape
28
+ * `{ "error": { "message": "...", "type": "..." } }` and are surfaced as a
29
+ * `GeneratedImageUploadError` carrying the HTTP status and the step that
30
+ * failed, so callers can decide whether to degrade gracefully.
31
+ *
32
+ * Shared by `image_generate` (Phase 1) and — in a later phase — user
33
+ * screenshots/attachments, hence `kind` is a parameter rather than a constant.
34
+ *
35
+ * @module @spectral/backend/generated-images
36
+ */
37
+ const DEFAULT_TIMEOUT_MS = 60_000;
38
+ /**
39
+ * Presigned urls are refreshed this long before they actually expire, so a
40
+ * url handed to a provider (or an `<img src>`) never dies mid-flight.
41
+ * Mirrors the landing client's refresh margin.
42
+ */
43
+ const PRESIGNED_REFRESH_MARGIN_MS = 60_000;
44
+ /** `/generated-images/<id>` — matched against the PATHNAME only. */
45
+ const HOSTED_IMAGE_PATH = /\/generated-images\/([^/?#\s]+)/;
46
+ /**
47
+ * Storage ids the backend accepts. Mirrors `ID_PATTERN` / `isValidId` from
48
+ * `backend/src/routes/generated-images/index.ts` so an id that could never
49
+ * exist server-side is never turned into a request (or an S3 key).
50
+ */
51
+ const HOSTED_IMAGE_ID = /^[A-Za-z0-9_-]{1,64}$/;
52
+ const ABSOLUTE_URL_SCHEME = /^[a-z][a-z0-9+.-]*:/i;
53
+ /** Upper bound on the presigned-url cache so a long session cannot grow it forever. */
54
+ const HOSTED_IMAGE_URL_CACHE_MAX = 200;
55
+ /** Assumed presigned-url lifetime when the backend omits/invalidates `expiresIn`. */
56
+ const DEFAULT_PRESIGNED_TTL_SECONDS = 3600;
57
+ /**
58
+ * `presigned url` cache keyed by `<backendUrl>|<token>|<imageId>`. Resolving a hosted
59
+ * image costs one authenticated round trip per image per request — with
60
+ * multi-turn image conversations (and retries) that is the same url minted
61
+ * over and over, so entries are reused until they are close to expiring.
62
+ */
63
+ const hostedImageUrlCache = new Map();
64
+ /**
65
+ * Normalized `scheme://host[:port]` origin of an absolute url, or `null` for
66
+ * relative/unparseable input. `URL` already drops default ports (`:443` for
67
+ * https), so `https://api.test:443/x` and `https://api.test/x` compare equal.
68
+ */
69
+ function originOf(value) {
70
+ try {
71
+ return new URL(value).origin;
72
+ }
73
+ catch {
74
+ return null;
75
+ }
76
+ }
77
+ /**
78
+ * Path portion of an absolute url (`new URL(v).pathname`) or of a relative
79
+ * reference (everything before `?`/`#`). Query and fragment are NEVER part of
80
+ * the result, so `https://cdn.test/a.png?next=/generated-images/abc` cannot
81
+ * masquerade as one of our hosted images.
82
+ */
83
+ function pathnameOf(value) {
84
+ if (ABSOLUTE_URL_SCHEME.test(value)) {
85
+ try {
86
+ return new URL(value).pathname;
87
+ }
88
+ catch {
89
+ return null;
90
+ }
91
+ }
92
+ return value.split("#")[0].split("?")[0];
93
+ }
94
+ /** Drop expired entries and evict the oldest ones past the size cap. */
95
+ function purgeHostedImageUrlCache(now = Date.now()) {
96
+ for (const [key, entry] of hostedImageUrlCache) {
97
+ if (entry.expiresAt <= now)
98
+ hostedImageUrlCache.delete(key);
99
+ }
100
+ // Map iteration is insertion-ordered, so the first key is the oldest.
101
+ while (hostedImageUrlCache.size > HOSTED_IMAGE_URL_CACHE_MAX) {
102
+ const oldest = hostedImageUrlCache.keys().next();
103
+ if (oldest.done)
104
+ break;
105
+ hostedImageUrlCache.delete(oldest.value);
106
+ }
107
+ }
108
+ /**
109
+ * Extract the hosted image id from a backend-relative path
110
+ * (`/generated-images/<id>`) or from an absolute url
111
+ * (`https://api.aexol.ai/generated-images/<id>?x=1`).
112
+ *
113
+ * Only the PATH is inspected: a query/fragment that happens to contain
114
+ * `/generated-images/<id>` does not make the url one of ours.
115
+ *
116
+ * Returns `null` when the value is not a hosted backend image reference, so
117
+ * callers can use it as a cheap "is this ours?" predicate.
118
+ */
119
+ export function extractHostedImageId(urlOrPath) {
120
+ if (typeof urlOrPath !== "string")
121
+ return null;
122
+ const trimmed = urlOrPath.trim();
123
+ if (!trimmed)
124
+ return null;
125
+ const path = pathnameOf(trimmed);
126
+ if (!path)
127
+ return null;
128
+ const match = path.match(HOSTED_IMAGE_PATH);
129
+ const raw = match?.[1];
130
+ if (!raw)
131
+ return null;
132
+ // Strip an optional extension (`<id>.png`) — the id is the first segment.
133
+ const dot = raw.lastIndexOf(".");
134
+ let id = dot > 0 ? raw.slice(0, dot) : raw;
135
+ try {
136
+ id = decodeURIComponent(id);
137
+ }
138
+ catch {
139
+ // Malformed percent-encoding: keep the raw segment.
140
+ }
141
+ if (!HOSTED_IMAGE_ID.test(id))
142
+ return null;
143
+ return id;
144
+ }
145
+ /**
146
+ * The hosted image id carried by `url`, or `null` when `url` is not a hosted
147
+ * backend image reference. Alias of `extractHostedImageId` kept for callers
148
+ * that read better with a url-oriented name.
149
+ */
150
+ export function hostedImageIdFromUrl(url) {
151
+ return extractHostedImageId(url);
152
+ }
153
+ /**
154
+ * True when `url` points at a backend-hosted image
155
+ * (`/generated-images/<id>`), either backend-relative or absolute.
156
+ *
157
+ * SECURITY: an absolute url is only "ours" when it actually points at OUR
158
+ * backend. `backendUrl` is therefore REQUIRED for absolute urls — without it
159
+ * there is nothing to compare against and the answer is `false`, so callers
160
+ * never attach the machine JWT to a host they cannot vouch for. Relative urls
161
+ * always match (they can only ever be resolved against the backend base url).
162
+ */
163
+ export function isHostedImageUrl(url, backendUrl) {
164
+ if (typeof url !== "string")
165
+ return false;
166
+ const trimmed = url.trim();
167
+ if (!trimmed)
168
+ return false;
169
+ if (hostedImageIdFromUrl(trimmed) === null)
170
+ return false;
171
+ if (!ABSOLUTE_URL_SCHEME.test(trimmed))
172
+ return true;
173
+ const base = typeof backendUrl === "string" ? backendUrl.trim() : "";
174
+ if (!base)
175
+ return false;
176
+ const origin = originOf(trimmed);
177
+ const expected = originOf(base);
178
+ return origin !== null && expected !== null && origin === expected;
179
+ }
180
+ /**
181
+ * Cheap, non-cryptographic fingerprint of the credential: a cached presigned
182
+ * url must never be handed out under a different (possibly less privileged)
183
+ * machine JWT than the one that minted it, so the key carries the token too
184
+ * — without keeping the token itself in the map.
185
+ */
186
+ function credentialFingerprint(token) {
187
+ let hash = 5381;
188
+ for (let index = 0; index < token.length; index += 1) {
189
+ hash = ((hash << 5) + hash + token.charCodeAt(index)) | 0;
190
+ }
191
+ return (hash >>> 0).toString(36);
192
+ }
193
+ function cacheKey(backendUrl, imageId, token) {
194
+ return `${baseUrlOf(backendUrl)}|${credentialFingerprint(token)}|${imageId}`;
195
+ }
196
+ /**
197
+ * Drop cached presigned urls. Omit `imageId` to clear the whole cache (used
198
+ * by tests and after a logout/credential change).
199
+ */
200
+ export function invalidateHostedImageUrl(imageId, backendUrl) {
201
+ const id = typeof imageId === "string" ? imageId.trim() : "";
202
+ if (!id) {
203
+ hostedImageUrlCache.clear();
204
+ return;
205
+ }
206
+ const base = typeof backendUrl === "string" ? backendUrl.trim() : "";
207
+ const basePrefix = base ? `${baseUrlOf(base)}|` : "";
208
+ for (const key of [...hostedImageUrlCache.keys()]) {
209
+ if (!key.endsWith(`|${id}`))
210
+ continue;
211
+ if (basePrefix && !key.startsWith(basePrefix))
212
+ continue;
213
+ hostedImageUrlCache.delete(key);
214
+ }
215
+ }
216
+ /**
217
+ * Turn a hosted image reference into a presigned url that a third party (LLM
218
+ * provider, browser) can fetch WITHOUT authenticating against our backend.
219
+ *
220
+ * `GET {backendUrl}/generated-images/<id>/url` with the machine JWT returns
221
+ * `{ url, expiresIn }`; the plain `GET /generated-images/<id>` endpoint is
222
+ * auth-gated and 302-redirects, so handing it to a provider only produces a
223
+ * 401 and a silently dropped image.
224
+ *
225
+ * Results are cached per `<backendUrl>|<imageId>` until
226
+ * `PRESIGNED_REFRESH_MARGIN_MS` before their expiry (see
227
+ * `invalidateHostedImageUrl`), so repeated requests for the same image do not
228
+ * each pay a round trip.
229
+ *
230
+ * NEVER throws and never logs: returns `null` on any failure (missing token,
231
+ * non-2xx, network error, malformed body) so callers can fall back to their
232
+ * previous behaviour. Pass `onWarn` to see why.
233
+ */
234
+ export async function resolveHostedImageUrl(options) {
235
+ const { backendUrl, token, url, fetchImpl, signal, timeoutMs, onWarn, } = options;
236
+ const explicitId = typeof options.imageId === "string" ? options.imageId.trim() : "";
237
+ const imageId = explicitId || (url ? extractHostedImageId(url) : null);
238
+ if (!backendUrl || !token || !imageId) {
239
+ onWarn?.(`Cannot resolve hosted image url: missing ${!backendUrl ? "backend url" : !token ? "machine token" : "image id"}`);
240
+ return null;
241
+ }
242
+ // SECURITY: `url` is data that came from an image block, i.e. attacker
243
+ // influenceable. Refuse anything that is not a hosted reference on OUR
244
+ // backend origin — otherwise the machine JWT below would be sent to a
245
+ // third-party host (and a foreign `/generated-images/<id>` path would be
246
+ // swapped for a presigned url of one of our tenant's images).
247
+ if (url && !isHostedImageUrl(url, backendUrl)) {
248
+ onWarn?.(`Refusing to resolve a hosted image url for a foreign origin: ${url}`);
249
+ return null;
250
+ }
251
+ const key = cacheKey(backendUrl, imageId, token);
252
+ purgeHostedImageUrlCache();
253
+ const cached = hostedImageUrlCache.get(key);
254
+ if (cached && cached.expireRefreshAt > Date.now()) {
255
+ return {
256
+ url: cached.url,
257
+ expiresIn: Math.max(0, Math.floor((cached.expiresAt - Date.now()) / 1000)),
258
+ };
259
+ }
260
+ const doFetch = fetchImpl ?? ((...args) => fetch(...args));
261
+ try {
262
+ const response = await fetchWithTimeout(joinUrl(baseUrlOf(backendUrl), `/generated-images/${encodeURIComponent(imageId)}/url`), { method: "GET", headers: { Authorization: `Bearer ${token}` } }, { timeoutMs: timeoutMs ?? DEFAULT_TIMEOUT_MS, signal, fetchImpl: doFetch });
263
+ const json = await readJson(response);
264
+ if (!response.ok) {
265
+ const { message } = errorMessageFrom(json, response.status);
266
+ onWarn?.(`Could not resolve hosted image url for ${imageId}: ${message}`);
267
+ return null;
268
+ }
269
+ const payload = isRecord(json) ? json : {};
270
+ const resolvedUrl = typeof payload.url === "string" ? payload.url.trim() : "";
271
+ if (!resolvedUrl) {
272
+ onWarn?.(`Backend response for hosted image ${imageId} is missing "url"`);
273
+ return null;
274
+ }
275
+ // A missing/invalid `expiresIn` must not disable caching (that used to
276
+ // mint a fresh presigned url for every single request) — assume the
277
+ // backend default instead.
278
+ const expiresIn = Number(payload.expiresIn);
279
+ const ttlSeconds = Number.isFinite(expiresIn) && expiresIn > 0 ? expiresIn : DEFAULT_PRESIGNED_TTL_SECONDS;
280
+ const expiresAt = Date.now() + ttlSeconds * 1000;
281
+ hostedImageUrlCache.set(key, {
282
+ url: resolvedUrl,
283
+ expiresAt,
284
+ expireRefreshAt: Math.max(expiresAt - PRESIGNED_REFRESH_MARGIN_MS, Date.now()),
285
+ });
286
+ purgeHostedImageUrlCache();
287
+ return { url: resolvedUrl, expiresIn: ttlSeconds };
288
+ }
289
+ catch (error) {
290
+ const message = error instanceof Error ? error.message : String(error);
291
+ onWarn?.(`Could not resolve hosted image url for ${imageId}: ${message}`);
292
+ return null;
293
+ }
294
+ }
295
+ /**
296
+ * Thrown for every failure of the upload sequence (including presigned PUT
297
+ * failures). `status` is `undefined` for network-level errors.
298
+ */
299
+ export class GeneratedImageUploadError extends Error {
300
+ step;
301
+ status;
302
+ type;
303
+ constructor(message, options) {
304
+ super(message, options.cause === undefined ? undefined : { cause: options.cause });
305
+ this.name = "GeneratedImageUploadError";
306
+ this.step = options.step;
307
+ this.status = options.status;
308
+ this.type = options.type;
309
+ }
310
+ }
311
+ function baseUrlOf(backendUrl) {
312
+ return backendUrl.replace(/\/+$/, "");
313
+ }
314
+ function joinUrl(base, path) {
315
+ return `${baseUrlOf(base)}${path.startsWith("/") ? path : `/${path}`}`;
316
+ }
317
+ function isRecord(value) {
318
+ return typeof value === "object" && value !== null;
319
+ }
320
+ function errorMessageFrom(body, status) {
321
+ const error = isRecord(body) && isRecord(body.error) ? body.error : undefined;
322
+ const message = typeof error?.message === "string" && error.message ? error.message : undefined;
323
+ const type = typeof error?.type === "string" && error.type ? error.type : undefined;
324
+ return { message: message ?? `HTTP ${status}`, type };
325
+ }
326
+ /** Parse a JSON body, tolerating empty/invalid bodies (presigned S3 replies are empty). */
327
+ async function readJson(response) {
328
+ const text = await response.text();
329
+ if (!text)
330
+ return undefined;
331
+ try {
332
+ return JSON.parse(text);
333
+ }
334
+ catch {
335
+ return undefined;
336
+ }
337
+ }
338
+ function requireString(value, field, step) {
339
+ if (typeof value !== "string" || !value) {
340
+ throw new GeneratedImageUploadError(`Backend response is missing "${field}"`, { step });
341
+ }
342
+ return value;
343
+ }
344
+ /**
345
+ * Run a single request with an optional timeout + external abort signal.
346
+ *
347
+ * The timeout never outlives the call and an already-aborted external signal
348
+ * aborts immediately.
349
+ */
350
+ async function fetchWithTimeout(url, init, options) {
351
+ const controller = new AbortController();
352
+ const timer = setTimeout(() => controller.abort(), options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
353
+ const onAbort = () => controller.abort();
354
+ if (options.signal) {
355
+ if (options.signal.aborted)
356
+ controller.abort();
357
+ else
358
+ options.signal.addEventListener("abort", onAbort, { once: true });
359
+ }
360
+ try {
361
+ return await options.fetchImpl(url, { ...init, signal: controller.signal });
362
+ }
363
+ finally {
364
+ clearTimeout(timer);
365
+ options.signal?.removeEventListener("abort", onAbort);
366
+ }
367
+ }
368
+ function networkError(step, error) {
369
+ if (error instanceof GeneratedImageUploadError)
370
+ return error;
371
+ const message = error instanceof Error ? error.message : String(error);
372
+ const aborted = error instanceof Error && error.name === "AbortError";
373
+ return new GeneratedImageUploadError(aborted
374
+ ? `Upload ${step} timed out or was cancelled (${message})`
375
+ : `Upload ${step} failed: ${message}`, { step, cause: error });
376
+ }
377
+ /**
378
+ * Upload raw image bytes to backend storage and confirm them.
379
+ *
380
+ * @returns the image id, the backend-relative url and the confirmed byte size.
381
+ * @throws {GeneratedImageUploadError} on any HTTP or network failure, with a
382
+ * human-readable message taken from the backend `error.message` when present.
383
+ */
384
+ export async function uploadImage(options) {
385
+ const { backendUrl, token, bytes, contentType, kind = "generated", width, height, fetchImpl, signal, timeoutMs, } = options;
386
+ if (!backendUrl) {
387
+ throw new GeneratedImageUploadError("Cannot upload image: no backend url configured", { step: "upload-url" });
388
+ }
389
+ if (!token) {
390
+ throw new GeneratedImageUploadError("Cannot upload image: not authenticated (missing machine token)", {
391
+ step: "upload-url",
392
+ });
393
+ }
394
+ const doFetch = fetchImpl ?? ((...args) => fetch(...args));
395
+ const base = baseUrlOf(backendUrl);
396
+ const authHeaders = { Authorization: `Bearer ${token}` };
397
+ // ── 1. mint an upload url ────────────────────────────────────────────────
398
+ let uploadUrlResponse;
399
+ try {
400
+ const body = {
401
+ contentType,
402
+ kind,
403
+ size: bytes.byteLength,
404
+ };
405
+ if (typeof width === "number" && Number.isFinite(width))
406
+ body.width = width;
407
+ if (typeof height === "number" && Number.isFinite(height))
408
+ body.height = height;
409
+ const response = await fetchWithTimeout(joinUrl(base, "/generated-images/upload-url"), {
410
+ method: "POST",
411
+ headers: { ...authHeaders, "Content-Type": "application/json" },
412
+ body: JSON.stringify(body),
413
+ }, { timeoutMs, signal, fetchImpl: doFetch });
414
+ const json = await readJson(response);
415
+ if (!response.ok) {
416
+ const { message, type } = errorMessageFrom(json, response.status);
417
+ throw new GeneratedImageUploadError(`Could not mint image upload url: ${message}`, {
418
+ step: "upload-url",
419
+ status: response.status,
420
+ type,
421
+ });
422
+ }
423
+ const payload = isRecord(json) ? json : {};
424
+ uploadUrlResponse = {
425
+ imageId: requireString(payload.imageId, "imageId", "upload-url"),
426
+ uploadUrl: requireString(payload.uploadUrl, "uploadUrl", "upload-url"),
427
+ url: requireString(payload.url, "url", "upload-url"),
428
+ expiresIn: payload.expiresIn,
429
+ requiredHeaders: payload.requiredHeaders,
430
+ };
431
+ }
432
+ catch (error) {
433
+ throw networkError("upload-url", error);
434
+ }
435
+ // ── 2. PUT the bytes to the presigned url ────────────────────────────────
436
+ try {
437
+ const headers = { "Content-Type": contentType };
438
+ // The backend signs extra headers (e.g. `x-amz-acl: private`) into the
439
+ // presigned url — they MUST be sent verbatim or S3 rejects the PUT.
440
+ if (isRecord(uploadUrlResponse.requiredHeaders)) {
441
+ for (const [key, value] of Object.entries(uploadUrlResponse.requiredHeaders)) {
442
+ if (typeof value === "string")
443
+ headers[key] = value;
444
+ }
445
+ }
446
+ // Normalize to a standalone buffer so byteOffset/byteLength of a
447
+ // subarray (Buffer view) cannot leak extra bytes into the body.
448
+ const payload = new Uint8Array(bytes.byteLength);
449
+ payload.set(bytes);
450
+ const response = await fetchWithTimeout(uploadUrlResponse.uploadUrl, { method: "PUT", headers, body: payload }, { timeoutMs, signal, fetchImpl: doFetch });
451
+ if (!response.ok) {
452
+ const detail = await response.text().catch(() => "");
453
+ const snippet = detail.replace(/\s+/g, " ").trim().slice(0, 300);
454
+ throw new GeneratedImageUploadError(`Image upload to storage failed: HTTP ${response.status}${snippet ? ` (${snippet})` : ""}`, { step: "put", status: response.status });
455
+ }
456
+ }
457
+ catch (error) {
458
+ throw networkError("put", error);
459
+ }
460
+ // ── 3. confirm so the backend can serve the image ────────────────────────
461
+ try {
462
+ const response = await fetchWithTimeout(joinUrl(base, `/generated-images/${encodeURIComponent(uploadUrlResponse.imageId)}/confirm`), { method: "POST", headers: { ...authHeaders } }, { timeoutMs, signal, fetchImpl: doFetch });
463
+ const json = await readJson(response);
464
+ if (!response.ok) {
465
+ const { message, type } = errorMessageFrom(json, response.status);
466
+ throw new GeneratedImageUploadError(`Could not confirm uploaded image: ${message}`, {
467
+ step: "confirm",
468
+ status: response.status,
469
+ type,
470
+ });
471
+ }
472
+ const payload = isRecord(json) ? json : {};
473
+ const confirmedSize = Number(payload.size);
474
+ return {
475
+ imageId: typeof payload.id === "string" && payload.id ? payload.id : uploadUrlResponse.imageId,
476
+ url: typeof payload.url === "string" && payload.url ? payload.url : uploadUrlResponse.url,
477
+ size: Number.isFinite(confirmedSize) && confirmedSize > 0 ? confirmedSize : bytes.byteLength,
478
+ };
479
+ }
480
+ catch (error) {
481
+ throw networkError("confirm", error);
482
+ }
483
+ }