@aexol/spectral 0.9.215 → 0.9.218

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.
@@ -12,6 +12,18 @@
12
12
  * 3. `POST {backendUrl}/generated-images/<imageId>/confirm`
13
13
  * -> `{ id, size, url }`
14
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
+ *
15
27
  * Errors from the backend have the shape
16
28
  * `{ "error": { "message": "...", "type": "..." } }` and are surfaced as a
17
29
  * `GeneratedImageUploadError` carrying the HTTP status and the step that
@@ -64,6 +76,84 @@ export interface ConfirmResponse {
64
76
  size: number;
65
77
  url: string;
66
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>;
67
157
  /**
68
158
  * Thrown for every failure of the upload sequence (including presigned PUT
69
159
  * failures). `status` is `undefined` for network-level errors.
@@ -1 +1 @@
1
- {"version":3,"file":"generated-images.d.ts","sourceRoot":"","sources":["../../src/backend/generated-images.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAEH,MAAM,MAAM,kBAAkB,GAAG,WAAW,GAAG,YAAY,CAAC;AAE5D,MAAM,MAAM,UAAU,GAAG,YAAY,GAAG,KAAK,GAAG,SAAS,CAAC;AAE1D,MAAM,WAAW,kBAAkB;IAClC,gFAAgF;IAChF,UAAU,EAAE,MAAM,CAAC;IACnB,qDAAqD;IACrD,KAAK,EAAE,MAAM,CAAC;IACd,uBAAuB;IACvB,KAAK,EAAE,UAAU,CAAC;IAClB,gDAAgD;IAChD,WAAW,EAAE,MAAM,CAAC;IACpB,qEAAqE;IACrE,IAAI,CAAC,EAAE,kBAAkB,CAAC;IAC1B,6DAA6D;IAC7D,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,8DAA8D;IAC9D,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,+EAA+E;IAC/E,SAAS,CAAC,EAAE,OAAO,KAAK,CAAC;IACzB,yDAAyD;IACzD,MAAM,CAAC,EAAE,WAAW,CAAC;IACrB,4CAA4C;IAC5C,SAAS,CAAC,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,iBAAiB;IACjC,OAAO,EAAE,MAAM,CAAC;IAChB,2FAA2F;IAC3F,GAAG,EAAE,MAAM,CAAC;IACZ,IAAI,EAAE,MAAM,CAAC;CACb;AAED,MAAM,WAAW,iBAAiB;IACjC,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;IAClB,GAAG,EAAE,MAAM,CAAC;IACZ,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,eAAe,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CACzC;AAED,MAAM,WAAW,eAAe;IAC/B,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,GAAG,EAAE,MAAM,CAAC;CACZ;AAID;;;GAGG;AACH,qBAAa,yBAA0B,SAAQ,KAAK;IACnD,QAAQ,CAAC,IAAI,EAAE,UAAU,CAAC;IAC1B,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC;gBAGtB,OAAO,EAAE,MAAM,EACf,OAAO,EAAE;QAAE,IAAI,EAAE,UAAU,CAAC;QAAC,MAAM,CAAC,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAC;QAAC,KAAK,CAAC,EAAE,OAAO,CAAA;KAAE;CAQ/E;AA6ED;;;;;;GAMG;AACH,wBAAsB,WAAW,CAAC,OAAO,EAAE,kBAAkB,GAAG,OAAO,CAAC,iBAAiB,CAAC,CA0HzF"}
1
+ {"version":3,"file":"generated-images.d.ts","sourceRoot":"","sources":["../../src/backend/generated-images.ts"],"names":[],"mappings":"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"}
@@ -12,6 +12,18 @@
12
12
  * 3. `POST {backendUrl}/generated-images/<imageId>/confirm`
13
13
  * -> `{ id, size, url }`
14
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
+ *
15
27
  * Errors from the backend have the shape
16
28
  * `{ "error": { "message": "...", "type": "..." } }` and are surfaced as a
17
29
  * `GeneratedImageUploadError` carrying the HTTP status and the step that
@@ -23,6 +35,263 @@
23
35
  * @module @spectral/backend/generated-images
24
36
  */
25
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
+ }
26
295
  /**
27
296
  * Thrown for every failure of the upload sequence (including presigned PUT
28
297
  * failures). `status` is `undefined` for network-level errors.
@@ -1 +1 @@
1
- {"version":3,"file":"logout.d.ts","sourceRoot":"","sources":["../../src/commands/logout.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAMH,wBAAsB,SAAS,IAAI,OAAO,CAAC,IAAI,CAAC,CAa/C"}
1
+ {"version":3,"file":"logout.d.ts","sourceRoot":"","sources":["../../src/commands/logout.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAOH,wBAAsB,SAAS,IAAI,OAAO,CAAC,IAAI,CAAC,CAgB/C"}
@@ -5,10 +5,14 @@
5
5
  * filesystem itself misbehaves (in which case we surface the OS error).
6
6
  */
