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