@preventive/triage 1.0.0-alpha.20 → 1.0.0-alpha.22
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.
- package/common/bundle-source-links.d.ts +5 -0
- package/common/bundle-source-links.js +82 -0
- package/common/github-issue-labels.d.ts +1 -0
- package/common/github-issue-labels.js +11 -0
- package/common/managed/routes.js +3 -0
- package/common/server-info.ts +1 -0
- package/out/client-managed-import.js +1 -1
- package/out/client-managed.js +74 -58
- package/out/client-sync.js +3 -3
- package/out/github-issue-authorized.html +3 -0
- package/out/github-issue-authorized.js +1 -0
- package/out/graph.js +6 -6
- package/out/prism.js +2 -2
- package/out/view.css +1 -1
- package/out/view.js +186 -159
- package/package.json +11 -1
- package/server-e2e/app.ts +2 -1
- package/server-e2e/config.ts +2 -0
- package/server-managed/config.ts +11 -18
- package/server-managed/db-methods.ts +67 -31
- package/server-managed/db-neon.ts +6 -1
- package/server-managed/db-schema.ts +1 -0
- package/server-managed/db.ts +2 -1
- package/server-managed/github-app.ts +106 -36
- package/server-managed/github-issue-oauth.ts +48 -0
- package/server-managed/github-issues.ts +152 -0
- package/server-managed/github-oauth.ts +7 -23
- package/server-managed/http.ts +178 -9
- package/server-managed/managed-issues.ts +47 -0
- package/server-managed/report-sources.ts +4 -2
- package/server-managed/repository-browser.ts +111 -0
- package/server-managed/repository-policy.ts +2 -0
|
@@ -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
|
|
2
|
-
//
|
|
3
|
-
//
|
|
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
|
|
51
|
-
//
|
|
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, {
|
package/server-managed/http.ts
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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,61 @@ 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) { sendJson(res, err.status === 401 ? 502 : err.status, { error: err.message }); return }
|
|
1064
|
+
throw err
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
|
|
928
1068
|
function prebuildBundle(deps: ManagedHttpDeps, id: string) {
|
|
929
1069
|
if (!deps.bundleCache || deps.config.serverless) return
|
|
930
1070
|
deps.track((async () => {
|
|
@@ -1796,7 +1936,8 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
|
|
|
1796
1936
|
if (path === CONFIG_PATH) {
|
|
1797
1937
|
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
1798
1938
|
const info = deps.serverInfo ?? { mode: 'managed', managed: { loginPath: LOGIN_PATH, cookieName: config.sessionCookieName } }
|
|
1799
|
-
sendJson(res, 200, { ...info,
|
|
1939
|
+
sendJson(res, 200, { ...info, ...(config.githubNewIssueLabels ? { githubNewIssueLabels: config.githubNewIssueLabels } : {}),
|
|
1940
|
+
managed: { ...info.managed, ...(config.allowShare ? { allowShare: true } : {}), ...(deps.uploadStore ? { uploadChunkBytes: UPLOAD_CHUNK_BYTES } : {}) } })
|
|
1800
1941
|
return
|
|
1801
1942
|
}
|
|
1802
1943
|
// OAuth: start → redirect to GitHub with the CSRF state cookie.
|
|
@@ -1811,6 +1952,13 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
|
|
|
1811
1952
|
if (path === CALLBACK_PATH) {
|
|
1812
1953
|
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
1813
1954
|
try {
|
|
1955
|
+
if (isIssueOAuthCallback(config, url.searchParams, cookie)) {
|
|
1956
|
+
const s = await readWorkspaceSession(res, deps, cookie)
|
|
1957
|
+
if (!s) return
|
|
1958
|
+
const result = await issueOAuthCallback(config, db, s.session, url.searchParams, cookie)
|
|
1959
|
+
res.writeHead(302, { location: result.location, 'set-cookie': result.setCookie, 'cache-control': 'no-store' })
|
|
1960
|
+
res.end(); return
|
|
1961
|
+
}
|
|
1814
1962
|
const result = await handleCallback(url.searchParams, cookie, { config, db, avatarStore })
|
|
1815
1963
|
res.writeHead(302, { location: result.location, 'set-cookie': result.setCookies, 'cache-control': 'no-store' })
|
|
1816
1964
|
res.end()
|
|
@@ -1820,6 +1968,14 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
|
|
|
1820
1968
|
}
|
|
1821
1969
|
return
|
|
1822
1970
|
}
|
|
1971
|
+
if (path === ISSUE_LOGIN_PATH) {
|
|
1972
|
+
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
1973
|
+
const s = await readWorkspaceSession(res, deps, cookie)
|
|
1974
|
+
if (!s) return
|
|
1975
|
+
const result = issueLoginRedirect(config, s.session)
|
|
1976
|
+
res.writeHead(302, { location: result.location, 'set-cookie': result.setCookie, 'cache-control': 'no-store' })
|
|
1977
|
+
res.end(); return
|
|
1978
|
+
}
|
|
1823
1979
|
// Who am I?
|
|
1824
1980
|
if (path === SESSION_PATH) {
|
|
1825
1981
|
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
@@ -1837,6 +1993,10 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
|
|
|
1837
1993
|
const managedDataPath = ['/api/admin', '/api/reports', '/api/bundles', '/api/teams', '/api/avatar', '/api/github']
|
|
1838
1994
|
.some(prefix => path === prefix || path.startsWith(prefix + '/'))
|
|
1839
1995
|
if (managedDataPath && await readWorkspaceSession(res, deps, cookie) == null) return
|
|
1996
|
+
const issueRoute = /^\/api\/teams\/([^/]+)\/issues$/u.exec(path)
|
|
1997
|
+
if (issueRoute) {
|
|
1998
|
+
await handleWorkspaceIssue(req, res, deps, cookie, decodeURIComponent(issueRoute[1]!), url.searchParams); return
|
|
1999
|
+
}
|
|
1840
2000
|
if (path === '/api/admin/links') {
|
|
1841
2001
|
if (!config.allowShare) { sendJson(res, 404, { error: 'sharing-disabled' }); return }
|
|
1842
2002
|
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
@@ -1887,9 +2047,18 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
|
|
|
1887
2047
|
if (method !== 'POST') { send405(res, 'POST'); return }
|
|
1888
2048
|
await handleRemoveRepository(req, res, deps, cookie); return
|
|
1889
2049
|
}
|
|
2050
|
+
if (path === BROWSABLE_REPOS_PATH) {
|
|
2051
|
+
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
2052
|
+
await handleBrowsableRepositories(res, deps, cookie); return
|
|
2053
|
+
}
|
|
2054
|
+
if (path === REPO_REFS_PATH || path === REPO_CONTENTS_PATH) {
|
|
2055
|
+
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
2056
|
+
await handleRepositoryBrowser(res, deps, cookie, url.searchParams, path === REPO_REFS_PATH); return
|
|
2057
|
+
}
|
|
1890
2058
|
if (path === ADMIN_REPOS_PATH) { await handleListRepositories(req, res, deps, cookie, repositoryDiscovery); return }
|
|
1891
2059
|
if (path === SELECT_REPO_PATH) { await handleSelectRepository(req, res, deps, cookie); return }
|
|
1892
2060
|
if (path === ADD_PUBLIC_REPO_PATH) { await handleAddPublicRepository(req, res, deps, cookie); return }
|
|
2061
|
+
if (path === CONNECT_REPO_APP_PATH) { await handleConnectRepositoryApp(req, res, deps, cookie); return }
|
|
1893
2062
|
// Reports: list / upload on the exact path, download / delete per-id on the
|
|
1894
2063
|
// prefix. Method-dispatched here since each path carries two verbs.
|
|
1895
2064
|
if (path === ADMIN_REPORTS_PATH) {
|
|
@@ -2032,7 +2201,7 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
|
|
|
2032
2201
|
return (req, res) => {
|
|
2033
2202
|
if (isShuttingDown()) { sendJson(res, 503, { error: 'shutting-down' }, { connection: 'close' }); return }
|
|
2034
2203
|
const work = route(req, res).catch((err) => {
|
|
2035
|
-
if (err instanceof TeamReportsError && !res.headersSent) { sendJson(res, err.status, { error: err.message }); return }
|
|
2204
|
+
if ((err instanceof TeamReportsError || err instanceof IssueError || err instanceof OAuthError) && !res.headersSent) { sendJson(res, err.status, { error: err.message }); return }
|
|
2036
2205
|
console.warn('managed: request handler error:', err)
|
|
2037
2206
|
if (res.headersSent) { try { res.destroy() } catch {} }
|
|
2038
2207
|
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
|
+
}
|