@preventive/triage 1.0.0-alpha.3 → 1.0.0-alpha.31

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 (183) hide show
  1. package/LICENSE-APACHE +202 -0
  2. package/api/reap.ts +17 -0
  3. package/cli.js +6 -0
  4. package/client/finding-link.js +305 -0
  5. package/client/linked-findings.d.ts +1 -0
  6. package/client/linked-findings.js +111 -0
  7. package/common/bundle-commit.d.ts +1 -0
  8. package/common/bundle-commit.js +4 -0
  9. package/common/bundle-metadata.d.ts +10 -0
  10. package/common/bundle-metadata.js +182 -0
  11. package/common/bundle-reasons.d.ts +2 -0
  12. package/common/bundle-reasons.js +21 -0
  13. package/common/bundle-source-links.d.ts +5 -0
  14. package/common/bundle-source-links.js +82 -0
  15. package/common/bundle-sources.d.ts +3 -0
  16. package/common/bundle-sources.js +284 -0
  17. package/common/bundle-stats.js +41 -0
  18. package/common/bundle-tabs.js +1 -0
  19. package/common/code-language.js +51 -0
  20. package/common/default-scan-models.ts +30 -0
  21. package/common/finding-id.js +47 -0
  22. package/common/github-issue-labels.d.ts +1 -0
  23. package/common/github-issue-labels.js +11 -0
  24. package/common/github-pr.ts +56 -0
  25. package/common/managed/comments.ts +34 -0
  26. package/common/managed/permissions.ts +35 -0
  27. package/common/managed/report-content.ts +42 -0
  28. package/common/managed/report-filter.ts +108 -0
  29. package/common/managed/roles.ts +28 -0
  30. package/common/managed/routes.d.ts +2 -0
  31. package/common/managed/routes.js +124 -0
  32. package/common/managed/scan-models.ts +6 -0
  33. package/common/managed/triage.ts +83 -0
  34. package/common/save-error-reason.ts +20 -7
  35. package/common/scan-server.ts +13 -0
  36. package/common/server-info.ts +35 -0
  37. package/common/utf8.d.ts +3 -0
  38. package/common/utf8.js +45 -0
  39. package/out/brotli-fallback.js +3 -3
  40. package/out/client-managed-import.js +83 -0
  41. package/out/client-managed.js +169 -0
  42. package/out/client-sync.js +16 -13
  43. package/out/github-issue-authorized.html +3 -0
  44. package/out/github-issue-authorized.js +1 -0
  45. package/out/graph.js +30 -4
  46. package/out/index.html +55 -8
  47. package/out/prism.js +2 -2
  48. package/out/stasis.svg +20 -0
  49. package/out/terminal.js +273 -39
  50. package/out/view.css +1 -1
  51. package/out/view.js +259 -63
  52. package/package.json +205 -57
  53. package/server-common/database-config.ts +16 -0
  54. package/server-common/initialize.ts +18 -0
  55. package/server-common/npm-advisories.ts +101 -0
  56. package/{server → server-common}/origin.ts +5 -5
  57. package/server-common/reap.ts +48 -0
  58. package/server-common/scan-config.ts +19 -0
  59. package/server-common/standalone.ts +29 -0
  60. package/server-common/storage-crypto.ts +182 -0
  61. package/server-common/storage-log.ts +38 -0
  62. package/server-common/vercel-blob.ts +103 -0
  63. package/server-e2e/app.ts +486 -0
  64. package/{server → server-e2e}/auth.ts +5 -1
  65. package/{server → server-e2e}/bus-receiver.ts +2 -1
  66. package/{server → server-e2e}/cli.js +9 -4
  67. package/{server → server-e2e}/config.ts +56 -35
  68. package/{server → server-e2e}/db-neon.ts +2 -2
  69. package/{server → server-e2e}/db-revision-sql.ts +1 -1
  70. package/{server → server-e2e}/db-stmt.ts +2 -2
  71. package/{server → server-e2e}/db.ts +7 -7
  72. package/{server → server-e2e}/http.ts +110 -12
  73. package/{server → server-e2e}/hub.ts +40 -9
  74. package/server-e2e/index.ts +17 -0
  75. package/server-e2e/lifecycle.ts +95 -0
  76. package/{server → server-e2e}/neon-driver.ts +2 -2
  77. package/{server → server-e2e}/npm-proxy.ts +11 -144
  78. package/{server → server-e2e}/objstore/blob-fs.ts +2 -2
  79. package/{server → server-e2e}/objstore/blob-vercel.ts +13 -117
  80. package/{server → server-e2e}/objstore/blob.ts +9 -2
  81. package/server-e2e/objstore/fetch-mint-guard.ts +74 -0
  82. package/{server → server-e2e}/objstore/handlers.ts +9 -8
  83. package/{server → server-e2e}/objstore/init.ts +35 -18
  84. package/{server → server-e2e}/objstore/reaper.ts +31 -11
  85. package/server-e2e/objstore/rest-deny.ts +28 -0
  86. package/server-e2e/objstore/rest-mint.ts +224 -0
  87. package/{server → server-e2e}/objstore/rest.ts +58 -33
  88. package/{server → server-e2e}/objstore/sign.ts +105 -0
  89. package/{server → server-e2e}/objstore/store-neon.ts +6 -10
  90. package/{server → server-e2e}/objstore/store.ts +7 -7
  91. package/{server → server-e2e}/objstore/tokens.ts +1 -1
  92. package/{server → server-e2e}/pubsub.ts +12 -9
  93. package/{server → server-e2e}/sign.ts +4 -4
  94. package/{server → server-e2e}/sse-server.ts +102 -68
  95. package/{server → server-e2e}/sse-session.ts +28 -8
  96. package/{server → server-e2e}/static.ts +31 -21
  97. package/server-e2e/sync-handlers.ts +408 -0
  98. package/{server → server-e2e}/util.ts +9 -0
  99. package/{server → server-e2e}/ws-server.ts +20 -9
  100. package/server-managed/activity.ts +231 -0
  101. package/server-managed/avatar-store.ts +12 -0
  102. package/server-managed/blob-store.ts +17 -0
  103. package/server-managed/brotli.ts +10 -0
  104. package/server-managed/bundle-advisories.ts +53 -0
  105. package/server-managed/bundle-advisory-inventory.ts +75 -0
  106. package/server-managed/bundle-build-diagnostics.d.ts +10 -0
  107. package/server-managed/bundle-build-diagnostics.js +23 -0
  108. package/server-managed/bundle-build-leases.ts +41 -0
  109. package/server-managed/bundle-build-worker.js +54 -0
  110. package/server-managed/bundle-build.ts +168 -0
  111. package/server-managed/bundle-cache.ts +166 -0
  112. package/server-managed/bundle-catalog.ts +29 -0
  113. package/server-managed/bundle-store.ts +24 -0
  114. package/server-managed/bundle-summary-cache.ts +116 -0
  115. package/server-managed/bundle.ts +104 -0
  116. package/server-managed/cache-storage.ts +19 -0
  117. package/server-managed/cli.js +21 -0
  118. package/server-managed/combined.ts +46 -0
  119. package/server-managed/comments.ts +151 -0
  120. package/server-managed/config.ts +149 -0
  121. package/server-managed/content-access.ts +15 -0
  122. package/server-managed/crypto.ts +25 -0
  123. package/server-managed/db-methods.ts +1517 -0
  124. package/server-managed/db-neon.ts +277 -0
  125. package/server-managed/db-schema.ts +206 -0
  126. package/server-managed/db-table-names.ts +22 -0
  127. package/server-managed/db.ts +120 -0
  128. package/server-managed/github-app.ts +385 -0
  129. package/server-managed/github-errors.ts +49 -0
  130. package/server-managed/github-issue-oauth.ts +48 -0
  131. package/server-managed/github-issues.ts +152 -0
  132. package/server-managed/github-metadata.ts +98 -0
  133. package/server-managed/github-oauth.ts +199 -0
  134. package/server-managed/github-pulls.ts +227 -0
  135. package/server-managed/http-response.ts +18 -0
  136. package/server-managed/http.ts +2350 -0
  137. package/server-managed/import-triage.ts +51 -0
  138. package/server-managed/index.ts +175 -0
  139. package/server-managed/managed-issues.ts +47 -0
  140. package/server-managed/management-catalog.ts +45 -0
  141. package/server-managed/management.ts +86 -0
  142. package/server-managed/object-storage-disk.ts +144 -0
  143. package/server-managed/object-storage-vercel.ts +63 -0
  144. package/server-managed/object-storage.ts +71 -0
  145. package/server-managed/package-entry-points.ts +51 -0
  146. package/server-managed/public-workspace.ts +146 -0
  147. package/server-managed/repo-path.ts +21 -0
  148. package/server-managed/report-migration.ts +35 -0
  149. package/server-managed/report-query.ts +10 -0
  150. package/server-managed/report-response.ts +16 -0
  151. package/server-managed/report-sources.ts +166 -0
  152. package/server-managed/repository-browser.ts +139 -0
  153. package/server-managed/repository-discovery.ts +82 -0
  154. package/server-managed/repository-policy.ts +27 -0
  155. package/server-managed/revisions.ts +35 -0
  156. package/server-managed/session.ts +78 -0
  157. package/server-managed/slugs.ts +38 -0
  158. package/server-managed/solidity-entry-points.ts +69 -0
  159. package/server-managed/sql-postgres.ts +31 -0
  160. package/server-managed/sql.ts +72 -0
  161. package/server-managed/static.ts +28 -0
  162. package/server-managed/storage-command.ts +16 -0
  163. package/server-managed/storage-db.ts +175 -0
  164. package/server-managed/storage-encryption.ts +211 -0
  165. package/server-managed/storage-maintenance.ts +223 -0
  166. package/server-managed/storage-payload.ts +72 -0
  167. package/server-managed/storage-stores.ts +81 -0
  168. package/server-managed/storage.ts +58 -0
  169. package/server-managed/team-catalog.ts +7 -0
  170. package/server-managed/team-feed.ts +173 -0
  171. package/server-managed/team-reports.ts +219 -0
  172. package/server-managed/triage-response.ts +16 -0
  173. package/server-managed/uploads.ts +65 -0
  174. package/server-managed/workspace-shares.ts +162 -0
  175. package/server.ts +50 -0
  176. package/server/index.ts +0 -477
  177. package/server/lifecycle.ts +0 -204
  178. package/server/sync-handlers.ts +0 -320
  179. /package/{LICENSE → LICENSE-MIT} +0 -0
  180. /package/{server → server-e2e}/config.example.json +0 -0
  181. /package/{server → server-e2e}/objstore/fs.ts +0 -0
  182. /package/{server → server-e2e}/peer.ts +0 -0
  183. /package/{server → server-e2e}/validation.ts +0 -0
