@preventive/triage 1.0.0-alpha.21 → 1.0.0-alpha.23

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.
@@ -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 '../report/index.js'
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
+ }
@@ -1,22 +1,6 @@
1
- // GitHub App user-authorization (identity) for the managed server. This is the
2
- // GitHub *App* user-to-server flow, NOT an OAuth App: the authorize request
3
- // carries NO `scope` (a GitHub App's permissions live on the App itself), so
4
- // login asks for identity only — repo access is a SEPARATE installation flow
5
- // (later work), keeping the broad "act on your behalf" ask off of login.
6
- //
7
- // GET /api/oauth/github/login → 302 to github.com/login/oauth/authorize
8
- // with a CSRF `state` mirrored into a
9
- // short-lived state cookie.
10
- // GET /api/oauth/github/callback → verify `state` vs the cookie, exchange
11
- // the code for a user token, read the
12
- // GitHub identity, upsert the user, mint a
13
- // session, set the cookie, 302 to the app.
14
- //
15
- // The user-to-server token is read for the identity AND persisted (see
16
- // db.setUserTokens): the "Manage repositories" page lists the user's repos with
17
- // it on demand (GET /user/repos — public repos with no App install; private
18
- // ones once the App is installed). `ensureUserAccessToken` refreshes it when the
19
- // App issues expiring tokens.
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.
20
4
  import { Buffer } from 'node:buffer'
21
5
  import type { AvatarStore } from './avatar-store.ts'
22
6
  import type { ManagedConfig } from './config.ts'
