@preventive/triage 1.0.0-alpha.37 → 1.0.0-alpha.39

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 (177) hide show
  1. package/README.md +16 -0
  2. package/common/bundle-compare-diff.d.ts +3 -0
  3. package/common/bundle-compare-diff.js +522 -0
  4. package/common/bundle-compare-inputs.d.ts +2 -0
  5. package/common/bundle-compare-inputs.js +65 -0
  6. package/common/bundle-metadata.d.ts +6 -2
  7. package/common/bundle-metadata.js +63 -20
  8. package/common/bundle-pkg-of.d.ts +1 -0
  9. package/common/bundle-pkg-of.js +90 -0
  10. package/common/bundle-source-links.js +7 -0
  11. package/common/bundle-sourcemap.d.ts +10 -0
  12. package/common/bundle-sourcemap.js +100 -0
  13. package/common/bundle-sources.d.ts +3 -1
  14. package/common/bundle-sources.js +47 -19
  15. package/common/code-language.js +16 -2
  16. package/common/default-scan-models.ts +2 -1
  17. package/common/dependency-reviews.d.ts +23 -0
  18. package/common/dependency-reviews.js +81 -0
  19. package/common/finding-duplicates.js +58 -0
  20. package/common/finding-filters.js +274 -0
  21. package/common/github-pr.ts +1 -1
  22. package/common/gzip.js +59 -0
  23. package/common/line-diff.d.ts +8 -0
  24. package/common/line-diff.js +56 -0
  25. package/common/linked-workspace-groups.js +63 -0
  26. package/common/managed/npm-packages.d.ts +5 -0
  27. package/common/managed/npm-packages.js +34 -0
  28. package/common/managed/permissions.ts +1 -5
  29. package/common/managed/routes.js +123 -12
  30. package/common/managed/team-app.d.ts +2 -0
  31. package/common/managed/team-app.js +31 -0
  32. package/common/managed/triage.ts +1 -1
  33. package/common/managed/upload-seal.ts +126 -0
  34. package/common/pretty-print.d.ts +5 -0
  35. package/common/pretty-print.js +24 -0
  36. package/common/revalidation-conflicts.js +48 -0
  37. package/common/revalidation-input-groups.js +56 -0
  38. package/common/workspace-app.js +23 -0
  39. package/common/workspace-groups.js +176 -0
  40. package/out/brotli-fallback.js +3 -3
  41. package/out/chunk-2KBF5TZ7.js +2 -0
  42. package/out/chunk-2XUEZHIP.js +28 -0
  43. package/out/chunk-35VE6CDH.js +2 -0
  44. package/out/chunk-3BZ7JERB.js +1 -0
  45. package/out/chunk-45WIN3SW.js +1 -0
  46. package/out/chunk-4XCDFGDP.js +10 -0
  47. package/out/chunk-6C76GAXK.js +1 -0
  48. package/out/chunk-7ZKDKGAH.js +1 -0
  49. package/out/chunk-ABW5FVSN.js +1 -0
  50. package/out/chunk-C4Z6ZBXV.js +10 -0
  51. package/out/chunk-CMQA4DM6.js +1 -0
  52. package/out/chunk-CVVJAXVY.js +2 -0
  53. package/out/chunk-DIJJNTRH.js +10 -0
  54. package/out/chunk-F4OMDNEY.js +1 -0
  55. package/out/chunk-GJ4IUCNS.js +1 -0
  56. package/out/chunk-GMFXFEUA.js +2 -0
  57. package/out/chunk-GNXOKIGP.js +80 -0
  58. package/out/chunk-GZ4MS3TE.js +13 -0
  59. package/out/chunk-HIV7SVI7.js +1 -0
  60. package/out/chunk-IU3CCINR.js +3 -0
  61. package/out/chunk-JVBB4LZX.js +1 -0
  62. package/out/chunk-K3X5EJBQ.js +2 -0
  63. package/out/chunk-KEX2ATEW.js +2 -0
  64. package/out/chunk-KYCNJVB7.js +1 -0
  65. package/out/chunk-NRPC74AM.js +1 -0
  66. package/out/chunk-OOJM4CTU.js +1 -0
  67. package/out/chunk-PNHBZGDD.js +1 -0
  68. package/out/chunk-QSLWGJBQ.js +223 -0
  69. package/out/chunk-R5O6FAW4.js +2 -0
  70. package/out/chunk-RFZQQXSG.js +17 -0
  71. package/out/chunk-TINZZKP5.js +33 -0
  72. package/out/chunk-TUII34K2.js +1 -0
  73. package/out/chunk-TZ5KB2Y5.js +10 -0
  74. package/out/chunk-UNTRGE26.js +2 -0
  75. package/out/chunk-UYAXVRVL.js +1 -0
  76. package/out/chunk-V3ME6FI5.js +2 -0
  77. package/out/chunk-V5O7M2YT.js +1 -0
  78. package/out/chunk-VIHGO77Y.js +1 -0
  79. package/out/chunk-W4J3MGLB.js +1 -0
  80. package/out/chunk-WGG7KQUH.js +1 -0
  81. package/out/chunk-XNGEQO7Y.js +2 -0
  82. package/out/chunk-XRWAV3DX.js +7 -0
  83. package/out/chunk-XYDGU6BM.js +51 -0
  84. package/out/chunk-XYFXXB2G.js +10 -0
  85. package/out/chunk-YVZH5SHP.js +1 -0
  86. package/out/chunk-ZPD5R5RP.js +3 -0
  87. package/out/client-managed-import.js +3 -87
  88. package/out/client-managed.js +26 -338
  89. package/out/client-sync.js +8 -14
  90. package/out/graph.js +17 -30
  91. package/out/index.html +34 -0
  92. package/out/prism.js +6 -2
  93. package/out/terminal.js +241 -257
  94. package/out/view.css +1 -1
  95. package/out/view.js +76 -520
  96. package/package.json +89 -7
  97. package/risk/files/characters.js +196 -0
  98. package/risk/files/hosts.js +205 -0
  99. package/risk/files/readability.js +171 -0
  100. package/risk/files/runs-code.js +358 -0
  101. package/risk/files/script.js +114 -0
  102. package/risk/files/stamps.js +46 -0
  103. package/risk/index.d.ts +142 -0
  104. package/risk/index.js +7 -0
  105. package/risk/npm/assess.js +219 -0
  106. package/risk/npm/combined.js +65 -0
  107. package/risk/npm/compare.js +228 -0
  108. package/risk/npm/dependencies.js +153 -0
  109. package/risk/npm/diff.js +43 -0
  110. package/risk/npm/downloads.js +84 -0
  111. package/risk/npm/files.js +34 -0
  112. package/risk/npm/held.js +60 -0
  113. package/risk/npm/install-scripts.js +34 -0
  114. package/risk/npm/malware.js +36 -0
  115. package/risk/npm/manifest.js +65 -0
  116. package/risk/npm/new-dependency.js +30 -0
  117. package/risk/npm/old-version.js +108 -0
  118. package/risk/npm/registry.js +282 -0
  119. package/risk/npm/release.js +85 -0
  120. package/risk/npm/specs.js +124 -0
  121. package/risk/npm/tarball.js +113 -0
  122. package/server-e2e/http.ts +7 -7
  123. package/server-e2e/npm-proxy.ts +1 -1
  124. package/server-e2e/objstore/fetch-mint-guard.ts +2 -2
  125. package/server-e2e/objstore/handlers.ts +1 -1
  126. package/server-e2e/objstore/tokens.ts +1 -1
  127. package/server-e2e/sign.ts +1 -1
  128. package/server-managed/bundle-advisories.ts +74 -12
  129. package/server-managed/bundle-cache.ts +37 -4
  130. package/server-managed/bundle-commits.ts +140 -0
  131. package/server-managed/bundle-summary-cache.ts +24 -10
  132. package/server-managed/config.ts +4 -0
  133. package/server-managed/db-methods.ts +98 -11
  134. package/server-managed/db-neon.ts +39 -4
  135. package/server-managed/db-schema.ts +14 -2
  136. package/server-managed/db-table-names.ts +1 -1
  137. package/server-managed/db.ts +7 -3
  138. package/server-managed/dependency-reviews.ts +248 -0
  139. package/server-managed/github-app.ts +9 -3
  140. package/server-managed/github-errors.ts +21 -2
  141. package/server-managed/github-issue-oauth.ts +5 -6
  142. package/server-managed/github-metadata.ts +115 -0
  143. package/server-managed/github-oauth.ts +38 -2
  144. package/server-managed/http-response.ts +26 -0
  145. package/server-managed/http.ts +952 -53
  146. package/server-managed/index.ts +11 -5
  147. package/server-managed/kept.ts +28 -0
  148. package/server-managed/locked-packages.ts +85 -0
  149. package/server-managed/lru.ts +30 -0
  150. package/server-managed/npm-insights.ts +440 -0
  151. package/server-managed/npm-kept.ts +65 -0
  152. package/server-managed/npm-loads.ts +167 -0
  153. package/server-managed/npm-manifest.ts +115 -0
  154. package/server-managed/npm-packages.ts +378 -0
  155. package/server-managed/npm-provenance.ts +123 -0
  156. package/server-managed/npm-risk.ts +177 -0
  157. package/server-managed/pretty-print.ts +352 -0
  158. package/server-managed/public-workspace.ts +78 -7
  159. package/server-managed/rename-diff-worker.js +26 -0
  160. package/server-managed/rename-diff.ts +270 -0
  161. package/server-managed/report-sources.ts +13 -2
  162. package/server-managed/repository-browser.ts +19 -5
  163. package/server-managed/repository-risk.ts +418 -0
  164. package/server-managed/session.ts +16 -2
  165. package/server-managed/sql.ts +5 -2
  166. package/server-managed/storage-encryption.ts +5 -3
  167. package/server-managed/storage-payload.ts +4 -0
  168. package/server-managed/storage-stores.ts +2 -0
  169. package/server-managed/storage.ts +6 -3
  170. package/server-managed/team-app.ts +94 -0
  171. package/server-managed/team-feed.ts +1 -1
  172. package/server-managed/team-npm-scopes.ts +77 -0
  173. package/server-managed/type-only-worker.js +19 -0
  174. package/server-managed/type-only.ts +103 -0
  175. package/server-managed/uploads.ts +25 -4
  176. package/server-managed/upstream-cache.ts +147 -5
  177. package/server-managed/view-as.ts +58 -0