@@ -0,0 +1,385 @@
1
+ // Repository discovery uses the signed-in user token and App installation
2
+ // tokens. Login and repository installation can use one GitHub App: user
3
+ // authorization requests account permissions, installation grants repository
4
+ // permissions. Discovery itself is read-only and skips archived repositories.
5
+ import { Buffer } from 'node:buffer'
6
+ import { createHash, createSign } from 'node:crypto'
7
+ import { createClient } from '@preventive/upstream/github.js'
8
+ import type { ManagedConfig } from './config.ts'
9
+ import { GithubApiError, throwGithubResponseError, upstreamGithub } from './github-errors.ts'
10
+
11
+ export { GithubApiError } from './github-errors.ts'
12
+
13
+ const GITHUB_API = 'https://api.github.com'
14
+ const API_VERSION = '2022-11-28'
15
+ const USER_AGENT = 'deepview-triage'
16
+ const PER_PAGE = 100
17
+ const INSTALLATION_TOKEN_TTL_MS = 5 * 60_000
18
+ const INSTALLATION_TOKEN_EXPIRY_MARGIN_MS = 60_000
19
+ const MAX_INSTALLATION_TOKENS = 256
20
+ interface InstallationTokenEntry { token: string | null; expiresAt: number; pending: Promise<string> | null }
21
+ // Credentials only: visibility, user identities, and permissions are never
22
+ // cached here. Bound memory per transport and separate App/key/install scopes.
23
+ const installationTokens = new WeakMap<typeof fetch, Map<string, InstallationTokenEntry>>()
24
+
25
+ function discardInstallationToken(token: string | null, fetchImpl: typeof fetch): void {
26
+ if (!token) return
27
+ const cache = installationTokens.get(fetchImpl)
28
+ if (!cache) return
29
+ for (const [key, entry] of cache) if (entry.token === token) cache.delete(key)
30
+ }
31
+
32
+ // One listed repository. Carries the context to read its contents later:
33
+ // `installationId` mints an App installation token (Contents: Read) for a repo
34
+ // reached through an installation — null for a public repo listed off the user
35
+ // token, readable without the App — and `defaultBranch` is the ref to read.
36
+ export interface ConnectedRepo {
37
+ id: number
38
+ fullName: string
39
+ private: boolean
40
+ visibility: 'public' | 'private' | 'internal' | null
41
+ htmlUrl: string
42
+ defaultBranch: string
43
+ installationId: number | null
44
+ }
45
+
46
+ // App installation credentials are configured (id + private key present) →
47
+ // private repos can be listed via its installation tokens.
48
+ export function githubAppConfigured(config: ManagedConfig): boolean {
49
+ return config.githubAppId != null && config.githubAppPrivateKey != null
50
+ }
51
+
52
+ // The install URL for the repositories App ("Connect a repository"), or null
53
+ // when no slug is set.
54
+ export function installUrl(config: ManagedConfig): string | null {
55
+ if (config.githubAppSlug == null) return null
56
+ return `https://github.com/apps/${encodeURIComponent(config.githubAppSlug)}/installations/new`
57
+ }
58
+
59
+ // Merge repo lists from every source, deduped by full name and sorted. Later
60
+ // sources win a collision (the handler lists private after public).
61
+ export function mergeRepos(...lists: ConnectedRepo[][]): ConnectedRepo[] {
62
+ const byName = new Map<string, ConnectedRepo>()
63
+ for (const list of lists) {
64
+ for (const repo of list) byName.set(repo.fullName, repo)
65
+ }
66
+ return [...byName.values()].toSorted((a, b) => a.fullName.localeCompare(b.fullName))
67
+ }
68
+
69
+ // One GitHub API call returning parsed JSON, optionally Bearer-authed by a
70
+ // user token, App JWT, or installation token. Network / non-2xx / malformed
71
+ // fold into a GithubApiError (401 passes through for the caller to handle).
72
+ export async function githubJson(url: string, token: string | null, fetchImpl: typeof fetch, method: 'GET' | 'POST' = 'GET'): Promise<unknown> {
73
+ let res: Response
74
+ try {
75
+ res = await fetchImpl(url, {
76
+ method,
77
+ signal: AbortSignal.timeout(10_000),
78
+ redirect: 'error',
79
+ headers: {
80
+ ...(token == null ? {} : { authorization: `Bearer ${token}` }), 'accept': 'application/vnd.github+json',
81
+ 'user-agent': USER_AGENT, 'x-github-api-version': API_VERSION,
82
+ },
83
+ })
84
+ } catch { throw new GithubApiError(502, 'github-unreachable') }
85
+ if (res.status === 401) discardInstallationToken(token, fetchImpl)
86
+ if (!res.ok) await throwGithubResponseError(res)
87
+ try { return await res.json() } catch { throw new GithubApiError(502, 'github-malformed') }
88
+ }
89
+
90
+ // Validate one repo object into a ConnectedRepo (or null to skip), tagging it
91
+ // with the installation it was reached through (null for the user-token path). A
92
+ // missing numeric id or full name → skip (can't key/locate it). Archived repos
93
+ // are skipped — read-only history, not triage targets.
94
+ function parseRepo(raw: unknown, installationId: number | null): ConnectedRepo | null {
95
+ if (raw == null || typeof raw !== 'object') return null
96
+ if ((raw as { archived?: unknown }).archived === true) return null
97
+ const id = (raw as { id?: unknown }).id
98
+ const fullName = (raw as { full_name?: unknown }).full_name
99
+ if (typeof id !== 'number' || !Number.isSafeInteger(id) || id <= 0) return null
100
+ if (typeof fullName !== 'string' || fullName === '') return null
101
+ const htmlUrl = (raw as { html_url?: unknown }).html_url
102
+ const branch = (raw as { default_branch?: unknown }).default_branch
103
+ const visibility = (raw as { visibility?: unknown }).visibility
104
+ return {
105
+ id,
106
+ fullName,
107
+ private: (raw as { private?: unknown }).private === true,
108
+ visibility: visibility === 'public' || visibility === 'private' || visibility === 'internal' ? visibility : null,
109
+ htmlUrl: typeof htmlUrl === 'string' ? htmlUrl : '',
110
+ defaultBranch: typeof branch === 'string' ? branch : '',
111
+ installationId,
112
+ }
113
+ }
114
+
115
+ // Accept an owner/repo or exact GitHub repository URL, never an arbitrary API
116
+ // path. Reject traversal and encoded separators before constructing a fixed URL.
117
+ export function publicRepositoryName(raw: unknown): string | null {
118
+ if (typeof raw !== 'string') return null
119
+ const name = raw.trim().replace(/^https:\/\/github\.com\//iu, '').replace(/\/$/u, '')
120
+ if (!/^[a-z\d](?:[a-z\d-]{0,38})\/[a-z\d_.-]{1,100}$/iu.test(name)) return null
121
+ if (['.', '..'].includes(name.split('/')[1]!)) return null
122
+ return name
123
+ }
124
+
125
+ // Public additions must be readable by the server without the acting user's
126
+ // credentials. Require explicit public visibility and use GitHub's canonical
127
+ // metadata, never client-supplied ids, installation ids, or branches.
128
+ export async function fetchPublicRepository(fullName: string): Promise<ConnectedRepo> {
129
+ if (publicRepositoryName(fullName) !== fullName) throw new GithubApiError(400, 'bad-repository')
130
+ const repo = parseRepo(await upstreamGithub(() => createClient({ token: null, userAgent: USER_AGENT }).getRepo({ repo: fullName })), null)
131
+ if (repo == null || repo.private || repo.visibility !== 'public') throw new GithubApiError(409, 'repo-not-public')
132
+ if (publicRepositoryName(repo.fullName) !== repo.fullName || repo.fullName.toLowerCase() !== fullName.toLowerCase()) throw new GithubApiError(502, 'github-malformed')
133
+ return { ...repo, htmlUrl: `https://github.com/${repo.fullName}` }
134
+ }
135
+
136
+ // ── PUBLIC: the user's own repos via their login token ──
137
+
138
+ // List the authenticated user's repositories (GET /user/repos), paginated until
139
+ // a short page, deduped + sorted. With an identity-only token this returns the
140
+ // user's PUBLIC repos. READ-ONLY.
141
+ export async function listUserRepos(accessToken: string): Promise<ConnectedRepo[]> {
142
+ const byName = new Map<string, ConnectedRepo>()
143
+ const body = await upstreamGithub(() => createClient({ token: accessToken, userAgent: USER_AGENT }).listUserRepos())
144
+ for (const r of body) {
145
+ const repo = parseRepo(r, null)
146
+ if (repo != null) byName.set(repo.fullName, repo)
147
+ }
148
+ return [...byName.values()].toSorted((a, b) => a.fullName.localeCompare(b.fullName))
149
+ }
150
+
151
+ // ── PRIVATE: App installations via installation tokens ──
152
+
153
+ function base64url(input: Buffer | string): string {
154
+ return Buffer.from(input).toString('base64url')
155
+ }
156
+
157
+ // Mint a short-lived App JWT (RS256) for the repositories App. iat backdated 60s
158
+ // for clock skew; exp 8 min out (within GitHub's 10-min cap); iss = the App id.
159
+ export function appJwt(appId: string, privateKeyPem: string, now: number = Date.now()): string {
160
+ const seconds = Math.floor(now / 1000)
161
+ const iat = seconds - 60
162
+ const header = base64url(JSON.stringify({ alg: 'RS256', typ: 'JWT' }))
163
+ const payload = base64url(JSON.stringify({ iat, exp: seconds + 480, iss: appId }))
164
+ const signingInput = `${header}.${payload}`
165
+ return `${signingInput}.${base64url(createSign('RSA-SHA256').update(signingInput).sign(privateKeyPem))}`
166
+ }
167
+
168
+ // The repositories App's installation ids (one per org/user that installed it).
169
+ async function listInstallationIds(jwt: string, fetchImpl: typeof fetch): Promise<number[]> {
170
+ const ids: number[] = []
171
+ for (let page = 1; ; page++) {
172
+ const body = await githubJson(`${GITHUB_API}/app/installations?per_page=${PER_PAGE}&page=${page}`, jwt, fetchImpl)
173
+ if (!Array.isArray(body)) throw new GithubApiError(502, 'github-malformed')
174
+ for (const inst of body) {
175
+ const id = (inst as { id?: unknown }).id
176
+ if (typeof id === 'number' && Number.isSafeInteger(id) && id > 0) ids.push(id)
177
+ }
178
+ if (body.length < PER_PAGE) break
179
+ }
180
+ return ids
181
+ }
182
+
183
+ // Reuse installation credentials only in this process. Refresh well before
184
+ // expiry and coalesce concurrent mints; failed or undated tokens are not cached.
185
+ async function installationAccessToken(appId: string, privateKey: string, installId: number, fetchImpl: typeof fetch): Promise<string> {
186
+ let cache = installationTokens.get(fetchImpl)
187
+ if (!cache) { cache = new Map(); installationTokens.set(fetchImpl, cache) }
188
+ const now = Date.now()
189
+ for (const [key, entry] of cache) if (!entry.pending && entry.expiresAt <= now) cache.delete(key)
190
+ const key = JSON.stringify([appId, createHash('sha256').update(privateKey).digest('hex'), installId])
191
+ const cached = cache.get(key)
192
+ if (cached) {
193
+ cache.delete(key)
194
+ cache.set(key, cached)
195
+ if (cached.pending) return cached.pending
196
+ return cached.token!
197
+ }
198
+ const entry: InstallationTokenEntry = { token: null, expiresAt: 0, pending: null }
199
+ const pending = Promise.resolve().then(async () => {
200
+ try {
201
+ const jwt = appJwt(appId, privateKey)
202
+ const body = await githubJson(`${GITHUB_API}/app/installations/${installId}/access_tokens`, jwt, fetchImpl, 'POST') as { token?: unknown; expires_at?: unknown }
203
+ if (typeof body?.token !== 'string' || body.token === '') throw new GithubApiError(502, 'github-install-token-denied')
204
+ entry.token = body.token
205
+ const expiresAt = typeof body.expires_at === 'string' ? Date.parse(body.expires_at) : NaN
206
+ entry.expiresAt = Number.isFinite(expiresAt) ? Math.min(now + INSTALLATION_TOKEN_TTL_MS, expiresAt - INSTALLATION_TOKEN_EXPIRY_MARGIN_MS) : 0
207
+ if (entry.expiresAt <= Date.now() && cache.get(key) === entry) cache.delete(key)
208
+ return body.token
209
+ } catch (err) {
210
+ if (cache.get(key) === entry) cache.delete(key)
211
+ throw err
212
+ } finally { entry.pending = null }
213
+ })
214
+ entry.pending = pending
215
+ cache.set(key, entry)
216
+ if (cache.size > MAX_INSTALLATION_TOKENS) cache.delete(cache.keys().next().value!)
217
+ return await pending
218
+ }
219
+
220
+ // Resolve this exact repository with the App's identity, then confirm the
221
+ // installation token reaches the same numeric repository. A missing grant is
222
+ // actionable through installation; transport/auth/rate errors must stay errors.
223
+ export async function repositoryInstallation(config: ManagedConfig, repoId: number, fullName: string, fetchImpl: typeof fetch = globalThis.fetch): Promise<number | null> {
224
+ const { githubAppId, githubAppPrivateKey } = config
225
+ if (githubAppId == null || githubAppPrivateKey == null) throw new GithubApiError(503, 'github-app-not-configured')
226
+ if (publicRepositoryName(fullName) !== fullName) throw new GithubApiError(409, 'repo-identity-changed')
227
+ const path = fullName.split('/').map(encodeURIComponent).join('/')
228
+ const jwt = appJwt(githubAppId, githubAppPrivateKey)
229
+ let raw
230
+ try { raw = await githubJson(`${GITHUB_API}/repos/${path}/installation`, jwt, fetchImpl) } catch (err) {
231
+ if (err instanceof GithubApiError && err.status === 404) return null
232
+ throw err
233
+ }
234
+ const installation = raw as { id?: unknown; suspended_at?: unknown; permissions?: { contents?: unknown } } | null
235
+ const id = installation?.id
236
+ if (typeof id !== 'number' || !Number.isSafeInteger(id) || id <= 0) throw new GithubApiError(502, 'github-malformed')
237
+ if (installation?.suspended_at != null || !['read', 'write'].includes(String(installation?.permissions?.contents))) return null
238
+ const token = await installationAccessToken(githubAppId, githubAppPrivateKey, id, fetchImpl)
239
+ const repo = parseRepo(await githubJson(`${GITHUB_API}/repos/${path}`, token, fetchImpl), id)
240
+ if (repo == null || repo.id !== repoId || repo.fullName.toLowerCase() !== fullName.toLowerCase()) throw new GithubApiError(409, 'repo-identity-changed')
241
+ return id
242
+ }
243
+
244
+ // The `repositories` array of one /installation/repositories page → ConnectedRepo[],
245
+ // each tagged with the installation id that can read it.
246
+ function repoPage(body: unknown, installationId: number): ConnectedRepo[] {
247
+ const repositories = (body as { repositories?: unknown }).repositories
248
+ if (!Array.isArray(repositories)) return []
249
+ const out: ConnectedRepo[] = []
250
+ for (const r of repositories) {
251
+ const repo = parseRepo(r, installationId)
252
+ if (repo != null) out.push(repo)
253
+ }
254
+ return out
255
+ }
256
+
257
+ // Every repository one installation can read, paginated by the page count from
258
+ // `total_count` (avoids Link-header parsing).
259
+ async function listInstallationRepos(token: string, installationId: number, fetchImpl: typeof fetch): Promise<ConnectedRepo[]> {
260
+ const pageUrl = (page: number): string => `${GITHUB_API}/installation/repositories?per_page=${PER_PAGE}&page=${page}`
261
+ const first = await githubJson(pageUrl(1), token, fetchImpl) as { total_count?: unknown }
262
+ const repos = repoPage(first, installationId)
263
+ const total = typeof first.total_count === 'number' ? first.total_count : repos.length
264
+ const pages = Math.ceil(total / PER_PAGE)
265
+ for (let page = 2; page <= pages; page++) {
266
+ repos.push(...repoPage(await githubJson(pageUrl(page), token, fetchImpl), installationId))
267
+ }
268
+ return repos
269
+ }
270
+
271
+ // List every repo the repositories App is installed on, across installations
272
+ // (public + PRIVATE within those installs), deduped + sorted. Empty when the App
273
+ // isn't configured. READ-ONLY.
274
+ export async function listInstalledRepos(config: ManagedConfig, fetchImpl: typeof fetch = globalThis.fetch): Promise<ConnectedRepo[]> {
275
+ const { githubAppId, githubAppPrivateKey } = config
276
+ if (githubAppId == null || githubAppPrivateKey == null) return []
277
+ const jwt = appJwt(githubAppId, githubAppPrivateKey)
278
+ const lists = await mapGithubRequests(await listInstallationIds(jwt, fetchImpl), async (id) => {
279
+ const token = await installationAccessToken(githubAppId, githubAppPrivateKey, id, fetchImpl)
280
+ return listInstallationRepos(token, id, fetchImpl)
281
+ })
282
+ return mergeRepos(...lists)
283
+ }
284
+
285
+ // Bound fan-out while keeping independent installations/permission checks off
286
+ // one long serial critical path. Preserve input order for deterministic merges.
287
+ async function mapGithubRequests<T, U>(items: T[], work: (item: T) => Promise<U>): Promise<U[]> {
288
+ const results: U[] = []
289
+ let next = 0
290
+ await Promise.all(Array.from({ length: Math.min(4, items.length) }, async () => {
291
+ while (next < items.length) {
292
+ const index = next++
293
+ results[index] = await work(items[index]!)
294
+ }
295
+ }))
296
+ return results
297
+ }
298
+
299
+ // The login App and repository App are separate: /user/repos with the login
300
+ // token is not evidence of private-repo access. Ask the repository App for the
301
+ // user's effective GitHub permission (including teams/org/enterprise grants).
302
+ // This endpoint accepts installation tokens with Metadata: read; it does NOT
303
+ // require Administration permission (unlike collaborator mutation endpoints).
304
+ // https://docs.github.com/en/rest/collaborators/collaborators#get-repository-permissions-for-a-user
305
+ // Resolve the current login from /user so renamed handles cannot check someone
306
+ // else's access. All repo paths come from GitHub's installation catalogue.
307
+ export async function githubUserIdentity(userToken: string, fetchImpl: typeof fetch = globalThis.fetch): Promise<{ id: number; login: string }> {
308
+ const identity = await githubJson(`${GITHUB_API}/user`, userToken, fetchImpl) as { id?: unknown; login?: unknown }
309
+ if (typeof identity?.login !== 'string' || !identity.login || typeof identity.id !== 'number' || !Number.isSafeInteger(identity.id) || identity.id <= 0) throw new GithubApiError(502, 'github-malformed')
310
+ return { id: identity.id, login: identity.login }
311
+ }
312
+
313
+ export async function githubRepoReadPermission(fullName: string, token: string, identity: { id: number; login: string }, fetchImpl: typeof fetch = globalThis.fetch): Promise<boolean> {
314
+ const path = fullName.split('/').map(encodeURIComponent).join('/')
315
+ try {
316
+ const body = await githubJson(`${GITHUB_API}/repos/${path}/collaborators/${encodeURIComponent(identity.login)}/permission`, token, fetchImpl) as { permission?: unknown; user?: { id?: unknown } }
317
+ return body?.user?.id === identity.id && ['read', 'write', 'admin'].includes(String(body?.permission))
318
+ } catch (err) {
319
+ if (err instanceof GithubApiError && err.status === 404) return false
320
+ throw err // Never broaden access when GitHub cannot verify permission.
321
+ }
322
+ }
323
+
324
+ export async function filterInstalledRepos(config: ManagedConfig, repositories: ConnectedRepo[], userToken: string, fetchImpl: typeof fetch = globalThis.fetch): Promise<ConnectedRepo[]> {
325
+ const identity = await githubUserIdentity(userToken, fetchImpl)
326
+ const tokens = new Map<number, Promise<string | null>>()
327
+ const allowed = await mapGithubRequests(repositories, async (repo) => {
328
+ // Internal repos may have private=false; missing visibility is not proof
329
+ // of public access either. Only explicitly public repositories skip checks.
330
+ if (repo.visibility === 'public') return true
331
+ if (repo.installationId == null) return false
332
+ let token = tokens.get(repo.installationId)
333
+ if (!token) {
334
+ token = repoAccessToken(config, repo.installationId, fetchImpl)
335
+ tokens.set(repo.installationId, token)
336
+ }
337
+ const accessToken = await token
338
+ if (!accessToken) return false
339
+ return githubRepoReadPermission(repo.fullName, accessToken, identity, fetchImpl)
340
+ })
341
+ return repositories.filter((_, index) => allowed[index])
342
+ }
343
+
344
+ // Get a token to READ a selected repo's contents: an installation token (the
345
+ // App's Contents: Read) when the repo was reached through an installation, or
346
+ // null for a public repo readable unauthenticated. This is the whole point of
347
+ // persisting `installationId` — the stored context is enough to read.
348
+ export async function repoAccessToken(config: ManagedConfig, installationId: number | null, fetchImpl: typeof fetch = globalThis.fetch): Promise<string | null> {
349
+ const { githubAppId, githubAppPrivateKey } = config
350
+ if (installationId == null || githubAppId == null || githubAppPrivateKey == null) return null
351
+ return await installationAccessToken(githubAppId, githubAppPrivateKey, installationId, fetchImpl)
352
+ }
353
+
354
+ // ── merged listing ──
355
+
356
+ export interface RepoListing {
357
+ repositories: ConnectedRepo[]
358
+ tokenMissing: boolean
359
+ }
360
+
361
+ // Every repo a user can reach, merged (deduped + sorted): PUBLIC via their login
362
+ // token + PRIVATE via the App's installations. Each source is best-effort so one
363
+ // failing never blanks the other; `tokenMissing` flags a missing/stale user
364
+ // token (→ the page prompts re-login). A repo seen on both sides keeps the
365
+ // installation tag (private listed last → wins the merge).
366
+ export async function collectRepos(config: ManagedConfig, userToken: string | null, fetchImpl: typeof fetch = globalThis.fetch): Promise<RepoListing> {
367
+ let tokenMissing = userToken == null
368
+ const lists: ConnectedRepo[][] = []
369
+ if (userToken != null) {
370
+ try {
371
+ lists.push(await listUserRepos(userToken))
372
+ } catch (err) {
373
+ if (err instanceof GithubApiError && err.status === 401) tokenMissing = true
374
+ else console.warn('managed: public repo list failed:', err)
375
+ }
376
+ }
377
+ if (githubAppConfigured(config)) {
378
+ try {
379
+ lists.push(await listInstalledRepos(config, fetchImpl))
380
+ } catch (err) {
381
+ console.warn('managed: private repo list failed:', err)
382
+ }
383
+ }
384
+ return { repositories: mergeRepos(...lists), tokenMissing }
385
+ }
@@ -0,0 +1,49 @@
1
+ import { HttpError } from '@preventive/upstream/github.js'
2
+
3
+ // A failure carrying the HTTP status the router should surface. 401 passes
4
+ // through so the user-token path can map it to "log in again".
5
+ export class GithubApiError extends Error {
6
+ status: number
7
+ retryAfter: number | null
8
+ constructor(status: number, message: string, retryAfter: number | null = null) {
9
+ super(message)
10
+ this.name = 'GithubApiError'
11
+ this.status = status
12
+ this.retryAfter = retryAfter
13
+ }
14
+ }
15
+
16
+ export async function throwGithubResponseError(res: Response): Promise<never> {
17
+ if (res.status === 403 || res.status === 429) {
18
+ const body = await res.json().catch(() => null) as { message?: unknown } | null
19
+ if (res.status === 429 || res.headers.get('x-ratelimit-remaining') === '0' || res.headers.has('retry-after')
20
+ || (typeof body?.message === 'string' && /rate limit|abuse detection/iu.test(body.message))) {
21
+ const retry = Number(res.headers.get('retry-after'))
22
+ const reset = Number(res.headers.get('x-ratelimit-reset')) * 1000
23
+ const wait = Math.max(Number.isFinite(retry) ? retry : 0, Number.isFinite(reset) ? (reset - Date.now()) / 1000 : 0)
24
+ throw new GithubApiError(429, 'github-rate-limited', Math.max(60, Math.ceil(wait)))
25
+ }
26
+ }
27
+ if (res.status === 401) throw new GithubApiError(401, 'github-unauthorized')
28
+ if (res.status === 404) throw new GithubApiError(404, 'github-not-found')
29
+ throw new GithubApiError(502, `github-status-${res.status}`)
30
+ }
31
+
32
+ export async function upstreamGithub<T>(request: () => Promise<T>): Promise<T> {
33
+ try { return await request() } catch (error) {
34
+ if (error instanceof HttpError) {
35
+ // The upstream error includes GitHub's response text. Retain managed
36
+ // status handling, including rate limits, without exposing that text.
37
+ if (error.status === 401) throw new GithubApiError(401, 'github-unauthorized')
38
+ if (error.status === 404) throw new GithubApiError(404, 'github-not-found')
39
+ if (error.status === 429 || (error.status === 403 && /rate limit|abuse detection/iu.test(error.message))) {
40
+ throw new GithubApiError(429, 'github-rate-limited', 60)
41
+ }
42
+ throw new GithubApiError(502, `github-status-${error.status}`)
43
+ }
44
+ if (error instanceof Error && (error.name === 'AssertionError' || error.cause instanceof SyntaxError)) {
45
+ throw new GithubApiError(502, 'github-malformed')
46
+ }
47
+ throw new GithubApiError(502, 'github-unreachable')
48
+ }
49
+ }
@@ -0,0 +1,48 @@
1
+ import type { ManagedConfig } from './config.ts'
2
+ import type { ManagedDb, ManagedSession } from './db.ts'
3
+ import { randomToken, safeEqual } from './crypto.ts'
4
+ import { buildCookie, clearCookie, cookieName, parseCookies } from './session.ts'
5
+ import { OAuthError, ensureUserAccessToken, exchangeCode, fetchIdentity } from './github-oauth.ts'
6
+
7
+ export const ISSUE_LOGIN_PATH = '/api/oauth/github/issues/login'
8
+ const COOKIE = 'dvissuestate'
9
+
10
+ export function isIssueOAuthCallback(config: ManagedConfig, query: URLSearchParams, cookie: string | undefined): boolean {
11
+ const expected = parseCookies(cookie).get(cookieName(config, COOKIE))?.split('.')[0]
12
+ return !!expected && safeEqual(query.get('state') ?? '', expected)
13
+ }
14
+
15
+ export function issueLoginRedirect(config: ManagedConfig, session: ManagedSession, now = Date.now()) {
16
+ const state = randomToken()
17
+ const url = new URL('https://github.com/login/oauth/authorize')
18
+ url.searchParams.set('client_id', config.githubClientId)
19
+ url.searchParams.set('redirect_uri', config.oauthCallbackUrl)
20
+ url.searchParams.set('state', state)
21
+ url.searchParams.set('allow_signup', 'false')
22
+ // Reauthorize the same app for the current account, without replacing the
23
+ // DeepView session or permitting an OAuth account switch during creation.
24
+ const value = `${state}.${session.id}.${now}`
25
+ return { location: url.href, setCookie: buildCookie(cookieName(config, COOKIE), value,
26
+ { secure: config.cookieSecure, maxAgeS: 600, sameSite: 'Lax' }) }
27
+ }
28
+
29
+ export async function issueOAuthCallback(config: ManagedConfig, db: ManagedDb, session: ManagedSession,
30
+ query: URLSearchParams, cookie: string | undefined, fetchImpl: typeof fetch = fetch, now = Date.now()) {
31
+ const [expected, sessionId, timestamp] = (parseCookies(cookie).get(cookieName(config, COOKIE)) ?? '').split('.')
32
+ const code = query.get('code'), state = query.get('state')
33
+ if (!code || !state || !expected || !safeEqual(state, expected) || sessionId !== session.id
34
+ || !timestamp || !Number.isFinite(Number(timestamp)) || now < Number(timestamp) || now - Number(timestamp) > 600_000) {
35
+ throw new OAuthError(400, 'invalid-oauth-state')
36
+ }
37
+ const tokens = await exchangeCode(config, code, now, fetchImpl)
38
+ const identity = await fetchIdentity(tokens.accessToken, fetchImpl)
39
+ if (identity.githubUserId !== await db.getUserGithubId(session.userId)) throw new OAuthError(403, 'github-account-mismatch')
40
+ const current = await db.sessionWithUser(session.id, Date.now())
41
+ if (!current || current.user.role === 'none' || current.session.userId !== session.userId) throw new OAuthError(401, 'unauthenticated')
42
+ await db.setUserTokens(session.userId, tokens)
43
+ return { location: '/github-issue-authorized.html', setCookie: clearCookie(cookieName(config, COOKIE), config.cookieSecure) }
44
+ }
45
+
46
+ export function issueUserToken(config: ManagedConfig, db: ManagedDb, userId: string, fetchImpl: typeof fetch = fetch) {
47
+ return ensureUserAccessToken(config, db, userId, Date.now(), fetchImpl)
48
+ }
@@ -0,0 +1,152 @@
1
+ import type { ManagedConfig } from './config.ts'
2
+ import type { ManagedDb, ManagedSession } from './db.ts'
3
+ import type { BlobStore } from './blob-store.ts'
4
+ import type { ManagedIssue } from './managed-issues.ts'
5
+ import { randomToken } from './crypto.ts'
6
+ import { githubIssueClosedReason } from '../common/github-pr.ts'
7
+ import { reportEntries, reportRepoGithub } from '@preventive/report'
8
+ import { newIssueLabels } from '../common/github-issue-labels.js'
9
+ import { appJwt, githubAppConfigured, installUrl, publicRepositoryName } from './github-app.ts'
10
+ import { ISSUE_LOGIN_PATH, issueUserToken } from './github-issue-oauth.ts'
11
+ import { TeamReportsError, loadTeamReports, recheckTeam, teamSnapshot } from './team-reports.ts'
12
+
13
+ export const MAX_ISSUE_BODY_BYTES = 512_000
14
+ export class IssueError extends Error {
15
+ status: number
16
+ constructor(status: number, message: string) { super(message); this.status = status }
17
+ }
18
+
19
+ type Context = { reportId: string; findingId: string; repository: string }
20
+ export function parseIssueContext(raw: unknown): Context {
21
+ const value = raw as Partial<Context> | null
22
+ const repository = publicRepositoryName(value?.repository)
23
+ if (!repository || typeof value?.reportId !== 'string' || !value.reportId || value.reportId.length > 200
24
+ || typeof value.findingId !== 'string' || !value.findingId || value.findingId.length > 4096) throw new IssueError(400, 'bad-request')
25
+ return { repository, reportId: value.reportId, findingId: value.findingId }
26
+ }
27
+
28
+ async function github(path: string, token: string, fetchImpl: typeof fetch, payload?: unknown) {
29
+ let response: Response
30
+ try {
31
+ response = await fetchImpl(`https://api.github.com${path}`, {
32
+ method: payload === undefined ? 'GET' : 'POST', redirect: 'error', signal: AbortSignal.timeout(15_000),
33
+ headers: { authorization: `Bearer ${token}`, accept: 'application/vnd.github+json',
34
+ 'user-agent': 'deepview-triage', 'x-github-api-version': '2022-11-28', 'content-type': 'application/json' },
35
+ ...(payload === undefined ? {} : { body: JSON.stringify(payload) }),
36
+ })
37
+ } catch { throw new IssueError(502, payload === undefined ? 'github-unavailable' : 'github-create-uncertain') }
38
+ return response
39
+ }
40
+
41
+ function referenceResult(stored: ManagedIssue, repoIds: number[]) {
42
+ // A shared finding ID does not grant access to another team's repository.
43
+ if (!repoIds.includes(stored.repoId)) return { mode: 'unavailable' as const }
44
+ return stored.issueUrl ? { mode: 'existing' as const, url: stored.issueUrl }
45
+ : { mode: 'pending' as const, repositoryUrl: `https://github.com/${stored.repository}/issues` }
46
+ }
47
+
48
+ function issueDetails(raw: unknown, url: string, number: number, fallback: { title: string; body: string; labels: string[] }) {
49
+ const issue = raw as { title?: unknown; body?: unknown; state?: unknown; state_reason?: unknown;
50
+ user?: { login?: unknown }; labels?: ({ name?: unknown } | string)[] } | null
51
+ const status = issue?.state === 'closed' ? 'closed' : 'open'
52
+ return { url, number, title: typeof issue?.title === 'string' ? issue.title : fallback.title,
53
+ description: typeof issue?.body === 'string' ? issue.body : fallback.body, status,
54
+ stateReason: status === 'closed' ? githubIssueClosedReason(issue?.state_reason) : null,
55
+ author: typeof issue?.user?.login === 'string' ? issue.user.login : null,
56
+ labels: Array.isArray(issue?.labels) ? issue.labels.map(label => typeof label === 'string' ? label : label?.name).filter((label): label is string => typeof label === 'string') : fallback.labels }
57
+ }
58
+
59
+ // Authorize before any upstream lookup, including installation/label discovery.
60
+ // Report filtering also supplies the full row/link isSecurity classification.
61
+ export async function prepareGithubIssue(config: ManagedConfig, db: ManagedDb, store: BlobStore,
62
+ session: ManagedSession, teamId: string, context: Context, fetchImpl: typeof fetch = fetch) {
63
+ const snapshot = await teamSnapshot(db, session.id, teamId)
64
+ if (!snapshot.reports.some(report => report.id === context.reportId)) throw new TeamReportsError(404, 'no-report')
65
+ const reports = await loadTeamReports(db, store, snapshot)
66
+ const report = reports.find(item => item.id === context.reportId)!
67
+ const findings = (reportEntries(report.data) ?? []).flat() as Record<string, unknown>[]
68
+ const finding = findings.find(item => item['id'] === context.findingId)
69
+ if (!finding) throw new TeamReportsError(404, 'no-finding')
70
+ const recheck = () => recheckTeam(db, session.id, snapshot)
71
+ await recheck()
72
+ const labels = newIssueLabels(finding['isSecurity'] === true, config.githubNewIssueLabels ?? '')
73
+ const repoIds = snapshot.repositories.map(repo => repo.repoId)
74
+ const stored = await db.getManagedIssue(context.findingId)
75
+ if (stored) return { ...referenceResult(stored, repoIds), labels, recheck }
76
+ // The target comes from this finding's own source, or the report's managed
77
+ // assignment. Team membership alone must not let a caller claim an unrelated
78
+ // repository's issue as the permanent reference for this finding.
79
+ const findingRepo = finding['repo'] as { github?: unknown } | null | undefined
80
+ const targetRepository = publicRepositoryName(reportRepoGithub({ repo: { github: findingRepo?.github || report.repo.github } }))
81
+ if (!targetRepository || targetRepository.toLowerCase() !== context.repository.toLowerCase()) throw new IssueError(400, 'bad-issue-repository')
82
+ const repository = snapshot.repositories.find(repo => repo.github.toLowerCase() === targetRepository.toLowerCase())
83
+ if (!repository || !githubAppConfigured(config)) return { mode: 'form' as const, labels, recheck }
84
+ const path = `/repos/${targetRepository.split('/').map(encodeURIComponent).join('/')}`
85
+ const installation = await github(`${path}/installation`, appJwt(config.githubAppId!, config.githubAppPrivateKey!), fetchImpl)
86
+ if (installation.status === 404) return { mode: 'form' as const, labels, recheck }
87
+ if (!installation.ok) throw new IssueError(502, 'github-unavailable')
88
+ let installed: { permissions?: { issues?: string } }
89
+ try { installed = await installation.json() } catch { throw new IssueError(502, 'github-unavailable') }
90
+ if (installed?.permissions?.issues !== 'write') return { mode: 'permissions' as const, authorizationPath: installUrl(config), labels, recheck }
91
+ const token = await issueUserToken(config, db, session.userId, fetchImpl)
92
+ if (!token) return { mode: 'authorize' as const, authorizationPath: ISSUE_LOGIN_PATH, labels, recheck }
93
+ // All requested labels are optional, including security and operator labels.
94
+ // Omit missing names rather than rejecting issue creation or creating labels.
95
+ const existingLabels: string[] = []
96
+ for (const name of labels) {
97
+ const label = await github(`${path}/labels/${encodeURIComponent(name)}`, token, fetchImpl)
98
+ if (label.status === 401) return { mode: 'authorize' as const, authorizationPath: ISSUE_LOGIN_PATH, labels, recheck }
99
+ if (label.status === 404) continue
100
+ if (!label.ok) throw new IssueError(502, 'github-unavailable')
101
+ existingLabels.push(name)
102
+ }
103
+ return { mode: 'api' as const, token, path, labels: existingLabels, recheck,
104
+ db, findingId: context.findingId, repoId: repository.repoId, repository: targetRepository, repoIds, userId: session.userId }
105
+ }
106
+
107
+ export async function createGithubIssue(prepared: Awaited<ReturnType<typeof prepareGithubIssue>>, input: unknown, fetchImpl: typeof fetch = fetch) {
108
+ if (prepared.mode !== 'api') throw new IssueError(409, 'github-authorization-required')
109
+ const { title, body } = input as { title?: unknown; body?: unknown }
110
+ if (typeof title !== 'string' || !title.trim() || title.length > 256 || typeof body !== 'string' || body.length > 65_536) throw new IssueError(400, 'bad-issue')
111
+ const { db, findingId } = prepared
112
+ const requestId = randomToken()
113
+ if (!await db.claimManagedIssue({ findingId, repoId: prepared.repoId, repository: prepared.repository,
114
+ requestId, createdBy: prepared.userId, createdAt: Date.now() })) {
115
+ const stored = await db.getManagedIssue(findingId)
116
+ await prepared.recheck()
117
+ // The competing request may have released its reservation after a definite
118
+ // failure. Still return a listing, never a fresh creation form, for this race.
119
+ return stored ? referenceResult(stored, prepared.repoIds)
120
+ : { mode: 'pending' as const, repositoryUrl: `https://github.com/${prepared.repository}/issues` }
121
+ }
122
+ // The last awaited operation before the external write checks current access.
123
+ // Release only when we know no issue was created; unknown outcomes stay claimed.
124
+ try { await prepared.recheck() }
125
+ catch (error) { await db.releaseManagedIssue(findingId, requestId); throw error }
126
+ const response = await github(`${prepared.path}/issues`, prepared.token, fetchImpl, { title: title.trim(), body, labels: prepared.labels })
127
+ if (response.status >= 400 && response.status < 500) await db.releaseManagedIssue(findingId, requestId)
128
+ if (response.status === 401) throw new IssueError(409, 'github-authorization-required')
129
+ if (response.status === 403) throw new IssueError(403, 'github-issue-forbidden')
130
+ if (!response.ok) throw new IssueError(response.status === 422 ? 422 : 502, response.status >= 500 ? 'github-create-uncertain' : 'github-create-failed')
131
+ let issue: { number?: unknown }
132
+ try { issue = await response.json() } catch { throw new IssueError(502, 'github-create-uncertain') }
133
+ if (!Number.isSafeInteger(issue?.number) || Number(issue.number) <= 0) throw new IssueError(502, 'github-create-uncertain')
134
+ // Construct the URL from validated inputs, never an upstream redirect target.
135
+ const number = Number(issue.number), url = `https://github.com${prepared.path.slice('/repos'.length)}/issues/${issue.number}`
136
+ if (!await db.finishManagedIssue(findingId, requestId, url)) throw new IssueError(502, 'github-create-uncertain')
137
+ let details = issueDetails(issue, url, number, { title, body, labels: prepared.labels }), detailsUnavailable = true
138
+ try {
139
+ const fetched = await github(`${prepared.path}/issues/${number}`, prepared.token, fetchImpl)
140
+ if (fetched.ok) {
141
+ details = issueDetails(await fetched.json(), url, number, { title, body, labels: prepared.labels })
142
+ detailsUnavailable = false
143
+ }
144
+ } catch { /* Creation and its permanent reference succeeded; detail reads are best effort. */ }
145
+ try { await prepared.recheck() }
146
+ catch {
147
+ // The external write and permanent reference succeeded. Withhold details
148
+ // if current access cannot be confirmed, but never advertise a safe retry.
149
+ return { mode: 'created-unavailable' as const }
150
+ }
151
+ return { url, issue: details, detailsUnavailable }
152
+ }