@@ -47,8 +31,8 @@ export interface CallbackDeps { config: ManagedConfig; db: ManagedDb; avatarStor
47
31
 
48
32
  // GET /api/oauth/github/login — redirect to GitHub, stashing the CSRF state. No
49
33
  // `scope` is sent: as the GitHub App user-authorization flow, the App's own
50
- // permission set governs access, so login requests identity only (repo access
51
- // is granted separately by installing the App).
34
+ // permission set governs access. Keep account permissions minimal; repository
35
+ // grants are approved separately when installing the App.
52
36
  export function buildLoginRedirect(config: ManagedConfig): { location: string; setCookie: string } {
53
37
  const state = randomToken()
54
38
  const u = new URL(GITHUB_AUTHORIZE_URL)
@@ -97,7 +81,7 @@ export async function handleCallback(
97
81
  }
98
82
 
99
83
  // Exchange the authorization code for a user-to-server token set.
100
- function exchangeCode(config: ManagedConfig, code: string, now: number, fetchImpl: typeof fetch): Promise<UserTokens> {
84
+ export function exchangeCode(config: ManagedConfig, code: string, now: number, fetchImpl: typeof fetch): Promise<UserTokens> {
101
85
  return postToken({
102
86
  client_id: config.githubClientId, client_secret: config.githubClientSecret,
103
87
  code, redirect_uri: config.oauthCallbackUrl,
@@ -160,7 +144,7 @@ export async function ensureUserAccessToken(
160
144
  }
161
145
 
162
146
  // Read the authenticated user's identity (`GET /user`).
163
- async function fetchIdentity(token: string, fetchImpl: typeof fetch): Promise<ManagedUser> {
147
+ export async function fetchIdentity(token: string, fetchImpl: typeof fetch): Promise<ManagedUser> {
164
148
  let res: Response
165
149
  try {
166
150
  res = await fetchImpl(GITHUB_USER_URL, {
@@ -23,6 +23,7 @@
23
23
  // GET /api/admin/repositories → admin repo list (each flagged selected) | 401/403
24
24
  // POST /api/admin/repositories/select → admin selects/deactivates a repo | 401/403
25
25
  // GET /api/admin/repositories/impact → admin attached data summary
26
+ // GET /api/admin/repositories/{browsable,refs,contents} → admin|manage + GitHub read access
26
27
  // POST /api/admin/repositories/remove → admin permanently removes a repo
27
28
  // GET /api/admin/reports → admin|manage list of uploaded reports | 401/403
28
29
  // POST /api/admin/reports → admin|manage uploads a report (raw body) | 401/403/413
@@ -58,7 +59,7 @@ import type { AvatarStore } from './avatar-store.ts'
58
59
  import type { BlobStore } from './blob-store.ts'
59
60
  import { bundleIntegrity, bundleKind, reportBundleHashes } from './bundle.ts'
60
61
  import type { ManagedConfig } from './config.ts'
61
- import type { ManagedDb, ManagedSession, StoredUser, TriageEventRow } from './db.ts'
62
+ import type { ManagedDb, ManagedSession, ReportRecord, StoredUser, TriageEventRow } from './db.ts'
62
63
  import type { OriginGate } from '../server-common/origin.ts'
63
64
  import { isRole, roleAtLeast } from '../common/managed/roles.ts'
64
65
  import { VISIBILITY_PERMISSIONS, parseTeamUserPermissions } from '../common/managed/permissions.ts'
@@ -71,10 +72,11 @@ import type { ReportSourcesCache } from './report-sources.ts'
71
72
  import { normalizeTeamPath } from './repo-path.ts'
72
73
  import { DEFAULT_MANAGED_SCAN_MODEL, MANAGED_SCAN_MODELS } from '../common/managed/scan-models.ts'
73
74
  import { CONFIG_PATH, type ServerInfo } from '../common/server-info.ts'
74
- import { GithubApiError, collectRepos, fetchPublicRepository, installUrl, publicRepositoryName } from './github-app.ts'
75
+ import { GithubApiError, collectRepos, fetchPublicRepository, installUrl, publicRepositoryName, repositoryInstallation } from './github-app.ts'
75
76
  import type { ConnectedRepo } from './github-app.ts'
76
77
  import { canAddAnyPublicRepository, canAddRepositories, passesPublicRepositorySafeguard } from './repository-policy.ts'
77
78
  import { RepositoryDiscovery } from './repository-discovery.ts'
79
+ import { createRepositoryBrowser, scopedDirectory } from './repository-browser.ts'
78
80
  import { CALLBACK_PATH, LOGIN_PATH, OAuthError, buildLoginRedirect, ensureUserAccessToken, handleCallback } from './github-oauth.ts'
79
81
  import { clearCookie, endSession, readSession } from './session.ts'
80
82
  import type { ActivityContext, ActivityInput } from './activity.ts'
@@ -82,6 +84,8 @@ import { acceptsReportMetadata } from './report-response.ts'
82
84
  import { TeamReportsError, loadTeamReports, recheckTeam, teamFindingIds, teamSnapshot, teamSourcePaths, teamWorkspaceFindingIds } from './team-reports.ts'
83
85
  import { MAX_REPORT_QUERY_BYTES, MAX_REPORT_QUERY_COUNT } from './report-query.ts'
84
86
  import { lookupFixes, storedFixUrls } from './github-pulls.ts'
87
+ import { IssueError, MAX_ISSUE_BODY_BYTES, createGithubIssue, parseIssueContext, prepareGithubIssue } from './github-issues.ts'
88
+ import { ISSUE_LOGIN_PATH, isIssueOAuthCallback, issueLoginRedirect, issueOAuthCallback } from './github-issue-oauth.ts'
85
89
  import { sendJson, writeResponse } from './http-response.ts'
86
90
  import { triageWireEntry } from './triage-response.ts'
87
91
  import { handlePublicWorkspace } from './public-workspace.ts'
@@ -97,8 +101,12 @@ const ADMIN_HISTORY_PATH = '/api/admin/history'
97
101
  const ADMIN_SCAN_MODELS_PATH = '/api/admin/scan/models'
98
102
  const SET_ROLE_PATH = '/api/admin/set-role'
99
103
  const ADMIN_REPOS_PATH = '/api/admin/repositories'
104
+ const BROWSABLE_REPOS_PATH = '/api/admin/repositories/browsable'
105
+ const REPO_REFS_PATH = '/api/admin/repositories/refs'
106
+ const REPO_CONTENTS_PATH = '/api/admin/repositories/contents'
100
107
  const SELECT_REPO_PATH = '/api/admin/repositories/select'
101
108
  const ADD_PUBLIC_REPO_PATH = '/api/admin/repositories/add-public'
109
+ const CONNECT_REPO_APP_PATH = '/api/admin/repositories/connect-app'
102
110
  const REPO_IMPACT_PATH = '/api/admin/repositories/impact'
103
111
  const REMOVE_REPO_PATH = '/api/admin/repositories/remove'
104
112
  const ADMIN_REPORTS_PATH = '/api/admin/reports'
@@ -140,6 +148,29 @@ async function handleWorkspaceFixes(res: ServerResponse, deps: ManagedHttpDeps,
140
148
  sendJson(res, 200, { fixes })
141
149
  }
142
150
 
151
+ async function handleWorkspaceIssue(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps,
152
+ cookie: string | undefined, teamId: string, query: URLSearchParams): Promise<void> {
153
+ if (req.method !== 'GET' && req.method !== 'POST') { send405(res, 'GET, POST'); return }
154
+ const s = req.method === 'GET' ? await readWorkspaceSession(res, deps, cookie) : await checkMutation(req, res, deps, cookie)
155
+ if (!s) return
156
+ if (!roleAtLeast(s.user.role, 'view')) { sendJson(res, 403, { error: 'forbidden' }); return }
157
+ let body: unknown = Object.fromEntries(query)
158
+ if (req.method === 'POST') {
159
+ try { body = await readJsonBody(req, MAX_ISSUE_BODY_BYTES) }
160
+ catch { sendJson(res, 400, { error: 'bad-body' }); return }
161
+ }
162
+ const context = parseIssueContext(body)
163
+ const prepared = await prepareGithubIssue(deps.config, deps.db, deps.reportStore, s.session, teamId, context)
164
+ await prepared.recheck()
165
+ if (req.method === 'POST' && prepared.mode === 'api') {
166
+ sendJson(res, 201, await createGithubIssue(prepared, body)); return
167
+ }
168
+ sendJson(res, 200, { mode: prepared.mode, labels: prepared.labels,
169
+ ...(prepared.mode === 'existing' ? { url: prepared.url } : {}),
170
+ ...(prepared.mode === 'pending' ? { repositoryUrl: prepared.repositoryUrl } : {}),
171
+ ...(prepared.mode === 'authorize' || prepared.mode === 'permissions' ? { authorizationPath: prepared.authorizationPath } : {}) })
172
+ }
173
+
143
174
  function activity(deps: ManagedHttpDeps, user: StoredUser, kind: ActivityInput['kind'], action: string, context: Pick<ActivityInput, 'repo' | 'reportId' | 'bundleId' | 'report' | 'repoId' | 'repoDirectory'> = {}): Promise<void> {
144
175
  return deps.db.recordActivity({ kind, actor: user.login, actorId: user.id, action, ...context }, Date.now())
145
176
  }
@@ -495,6 +526,43 @@ async function handleAddPublicRepository(req: IncomingMessage, res: ServerRespon
495
526
  await connectRepository(res, deps, s.user, repo)
496
527
  }
497
528
 
529
+ // Associate an existing connection with this App without changing its active
530
+ // state, ownership, team grants, or stored data. GitHub grants remain separate
531
+ // from the per-user checks required for browsing source.
532
+ async function handleConnectRepositoryApp(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
533
+ if ((req.method ?? 'GET') !== 'POST') { send405(res, 'POST'); return }
534
+ const s = await adminMutation(req, res, deps, cookie)
535
+ if (s == null) return
536
+ let body: unknown
537
+ try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
538
+ const repoId = (body as { repoId?: unknown } | null)?.repoId
539
+ if (typeof repoId !== 'number' || !Number.isSafeInteger(repoId) || repoId <= 0) { sendJson(res, 400, { error: 'bad-request' }); return }
540
+ const repo = (await deps.db.listAllRepos()).find(row => row.repoId === repoId)
541
+ if (repo == null) { sendJson(res, 404, { error: 'repo-not-connected' }); return }
542
+ if (repo.installationId != null) { sendJson(res, 200, { connected: true }); return }
543
+ let installationId
544
+ try { installationId = await repositoryInstallation(deps.config, repo.repoId, repo.fullName) } catch (err) {
545
+ if (!(err instanceof GithubApiError)) throw err
546
+ // These credentials belong to the server App, not the user's session.
547
+ sendJson(res, err.status === 401 ? 502 : err.status, { error: err.message }); return
548
+ }
549
+ if (await readAdminSession(res, deps, cookie) == null) return
550
+ if (installationId == null) {
551
+ const current = (await deps.db.listAllRepos()).find(row => row.repoId === repoId)
552
+ if (current == null || current.fullName !== repo.fullName || current.addedAt !== repo.addedAt || current.installationId != null) {
553
+ sendJson(res, 409, { error: 'repo-connection-changed' }); return
554
+ }
555
+ const url = installUrl(deps.config)
556
+ if (url == null) { sendJson(res, 503, { error: 'github-app-not-configured' }); return }
557
+ sendJson(res, 200, { connected: false, installUrl: url }); return
558
+ }
559
+ if (!await deps.db.connectRepoInstallation(repo, installationId, s.session.id, Date.now())) {
560
+ sendJson(res, 409, { error: 'repo-connection-changed' }); return
561
+ }
562
+ await activity(deps, s.user, 'repository', 'connected GitHub App access', { repo: repo.fullName })
563
+ sendJson(res, 200, { connected: true })
564
+ }
565
+
498
566
  // POST /api/admin/repositories/select — an admin toggles whether a repo is
499
567
  // active in the operate-on set. Mutation: same-origin + CSRF. Body { repoId,
500
568
  // selected }. selected:true verifies + records the read context; selected:false
@@ -786,6 +854,17 @@ async function resolveReportBundle(deps: ManagedHttpDeps, user: StoredUser, byte
786
854
  return { bundleId: null, integrity: hashes[0] ?? null }
787
855
  }
788
856
 
857
+ async function sendUploadedReport(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, report: ReportRecord, deduped: boolean): Promise<void> {
858
+ const current = await checkMutation(req, res, deps, cookie)
859
+ if (!current || !requireManageRole(res, current.user)) return
860
+ // Uploading known bytes cannot reveal another team's report or its source
861
+ // bundle. The stored location/ownership wins over the upload headers.
862
+ if (!(await canViewReport(deps, current.user, report.id))) { sendJson(res, 409, { error: 'report-conflict' }); return }
863
+ const bundleId = report.bundleId != null && await canAccessBundle(deps, current.user, report.bundleId) ? report.bundleId : null
864
+ const { id, slug, filename, byteSize, sha256, repoId, repoDirectory, repoEmbedded, analyzer, visible } = report
865
+ sendJson(res, deduped ? 200 : 201, { id, slug, filename, byteSize, sha256, repoId, repoDirectory, repoEmbedded, analyzer, visible, bundleId, ...(deduped ? { deduped: true } : {}) })
866
+ }
867
+
789
868
  // POST /api/admin/reports — upload a report. Mutation: same-origin + CSRF,
790
869
  // admin|manage. The body is the raw report bytes (any findings format — JSON /
791
870
  // markdown / CSV — archived as-is, like the e2e objstore; the server parses them
@@ -793,9 +872,9 @@ async function resolveReportBundle(deps: ManagedHttpDeps, user: StoredUser, byte
793
872
  // directory come from the report header, or from optional repository/directory
794
873
  // headers when the report has no repository metadata. The bundle link is
795
874
  // auto-resolved from the report's bundleHashes. New reports start hidden until
796
- // published. Bytes
875
+ // published. Identical content reuses its stored identity and metadata. Bytes
797
876
  // are written first (keyed by a fresh uuid) then the metadata row — a failed
798
- // insert drops the orphan blob. 413 over the cap, 400 on empty.
877
+ // insert or concurrent duplicate drops the orphan blob. 413 over the cap, 400 on empty.
799
878
  async function handleUploadReport(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
800
879
  let s = await checkMutation(req, res, deps, cookie)
801
880
  if (s == null) return
@@ -837,13 +916,18 @@ async function handleUploadReport(req: IncomingMessage, res: ServerResponse, dep
837
916
  matchedRepo = legacyRepo.repoId == null ? null : selected.find((repo) => repo.repoId === legacyRepo.repoId) ?? null
838
917
  }
839
918
  if (matchedRepo && s.user.role !== 'admin' && !(await deps.db.userCanReadRepoPath(s.user.id, matchedRepo.repoId, directory))) { sendJson(res, 403, { error: 'repo-forbidden' }); return }
919
+ const sha256 = createHash('sha256').update(bytes).digest('base64url')
920
+ // The analyzer also distinguishes a CSV from the same bytes previously
921
+ // uploaded under a filename that did not identify it as a report.
922
+ const existing = await deps.db.getReportByHash(sha256, analyzer)
923
+ if (existing) { await sendUploadedReport(req, res, deps, cookie, existing, true); return }
840
924
  const id = randomUUID()
841
925
  const contentType = (firstHeader(req.headers['content-type']) ?? '').split(';', 1)[0]!.trim() || 'application/json'
842
- const sha256 = createHash('sha256').update(bytes).digest('base64url')
843
926
  const { bundleId, integrity } = await resolveReportBundle(deps, s.user, bytes)
844
927
  await deps.reportStore.put(id, bytes)
928
+ let report: ReportRecord
845
929
  try {
846
- await deps.db.insertReport({
930
+ report = await deps.db.insertOrReuseReport({
847
931
  id, filename, contentType, byteSize: bytes.length, sha256,
848
932
  uploadedBy: s.user.id, uploadedByLogin: s.user.login, repoId: matchedRepo?.repoId ?? null,
849
933
  repoDirectory: directory, repoEmbedded, analyzer, visible: false, bundleId, bundleIntegrity: integrity,
@@ -852,7 +936,8 @@ async function handleUploadReport(req: IncomingMessage, res: ServerResponse, dep
852
936
  await deps.reportStore.delete(id).catch(() => {})
853
937
  throw err
854
938
  }
855
- sendJson(res, 201, { id, slug: (await deps.db.getReport(id))!.slug, filename, byteSize: bytes.length, sha256, repoId: matchedRepo?.repoId ?? null, repoDirectory: directory, repoEmbedded, analyzer, visible: false, bundleId })
939
+ if (report.id !== id) await deps.reportStore.delete(id).catch(() => {})
940
+ await sendUploadedReport(req, res, deps, cookie, report, report.id !== id)
856
941
  }
857
942
 
858
943
  // GET /api/admin/reports/<id> — download a stored report (admin|manage). Serves
@@ -925,6 +1010,64 @@ async function bundleRepos(deps: ManagedHttpDeps, user: StoredUser) {
925
1010
  return repos.filter((_, index) => allowed[index])
926
1011
  }
927
1012
 
1013
+ async function repositoryBrowserUser(deps: ManagedHttpDeps, userId: string) {
1014
+ const [githubUserId, token] = await Promise.all([
1015
+ deps.db.getUserGithubId(userId), ensureUserAccessToken(deps.config, deps.db, userId, Date.now()),
1016
+ ])
1017
+ return { githubUserId, token }
1018
+ }
1019
+
1020
+ // Listing connected repository names uses managed access only. Verify GitHub
1021
+ // access after selection, before returning any refs or source directory data.
1022
+ async function handleBrowsableRepositories(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
1023
+ const s = await readManageSession(res, deps, cookie)
1024
+ if (!s) return
1025
+ sendJson(res, 200, { repos: selectableRepos(await bundleRepos(deps, s.user)) })
1026
+ }
1027
+
1028
+ // Recheck repository/path grants after upstream reads, before returning names.
1029
+ async function handleRepositoryBrowser(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, query: URLSearchParams, refs: boolean): Promise<void> {
1030
+ const repoId = Number(query.get('repoId'))
1031
+ const normalized = normalizeTeamPath(query.get('path') ?? '')
1032
+ const ref = query.get('ref') ?? ''
1033
+ if (!Number.isSafeInteger(repoId) || repoId <= 0 || !normalized.ok || ref.length > 1024 || /\p{Cc}/u.test(ref)) {
1034
+ sendJson(res, 400, { error: 'bad-request' }); return
1035
+ }
1036
+ const path = normalized.path ?? ''
1037
+ const authorize = async () => {
1038
+ const s = await readManageSession(res, deps, cookie)
1039
+ if (!s) return null
1040
+ const repo = (await bundleRepos(deps, s.user)).find(item => item.repoId === repoId)
1041
+ if (!repo) { sendJson(res, 404, { error: 'no-repository' }); return null }
1042
+ const scopes = s.user.role === 'admin' ? [null] : (await deps.db.listRepoScopesForUser(s.user.id)).filter(scope => scope.repoId === repoId).map(scope => scope.path)
1043
+ const virtualEntries = scopedDirectory(path, scopes)
1044
+ if (!refs && virtualEntries?.length === 0) { sendJson(res, 404, { error: 'no-directory' }); return null }
1045
+ return { repo, virtualEntries, user: s.user }
1046
+ }
1047
+ const access = await authorize()
1048
+ if (!access) return
1049
+ try {
1050
+ const browser = createRepositoryBrowser(deps.config, await repositoryBrowserUser(deps, access.user.id))
1051
+ const reader = await browser.reader(access.repo)
1052
+ const commit = refs ? '' : await reader.commit(ref)
1053
+ const result = refs ? await reader.refs() : access.virtualEntries
1054
+ ? { entries: access.virtualEntries, limited: false } : await reader.directory(path, commit)
1055
+ await reader.recheckAccess()
1056
+ const current = await authorize()
1057
+ if (!current) return
1058
+ if (JSON.stringify(current.repo) !== JSON.stringify(access.repo)) { sendJson(res, 409, { error: 'repository-changed' }); return }
1059
+ sendJson(res, 200, refs ? result : {
1060
+ ...result, ...(current.virtualEntries ? { entries: current.virtualEntries, limited: false } : {}), path, commit,
1061
+ })
1062
+ } catch (err) {
1063
+ if (err instanceof GithubApiError) {
1064
+ sendJson(res, err.status === 401 ? 502 : err.status, { error: err.message }, err.retryAfter == null ? {} : { 'retry-after': String(err.retryAfter) })
1065
+ return
1066
+ }
1067
+ throw err
1068
+ }
1069
+ }
1070
+
928
1071
  function prebuildBundle(deps: ManagedHttpDeps, id: string) {
929
1072
  if (!deps.bundleCache || deps.config.serverless) return
930
1073
  deps.track((async () => {
@@ -1796,7 +1939,8 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
1796
1939
  if (path === CONFIG_PATH) {
1797
1940
  if (method !== 'GET') { send405(res, 'GET'); return }
1798
1941
  const info = deps.serverInfo ?? { mode: 'managed', managed: { loginPath: LOGIN_PATH, cookieName: config.sessionCookieName } }
1799
- sendJson(res, 200, { ...info, managed: { ...info.managed, ...(config.allowShare ? { allowShare: true } : {}), ...(deps.uploadStore ? { uploadChunkBytes: UPLOAD_CHUNK_BYTES } : {}) } })
1942
+ sendJson(res, 200, { ...info, ...(config.githubNewIssueLabels ? { githubNewIssueLabels: config.githubNewIssueLabels } : {}),
1943
+ managed: { ...info.managed, ...(config.allowShare ? { allowShare: true } : {}), ...(deps.uploadStore ? { uploadChunkBytes: UPLOAD_CHUNK_BYTES } : {}) } })
1800
1944
  return
1801
1945
  }
1802
1946
  // OAuth: start → redirect to GitHub with the CSRF state cookie.
@@ -1811,6 +1955,13 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
1811
1955
  if (path === CALLBACK_PATH) {
1812
1956
  if (method !== 'GET') { send405(res, 'GET'); return }
1813
1957
  try {
1958
+ if (isIssueOAuthCallback(config, url.searchParams, cookie)) {
1959
+ const s = await readWorkspaceSession(res, deps, cookie)
1960
+ if (!s) return
1961
+ const result = await issueOAuthCallback(config, db, s.session, url.searchParams, cookie)
1962
+ res.writeHead(302, { location: result.location, 'set-cookie': result.setCookie, 'cache-control': 'no-store' })
1963
+ res.end(); return
1964
+ }
1814
1965
  const result = await handleCallback(url.searchParams, cookie, { config, db, avatarStore })
1815
1966
  res.writeHead(302, { location: result.location, 'set-cookie': result.setCookies, 'cache-control': 'no-store' })
1816
1967
  res.end()
@@ -1820,6 +1971,14 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
1820
1971
  }
1821
1972
  return
1822
1973
  }
1974
+ if (path === ISSUE_LOGIN_PATH) {
1975
+ if (method !== 'GET') { send405(res, 'GET'); return }
1976
+ const s = await readWorkspaceSession(res, deps, cookie)
1977
+ if (!s) return
1978
+ const result = issueLoginRedirect(config, s.session)
1979
+ res.writeHead(302, { location: result.location, 'set-cookie': result.setCookie, 'cache-control': 'no-store' })
1980
+ res.end(); return
1981
+ }
1823
1982
  // Who am I?
1824
1983
  if (path === SESSION_PATH) {
1825
1984
  if (method !== 'GET') { send405(res, 'GET'); return }
@@ -1837,6 +1996,10 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
1837
1996
  const managedDataPath = ['/api/admin', '/api/reports', '/api/bundles', '/api/teams', '/api/avatar', '/api/github']
1838
1997
  .some(prefix => path === prefix || path.startsWith(prefix + '/'))
1839
1998
  if (managedDataPath && await readWorkspaceSession(res, deps, cookie) == null) return
1999
+ const issueRoute = /^\/api\/teams\/([^/]+)\/issues$/u.exec(path)
2000
+ if (issueRoute) {
2001
+ await handleWorkspaceIssue(req, res, deps, cookie, decodeURIComponent(issueRoute[1]!), url.searchParams); return
2002
+ }
1840
2003
  if (path === '/api/admin/links') {
1841
2004
  if (!config.allowShare) { sendJson(res, 404, { error: 'sharing-disabled' }); return }
1842
2005
  if (method !== 'GET') { send405(res, 'GET'); return }
@@ -1887,9 +2050,18 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
1887
2050
  if (method !== 'POST') { send405(res, 'POST'); return }
1888
2051
  await handleRemoveRepository(req, res, deps, cookie); return
1889
2052
  }
2053
+ if (path === BROWSABLE_REPOS_PATH) {
2054
+ if (method !== 'GET') { send405(res, 'GET'); return }
2055
+ await handleBrowsableRepositories(res, deps, cookie); return
2056
+ }
2057
+ if (path === REPO_REFS_PATH || path === REPO_CONTENTS_PATH) {
2058
+ if (method !== 'GET') { send405(res, 'GET'); return }
2059
+ await handleRepositoryBrowser(res, deps, cookie, url.searchParams, path === REPO_REFS_PATH); return
2060
+ }
1890
2061
  if (path === ADMIN_REPOS_PATH) { await handleListRepositories(req, res, deps, cookie, repositoryDiscovery); return }
1891
2062
  if (path === SELECT_REPO_PATH) { await handleSelectRepository(req, res, deps, cookie); return }
1892
2063
  if (path === ADD_PUBLIC_REPO_PATH) { await handleAddPublicRepository(req, res, deps, cookie); return }
2064
+ if (path === CONNECT_REPO_APP_PATH) { await handleConnectRepositoryApp(req, res, deps, cookie); return }
1893
2065
  // Reports: list / upload on the exact path, download / delete per-id on the
1894
2066
  // prefix. Method-dispatched here since each path carries two verbs.
1895
2067
  if (path === ADMIN_REPORTS_PATH) {
@@ -2032,7 +2204,7 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
2032
2204
  return (req, res) => {
2033
2205
  if (isShuttingDown()) { sendJson(res, 503, { error: 'shutting-down' }, { connection: 'close' }); return }
2034
2206
  const work = route(req, res).catch((err) => {
2035
- if (err instanceof TeamReportsError && !res.headersSent) { sendJson(res, err.status, { error: err.message }); return }
2207
+ if ((err instanceof TeamReportsError || err instanceof IssueError || err instanceof OAuthError) && !res.headersSent) { sendJson(res, err.status, { error: err.message }); return }
2036
2208
  console.warn('managed: request handler error:', err)
2037
2209
  if (res.headersSent) { try { res.destroy() } catch {} }
2038
2210
  else sendJson(res, 500, { error: 'internal' })
@@ -0,0 +1,47 @@
1
+ import type { ManagedSql } from './sql.ts'
2
+
3
+ // One immutable reference per finding, shared across reports and teams. Keep
4
+ // reservations across crashes/uncertain GitHub responses to prevent duplicates.
5
+ // No triage/import/admin patch accepts these fields.
6
+ export const MANAGED_ISSUE_SCHEMA = `
7
+ CREATE TABLE IF NOT EXISTS managed_finding_issue (
8
+ finding_id TEXT PRIMARY KEY,
9
+ repo_id INTEGER NOT NULL,
10
+ repository TEXT NOT NULL,
11
+ request_id TEXT NOT NULL,
12
+ issue_url TEXT,
13
+ created_by TEXT REFERENCES managed_user(id) ON DELETE SET NULL,
14
+ created_at INTEGER NOT NULL
15
+ ) STRICT;
16
+ `
17
+ export interface ManagedIssue {
18
+ findingId: string
19
+ repoId: number
20
+ repository: string
21
+ requestId: string
22
+ issueUrl: string | null
23
+ createdBy: string | null
24
+ createdAt: number
25
+ }
26
+ export interface ManagedIssueStore {
27
+ getManagedIssue(findingId: string): Promise<ManagedIssue | null>
28
+ claimManagedIssue(issue: Omit<ManagedIssue, 'issueUrl'>): Promise<boolean>
29
+ finishManagedIssue(findingId: string, requestId: string, url: string): Promise<boolean>
30
+ releaseManagedIssue(findingId: string, requestId: string): Promise<void>
31
+ }
32
+ export function managedIssueMethods(db: ManagedSql): ManagedIssueStore {
33
+ const get = db.prepare(`SELECT finding_id AS findingId, repo_id AS repoId, repository, request_id AS requestId,
34
+ issue_url AS issueUrl, created_by AS createdBy, created_at AS createdAt FROM managed_finding_issue WHERE finding_id = ?`)
35
+ const claim = db.prepare(`INSERT INTO managed_finding_issue (finding_id, repo_id, repository, request_id, created_by, created_at)
36
+ VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT (finding_id) DO NOTHING`)
37
+ const finish = db.prepare(`UPDATE managed_finding_issue SET issue_url = ? WHERE finding_id = ? AND request_id = ? AND issue_url IS NULL`)
38
+ const release = db.prepare(`DELETE FROM managed_finding_issue WHERE finding_id = ? AND request_id = ? AND issue_url IS NULL`)
39
+ return {
40
+ async getManagedIssue(findingId) { return (await get.get(findingId) as ManagedIssue | undefined) ?? null },
41
+ async claimManagedIssue(issue) {
42
+ return Number((await claim.run(issue.findingId, issue.repoId, issue.repository, issue.requestId, issue.createdBy, issue.createdAt)).changes) > 0
43
+ },
44
+ async finishManagedIssue(findingId, requestId, url) { return Number((await finish.run(url, findingId, requestId)).changes) > 0 },
45
+ async releaseManagedIssue(findingId, requestId) { await release.run(findingId, requestId) },
46
+ }
47
+ }