7
7
  import pc from "picocolors";
8
+ import { invalidateHostedImageUrl } from "../backend/generated-images.js";
8
9
  import { CONFIG_FILE, deleteConfig } from "../config.js";
9
10
  export async function runLogout() {
10
11
  try {
11
12
  const removed = await deleteConfig();
13
+ // Presigned urls are minted under the (now deleted) credentials — drop
14
+ // them so a later login cannot hand out a url minted by the old identity.
15
+ invalidateHostedImageUrl();
12
16
  if (removed) {
13
17
  process.stdout.write(pc.green(`✓ Logged out. Removed ${CONFIG_FILE}\n`));
14
18
  }
@@ -1 +1 @@
1
- {"version":3,"file":"openai-completions.d.ts","sourceRoot":"","sources":["../../../../src/sdk/ai/providers/openai-completions.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAMX,0BAA0B,EAE1B,MAAM,sCAAsC,CAAC;AAG9C,OAAO,KAAK,EAGX,OAAO,EAGP,KAAK,EACL,uBAAuB,EACvB,mBAAmB,EAEnB,cAAc,EACd,aAAa,EAMb,MAAM,aAAa,CAAC;AAmErB,MAAM,WAAW,wBAAyB,SAAQ,aAAa;IAC9D,UAAU,CAAC,EACR,MAAM,GACN,MAAM,GACN,UAAU,GACV;QAAE,IAAI,EAAE,UAAU,CAAC;QAAC,QAAQ,EAAE;YAAE,IAAI,EAAE,MAAM,CAAA;SAAE,CAAA;KAAE,CAAC;IACpD,eAAe,CAAC,EAAE,SAAS,GAAG,KAAK,GAAG,QAAQ,GAAG,MAAM,GAAG,OAAO,CAAC;CAClE;AAED,KAAK,+BAA+B,GAAG,QAAQ,CAAC,uBAAuB,CAAC,CAAC;AAqRzE,eAAO,MAAM,uBAAuB,EAAE,cAAc,CACnD,oBAAoB,EACpB,wBAAwB,CAybxB,CAAC;AAEF,eAAO,MAAM,6BAA6B,EAAE,cAAc,CACzD,oBAAoB,EACpB,mBAAmB,CAyBnB,CAAC;AAsMF,wBAAgB,eAAe,CAC9B,KAAK,EAAE,KAAK,CAAC,oBAAoB,CAAC,EAClC,OAAO,EAAE,OAAO,EAChB,MAAM,EAAE,+BAA+B,GACrC,0BAA0B,EAAE,CA0R9B"}
1
+ {"version":3,"file":"openai-completions.d.ts","sourceRoot":"","sources":["../../../../src/sdk/ai/providers/openai-completions.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAMX,0BAA0B,EAE1B,MAAM,sCAAsC,CAAC;AAG9C,OAAO,KAAK,EAGX,OAAO,EAGP,KAAK,EACL,uBAAuB,EACvB,mBAAmB,EAEnB,cAAc,EACd,aAAa,EAMb,MAAM,aAAa,CAAC;AAmErB,MAAM,WAAW,wBAAyB,SAAQ,aAAa;IAC9D,UAAU,CAAC,EACR,MAAM,GACN,MAAM,GACN,UAAU,GACV;QAAE,IAAI,EAAE,UAAU,CAAC;QAAC,QAAQ,EAAE;YAAE,IAAI,EAAE,MAAM,CAAA;SAAE,CAAA;KAAE,CAAC;IACpD,eAAe,CAAC,EAAE,SAAS,GAAG,KAAK,GAAG,QAAQ,GAAG,MAAM,GAAG,OAAO,CAAC;CAClE;AAED,KAAK,+BAA+B,GAAG,QAAQ,CAAC,uBAAuB,CAAC,CAAC;AAqRzE,eAAO,MAAM,uBAAuB,EAAE,cAAc,CACnD,oBAAoB,EACpB,wBAAwB,CAocxB,CAAC;AAEF,eAAO,MAAM,6BAA6B,EAAE,cAAc,CACzD,oBAAoB,EACpB,mBAAmB,CAyBnB,CAAC;AAsMF,wBAAgB,eAAe,CAC9B,KAAK,EAAE,KAAK,CAAC,oBAAoB,CAAC,EAClC,OAAO,EAAE,OAAO,EAChB,MAAM,EAAE,+BAA+B,GACrC,0BAA0B,EAAE,CA0R9B"}
@@ -1,4 +1,5 @@
1
1
  import OpenAI from "openai";
2
+ import { backendHostedImageResolver } from "../utils/hosted-image-resolver.js";
2
3
  import { getApiUrl, readConfig } from "../../../config.js";
3
4
  import { getEnvApiKey } from "../env-api-keys.js";
4
5
  import { calculateCost, clampThinkingLevel } from "../models.js";
@@ -311,10 +312,21 @@ export const streamOpenAICompletions = (model, context, options) => {
311
312
  // downgraded by `buildParams` anyway, so fetching them would only
312
313
  // burn bandwidth and memory.
313
314
  const modelSupportsImages = model.input.includes("image");
315
+ // Handing `/generated-images/<id>` to the provider would be a 401 —
316
+ // swap it for a presigned url (lazy: only touches config/machine
317
+ // store when such an image is actually in the context).
318
+ const hostedImages = backendHostedImageResolver({
319
+ onWarn: options?.onWarn ?? defaultWarn,
320
+ // Explicit override (e.g. a local backend) wins; otherwise the
321
+ // backend url from the machine routing resolves relative urls.
322
+ baseUrl: () => backendImageBaseUrl(options?.imageBaseUrl),
323
+ });
314
324
  const requestContext = modelSupportsImages
315
325
  ? await resolveImagesForContext(context, {
316
326
  signal: options?.signal,
317
- baseUrl: () => backendImageBaseUrl(options?.imageBaseUrl),
327
+ baseUrl: hostedImages.baseUrl,
328
+ resolveHostedUrl: hostedImages.resolveHostedUrl,
329
+ authHeaders: hostedImages.authHeaders,
318
330
  onWarn: options?.onWarn ?? defaultWarn,
319
331
  })
320
332
  : context;
@@ -1 +1 @@
1
- {"version":3,"file":"openrouter-images.d.ts","sourceRoot":"","sources":["../../../../src/sdk/ai/providers/openrouter-images.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACX,eAAe,EAEf,aAAa,EACb,WAAW,EACX,qBAAqB,EAGrB,MAAM,aAAa,CAAC;AA8FrB,wBAAsB,wBAAwB,CAC7C,KAAK,EAAE,WAAW,CAAC,mBAAmB,CAAC,EACvC,OAAO,EAAE,aAAa,EACtB,OAAO,GAAE,qBAA0B,GACjC,OAAO,CAAC,eAAe,CAAC,CAyH1B"}
1
+ {"version":3,"file":"openrouter-images.d.ts","sourceRoot":"","sources":["../../../../src/sdk/ai/providers/openrouter-images.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACX,eAAe,EAEf,aAAa,EACb,WAAW,EACX,qBAAqB,EAGrB,MAAM,aAAa,CAAC;AA2GrB,wBAAsB,wBAAwB,CAC7C,KAAK,EAAE,WAAW,CAAC,mBAAmB,CAAC,EACvC,OAAO,EAAE,aAAa,EACtB,OAAO,GAAE,qBAA0B,GACjC,OAAO,CAAC,eAAe,CAAC,CAyH1B"}
@@ -1,5 +1,6 @@
1
1
  import { getEnvApiKey } from "../env-api-keys.js";
2
2
  import { imageSourceUrlForProvider, resolveImagesForInput } from "../utils/image-source.js";
3
+ import { backendHostedImageResolver } from "../utils/hosted-image-resolver.js";
3
4
  /**
4
5
  * Image generation routing.
5
6
  *
@@ -39,10 +40,22 @@ function textFromContext(context) {
39
40
  async function referencesFromContext(context, options = {}) {
40
41
  const backendUrl = typeof options.backendUrl === "string" ? options.backendUrl : undefined;
41
42
  const onWarn = typeof options.onWarn === "function" ? options.onWarn : undefined;
43
+ // `/generated-images/<id>` is auth-gated: a 401 for anything that does not
44
+ // send our machine JWT. Exchange it for a presigned url instead.
45
+ // NOTE: `options.apiKey` is deliberately NOT used here — on the fallback
46
+ // path it is a local OpenRouter key that must never be sent to our
47
+ // backend. The machine JWT comes from `resolveBackendRouting()`, which is
48
+ // lazy + memoized (only awaited for hosted images) and returns null when
49
+ // the CLI is not logged in.
50
+ const hostedImages = backendHostedImageResolver({ onWarn, baseUrl: backendUrl });
42
51
  const input = await resolveImagesForInput(context.input, {
43
52
  // Backend-relative urls (`/generated-images/<id>`) resolve against the
44
53
  // same backend url this request is routed through.
45
- baseUrl: backendUrl,
54
+ baseUrl: hostedImages.baseUrl,
55
+ resolveHostedUrl: hostedImages.resolveHostedUrl,
56
+ authHeaders: hostedImages.authHeaders,
57
+ // A cancelled request must not wait out the 60s resolve timeout.
58
+ signal: options.signal,
46
59
  onWarn,
47
60
  });
48
61
  return input
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Shared wiring for backend-hosted images in provider requests.
3
+ *
4
+ * The backend serves conversation images from S3 behind
5
+ * `GET /generated-images/<id>`, which is AUTH-GATED: an LLM provider
6
+ * (OpenAI, Anthropic, OpenRouter, ...) fetching that url anonymously gets a
7
+ * 401 and the image is silently dropped. `GET /generated-images/<id>/url`
8
+ * — behind the machine JWT — returns a presigned S3 url that anyone can
9
+ * fetch, so that is what we hand to the provider.
10
+ *
11
+ * `resolveBackendRouting()` supplies both the backend url and the machine
12
+ * JWT. It is lazy (config + machine store are only read when a hosted image
13
+ * is actually present) and memoized per resolver instance, and it degrades to
14
+ * `null` when the CLI is not logged in — in which case every hook here is a
15
+ * no-op and behaviour falls back to whatever it was before.
16
+ *
17
+ * @module @spectral/ai/utils/hosted-image-resolver
18
+ */
19
+ export interface BackendHostedImageResolverOptions {
20
+ /** Diagnostics sink — resolution failures are silent without it. */
21
+ onWarn?: (message: string) => void;
22
+ /**
23
+ * Explicit backend base url (or a lazy getter for it). Wins over the url
24
+ * from `resolveBackendRouting()`, so per-request overrides keep working;
25
+ * used to resolve backend-relative image urls (`/generated-images/<id>`).
26
+ */
27
+ baseUrl?: string | (() => string | undefined | Promise<string | undefined>);
28
+ }
29
+ export interface BackendHostedImageResolver {
30
+ /**
31
+ * Swap a hosted url for a presigned one. Returns `null` when the url is
32
+ * not resolvable (not logged in, foreign origin, backend error, ...) so
33
+ * the caller can fall back to downloading + inlining the bytes itself.
34
+ */
35
+ resolveHostedUrl: (url: string, options?: {
36
+ signal?: AbortSignal;
37
+ timeoutMs?: number;
38
+ }) => Promise<string | null>;
39
+ /**
40
+ * `Authorization: Bearer <machine JWT>` for downloading the image bytes
41
+ * from our own backend. Only ever attached to hosted backend urls.
42
+ */
43
+ authHeaders: () => Promise<Record<string, string> | undefined>;
44
+ /** Backend base url for resolving backend-relative image urls. */
45
+ baseUrl: () => Promise<string | undefined>;
46
+ /**
47
+ * The backend url `resolveHostedUrl` authenticates against — i.e. the
48
+ * origin an absolute image url has to match to be considered ours.
49
+ * `undefined` when the CLI is not logged in (nothing is resolvable then).
50
+ */
51
+ backendUrl: () => Promise<string | undefined>;
52
+ }
53
+ /**
54
+ * Build the hosted-image hooks for a single request.
55
+ *
56
+ * Cheap to construct: nothing is read from disk/config until one of the
57
+ * returned functions is actually awaited.
58
+ */
59
+ export declare function backendHostedImageResolver(options?: BackendHostedImageResolverOptions): BackendHostedImageResolver;
60
+ //# sourceMappingURL=hosted-image-resolver.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"hosted-image-resolver.d.ts","sourceRoot":"","sources":["../../../../src/sdk/ai/utils/hosted-image-resolver.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAMH,MAAM,WAAW,iCAAiC;IACjD,oEAAoE;IACpE,MAAM,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC;IACnC;;;;OAIG;IACH,OAAO,CAAC,EAAE,MAAM,GAAG,CAAC,MAAM,MAAM,GAAG,SAAS,GAAG,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAAC,CAAC;CAC5E;AAED,MAAM,WAAW,0BAA0B;IAC1C;;;;OAIG;IACH,gBAAgB,EAAE,CACjB,GAAG,EAAE,MAAM,EACX,OAAO,CAAC,EAAE;QAAE,MAAM,CAAC,EAAE,WAAW,CAAC;QAAC,SAAS,CAAC,EAAE,MAAM,CAAA;KAAE,KAClD,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;IAC5B;;;OAGG;IACH,WAAW,EAAE,MAAM,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,SAAS,CAAC,CAAC;IAC/D,kEAAkE;IAClE,OAAO,EAAE,MAAM,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAAC;IAC3C;;;;OAIG;IACH,UAAU,EAAE,MAAM,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAAC;CAC9C;AAED;;;;;GAKG;AACH,wBAAgB,0BAA0B,CACzC,OAAO,GAAE,iCAAsC,GAC7C,0BAA0B,CA6C5B"}
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Shared wiring for backend-hosted images in provider requests.
3
+ *
4
+ * The backend serves conversation images from S3 behind
5
+ * `GET /generated-images/<id>`, which is AUTH-GATED: an LLM provider
6
+ * (OpenAI, Anthropic, OpenRouter, ...) fetching that url anonymously gets a
7
+ * 401 and the image is silently dropped. `GET /generated-images/<id>/url`
8
+ * — behind the machine JWT — returns a presigned S3 url that anyone can
9
+ * fetch, so that is what we hand to the provider.
10
+ *
11
+ * `resolveBackendRouting()` supplies both the backend url and the machine
12
+ * JWT. It is lazy (config + machine store are only read when a hosted image
13
+ * is actually present) and memoized per resolver instance, and it degrades to
14
+ * `null` when the CLI is not logged in — in which case every hook here is a
15
+ * no-op and behaviour falls back to whatever it was before.
16
+ *
17
+ * @module @spectral/ai/utils/hosted-image-resolver
18
+ */
19
+ import { isHostedImageUrl, resolveHostedImageUrl } from "../../../backend/generated-images.js";
20
+ import { resolveBackendRouting } from "../../../backend/machine-routing.js";
21
+ /**
22
+ * Build the hosted-image hooks for a single request.
23
+ *
24
+ * Cheap to construct: nothing is read from disk/config until one of the
25
+ * returned functions is actually awaited.
26
+ */
27
+ export function backendHostedImageResolver(options = {}) {
28
+ const { onWarn, baseUrl } = options;
29
+ let routing;
30
+ // Memoized so a context with five hosted images reads the machine store
31
+ // once, and text-only requests never touch it at all.
32
+ const getRouting = () => (routing ??= resolveBackendRouting().catch(() => null));
33
+ const resolveExplicitBaseUrl = async () => {
34
+ if (baseUrl === undefined)
35
+ return undefined;
36
+ if (typeof baseUrl === "string")
37
+ return baseUrl.trim() || undefined;
38
+ try {
39
+ const value = await baseUrl();
40
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
41
+ }
42
+ catch {
43
+ return undefined;
44
+ }
45
+ };
46
+ return {
47
+ resolveHostedUrl: async (url, options) => {
48
+ const resolved = await getRouting();
49
+ if (!resolved)
50
+ return null;
51
+ // SECURITY: never send the machine JWT to (or mint a presigned url
52
+ // for) a host that is not our backend — a third-party url must not
53
+ // be able to impersonate `/generated-images/<id>`.
54
+ if (!isHostedImageUrl(url, resolved.backendUrl)) {
55
+ onWarn?.(`Refusing to resolve a hosted image url for a foreign origin: ${url}`);
56
+ return null;
57
+ }
58
+ const result = await resolveHostedImageUrl({
59
+ backendUrl: resolved.backendUrl,
60
+ token: resolved.machineJwt,
61
+ url,
62
+ signal: options?.signal,
63
+ timeoutMs: options?.timeoutMs,
64
+ onWarn,
65
+ });
66
+ return result?.url ?? null;
67
+ },
68
+ authHeaders: async () => {
69
+ const resolved = await getRouting();
70
+ return resolved ? { Authorization: `Bearer ${resolved.machineJwt}` } : undefined;
71
+ },
72
+ baseUrl: async () => (await resolveExplicitBaseUrl()) ?? (await getRouting())?.backendUrl,
73
+ backendUrl: async () => (await getRouting())?.backendUrl,
74
+ };
75
+ }
@@ -9,8 +9,11 @@
9
9
  *
10
10
  * - `data` present -> `data:<mimeType>;base64,<data>` (unchanged, legacy safe)
11
11
  * - `url` public -> passed through as-is (no base64 in the context!)
12
+ * - `url` backend-hosted -> swapped for a presigned, headerless-fetchable
13
+ * url (`/generated-images/<id>` is what the
14
+ * backend returns; it is AUTH-GATED, so handing
15
+ * it to a provider only yields a 401)
12
16
  * - `url` relative -> resolved against the backend base url first
13
- * (`/generated-images/<id>` is what the backend returns)
14
17
  * - `url` local/private -> fetched and inlined **at send time only**
15
18
  *
16
19
  * The inlining is deliberately scoped to a single request: the fetched bytes
@@ -38,6 +41,29 @@ export interface ResolveImageOptions {
38
41
  * actually present.
39
42
  */
40
43
  baseUrl?: string | (() => string | undefined | Promise<string | undefined>);
44
+ /**
45
+ * Exchange a backend-hosted image url (`/generated-images/<id>`) for a
46
+ * short-lived presigned url that the provider can fetch WITHOUT any
47
+ * Authorization header (see `resolveHostedImageUrl` in
48
+ * `backend/generated-images.ts`).
49
+ *
50
+ * Return `null`/`undefined` to fall back to the download+inline path.
51
+ * Only ever called for urls where `isHostedBackendImageUrl()` is true —
52
+ * i.e. relative urls and absolute urls on the backend origin.
53
+ */
54
+ resolveHostedUrl?: (url: string, options?: {
55
+ signal?: AbortSignal;
56
+ timeoutMs?: number;
57
+ }) => Promise<string | null | undefined>;
58
+ /**
59
+ * Headers (in practice `Authorization: Bearer <machine JWT>`) used when the
60
+ * image bytes have to be downloaded for inlining. May be a lazy getter so
61
+ * the credentials are only read when a hosted image is actually present.
62
+ *
63
+ * SECURITY: these are only ever attached to a backend-hosted image url —
64
+ * never to a third-party host the credentials were not issued for.
65
+ */
66
+ authHeaders?: Record<string, string> | (() => Promise<Record<string, string> | undefined>);
41
67
  onWarn?: (message: string) => void;
42
68
  }
43
69
  export interface ResolvedImageSource {
@@ -52,6 +78,11 @@ export interface ResolvedImageSource {
52
78
  * (inline `data` present, or fetched from a local url).
53
79
  */
54
80
  data?: string;
81
+ /**
82
+ * True when `url` is a presigned url returned by `resolveHostedUrl` (i.e.
83
+ * it replaced an auth-gated `/generated-images/<id>` reference).
84
+ */
85
+ hostedResolved?: boolean;
55
86
  }
56
87
  /**
57
88
  * True when `rawUrl` has no scheme, i.e. it is relative and therefore
@@ -74,6 +105,22 @@ export declare function resolveImageUrlAgainstBase(rawUrl: string, baseUrl?: str
74
105
  * resolved against a base url.
75
106
  */
76
107
  export declare function isLocalOrPrivateImageUrl(rawUrl: string): boolean;
108
+ /**
109
+ * True for a backend-hosted image reference, either backend-relative
110
+ * (`/generated-images/<id>`) or absolute
111
+ * (`https://api.aexol.ai/generated-images/<id>`).
112
+ *
113
+ * `backendUrl` is REQUIRED to accept an ABSOLUTE url: without it there is no
114
+ * origin to compare against, and a foreign host that happens to expose
115
+ * `/generated-images/<id>` must never be treated as ours (that would send the
116
+ * machine JWT to it, and would let it swap in a presigned url of one of our
117
+ * tenant's images). Relative urls are always accepted — they can only ever be
118
+ * resolved against the backend base url.
119
+ *
120
+ * Those endpoints are auth-gated: a provider fetching them without an
121
+ * `Authorization` header gets a 401 and never sees the image.
122
+ */
123
+ export declare function isHostedBackendImageUrl(rawUrl: string, backendUrl?: string | null): boolean;
77
124
  export declare function hasInlineImageData(image: ImageContent): image is ImageContent & {
78
125
  data: string;
79
126
  };
@@ -1 +1 @@
1
- {"version":3,"file":"image-source.d.ts","sourceRoot":"","sources":["../../../../src/sdk/ai/utils/image-source.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAEH,OAAO,KAAK,EAAE,OAAO,EAAE,YAAY,EAAW,MAAM,aAAa,CAAC;AAElE,8EAA8E;AAC9E,eAAO,MAAM,wBAAwB,QAAmB,CAAC;AAEzD,iEAAiE;AACjE,eAAO,MAAM,+BAA+B,QAAS,CAAC;AAEtD,MAAM,WAAW,mBAAmB;IACnC,mEAAmE;IACnE,MAAM,CAAC,EAAE,WAAW,CAAC;IACrB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,mDAAmD;IACnD,SAAS,CAAC,EAAE,OAAO,KAAK,CAAC;IACzB;;;;;OAKG;IACH,OAAO,CAAC,EAAE,MAAM,GAAG,CAAC,MAAM,MAAM,GAAG,SAAS,GAAG,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAAC,CAAC;IAC5E,MAAM,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC;CACnC;AAED,MAAM,WAAW,mBAAmB;IACnC,mEAAmE;IACnE,GAAG,EAAE,MAAM,CAAC;IACZ,2DAA2D;IAC3D,OAAO,EAAE,OAAO,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;IACjB;;;;OAIG;IACH,IAAI,CAAC,EAAE,MAAM,CAAC;CACd;AAuCD;;;GAGG;AACH,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAI1D;AAED;;;GAGG;AACH,wBAAgB,0BAA0B,CAAC,MAAM,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,MAAM,GAAG,MAAM,CAUnF;AAmBD;;;;;;;;;GASG;AACH,wBAAgB,wBAAwB,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CA4BhE;AAcD,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,YAAY,GAAG,KAAK,IAAI,YAAY,GAAG;IAAE,IAAI,EAAE,MAAM,CAAA;CAAE,CAEhG;AAED,wEAAwE;AACxE,wBAAgB,eAAe,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM,CAGtE;AAED;;;;GAIG;AACH,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAIxD;AAoBD;;;;;;GAMG;AACH,wBAAsB,uBAAuB,CAC5C,KAAK,EAAE,YAAY,EACnB,OAAO,GAAE,mBAAwB,GAC/B,OAAO,CAAC,mBAAmB,GAAG,IAAI,CAAC,CAgErC;AAcD;;;;;;;;;GASG;AACH,wBAAsB,uBAAuB,CAC5C,OAAO,EAAE,OAAO,EAChB,OAAO,GAAE,mBAAwB,GAC/B,OAAO,CAAC,OAAO,CAAC,CAoDlB;AAED;;GAEG;AACH,wBAAsB,qBAAqB,CAC1C,KAAK,EAAE,KAAK,CAAC;IAAE,IAAI,EAAE,MAAM,CAAA;CAAE,CAAC,EAC9B,OAAO,GAAE,mBAAwB,GAC/B,OAAO,CAAC,KAAK,CAAC;IAAE,IAAI,EAAE,MAAM,CAAA;CAAE,CAAC,CAAC,CAsBlC;AAED;;;;GAIG;AACH,wBAAgB,yBAAyB,CAAC,KAAK,EAAE,YAAY,EAAE,OAAO,CAAC,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAUnG"}
1
+ {"version":3,"file":"image-source.d.ts","sourceRoot":"","sources":["../../../../src/sdk/ai/utils/image-source.ts"],"names":[],"mappings":"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"}
@@ -9,8 +9,11 @@
9
9
  *
10
10
  * - `data` present -> `data:<mimeType>;base64,<data>` (unchanged, legacy safe)
11
11
  * - `url` public -> passed through as-is (no base64 in the context!)
12
+ * - `url` backend-hosted -> swapped for a presigned, headerless-fetchable
13
+ * url (`/generated-images/<id>` is what the
14
+ * backend returns; it is AUTH-GATED, so handing
15
+ * it to a provider only yields a 401)
12
16
  * - `url` relative -> resolved against the backend base url first
13
- * (`/generated-images/<id>` is what the backend returns)
14
17
  * - `url` local/private -> fetched and inlined **at send time only**
15
18
  *
16
19
  * The inlining is deliberately scoped to a single request: the fetched bytes
@@ -19,6 +22,7 @@
19
22
  *
20
23
  * @module @spectral/ai/utils/image-source
21
24
  */
25
+ import { hostedImageIdFromUrl, isHostedImageUrl } from "../../../backend/generated-images.js";
22
26
  /** Don't inline absurd payloads — 25 MiB of base64 would blow the request. */
23
27
  export const DEFAULT_MAX_INLINE_BYTES = 25 * 1024 * 1024;
24
28
  /** Timeout for fetching a local image that has to be inlined. */
@@ -170,6 +174,44 @@ async function resolveBaseUrlOption(baseUrl) {
170
174
  return undefined;
171
175
  }
172
176
  }
177
+ /**
178
+ * True for a backend-hosted image reference, either backend-relative
179
+ * (`/generated-images/<id>`) or absolute
180
+ * (`https://api.aexol.ai/generated-images/<id>`).
181
+ *
182
+ * `backendUrl` is REQUIRED to accept an ABSOLUTE url: without it there is no
183
+ * origin to compare against, and a foreign host that happens to expose
184
+ * `/generated-images/<id>` must never be treated as ours (that would send the
185
+ * machine JWT to it, and would let it swap in a presigned url of one of our
186
+ * tenant's images). Relative urls are always accepted — they can only ever be
187
+ * resolved against the backend base url.
188
+ *
189
+ * Those endpoints are auth-gated: a provider fetching them without an
190
+ * `Authorization` header gets a 401 and never sees the image.
191
+ */
192
+ export function isHostedBackendImageUrl(rawUrl, backendUrl) {
193
+ return isHostedImageUrl(rawUrl, backendUrl ?? undefined);
194
+ }
195
+ /** Resolve (and normalize) the lazily supplied auth headers. */
196
+ async function resolveAuthHeadersOption(authHeaders) {
197
+ if (!authHeaders)
198
+ return undefined;
199
+ let headers;
200
+ if (typeof authHeaders === "function") {
201
+ try {
202
+ headers = await authHeaders();
203
+ }
204
+ catch {
205
+ return undefined;
206
+ }
207
+ }
208
+ else {
209
+ headers = authHeaders;
210
+ }
211
+ if (!headers || Object.keys(headers).length === 0)
212
+ return undefined;
213
+ return headers;
214
+ }
173
215
  export function hasInlineImageData(image) {
174
216
  return typeof image.data === "string" && image.data.length > 0;
175
217
  }
@@ -230,14 +272,46 @@ export async function resolveImageForProvider(image, options = {}) {
230
272
  let url = typeof image.url === "string" ? image.url.trim() : "";
231
273
  if (!url)
232
274
  return null;
275
+ // Resolve the backend base url lazily, and ONLY when the url even looks
276
+ // hosted (path-wise): public cdn urls must not pay for a config/machine
277
+ // store read. The base url doubles as the origin an absolute url has to
278
+ // match before it is accepted as ours.
279
+ const looksHosted = hostedImageIdFromUrl(url) !== null;
280
+ const backendUrl = looksHosted ? await resolveBaseUrlOption(options.baseUrl) : undefined;
233
281
  // Relative urls (`/generated-images/<id>`) must be joined with the backend
234
282
  // base url before anything else — otherwise the provider gets a path.
235
283
  if (isRelativeImageUrl(url)) {
236
- url = resolveImageUrlAgainstBase(url, await resolveBaseUrlOption(options.baseUrl));
284
+ url = resolveImageUrlAgainstBase(url, backendUrl);
285
+ }
286
+ // A hosted url points at OUR backend, which authenticates every request:
287
+ // the provider would fetch it anonymously and get a 401. Swap it for a
288
+ // presigned url first (that one needs no headers at all).
289
+ const hosted = isHostedBackendImageUrl(url, backendUrl);
290
+ if (hosted && options.resolveHostedUrl) {
291
+ try {
292
+ const resolvedUrl = (await options.resolveHostedUrl(url, { signal: options.signal, timeoutMs: options.timeoutMs }))?.trim();
293
+ if (resolvedUrl) {
294
+ return { url: resolvedUrl, inlined: false, mimeType: image.mimeType, hostedResolved: true };
295
+ }
296
+ }
297
+ catch (error) {
298
+ const reason = error instanceof Error ? error.message : String(error);
299
+ options.onWarn?.(`Could not resolve hosted image ${url} (${reason})`);
300
+ }
237
301
  }
238
- if (!isLocalOrPrivateImageUrl(url)) {
302
+ // Only inline a hosted url when we can actually authenticate the download;
303
+ // without credentials the fetch is a guaranteed 401 anyway, so the plain
304
+ // pass-through (identical end result, one request less) is kept.
305
+ const authHeaders = hosted ? await resolveAuthHeadersOption(options.authHeaders) : undefined;
306
+ // Belt and braces: re-check the origin here, because `url` was rewritten
307
+ // above (relative -> absolute) and only a url that is still ours may carry
308
+ // credentials. The resolver refuses foreign origins too, this is the last
309
+ // gate before the token goes on the wire.
310
+ const authenticated = authHeaders !== undefined && isHostedBackendImageUrl(url, backendUrl);
311
+ if (!isLocalOrPrivateImageUrl(url) && !authenticated) {
239
312
  return { url, inlined: false, mimeType: image.mimeType };
240
313
  }
314
+ const requestHeaders = authenticated ? authHeaders : undefined;
241
315
  const doFetch = options.fetchImpl ?? fetch;
242
316
  const timeoutMs = options.timeoutMs ?? DEFAULT_INLINE_FETCH_TIMEOUT_MS;
243
317
  const maxInlineBytes = options.maxInlineBytes ?? DEFAULT_MAX_INLINE_BYTES;
@@ -249,7 +323,10 @@ export async function resolveImageForProvider(image, options = {}) {
249
323
  // An already-aborted signal never fires the listener above.
250
324
  if (options.signal?.aborted)
251
325
  throw new Error("aborted");
252
- const response = await doFetch(url, { signal: controller.signal });
326
+ // `requestHeaders` is only set when the (possibly rewritten) url still
327
+ // resolves to the backend origin — a url on any other host is fetched
328
+ // anonymously, so the JWT cannot leak to a third party.
329
+ const response = await doFetch(url, requestHeaders ? { signal: controller.signal, headers: requestHeaders } : { signal: controller.signal });
253
330
  if (!response.ok)
254
331
  throw new Error(`HTTP ${response.status}`);
255
332
  // Reject oversized payloads from the declared length BEFORE reading
@@ -276,7 +353,7 @@ export async function resolveImageForProvider(image, options = {}) {
276
353
  }
277
354
  catch (error) {
278
355
  const reason = error instanceof Error ? error.message : String(error);
279
- options.onWarn?.(`Could not inline local image ${url} (${reason}); sending url as-is`);
356
+ options.onWarn?.(`Could not inline local image ${url} (${reason}); sending url as-is${hosted ? " — the provider may not be able to fetch it (auth required)" : ""}`);
280
357
  return { url, inlined: false, mimeType: image.mimeType };
281
358
  }
282
359
  finally {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aexol/spectral",
3
- "version": "0.9.215",
3
+ "version": "0.9.218",
4
4
  "description": "AI coding agent for Aexol with relay-based browser access.",
5
5
  "type": "module",
6
6
  "private": false,