@supersuit/suggest-a-change 0.1.13 → 0.2.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.
Files changed (61) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +162 -7
  3. package/dist/client/adapters.d.ts +27 -0
  4. package/dist/client/context.d.ts +61 -0
  5. package/dist/client/context.js +279 -0
  6. package/dist/client/detail.d.ts +2 -2
  7. package/dist/client/detail.js +12 -8
  8. package/dist/client/entry.js +2 -1
  9. package/dist/client/index.d.ts +2 -0
  10. package/dist/client/index.js +1 -0
  11. package/dist/client/mine.js +4 -2
  12. package/dist/client/runtime.js +3 -0
  13. package/dist/client/seen.js +9 -7
  14. package/dist/client/sheet.js +64 -4
  15. package/dist/client/words.d.ts +10 -1
  16. package/dist/client/words.js +11 -2
  17. package/dist/contract.d.mts +32 -22
  18. package/dist/contract.mjs +127 -37
  19. package/dist/github/api.d.ts +100 -0
  20. package/dist/github/api.js +164 -0
  21. package/dist/github/compose.d.ts +152 -0
  22. package/dist/github/compose.js +309 -0
  23. package/dist/github/filer.d.ts +72 -0
  24. package/dist/github/filer.js +89 -0
  25. package/dist/github/hosts.d.ts +41 -0
  26. package/dist/github/hosts.js +154 -0
  27. package/dist/github/index.d.ts +12 -0
  28. package/dist/github/index.js +9 -0
  29. package/dist/github/privacy.d.ts +34 -0
  30. package/dist/github/privacy.js +142 -0
  31. package/dist/github/relay.d.ts +159 -0
  32. package/dist/github/relay.js +734 -0
  33. package/dist/server/geo.d.ts +17 -0
  34. package/dist/server/geo.js +35 -0
  35. package/dist/server/handlers.d.ts +54 -3
  36. package/dist/server/handlers.js +143 -41
  37. package/dist/server/index.d.ts +3 -1
  38. package/dist/server/index.js +1 -0
  39. package/package.json +5 -1
  40. package/src/client/adapters.ts +18 -1
  41. package/src/client/context.ts +243 -0
  42. package/src/client/detail.tsx +13 -8
  43. package/src/client/entry.tsx +2 -1
  44. package/src/client/index.ts +2 -0
  45. package/src/client/mine.tsx +7 -3
  46. package/src/client/runtime.ts +3 -0
  47. package/src/client/seen.ts +8 -6
  48. package/src/client/sheet.tsx +78 -4
  49. package/src/client/styles.css +5 -0
  50. package/src/client/words.ts +15 -2
  51. package/src/contract.mjs +110 -37
  52. package/src/github/api.ts +169 -0
  53. package/src/github/compose.ts +354 -0
  54. package/src/github/filer.ts +96 -0
  55. package/src/github/hosts.ts +117 -0
  56. package/src/github/index.ts +15 -0
  57. package/src/github/privacy.ts +131 -0
  58. package/src/github/relay.ts +637 -0
  59. package/src/server/geo.ts +32 -0
  60. package/src/server/handlers.ts +158 -45
  61. package/src/server/index.ts +3 -1
