@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/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +70 -0
- package/dist/bundle.js +214 -33
- package/dist/client.js +18 -0
- package/dist/connector-origin.js +34 -0
- package/dist/jobs.js +77 -1
- package/dist/media-cleanup.js +75 -0
- package/dist/media-endpoint.js +160 -0
- package/dist/media-url.js +135 -0
- package/dist/storage/media.js +72 -12
- package/dist/tools/files.js +67 -6
- package/dist/tools/generate.js +42 -21
- package/dist/tools/interact.js +5 -3
- package/dist/tools/set.js +41 -5
- package/dist/tools/shared.js +33 -2
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/server.json +2 -2
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
|
-
|
|
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
|
+
}
|
package/dist/storage/media.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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 = `${
|
|
73
|
-
|
|
74
|
-
await
|
|
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: () =>
|
|
80
|
-
? 'Generated media was uploaded to R2
|
|
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
|
-
:
|
|
83
|
-
'
|
|
84
|
-
|
|
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
|
}
|