@vzn/vx-cache-server 0.0.650 → 0.0.651

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/PROTOCOL.md CHANGED
@@ -24,9 +24,9 @@ are refused; there is no events route.
24
24
 
25
25
  The server never reads a hash or an artifact's bytes. It only stores
26
26
  them and compares hashes for equality, so a client may send them
27
- encrypted (a keyed hash for names, a cipher for bytes). No ref goes on
28
- the wire plain: a scope is its ref's hash (§ Keys), and the server
29
- hashes the token's ref the same way to find where a write goes.
27
+ encrypted (a keyed hash for names, a cipher for bytes). A scope is its
28
+ ref, percent-encoded (§ Keys), and the server encodes the token's ref
29
+ the same way to find where a write goes.
30
30
 
31
31
  ## Repository
32
32
 
@@ -61,9 +61,13 @@ alone decides what it reads and where it writes, by the server's
61
61
  become, else its `ref`; on GitLab its `ref_path`.
62
62
  - A `pull_request_target`, `issue_comment` or `workflow_run` run writes
63
63
  nothing (a write is `403`): its `ref` is the base while it may run a
64
- pull request's code. Nor does a GitLab `merge_request_event` pipeline unless its
65
- `source_project_path` (GitLab 19.5+) names its own project: a fork's
66
- runs in the parent project with the fork's branch as its `ref_path`.
64
+ pull request's code. Nor does a GitLab run whose `source_project_path`
65
+ or `job_project_path` names another project, whatever its
66
+ `pipeline_source`: a fork's merge request, and a child pipeline of one,
67
+ runs in the parent project with the fork's branch as its `ref_path`. A
68
+ `merge_request_event` pipeline must name its source project (GitLab 19.5+).
69
+ - A ref over 512 characters percent-encoded writes nothing (a write is
70
+ `403`): no read could name its scope (§ Keys).
67
71
 
68
72
  A bad token is `401`. A server with `ALLOW` empty does not start; a
69
73
  Worker, which cannot refuse to, refuses every request: `401`,
@@ -75,8 +79,10 @@ so a client keeps the remote for its next request.
75
79
  ## Keys
76
80
 
77
81
  An artifact lives in one scope, and a key names both: `{ scope, hash }`.
78
- `scope` is a ref's hash: the first 128 bits of the SHA-256 of its UTF-8
79
- (`refs/heads/main`), 32 lowercase hex digits; a plain ref is `400`.
82
+ `scope` is a ref, percent-encoded as JavaScript's `encodeURIComponent`
83
+ does (`refs/heads/main` is `refs%2Fheads%2Fmain`), at most 512
84
+ characters. Any other spelling (a plain ref, another encoding of it, a
85
+ name not under `refs/`) is `400`, so one ref is one scope.
80
86
  `GET` and `HEAD` name one scope in `vx-scope`; a check names
81
87
  its scopes in its body.
82
88
  `hash` is 1 to 128 hex digits. A read is exact: the server answers the scope named and no
package/README.md CHANGED
@@ -109,18 +109,29 @@ subrequests a request, and `MAX_UPLOAD_BYTES` your plan's request body.
109
109
  The server lowers neither. A download is read through the data center's cache: an
110
110
  artifact never changes, so after R2 serves it once, that data center
111
111
  answers it without R2 (free on every plan). Whether R2 holds an