@@ -0,0 +1,637 @@
1
+ // RUN YOUR OWN SUGGESTION RELAY. One handler on a server the repo's owner runs, holding the private key of a GitHub App
2
+ // the owner created (contents: write, issues: write) and installed on the repos suggestions go to. It does four things,
3
+ // chosen by the last segment of the path it is mounted on (.../file, .../follow-up, .../status, .../attach):
4
+ //
5
+ // - file: for someone with NO access to the repo, not even read. The relay runs the privacy catch itself, saves the
6
+ // files to the suggestion-attachments branch, then opens the issue AS THE APP with the filer's name (never their
7
+ // account), and remembers which identity filed which issue.
8
+ // - follow-up and status: only for the identity that filed that report.
9
+ // - attach: for a filer who opened the issue themselves but cannot push; the relay proves they could file there by
10
+ // reading the issue and requiring it to carry the report.
11
+ //
12
+ // Web-standard only: Request, Response, fetch, crypto.subtle.
13
+ import { CEILINGS, isRepoName, isReportId, validContact, validateReport } from '../contract.mjs'
14
+ import { cappedText, json } from '../server/http.ts'
15
+ import { ACCEPTED_TYPES, canonicalType, sameType, sniffType } from '../server/sniff.ts'
16
+ import { withPlace, type GeoHook } from '../server/geo.ts'
17
+ import { addFollowUp, fileIssue, github, GitHubError, issueStates, issueStatus, type Fetch } from './api.ts'
18
+ import { BODY_LIMIT, composeIssue, endsWithFiledBy } from './compose.ts'
19
+ import { ATTACHMENTS_BRANCH, attachmentUrl, branchHost } from './hosts.ts'
20
+ import { privacyCatch, redact, type Person } from './privacy.ts'
21
+
22
+ /** A voice recording's ceiling; the same number as the server half's VOICE_FILE_BYTES (a test holds them together). */
23
+ export const RELAY_VOICE_BYTES = 25 * 2 ** 20
24
+ export interface RelayLimits {
25
+ /** Different reports one identity may file or attach to in a day. */
26
+ perPersonPerDay: number
27
+ /** Bytes of files one identity may send in a day, across every action. */
28
+ bytesPerPersonPerDay: number
29
+ images: number
30
+ imageBytes: number
31
+ videos: number
32
+ videoBytes: number
33
+ /** Recordings per report: the report's voice notes and those added to it later. */
34
+ voice: number
35
+ voiceBytes: number
36
+ /** How long after an issue closed a file may still be attached to it. */
37
+ closedGraceMs: number
38
+ /** The largest file taken inside a request body. A larger one is staged (upload-url), or refused without staging.
39
+ * Serverless hosts cap a request body (Vercel at about 4.5 MB), so the default leaves room for base64 and the rest. */
40
+ inlineBytes: number
41
+ }
42
+ export const RELAY_LIMITS: RelayLimits = {
43
+ perPersonPerDay: CEILINGS.perPersonPerDay,
44
+ bytesPerPersonPerDay: 300 * 2 ** 20,
45
+ images: CEILINGS.images, imageBytes: CEILINGS.imageBytes,
46
+ videos: CEILINGS.videos, videoBytes: CEILINGS.videoBytes,
47
+ voice: 60, voiceBytes: RELAY_VOICE_BYTES,
48
+ closedGraceMs: 7 * 24 * 3600_000,
49
+ inlineBytes: 3 * 2 ** 20,
50
+ }
51
+
52
+ export interface FiledRecord { account: string; reportId: string; number: number; url: string }
53
+ /** A day's allowance held for one request, until it is committed (it happened) or released (it did not). */
54
+ export type Reservation = { ok: true; token: string } | { ok: false; over: 'reports' | 'bytes' }
55
+ /** What the relay remembers. Every method is ATOMIC in a real store (one transaction or a conditional write): a check
56
+ * and the change it guards are one step, so two requests at once can never both pass a cap of one. */
57
+ export interface RelayUsage {
58
+ /** Holds today's allowance for this request: one more report (unless `reportId` was already counted today for
59
+ * `account`) and `bytes` more bytes, refusing when either would pass its limit. Nothing is held on a refusal. */
60
+ reserve(account: string, day: string, reportId: string, bytes: number, limits: { reports: number; bytes: number }): Promise<Reservation>
61
+ /** The request that reserved this happened: keep it. */
62
+ commit(token: string): Promise<void>
63
+ /** The request that reserved this did not happen: give it back. */
64
+ release(token: string): Promise<void>
65
+ /** A report belongs to the first identity that files or attaches for it: 'ok' for that identity, 'taken' for anyone else. */
66
+ bind(repo: string, reportId: string, account: string): Promise<'ok' | 'taken'>
67
+ /** Starts filing a report, first caller wins: 'go', or 'busy' while another filing of it runs, or the record of the issue it already became. */
68
+ /** Starts filing a report under a lease of `leaseMs`, first caller wins: 'go', or 'busy' while another filing of it holds a
69
+ * live lease, or the record of the issue it already became. A lease past its time is taken over (its process died). */
70
+ begin(repo: string, reportId: string, account: string, leaseMs: number): Promise<{ state: 'go' } | { state: 'busy' } | ({ state: 'filed' } & FiledRecord)>
71
+ /** upload-url issued a link for this staging key, for at most `bytes`: a later read is bounded by it. */
72
+ stage(key: string, bytes: number): Promise<void>
73
+ /** The bytes upload-url held for this key, or null when it never issued one. */
74
+ staged(key: string): Promise<number | null>
75
+ /** The staged file was written and discarded: forget its key. */
76
+ unstage(key: string): Promise<void>
77
+ /** The filing that began succeeded: keep the record, by report and by issue number. */
78
+ finish(repo: string, record: FiledRecord): Promise<void>
79
+ /** The filing that began failed: forget it, so a retry can begin again. */
80
+ abandon(repo: string, reportId: string): Promise<void>
81
+ /** Who filed this issue through the relay, or null. */
82
+ filer(repo: string, number: number): Promise<FiledRecord | null>
83
+ }
84
+ /** In memory, so ONE SERVER INSTANCE ONLY: a platform that runs several instances (most serverless hosts) needs a store
85
+ * backed by its database, or the caps, the idempotency and who-filed-what are per instance and lost on restart. Each
86
+ * method below changes what it checks before it yields, which is what makes it atomic in one process. */
87
+ export function memoryUsage({ now = () => Date.now() }: { now?: () => number } = {}): RelayUsage {
88
+ const stagedSizes = new Map<string, number>()
89
+ const days = new Map<string, Set<string>>()
90
+ const bytes = new Map<string, number>()
91
+ const held = new Map<string, { k: string; reportId: string; counted: boolean; bytes: number }>()
92
+ const owners = new Map<string, string>()
93
+ const filings = new Map<string, { state: 'busy'; until: number } | ({ state: 'filed' } & FiledRecord)>()
94
+ const issues = new Map<string, FiledRecord>()
95
+ let n = 0
96
+ return {
97
+ async reserve(a, d, r, b, lim) {
98
+ const k = `${a}\u0000${d}`
99
+ const set = days.get(k) ?? new Set<string>()
100
+ const counted = !set.has(r)
101
+ if (counted && set.size >= lim.reports) return { ok: false, over: 'reports' }
102
+ if ((bytes.get(k) ?? 0) + b > lim.bytes) return { ok: false, over: 'bytes' }
103
+ if (counted) { set.add(r); days.set(k, set) }
104
+ bytes.set(k, (bytes.get(k) ?? 0) + b)
105
+ const token = `t${++n}`
106
+ held.set(token, { k, reportId: r, counted, bytes: b })
107
+ return { ok: true, token }
108
+ },
109
+ async commit(t) { held.delete(t) },
110
+ async release(t) {
111
+ const h = held.get(t); if (!h) return
112
+ held.delete(t)
113
+ if (h.counted) days.get(h.k)?.delete(h.reportId)
114
+ bytes.set(h.k, Math.max(0, (bytes.get(h.k) ?? 0) - h.bytes))
115
+ },
116
+ async bind(repo, r, a) { const k = `${repo}#${r}`; const had = owners.get(k); if (had === undefined) { owners.set(k, a); return 'ok' } return had === a ? 'ok' : 'taken' },
117
+ async begin(repo, r, _a, leaseMs) {
118
+ const k = `${repo}#${r}`
119
+ const had = filings.get(k)
120
+ if (had?.state === 'filed') return had
121
+ if (had?.state === 'busy' && had.until > now()) return { state: 'busy' }
122
+ filings.set(k, { state: 'busy', until: now() + leaseMs })
123
+ return { state: 'go' }
124
+ },
125
+ async stage(k, b) { stagedSizes.set(k, b) },
126
+ async staged(k) { return stagedSizes.get(k) ?? null },
127
+ async unstage(k) { stagedSizes.delete(k) },
128
+ async finish(repo, rec) { filings.set(`${repo}#${rec.reportId}`, { state: 'filed', ...rec }); issues.set(`${repo}#${rec.number}`, rec) },
129
+ async abandon(repo, r) { const k = `${repo}#${r}`; if (filings.get(k)?.state === 'busy') filings.delete(k) },
130
+ async filer(repo, num) { return issues.get(`${repo}#${num}`) ?? null },
131
+ }
132
+ }
133
+
134
+ /** Who is calling, as the host knows them. The issue's name, role, app and contact come from here and never from the
135
+ * request: a request can say whether to share the contact (it sends `report.who.contact`), never what it is. */
136
+ export interface RelayIdentity {
137
+ account: string
138
+ /** The name the issue says it was filed by; "Someone" without one. */
139
+ name?: string
140
+ role?: 'operator' | 'member' | 'maintainer'
141
+ /** The GitHub login this person files under, when the host knows it: `attach` to an issue the relay did not file
142
+ * is accepted only when that issue was opened by this login. */
143
+ githubLogin?: string
144
+ /** How to reach them, shared only when the request asks (the person left it ticked). */
145
+ contact?: { email?: string; phone?: string; handle?: string }
146
+ }
147
+
148
+ export interface SuggestionRelayOptions {
149
+ /** The GitHub App the relay acts as: its numeric id and its private key (PEM, PKCS#1 as GitHub issues it, or PKCS#8). */
150
+ app: { id: number | string; privateKey: string }
151
+ /** Who is calling, from the request (a session, an API key, whatever the server already uses), or null. */
152
+ identity(req: Request): Promise<RelayIdentity | null>
153
+ /** The repos this identity may file on; null (or no function) means any repo the App is installed on. */
154
+ repos?(account: string): string[] | null | Promise<string[] | null>
155
+ /** What the privacy catch looks for besides credentials, emails and phones: third parties, and secrets the host knows. */
156
+ privacy?: { people?: Person[]; secrets?: string[] }
157
+ /** Roughly where the filer was, from the request (see the server half's geo hooks); added to the issue's context. */
158
+ geo?: GeoHook
159
+ /** The apps this relay files for, by the id a request names (`app`): the issue's app name and its `app:<label>`
160
+ * come from here. A request naming an app not in the table is refused; without a table no request names one. */
161
+ apps?: Record<string, { name: string; label: string }>
162
+ /** Where files too large for one request body wait: the filer PUTs each to a one-time signed link (`upload-url`),
163
+ * and `file` or `attach` names it as `staged`. Without it, only files up to `inlineBytes` can be sent. */
164
+ staging?: StagingAdapter
165
+ /** May lower any limit, never raise one. */
166
+ limits?: Partial<RelayLimits>
167
+ usage?: RelayUsage
168
+ fetch?: Fetch
169
+ now?: () => number
170
+ /** Default https://api.github.com */
171
+ api?: string
172
+ }
173
+ /** The 0.2.0 name, for a relay that only attaches. */
174
+ export type AttachRelayOptions = SuggestionRelayOptions
175
+
176
+ /** A bucket the relay controls, for files past a request body limit. `uploadUrl` answers a one-time signed PUT that
177
+ * can only create `key` once, with this type and at most these bytes: on Google Cloud Storage a V4 signed URL for PUT
178
+ * with `x-goog-if-generation-match: 0` and `x-goog-content-length-range: 0,<bytes>` as signed headers; on S3 a
179
+ * presigned PUT with `If-None-Match: *` and the content type and length signed. `read` answers what arrived (or null),
180
+ * `discard` deletes it once the relay has written it to GitHub. */
181
+ export interface StagingAdapter {
182
+ uploadUrl(key: string, o: { type: string; bytes: number }): Promise<{ url: string; headers?: Record<string, string> }>
183
+ read(key: string): Promise<ReadableStream<Uint8Array> | Uint8Array | ArrayBuffer | null>
184
+ discard(key: string): Promise<void>
185
+ }
186
+
187
+ /** How long a filing holds its report before another try may take it over (its process is presumed dead). */
188
+ export const FILING_LEASE_MS = 5 * 60_000
189
+ const FILE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/
190
+ const B64 = /^[A-Za-z0-9+/]+={0,2}$/
191
+ const ACTIONS = ['file', 'follow-up', 'status', 'attach', 'upload-url'] as const
192
+ type Action = (typeof ACTIONS)[number]
193
+
194
+ const enc = new TextEncoder()
195
+ const b64url = (bytes: Uint8Array) => {
196
+ let s = ''
197
+ for (const b of bytes) s += String.fromCharCode(b)
198
+ return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
199
+ }
200
+ function fromBase64(s: string): Uint8Array | null {
201
+ if (!s || s.length % 4 !== 0 || !B64.test(s)) return null
202
+ try { const bin = atob(s); const out = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); return out } catch { return null }
203
+ }
204
+
205
+ /** DER length bytes. */
206
+ const derLen = (n: number) => (n < 0x80 ? [n] : (() => { const b: number[] = []; while (n > 0) { b.unshift(n & 0xff); n >>= 8 } return [0x80 | b.length, ...b] })())
207
+ /** Wraps a PKCS#1 RSA key (what GitHub hands out) in the PKCS#8 envelope crypto.subtle imports. */
208
+ function pkcs1To8(pkcs1: Uint8Array): Uint8Array {
209
+ const algo = [0x30, 0x0d, 0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01, 0x01, 0x05, 0x00]
210
+ const octet = [0x04, ...derLen(pkcs1.length)]
211
+ const inner = [0x02, 0x01, 0x00, ...algo, ...octet]
212
+ const total = inner.length + pkcs1.length
213
+ const out = new Uint8Array(1 + derLen(total).length + total)
214
+ out.set([0x30, ...derLen(total), ...inner]); out.set(pkcs1, out.length - pkcs1.length)
215
+ return out
216
+ }
217
+ async function importKey(pem: string): Promise<CryptoKey> {
218
+ const body = pem.replace(/-----(BEGIN|END) [A-Z ]+-----/g, '').replace(/\s+/g, '')
219
+ const der = fromBase64(body)
220
+ if (!der) throw new Error('the App private key is not a PEM key')
221
+ const pkcs8 = /BEGIN RSA PRIVATE KEY/.test(pem) ? pkcs1To8(der) : der
222
+ return crypto.subtle.importKey('pkcs8', pkcs8 as BufferSource, { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' }, false, ['sign'])
223
+ }
224
+ /** The App's own JWT (RS256), good for nine minutes, issued a minute early for clock drift, as GitHub asks. */
225
+ async function appJwt(key: CryptoKey, id: string, nowMs: number): Promise<string> {
226
+ const t = Math.floor(nowMs / 1000)
227
+ const head = b64url(enc.encode(JSON.stringify({ alg: 'RS256', typ: 'JWT' })))
228
+ const body = b64url(enc.encode(JSON.stringify({ iat: t - 60, exp: t + 540, iss: id })))
229
+ const sig = new Uint8Array(await crypto.subtle.sign('RSASSA-PKCS1-v1_5', key, enc.encode(`${head}.${body}`)))
230
+ return `${head}.${body}.${b64url(sig)}`
231
+ }
232
+
233
+ /** What kind of attachment a name in the folder is, by its extension (the names the module's sheet writes). */
234
+ const kindByName = (name: string): 'image' | 'video' | 'voice' | null => {
235
+ const ext = name.split('.').pop()?.toLowerCase() ?? ''
236
+ if (['png', 'jpg', 'jpeg', 'webp', 'heic', 'heif', 'gif'].includes(ext)) return 'image'
237
+ if (['mp4', 'm4v', 'mov', 'webm'].includes(ext) && !name.startsWith('voice-') && !name.startsWith('added-')) return 'video'
238
+ if (['m4a', 'aac', 'mp3', 'wav', 'ogg', 'opus', 'webm', 'mp4', 'bin'].includes(ext)) return 'voice'
239
+ return null
240
+ }
241
+ const kindOf = (type: string): 'image' | 'video' | 'voice' => (type.startsWith('image/') ? 'image' : type.startsWith('video/') ? 'video' : 'voice')
242
+
243
+ /** A file ready to write: its real type and bytes, and (when it was staged) the key to discard once it is written. */
244
+ type Checked = { name: string; type: string; kind: 'image' | 'video' | 'voice'; bytes: Uint8Array; stagedKey?: string }
245
+ const stageKey = (account: string, reportId: string, name: string) => `staging/${encodeURIComponent(account)}/${reportId}/${name}`
246
+ /** Everything a staged object holds, refusing past `max` as it is read. */
247
+ async function bytesOf(got: ReadableStream<Uint8Array> | Uint8Array | ArrayBuffer, max: number): Promise<Uint8Array | null> {
248
+ if (got instanceof Uint8Array) return got.length > max ? null : got
249
+ if (got instanceof ArrayBuffer) return got.byteLength > max ? null : new Uint8Array(got)
250
+ const parts: Uint8Array[] = []
251
+ let n = 0
252
+ const reader = got.getReader()
253
+ for (;;) {
254
+ const { done, value } = await reader.read()
255
+ if (done) break
256
+ n += value.length
257
+ if (n > max) { await reader.cancel(); return null }
258
+ parts.push(value)
259
+ }
260
+ const out = new Uint8Array(n); let at = 0
261
+ for (const p of parts) { out.set(p, at); at += p.length }
262
+ return out
263
+ }
264
+
265
+ export function createSuggestionRelay(opts: SuggestionRelayOptions, only?: Action): (req: Request) => Promise<Response> {
266
+ const limits: RelayLimits = { ...RELAY_LIMITS }
267
+ for (const [k, v] of Object.entries(opts.limits ?? {}) as [keyof RelayLimits, number][]) {
268
+ if (typeof v !== 'number' || !(k in RELAY_LIMITS)) continue
269
+ if (v > RELAY_LIMITS[k]) throw new Error(`limits.${k} may lower the relay's ${RELAY_LIMITS[k]}, never raise it`)
270
+ limits[k] = v
271
+ }
272
+ const id = String(opts.app.id)
273
+ const cfgBase = { fetch: opts.fetch, api: opts.api }
274
+ const usage = opts.usage ?? memoryUsage()
275
+ const now = opts.now ?? (() => Date.now())
276
+ const day = () => new Date(now()).toISOString().slice(0, 10)
277
+ let key: Promise<CryptoKey> | null = null
278
+ // The largest body each action can need, base64 and the rest of the JSON included, and never more than one day's
279
+ // byte budget allows: a body past it is refused by its declared length, or as soon as the count passes it.
280
+ const b64 = (n: number) => Math.ceil(n / 3) * 4
281
+ const room = 64 * 1024
282
+ const budget = b64(limits.bytesPerPersonPerDay) + room
283
+ const oneCap = Math.min(b64(Math.min(limits.inlineBytes, Math.max(limits.imageBytes, limits.videoBytes, limits.voiceBytes))) + room, budget)
284
+ const reportCap = Math.min(b64(Math.min(limits.inlineBytes, limits.imageBytes) * limits.images + Math.min(limits.inlineBytes, limits.videoBytes) * limits.videos + Math.min(limits.inlineBytes, limits.voiceBytes) * 10) + 4 * room, budget)
285
+ const caps: Record<Action, number> = { file: reportCap, 'follow-up': oneCap, status: room, attach: oneCap, 'upload-url': room }
286
+ const bad = (error: string) => json(400, { error })
287
+ const gone = (e: unknown) => json(502, { error: 'GitHub could not be reached or refused the relay; try again', ...(e instanceof GitHubError ? { status: e.status } : {}) })
288
+
289
+ /** A token for one repo, as the App's installation on it; a Response when the App is not installed there. */
290
+ async function repoToken(repo: string): Promise<string | Response> {
291
+ key ??= importKey(opts.app.privateKey)
292
+ const jwt = await appJwt(await key, id, now())
293
+ let installation: number
294
+ try { installation = (await github({ ...cfgBase, token: jwt }, 'GET', `/repos/${repo}/installation`)).body.id }
295
+ catch (e) { if (e instanceof GitHubError && e.status === 404) return json(403, { error: `the app is not installed on ${repo}` }); throw e }
296
+ return (await github({ ...cfgBase, token: jwt }, 'POST', `/app/installations/${installation}/access_tokens`, { repositories: [repo.split('/')[1]] })).body.token as string
297
+ }
298
+
299
+ const kindCap = (kind: 'image' | 'video' | 'voice') => (kind === 'image' ? limits.imageBytes : kind === 'video' ? limits.videoBytes : limits.voiceBytes)
300
+ const kindOfType = (t: string): 'image' | 'video' | 'voice' => (t.startsWith('audio/') ? 'voice' : kindOf(t))
301
+
302
+ /** One file, inline (base64 `bytes`, up to `inlineBytes`) or `staged` (put to an upload-url link first): a plain name,
303
+ * an accepted type by its bytes that agrees with what was declared, within its kind's cap. */
304
+ async function checkFile(f: unknown, declared: string | undefined, account: string, reportId: string): Promise<Checked | Response> {
305
+ const x = f as Record<string, unknown> | null
306
+ if (!x || typeof x !== 'object' || typeof x.name !== 'string' || !FILE.test(x.name)) return bad('a file name is not a plain file name')
307
+ if (typeof x.type !== 'string') return bad(`${x.name} has no type`)
308
+ const kind = kindOfType(x.type)
309
+ let bytes: Uint8Array | null
310
+ let stagedKey: string | undefined
311
+ if (x.staged !== undefined) {
312
+ if (!opts.staging) return bad(`${x.name} is staged, and this relay has no staging`)
313
+ if (x.staged !== true && x.staged !== x.name) return bad(`${x.name} is staged under another name`)
314
+ stagedKey = stageKey(account, reportId, x.name)
315
+ const heldFor = await usage.staged(stagedKey)
316
+ if (heldFor === null) return bad(`${x.name} was never uploaded; ask upload-url for a link and put it there first`)
317
+ const got = await opts.staging.read(stagedKey)
318
+ if (!got) return bad(`${x.name} was never uploaded; ask upload-url for a link and put it there first`)
319
+ // Read with a counter, stopping past what upload-url held for it (and the kind's own cap).
320
+ const limit = Math.min(heldFor, kindCap(kind))
321
+ bytes = await bytesOf(got, limit)
322
+ if (!bytes) return json(413, { error: `${x.name} is larger than the ${limit} bytes it was given a link for` })
323
+ } else {
324
+ bytes = typeof x.bytes === 'string' ? fromBase64(x.bytes) : null
325
+ if (!bytes || !bytes.length) return bad(`${x.name} is not base64`)
326
+ if (bytes.length > limits.inlineBytes && bytes.length <= kindCap(kind)) {
327
+ return json(413, { error: opts.staging ? `${x.name} is larger than this relay takes in one request; send it staged (upload-url)` : `${x.name} is larger than this relay takes in one request, and it has no staging for larger files` })
328
+ }
329
+ }
330
+ if (!bytes.length) return bad(`${x.name} is empty`)
331
+ const real = sniffType(bytes.subarray(0, 64))
332
+ if (!real || !(ACCEPTED_TYPES as readonly string[]).includes(real)) return json(422, { error: 'the file is not a picture, video or recording this module accepts' })
333
+ if (!sameType(x.type, real) || (declared && !sameType(declared, real))) return json(422, { error: 'the file is not the type it was declared as' })
334
+ const max = kindCap(kind)
335
+ if (bytes.length > max) return json(413, { error: `that file is larger than the ${max} bytes allowed` })
336
+ return { name: x.name, type: real, kind, bytes, ...(stagedKey ? { stagedKey } : {}) }
337
+ }
338
+ /** The bytes a request still has to hold today: inline files only, since upload-url held a staged file's up front. */
339
+ const inlineBytesOf = (files: Checked[]) => files.reduce((n, f) => n + (f.stagedKey ? 0 : f.bytes.length), 0)
340
+ const discardStaged = async (files: Checked[]) => {
341
+ for (const f of files) if (f.stagedKey) { try { await opts.staging?.discard(f.stagedKey); await usage.unstage(f.stagedKey) } catch { /* the bucket's own expiry catches it */ } }
342
+ }
343
+ /** One request's files: each name once, and no more of each kind than a report may carry. */
344
+ const checkMany = async (list: unknown[], declared: (name: unknown) => string | undefined | false, account: string, reportId: string): Promise<Checked[] | Response> => {
345
+ const seen = new Set<string>()
346
+ const out: Checked[] = []
347
+ for (const f of list) {
348
+ const name = (f as { name?: unknown })?.name
349
+ if (typeof name === 'string' && seen.has(name)) return bad(`${name} is listed twice`)
350
+ if (typeof name === 'string') seen.add(name)
351
+ const want = declared(name)
352
+ if (want === false) return bad(`${String(name)} is not a file the report declares`)
353
+ const c = await checkFile(f, want === 'audio/*' ? undefined : want, account, reportId); if (c instanceof Response) return c
354
+ if (want === 'audio/*' && c.kind !== 'voice') return json(422, { error: `${c.name} is a voice note and is not audio` })
355
+ out.push(c)
356
+ }
357
+ const count = (k: Checked['kind']) => out.filter((c) => c.kind === k).length
358
+ if (count('image') > limits.images || count('video') > limits.videos || count('voice') > limits.voice) return bad(`a request carries at most ${limits.images} pictures, ${limits.videos} video and ${limits.voice} recordings`)
359
+ return out
360
+ }
361
+
362
+ /** The catch, on the relay, over every string that would reach the issue. A credential refuses by its kind (never its
363
+ * text). Anything else holds, unless the filer says they redacted: then the relay redacts what it found itself and
364
+ * answers the clean copy, so raw text is never filed on the filer's word. */
365
+ function catchIt<T extends Record<string, unknown>>(readable: T, account: string, redacted: boolean): { res: Response } | { clean: T } {
366
+ const { hits } = privacyCatch(readable, { people: opts.privacy?.people ?? [], secrets: [...(opts.privacy?.secrets ?? []), account] })
367
+ const creds = hits.filter((h) => h.kind === 'credential')
368
+ if (creds.length) return { res: json(422, { error: `a credential was found in ${[...new Set(creds.map((h) => h.field))].join(', ')}; it is never filed`, kinds: ['credential'] }) }
369
+ if (hits.length && !redacted) return { res: json(409, { held: true, hits: hits.map((h) => ({ kind: h.kind, field: h.field })) }) }
370
+ return { clean: hits.length ? redact(readable, hits) : readable }
371
+ }
372
+
373
+ async function readJson(req: Request, cap: number): Promise<Record<string, unknown> | Response> {
374
+ const text = await cappedText(req, cap)
375
+ if (typeof text !== 'string') return text
376
+ try { const v = JSON.parse(text); return v && typeof v === 'object' && !Array.isArray(v) ? v : bad('the body is not a JSON object') } catch { return bad('the body is not JSON') }
377
+ }
378
+
379
+ async function allowed(account: string, repo: string): Promise<Response | null> {
380
+ const list = opts.repos ? await opts.repos(account) : null
381
+ return list && !list.includes(repo) ? json(403, { error: `this account may not file on ${repo}` }) : null
382
+ }
383
+
384
+ /** Today's allowance, held atomically for this request: the token to commit or release, or the refusal. */
385
+ async function reserve(account: string, reportId: string, bytes: number): Promise<{ token: string } | Response> {
386
+ const r = await usage.reserve(account, day(), reportId, bytes, { reports: limits.perPersonPerDay, bytes: limits.bytesPerPersonPerDay })
387
+ if (r.ok) return { token: r.token }
388
+ return json(429, { error: r.over === 'reports' ? 'you have reached today\'s limit of suggestions' : 'you have reached today\'s limit of attachments' })
389
+ }
390
+
391
+ /** The contact to share: the host's, and only when the request asked (the person left it ticked). */
392
+ const contactFor = (me: RelayIdentity, asked: unknown) => (asked !== undefined && me.contact && !validContact(me.contact).length ? me.contact : undefined)
393
+
394
+ async function file(me: RelayIdentity, body: Record<string, unknown>, req: Request): Promise<Response> {
395
+ const account = me.account
396
+ const repo = body.repo
397
+ if (!isRepoName(repo)) return bad('repo is not owner/name')
398
+ const refused = await allowed(account, repo); if (refused) return refused
399
+ const report = body.report as Record<string, unknown> | undefined
400
+ if (!report || typeof report !== 'object') return bad('report is needed')
401
+ const problems = validateReport(report)
402
+ if (problems.length) return json(400, { error: 'the report is not valid', problems })
403
+ const reportId = String(report.id)
404
+ const declared = [...((report.media as { file: string; type: string }[]) ?? []).map((m) => [m.file, m.type] as const), ...((report.voice as { file: string }[]) ?? []).map((v) => [v.file, 'audio/*'] as const)]
405
+ const checked = await checkMany(Array.isArray(body.files) ? body.files : [], (n) => declared.find(([d]) => d === n)?.[1] ?? false, account, reportId)
406
+ if (checked instanceof Response) return checked
407
+ const files = checked
408
+ // The app is the host's: a request names one by its id in the host's table, or none.
409
+ const appId = body.app
410
+ if (appId !== undefined && opts.apps && (typeof appId !== 'string' || !Object.prototype.hasOwnProperty.call(opts.apps, appId))) return bad('unknown app')
411
+ const app = opts.apps && typeof appId === 'string' ? opts.apps[appId] : undefined
412
+ // Who it is from comes from the host, never the request: the name, the role and the contact.
413
+ const contact = contactFor(me, (report.who as { contact?: unknown } | undefined)?.contact)
414
+ const appVersion = typeof (report.diagnostics as { appVersion?: unknown } | undefined)?.appVersion === 'string' ? (report.diagnostics as { appVersion: string }).appVersion : undefined
415
+ const readable = {
416
+ words: report.words, followUps: report.followUps, where: report.where, diagnostics: report.diagnostics,
417
+ context: report.context, transcript: typeof body.transcript === 'string' ? body.transcript : undefined,
418
+ filerName: me.name, appName: app?.name, who: { contact },
419
+ files: files.map((f) => ({ name: f.name })),
420
+ }
421
+ // A hit in a file name cannot be redacted (the file is named by it): it holds the report whatever the filer said.
422
+ const nameHits = privacyCatch({ files: readable.files }, { people: opts.privacy?.people ?? [], secrets: [...(opts.privacy?.secrets ?? []), account] }).hits
423
+ if (nameHits.some((h) => h.kind === 'credential')) return json(422, { error: 'a credential was found in a file name; it is never filed', kinds: ['credential'] })
424
+ if (nameHits.length) return json(409, { held: true, hits: nameHits.map((h) => ({ kind: h.kind, field: h.field })) })
425
+ const caught = catchIt(readable, account, body.redacted === true); if ('res' in caught) return caught.res
426
+ const clean = caught.clean as typeof readable
427
+ const context = clean.context !== undefined || opts.geo ? await withPlace(clean.context, req, opts.geo) : undefined
428
+
429
+ if ((await usage.bind(repo, reportId, account)) === 'taken') return json(409, { error: 'that report id is taken; start the suggestion again' })
430
+ // Once per report: a repeat answers the issue it already became.
431
+ const began = await usage.begin(repo, reportId, account, FILING_LEASE_MS)
432
+ if (began.state === 'filed') return began.account === account ? json(200, { number: began.number, url: began.url }) : json(409, { error: 'that report id is taken; start the suggestion again' })
433
+ if (began.state === 'busy') return json(409, { error: 'that report is being filed right now; try again in a moment' })
434
+ const held = await reserve(account, reportId, inlineBytesOf(files))
435
+ if (held instanceof Response) { await usage.abandon(repo, reportId); return held }
436
+ try {
437
+ const token = await repoToken(repo); if (token instanceof Response) { await usage.abandon(repo, reportId); await usage.release(held.token); return token }
438
+ // The files first, so the issue opens complete; a file already there from an interrupted try is its link.
439
+ const host = branchHost({ repo, token, fetch: opts.fetch })
440
+ const attachmentUrls: Record<string, string> = {}
441
+ for (const f of files) attachmentUrls[f.name] = (await host.put({ repo, issue: 0, reportId }, { name: f.name, type: f.type, bytes: f.bytes })).url
442
+ const role = me.role ?? 'member'
443
+ // The report's own app and filer names, if a request put any there, never reach the issue: only the host's do.
444
+ const { appName: _a, filerName: _f, ...plain } = report
445
+ void _a; void _f
446
+ const issue = composeIssue({ ...plain, words: clean.words, where: clean.where, diagnostics: clean.diagnostics, ...(context !== undefined ? { context } : {}), who: { role, ...(clean.who.contact ? { contact: clean.who.contact } : {}) } } as never, {
447
+ appName: typeof clean.appName === 'string' ? clean.appName.slice(0, 100) : undefined,
448
+ appLabel: app ? app.label : null,
449
+ appVersion: appVersion?.slice(0, 50),
450
+ filerName: typeof clean.filerName === 'string' && clean.filerName.trim() ? clean.filerName.trim().slice(0, 100) : 'Someone',
451
+ filerRole: role,
452
+ transcript: clean.transcript,
453
+ attachmentUrls,
454
+ })
455
+ const made = await fileIssue({ repo, token, fetch: opts.fetch, api: opts.api }, issue)
456
+ // The issue exists now. Record it (the store is asked again if it blinks); a filing that made an issue is never
457
+ // abandoned, so a retry answers this issue, or waits out the lease, and never opens a second one.
458
+ const record = { account, reportId, number: made.number, url: made.url }
459
+ for (let attempt = 0; ; attempt++) {
460
+ try { await usage.finish(repo, record); break } catch (e) { if (attempt >= 2) { void e; break } }
461
+ }
462
+ try { await usage.commit(held.token) } catch { /* the day's count is best effort once the issue exists */ }
463
+ await discardStaged(files)
464
+ return json(200, { number: made.number, url: made.url })
465
+ } catch (e) { await usage.abandon(repo, reportId); await usage.release(held.token); return gone(e) }
466
+ }
467
+
468
+ async function followUp(me: RelayIdentity, body: Record<string, unknown>): Promise<Response> {
469
+ const account = me.account
470
+ const { repo, number, reportId } = body
471
+ if (!isRepoName(repo)) return bad('repo is not owner/name')
472
+ if (!Number.isInteger(number) || (number as number) < 1) return bad('number is not an issue number')
473
+ if (typeof reportId !== 'string' || !isReportId(reportId)) return bad('reportId is not a report id')
474
+ const refused = await allowed(account, repo); if (refused) return refused
475
+ const rec = await usage.filer(repo, number as number)
476
+ if (!rec || rec.account !== account || rec.reportId !== reportId) return json(403, { error: 'only the person who filed it may add to it' })
477
+ const checked = await checkMany(Array.isArray(body.files) ? body.files : [], () => undefined, account, reportId)
478
+ if (checked instanceof Response) return checked
479
+ const files = checked
480
+ const readable = { words: typeof body.words === 'string' ? body.words.slice(0, 20_000) : '', transcript: typeof body.transcript === 'string' ? body.transcript : undefined, files: files.map((f) => ({ name: f.name })) }
481
+ const caught = catchIt(readable, account, body.redacted === true); if ('res' in caught) return caught.res
482
+ const { words, transcript } = caught.clean
483
+ if (!words.trim() && !transcript && !files.length) return bad('a follow-up needs words or a file')
484
+ // The comment is composed, and measured, BEFORE anything is written: a file's link is known from its path.
485
+ const folder = `attachments/${reportId}`
486
+ const links = files.map((f) => { const url = attachmentUrl(repo, ATTACHMENTS_BRANCH, `${folder}/${f.name}`); return f.kind === 'image' ? `![${f.name}](${url})` : `[${f.name}](${url})` })
487
+ // A follow-up is the person's own words, so it sits in a fence like the words of the report.
488
+ const fenced = (s: string) => { const run = Math.max(0, ...[...s.matchAll(/`+/g)].map((m) => m[0].length)); const f = '`'.repeat(Math.max(3, run + 1)); return `${f}\n${s}\n${f}` }
489
+ const text = ['## Added later', words.trim() ? fenced(words.trim()) : '', transcript ? `\n### Voice note\n${fenced(transcript.trim())}` : '', links.length ? `\n${links.join('\n')}` : ''].filter(Boolean).join('\n')
490
+ if (text.length > BODY_LIMIT) return json(413, { error: `that follow-up is longer than GitHub takes in a comment (${BODY_LIMIT} characters)` })
491
+ const held = await reserve(account, reportId, inlineBytesOf(files))
492
+ if (held instanceof Response) return held
493
+ try {
494
+ const token = await repoToken(repo); if (token instanceof Response) { await usage.release(held.token); return token }
495
+ // The report's own caps hold across follow-ups, as they do for attach.
496
+ if (files.length) {
497
+ let names: string[] = []
498
+ try { names = ((await github({ ...cfgBase, token }, 'GET', `/repos/${repo}/contents/${folder}?ref=${ATTACHMENTS_BRANCH}`)).body as { name: string }[]).map((x) => x.name) }
499
+ catch (e) { if (!(e instanceof GitHubError) || e.status !== 404) throw e }
500
+ for (const f of files) if (names.includes(f.name)) { await usage.release(held.token); return json(409, { error: `${folder}/${f.name} is already written` }) }
501
+ for (const k of ['image', 'video', 'voice'] as const) {
502
+ const room = k === 'image' ? limits.images : k === 'video' ? limits.videos : limits.voice
503
+ if (names.filter((n) => kindByName(n) === k).length + files.filter((f) => f.kind === k).length > room) { await usage.release(held.token); return json(422, { error: `this report has all the ${k === 'voice' ? 'recordings' : `${k}s`} it can carry` }) }
504
+ }
505
+ }
506
+ const host = branchHost({ repo, token, fetch: opts.fetch })
507
+ for (const f of files) await host.put({ repo, issue: number as number, reportId }, { name: f.name, type: f.type, bytes: f.bytes })
508
+ const got = await addFollowUp({ repo, token, fetch: opts.fetch, api: opts.api }, number as number, text)
509
+ await usage.commit(held.token)
510
+ await discardStaged(files)
511
+ return json(200, got)
512
+ } catch (e) { await usage.release(held.token); return gone(e) }
513
+ }
514
+
515
+ async function status(me: RelayIdentity, body: Record<string, unknown>): Promise<Response> {
516
+ const items = Array.isArray(body.items) ? body.items : null
517
+ if (!items || !items.length || items.length > 100) return bad('items is a list of 1 to 100 { repo, number }')
518
+ const list: { repo: string; number: number }[] = []
519
+ for (const it of items) {
520
+ const x = it as { repo?: unknown; number?: unknown }
521
+ if (!isRepoName(x?.repo) || !Number.isInteger(x.number) || (x.number as number) < 1) return bad('an item is not { repo, number }')
522
+ const rec = await usage.filer(x.repo as string, x.number as number)
523
+ if (!rec || rec.account !== me.account) return json(403, { error: 'only the person who filed an issue may ask its state' })
524
+ list.push({ repo: x.repo as string, number: x.number as number })
525
+ }
526
+ try {
527
+ const out: Record<string, unknown>[] = []
528
+ for (const repo of [...new Set(list.map((i) => i.repo))]) {
529
+ const token = await repoToken(repo); if (token instanceof Response) return token
530
+ for (const s of await issueStates({ token, fetch: opts.fetch, api: opts.api }, list.filter((i) => i.repo === repo))) {
531
+ out.push({ repo: s.repo, number: s.number, status: s.missing ? 'missing' : issueStatus(s), ...(s.reason ? { reason: s.reason } : {}) })
532
+ }
533
+ }
534
+ return json(200, { items: out })
535
+ } catch (e) { return gone(e) }
536
+ }
537
+
538
+
539
+ async function attach(me: RelayIdentity, body: Record<string, unknown>): Promise<Response> {
540
+ const account = me.account
541
+ const { repo, issue, reportId } = body
542
+ if (!isRepoName(repo)) return bad('repo is not owner/name')
543
+ if (!Number.isInteger(issue) || (issue as number) < 1) return bad('issue is not an issue number')
544
+ if (typeof reportId !== 'string' || !isReportId(reportId)) return bad('reportId is not a report id')
545
+ const refused = await allowed(account, repo); if (refused) return refused
546
+ const c = await checkFile(body, undefined, account, reportId); if (c instanceof Response) return c
547
+ // An issue the relay filed belongs to whoever filed it through the relay.
548
+ const mine = await usage.filer(repo, issue as number)
549
+ if (mine && (mine.account !== account || mine.reportId !== reportId)) return json(403, { error: 'this report belongs to someone else' })
550
+ const held = await reserve(account, reportId, inlineBytesOf([c])); if (held instanceof Response) return held
551
+ const fail = async (res: Response) => { await usage.release(held.token); return res }
552
+ try {
553
+ const token = await repoToken(repo); if (token instanceof Response) return fail(token)
554
+ const cfg = { ...cfgBase, token }
555
+ // Proof the caller could file here: the issue exists, is an issue, is open or recently closed, and ends with this report's line.
556
+ let found: { body?: string; pull_request?: unknown; state?: string; closed_at?: string | null; user?: { login?: string } }
557
+ try { found = (await github(cfg, 'GET', `/repos/${repo}/issues/${issue}`)).body }
558
+ catch (e) { if (e instanceof GitHubError && e.status === 404) return fail(json(404, { error: `there is no issue #${issue} on ${repo}` })); throw e }
559
+ if (found.pull_request) return fail(json(403, { error: `#${issue} is a pull request, not an issue` }))
560
+ if (!endsWithFiledBy(String(found.body ?? ''), reportId)) return fail(json(403, { error: `issue #${issue} does not carry Report ${reportId}` }))
561
+ if (found.state === 'closed' && found.closed_at && now() - Date.parse(found.closed_at) > limits.closedGraceMs) return fail(json(403, { error: `issue #${issue} closed too long ago to add to` }))
562
+ // An issue someone opened themselves takes files only from them: the login that opened it must be one this
563
+ // identity vouches for. Nothing is claimed until that proof holds, so a stranger's failed try blocks nobody.
564
+ if (!mine) {
565
+ const opener = String(found.user?.login ?? '').toLowerCase()
566
+ if (!opener || !me.githubLogin || me.githubLogin.toLowerCase() !== opener) return fail(json(403, { error: `only the person who opened issue #${issue} may attach to it` }))
567
+ }
568
+ if ((await usage.bind(repo, reportId, account)) === 'taken') return fail(json(403, { error: 'this report belongs to someone else' }))
569
+ // What the report already holds: never the same path twice, never past its caps.
570
+ const folder = `attachments/${reportId}`
571
+ let names: string[] = []
572
+ try { names = ((await github(cfg, 'GET', `/repos/${repo}/contents/${folder}?ref=${ATTACHMENTS_BRANCH}`)).body as { name: string }[]).map((x) => x.name) }
573
+ catch (e) { if (!(e instanceof GitHubError) || e.status !== 404) throw e }
574
+ if (names.includes(c.name)) return fail(json(409, { error: `${folder}/${c.name} is already written` }))
575
+ const have = names.filter((n) => kindByName(n) === c.kind).length
576
+ const room = c.kind === 'image' ? limits.images : c.kind === 'video' ? limits.videos : limits.voice
577
+ if (have >= room) return fail(json(422, { error: `this report has all the ${c.kind === 'voice' ? 'recordings' : `${c.kind}s`} it can carry` }))
578
+ try {
579
+ await branchHost({ repo, token, fetch: opts.fetch, refuseExisting: true }).put({ repo, issue: issue as number, reportId }, { name: c.name, type: c.type, bytes: c.bytes })
580
+ } catch (e) { if (e instanceof GitHubError && e.status === 409) return fail(json(409, { error: `${folder}/${c.name} is already written` })); throw e }
581
+ await usage.commit(held.token)
582
+ await discardStaged([c])
583
+ return json(200, { url: attachmentUrl(repo, ATTACHMENTS_BRANCH, `${folder}/${c.name}`) })
584
+ } catch (e) { await usage.release(held.token); return gone(e) }
585
+ }
586
+
587
+ /** A one-time signed PUT for one file of a report, for a file too large to send inside a request. Bound to this
588
+ * identity and report like a filing, with the day's bytes held now (the later file or attach does not hold them again). */
589
+ async function uploadUrl(me: RelayIdentity, body: Record<string, unknown>): Promise<Response> {
590
+ const account = me.account
591
+ const { repo, reportId, name, type, bytes } = body
592
+ if (!isRepoName(repo)) return bad('repo is not owner/name')
593
+ if (typeof reportId !== 'string' || !isReportId(reportId)) return bad('reportId is not a report id')
594
+ if (typeof name !== 'string' || !FILE.test(name)) return bad('name is not a plain file name')
595
+ if (typeof type !== 'string' || !(ACCEPTED_TYPES as readonly string[]).includes(canonicalType(type))) return json(422, { error: 'that is not a picture, video or recording this module accepts' })
596
+ if (!Number.isInteger(bytes) || (bytes as number) < 1) return bad("bytes is the file's size")
597
+ const kind = kindOfType(canonicalType(type))
598
+ if ((bytes as number) > kindCap(kind)) return json(413, { error: `${name} is larger than the ${kindCap(kind)} bytes allowed` })
599
+ const refused = await allowed(account, repo); if (refused) return refused
600
+ if ((await usage.bind(repo, reportId, account)) === 'taken') return json(403, { error: 'this report belongs to someone else' })
601
+ const held = await reserve(account, reportId, bytes as number); if (held instanceof Response) return held
602
+ try {
603
+ const key = stageKey(account, reportId, name)
604
+ const link = await opts.staging!.uploadUrl(key, { type: canonicalType(type), bytes: bytes as number })
605
+ await usage.stage(key, bytes as number)
606
+ await usage.commit(held.token)
607
+ return json(200, { url: link.url, headers: link.headers ?? {}, staged: name })
608
+ } catch (e) { await usage.release(held.token); return gone(e) }
609
+ }
610
+
611
+ const run: Record<Action, (me: RelayIdentity, body: Record<string, unknown>, req: Request) => Promise<Response>> = { file, 'follow-up': followUp, status, attach, 'upload-url': uploadUrl }
612
+ /** What this relay can take, so a filer knows before it sends (no sign-in needed: it says nothing about anyone). */
613
+ const capabilities = () => json(200, {
614
+ actions: ACTIONS.filter((a) => (only ? a === only : a !== 'upload-url' || Boolean(opts.staging))),
615
+ staging: Boolean(opts.staging),
616
+ inlineBytes: limits.inlineBytes,
617
+ maxBytes: { image: limits.imageBytes, video: limits.videoBytes, voice: limits.voiceBytes },
618
+ })
619
+
620
+ return async function relay(req: Request): Promise<Response> {
621
+ // The action is the last segment of the path the handler is mounted on: .../file, .../follow-up, .../status, .../attach.
622
+ const last = new URL(req.url).pathname.replace(/\/+$/, '').split('/').pop() ?? ''
623
+ if (req.method === 'GET' && last === 'capabilities') return capabilities()
624
+ const found = (ACTIONS as readonly string[]).find((a) => a === last && (a !== 'upload-url' || opts.staging)) as Action | undefined
625
+ const action = only ?? found
626
+ if (!action) return json(404, { error: `no such action; use one of ${ACTIONS.join(', ')}` })
627
+ if (req.method !== 'POST') return json(405, { error: 'method not allowed' })
628
+ const me = await opts.identity(req)
629
+ if (!me || typeof me.account !== 'string' || !me.account) return json(401, { error: 'sign in first' })
630
+ const body = await readJson(req, caps[action])
631
+ if (body instanceof Response) return body
632
+ return run[action](me, body, req)
633
+ }
634
+ }
635
+
636
+ /** A relay that only attaches (the 0.2.0 shape): every request is `attach`, whatever its path. */
637
+ export const createAttachRelay = (opts: AttachRelayOptions) => createSuggestionRelay(opts, 'attach')