@chrischall/gemini-mcp 1.2.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/jobs.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { randomUUID, createHash } from 'node:crypto';
2
2
  import { textResult, McpToolError } from '@chrischall/mcp-utils';
3
+ import { downloadFilename } from './media-endpoint.js';
3
4
  /** Recently-completed jobs are reusable by key for this long, then evicted. */
4
5
  const JOB_TTL_MS = 600_000; // 10 min
5
6
  /** Hard cap on retained entries (oldest-settled evicted first). */
@@ -22,6 +23,72 @@ function isExpired(entry, now) {
22
23
  export function fingerprintRequest(toolName, parts) {
23
24
  return createHash('sha256').update(`${toolName}\u0000${JSON.stringify(parts)}`).digest('hex');
24
25
  }
26
+ /**
27
+ * Re-mint any media URLs a recorded result carries, before it is replayed.
28
+ *
29
+ * A recorded result can outlive the signed URLs inside it — and after a change
30
+ * to the result shape it can even carry the OLD shape entirely. Either way the
31
+ * caller gets something that looks right and does not work. Re-signing from the
32
+ * `r2_key`, which is stable, fixes both: an expired link becomes a live one,
33
+ * and a legacy entry gains the `url`/`curl_hint` fields the current shape has.
34
+ *
35
+ * Best-effort by design. A replay that cannot be refreshed is still a valid
36
+ * replay — the point of idempotency is not billing twice, and failing here
37
+ * would trade a stale URL for no result at all.
38
+ */
39
+ async function refreshMedia(result, sink) {
40
+ const first = result.content?.[0];
41
+ if (!sink?.resign || !first || first.type !== 'text')
42
+ return result;
43
+ let body;
44
+ try {
45
+ body = JSON.parse(first.text);
46
+ }
47
+ catch {
48
+ return result;
49
+ }
50
+ const entries = Array.isArray(body.media) ? body.media : [];
51
+ if (!entries.some((e) => typeof e.r2_key === 'string'))
52
+ return result;
53
+ // Per ENTRY, not per key: filtering first would shift every assignment after
54
+ // an entry without an r2_key and truncate the flat list below.
55
+ const refreshed = await Promise.all(entries.map(async (e) => {
56
+ if (typeof e.r2_key !== 'string')
57
+ return undefined;
58
+ try {
59
+ return await sink.resign(e.r2_key);
60
+ }
61
+ catch {
62
+ return undefined;
63
+ }
64
+ }));
65
+ if (refreshed.every((r) => r === undefined))
66
+ return result;
67
+ const mediaKeyName = ['images', 'videos', 'audios'].find((k) => Array.isArray(body[k]));
68
+ const refs = [];
69
+ refreshed.forEach((fresh, i) => {
70
+ if (!fresh) {
71
+ refs.push(String(entries[i]?.url ?? ''));
72
+ return;
73
+ }
74
+ // Drop the recorded expiry rather than carrying it over: a re-signed URL
75
+ // has its own deadline (or none, on a public-bucket sink), and the old one
76
+ // would mislabel a live link as expired.
77
+ const { expires_at: _stale, ...rest } = entries[i];
78
+ entries[i] = {
79
+ ...rest,
80
+ url: fresh.ref,
81
+ ...(fresh.expiresAt ? { expires_at: fresh.expiresAt } : {}),
82
+ curl_hint: `curl -sS -o ${downloadFilename(fresh.key ?? 'media')} "${fresh.ref}"`,
83
+ };
84
+ refs.push(fresh.ref);
85
+ });
86
+ body.media = entries;
87
+ if (mediaKeyName)
88
+ body[mediaKeyName] = refs;
89
+ body.media_urls_refreshed = true;
90
+ return { ...result, content: [{ ...first, text: JSON.stringify(body, null, 2) }, ...(result.content ?? []).slice(1)] };
91
+ }
25
92
  /** Clone `result`, adding `reused: true` + `reused_job_id` to its meta block. */
26
93
  function annotateReused(result, jobId) {
27
94
  const content = result.content ?? [];
@@ -58,6 +125,12 @@ function jobHandle(jobId, status) {
58
125
  * `client.session.jobs`.
59
126
  */
60
127
  export class JobRegistry {
128
+ /**
129
+ * Lets a replayed result have its media URLs re-minted. Set by the session
130
+ * that owns this registry; absent on stdio, where refs are file paths that
131
+ * never expire.
132
+ */
133
+ resigner;
61
134
  jobs = new Map();
62
135
  byKey = new Map(); // idempotencyKey -> jobId
63
136
  runningByFingerprint = new Map(); // fingerprint -> jobId (only while running)
@@ -126,7 +199,10 @@ export class JobRegistry {
126
199
  if (hit) {
127
200
  if (async)
128
201
  return jobHandle(hit.jobId, hit.status);
129
- return annotateReused(await hit.promise, hit.jobId);
202
+ // Refresh before annotating: a recorded result can outlive the signed
203
+ // URLs inside it, and replaying an expired link is its own kind of wrong
204
+ // answer — it looks like success.
205
+ return annotateReused(await refreshMedia(await hit.promise, this.resigner), hit.jobId);
130
206
  }
131
207
  const jobId = randomUUID();
132
208
  this.evict(now);
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Retention for generated media.
3
+ *
4
+ * Every hosted generation writes an object to R2 and nothing ever removed it,
5
+ * so the bucket grew without bound — and the signed links pointing into it are
6
+ * only useful for a couple of days anyway. A scheduled sweep keeps the two in
7
+ * step: objects live `MEDIA_TTL_DAYS` (default 7), and a signed URL's lifetime
8
+ * is clamped to that, so a link never outlives the object behind it.
9
+ *
10
+ * R2 lifecycle rules would be the cheaper mechanism, but they are configured on
11
+ * the bucket rather than in `wrangler.jsonc`, so a fresh deploy of this repo
12
+ * would silently have no retention at all. A cron in the Worker ships with the
13
+ * code.
14
+ */
15
+ const DEFAULT_MAX_OBJECTS = 5_000;
16
+ /**
17
+ * `gen/` is where media is written now; `media/` is where it was written before
18
+ * the rename that made URLs read `/media/gen/…` instead of `/media/media/…`.
19
+ * Both are swept — retention that ignores the existing backlog is not retention.
20
+ */
21
+ const LEGACY_AND_CURRENT_PREFIXES = ['gen/', 'media/'];
22
+ /** R2 accepts bulk deletes in batches; 1000 is the documented ceiling. */
23
+ const DELETE_BATCH = 1_000;
24
+ /** Days → ms, defaulting to 7 for anything missing or nonsensical. */
25
+ export function retentionMsFromDays(days) {
26
+ const n = Number(days);
27
+ return (Number.isFinite(n) && n > 0 ? n : 7) * 24 * 60 * 60 * 1000;
28
+ }
29
+ /**
30
+ * Delete generated media older than `retentionMs`. Returns counts rather than
31
+ * logging them, so the caller decides what to do with the numbers and the sweep
32
+ * itself stays testable.
33
+ */
34
+ export async function cleanupExpiredMedia(bucket, retentionMs, opts = {}) {
35
+ const now = opts.now?.() ?? Date.now();
36
+ const cutoff = now - retentionMs;
37
+ const maxObjects = opts.maxObjects ?? DEFAULT_MAX_OBJECTS;
38
+ const prefixes = opts.prefixes ?? LEGACY_AND_CURRENT_PREFIXES;
39
+ let scanned = 0;
40
+ let deleted = 0;
41
+ let truncated = false;
42
+ const doomed = [];
43
+ const flush = async (all) => {
44
+ while (doomed.length >= (all ? 1 : DELETE_BATCH)) {
45
+ const batch = doomed.splice(0, DELETE_BATCH);
46
+ await bucket.delete(batch);
47
+ deleted += batch.length;
48
+ }
49
+ };
50
+ for (const prefix of prefixes) {
51
+ let cursor;
52
+ for (;;) {
53
+ if (scanned >= maxObjects) {
54
+ truncated = true;
55
+ break;
56
+ }
57
+ const page = await bucket.list({ prefix, cursor, limit: Math.min(1000, maxObjects - scanned) });
58
+ for (const object of page.objects) {
59
+ scanned++;
60
+ if (object.uploaded.getTime() < cutoff)
61
+ doomed.push(object.key);
62
+ }
63
+ await flush(false);
64
+ if (!page.truncated)
65
+ break;
66
+ if (scanned >= maxObjects) {
67
+ truncated = true;
68
+ break;
69
+ }
70
+ cursor = page.cursor;
71
+ }
72
+ }
73
+ await flush(true);
74
+ return { scanned, deleted, truncated };
75
+ }
@@ -0,0 +1,160 @@
1
+ import { loadSigningKey, verifyMediaSignature } from './media-url.js';
2
+ /**
3
+ * Human-friendly download name from an object key.
4
+ *
5
+ * Keys look like `gen/<tenant>/2026-07-29/ab12cd34-a-cat.png`; the random component is
6
+ * there to make the key unique, not to be read. Stripping it turns a saved file
7
+ * into `a-cat.png`, which matters because the reliable way to show a user an
8
+ * image in a chat client is for the agent to download it and attach it — and an
9
+ * attachment called `ab12cd34-a-cat.png` looks like a machine artefact.
10
+ */
11
+ export function downloadFilename(key) {
12
+ const last = key.split('/').pop() || 'media';
13
+ const stripped = last.replace(/^[0-9a-f]{6,12}-/i, '');
14
+ return stripped || last;
15
+ }
16
+ /**
17
+ * Parse a single-range `Range: bytes=a-b` header. Multi-range is unsupported,
18
+ * and an unknown object size means no range handling at all (Range is advisory
19
+ * — `undefined` degrades to a full 200, never an error).
20
+ */
21
+ function parseRange(header, size) {
22
+ if (!header || size === undefined)
23
+ return undefined;
24
+ const match = /^bytes=(\d*)-(\d*)$/.exec(header.trim());
25
+ if (!match)
26
+ return undefined;
27
+ const [, startText, endText] = match;
28
+ if (startText === '' && endText === '')
29
+ return undefined;
30
+ if (startText === '') {
31
+ // Suffix range: the last N bytes.
32
+ const suffix = Number(endText);
33
+ if (!suffix)
34
+ return undefined;
35
+ const offset = Math.max(0, size - suffix);
36
+ return { offset, length: size - offset };
37
+ }
38
+ const offset = Number(startText);
39
+ if (offset >= size)
40
+ return 'unsatisfiable';
41
+ const end = endText === '' ? size - 1 : Number(endText);
42
+ if (end < offset)
43
+ return 'unsatisfiable';
44
+ return { offset, length: end - offset + 1 };
45
+ }
46
+ const MISSING = 'That media object no longer exists — it may have been cleaned up. Re-run the generation to get a fresh one.';
47
+ /**
48
+ * Response headers shared by GET, HEAD and 206. `Content-Disposition` carries a
49
+ * readable filename because the reliable way to show a user an image in a chat
50
+ * client is for the agent to download it and attach it.
51
+ */
52
+ function baseHeaders(key, object, size) {
53
+ const headers = new Headers({
54
+ 'content-type': object.httpMetadata?.contentType ?? 'application/octet-stream',
55
+ // The URL is signed and expiring, so it is safe to cache for its lifetime;
56
+ // `private` keeps shared caches from serving it to anyone else.
57
+ 'cache-control': 'private, max-age=3600',
58
+ 'content-disposition': `inline; filename="${downloadFilename(key)}"`,
59
+ // The bytes are model output being handed to a browser — never let a
60
+ // sniffed content type turn an image response into something executable.
61
+ 'x-content-type-options': 'nosniff',
62
+ // Range support is cheap on R2 and is what makes a resumable `curl -C -`
63
+ // work against a large video.
64
+ 'accept-ranges': 'bytes',
65
+ });
66
+ const etag = object.httpEtag ?? object.etag;
67
+ if (etag)
68
+ headers.set('etag', etag);
69
+ if (size !== undefined)
70
+ headers.set('content-length', String(size));
71
+ return headers;
72
+ }
73
+ function problem(status, message) {
74
+ return new Response(`${message}\n`, {
75
+ status,
76
+ headers: { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' },
77
+ });
78
+ }
79
+ /**
80
+ * Build the `/media` handler. `basePath` is stripped from the pathname to get
81
+ * the R2 key, so the key keeps its own prefix and the two never drift.
82
+ */
83
+ export function createMediaHandler(env, basePath = '/media/') {
84
+ return async function handleMedia(request) {
85
+ if (request.method !== 'GET' && request.method !== 'HEAD') {
86
+ return problem(405, `Method ${request.method} not allowed; media links are GET-only.`);
87
+ }
88
+ const url = new URL(request.url);
89
+ if (!url.pathname.startsWith(basePath))
90
+ return problem(404, 'Not found');
91
+ // decodeURIComponent per segment, mirroring how the URL was built. A key
92
+ // cannot contain `..` (the sink sanitises every segment), and R2 has no
93
+ // path traversal to exploit anyway — a key is an opaque string, not a path.
94
+ const key = url.pathname
95
+ .slice(basePath.length)
96
+ .split('/')
97
+ .map((segment) => {
98
+ try {
99
+ return decodeURIComponent(segment);
100
+ }
101
+ catch {
102
+ return segment;
103
+ }
104
+ })
105
+ .join('/');
106
+ if (!key)
107
+ return problem(404, 'Not found');
108
+ const now = env.now?.() ?? Date.now();
109
+ // Read-only: verification must never CREATE a signing secret. A stale KV
110
+ // read here would otherwise clobber the secret every outstanding link was
111
+ // signed with (see loadSigningKey). No secret ⇒ nothing was legitimately
112
+ // signed ⇒ refuse, without touching storage or the bucket.
113
+ const signingKey = await loadSigningKey(env.secret, env.store);
114
+ const check = signingKey
115
+ ? await verifyMediaSignature(signingKey, key, url.searchParams.get('exp'), url.searchParams.get('sig'), now)
116
+ : { ok: false, reason: 'invalid' };
117
+ if (!check.ok) {
118
+ if (check.reason === 'expired') {
119
+ return problem(410, 'This media link has expired. Generated media is retained for a limited time — re-run the generation to get a fresh link.');
120
+ }
121
+ return problem(403, 'Invalid or missing media signature. Media links must be used exactly as returned by the tool, including the exp and sig parameters.');
122
+ }
123
+ const rangeHeader = request.headers.get('range');
124
+ // HEAD is what a link-preview fetcher and `curl -I` send first; serve it
125
+ // from a metadata read where the bucket offers one, rather than opening a
126
+ // body we are about to throw away.
127
+ if (request.method === 'HEAD' && env.bucket.head) {
128
+ const meta = await env.bucket.head(key);
129
+ if (!meta)
130
+ return problem(404, MISSING);
131
+ return new Response(null, { status: 200, headers: baseHeaders(key, meta, meta.size) });
132
+ }
133
+ // Fetch whole and slice on the way out rather than pushing the range into
134
+ // R2: the objects here are single images and short clips, and a typed
135
+ // range option is not expressible against both the real binding and a test
136
+ // fake without casting away the very types that make this safe.
137
+ const object = await env.bucket.get(key);
138
+ if (!object)
139
+ return problem(404, MISSING);
140
+ const totalSize = object.size;
141
+ const range = parseRange(rangeHeader, totalSize);
142
+ if (range === 'unsatisfiable') {
143
+ return new Response('Requested range not satisfiable\n', {
144
+ status: 416,
145
+ headers: { 'content-range': `bytes */${totalSize ?? 0}`, 'content-type': 'text/plain; charset=utf-8' },
146
+ });
147
+ }
148
+ const headers = baseHeaders(key, object, totalSize);
149
+ if (request.method === 'HEAD')
150
+ return new Response(null, { status: 200, headers });
151
+ if (range && totalSize !== undefined && object.arrayBuffer) {
152
+ const length = Math.min(range.length, totalSize - range.offset);
153
+ const slice = (await object.arrayBuffer()).slice(range.offset, range.offset + length);
154
+ headers.set('content-length', String(slice.byteLength));
155
+ headers.set('content-range', `bytes ${range.offset}-${range.offset + slice.byteLength - 1}/${totalSize}`);
156
+ return new Response(slice, { status: 206, headers });
157
+ }
158
+ return new Response(object.body ?? null, { status: 200, headers });
159
+ };
160
+ }
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Signed URLs for generated media on the hosted connector.
3
+ *
4
+ * The problem these exist to solve: a chat client shows the user whatever the
5
+ * assistant *says*, and MCP inline image blocks are not that. On claude.ai and
6
+ * the mobile app the bytes arrive in the model's context and are never rendered
7
+ * — so a generation that billed successfully is invisible. A URL is the only
8
+ * portable answer: a human can tap it and an agent can curl it.
9
+ *
10
+ * That means the media route cannot sit behind the connector's OAuth, because
11
+ * a browser opening a link carries no bearer token. The signature IS the
12
+ * authorization: a URL is valid for one key until it expires, and nothing else
13
+ * in the bucket is reachable without one.
14
+ *
15
+ * Kept free of any Cloudflare import so the whole thing is testable under Node:
16
+ * WebCrypto is the only primitive used, and it exists on both runtimes.
17
+ */
18
+ /** How long a freshly minted media URL stays valid. Matches Files API retention. */
19
+ export const MEDIA_URL_TTL_MS = 48 * 60 * 60 * 1000;
20
+ /** KV key holding the lazily-generated signing secret (zero-config path). */
21
+ export const SIGNING_SECRET_KV_KEY = 'media:url-signing-secret';
22
+ function base64UrlEncode(bytes) {
23
+ const view = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
24
+ let binary = '';
25
+ for (let i = 0; i < view.length; i++)
26
+ binary += String.fromCharCode(view[i]);
27
+ return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
28
+ }
29
+ function base64UrlDecode(text) {
30
+ const padded = text.replace(/-/g, '+').replace(/_/g, '/').padEnd(Math.ceil(text.length / 4) * 4, '=');
31
+ const binary = atob(padded);
32
+ const out = new Uint8Array(binary.length);
33
+ for (let i = 0; i < binary.length; i++)
34
+ out[i] = binary.charCodeAt(i);
35
+ return out;
36
+ }
37
+ /** Import raw secret material as an HMAC key. */
38
+ async function importKey(material) {
39
+ return crypto.subtle.importKey('raw', new TextEncoder().encode(material), { name: 'HMAC', hash: 'SHA-256' }, false, [
40
+ 'sign',
41
+ 'verify',
42
+ ]);
43
+ }
44
+ /**
45
+ * The HMAC key for **signing**, creating one if this deployment has never
46
+ * signed anything.
47
+ *
48
+ * `MEDIA_URL_SECRET` wins when an operator sets one. Otherwise a random 32-byte
49
+ * secret is generated once and persisted in KV — which is what makes the whole
50
+ * feature work with **zero configuration**, the entire point of this change.
51
+ *
52
+ * Creating is confined to this function, and this function is called only when
53
+ * minting a URL. {@link loadSigningKey} is what the verify path uses. That
54
+ * split is load-bearing, not stylistic — see the warning there.
55
+ *
56
+ * Deliberately NOT cached in a module-level variable: `src/` holds no
57
+ * module-scope mutable state (see session.ts), and a KV read per sign is cheap
58
+ * next to an image generation. There is a narrow first-use race — two isolates
59
+ * generating simultaneously — so the value is re-read after writing and the
60
+ * stored one wins, collapsing the window to URLs minted in that same instant.
61
+ */
62
+ export async function resolveSigningKey(secret, store) {
63
+ let material = secret?.trim();
64
+ if (!material) {
65
+ material = (await store.get(SIGNING_SECRET_KV_KEY)) ?? undefined;
66
+ if (!material) {
67
+ const generated = base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)));
68
+ await store.put(SIGNING_SECRET_KV_KEY, generated);
69
+ // Re-read so a concurrent generator's value wins consistently for both.
70
+ material = (await store.get(SIGNING_SECRET_KV_KEY)) ?? generated;
71
+ }
72
+ }
73
+ return importKey(material);
74
+ }
75
+ /**
76
+ * The HMAC key for **verifying**. Read-only: returns `undefined` when no secret
77
+ * is stored, and never creates one.
78
+ *
79
+ * This is not a stylistic split. KV is eventually consistent — a `put` on one
80
+ * isolate is not immediately visible to another — so the verify path can and
81
+ * will see a miss for a secret that exists. If verification created-on-miss
82
+ * (as it did originally), that transient miss would mint a NEW secret and
83
+ * overwrite the one every outstanding link was signed with, permanently 403ing
84
+ * media that had been signed perfectly correctly. A self-inflicted denial of
85
+ * service, triggered by ordinary traffic, and irreversible once the old secret
86
+ * is gone.
87
+ *
88
+ * No secret stored ⇒ nothing was ever legitimately signed ⇒ refuse.
89
+ */
90
+ export async function loadSigningKey(secret, store) {
91
+ const material = secret?.trim() || (await store.get(SIGNING_SECRET_KV_KEY));
92
+ return material ? importKey(material) : undefined;
93
+ }
94
+ /** The bytes covered by a signature: the object key and the expiry, nothing else. */
95
+ function payload(key, expiresAtMs) {
96
+ return new TextEncoder().encode(`${key}\n${expiresAtMs}`);
97
+ }
98
+ /** Sign one object key for use until `expiresAtMs`. */
99
+ export async function signMediaKey(signingKey, key, expiresAtMs) {
100
+ return base64UrlEncode(await crypto.subtle.sign('HMAC', signingKey, payload(key, expiresAtMs)));
101
+ }
102
+ /**
103
+ * Check a `?exp=&sig=` pair against an object key.
104
+ *
105
+ * Verification goes through `crypto.subtle.verify` rather than a string
106
+ * comparison, so it is constant-time — a `===` on the signature would leak the
107
+ * expected value one byte at a time to anyone willing to time it.
108
+ */
109
+ export async function verifyMediaSignature(signingKey, key, exp, sig, nowMs) {
110
+ if (!exp || !sig)
111
+ return { ok: false, reason: 'missing' };
112
+ const expiresAtMs = Number(exp);
113
+ if (!Number.isFinite(expiresAtMs))
114
+ return { ok: false, reason: 'invalid' };
115
+ let valid;
116
+ try {
117
+ valid = await crypto.subtle.verify('HMAC', signingKey, base64UrlDecode(sig), payload(key, expiresAtMs));
118
+ }
119
+ catch {
120
+ return { ok: false, reason: 'invalid' };
121
+ }
122
+ // Signature first, then expiry: an expired-but-authentic link deserves a
123
+ // different message from a forged one, and saying "expired" about a forgery
124
+ // would confirm the attacker got the signature right.
125
+ if (!valid)
126
+ return { ok: false, reason: 'invalid' };
127
+ if (nowMs > expiresAtMs)
128
+ return { ok: false, reason: 'expired' };
129
+ return { ok: true };
130
+ }
131
+ /** `<base>/<key>?exp=…&sig=…` — the URL handed back to the caller. */
132
+ export function buildMediaUrl(baseUrl, key, expiresAtMs, signature) {
133
+ const path = key.split('/').map(encodeURIComponent).join('/');
134
+ return `${baseUrl.replace(/\/+$/, '')}/${path}?exp=${expiresAtMs}&sig=${signature}`;
135
+ }
@@ -30,13 +30,20 @@ export function createDiskSink() {
30
30
  // Sequential on purpose: uniquePath checks the filesystem, so writing two
31
31
  // colliding names concurrently would race onto the same path.
32
32
  for (const it of items)
33
- refs.push(await writeMedia(dir, it.base, it.base64, it.mimeType));
33
+ refs.push({ ref: await writeMedia(dir, it.base, it.base64, it.mimeType) });
34
34
  return refs;
35
35
  },
36
36
  note: () => undefined,
37
37
  };
38
38
  }
39
- const KEY_PREFIX_DEFAULT = 'media';
39
+ /** Stable, non-reversible short tenant id from a per-session secret. */
40
+ export async function tenantIdFor(secret) {
41
+ const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(secret));
42
+ return [...new Uint8Array(digest).slice(0, 6)].map((b) => b.toString(16).padStart(2, '0')).join('');
43
+ }
44
+ // `gen`, not `media`: the objects are served at `/media/<key>`, and a `media`
45
+ // prefix would make every URL read `/media/media/…`.
46
+ const KEY_PREFIX_DEFAULT = 'gen';
40
47
  /** Object-key-safe name: no slashes, no dot-segments, never empty. */