@@ -0,0 +1,248 @@
1
+ import { randomUUID } from 'node:crypto'
2
+ import { ManagedMutationError } from './management.ts'
3
+ import type { ManagedSql } from './sql.ts'
4
+ import { REVIEW_VERDICTS, type ReviewSummary, type ReviewVerdict, allowedReviewVerdicts, needsPackageJsonReview, summarizeReview } from '../common/dependency-reviews.js'
5
+
6
+ export interface ReviewContext {
7
+ ecosystem: 'npm'; package: string; version: string; integrity: string
8
+ basePackage: string | null; baseVersion: string | null; baseIntegrity: string | null
9
+ }
10
+ export interface ReviewInput { file: string; comment: string; status: ReviewVerdict | null }
11
+ export interface ReviewEntry extends ReviewInput { id: string; time: number }
12
+ export interface ReviewTeam { id: string; name: string }
13
+ export interface DependencyReview extends ReviewContext, ReviewSummary {
14
+ id: string; user: string | null; userLogin: string; time: number; revision: number
15
+ publishedAt: number | null; revokedAt: number | null; entries: ReviewEntry[]; teams: ReviewTeam[]; files: string[] | null
16
+ nonTextFiles: string[] | null
17
+ status: ReviewVerdict | null; comment: string
18
+ }
19
+ export type ReviewChange = { action: 'save'; reviewId: string | null; revision: number | null; entryId: string | null; entry: ReviewInput }
20
+ | { action: 'start'; reviewId: null; revision: null }
21
+ | { action: 'publish'; reviewId: string; revision: number; teamIds: string[]; files: string[]; nonTextFiles: string[]; final: { status: ReviewVerdict; comment: string } | null }
22
+ | { action: 'continue'; reviewId: string; revision: number }
23
+ | { action: 'revoke'; reviewId: string; revision: number }
24
+ export interface DependencyReviewStore {
25
+ listDependencyReviews(context: ReviewContext, user: string, allBases?: boolean): Promise<DependencyReview[]>
26
+ getLatestOwnDependencyReview(user: string, target: Pick<ReviewContext, 'ecosystem' | 'package' | 'version' | 'integrity'>): Promise<DependencyReview | null>
27
+ listOwnDependencyReviews(user: string, before: { time: number; id: string } | null): Promise<{ reviews: DependencyReview[]; next: { time: number; id: string } | null }>
28
+ listDependencyReviewTeams(user: string): Promise<ReviewTeam[]>
29
+ listDependencyReviewEnabledTeams(): Promise<string[]>
30
+ setTeamDependencyReview(sessionId: string, teamId: string, enabled: boolean): Promise<boolean>
31
+ discardDependencyReview(sessionId: string, reviewId: string, revision: number): Promise<void>
32
+ changeDependencyReview(context: ReviewContext, sessionId: string, change: ReviewChange, now: number): Promise<DependencyReview>
33
+ }
34
+
35
+ // An unpublished review belongs only to its author. Publication freezes all
36
+ // entries together and attaches that snapshot to its selected teams. Continuing
37
+ // makes a new review; published entries and their timestamps never change.
38
+ export const DEPENDENCY_REVIEW_SCHEMA = `
39
+ CREATE TABLE IF NOT EXISTS managed_dependency_review (
40
+ id TEXT PRIMARY KEY,
41
+ user_id TEXT REFERENCES managed_user(id) ON DELETE SET NULL,
42
+ user_login TEXT NOT NULL, ecosystem TEXT NOT NULL,
43
+ package TEXT NOT NULL, version TEXT NOT NULL, integrity TEXT NOT NULL,
44
+ base_package TEXT NOT NULL, base_version TEXT NOT NULL, base_integrity TEXT NOT NULL,
45
+ time INTEGER NOT NULL, revision INTEGER NOT NULL, published_at INTEGER, revoked_at INTEGER, files TEXT,
46
+ status TEXT CHECK (status IN ('accept', 'accept-with-nits', 'request-changes', 'reject')), comment TEXT NOT NULL DEFAULT ''
47
+ ) STRICT;
48
+ CREATE INDEX IF NOT EXISTS managed_dependency_review_context_idx
49
+ ON managed_dependency_review(ecosystem, package, version, integrity, base_package, base_version, base_integrity, time);
50
+ CREATE UNIQUE INDEX IF NOT EXISTS managed_dependency_review_unpublished_idx
51
+ ON managed_dependency_review(user_id, ecosystem, package, version, integrity, base_package, base_version, base_integrity)
52
+ WHERE published_at IS NULL;
53
+ CREATE INDEX IF NOT EXISTS managed_dependency_review_author_idx ON managed_dependency_review(user_id, time, id);
54
+ CREATE TABLE IF NOT EXISTS managed_dependency_file_review (
55
+ id TEXT PRIMARY KEY, review_id TEXT NOT NULL REFERENCES managed_dependency_review(id) ON DELETE CASCADE,
56
+ file TEXT NOT NULL, comment TEXT NOT NULL,
57
+ status TEXT CHECK (status IN ('accept', 'accept-with-nits', 'request-changes', 'reject')), time INTEGER NOT NULL
58
+ ) STRICT;
59
+ CREATE INDEX IF NOT EXISTS managed_dependency_file_review_idx ON managed_dependency_file_review(review_id);
60
+ CREATE TABLE IF NOT EXISTS managed_dependency_review_team (
61
+ review_id TEXT NOT NULL REFERENCES managed_dependency_review(id) ON DELETE CASCADE,
62
+ team_id TEXT NOT NULL REFERENCES managed_team(id) ON DELETE CASCADE,
63
+ PRIMARY KEY (review_id, team_id)
64
+ ) STRICT;
65
+ `
66
+ const values = (c: ReviewContext) => [c.ecosystem, c.package, c.version, c.integrity, c.basePackage ?? '', c.baseVersion ?? '', c.baseIntegrity ?? '']
67
+ const targetWhere = 'ecosystem = ? AND package = ? AND version = ? AND integrity = ?'
68
+ const contextWhere = `${targetWhere} AND base_package = ? AND base_version = ? AND base_integrity = ?`
69
+ const columns = `id, user_id AS user, user_login AS userLogin, ecosystem, package, version, integrity,
70
+ NULLIF(base_package, '') AS basePackage, NULLIF(base_version, '') AS baseVersion, NULLIF(base_integrity, '') AS baseIntegrity,
71
+ time, revision, published_at AS publishedAt, revoked_at AS revokedAt, files, status, comment`
72
+ const conflict = () => { throw new ManagedMutationError(409, 'review-conflict') }
73
+
74
+ // Fold legacy multi-note files into one text field without changing a frozen
75
+ // snapshot's stored rows. Continuing or saving uses one row per file.
76
+ function fileEntries(entries: ReviewEntry[]): ReviewEntry[] {
77
+ const files = new Map<string, ReviewEntry>(), verdicts = Object.keys(REVIEW_VERDICTS)
78
+ for (const entry of entries) {
79
+ if (entry.status === null) continue
80
+ const current = files.get(entry.file)
81
+ if (!current) { files.set(entry.file, { ...entry }); continue }
82
+ current.comment = [current.comment, entry.comment].filter(Boolean).join('\n\n')
83
+ if (verdicts.indexOf(entry.status ?? '') > verdicts.indexOf(current.status ?? '')) current.status = entry.status
84
+ current.time = Math.max(current.time, entry.time)
85
+ }
86
+ return [...files.values()]
87
+ }
88
+
89
+ async function actor(db: ManagedSql, sessionId: string, admin = false) {
90
+ const user = await db.prepare(`SELECT u.id, u.login, u.role FROM managed_session s JOIN managed_user u ON u.id = s.user_id
91
+ WHERE s.id = ? AND s.expires_at > ? AND s.viewer_session IS NULL`).get(sessionId, Date.now()) as { id: string; login: string; role: string } | undefined
92
+ if (!user) throw new ManagedMutationError(401, 'unauthenticated')
93
+ if (!(admin ? ['admin'] : ['triage', 'manage', 'admin']).includes(user.role)) throw new ManagedMutationError(403, 'forbidden')
94
+ return user
95
+ }
96
+ async function teams(db: ManagedSql, user: string): Promise<ReviewTeam[]> {
97
+ return await db.prepare(`SELECT t.id, t.name FROM managed_team t
98
+ JOIN managed_team_user m ON m.team_id = t.id AND m.user_id = ?
99
+ WHERE t.hidden = 0 AND t.dependency_review = 1 ORDER BY t.name, t.id`).all(user) as ReviewTeam[]
100
+ }
101
+ async function expandReviews(db: ManagedSql, rows: unknown[], user: string): Promise<DependencyReview[]> {
102
+ const reviews = rows as (Omit<DependencyReview, 'files'> & { files: string | null })[]
103
+ if (reviews.length === 0) return []
104
+ const ids = reviews.map(review => review.id), placeholders = ids.map(() => '?').join(',')
105
+ const entries = await db.prepare(`SELECT review_id AS reviewId, id, file, comment, status, time
106
+ FROM managed_dependency_file_review WHERE review_id IN (${placeholders}) ORDER BY time, id`).all(...ids) as (ReviewEntry & { reviewId: string })[]
107
+ const linked = await db.prepare(`SELECT rt.review_id AS reviewId, t.id, t.name FROM managed_dependency_review_team rt
108
+ JOIN managed_dependency_review r ON r.id = rt.review_id JOIN managed_team t ON t.id = rt.team_id
109
+ WHERE rt.review_id IN (${placeholders}) AND (r.user_id = ? OR (t.hidden = 0 AND EXISTS (
110
+ SELECT 1 FROM managed_team_user m WHERE m.team_id = t.id AND m.user_id = ?)))
111
+ ORDER BY t.name, t.id`).all(...ids, user, user) as (ReviewTeam & { reviewId: string })[]
112
+ return reviews.map(review => {
113
+ const snapshot = review.files === null ? null : JSON.parse(review.files) as string[] | { paths: string[]; nonTextFiles: string[] }
114
+ const files = Array.isArray(snapshot) ? snapshot : snapshot?.paths ?? null
115
+ const nonTextFiles = Array.isArray(snapshot) ? null : snapshot?.nonTextFiles ?? []
116
+ const ownEntries = fileEntries(entries.filter(entry => entry.reviewId === review.id).map(({ reviewId: _reviewId, ...entry }) => entry))
117
+ return { ...review, files, nonTextFiles, entries: ownEntries, ...summarizeReview(ownEntries, files, nonTextFiles),
118
+ teams: linked.filter(team => team.reviewId === review.id).map(({ reviewId: _reviewId, ...team }) => team) }
119
+ })
120
+ }
121
+
122
+ async function publish(db: ManagedSql, current: DependencyReview | undefined, user: string, change: Extract<ReviewChange, { action: 'publish' }>, now: number) {
123
+ if (!current || current.revokedAt !== null) return conflict()
124
+ const allowed = new Set((await teams(db, user)).map(team => team.id))
125
+ const selected = [...new Set(change.teamIds)]
126
+ if (selected.length === 0 || selected.some(team => !allowed.has(team))) throw new ManagedMutationError(400, 'bad-review-teams')
127
+ const entries = await db.prepare('SELECT file, status FROM managed_dependency_file_review WHERE review_id = ?').all(current.id) as ReviewInput[]
128
+ if (current.publishedAt === null) {
129
+ if (!summarizeReview(entries, change.files, change.nonTextFiles).checkedFiles) throw new ManagedMutationError(400, 'no-reviewed-files')
130
+ if (!change.final || !allowedReviewVerdicts(entries.filter(entry => !change.nonTextFiles.includes(entry.file))).includes(change.final.status)) throw new ManagedMutationError(400, 'bad-final-verdict')
131
+ if (needsPackageJsonReview(entries, change.files, change.nonTextFiles)) throw new ManagedMutationError(400, 'package-json-not-reviewed')
132
+ await db.prepare(`UPDATE managed_dependency_review SET published_at = ?, files = ?, status = ?, comment = ?, revision = revision + 1 WHERE id = ?`)
133
+ .run(now, JSON.stringify({ paths: [...new Set(change.files)].toSorted(), nonTextFiles: [...new Set(change.nonTextFiles)].toSorted() }), change.final.status, change.final.comment, current.id)
134
+ } else {
135
+ if (change.final) return conflict()
136
+ await db.prepare('UPDATE managed_dependency_review SET revision = revision + 1 WHERE id = ?').run(current.id)
137
+ }
138
+ for (const team of selected) await db.prepare('INSERT INTO managed_dependency_review_team (review_id, team_id) VALUES (?, ?) ON CONFLICT DO NOTHING').run(current.id, team)
139
+ }
140
+
141
+ async function listOwn(db: ManagedSql, user: string, before: { time: number; id: string } | null) {
142
+ const rows = await db.prepare(`SELECT ${columns} FROM managed_dependency_review WHERE user_id = ?
143
+ ${before ? 'AND (time < ? OR (time = ? AND id < ?))' : ''} ORDER BY time DESC, id DESC LIMIT 51`)
144
+ .all(user, ...before ? [before.time, before.time, before.id] : []) as DependencyReview[]
145
+ const page = rows.slice(0, 50)
146
+ const last = page.at(-1)
147
+ return { reviews: await expandReviews(db, page, user), next: rows.length > 50 && last ? { time: last.time, id: last.id } : null }
148
+ }
149
+
150
+ async function saveFile(db: ManagedSql, reviewId: string, entry: ReviewInput, requestedId: string | null, now: number) {
151
+ const existing = await db.prepare(`SELECT id FROM managed_dependency_file_review
152
+ WHERE review_id = ? AND file = ? ORDER BY time, id`).all(reviewId, entry.file) as { id: string }[]
153
+ if (requestedId && !existing.some(row => row.id === requestedId)) return conflict()
154
+ if (entry.status === null) {
155
+ await db.prepare('DELETE FROM managed_dependency_file_review WHERE review_id = ? AND file = ?').run(reviewId, entry.file)
156
+ } else if (existing.length > 0) {
157
+ const entryId = existing[0]!.id
158
+ await db.prepare('UPDATE managed_dependency_file_review SET comment = ?, status = ?, time = ? WHERE id = ?')
159
+ .run(entry.comment, entry.status, now, entryId)
160
+ if (existing.length > 1) {
161
+ await db.prepare(`DELETE FROM managed_dependency_file_review
162
+ WHERE review_id = ? AND file = ? AND id <> ?`).run(reviewId, entry.file, entryId)
163
+ }
164
+ } else {
165
+ await db.prepare('INSERT INTO managed_dependency_file_review (id, review_id, file, comment, status, time) VALUES (?, ?, ?, ?, ?, ?)')
166
+ .run(randomUUID(), reviewId, entry.file, entry.comment, entry.status, now)
167
+ }
168
+ }
169
+
170
+ export function dependencyReviewMethods(db: ManagedSql): DependencyReviewStore {
171
+ const read = (id: string) => db.prepare(`SELECT ${columns} FROM managed_dependency_review WHERE id = ?`).get(id)
172
+ return {
173
+ listOwnDependencyReviews: (user, before) => listOwn(db, user, before),
174
+ async getLatestOwnDependencyReview(user, target) {
175
+ const row = await db.prepare(`SELECT ${columns} FROM managed_dependency_review
176
+ WHERE user_id = ? AND ecosystem = ? AND package = ? AND version = ? AND integrity = ? ORDER BY time DESC, id DESC LIMIT 1`)
177
+ .get(user, target.ecosystem, target.package, target.version, target.integrity)
178
+ return row ? (await expandReviews(db, [row], user))[0]! : null
179
+ },
180
+ async discardDependencyReview(sessionId, reviewId, revision) {
181
+ const user = await actor(db, sessionId)
182
+ const { changes } = await db.prepare(`DELETE FROM managed_dependency_review
183
+ WHERE id = ? AND user_id = ? AND revision = ? AND published_at IS NULL`).run(reviewId, user.id, revision)
184
+ if (Number(changes) !== 1) conflict()
185
+ },
186
+ listDependencyReviewEnabledTeams: async () => (await db.prepare('SELECT id FROM managed_team WHERE dependency_review = 1 ORDER BY id').all() as { id: string }[]).map(row => row.id),
187
+ async setTeamDependencyReview(sessionId, teamId, enabled) {
188
+ await actor(db, sessionId, true)
189
+ if (!await db.prepare('SELECT id FROM managed_team WHERE id = ?').get(teamId)) return false
190
+ await db.prepare('UPDATE managed_team SET dependency_review = ? WHERE id = ?').run(enabled ? 1 : 0, teamId)
191
+ return true
192
+ },
193
+ listDependencyReviewTeams: user => teams(db, user),
194
+ async listDependencyReviews(context, user, allBases = false) {
195
+ const rows = await db.prepare(`SELECT ${columns} FROM managed_dependency_review r WHERE ${allBases ? targetWhere : contextWhere}
196
+ AND (user_id = ? OR (published_at IS NOT NULL AND EXISTS (
197
+ SELECT 1 FROM managed_dependency_review_team rt JOIN managed_team t ON t.id = rt.team_id AND t.hidden = 0
198
+ JOIN managed_team_user m ON m.team_id = rt.team_id WHERE rt.review_id = r.id AND m.user_id = ?)))
199
+ ORDER BY time, id`).all(...values(context).slice(0, allBases ? 4 : 7), user, user)
200
+ return expandReviews(db, rows, user)
201
+ },
202
+ async changeDependencyReview(context, sessionId, change, now) {
203
+ const user = await actor(db, sessionId)
204
+ let id = change.reviewId
205
+ let current: DependencyReview | undefined
206
+ if (id) {
207
+ current = await db.prepare(`SELECT ${columns} FROM managed_dependency_review WHERE id = ? AND user_id = ? AND ${contextWhere}`)
208
+ .get(id, user.id, ...values(context)) as DependencyReview | undefined
209
+ if (!current || current.revision !== change.revision) return conflict()
210
+ }
211
+ const unpublished = () => db.prepare(`SELECT id FROM managed_dependency_review WHERE user_id = ? AND ${contextWhere} AND published_at IS NULL`)
212
+ .get(user.id, ...values(context)) as Promise<{ id: string } | undefined>
213
+ const create = async () => {
214
+ if (await unpublished()) return conflict()
215
+ const next = randomUUID()
216
+ await db.prepare(`INSERT INTO managed_dependency_review
217
+ (id, user_id, user_login, ecosystem, package, version, integrity, base_package, base_version, base_integrity, time, revision)
218
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)`).run(next, user.id, user.login, ...values(context), now)
219
+ return next
220
+ }
221
+ if (change.action === 'start') {
222
+ id = await create()
223
+ } else if (change.action === 'continue') {
224
+ if (!current?.publishedAt) return conflict()
225
+ id = await create()
226
+ await db.prepare('UPDATE managed_dependency_review SET comment = ? WHERE id = ?').run(current.comment, id)
227
+ const entries = await db.prepare('SELECT id, file, comment, status, time FROM managed_dependency_file_review WHERE review_id = ? ORDER BY time, id').all(current.id) as ReviewEntry[]
228
+ for (const entry of fileEntries(entries)) {
229
+ await db.prepare(`INSERT INTO managed_dependency_file_review
230
+ (id, review_id, file, comment, status, time) VALUES (?, ?, ?, ?, ?, ?)`)
231
+ .run(randomUUID(), id, entry.file, entry.comment, entry.status, entry.time)
232
+ }
233
+ } else if (change.action === 'revoke') {
234
+ if (!current || current.publishedAt === null || current.revokedAt !== null) return conflict()
235
+ await db.prepare('UPDATE managed_dependency_review SET revoked_at = ?, revision = revision + 1 WHERE id = ?').run(now, id)
236
+ } else if (change.action === 'publish') {
237
+ await publish(db, current, user.id, change, now)
238
+ } else {
239
+ if (current?.publishedAt != null) return conflict()
240
+ if (change.entry.status === null && !change.entryId) throw new ManagedMutationError(400, 'bad-review')
241
+ if (id) await db.prepare('UPDATE managed_dependency_review SET time = ?, revision = revision + 1 WHERE id = ?').run(now, id)
242
+ else { if (change.entryId) return conflict(); id = await create() }
243
+ await saveFile(db, id, change.entry, change.entryId, now)
244
+ }
245
+ return (await expandReviews(db, [await read(id!)], user.id))[0]!
246
+ },
247
+ }
248
+ }
@@ -6,7 +6,7 @@ import { Buffer } from 'node:buffer'
6
6
  import { createHash, createSign } from 'node:crypto'
