@chrischall/gemini-mcp 1.4.0 → 1.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/client.js CHANGED
@@ -153,13 +153,30 @@ export class GeminiClient {
153
153
  * server is single-user, so its one singleton client is one session.
154
154
  */
155
155
  session = new SessionState();
156
+ /** Per-account character/style library — hosted connector only (see options). */
157
+ library;
158
+ /** Signed upload-URL minter — hosted connector only (see options). */
159
+ uploadUrls;
156
160
  constructor(opts = {}) {
157
161
  this.mediaSink = opts.mediaSink ?? createDiskSink();
162
+ this.library = opts.library;
163
+ this.uploadUrls = opts.uploadUrls;
158
164
  // A replayed idempotent result must not hand back an expired media URL, so
159
165
  // the job registry needs a way to re-sign from the stable r2_key.
160
166
  this.session.jobs.resigner = this.mediaSink;
161
167
  this.explicitApiKey = opts.apiKey?.trim() || undefined;
162
- this.fetchImpl = opts.fetchImpl ?? fetch;
168
+ // Never store the global `fetch` raw on the instance: it would be invoked
169
+ // as `this.fetchImpl(...)`, handing native fetch this GeminiClient as its
170
+ // receiver — workerd rejects any non-global receiver with "Illegal
171
+ // invocation: function called with incorrect 'this' reference", while
172
+ // Node's fetch does not care, so only production could see the crash
173
+ // (it did: every hosted generation whose reference image took the >6MB
174
+ // Files-API promotion). The arrow pins the receiver to the global scope.
175
+ // An INJECTED impl is stored as-is, so tests can emulate workerd's
176
+ // receiver check and catch any `this.fetchImpl(...)` call site — which is
177
+ // also why call sites must alias it to a local before calling (see
178
+ // uploadToFilesApi). Guarded by tests/tools/hosted-reference-forms.test.ts.
179
+ this.fetchImpl = opts.fetchImpl ?? ((...args) => fetch(...args));
163
180
  this.sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
164
181
  }
165
182
  /**
@@ -337,9 +354,13 @@ export class GeminiClient {
337
354
  */
338
355
  async uploadToFilesApi(body, mimeType, displayName, contentLength) {
339
356
  const key = this.requireKey();
357
+ // Local alias so the calls below carry NO receiver: `this.fetchImpl(...)`
358
+ // would hand native fetch this GeminiClient as `this`, which workerd
359
+ // rejects with an Illegal invocation (see the constructor note).
360
+ const doFetch = this.fetchImpl;
340
361
  // 1. start — establish the resumable upload session
341
362
  const startPath = '/files';
342
- const startRes = await this.fetchImpl(`${UPLOAD_BASE_URL}${startPath}`, {
363
+ const startRes = await doFetch(`${UPLOAD_BASE_URL}${startPath}`, {
343
364
  method: 'POST',
344
365
  headers: {
345
366
  'x-goog-api-key': key,
@@ -369,7 +390,7 @@ export class GeminiClient {
369
390
  // falls back to chunked encoding, which the resumable protocol does not
370
391
  // accept (this half is docs-derived; see docs/GEMINI-API.md).
371
392
  const streaming = !(body instanceof Blob);
372
- const upRes = await this.fetchImpl(uploadUrl, {
393
+ const upRes = await doFetch(uploadUrl, {
373
394
  method: 'POST',
374
395
  headers: {
375
396
  'X-Goog-Upload-Offset': '0',
package/dist/inputs.js CHANGED
@@ -7,6 +7,7 @@ export function hasImageInput(args) {
7
7
  args.images_base64?.length ||
8
8
  args.images_url?.length ||
9
9
  args.images_file_uris?.length ||
10
+ args.images_r2_keys?.length ||
10
11
  args.from_clipboard);
11
12
  }
12
13
  /** Conservative TTL when the API doesn't tell us: documented retention is ~48h. */
@@ -75,6 +76,40 @@ export async function resolveImageInputs(args, client) {
75
76
  (report.file_uris ??= []).push(file.name);
76
77
  inputs.push(input);
77
78
  }
79
+ // 6. r2_keys — the connector reads its OWN store (tenant-gated), so a signed
80
+ // upload or an earlier generation becomes a reference without a fetch, a
81
+ // signature, or a byte in the conversation. Deduped within the call, same as
82
+ // URLs, and — also same as URLs — a large object is promoted to a Files API
83
+ // reference rather than inlined, because `generateContent` caps a whole
84
+ // request near 20MB and two large inline references would fail it.
85
+ // `readStoredMedia` throws an actionable error for a swept/foreign key.
86
+ const seenKeys = new Map();
87
+ for (const key of args.images_r2_keys ?? []) {
88
+ const hit = seenKeys.get(key);
89
+ if (hit) {
90
+ inputs.push(hit);
91
+ continue;
92
+ }
93
+ const stored = await client.readStoredMedia(key);
94
+ const entry = {
95
+ r2_key: key.trim(),
96
+ mime_type: stored.mimeType,
97
+ bytes: stored.bytes.byteLength,
98
+ };
99
+ (report.r2_keys ??= []).push(entry);
100
+ let input;
101
+ if (stored.bytes.byteLength <= IMAGE_INLINE_MAX_BYTES) {
102
+ input = { base64: bytesToBase64(stored.bytes), mimeType: stored.mimeType };
103
+ }
104
+ else {
105
+ const displayName = key.trim().split('/').pop() || 'image';
106
+ const uploaded = await client.uploadBytes(stored.bytes, stored.mimeType, displayName);
107
+ entry.file_uri = uploaded.name;
108
+ input = { uri: uploaded.uri, mimeType: uploaded.mimeType };
109
+ }
110
+ seenKeys.set(key, input);
111
+ inputs.push(input);
112
+ }
78
113
  return { inputs, report: Object.keys(report).length > 0 ? report : undefined };
79
114
  }
80
115
  /**
@@ -161,5 +196,6 @@ export function requireImageInput(args) {
161
196
  if (hasImageInput(args))
162
197
  return;
163
198
  throw new McpToolError('Provide at least one input image via `images_url` (an https URL the server fetches), `images_file_uris` ' +
164
- '(from gemini_upload_file), `images` (a local path), `images_base64`, or `from_clipboard`.');
199
+ '(from gemini_upload_file), `images_r2_keys` (a hosted-connector upload or earlier generation), ' +
200
+ '`images` (a local path), `images_base64`, or `from_clipboard`.');
165
201
  }
package/dist/jobs.js CHANGED
@@ -86,6 +86,25 @@ async function refreshMedia(result, sink) {
86
86
  body.media = entries;
87
87
  if (mediaKeyName)
88
88
  body[mediaKeyName] = refs;
89
+ // A multi-image set also carries a bundle zip; its link expires like any
90
+ // other and re-signs from its own r2_key. Best-effort, like the rest.
91
+ const bundle = body.bundle;
92
+ if (bundle && typeof bundle.r2_key === 'string') {
93
+ try {
94
+ const fresh = await sink.resign(bundle.r2_key);
95
+ if (fresh) {
96
+ const { expires_at: _staleBundle, ...rest } = bundle;
97
+ body.bundle = {
98
+ ...rest,
99
+ url: fresh.ref,
100
+ ...(fresh.expiresAt ? { expires_at: fresh.expiresAt } : {}),
101
+ curl_hint: `curl -sS -o ${downloadFilename(fresh.key ?? 'media')} "${fresh.ref}"`,
102
+ };
103
+ body.bundle_url = fresh.ref;
104
+ }
105
+ }
106
+ catch { /* an un-refreshed bundle link is still a valid replay */ }
107
+ }
89
108
  body.media_urls_refreshed = true;
90
109
  return { ...result, content: [{ ...first, text: JSON.stringify(body, null, 2) }, ...(result.content ?? []).slice(1)] };
91
110
  }
@@ -119,6 +138,19 @@ function jobHandle(jobId, status) {
119
138
  hint: `Generation running in the background. Poll gemini_get_result with job_id "${jobId}" until status is "done" (results are per-process and expire ~10 min after completion).`,
120
139
  });
121
140
  }
141
+ /**
142
+ * Await `promise` for at most `waitMs`; `undefined` means the budget expired
143
+ * with the work still pending (rejections propagate as usual). The timer never
144
+ * holds the process open, and a late rejection after the budget expired is
145
+ * already handled by the registry's own settle handlers.
146
+ */
147
+ function awaitWithBudget(promise, waitMs) {
148
+ return new Promise((resolve, reject) => {
149
+ const timer = setTimeout(() => resolve(undefined), waitMs);
150
+ timer.unref?.();
151
+ promise.then((result) => { clearTimeout(timer); resolve(result); }, (err) => { clearTimeout(timer); reject(err); });
152
+ });
153
+ }
122
154
  /**
123
155
  * One session's jobs. Construct one per session and never share it — see the
124
156
  * cross-tenant note at the top of this file. Reachable as
@@ -181,7 +213,7 @@ export class JobRegistry {
181
213
  * different registry and therefore a genuinely separate generation.
182
214
  */
183
215
  async dispatch(opts, work) {
184
- const { toolName, fingerprint, idempotencyKey, async } = opts;
216
+ const { toolName, fingerprint, idempotencyKey, async, waitMs } = opts;
185
217
  const now = Date.now();
186
218
  let hit;
187
219
  if (idempotencyKey !== undefined) {
@@ -199,10 +231,15 @@ export class JobRegistry {
199
231
  if (hit) {
200
232
  if (async)
201
233
  return jobHandle(hit.jobId, hit.status);
234
+ const settled = waitMs !== undefined && hit.status === 'running'
235
+ ? await awaitWithBudget(hit.promise, waitMs)
236
+ : await hit.promise;
237
+ if (settled === undefined)
238
+ return jobHandle(hit.jobId, 'running');
202
239
  // Refresh before annotating: a recorded result can outlive the signed
203
240
  // URLs inside it, and replaying an expired link is its own kind of wrong
204
241
  // answer — it looks like success.
205
- return annotateReused(await refreshMedia(await hit.promise, this.resigner), hit.jobId);
242
+ return annotateReused(await refreshMedia(settled, this.resigner), hit.jobId);
206
243
  }
207
244
  const jobId = randomUUID();
208
245
  this.evict(now);
@@ -222,6 +259,13 @@ export class JobRegistry {
222
259
  });
223
260
  if (async)
224
261
  return jobHandle(jobId, 'running');
262
+ if (waitMs !== undefined) {
263
+ const settled = await awaitWithBudget(promise, waitMs);
264
+ // Budget expired with the work still running: hand back the job id — the
265
+ // work continues and the caller polls, exactly as with `async: true`,
266
+ // except a fast result would have been returned in-band.
267
+ return settled ?? jobHandle(jobId, 'running');
268
+ }
225
269
  return promise;
226
270
  }
227
271
  /**
@@ -0,0 +1,151 @@
1
+ /**
2
+ * The per-account reference library: named characters (a reference image + a
3
+ * description) and named styles (a reusable prompt fragment, optionally with a
4
+ * reference image of its own).
5
+ *
6
+ * Why it exists: keeping a recurring subject consistent across generations
7
+ * means re-supplying the same reference image and the same description every
8
+ * session — re-uploading the photo, re-typing the description, re-explaining
9
+ * the style. The library makes each of those a NAME: save once, then pass
10
+ * `characters: ["finn"]` / `style: "bold-cartoon-sports"` on any generation.
11
+ *
12
+ * Storage is the connector's R2 bucket under `lib/<tenant>/…`:
13
+ *
14
+ * - `lib/<tenant>/characters/<name>.json` — the record
15
+ * - `lib/<tenant>/characters/<name>.img` — the reference image bytes
16
+ * - `lib/<tenant>/styles/<name>.json` (+ `.img` when a reference was given)
17
+ *
18
+ * Two properties are load-bearing:
19
+ *
20
+ * - **No expiry.** The retention cron sweeps `gen/`, `media/` and `up/` but
21
+ * deliberately never `lib/` (media-cleanup.ts) — a saved character outlives
22
+ * every signed URL and every upload. That is why saving COPIES the image
23
+ * bytes into `lib/` rather than storing an `r2_key` pointer: a pointer into
24
+ * `gen/` or `up/` would dangle after the next sweep.
25
+ * - **Tenant scoping by construction.** Every key this module touches is
26
+ * derived from the session's own tenant id; caller-supplied names are
27
+ * validated against a strict slug charset, so no name can address another
28
+ * account's namespace (or escape `lib/` at all).
29
+ *
30
+ * Worker-safe: no node imports, no module-scope state, structural bucket type.
31
+ */
32
+ /** Key prefix for the library — the one prefix the retention sweep must skip. */
33
+ export const LIBRARY_KEY_PREFIX = 'lib';
34
+ /**
35
+ * Library names are slugs: 1–40 chars, lowercase alphanumerics plus `-`/`_`,
36
+ * starting alphanumeric. Doubles as key-safety (no `/`, `.`, whitespace).
37
+ */
38
+ export const LIBRARY_NAME_PATTERN = /^[a-z0-9][a-z0-9_-]{0,39}$/;
39
+ export function isValidLibraryName(name) {
40
+ return LIBRARY_NAME_PATTERN.test(name);
41
+ }
42
+ /** Build the R2-backed library for one session. */
43
+ export function createR2Library(bucket, opts) {
44
+ const now = opts.now ?? (() => new Date());
45
+ let tenantCache;
46
+ const tenantId = () => (tenantCache ??= Promise.resolve(typeof opts.tenant === 'function' ? opts.tenant() : opts.tenant));
47
+ const assertName = (name) => {
48
+ if (!isValidLibraryName(name)) {
49
+ throw new Error(`Invalid library name "${name}" — use 1-40 lowercase letters, digits, "-" or "_" (e.g. "finn", "bold-cartoon-sports").`);
50
+ }
51
+ return name;
52
+ };
53
+ const keyFor = async (kind, name, ext) => `${LIBRARY_KEY_PREFIX}/${await tenantId()}/${kind}/${assertName(name)}.${ext}`;
54
+ async function readJson(key) {
55
+ const object = await bucket.get(key);
56
+ if (!object)
57
+ return undefined;
58
+ try {
59
+ return JSON.parse(new TextDecoder().decode(await object.arrayBuffer()));
60
+ }
61
+ catch {
62
+ // A malformed record is unreadable, not fatal — callers treat it as absent.
63
+ return undefined;
64
+ }
65
+ }
66
+ async function readImage(kind, name) {
67
+ const object = await bucket.get(await keyFor(kind, name, 'img'));
68
+ if (!object)
69
+ return undefined;
70
+ return {
71
+ bytes: new Uint8Array(await object.arrayBuffer()),
72
+ mimeType: object.httpMetadata?.contentType ?? 'application/octet-stream',
73
+ };
74
+ }
75
+ async function listRecords(kind) {
76
+ const prefix = `${LIBRARY_KEY_PREFIX}/${await tenantId()}/${kind}/`;
77
+ const keys = [];
78
+ let cursor;
79
+ for (;;) {
80
+ const page = await bucket.list({ prefix, cursor, limit: 1000 });
81
+ keys.push(...page.objects.map((o) => o.key).filter((k) => k.endsWith('.json')));
82
+ if (!page.truncated)
83
+ break;
84
+ cursor = page.cursor;
85
+ }
86
+ const records = await Promise.all(keys.map((k) => readJson(k)));
87
+ return records
88
+ .filter((r) => r !== undefined)
89
+ .sort((a, b) => a.name.localeCompare(b.name));
90
+ }
91
+ async function deleteEntry(kind, name) {
92
+ const jsonKey = await keyFor(kind, name, 'json');
93
+ const existed = (await bucket.get(jsonKey)) !== null;
94
+ await bucket.delete([jsonKey, await keyFor(kind, name, 'img')]);
95
+ return existed;
96
+ }
97
+ return {
98
+ async saveCharacter({ name, description, image }) {
99
+ const jsonKey = await keyFor('characters', name, 'json');
100
+ const imageKey = await keyFor('characters', name, 'img');
101
+ const at = now().toISOString();
102
+ const existing = await readJson(jsonKey);
103
+ const record = {
104
+ name,
105
+ description,
106
+ image_key: imageKey,
107
+ mime_type: image.mimeType,
108
+ created_at: existing?.created_at ?? at,
109
+ updated_at: at,
110
+ };
111
+ await bucket.put(imageKey, image.bytes, { httpMetadata: { contentType: image.mimeType } });
112
+ await bucket.put(jsonKey, JSON.stringify(record, null, 2), { httpMetadata: { contentType: 'application/json' } });
113
+ return record;
114
+ },
115
+ async getCharacter(name) {
116
+ return readJson(await keyFor('characters', name, 'json'));
117
+ },
118
+ readCharacterImage: (name) => readImage('characters', name),
119
+ listCharacters: () => listRecords('characters'),
120
+ deleteCharacter: (name) => deleteEntry('characters', name),
121
+ async saveStyle({ name, promptFragment, image }) {
122
+ const jsonKey = await keyFor('styles', name, 'json');
123
+ const imageKey = await keyFor('styles', name, 'img');
124
+ const at = now().toISOString();
125
+ const existing = await readJson(jsonKey);
126
+ const record = {
127
+ name,
128
+ prompt_fragment: promptFragment,
129
+ ...(image ? { image_key: imageKey, mime_type: image.mimeType } : {}),
130
+ created_at: existing?.created_at ?? at,
131
+ updated_at: at,
132
+ };
133
+ if (image) {
134
+ await bucket.put(imageKey, image.bytes, { httpMetadata: { contentType: image.mimeType } });
135
+ }
136
+ else if (existing?.image_key) {
137
+ // Re-saving without an image REPLACES the style — a stale reference
138
+ // image left behind would silently keep steering generations.
139
+ await bucket.delete(imageKey);
140
+ }
141
+ await bucket.put(jsonKey, JSON.stringify(record, null, 2), { httpMetadata: { contentType: 'application/json' } });
142
+ return record;
143
+ },
144
+ async getStyle(name) {
145
+ return readJson(await keyFor('styles', name, 'json'));
146
+ },
147
+ readStyleImage: (name) => readImage('styles', name),
148
+ listStyles: () => listRecords('styles'),
149
+ deleteStyle: (name) => deleteEntry('styles', name),
150
+ };
151
+ }
@@ -17,8 +17,11 @@ const DEFAULT_MAX_OBJECTS = 5_000;
17
17
  * `gen/` is where media is written now; `media/` is where it was written before
18
18
  * the rename that made URLs read `/media/gen/…` instead of `/media/media/…`.
19
19
  * Both are swept — retention that ignores the existing backlog is not retention.
20
+ * `up/` (signed uploads) is swept on the same schedule: a reference photo worth
21
+ * keeping gets copied into the character library (`lib/`), which is the ONE
22
+ * prefix deliberately absent here — saved characters and styles have no expiry.
20
23
  */
21
- const LEGACY_AND_CURRENT_PREFIXES = ['gen/', 'media/'];
24
+ const LEGACY_AND_CURRENT_PREFIXES = ['gen/', 'media/', 'up/'];
22
25
  /** R2 accepts bulk deletes in batches; 1000 is the documented ceiling. */
23
26
  const DELETE_BATCH = 1_000;
24
27
  /** Days → ms, defaulting to 7 for anything missing or nonsensical. */
@@ -59,6 +59,12 @@ function baseHeaders(key, object, size) {
59
59
  // The bytes are model output being handed to a browser — never let a
60
60
  // sniffed content type turn an image response into something executable.
61
61
  'x-content-type-options': 'nosniff',
62
+ // Defense in depth against stored XSS: this route serves user-influenced
63
+ // bytes from the SAME ORIGIN as the connector's OAuth pages, so if a
64
+ // scriptable document type (SVG, HTML) ever slipped past the raster-only
65
+ // upload gates, this neutralizes it — no script, no fetch, and a unique
66
+ // opaque origin via sandbox. Pixels don't need any of what this disables.
67
+ 'content-security-policy': "default-src 'none'; sandbox",
62
68
  // Range support is cheap on R2 and is what makes a resumable `curl -C -`
63
69
  // work against a large video.
64
70
  'accept-ranges': 'bytes',
package/dist/media-url.js CHANGED
@@ -19,14 +19,14 @@
19
19
  export const MEDIA_URL_TTL_MS = 48 * 60 * 60 * 1000;
20
20
  /** KV key holding the lazily-generated signing secret (zero-config path). */
21
21
  export const SIGNING_SECRET_KV_KEY = 'media:url-signing-secret';
22
- function base64UrlEncode(bytes) {
22
+ export function base64UrlEncode(bytes) {
23
23
  const view = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
24
24
  let binary = '';
25
25
  for (let i = 0; i < view.length; i++)
26
26
  binary += String.fromCharCode(view[i]);
27
27
  return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
28
28
  }
29
- function base64UrlDecode(text) {
29
+ export function base64UrlDecode(text) {
30
30
  const padded = text.replace(/-/g, '+').replace(/_/g, '/').padEnd(Math.ceil(text.length / 4) * 4, '=');
31
31
  const binary = atob(padded);
32
32
  const out = new Uint8Array(binary.length);
@@ -0,0 +1,122 @@
1
+ import { loadSigningKey } from './media-url.js';
2
+ import { verifyUploadSignature, UPLOAD_MAX_BYTES, UPLOAD_KEY_PREFIX, acceptableUploadType } from './upload-url.js';
3
+ import { wholeMb } from './bytes.js';
4
+ function json(body, status) {
5
+ return new Response(JSON.stringify(body, null, 2), {
6
+ status,
7
+ headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' },
8
+ });
9
+ }
10
+ /** Every error names the fix — the caller here is a shell, not a model. */
11
+ function fail(status, error, hint) {
12
+ return json({ error, hint }, status);
13
+ }
14
+ /** A key this endpoint is willing to even verify a signature for: the upload
15
+ * prefix, safe segment charset, no dot-segments, no empty segments. */
16
+ export function wellFormedUploadKey(key) {
17
+ if (!key.startsWith(`${UPLOAD_KEY_PREFIX}/`))
18
+ return false;
19
+ const segments = key.split('/');
20
+ if (segments.length < 2)
21
+ return false;
22
+ return segments.every((s) => s.length > 0 && s !== '.' && s !== '..' && /^[A-Za-z0-9._-]+$/.test(s));
23
+ }
24
+ /**
25
+ * Build the `/put` handler. `basePath` is stripped from the pathname to get the
26
+ * R2 key, mirroring the `/media` handler so the two never drift.
27
+ */
28
+ export function createSignedPutHandler(env, basePath = '/put/') {
29
+ const maxBytes = env.maxBytes ?? UPLOAD_MAX_BYTES;
30
+ return async function handleSignedPut(request) {
31
+ if (request.method === 'OPTIONS') {
32
+ return new Response(null, { status: 204, headers: { allow: 'PUT, OPTIONS' } });
33
+ }
34
+ if (request.method !== 'PUT') {
35
+ return fail(405, `Method ${request.method} not allowed; signed upload URLs are PUT-only.`, 'Send the file bytes with: curl -sS -X PUT -H "Content-Type: <the signed type>" --data-binary @photo.jpg "<upload_url>"');
36
+ }
37
+ const url = new URL(request.url);
38
+ if (!url.pathname.startsWith(basePath))
39
+ return fail(404, 'Not found.', 'Use the exact upload_url returned by gemini_get_upload_url.');
40
+ const key = url.pathname
41
+ .slice(basePath.length)
42
+ .split('/')
43
+ .map((segment) => {
44
+ try {
45
+ return decodeURIComponent(segment);
46
+ }
47
+ catch {
48
+ return segment;
49
+ }
50
+ })
51
+ .join('/');
52
+ // Charset-checked BEFORE signature verification: the cross-protocol safety
53
+ // argument in upload-url.ts rests on keys never containing control chars.
54
+ if (!wellFormedUploadKey(key)) {
55
+ return fail(404, 'Not a valid upload key.', 'Use the exact upload_url returned by gemini_get_upload_url — the key path cannot be altered.');
56
+ }
57
+ const ct = url.searchParams.get('ct');
58
+ if (ct && !acceptableUploadType(ct)) {
59
+ return fail(415, `Unsupported content type "${ct}" — signed uploads accept raster image types only ` +
60
+ '(jpeg/png/webp/gif/avif/heic/heif/bmp/tiff; not SVG, which can carry scripts).', 'Mint the URL with a raster image content_type (e.g. image/jpeg).');
61
+ }
62
+ const now = env.now?.() ?? Date.now();
63
+ // Read-only key load: verification must never CREATE a signing secret (see
64
+ // loadSigningKey — a stale KV read would clobber the secret every
65
+ // outstanding media link was signed with).
66
+ const signingKey = await loadSigningKey(env.secret, env.store);
67
+ const check = signingKey
68
+ ? await verifyUploadSignature(signingKey, key, ct, url.searchParams.get('exp'), url.searchParams.get('sig'), now)
69
+ : { ok: false, reason: 'invalid' };
70
+ if (!check.ok) {
71
+ if (check.reason === 'expired') {
72
+ return fail(410, 'This upload URL has expired (they are valid for ~10 minutes).', 'Mint a fresh one with gemini_get_upload_url and retry.');
73
+ }
74
+ return fail(403, 'Invalid or missing upload signature.', 'Use the upload_url exactly as returned by gemini_get_upload_url, including the ct, exp and sig query parameters.');
75
+ }
76
+ const sentType = (request.headers.get('content-type') ?? '').split(';')[0].trim().toLowerCase();
77
+ if (sentType !== ct) {
78
+ return fail(415, `Content-Type "${sentType || '(none)'}" does not match the type this URL was minted for ("${ct}").`, `Send -H "Content-Type: ${ct}", or mint a new URL with the right content_type.`);
79
+ }
80
+ const declared = Number(request.headers.get('content-length'));
81
+ if (!request.headers.get('content-length') || !Number.isFinite(declared) || declared <= 0) {
82
+ return fail(411, 'Content-Length is required (and must be > 0).', 'curl sets it automatically with --data-binary @file.');
83
+ }
84
+ if (declared > maxBytes) {
85
+ return fail(413, `Upload is ${wholeMb(declared)}MB; the cap is ${wholeMb(maxBytes)}MB.`, 'Downscale the image before uploading — a reference photo does not need to be full-resolution.');
86
+ }
87
+ if (!request.body) {
88
+ return fail(400, 'Request had no body.', 'Send the file bytes as the request body, e.g. --data-binary @photo.jpg.');
89
+ }
90
+ // Enforce the cap on bytes actually READ, not the declared length — a
91
+ // hostile client can under-declare and stream more.
92
+ const chunks = [];
93
+ let total = 0;
94
+ const reader = request.body.getReader();
95
+ for (;;) {
96
+ const { done, value } = await reader.read();
97
+ if (done)
98
+ break;
99
+ total += value.byteLength;
100
+ if (total > maxBytes) {
101
+ await reader.cancel().catch(() => { });
102
+ return fail(413, `Upload exceeded the ${wholeMb(maxBytes)}MB cap while being read.`, 'Downscale the image before uploading.');
103
+ }
104
+ chunks.push(value);
105
+ }
106
+ const body = new Uint8Array(total);
107
+ let offset = 0;
108
+ for (const chunk of chunks) {
109
+ body.set(chunk, offset);
110
+ offset += chunk.byteLength;
111
+ }
112
+ await env.bucket.put(key, body, { httpMetadata: { contentType: ct ?? 'application/octet-stream' } });
113
+ return json({
114
+ r2_key: key,
115
+ size_bytes: total,
116
+ content_type: ct,
117
+ hint: `Uploaded. Reference it by r2_key: pass it in images_r2_keys on a generation tool (master_images_r2_keys on gemini_image_set), ` +
118
+ `save it permanently with gemini_save_character, or convert it to a ~48h Gemini Files API reference with gemini_upload_file({ r2_key }). ` +
119
+ `Uploads follow the connector's media retention schedule (MEDIA_TTL_DAYS, default 7 days); saved characters are kept indefinitely.`,
120
+ }, 201);
121
+ };
122
+ }
@@ -45,7 +45,7 @@ export async function tenantIdFor(secret) {
45
45
  // prefix would make every URL read `/media/media/…`.
46
46
  const KEY_PREFIX_DEFAULT = 'gen';
47
47
  /** Object-key-safe name: no slashes, no dot-segments, never empty. */
48
- function safeKeySegment(base) {
48
+ export function safeKeySegment(base) {
49
49
  const cleaned = base
50
50
  .replace(/[^A-Za-z0-9._-]+/g, '-')
51
51
  .replace(/\.{2,}/g, '-')
@@ -67,18 +67,27 @@ export function createR2Sink(bucket, opts) {
67
67
  const publicBase = opts.publicBaseUrl?.replace(/\/+$/, '');
68
68
  const signedBase = opts.signedBaseUrl?.replace(/\/+$/, '');
69
69
  const ttlMs = opts.urlTtlMs ?? 0;
70
- /** `<prefix>/` or `<prefix>/<tenant>/` — every key this sink may touch. */
71
- let ownPrefixCache;
72
- const ownPrefix = () => (ownPrefixCache ??= (async () => {
73
- const tenant = typeof opts.tenant === 'function' ? await opts.tenant() : opts.tenant;
70
+ const readPrefixes = [prefix, ...(opts.readPrefixes ?? [])];
71
+ let tenantCache;
72
+ const tenantId = () => (tenantCache ??= Promise.resolve(typeof opts.tenant === 'function' ? opts.tenant() : opts.tenant));
73
+ /** `<prefix>/` or `<prefix>/<tenant>/` — where every WRITE goes. */
74
+ const ownPrefix = async () => {
75
+ const tenant = await tenantId();
74
76
  return tenant ? `${prefix}/${tenant}/` : `${prefix}/`;
75
- })());
77
+ };
76
78
  /**
77
- * Ownership gate for caller-supplied keys. A foreign key is refused without
78
- * touching storage, so the refusal is indistinguishable from a swept object.
79
- * With no tenant configured there is nothing to enforce (single-user shapes).
79
+ * Ownership gate for caller-supplied keys: this session's own namespace
80
+ * under any readable prefix (`gen/<tenant>/…`, plus e.g. `up/`/`lib/` on the
81
+ * Worker). A foreign key is refused without touching storage, so the refusal
82
+ * is indistinguishable from a swept object. With no tenant configured there
83
+ * is nothing to enforce (single-user shapes).
80
84
  */
81
- const owned = async (key) => !opts.tenant || key.startsWith(await ownPrefix());
85
+ const owned = async (key) => {
86
+ if (!opts.tenant)
87
+ return true;
88
+ const tenant = await tenantId();
89
+ return readPrefixes.some((p) => key.startsWith(`${p}/${tenant}/`));
90
+ };
82
91
  return {
83
92
  kind: 'r2',
84
93
  persistsFiles: false,
@@ -102,14 +111,16 @@ export function createR2Sink(bucket, opts) {
102
111
  return undefined;
103
112
  return describe(key, now().getTime() + ttlMs);
104
113
  },
105
- async persist(items, _opts) {
114
+ async persist(items, persistOpts) {
106
115
  // `mediaExt` is pure string work but lives in images.ts next to node:fs
107
116
  // imports; the dynamic import keeps that module off a Worker's eager
108
117
  // module graph.
109
118
  const { mediaExt } = await import('../images.js');
110
119
  const keyBase = await ownPrefix();
111
120
  const day = now().toISOString().slice(0, 10);
112
- const expiresAtMs = now().getTime() + ttlMs;
121
+ // Per-persist TTL override, clamped so a link never outlives its object.
122
+ const effectiveTtlMs = Math.min(persistOpts.urlTtlMs ?? ttlMs, opts.maxUrlTtlMs ?? Infinity);
123
+ const expiresAtMs = now().getTime() + effectiveTtlMs;
113
124
  const refs = [];
114
125
  for (const it of items) {
115
126
  const key = `${keyBase}${day}/${randomId()}-${safeKeySegment(it.base)}.${mediaExt(it.mimeType)}`;