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

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 +81 -0
  41. package/out/client-managed.js +167 -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 +102 -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 +60 -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,98 @@
1
+ import type { GithubFixStatus, GithubIssueClosedReason } from '../common/github-pr.ts'
2
+ import type { ManagedSql } from './sql.ts'
3
+
4
+ export interface GithubMetadata {
5
+ key: string
6
+ title: string
7
+ description: string | null
8
+ status: GithubFixStatus
9
+ // NULL on legacy entries; 'unknown' means a closed issue was checked but
10
+ // GitHub supplied no recognized reason, so it needs no further backfill.
11
+ stateReason: GithubIssueClosedReason | null
12
+ fetchedAt: number
13
+ attemptedAt: number | null
14
+ }
15
+ export interface GithubRepositoryVisibility {
16
+ repoId: number
17
+ github: string
18
+ public: boolean
19
+ checkedAt: number
20
+ }
21
+ export interface GithubMetadataStore {
22
+ listGithubMetadata(keys: readonly string[]): Promise<GithubMetadata[]>
23
+ setGithubMetadata(entries: readonly Omit<GithubMetadata, 'attemptedAt'>[]): Promise<void>
24
+ recordGithubMetadataAttempts(keys: readonly string[], attemptedAt: number): Promise<void>
25
+ listGithubRepositoryVisibility(repoIds: readonly number[]): Promise<GithubRepositoryVisibility[]>
26
+ setGithubRepositoryVisibility(entries: readonly GithubRepositoryVisibility[]): Promise<void>
27
+ }
28
+
29
+ // Successful GitHub reads are shared and retained without eviction. The stable
30
+ // repository ID in the key survives renames, but is never an access grant.
31
+ // Callers must verify current user -> team -> repo grants plus either recently
32
+ // verified public visibility or the viewer's live GitHub access with their token.
33
+ export const GITHUB_STATE_REASON_COLUMN = "TEXT CHECK (state_reason IN ('completed', 'not_planned', 'duplicate', 'unknown'))"
34
+ export const GITHUB_METADATA_SCHEMA = `
35
+ CREATE TABLE IF NOT EXISTS managed_github_metadata (
36
+ cache_key TEXT PRIMARY KEY,
37
+ title TEXT NOT NULL,
38
+ description TEXT,
39
+ status TEXT NOT NULL CHECK (status IN ('open', 'draft', 'closed', 'merged')),
40
+ state_reason ${GITHUB_STATE_REASON_COLUMN},
41
+ fetched_at INTEGER NOT NULL,
42
+ attempted_at INTEGER
43
+ ) STRICT;
44
+ CREATE TABLE IF NOT EXISTS managed_github_repository_visibility (
45
+ repo_id INTEGER PRIMARY KEY,
46
+ github TEXT NOT NULL,
47
+ is_public INTEGER NOT NULL CHECK (is_public IN (0, 1)),
48
+ checked_at INTEGER NOT NULL
49
+ ) STRICT;
50
+ `
51
+
52
+ export function githubMetadataMethods(db: ManagedSql): GithubMetadataStore {
53
+ const list = db.prepare(`SELECT cache_key AS key, title, description, status, state_reason AS stateReason, fetched_at AS fetchedAt, attempted_at AS attemptedAt
54
+ FROM managed_github_metadata WHERE cache_key IN (SELECT value FROM json_each(?))`)
55
+ const set = db.prepare(`INSERT INTO managed_github_metadata (cache_key, title, description, status, state_reason, fetched_at)
56
+ SELECT json_extract(e.value, '$.key'), json_extract(e.value, '$.title'),
57
+ json_extract(e.value, '$.description'), json_extract(e.value, '$.status'), json_extract(e.value, '$.stateReason'),
58
+ CAST(json_extract(e.value, '$.fetchedAt') AS BIGINT)
59
+ FROM json_each(:contexts) e WHERE 1 = 1
60
+ ON CONFLICT (cache_key) DO UPDATE SET title = excluded.title, description = excluded.description,
61
+ status = excluded.status, state_reason = excluded.state_reason, fetched_at = excluded.fetched_at
62
+ WHERE managed_github_metadata.status != 'merged' AND managed_github_metadata.fetched_at <= excluded.fetched_at`)
63
+ // Scheduling attempts never overwrite metadata or make stale data fresh.
64
+ // The monotonic guard preserves a newer attempt recorded by another reader.
65
+ const attempt = db.prepare(`UPDATE managed_github_metadata SET attempted_at = ?
66
+ WHERE cache_key IN (SELECT value FROM json_each(?)) AND (attempted_at IS NULL OR attempted_at < ?)`)
67
+ const visibility = db.prepare(`SELECT repo_id AS repoId, github, is_public AS public, checked_at AS checkedAt
68
+ FROM managed_github_repository_visibility WHERE repo_id IN (SELECT CAST(value AS BIGINT) FROM json_each(?))`)
69
+ const setVisibility = db.prepare(`INSERT INTO managed_github_repository_visibility (repo_id, github, is_public, checked_at)
70
+ SELECT CAST(json_extract(e.value, '$.repoId') AS BIGINT), json_extract(e.value, '$.github'),
71
+ CAST(json_extract(e.value, '$.public') AS BIGINT), CAST(json_extract(e.value, '$.checkedAt') AS BIGINT)
72
+ FROM json_each(:contexts) e WHERE 1 = 1
73
+ ON CONFLICT (repo_id) DO UPDATE SET github = excluded.github, is_public = excluded.is_public, checked_at = excluded.checked_at
74
+ WHERE managed_github_repository_visibility.checked_at < excluded.checked_at
75
+ OR (managed_github_repository_visibility.checked_at = excluded.checked_at
76
+ AND (managed_github_repository_visibility.is_public = 1 OR excluded.is_public = 0))`)
77
+ return {
78
+ async listGithubMetadata(keys) {
79
+ if (keys.length === 0) return []
80
+ const rows = await list.all(JSON.stringify(keys)) as GithubMetadata[]
81
+ return rows.map(row => ({ ...row }))
82
+ },
83
+ async setGithubMetadata(entries) {
84
+ if (entries.length > 0) await set.run({ contexts: JSON.stringify(entries) })
85
+ },
86
+ async recordGithubMetadataAttempts(keys, attemptedAt) {
87
+ if (keys.length > 0) await attempt.run(attemptedAt, JSON.stringify(keys), attemptedAt)
88
+ },
89
+ async listGithubRepositoryVisibility(repoIds) {
90
+ if (repoIds.length === 0) return []
91
+ const rows = await visibility.all(JSON.stringify(repoIds)) as (Omit<GithubRepositoryVisibility, 'public'> & { public: number })[]
92
+ return rows.map(row => ({ ...row, public: row.public === 1 }))
93
+ },
94
+ async setGithubRepositoryVisibility(entries) {
95
+ if (entries.length > 0) await setVisibility.run({ contexts: JSON.stringify(entries.map(entry => ({ ...entry, public: entry.public ? 1 : 0 }))) })
96
+ },
97
+ }
98
+ }
@@ -0,0 +1,199 @@
1
+ // GitHub App user authorization for managed sign-in. Configure no additional
2
+ // account permissions on the App. Repository permissions are granted separately
3
+ // at installation; login does not install the App or request OAuth scopes.
4
+ import { Buffer } from 'node:buffer'
5
+ import type { AvatarStore } from './avatar-store.ts'
6
+ import type { ManagedConfig } from './config.ts'
7
+ import type { ManagedDb, ManagedUser, UserTokens } from './db.ts'
8
+ import { randomToken, safeEqual } from './crypto.ts'
9
+ import { STATE_COOKIE, buildCookie, clearCookie, cookieName, createSession, parseCookies } from './session.ts'
10
+
11
+ const GITHUB_AUTHORIZE_URL = 'https://github.com/login/oauth/authorize'
12
+ const GITHUB_TOKEN_URL = 'https://github.com/login/oauth/access_token'
13
+ const GITHUB_USER_URL = 'https://api.github.com/user'
14
+ const STATE_TTL_S = 600
15
+
16
+ export const LOGIN_PATH = '/api/oauth/github/login'
17
+ export const CALLBACK_PATH = '/api/oauth/github/callback'
18
+
19
+ // A failure with the HTTP status the router should surface.
20
+ export class OAuthError extends Error {
21
+ status: number
22
+ constructor(status: number, message: string) {
23
+ super(message)
24
+ this.name = 'OAuthError'
25
+ this.status = status
26
+ }
27
+ }
28
+
29
+ export interface CallbackResult { location: string; setCookies: string[] }
30
+ export interface CallbackDeps { config: ManagedConfig; db: ManagedDb; avatarStore?: AvatarStore; now?: number; fetchImpl?: typeof fetch }
31
+
32
+ // GET /api/oauth/github/login — redirect to GitHub, stashing the CSRF state. No
33
+ // `scope` is sent: as the GitHub App user-authorization flow, the App's own
34
+ // permission set governs access. Keep account permissions minimal; repository
35
+ // grants are approved separately when installing the App.
36
+ export function buildLoginRedirect(config: ManagedConfig): { location: string; setCookie: string } {
37
+ const state = randomToken()
38
+ const u = new URL(GITHUB_AUTHORIZE_URL)
39
+ u.searchParams.set('client_id', config.githubClientId)
40
+ u.searchParams.set('redirect_uri', config.oauthCallbackUrl)
41
+ u.searchParams.set('state', state)
42
+ u.searchParams.set('allow_signup', 'false')
43
+ const setCookie = buildCookie(cookieName(config, STATE_COOKIE), state, {
44
+ maxAgeS: STATE_TTL_S, secure: config.cookieSecure, sameSite: 'Lax',
45
+ })
46
+ return { location: u.toString(), setCookie }
47
+ }
48
+
49
+ // GET /api/oauth/github/callback?code&state — the GitHub OAuth hook.
50
+ export async function handleCallback(
51
+ query: URLSearchParams, cookieHeader: string | undefined, deps: CallbackDeps,
52
+ ): Promise<CallbackResult> {
53
+ const { config, db } = deps
54
+ const now = deps.now ?? Date.now()
55
+ const fetchImpl = deps.fetchImpl ?? globalThis.fetch
56
+ const code = query.get('code')
57
+ const state = query.get('state')
58
+ const stateCookie = parseCookies(cookieHeader).get(cookieName(config, STATE_COOKIE))
59
+ // CSRF: the `state` GitHub echoes back must match the one we set at /login.
60
+ if (!code || !state || !stateCookie || !safeEqual(state, stateCookie)) {
61
+ throw new OAuthError(400, 'invalid-oauth-state')
62
+ }
63
+ const tokens = await exchangeCode(config, code, now, fetchImpl)
64
+ const user = await fetchIdentity(tokens.accessToken, fetchImpl)
65
+ const { setCookie, userId } = await createSession(config, db, user, now)
66
+ // Persist the user token so the repositories page can list repos on demand.
67
+ await db.setUserTokens(userId, tokens)
68
+ // Cache the avatar for same-origin serving (the page's CSP forbids loading
69
+ // github's CDN directly). Best-effort: a fetch/store failure must not break
70
+ // login, and the avatar endpoint just 404s until a later login fills it.
71
+ if (deps.avatarStore != null && user.avatarUrl != null) {
72
+ await cacheAvatar(deps.avatarStore, userId, user.avatarUrl, fetchImpl).catch((err) => {
73
+ console.warn('managed: avatar cache failed:', err)
74
+ })
75
+ }
76
+ // Clear the spent state cookie, set the session cookie, land on the app.
77
+ return {
78
+ location: '/',
79
+ setCookies: [clearCookie(cookieName(config, STATE_COOKIE), config.cookieSecure), setCookie],
80
+ }
81
+ }
82
+
83
+ // Exchange the authorization code for a user-to-server token set.
84
+ export function exchangeCode(config: ManagedConfig, code: string, now: number, fetchImpl: typeof fetch): Promise<UserTokens> {
85
+ return postToken({
86
+ client_id: config.githubClientId, client_secret: config.githubClientSecret,
87
+ code, redirect_uri: config.oauthCallbackUrl,
88
+ }, now, fetchImpl)
89
+ }
90
+
91
+ // Refresh an expiring user-to-server token (GitHub Apps with expiring tokens
92
+ // enabled). Returns the fresh token set; OAuthError(502) on any failure.
93
+ export function refreshUserToken(config: ManagedConfig, refreshToken: string, now: number, fetchImpl: typeof fetch = globalThis.fetch): Promise<UserTokens> {
94
+ return postToken({
95
+ client_id: config.githubClientId, client_secret: config.githubClientSecret,
96
+ grant_type: 'refresh_token', refresh_token: refreshToken,
97
+ }, now, fetchImpl)
98
+ }
99
+
100
+ // POST the GitHub token endpoint and parse the {access_token, refresh_token?,
101
+ // expires_in?} body into a UserTokens (expiry resolved to an absolute ms time).
102
+ async function postToken(payload: Record<string, string>, now: number, fetchImpl: typeof fetch): Promise<UserTokens> {
103
+ let res: Response
104
+ try {
105
+ res = await fetchImpl(GITHUB_TOKEN_URL, {
106
+ method: 'POST',
107
+ headers: { 'accept': 'application/json', 'content-type': 'application/json' },
108
+ body: JSON.stringify(payload),
109
+ })
110
+ } catch { throw new OAuthError(502, 'github-token-unreachable') }
111
+ if (!res.ok) throw new OAuthError(502, `github-token-status-${res.status}`)
112
+ let body: unknown
113
+ try { body = await res.json() } catch { throw new OAuthError(502, 'github-token-malformed') }
114
+ const tok = (body as { access_token?: unknown }).access_token
115
+ if (typeof tok !== 'string' || tok === '') throw new OAuthError(502, 'github-token-denied')
116
+ const refresh = (body as { refresh_token?: unknown }).refresh_token
117
+ const expiresIn = (body as { expires_in?: unknown }).expires_in
118
+ return {
119
+ accessToken: tok,
120
+ refreshToken: typeof refresh === 'string' && refresh !== '' ? refresh : null,
121
+ expiresAt: typeof expiresIn === 'number' && expiresIn > 0 ? now + expiresIn * 1000 : null,
122
+ }
123
+ }
124
+
125
+ // Resolve a usable access token for a user: the stored one if still valid, else
126
+ // refreshed (when a refresh token is on file) and re-persisted. Null when there
127
+ // is no token or it's expired and unrefreshable — the caller prompts re-login.
128
+ // A 60s skew margin avoids handing back a token about to expire mid-request.
129
+ export async function ensureUserAccessToken(
130
+ config: ManagedConfig, db: ManagedDb, userId: string, now: number, fetchImpl: typeof fetch = globalThis.fetch,
131
+ ): Promise<string | null> {
132
+ const tokens = await db.getUserTokens(userId)
133
+ if (tokens == null) return null
134
+ if (tokens.expiresAt == null || tokens.expiresAt > now + 60_000) return tokens.accessToken
135
+ if (tokens.refreshToken == null) return null
136
+ try {
137
+ const fresh = await refreshUserToken(config, tokens.refreshToken, now, fetchImpl)
138
+ await db.setUserTokens(userId, fresh)
139
+ return fresh.accessToken
140
+ } catch (err) {
141
+ console.warn('managed: token refresh failed:', err)
142
+ return null
143
+ }
144
+ }
145
+
146
+ // Read the authenticated user's identity (`GET /user`).
147
+ export async function fetchIdentity(token: string, fetchImpl: typeof fetch): Promise<ManagedUser> {
148
+ let res: Response
149
+ try {
150
+ res = await fetchImpl(GITHUB_USER_URL, {
151
+ headers: {
152
+ 'authorization': `Bearer ${token}`, 'accept': 'application/vnd.github+json',
153
+ 'user-agent': 'deepview-triage', 'x-github-api-version': '2022-11-28',
154
+ },
155
+ })
156
+ } catch { throw new OAuthError(502, 'github-user-unreachable') }
157
+ if (!res.ok) throw new OAuthError(502, `github-user-status-${res.status}`)
158
+ let body: unknown
159
+ try { body = await res.json() } catch { throw new OAuthError(502, 'github-user-malformed') }
160
+ const user = parseGithubUser(body)
161
+ if (user == null) throw new OAuthError(502, 'github-user-invalid')
162
+ return user
163
+ }
164
+
165
+ // Validate GitHub's `GET /user` payload into a ManagedUser (or null).
166
+ function parseGithubUser(body: unknown): ManagedUser | null {
167
+ if (body == null || typeof body !== 'object') return null
168
+ const id = (body as { id?: unknown }).id
169
+ const login = (body as { login?: unknown }).login
170
+ if (typeof id !== 'number' || !Number.isSafeInteger(id) || typeof login !== 'string' || login === '') return null
171
+ const name = (body as { name?: unknown }).name
172
+ const avatar = (body as { avatar_url?: unknown }).avatar_url
173
+ return {
174
+ githubUserId: id, login,
175
+ name: typeof name === 'string' ? name : null,
176
+ avatarUrl: typeof avatar === 'string' ? avatar : null,
177
+ }
178
+ }
179
+
180
+ // Fetch the user's GitHub avatar and cache it for same-origin serving. The host
181
+ // is validated (githubusercontent.com over https) as SSRF defence-in-depth,
182
+ // even though the URL comes from GitHub's own /user response.
183
+ async function cacheAvatar(store: AvatarStore, id: string, avatarUrl: string, fetchImpl: typeof fetch): Promise<void> {
184
+ if (!isGithubAvatarUrl(avatarUrl)) return
185
+ // `redirect: 'manual'` so a 3xx off the (validated) avatars host can't bounce
186
+ // the fetch to an internal address — an opaque-redirect / 3xx fails the
187
+ // `res.ok` + image check below and is skipped (SSRF defence-in-depth).
188
+ const res = await fetchImpl(avatarUrl, { redirect: 'manual' })
189
+ const contentType = res.headers.get('content-type') ?? ''
190
+ if (!res.ok || !contentType.startsWith('image/')) return
191
+ await store.put(id, contentType, Buffer.from(await res.arrayBuffer()))
192
+ }
193
+
194
+ function isGithubAvatarUrl(raw: string): boolean {
195
+ let url: URL
196
+ try { url = new URL(raw) } catch { return false }
197
+ return url.protocol === 'https:'
198
+ && (url.hostname === 'avatars.githubusercontent.com' || url.hostname.endsWith('.githubusercontent.com'))
199
+ }
@@ -0,0 +1,227 @@
1
+ import { type GithubFixRef, type GithubFixResult, type GithubFixStatus, githubIssueClosedReason, isGithubRepoName, parseGithubFixUrl } from '../common/github-pr.ts'
2
+ import type { ManagedConfig } from './config.ts'
3
+ import type { ManagedDb, TeamReportAccessSnapshot } from './db.ts'
4
+ import type { GithubMetadata, GithubRepositoryVisibility } from './github-metadata.ts'
5
+ import { ensureUserAccessToken } from './github-oauth.ts'
6
+
7
+ type Metadata = Pick<GithubMetadata, 'title' | 'description' | 'status' | 'stateReason'>
8
+ const MAX_GITHUB_LOOKUPS = 200
9
+ const GITHUB_LOOKUP_TIMEOUT_MS = 10_000
10
+ const GITHUB_METADATA_TTL_MS = 60_000
11
+ const GITHUB_PUBLIC_VISIBILITY_TTL_MS = 60_000
12
+ const GITHUB_PRIVATE_REPOSITORIES_URL = 'https://api.github.com/user/repos?visibility=private&per_page=100&page=1'
13
+
14
+ type Repository = TeamReportAccessSnapshot['repositories'][number]
15
+ type RepositoryPayload = { id?: unknown; full_name?: unknown; private?: unknown; visibility?: unknown; permissions?: { pull?: unknown } }
16
+ type RepositoryChecks = { remaining: number; verified: Set<string> }
17
+ type RepositoryAccessContext = {
18
+ db: ManagedDb
19
+ visibility: GithubRepositoryVisibility[]
20
+ publicRepos: Set<string>
21
+ token: string | null
22
+ signal: AbortSignal
23
+ fetchImpl: typeof fetch
24
+ checks: RepositoryChecks
25
+ includePrivateList?: boolean
26
+ }
27
+
28
+ function githubRequest(token: string | null): RequestInit {
29
+ return {
30
+ headers: {
31
+ ...(token ? { authorization: `Bearer ${token}` } : {}), accept: 'application/vnd.github+json',
32
+ 'user-agent': 'deepview-triage', 'x-github-api-version': '2022-11-28',
33
+ },
34
+ redirect: 'error',
35
+ }
36
+ }
37
+
38
+ function repositoryMatches(repo: Repository, body: RepositoryPayload | null): boolean {
39
+ return body?.id === repo.repoId && typeof body.full_name === 'string' && body.full_name.toLowerCase() === repo.github.toLowerCase()
40
+ }
41
+
42
+ async function listPrivateRepositories(token: string, signal: AbortSignal, fetchImpl: typeof fetch): Promise<(RepositoryPayload | null)[]> {
43
+ try {
44
+ const response = await fetchImpl(GITHUB_PRIVATE_REPOSITORIES_URL, githubRequest(token))
45
+ if (!response.ok) return []
46
+ const body = await response.json() as (RepositoryPayload | null)[] | null
47
+ return Array.isArray(body) && body.length <= 100 && !signal.aborted ? body : []
48
+ } catch { return [] } // A failed list is unknown access; direct checks can still verify it.
49
+ }
50
+
51
+ // Public visibility can be reused briefly across users. Private access stays
52
+ // request-local: one page of the viewer's own repo list grants matching repos,
53
+ // then direct checks cover omissions, pagination and failed list requests.
54
+ async function readableRepositories(repositories: Repository[], { db, visibility, publicRepos, token, signal, fetchImpl, checks, includePrivateList = true }: RepositoryAccessContext): Promise<Set<string>> {
55
+ const readable = new Set(publicRepos)
56
+ const byId = new Map(repositories.map(repo => [repo.repoId, repo]))
57
+ const unverified = repositories.filter(repo => !readable.has(repo.github))
58
+ const updates = new Map<number, GithubRepositoryVisibility>()
59
+ const grant = (repo: Repository, body: RepositoryPayload, checkedAt: number) => {
60
+ const isPublic = body.private === false && body.visibility === 'public'
61
+ readable.add(repo.github)
62
+ checks.verified.add(repo.github)
63
+ if (isPublic) publicRepos.add(repo.github)
64
+ updates.set(repo.repoId, { repoId: repo.repoId, github: repo.github, public: isPublic, checkedAt })
65
+ }
66
+ if (includePrivateList && token && unverified.length > 0 && !signal.aborted) {
67
+ const checkedAt = Date.now()
68
+ for (const entry of await listPrivateRepositories(token, signal, fetchImpl)) {
69
+ const repo = typeof entry?.id === 'number' ? byId.get(entry.id) : undefined
70
+ if (repo && !readable.has(repo.github) && repositoryMatches(repo, entry) && entry?.permissions?.pull === true) grant(repo, entry, checkedAt)
71
+ }
72
+ }
73
+ const formerlyPublic = new Set(visibility.filter(entry => entry.public
74
+ && byId.get(entry.repoId)?.github.toLowerCase() === entry.github.toLowerCase()).map(entry => entry.repoId))
75
+ const pending = unverified.filter(repo => !readable.has(repo.github) && (token || formerlyPublic.has(repo.repoId))).slice(0, checks.remaining)
76
+ checks.remaining -= pending.length
77
+ for (let i = 0; i < pending.length && !signal.aborted; i += 4) {
78
+ await Promise.all(pending.slice(i, i + 4).map(async repo => {
79
+ if (signal.aborted) return
80
+ const checkedAt = Date.now()
81
+ try {
82
+ const response = await fetchImpl(`https://api.github.com/repos/${repo.github}`, githubRequest(token))
83
+ if (!response.ok) return
84
+ const body = await response.json() as RepositoryPayload | null
85
+ if (!signal.aborted && body && repositoryMatches(repo, body)
86
+ && (token || (body.private === false && body.visibility === 'public'))) grant(repo, body, checkedAt)
87
+ } catch { /* Unverified access must never expose cached metadata. */ }
88
+ }))
89
+ }
90
+ if (updates.size > 0) await db.setGithubRepositoryVisibility([...updates.values()])
91
+ return readable
92
+ }
93
+
94
+ async function verifyPublicRefreshes(config: ManagedConfig, userId: string, repositories: Repository[], context: RepositoryAccessContext): Promise<string | null> {
95
+ // Cached public metadata needs no credentials. New content uses a viewer
96
+ // token only after a live check, so privatization cannot publish private data
97
+ // under an old public grant. Anonymous reads remain available without a token.
98
+ const { db, publicRepos } = context
99
+ const token = context.token ?? await ensureUserAccessToken(config, db, userId, Date.now(), context.fetchImpl)
100
+ if (token) {
101
+ const currentPublic = new Set<string>()
102
+ const readable = await readableRepositories(repositories, { ...context, publicRepos: currentPublic, token, includePrivateList: false })
103
+ for (const repo of repositories) if (readable.has(repo.github) && !currentPublic.has(repo.github)) publicRepos.delete(repo.github)
104
+ }
105
+ return token
106
+ }
107
+
108
+ // ref.repo is always the canonical name from managed_selected_repo, never an
109
+ // owner/repo/path taken from a Fix URL. Redirects cannot move the user token.
110
+ async function fetchMetadata(ref: GithubFixRef, token: string | null, fetchImpl: typeof fetch): Promise<Metadata | null> {
111
+ try {
112
+ const response = await fetchImpl(`https://api.github.com/repos/${ref.repo}/${ref.kind === 'pull' ? 'pulls' : 'issues'}/${ref.number}`, githubRequest(token))
113
+ if (!response.ok) return null
114
+ const body = await response.json() as { number?: unknown; title?: unknown; body?: unknown; state?: unknown; merged?: unknown;
115
+ draft?: unknown; base?: { repo?: { full_name?: unknown } }; repository_url?: unknown; pull_request?: unknown; state_reason?: unknown } | null
116
+ if (body?.number !== ref.number || typeof body.title !== 'string' || !body.title.trim() || body.title.length > 1024
117
+ || !['open', 'closed'].includes(String(body.state)) || (body.body != null && typeof body.body !== 'string')) return null
118
+ let status: GithubFixStatus
119
+ if (ref.kind === 'pull') {
120
+ if (typeof body.merged !== 'boolean' || typeof body.base?.repo?.full_name !== 'string'
121
+ || body.base.repo.full_name.toLowerCase() !== ref.repo.toLowerCase()) return null
122
+ status = body.merged ? 'merged' : body.state === 'closed' ? 'closed' : body.draft === true ? 'draft' : 'open'
123
+ } else {
124
+ // The issues API also accepts PR numbers. Do not cache a PR as a closed
125
+ // ordinary issue: only the pulls API can establish its merged state.
126
+ if (body.pull_request != null || typeof body.repository_url !== 'string'
127
+ || body.repository_url.toLowerCase() !== `https://api.github.com/repos/${ref.repo}`.toLowerCase()) return null
128
+ status = body.state === 'closed' ? 'closed' : 'open'
129
+ }
130
+ const stateReason = ref.kind === 'issue' && status === 'closed' ? githubIssueClosedReason(body.state_reason) : null
131
+ return { title: body.title, description: body.body ?? null, status, stateReason }
132
+ } catch { return null }
133
+ }
134
+
135
+ // The workspace handler checks user -> team membership and finding visibility.
136
+ // Shared private metadata also requires this viewer's live GitHub access,
137
+ // including for admins. Only recently verified public repos skip that check.
138
+ export async function lookupFixes(config: ManagedConfig, db: ManagedDb, snapshot: TeamReportAccessSnapshot, urls: string[], fetchImpl: typeof fetch = globalThis.fetch): Promise<GithubFixResult[]> {
139
+ const allowed = new Map(snapshot.repositories.filter(repo => isGithubRepoName(repo.github)).map(repo => [repo.github.toLowerCase(), repo]))
140
+ const parsed = urls.map(parseGithubFixUrl)
141
+ const jobs = new Map<string, GithubFixRef>()
142
+ const keys = parsed.map(ref => {
143
+ const repo = ref && allowed.get(ref.repo.toLowerCase())
144
+ if (!ref || !repo) return null
145
+ const key = `${repo.repoId}:${ref.kind}:${ref.number}`
146
+ jobs.set(key, { ...ref, repo: repo.github })
147
+ return key
148
+ })
149
+ if (jobs.size === 0) return []
150
+ const now = Date.now()
151
+ const signal = AbortSignal.timeout(GITHUB_LOOKUP_TIMEOUT_MS)
152
+ const fetchWithinDeadline: typeof fetch = (input, init) => {
153
+ signal.throwIfAborted()
154
+ return fetchImpl(input, { ...init, signal })
155
+ }
156
+ const repositories = [...new Map([...jobs.values()].map(ref => {
157
+ const repo = allowed.get(ref.repo.toLowerCase())!
158
+ return [repo.repoId, repo] as const
159
+ })).values()]
160
+ const visibility = await db.listGithubRepositoryVisibility(repositories.map(repo => repo.repoId))
161
+ const byId = new Map(repositories.map(repo => [repo.repoId, repo]))
162
+ const publicRepos = new Set(visibility.filter(entry => entry.public && entry.checkedAt <= now
163
+ && entry.checkedAt + GITHUB_PUBLIC_VISIBILITY_TTL_MS > now
164
+ && byId.get(entry.repoId)?.github.toLowerCase() === entry.github.toLowerCase()).map(entry => byId.get(entry.repoId)!.github))
165
+ let token = repositories.some(repo => !publicRepos.has(repo.github))
166
+ ? await ensureUserAccessToken(config, db, snapshot.user.id, now, fetchWithinDeadline) : null
167
+ const checks: RepositoryChecks = { remaining: MAX_GITHUB_LOOKUPS, verified: new Set() }
168
+ const context = { db, visibility, publicRepos, token, signal, fetchImpl: fetchWithinDeadline, checks }
169
+ const readable = await readableRepositories(repositories, context)
170
+ const authorized = [...jobs.entries()].filter(([, ref]) => readable.has(ref.repo))
171
+ const metadata = new Map((await db.listGithubMetadata(authorized.map(([key]) => key))).map(entry => [entry.key, entry]))
172
+ const lastCheckedAt = (key: string) => {
173
+ const cached = metadata.get(key)
174
+ return Math.max(cached?.fetchedAt ?? 0, cached?.attemptedAt ?? 0)
175
+ }
176
+ const pending = authorized.filter(([key, ref]) => {
177
+ const cached = metadata.get(key)
178
+ // Backfill closed issues cached before closure reasons existed, within the
179
+ // same authorized/capped queue. Even an unknown reason completes backfill.
180
+ return !cached || (ref.kind === 'issue' && cached.status === 'closed' && cached.stateReason == null)
181
+ || (['open', 'draft'].includes(cached.status) && cached.fetchedAt + GITHUB_METADATA_TTL_MS <= now)
182
+ }).toSorted(([a], [b]) => lastCheckedAt(a) - lastCheckedAt(b)).slice(0, MAX_GITHUB_LOOKUPS)
183
+ // Only stale/missing, distinct, authorized entries consume the request budget.
184
+ // Prioritize missing/oldest entries so a large workspace makes progress.
185
+ if (pending.length > 0) {
186
+ const publicRefreshes = new Set(pending.filter(([, ref]) => publicRepos.has(ref.repo) && !checks.verified.has(ref.repo)).map(([, ref]) => ref.repo))
187
+ if (publicRefreshes.size > 0) token = await verifyPublicRefreshes(config, snapshot.user.id, repositories.filter(repo => publicRefreshes.has(repo.github)), context)
188
+ const fresh: Omit<GithubMetadata, 'attemptedAt'>[] = []
189
+ const attempted: string[] = []
190
+ // All waves and access checks share one deadline and four-call limit.
191
+ for (let i = 0; i < pending.length && !signal.aborted; i += 4) {
192
+ await Promise.all(pending.slice(i, i + 4).map(async ([key, ref]) => {
193
+ if (signal.aborted) return
194
+ if (metadata.has(key)) attempted.push(key)
195
+ const result = await fetchMetadata(ref, publicRepos.has(ref.repo) && !checks.verified.has(ref.repo) ? null : token, fetchWithinDeadline)
196
+ if (result) fresh.push({ key, ...result, fetchedAt: Date.now() })
197
+ }))
198
+ }
199
+ // Only started cached reads rotate. Missing entries retain priority, and
200
+ // jobs skipped by the cap, deadline or absent token keep their place.
201
+ if (attempted.length > 0) await db.recordGithubMetadataAttempts(attempted, Date.now())
202
+ if (fresh.length > 0) {
203
+ await db.setGithubMetadata(fresh)
204
+ // A concurrent merged result must win over a slower open/closed read.
205
+ for (const entry of await db.listGithubMetadata(fresh.map(row => row.key))) metadata.set(entry.key, entry)
206
+ }
207
+ }
208
+ if (publicRepos.size > 0) {
209
+ // Another request may already have discovered a public -> private change
210
+ // and written new private metadata. Honor that newer visibility observation.
211
+ const current = await db.listGithubRepositoryVisibility(repositories.filter(repo => publicRepos.has(repo.github)).map(repo => repo.repoId))
212
+ const stillPublic = new Set(current.filter(entry => entry.public
213
+ && byId.get(entry.repoId)?.github.toLowerCase() === entry.github.toLowerCase()).map(entry => byId.get(entry.repoId)!.github))
214
+ for (const [key, ref] of authorized) if (publicRepos.has(ref.repo) && !stillPublic.has(ref.repo)) metadata.delete(key)
215
+ }
216
+ // The handler rechecks workspace access and saved Fix links before returning.
217
+ return urls.flatMap((url, index): GithubFixResult[] => {
218
+ const key = keys[index]
219
+ if (!key) return []
220
+ const found = metadata.get(key)
221
+ return [found ? { url, title: found.title, description: found.description, status: found.status, stateReason: found.stateReason } : { url, error: 'unavailable' }]
222
+ })
223
+ }
224
+
225
+ export function storedFixUrls(entries: { fix: string | null }[]): string[] {
226
+ return [...new Set(entries.flatMap(entry => entry.fix && parseGithubFixUrl(entry.fix) ? [entry.fix] : []))].toSorted()
227
+ }
@@ -0,0 +1,18 @@
1
+ import type { ServerResponse } from 'node:http'
2
+ import { Buffer } from 'node:buffer'
3
+ import { UPLOAD_CHUNK_BYTES } from './uploads.ts'
4
+
5
+ // Writing body chunks enables the function's streamed-response path. Data is
6
+ // already materialized by the authorization/filtering layer before we get here.
7
+ export function writeResponse(res: ServerResponse, body: string | Buffer): void {
8
+ const bytes = typeof body === 'string' ? Buffer.from(body) : body
9
+ if (bytes.length <= UPLOAD_CHUNK_BYTES) { res.end(body); return }
10
+ for (let offset = 0; offset < bytes.length; offset += 64 * 1024) res.write(bytes.subarray(offset, offset + 64 * 1024))
11
+ res.end()
12
+ }
13
+
14
+ export function sendJson(res: ServerResponse, status: number, body: unknown, headers: Record<string, string> = {}): void {
15
+ res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store', ...headers })
16
+ writeResponse(res, JSON.stringify(body))
17
+ }
18
+