7
7
  import { createClient } from '@preventive/upstream/github.js'
8
8
  import type { ManagedConfig } from './config.ts'
9
- import { GithubApiError, throwGithubResponseError, upstreamGithub } from './github-errors.ts'
9
+ import { GithubApiError, githubUnreachable, throwGithubResponseError, upstreamGithub } from './github-errors.ts'
10
10
 
11
11
  export { GithubApiError } from './github-errors.ts'
12
12
 
@@ -71,6 +71,7 @@ export function mergeRepos(...lists: ConnectedRepo[][]): ConnectedRepo[] {
71
71
  // fold into a GithubApiError (401 passes through for the caller to handle).
72
72
  export async function githubJson(url: string, token: string | null, fetchImpl: typeof fetch, method: 'GET' | 'POST' = 'GET'): Promise<unknown> {
73
73
  let res: Response
74
+ const started = Date.now()
74
75
  try {
75
76
  res = await fetchImpl(url, {
76
77
  method,
@@ -81,10 +82,15 @@ export async function githubJson(url: string, token: string | null, fetchImpl: t
81
82
  'user-agent': USER_AGENT, 'x-github-api-version': API_VERSION,
82
83
  },
83
84
  })
84
- } catch { throw new GithubApiError(502, 'github-unreachable') }
85
+ } catch (error) { throw githubUnreachable(error, started) }
85
86
  if (res.status === 401) discardInstallationToken(token, fetchImpl)
86
87
  if (!res.ok) await throwGithubResponseError(res)
87
- try { return await res.json() } catch { throw new GithubApiError(502, 'github-malformed') }
88
+ // The answer's body is read within the same timeout: one not read whole is
89
+ // GitHub not reached, as one not answered is; only one that isn't JSON is
90
+ // malformed.
91
+ try { return await res.json() } catch (error) {
92
+ throw error instanceof SyntaxError ? new GithubApiError(502, 'github-malformed') : githubUnreachable(error, started)
93
+ }
88
94
  }