112
- artifact (a check, a `HEAD`, an upload's skip) is asked of R2 alone: the
113
- data center's copy can outlive R2's, and a writer told "held" there
114
- would skip an upload another data center then cannot read. An upload
115
- drops the copy in the data center it reaches: R2 had let those bytes go.
112
+ artifact (a check, a `HEAD`, an upload's skip) is asked of R2, never
113
+ of that copy: it can outlive R2's, and a writer told "held" there would
114
+ skip an upload another data center then cannot read. An upload drops
115
+ the copy in the data center it reaches: R2 had let those bytes go.
116
+
117
+ Set `EXPIRY_DAYS` to the bucket's lifecycle rule (below) and R2's
118
+ "held" is kept in the data center's cache too: an upload, R2's
119
+ answer to a lookup, or a download R2 serves marks the artifact held,
120
+ in the background, until the rule may drop it, counted from its upload. A repeat check or `HEAD` in that data center then
121
+ skips R2. Only "held" is kept: a miss always asks R2, so a later
122
+ upload is never hidden. Unset, every lookup asks R2. A larger value
123
+ than the rule is wrong: a mark would outlive the artifact. After
124
+ deleting artifacts by hand, a mark may still say "held" for up to
125
+ `EXPIRY_DAYS`; vx then misses the download and runs the task.
116
126
 
117
127
  ## Expiry
118
128
 
119
129
  The bucket's own lifecycle rule expires artifacts, by age since they
120
130
  were written; the server runs no sweep. An artifact's object name is
121
- `<issuer>/<repository>/<scope>/<hash>`, the first three each hashed (a
122
- client sends a scope as its ref's hash, never the ref), so one rule
123
- for the whole bucket is the one there is, for example 1 day:
131
+ `<issuer>/<repository>/<ref>/<hash>`, each a readable folder
132
+ (`https%3A%2F%2Ftoken.actions.githubusercontent.com/acme%2Fapp/refs%2Fheads%2Fmain/<hash>`),
133
+ so deleting one branch's prefix prunes that branch alone. One age rule
134
+ covers the bucket, for example 1 day:
124
135
 
125
136
  ```sh
126
137
  bunx wrangler r2 bucket lifecycle add vx-cache all '' --expire-days 1
@@ -145,9 +156,9 @@ over a bucket of your own (`src/index.ts`):
145
156
  (`{ maxCheckHashes, maxUploadBytes }`) over
146
157
  `DEFAULT_LIMITS`.
147
158
  - `blobName(issuer, repository, scope, hash)` is an artifact's object name,
148
- `<issuer>/<repository>/<scope>/<hash>`, issuer and repository each the first 128 bits
149
- of their SHA-256 in hex and `scope` the ref's hash as a client sends
150
- it (PROTOCOL.md § Keys), so no segment is a plain value.
159
+ `<issuer>/<repository>/<scope>/<hash>`, issuer and repository each
160
+ percent-encoded and `scope` the encoded ref as a client sends it
161
+ (PROTOCOL.md § Keys), so a slash in any part never starts a folder.
151
162
  - `ALLOW` and the CI token check are [`@vzn/vx-devtools`](https://github.com/vznjs/vx/tree/main/packages/vx-devtools)'s,
152
163
  the check every vx server shares.
153
164
  - `SCOPE_HEADER` (`vx-scope`) and `LIMIT_HEADERS` are the header names
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vzn/vx-cache-server",
3
- "version": "0.0.650",
3
+ "version": "0.0.651",
4
4
  "license": "MIT",
5
5
  "description": "A remote cache server for vx that you host: on Bun or Docker with an S3-compatible bucket, or on Cloudflare Workers with R2.",
6
6
  "keywords": [
@@ -45,7 +45,7 @@
45
45
  "miniflare: tests/worker.test.ts runs src/worker.ts in workerd with R2, as Workers deploy it."
46
46
  ],
47
47
  "dependencies": {
48
- "@vzn/vx-devtools": "0.0.650"
48
+ "@vzn/vx-devtools": "0.0.651"
49
49
  },
50
50
  "repository": {
51
51
  "type": "git",
package/src/protocol.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  // what a request carries. Every string a client sends is opaque to the
3
3
  // server, which only compares it, so a client can send it encrypted.
4
4
 
5
- /** The scope (a ref's hash) a download reads: `GET /v1/artifacts/<hash>`. */
5
+ /** The scope (a ref, percent-encoded) a download reads: `GET /v1/artifacts/<hash>`. */
6
6
  export const SCOPE_HEADER = 'vx-scope'
7
7
 
8
8
  /**
package/src/server.ts CHANGED
@@ -3,14 +3,7 @@
3
3
  // an artifact is one object, named by its issuer, repository name, scope and
4
4
  // hash, and a read names the exact scope it wants (2026-10-25).
5
5
 
6
- import {
7
- type Access,
8
- type Authorize,
9
- errorJson,
10
- hashPart,
11
- notAllowed,
12
- Refusal,
13
- } from '@vzn/vx-devtools'
6
+ import { type Access, type Authorize, errorJson, notAllowed, Refusal } from '@vzn/vx-devtools'
14
7
  import { isCount, isHash, LIMIT_HEADERS, SCOPE_HEADER } from './protocol.js'
15
8
  import { blobName, type Blobs } from './store.js'
16
9
 
@@ -64,8 +57,22 @@ export function limitsFrom(env: object): Partial<Limits> {
64
57
  /** A held artifact's bytes never change (2026-10-25). */
65
58
  const IMMUTABLE = 'private, max-age=31536000, immutable'
66
59
 
67
- /** A scope on the wire: its ref's `hashPart`, so no ref is ever sent or stored plain (2026-10-25). */
68
- const SCOPE = /^[0-9a-f]{32}$/
60
+ /**
61
+ * A scope on the wire: a ref (`refs/…`), percent-encoded as
62
+ * `encodeURIComponent` does, its folder's name (2026-11-03). One spelling
63
+ * per ref, so no two scopes name one folder; at most 512 characters, so
64
+ * a name stays under a bucket's 1024-byte key.
65
+ */
66
+ const SCOPE_MAX = 512
67
+ const isScope = (scope: string): boolean => {
68
+ if (scope.length > SCOPE_MAX || !scope.startsWith('refs%2F')) return false
69
+ try {
70
+ return encodeURIComponent(decodeURIComponent(scope)) === scope
71
+ } catch {
72
+ // quiet: a broken escape (`%E0`) is a bad scope, which the caller refuses `400`.
73
+ return false
74
+ }
75
+ }
69
76
 
70
77
  export function createCacheServer(opts: ServerOptions): (req: Request) => Promise<Response> {
71
78
  const { blobs, authorize } = opts
@@ -74,14 +81,17 @@ export function createCacheServer(opts: ServerOptions): (req: Request) => Promis
74
81
  Object.entries(LIMIT_HEADERS).map(([k, h]) => [h, String(limits[k as keyof Limits])]),
75
82
  )
76
83
 
77
- /** The scope a client names: a ref's hash, read as it is. */
84
+ /** The scope a client names: a ref, percent-encoded, read as it is. */
78
85
  const scopeOf = (scope: unknown): string => {
79
- if (typeof scope !== 'string' || !SCOPE.test(scope)) {
80
- throw new Refusal(400, "scope: a ref's hash, 32 hex digits (PROTOCOL.md § Keys)")
86
+ if (typeof scope !== 'string' || !isScope(scope)) {
87
+ throw new Refusal(
88
+ 400,
89
+ `scope: a ref, percent-encoded, up to ${SCOPE_MAX} characters (PROTOCOL.md § Keys)`,
90
+ )
81
91
  }
82
92
  return scope
83
93
  }
84
- const nameOf = (access: Access, scope: string, hash: string): Promise<string> =>
94
+ const nameOf = (access: Access, scope: string, hash: string): string =>
85
95
  blobName(access.issuer, access.repository, scope, hash)
86
96
  /** A read of the bucket: one it fails is its fault, `502`, never the server's `500`. */
87
97
  const read = async <T>(ask: Promise<T>): Promise<T> => {
@@ -93,9 +103,17 @@ export function createCacheServer(opts: ServerOptions): (req: Request) => Promis
93
103
  }
94
104
 
95
105
  /** The token's own scope, the one place it writes. */
96
- const writeScope = (access: Access): Promise<string> => {
106
+ const writeScope = (access: Access): string => {
97
107
  if (access.write === undefined) throw new Refusal(403, 'this request reads only')
98
- return hashPart(access.write)
108
+ const scope = encodeURIComponent(access.write)
109
+ // Stored, it was a folder no read can name: every read's scope is ≤ SCOPE_MAX.
110
+ if (scope.length > SCOPE_MAX) {
111
+ throw new Refusal(
112
+ 403,
113
+ `this job's ref is ${scope.length} characters percent-encoded, over the ${SCOPE_MAX} a scope takes: this request reads only`,
114
+ )
115
+ }
116
+ return scope
99
117
  }