41
48
  function safeKeySegment(base) {
42
49
  const cleaned = base
@@ -57,30 +64,83 @@ export function createR2Sink(bucket, opts) {
57
64
  const prefix = opts.prefix ?? KEY_PREFIX_DEFAULT;
58
65
  const now = opts.now ?? (() => new Date());
59
66
  const randomId = opts.randomId ?? (() => crypto.randomUUID().slice(0, 8));
60
- const base = opts.publicBaseUrl?.replace(/\/+$/, '');
67
+ const publicBase = opts.publicBaseUrl?.replace(/\/+$/, '');
68
+ const signedBase = opts.signedBaseUrl?.replace(/\/+$/, '');
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;
74
+ return tenant ? `${prefix}/${tenant}/` : `${prefix}/`;
75
+ })());
76
+ /**
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).
80
+ */
81
+ const owned = async (key) => !opts.tenant || key.startsWith(await ownPrefix());
61
82
  return {
62
83
  kind: 'r2',
63
84
  persistsFiles: false,
85
+ async read(key) {
86
+ if (!bucket.get || !(await owned(key)))
87
+ return undefined;
88
+ const object = await bucket.get(key);
89
+ if (!object)
90
+ return undefined;
91
+ return {
92
+ bytes: new Uint8Array(await object.arrayBuffer()),
93
+ mimeType: object.httpMetadata?.contentType ?? 'application/octet-stream',
94
+ };
95
+ },
96
+ async resign(key) {
97
+ if (!(await owned(key)))
98
+ return undefined;
99
+ // Only for objects that still exist — re-signing a swept key would hand
100
+ // back a link that 404s, which is no better than the expired one.
101
+ if (bucket.get && !(await bucket.get(key)))
102
+ return undefined;
103
+ return describe(key, now().getTime() + ttlMs);
104
+ },
64
105
  async persist(items, _opts) {
65
106
  // `mediaExt` is pure string work but lives in images.ts next to node:fs
66
107
  // imports; the dynamic import keeps that module off a Worker's eager
67
108
  // module graph.
68
109
  const { mediaExt } = await import('../images.js');
110
+ const keyBase = await ownPrefix();
69
111
  const day = now().toISOString().slice(0, 10);
112
+ const expiresAtMs = now().getTime() + ttlMs;
70
113
  const refs = [];
71
114
  for (const it of items) {
72
- const key = `${prefix}/${day}/${randomId()}-${safeKeySegment(it.base)}.${mediaExt(it.mimeType)}`;
73
- const bytes = base64ToBytes(it.base64);
74
- await bucket.put(key, bytes, { httpMetadata: { contentType: it.mimeType } });
75
- refs.push(base ? `${base}/${key}` : `r2://${opts.bucketName ?? 'media'}/${key}`);
115
+ const key = `${keyBase}${day}/${randomId()}-${safeKeySegment(it.base)}.${mediaExt(it.mimeType)}`;
116
+ await bucket.put(key, base64ToBytes(it.base64), { httpMetadata: { contentType: it.mimeType } });
117
+ refs.push(await describe(key, expiresAtMs));
76
118
  }
77
119
  return refs;
78
120
  },
79
- note: () => base
80
- ? 'Generated media was uploaded to R2; the values above are public URLs, not local file paths. ' +
121
+ note: () => publicBase
122
+ ? 'Generated media was uploaded to R2 and the values above are public URLs (not local file paths) — open or download them directly. ' +
81
123
  'This hosted connector has no filesystem, so `output_dir` is ignored and no <image>.json sidecar is written — capture interaction_id from this result to chain further turns.'
82
- : 'Generated media was uploaded to R2, which is not publicly served: the values above are object refs (r2://bucket/key), NOT fetchable URLs. ' +
83
- 'Set MEDIA_PUBLIC_BASE_URL on the Worker to get URLs, or pass inline: true to receive the bytes directly. ' +
84
- 'This hosted connector has no filesystem, so `output_dir` is ignored and no <image>.json sidecar is written — capture interaction_id from this result to chain further turns.',
124
+ : signedBase
125
+ ? 'Generated media is served by this connector at the signed URLs above — open them in a browser or fetch them with curl; no auth header is needed, the signature is in the link. ' +
126
+ 'They expire (see media[].expires_at), and the objects behind them are cleaned up on a retention schedule. ' +
127
+ 'This hosted connector has no filesystem, so `output_dir` is ignored and no <image>.json sidecar is written — capture interaction_id from this result to chain further turns.'
128
+ : 'Generated media was stored, but this connector has no public media URL configured and no signing route available, so the values above are bare object keys rather than fetchable links. ' +
129
+ 'Set MEDIA_PUBLIC_BASE_URL on the Worker, or pass inline: true to receive the bytes directly.',
85
130
  };
131
+ /** One stored object → the ref the caller sees, plus its key and expiry. */
132
+ async function describe(key, expiresAtMs) {
133
+ // A bucket served directly (r2.dev or a custom domain) needs no signature.
134
+ if (publicBase)
135
+ return { ref: `${publicBase}/${key}`, key };
136
+ if (signedBase && opts.sign) {
137
+ const { buildMediaUrl } = await import('../media-url.js');
138
+ const signature = await opts.sign(key, expiresAtMs);
139
+ return { ref: buildMediaUrl(signedBase, key, expiresAtMs, signature), key, expiresAt: new Date(expiresAtMs).toISOString() };
140
+ }
141
+ // Neither configured. Unreachable in the Worker (which always passes its
142
+ // own origin), so this is a misconfiguration rather than a mode — say so
143
+ // explicitly rather than returning something that looks like a filename.
144
+ return { ref: key, key, unavailable: true };
145
+ }
86
146
  }