89
95
 
90
96
  // Validate one repo object into a ConnectedRepo (or null to skip), tagging it
@@ -5,14 +5,32 @@ import { HttpError } from '@preventive/upstream/github.js'
5
5
  export class GithubApiError extends Error {
6
6
  status: number
7
7
  retryAfter: number | null
8
- constructor(status: number, message: string, retryAfter: number | null = null) {
8
+ // What failed, where GitHub wasn't reached (githubUnreachable).
9
+ detail: string | null
10
+ constructor(status: number, message: string, retryAfter: number | null = null, detail: string | null = null) {
9
11
  super(message)
10
12
  this.name = 'GithubApiError'
11
13
  this.status = status
12
14
  this.retryAfter = retryAfter
15
+ this.detail = detail
13
16
  }
14
17
  }
15
18
 
19
+ // GitHub not reached, or its answer not read whole, `started` ms since the
20
+ // epoch: as `github-unreachable`, with what failed as the error and its causes
21
+ // tell it (a timeout, a connection closed, an answer too large) and after how
22
+ // long, for an admin's page to show (repository-risk.ts). Only the errors'
23
+ // names, codes and messages, never GitHub's text nor a request's headers.
24
+ export function githubUnreachable(error: unknown, started: number): GithubApiError {
25
+ const told: string[] = []
26
+ for (let at = error, depth = 0; at instanceof Error && depth < 4; at = at.cause, depth++) {
27
+ const code = 'code' in at && typeof at.code === 'string' && at.code !== at.name ? ` ${at.code}` : ''
28
+ told.push(`${at.name}${code}${at.message ? `: ${at.message.slice(0, 200)}` : ''}`)
29
+ }
30
+ const failed = told.length > 0 ? told.join('; ') : String(error).slice(0, 200)
31
+ return new GithubApiError(502, 'github-unreachable', null, `${failed}, after ${((Date.now() - started) / 1000).toFixed(1)}s`)
32
+ }
33
+
16
34
  export async function throwGithubResponseError(res: Response): Promise<never> {
17
35
  if (res.status === 403 || res.status === 429) {
18
36
  const body = await res.json().catch(() => null) as { message?: unknown } | null
@@ -30,6 +48,7 @@ export async function throwGithubResponseError(res: Response): Promise<never> {
30
48
  }
31
49
 
32
50
  export async function upstreamGithub<T>(request: () => Promise<T>): Promise<T> {
51
+ const started = Date.now()
33
52
  try { return await request() } catch (error) {
34
53
  if (error instanceof HttpError) {
35
54
  // The upstream error includes GitHub's response text. Retain managed
@@ -44,6 +63,6 @@ export async function upstreamGithub<T>(request: () => Promise<T>): Promise<T> {
44
63
  if (error instanceof Error && (error.name === 'AssertionError' || error.cause instanceof SyntaxError)) {
45
64
  throw new GithubApiError(502, 'github-malformed')
46
65
  }
47
- throw new GithubApiError(502, 'github-unreachable')
66
+ throw githubUnreachable(error, started)
48
67
  }
49
68
  }
@@ -1,14 +1,13 @@
1
1
  import type { ManagedConfig } from './config.ts'
2
2
  import type { ManagedDb, ManagedSession } from './db.ts'
3
3
  import { randomToken, safeEqual } from './crypto.ts'
4
- import { buildCookie, clearCookie, cookieName, parseCookies } from './session.ts'
4
+ import { ISSUE_STATE_COOKIE, buildCookie, clearCookie, cookieName, parseCookies } from './session.ts'
5
5
  import { OAuthError, ensureUserAccessToken, exchangeCode, fetchIdentity } from './github-oauth.ts'
6
6
 
7
7
  export const ISSUE_LOGIN_PATH = '/api/oauth/github/issues/login'
8
- const COOKIE = 'dvissuestate'
9
8
 
10
9
  export function isIssueOAuthCallback(config: ManagedConfig, query: URLSearchParams, cookie: string | undefined): boolean {
11
- const expected = parseCookies(cookie).get(cookieName(config, COOKIE))?.split('.')[0]
10
+ const expected = parseCookies(cookie).get(cookieName(config, ISSUE_STATE_COOKIE))?.split('.')[0]
12
11
  return !!expected && safeEqual(query.get('state') ?? '', expected)
13
12
  }
14
13
 
@@ -22,13 +21,13 @@ export function issueLoginRedirect(config: ManagedConfig, session: ManagedSessio
22
21
  // Reauthorize the same app for the current account, without replacing the
23
22
  // DeepView session or permitting an OAuth account switch during creation.
24
23
  const value = `${state}.${session.id}.${now}`
25
- return { location: url.href, setCookie: buildCookie(cookieName(config, COOKIE), value,
24
+ return { location: url.href, setCookie: buildCookie(cookieName(config, ISSUE_STATE_COOKIE), value,
26
25
  { secure: config.cookieSecure, maxAgeS: 600, sameSite: 'Lax' }) }
27
26
  }
28
27
 
29
28
  export async function issueOAuthCallback(config: ManagedConfig, db: ManagedDb, session: ManagedSession,
30
29
  query: URLSearchParams, cookie: string | undefined, fetchImpl: typeof fetch = fetch, now = Date.now()) {
31
- const [expected, sessionId, timestamp] = (parseCookies(cookie).get(cookieName(config, COOKIE)) ?? '').split('.')
30
+ const [expected, sessionId, timestamp] = (parseCookies(cookie).get(cookieName(config, ISSUE_STATE_COOKIE)) ?? '').split('.')
32
31
  const code = query.get('code'), state = query.get('state')
33
32
  if (!code || !state || !expected || !safeEqual(state, expected) || sessionId !== session.id
34
33
  || !timestamp || !Number.isFinite(Number(timestamp)) || now < Number(timestamp) || now - Number(timestamp) > 600_000) {
@@ -40,7 +39,7 @@ export async function issueOAuthCallback(config: ManagedConfig, db: ManagedDb, s
40
39
  const current = await db.sessionWithUser(session.id, Date.now())
41
40
  if (!current || current.user.role === 'none' || current.session.userId !== session.userId) throw new OAuthError(401, 'unauthenticated')
42
41
  await db.setUserTokens(session.userId, tokens)
43
- return { location: '/github-issue-authorized.html', setCookie: clearCookie(cookieName(config, COOKIE), config.cookieSecure) }
42
+ return { location: '/github-issue-authorized.html', setCookie: clearCookie(cookieName(config, ISSUE_STATE_COOKIE), config.cookieSecure) }
44
43
  }
45
44
 
46
45
  export function issueUserToken(config: ManagedConfig, db: ManagedDb, userId: string, fetchImpl: typeof fetch = fetch) {
@@ -18,6 +18,18 @@ export interface GithubRepositoryVisibility {
18
18
  public: boolean
19
19
  checkedAt: number
20
20
  }
21
+ // A commit's details, keyed by githubCommitKey. Commits never change, so an
22
+ // entry is never refreshed or evicted.
23
+ export interface GithubCommit {
24
+ key: string
25
+ message: string
26
+ authorName: string | null
27
+ authorLogin: string | null
28
+ authoredAt: number | null
29
+ committedAt: number | null
30
+ fetchedAt: number
31
+ }
32
+ export interface GithubTag { name: string; sha: string }
21
33
  export interface GithubMetadataStore {
22
34
  listGithubMetadata(keys: readonly string[]): Promise<GithubMetadata[]>
23
35
  setGithubMetadata(entries: readonly Omit<GithubMetadata, 'attemptedAt'>[]): Promise<void>
@@ -25,6 +37,21 @@ export interface GithubMetadataStore {
25
37
  listGithubRepositoryVisibility(repoIds: readonly number[]): Promise<GithubRepositoryVisibility[]>
26
38
  setGithubRepositoryVisibility(entries: readonly GithubRepositoryVisibility[]): Promise<void>
27
39
  }
40
+ export interface GithubCommitStore {
41
+ listGithubCommits(keys: readonly string[]): Promise<GithubCommit[]>
42
+ setGithubCommits(entries: readonly GithubCommit[]): Promise<void>
43
+ // The cached tags that point to each commit, by githubCommitKey.
44
+ listGithubCommitTags(keys: readonly string[]): Promise<{ key: string; name: string }[]>
45
+ // `complete` is a listing of every tag the repository has: tags it leaves
46
+ // out were deleted. Otherwise only the listed tags are updated. `observedUs`
47
+ // orders overlapping refreshes (see githubCommitMethods).
48
+ refreshGithubTags(repoId: number, tags: readonly GithubTag[], complete: boolean, observedUs: number): Promise<void>
49
+ }
50
+
51
+ // A stable repository ID survives renames, as in the PR metadata keys.
52
+ export function githubCommitKey(repoId: number, sha: string): string {
53
+ return `${repoId}:${sha}`
54
+ }
28
55
 
29
56
  // Successful GitHub reads are shared and retained without eviction. The stable
30
57
  // repository ID in the key survives renames, but is never an access grant.
@@ -48,6 +75,33 @@ CREATE TABLE IF NOT EXISTS managed_github_repository_visibility (
48
75
  checked_at INTEGER NOT NULL
49
76
  ) STRICT;
50
77
  `
78
+ // Bundles show their commit's details and tags from these tables. Readers rely
79
+ // on bundle access alone; only a GitHub read made with access fills them.
80
+ // Commits are retained without eviction. Create a bundle refreshes a
81
+ // repository's tags, which go with the repository.
82
+ export const GITHUB_COMMIT_SCHEMA = `
83
+ CREATE TABLE IF NOT EXISTS managed_github_commit (
84
+ cache_key TEXT PRIMARY KEY,
85
+ message TEXT NOT NULL,
86
+ author_name TEXT,
87
+ author_login TEXT,
88
+ authored_at INTEGER,
89
+ committed_at INTEGER,
90
+ fetched_at INTEGER NOT NULL
91
+ ) STRICT;
92
+ CREATE TABLE IF NOT EXISTS managed_github_tag (
93
+ repo_id INTEGER NOT NULL REFERENCES managed_selected_repo(repo_id) ON DELETE CASCADE,
94
+ name TEXT NOT NULL,
95
+ commit_key TEXT NOT NULL,
96
+ observed_us INTEGER NOT NULL,
97
+ PRIMARY KEY (repo_id, name)
98
+ ) STRICT;
99
+ CREATE INDEX IF NOT EXISTS managed_github_tag_commit_idx ON managed_github_tag(commit_key);
100
+ CREATE TABLE IF NOT EXISTS managed_github_tag_listing (
101
+ repo_id INTEGER PRIMARY KEY REFERENCES managed_selected_repo(repo_id) ON DELETE CASCADE,
102
+ observed_us INTEGER NOT NULL
103
+ ) STRICT;
104
+ `
51
105
 
52
106
  export function githubMetadataMethods(db: ManagedSql): GithubMetadataStore {
53
107
  const list = db.prepare(`SELECT cache_key AS key, title, description, status, state_reason AS stateReason, fetched_at AS fetchedAt, attempted_at AS attemptedAt
@@ -96,3 +150,64 @@ export function githubMetadataMethods(db: ManagedSql): GithubMetadataStore {
96
150
  },
97
151
  }
98
152
  }
153
+
154
+ export function githubCommitMethods(db: ManagedSql): GithubCommitStore {
155
+ const commits = db.prepare(`SELECT cache_key AS key, message, author_name AS authorName, author_login AS authorLogin,
156
+ authored_at AS authoredAt, committed_at AS committedAt, fetched_at AS fetchedAt
157
+ FROM managed_github_commit WHERE cache_key IN (SELECT value FROM json_each(?))`)
158
+ // Concurrent reads of one commit store the same details; the first stays.
159
+ const setCommits = db.prepare(`INSERT INTO managed_github_commit (cache_key, message, author_name, author_login, authored_at, committed_at, fetched_at)
160
+ SELECT json_extract(e.value, '$.key'), json_extract(e.value, '$.message'), json_extract(e.value, '$.authorName'),
161
+ json_extract(e.value, '$.authorLogin'), CAST(json_extract(e.value, '$.authoredAt') AS BIGINT),
162
+ CAST(json_extract(e.value, '$.committedAt') AS BIGINT), CAST(json_extract(e.value, '$.fetchedAt') AS BIGINT)
163
+ FROM json_each(:contexts) e WHERE 1 = 1
164
+ ON CONFLICT (cache_key) DO NOTHING`)
165
+ const commitTags = db.prepare(`SELECT commit_key AS key, name FROM managed_github_tag
166
+ WHERE commit_key IN (SELECT value FROM json_each(?)) ORDER BY commit_key, name`)
167
+ // Overlapping browses can finish out of order, so every refresh carries
168
+ // when it was observed (in microseconds). Each tag keeps the time it was
169
+ // last observed and only a later observation moves it; a complete listing
170
+ // deletes only the tags observed before it. The listing table holds the
171
+ // last complete listing applied: one observed no later knew whether every
172
+ // tag existed, so a refresh it supersedes is skipped (a tie keeps the first).
173
+ const lastListing = db.prepare('SELECT observed_us AS observedUs FROM managed_github_tag_listing WHERE repo_id = ?')
174
+ const setListing = db.prepare(`INSERT INTO managed_github_tag_listing (repo_id, observed_us) VALUES (?, ?)
175
+ ON CONFLICT (repo_id) DO UPDATE SET observed_us = excluded.observed_us`)
176
+ const deleteTags = db.prepare(`DELETE FROM managed_github_tag WHERE repo_id = ? AND observed_us < ?
177
+ AND name NOT IN (SELECT value FROM json_each(?))`)
178
+ const setTags = db.prepare(`INSERT INTO managed_github_tag (repo_id, name, commit_key, observed_us)
179
+ SELECT CAST(json_extract(e.value, '$.repoId') AS BIGINT), json_extract(e.value, '$.name'), json_extract(e.value, '$.key'),
180
+ CAST(json_extract(e.value, '$.observedUs') AS BIGINT)
181
+ FROM json_each(:contexts) e WHERE 1 = 1
182
+ ON CONFLICT (repo_id, name) DO UPDATE SET commit_key = excluded.commit_key, observed_us = excluded.observed_us
183
+ WHERE managed_github_tag.observed_us < excluded.observed_us`)
184
+ return {
185
+ async listGithubCommits(keys) {
186
+ if (keys.length === 0) return []
187
+ const rows = await commits.all(JSON.stringify(keys)) as GithubCommit[]
188
+ return rows.map(row => ({ ...row }))
189
+ },
190
+ async setGithubCommits(entries) {
191
+ if (entries.length > 0) await setCommits.run({ contexts: JSON.stringify(entries) })
192
+ },
193
+ async listGithubCommitTags(keys) {
194
+ if (keys.length === 0) return []
195
+ const rows = await commitTags.all(JSON.stringify(keys)) as { key: string; name: string }[]
196
+ return rows.map(row => ({ ...row }))
197
+ },
198
+ async refreshGithubTags(repoId, tags, complete, observedUs) {
199
+ // Writes hold the store's writer lock: the listing read stays current.
200
+ const last = (await lastListing.get(repoId) as { observedUs: number } | undefined)?.observedUs
201
+ if (last !== undefined && last >= observedUs) return
202
+ // Postgres refuses an upsert that names one row twice.
203
+ const unique = [...new Map(tags.map(tag => [tag.name, tag])).values()]
204
+ if (complete) {
205
+ await setListing.run(repoId, observedUs)
206
+ await deleteTags.run(repoId, observedUs, JSON.stringify(unique.map(tag => tag.name)))
207
+ }
208
+ if (unique.length > 0) {
209
+ await setTags.run({ contexts: JSON.stringify(unique.map(tag => ({ repoId, name: tag.name, key: githubCommitKey(repoId, tag.sha), observedUs }))) })
210
+ }
211
+ },
212
+ }
213
+ }
@@ -7,6 +7,7 @@ import type { ManagedConfig } from './config.ts'
7
7
  import type { ManagedDb, ManagedUser, UserTokens } from './db.ts'
8
8
  import { randomToken, safeEqual } from './crypto.ts'
9
9
  import { STATE_COOKIE, buildCookie, clearCookie, cookieName, createSession, parseCookies } from './session.ts'
10
+ import { isViewing } from './view-as.ts'
10
11
 
11
12
  const GITHUB_AUTHORIZE_URL = 'https://github.com/login/oauth/authorize'
12
13
  const GITHUB_TOKEN_URL = 'https://github.com/login/oauth/access_token'
@@ -122,22 +123,57 @@ async function postToken(payload: Record<string, string>, now: number, fetchImpl
122
123
  }
123
124
  }
124
125
 
126
+ // The refreshes in flight in this process, by user. GitHub rotates refresh
127
+ // tokens and refuses a spent one (bad_refresh_token), so requests that find the
128
+ // token expired together share one refresh rather than race on the same token.
129
+ const refreshing = new Map<string, Promise<string | null>>()
130
+ // After GitHub refuses a refresh token, how long to wait before each further
131
+ // read for the pair another instance got with it: a second in all.
132
+ const STORED_PAIR_WAITS_MS = [100, 200, 300, 400]
133
+
125
134
  // Resolve a usable access token for a user: the stored one if still valid, else
126
135
  // refreshed (when a refresh token is on file) and re-persisted. Null when there
127
136
  // is no token or it's expired and unrefreshable — the caller prompts re-login.
128
137
  // A 60s skew margin avoids handing back a token about to expire mid-request.
138
+ // An admin viewing as another user never borrows that user's GitHub access.
129
139
  export async function ensureUserAccessToken(
130
140
  config: ManagedConfig, db: ManagedDb, userId: string, now: number, fetchImpl: typeof fetch = globalThis.fetch,
131
141
  ): Promise<string | null> {
142
+ if (isViewing()) return null
132
143
  const tokens = await db.getUserTokens(userId)
133
144
  if (tokens == null) return null
134
- if (tokens.expiresAt == null || tokens.expiresAt > now + 60_000) return tokens.accessToken
145
+ if (usableAt(tokens, now)) return tokens.accessToken
135
146
  if (tokens.refreshToken == null) return null
147
+ let pending = refreshing.get(userId)
148
+ if (pending == null) {
149
+ pending = refreshStoredToken(config, db, userId, tokens.accessToken, tokens.refreshToken, now, fetchImpl)
150
+ .finally(() => refreshing.delete(userId))
151
+ refreshing.set(userId, pending)
152
+ }
153
+ return pending
154
+ }
155
+
156
+ function usableAt(tokens: UserTokens, now: number): boolean {
157
+ return tokens.expiresAt == null || tokens.expiresAt > now + 60_000
158
+ }
159
+
160
+ async function refreshStoredToken(
161
+ config: ManagedConfig, db: ManagedDb, userId: string, staleToken: string, refreshToken: string, now: number, fetchImpl: typeof fetch,
162
+ ): Promise<string | null> {
136
163
  try {
137
- const fresh = await refreshUserToken(config, tokens.refreshToken, now, fetchImpl)
164
+ const fresh = await refreshUserToken(config, refreshToken, now, fetchImpl)
138
165
  await db.setUserTokens(userId, fresh)
139
166
  return fresh.accessToken
140
167
  } catch (err) {
168
+ // Another request or instance may have spent the refresh token first: use
169
+ // the pair it stored rather than leave this request to ask anonymously.
170
+ // Where GitHub refused the token, that pair may not be stored yet.
171
+ const waits = err instanceof OAuthError && err.message === 'github-token-denied' ? STORED_PAIR_WAITS_MS : []
172
+ for (const wait of [0, ...waits]) {
173
+ if (wait > 0) await new Promise(resolve => { setTimeout(resolve, wait) })
174
+ const stored = await db.getUserTokens(userId).catch(() => null)
175
+ if (stored != null && stored.accessToken !== staleToken && usableAt(stored, now)) return stored.accessToken
176
+ }
141
177
  console.warn('managed: token refresh failed:', err)
142
178
  return null
143
179
  }
@@ -1,5 +1,7 @@
1
1
  import type { ServerResponse } from 'node:http'
2
2
  import { Buffer } from 'node:buffer'
3
+ import { pipeline } from 'node:stream/promises'
4
+ import { createBrotliCompress, constants as zlibConstants } from 'node:zlib'
3
5
  import { UPLOAD_CHUNK_BYTES } from './uploads.ts'
4
6
 
5
7
  // Keep the header ASCII while filename* carries the original UTF-8 name.
@@ -22,3 +24,27 @@ export function sendJson(res: ServerResponse, status: number, body: unknown, hea
22
24
  res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store', ...headers })
23
25
  writeResponse(res, JSON.stringify(body))
24
26
  }
27
+
28
+ // Lines of JSON, Brotli-encoded and sent as each is written, so the reader
29
+ // has each while the next is made. A reader gone, or no longer `allowed`
30
+ // before a line, has the response cut short, as is one whose lines fail: a
31
+ // response that ends is one with every line.
32
+ export async function sendLines(res: ServerResponse, lines: AsyncIterable<unknown>, allowed: () => Promise<boolean>) {
33
+ res.writeHead(200, { 'content-type': 'application/x-ndjson', 'content-encoding': 'br', 'cache-control': 'private, no-store', 'x-content-type-options': 'nosniff' })
34
+ const encoder = createBrotliCompress({ params: { [zlibConstants.BROTLI_PARAM_QUALITY]: 4 } })
35
+ const sent = pipeline(encoder, res).catch(() => {})
36
+ const closed = new Promise(resolve => { res.once('close', resolve) })
37
+ try {
38
+ for await (const line of lines) {
39
+ if (res.destroyed || !await allowed()) { res.destroy(); return }
40
+ encoder.write(`${JSON.stringify(line)}\n`)
41
+ await Promise.race([new Promise<void>(resolve => { encoder.flush(() => { resolve() }) }), closed])
42
+ }
43
+ } catch (err) {
44
+ console.warn('managed: a streamed response failed:', err)
45
+ res.destroy()
46
+ return
47
+ }
48
+ encoder.end()
49
+ await sent
50
+ }