100
118
 
101
119
  /**
@@ -103,14 +121,15 @@ export function createCacheServer(opts: ServerOptions): (req: Request) => Promis
103
121
  * the order to read them; up to `max` lookups, hashes times scopes.
104
122
  */
105
123
  async function queryOf(req: Request, max: number) {
106
- const body = await readJson(req, max * 140 + 100)
124
+ // Each lookup a hash (≤ 128) or a scope (≤ SCOPE_MAX), quoted and comma'd.
125
+ const body = await readJson(req, max * (SCOPE_MAX + 140) + 100)
107
126
  const hashes = field(body, 'hashes')
108
127
  const scopes = field(body, 'scopes')
109
128
  if (!Array.isArray(hashes) || !hashes.every((h) => typeof h === 'string' && isHash(h))) {
110
129
  throw new Refusal(400, 'hashes: a list of 1 to 128 hex digits each')
111
130
  }
112
131
  if (!Array.isArray(scopes) || scopes.length === 0) {
113
- throw new Refusal(400, "scopes: a list of refs' hashes, in the order to read them")
132
+ throw new Refusal(400, 'scopes: a list of refs, percent-encoded, in the order to read them')
114
133
  }
115
134
  if (hashes.length * scopes.length > max) {
116
135
  throw new Refusal(400, `hashes × scopes: up to ${max} lookups`)
@@ -122,7 +141,7 @@ export function createCacheServer(opts: ServerOptions): (req: Request) => Promis
122
141
  if (!isHash(hash)) throw new Refusal(400, 'a hash is 1 to 128 hex digits')
123
142
  const access = await authorize(req)
124
143
  const scope = scopeOf(req.headers.get(SCOPE_HEADER))
125
- const blob = await read(blobs.get(await nameOf(access, scope, hash)))
144
+ const blob = await read(blobs.get(nameOf(access, scope, hash)))
126
145
  if (blob === null) return errorJson(404, 'no artifact under this key')
127
146
  return new Response(blob.body, {
128
147
  headers: {
@@ -139,7 +158,7 @@ export function createCacheServer(opts: ServerOptions): (req: Request) => Promis
139
158
  if (!isHash(hash)) throw new Refusal(400, 'a hash is 1 to 128 hex digits')
140
159
  const access = await authorize(req)
141
160
  const scope = scopeOf(req.headers.get(SCOPE_HEADER))
142
- const size = await read(blobs.size(await nameOf(access, scope, hash)))
161
+ const size = await read(blobs.size(nameOf(access, scope, hash)))
143
162
  if (size === null) return new Response(null, { status: 404 })
144
163
  return new Response(null, {
145
164
  headers: { 'content-length': String(size), 'cache-control': IMMUTABLE },
@@ -153,7 +172,7 @@ export function createCacheServer(opts: ServerOptions): (req: Request) => Promis
153
172
  const { hashes, scopes } = await queryOf(req, limits.maxCheckHashes)
154
173
  const sizes = await Promise.all(
155
174
  hashes.map((hash) =>
156
- Promise.all(scopes.map(async (s) => read(blobs.size(await nameOf(access, s, hash))))),
175
+ Promise.all(scopes.map((s) => read(blobs.size(nameOf(access, s, hash))))),
157
176
  ),
158
177
  )
159
178
  const held = Object.fromEntries(
@@ -175,7 +194,7 @@ export function createCacheServer(opts: ServerOptions): (req: Request) => Promis
175
194
  async function put(req: Request, hash: string): Promise<Response> {
176
195
  if (!isHash(hash)) throw new Refusal(400, 'a hash is 1 to 128 hex digits')
177
196
  const access = await authorize(req)
178
- const name = await nameOf(access, await writeScope(access), hash)
197
+ const name = nameOf(access, writeScope(access), hash)
179
198
  const length = contentLength(req)
180
199
  if (length > limits.maxUploadBytes) {
181
200
  throw new Refusal(413, `an upload is at most ${limits.maxUploadBytes} bytes`)
package/src/store.ts CHANGED
@@ -1,10 +1,8 @@
1
1
  // Where a server keeps what it is sent: one bucket (S3 or R2), no
2
2
  // database (2026-10-25). An artifact is one object whose name is its
3
- // issuer, repository name, scope and hash and whose size is the bucket's
3
+ // issuer, repository name, ref and hash and whose size is the bucket's
4
4
  // own, so any number of nodes share a bucket and nothing else.
5
5
 
6
- import { hashPart } from '@vzn/vx-devtools'
7
-
8
6
  /** A blob's bytes and its size, as the bucket holds them. */
9
7
  export interface Blob {
10
8
  readonly body: ReadableStream<Uint8Array>
@@ -22,14 +20,11 @@ export interface Blobs {
22
20
  }
23
21
 
24
22
  /**
25
- * An artifact's object name, `<issuer>/<id>/<scope>/<hash>`
26
- * (2026-10-25): issuer and id each hashed (`hashPart`), `scope` the ref's
27
- * hash the client sent, so no segment is a plain value and a ref's slashes
28
- * or any byte stay one segment. The server knows no tier.
23
+ * An artifact's object name, `<issuer>/<repository>/<ref>/<hash>`, each
24
+ * a readable folder (2026-11-03: pruning one branch is deleting its
25
+ * prefix). Issuer and repository are percent-encoded here and `scope` is
26
+ * the ref as the wire sends it, already encoded, so a slash or any other
27
+ * byte never starts another folder.
29
28
  */
30
- export const blobName = async (
31
- issuer: string,
32
- id: string,
33
- scope: string,
34
- hash: string,
35
- ): Promise<string> => `${await hashPart(issuer)}/${await hashPart(id)}/${scope}/${hash}`
29
+ export const blobName = (issuer: string, repository: string, scope: string, hash: string): string =>
30
+ `${encodeURIComponent(issuer)}/${encodeURIComponent(repository)}/${scope}/${hash}`
package/src/worker.ts CHANGED
@@ -1,16 +1,26 @@
1
1
  // `vx-cache-server` on Cloudflare Workers: artifacts in R2, nothing else.
2
- // `wrangler.jsonc` deploys it; ALLOW, AUDIENCE and the limits are vars in
2
+ // `wrangler.jsonc` deploys it; ALLOW, AUDIENCE, EXPIRY_DAYS and the limits are vars in
3
3
  // it (README.md § On Cloudflare Workers).
4
4
 
5
- import { type Authorize, errorJson, oidcAuth, parseAllow, Refusal } from '@vzn/vx-devtools'
5
+ import {
6
+ type Authorize,
7
+ cachedFetchJson,
8
+ errorJson,
9
+ type KeysCache,
10
+ oidcAuth,
11
+ parseAllow,
12
+ Refusal,
13
+ } from '@vzn/vx-devtools'
6
14
  import { createCacheServer, DEFAULT_LIMITS, type Limits, limitsFrom } from './server.js'
7
15
  import type { Blobs } from './store.js'
8
16
 
9
17
  /** The parts of the Workers runtime this file uses, so it needs no types package. */
10
18
  export interface R2Bucket {
11
19
  put(name: string, body: ReadableStream<Uint8Array>): Promise<unknown>
12
- get(name: string): Promise<{ body: ReadableStream<Uint8Array>; size: number } | null>
13
- head(name: string): Promise<{ size: number } | null>
20
+ get(
21
+ name: string,
22
+ ): Promise<{ body: ReadableStream<Uint8Array>; size: number; uploaded: Date } | null>
23
+ head(name: string): Promise<{ size: number; uploaded: Date } | null>
14
24
  }
15
25
  /** The data center's cache (Workers Cache API): free, and per data center. */
16
26
  interface WorkersCache {
@@ -18,7 +28,7 @@ interface WorkersCache {
18
28
  put(key: Request, res: Response): Promise<void>
19
29
  delete(key: Request): Promise<boolean>
20
30
  }
21
- declare const caches: { open(name: string): Promise<WorkersCache> }
31
+ declare const caches: { open(name: string): Promise<WorkersCache>; default: KeysCache }
22
32
  export interface Context {
23
33
  waitUntil(p: Promise<unknown>): void
24
34
  }
@@ -38,21 +48,45 @@ export interface Env {
38
48
  /** A string, or a number as wrangler.jsonc may write it. */
39
49
  readonly MAX_CHECK_HASHES?: string | number
40
50
  readonly MAX_UPLOAD_BYTES?: string | number
51
+ /** The bucket's lifecycle rule in days; set, "held" is kept that long from each upload. */
52
+ readonly EXPIRY_DAYS?: string | number
41
53
  }
42
54
 
43
55
  /** An object's key in the data center's cache. */
44
56
  const keyOf = (name: string): Request => new Request(`https://vx-cache.blobs/${name}`)
57
+ /** The key of the mark that R2 holds an object, and its size. */
58
+ const heldOf = (name: string): Request => new Request(`https://vx-cache.held/${name}`)
59
+ const SIZE = 'vx-size'
45
60
 
46
61
  /**
47
62
  * R2, read through the data center's cache: an artifact never changes
48
63
  * under its name while R2 holds it, so a copy needs no expiry of its own;
49
64
  * an upload drops the copy, of bytes R2 has since let go. The server has
50
65
  * checked the token and named the object before a read reaches here.
66
+ *
67
+ * With `expiryDays`, the bucket's lifecycle rule, whether R2 holds an
68
+ * object is kept there too, once R2 takes, heads or serves it, until the
69
+ * rule may drop it: counted from its upload, so a mark never outlives
70
+ * R2's copy. Only "held" is kept: a
71
+ * kept "missing" would hide an upload that lands later.
51
72
  */
52
-
53
- export function r2Blobs(bucket: R2Bucket, cache: Promise<WorkersCache>, ctx: Context): Blobs {
73
+ export function r2Blobs(
74
+ bucket: R2Bucket,
75
+ cache: Promise<WorkersCache>,
76
+ ctx: Context,
77
+ expiryDays?: number,
78
+ ): Blobs {
79
+ const mark = (name: string, size: number, uploaded: number): void => {
80
+ if (expiryDays === undefined) return
81
+ const left = Math.floor((uploaded + expiryDays * 86_400_000 - Date.now()) / 1000)
82
+ if (left <= 0) return
83
+ const headers = { [SIZE]: String(size), 'cache-control': `max-age=${left}` }
84
+ ctx.waitUntil(cache.then((c) => c.put(heldOf(name), new Response(null, { headers }))))
85
+ }
54
86
  return {
55
87
  async put(name, body, size) {
88
+ // Before R2's own upload time, so the mark ends no later than R2's copy.
89
+ const started = Date.now()
56
90
  // R2 takes a stream of known length; this one errors at any other.
57
91
  const fixed = new FixedLengthStream(size)
58
92
  const piped = body.pipeTo(fixed.writable)
@@ -70,13 +104,16 @@ export function r2Blobs(bucket: R2Bucket, cache: Promise<WorkersCache>, ctx: Con
70
104
  // R2 takes a name only once it holds none: a copy here is of bytes
71
105
  // R2 expired, a corrupt one too, and outlived the new ones a year.
72
106
  await (await cache).delete(keyOf(name))
107
+ mark(name, size, started)
73
108
  },
74
109
  get: async (name) => {
75
110
  const key = keyOf(name)
76
111
  const hit = await (await cache).match(key)
112
+ // No mark from this copy: it can outlive R2's.
77
113
  if (hit?.body) return { body: hit.body, size: Number(hit.headers.get('content-length')) }
78
114
  const o = await bucket.get(name)
79
115
  if (o === null) return null
116
+ mark(name, o.size, o.uploaded.getTime())
80
117
  // The copy is R2 read again, never a tee of the client's body: a tee
81
118
  // on Workers reads at its slower branch's pace, and the cache write
82
119
  // held a download for 19 minutes (main, 2026-10-28).
@@ -93,12 +130,21 @@ export function r2Blobs(bucket: R2Bucket, cache: Promise<WorkersCache>, ctx: Con
93
130
  )
94
131
  return { body: o.body, size: o.size }
95
132
  },
96
- // R2 alone answers whether it holds an object: a check, a HEAD and a
97
- // PUT's skip. The data center's copy outlives R2's (expiry, a wipe), so
98
- // a writer told "held" there skipped its upload and a reader in another
99
- // data center found nothing (2026-10-27: a DTE worker's save, then the
100
- // run's read, a 404).
101
- size: async (name) => (await bucket.head(name))?.size ?? null,
133
+ // R2 answers whether it holds an object (a check, a HEAD and a PUT's
134
+ // skip), or a mark it made: never the data center's copy of the bytes,
135
+ // which outlives R2's (expiry, a wipe), so a writer told "held" there
136
+ // skipped its upload and a reader in another data center found nothing
137
+ // (2026-10-27: a DTE worker's save, then the run's read, a 404).
138
+ size: async (name) => {
139
+ if (expiryDays !== undefined) {
140
+ const held = await (await cache).match(heldOf(name))
141
+ if (held !== undefined) return Number(held.headers.get(SIZE))
142
+ }
143
+ const o = await bucket.head(name)
144
+ if (o === null) return null
145
+ mark(name, o.size, o.uploaded.getTime())
146
+ return o.size
147
+ },
102
148
  }
103
149
  }
104
150
 
@@ -121,8 +167,34 @@ function workerLimits(env: Env): Limits {
121
167
  return { ...DEFAULT_LIMITS, maxCheckHashes: CHECK_LOOKUPS, ...limitsFrom(env) }
122
168
  }
123
169
 
170
+ /** EXPIRY_DAYS: unset, nothing is kept of R2's answer. */
171
+ function expiryOf(env: Env): number | undefined {
172
+ const v = env.EXPIRY_DAYS
173
+ if (v === undefined || v === '') return undefined
174
+ const n =
175
+ typeof v === 'number' ? v : typeof v === 'string' && /^\d+$/.test(v) ? Number(v) : Number.NaN
176
+ if (!(Number.isSafeInteger(n) && n > 0)) {
177
+ throw new Error(
178
+ `EXPIRY_DAYS: the bucket's lifecycle rule, whole days above 0, not ${JSON.stringify(v)}`,
179
+ )
180
+ }
181
+ return n
182
+ }
183
+
124
184
  /** What a request shares per isolate and settings. */
125
- let made: { key: string; authorize: Authorize; limits: Limits } | undefined
185
+ let made:
186
+ | { key: string; authorize: Authorize; limits: Limits; expiryDays: number | undefined }
187
+ | undefined
188
+
189
+ /**
190
+ * The issuer's keys through the data center's cache: a fresh isolate read
191
+ * GitHub on its first token, and one slow answer failed it 502 (2026-10-29).
192
+ * `caches.default` is read at each call, as the bucket is per request.
193
+ */
194
+ const edgeKeys = cachedFetchJson({
195
+ match: (url) => caches.default.match(url),
196
+ put: (url, res) => caches.default.put(url, res),
197
+ })
126
198
 
127
199
  export default {
128
200
  async fetch(req: Request, env: Env, ctx: Context): Promise<Response> {
@@ -136,6 +208,7 @@ export default {
136
208
  env.READ_ACCESS,
137
209
  env.MAX_CHECK_HASHES,
138
210
  env.MAX_UPLOAD_BYTES,
211
+ env.EXPIRY_DAYS,
139
212
  ].join('\0')
140
213
  if (made?.key !== key) {
141
214
  let allow
@@ -157,11 +230,14 @@ export default {
157
230
  return errorJson(500, `ALLOW: ${(err as Error).message}`)
158
231
  }
159
232
  let authorize
233
+ let expiryDays
160
234
  try {
161
235
  limits = workerLimits(env)
236
+ expiryDays = expiryOf(env)
162
237
  authorize = oidcAuth({
163
238
  allow,
164
239
  readAccess: env.READ_ACCESS,
240
+ fetchJson: edgeKeys,
165
241
  // jose matches aud exactly: a pasted newline refused every
166
242
  // token as the wrong audience.
167
243
  ...(env.AUDIENCE ? { audience: env.AUDIENCE.trim() } : {}),
@@ -169,11 +245,11 @@ export default {
169
245
  } catch (err) {
170
246
  return errorJson(500, (err as Error).message)
171
247
  }
172
- made = { key, authorize, limits }
248
+ made = { key, authorize, limits, expiryDays }
173
249
  }
174
250
  // The bucket and cache are this request's: a Worker's I/O objects do not outlive it.
175
251
  return createCacheServer({
176
- blobs: r2Blobs(env.BLOBS, caches.open('vx-cache'), ctx),
252
+ blobs: r2Blobs(env.BLOBS, caches.open('vx-cache'), ctx, made.expiryDays),
177
253
  authorize: made.authorize,
178
254
  limits: made.limits,
179
255
  })(req)
package/wrangler.jsonc CHANGED
@@ -10,6 +10,8 @@
10
10
  // refused with "set ALLOW". AUDIENCE: the aud a
11
11
  // CI token names, unset the Worker's own origin. MAX_CHECK_HASHES
12
12
  // and MAX_UPLOAD_BYTES set the limits, a string or a number (PROTOCOL.md).
13
+ // EXPIRY_DAYS: the bucket's lifecycle rule in days, so a repeat check
14
+ // skips R2 (README.md § On Cloudflare Workers); never above the rule.
13
15
  "vars": { "ALLOW": "" },
14
16
  // Expiry is the bucket's lifecycle rules (README.md § Expiry).
15
17
  "r2_buckets": [{ "binding": "BLOBS", "bucket_name": "vx-cache" }],