@preventive/triage 1.0.0-alpha.21 → 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 +9 -16
- package/server-managed/db-methods.ts +48 -18
- 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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@preventive/triage",
|
|
3
|
-
"version": "1.0.0-alpha.
|
|
3
|
+
"version": "1.0.0-alpha.22",
|
|
4
4
|
"description": "Client & relay server for triaging of automated reports",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": {
|
|
@@ -110,11 +110,14 @@
|
|
|
110
110
|
"server-managed/db.ts",
|
|
111
111
|
"server-managed/github-app.ts",
|
|
112
112
|
"server-managed/github-metadata.ts",
|
|
113
|
+
"server-managed/github-issue-oauth.ts",
|
|
114
|
+
"server-managed/github-issues.ts",
|
|
113
115
|
"server-managed/github-oauth.ts",
|
|
114
116
|
"server-managed/github-pulls.ts",
|
|
115
117
|
"server-managed/http-response.ts",
|
|
116
118
|
"server-managed/http.ts",
|
|
117
119
|
"server-managed/import-triage.ts",
|
|
120
|
+
"server-managed/managed-issues.ts",
|
|
118
121
|
"server-managed/index.ts",
|
|
119
122
|
"server-managed/public-workspace.ts",
|
|
120
123
|
"server-managed/repo-path.ts",
|
|
@@ -122,6 +125,7 @@
|
|
|
122
125
|
"server-managed/report-query.ts",
|
|
123
126
|
"server-managed/report-response.ts",
|
|
124
127
|
"server-managed/report-sources.ts",
|
|
128
|
+
"server-managed/repository-browser.ts",
|
|
125
129
|
"server-managed/repository-discovery.ts",
|
|
126
130
|
"server-managed/repository-policy.ts",
|
|
127
131
|
"server-managed/session.ts",
|
|
@@ -172,12 +176,16 @@
|
|
|
172
176
|
"common/bundle-reasons.js",
|
|
173
177
|
"common/bundle-sources.d.ts",
|
|
174
178
|
"common/bundle-sources.js",
|
|
179
|
+
"common/bundle-source-links.d.ts",
|
|
180
|
+
"common/bundle-source-links.js",
|
|
175
181
|
"common/bundle-stats.js",
|
|
176
182
|
"common/bundle-tabs.js",
|
|
177
183
|
"common/code-language.js",
|
|
178
184
|
"common/default-scan-models.ts",
|
|
179
185
|
"common/finding-id.js",
|
|
180
186
|
"common/github-pr.ts",
|
|
187
|
+
"common/github-issue-labels.js",
|
|
188
|
+
"common/github-issue-labels.d.ts",
|
|
181
189
|
"common/managed/comments.ts",
|
|
182
190
|
"common/managed/permissions.ts",
|
|
183
191
|
"common/managed/report-content.ts",
|
|
@@ -200,6 +208,8 @@
|
|
|
200
208
|
"out/client-managed-import.js",
|
|
201
209
|
"out/client-sync.js",
|
|
202
210
|
"out/graph.js",
|
|
211
|
+
"out/github-issue-authorized.html",
|
|
212
|
+
"out/github-issue-authorized.js",
|
|
203
213
|
"out/icon-maskable.svg",
|
|
204
214
|
"out/icon.svg",
|
|
205
215
|
"out/index.html",
|
package/server-e2e/app.ts
CHANGED
|
@@ -348,7 +348,8 @@ async function assembleE2eApp(config: Config, rollback: AsyncDisposableStack) {
|
|
|
348
348
|
// The sync protocol this build advertises — emitted as a `server-info` frame
|
|
349
349
|
// right after the challenge on every connection. This is the e2e boot, so it
|
|
350
350
|
// always advertises e2e.
|
|
351
|
-
const SERVER_INFO: ServerInfo = { mode: 'e2e', managed: null, ...(config.deepviewScanServer ? { deepviewScanServer: config.deepviewScanServer } : {})
|
|
351
|
+
const SERVER_INFO: ServerInfo = { mode: 'e2e', managed: null, ...(config.deepviewScanServer ? { deepviewScanServer: config.deepviewScanServer } : {}),
|
|
352
|
+
...(config.githubNewIssueLabels ? { githubNewIssueLabels: config.githubNewIssueLabels } : {}) }
|
|
352
353
|
|
|
353
354
|
// Shared per-connection dispatch surface. Both the WS plane
|
|
354
355
|
// (installWsServer below) and the SSE+POST fallback (installSseServer
|
package/server-e2e/config.ts
CHANGED
|
@@ -28,6 +28,7 @@ export type Config = {
|
|
|
28
28
|
password: string | null
|
|
29
29
|
trustProxyEnv: string | undefined
|
|
30
30
|
deepviewScanServer: string | null
|
|
31
|
+
githubNewIssueLabels?: string
|
|
31
32
|
}
|
|
32
33
|
|
|
33
34
|
// Parse + range-validate an integer env var, throwing a clear
|
|
@@ -207,5 +208,6 @@ export function loadConfig(): Config {
|
|
|
207
208
|
debug, neonUrl, blobToken, tokenSecret, password,
|
|
208
209
|
trustProxyEnv: env['TRUST_PROXY'],
|
|
209
210
|
deepviewScanServer: configuredScanServer(env['DEEPVIEW_SCAN_SERVER']),
|
|
211
|
+
githubNewIssueLabels: env['GITHUB_NEW_ISSUE_LABELS'] ?? '',
|
|
210
212
|
}
|
|
211
213
|
}
|
package/server-managed/config.ts
CHANGED
|
@@ -1,10 +1,6 @@
|
|
|
1
|
-
// Managed-server boot config
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
// optional, SEPARATE GitHub App (id + private key + slug) enables PRIVATE repo
|
|
5
|
-
// listing via installation tokens — kept distinct from the login App so its
|
|
6
|
-
// Contents permission never lands on the login consent. The sync protocol is
|
|
7
|
-
// not parsed here yet.
|
|
1
|
+
// Managed-server boot config. GitHub user authorization establishes identity;
|
|
2
|
+
// optional App installation credentials enable repository access. A single
|
|
3
|
+
// GitHub App can provide both flows, with repository grants at installation.
|
|
8
4
|
import { env } from 'node:process'
|
|
9
5
|
import { databaseUrls } from '../server-common/database-config.ts'
|
|
10
6
|
|
|
@@ -35,21 +31,17 @@ export interface ManagedConfig {
|
|
|
35
31
|
cookieSecure: boolean
|
|
36
32
|
sessionCookieName: string
|
|
37
33
|
sessionTtlMs: number
|
|
38
|
-
// Optional
|
|
39
|
-
//
|
|
40
|
-
// consent never touches login) and is read via installation tokens: the id +
|
|
41
|
-
// PEM private key mint them; the slug builds the "Connect a repository"
|
|
42
|
-
// install URL. id/key absent → private repos are off (public-only listing);
|
|
43
|
-
// slug absent → no install link.
|
|
34
|
+
// Optional installation credentials for the same GitHub App as login.
|
|
35
|
+
// Repository permissions are approved when connecting repositories.
|
|
44
36
|
githubAppId: string | null
|
|
45
37
|
githubAppPrivateKey: string | null
|
|
46
38
|
githubAppSlug: string | null
|
|
39
|
+
githubNewIssueLabels?: string
|
|
47
40
|
// Max accepted size (bytes) for an uploaded report on the "Manage reports"
|
|
48
41
|
// page. Reports are findings dumps (JSON / markdown / CSV), small to a few MB.
|
|
49
42
|
maxReportBytes: number
|
|
50
43
|
// Max accepted size (bytes) for an uploaded bundle (sourcemap / stasis
|
|
51
|
-
// archive). Bundles run larger than reports, so a higher cap (default
|
|
52
|
-
// matching the e2e objstore per-upload cap).
|
|
44
|
+
// archive). Bundles run larger than reports, so a higher cap (default 200 MiB).
|
|
53
45
|
maxBundleBytes: number
|
|
54
46
|
// How many triage-trail events to keep per finding (managed_finding_triage_event):
|
|
55
47
|
// 0, the default, keeps everything — the trail is the record. An operator
|
|
@@ -130,8 +122,9 @@ export function loadManagedConfig({ combined = false } = {}): ManagedConfig {
|
|
|
130
122
|
githubAppId: env['GITHUB_APP_ID'] ?? null,
|
|
131
123
|
githubAppPrivateKey: normalizePem(env['GITHUB_APP_PRIVATE_KEY']),
|
|
132
124
|
githubAppSlug: env['GITHUB_APP_SLUG'] ?? null,
|
|
125
|
+
githubNewIssueLabels: env['GITHUB_NEW_ISSUE_LABELS'] ?? '',
|
|
133
126
|
maxReportBytes: intEnv('MAX_REPORT_BYTES', 10_485_760, 1, 104_857_600),
|
|
134
|
-
maxBundleBytes: intEnv('MAX_BUNDLE_BYTES',
|
|
127
|
+
maxBundleBytes: intEnv('MAX_BUNDLE_BYTES', 209_715_200, 1, 1_073_741_824),
|
|
135
128
|
triageHistoryLimit: intEnv('TRIAGE_HISTORY_LIMIT', 0, 0, 1_000_000_000),
|
|
136
129
|
}
|
|
137
130
|
}
|
|
@@ -7,6 +7,7 @@ import { preferredSlug } from './slugs.ts'
|
|
|
7
7
|
import { type CommentStore, commentMethods } from './comments.ts'
|
|
8
8
|
import { type ActivityStore, activityMethods } from './activity.ts'
|
|
9
9
|
import { type GithubMetadataStore, githubMetadataMethods } from './github-metadata.ts'
|
|
10
|
+
import { type ManagedIssueStore, managedIssueMethods } from './managed-issues.ts'
|
|
10
11
|
import { type ImportTriageStore, importTriageMethods } from './import-triage.ts'
|
|
11
12
|
import type { ManagedSql } from './sql.ts'
|
|
12
13
|
import { type WorkspaceShareStore, workspaceShareMethods } from './workspace-shares.ts'
|
|
@@ -305,7 +306,7 @@ export interface UserTeam {
|
|
|
305
306
|
}
|
|
306
307
|
|
|
307
308
|
// Backend-agnostic store surface (SQLite + PostgreSQL implementations).
|
|
308
|
-
export interface ManagedDb extends ActivityStore, CommentStore, GithubMetadataStore, WorkspaceShareStore, ImportTriageStore {
|
|
309
|
+
export interface ManagedDb extends ActivityStore, CommentStore, GithubMetadataStore, ManagedIssueStore, WorkspaceShareStore, ImportTriageStore {
|
|
309
310
|
// Upsert the identity; returns the user's opaque id (stable across logins).
|
|
310
311
|
// Initial-admin approval comes only from trusted login configuration. It
|
|
311
312
|
// promotes a matching No access identity only while it is the sole user.
|
|
@@ -325,6 +326,7 @@ export interface ManagedDb extends ActivityStore, CommentStore, GithubMetadataSt
|
|
|
325
326
|
// mutable context while keeping the original added_by/added_at; deselectRepo
|
|
326
327
|
// resolves true iff a row was removed.
|
|
327
328
|
selectRepo(repo: SelectedRepoInput, now: number): Promise<void>
|
|
329
|
+
connectRepoInstallation(repo: SelectedRepo, installationId: number, sessionId: string, now: number): Promise<boolean>
|
|
328
330
|
deselectRepo(repoId: number): Promise<boolean>
|
|
329
331
|
listSelectedRepos(): Promise<SelectedRepo[]>
|
|
330
332
|
listAllRepos(): Promise<ManagedRepo[]>
|
|
@@ -344,6 +346,10 @@ export interface ManagedDb extends ActivityStore, CommentStore, GithubMetadataSt
|
|
|
344
346
|
// getReport reads one row (for download); deleteReport resolves true iff a row
|
|
345
347
|
// was removed.
|
|
346
348
|
insertReport(report: ReportRecordInput, now: number): Promise<void>
|
|
349
|
+
// Content reuse is atomic with insertion, including across server instances.
|
|
350
|
+
// Existing copies are kept; the oldest matching row is the stable identity.
|
|
351
|
+
insertOrReuseReport(report: ReportRecordInput, now: number): Promise<ReportRecord>
|
|
352
|
+
getReportByHash(sha256: string, analyzer: string | null): Promise<ReportRecord | null>
|
|
347
353
|
listReports(userId?: string): Promise<AdminReport[]>
|
|
348
354
|
getReport(id: string): Promise<ReportRecord | null>
|
|
349
355
|
getTeamReportAccessSnapshot(sessionId: string, now: number, teamId: string): Promise<TeamReportAccessSnapshot | null>
|
|
@@ -504,6 +510,12 @@ function prepareStatements(db: ManagedSql) {
|
|
|
504
510
|
installation_id = excluded.installation_id, default_branch = excluded.default_branch,
|
|
505
511
|
html_url = excluded.html_url, updated_at = excluded.updated_at, active = 1`,
|
|
506
512
|
),
|
|
513
|
+
// Never reactivate or recreate a repo, overwrite another connection, or
|
|
514
|
+
// commit after the administrator's session/role was revoked during GitHub I/O.
|
|
515
|
+
connectRepoInstallationStmt: db.prepare(`UPDATE managed_selected_repo SET installation_id = ?, updated_at = ?
|
|
516
|
+
WHERE repo_id = ? AND full_name = ? AND added_at = ? AND installation_id IS NULL
|
|
517
|
+
AND EXISTS (SELECT 1 FROM managed_session s JOIN managed_user u ON u.id = s.user_id
|
|
518
|
+
WHERE s.id = ? AND s.expires_at > ? AND u.role = 'admin')`),
|
|
507
519
|
deleteRepoStmt: db.prepare(`DELETE FROM managed_selected_repo WHERE repo_id = ?`),
|
|
508
520
|
deactivateRepoStmt: db.prepare(`UPDATE managed_selected_repo SET active = 0 WHERE repo_id = ? AND active = 1`),
|
|
509
521
|
reactivateRepoStmt: db.prepare(`UPDATE managed_selected_repo SET active = 1 WHERE repo_id = ? AND active = 0`),
|
|
@@ -563,6 +575,10 @@ function prepareStatements(db: ManagedSql) {
|
|
|
563
575
|
analyzer AS analyzer, visible AS visible, bundle_id AS bundleId
|
|
564
576
|
FROM managed_report WHERE id = ?`,
|
|
565
577
|
),
|
|
578
|
+
selectReportByHashStmt: db.prepare(
|
|
579
|
+
`SELECT id FROM managed_report WHERE sha256 = ? AND COALESCE(analyzer, '') = COALESCE(?, '')
|
|
580
|
+
ORDER BY uploaded_at, id LIMIT 1`,
|
|
581
|
+
),
|
|
566
582
|
selectReportAccessStmt: db.prepare(
|
|
567
583
|
`WITH requested AS (SELECT value AS id FROM json_each(?)), report_grants AS (
|
|
568
584
|
SELECT r.id AS id, MAX(tu.view_dependencies) AS dependencies, MAX(tu.view_security) AS security
|
|
@@ -837,6 +853,9 @@ function selectedRepoMethods(stmts: ReturnType<typeof prepareStatements>) {
|
|
|
837
853
|
async deselectRepo(repoId: number): Promise<boolean> {
|
|
838
854
|
return Number((await deleteRepoStmt.run(repoId)).changes) > 0
|
|
839
855
|
},
|
|
856
|
+
async connectRepoInstallation(repo: SelectedRepo, installationId: number, sessionId: string, now: number): Promise<boolean> {
|
|
857
|
+
return Number((await stmts.connectRepoInstallationStmt.run(installationId, now, repo.repoId, repo.fullName, repo.addedAt, sessionId, now)).changes) > 0
|
|
858
|
+
},
|
|
840
859
|
async listSelectedRepos(): Promise<SelectedRepo[]> {
|
|
841
860
|
const rows = (await selectReposStmt.all()) as RepoRow[]
|
|
842
861
|
return rows.map(readRepo)
|
|
@@ -888,14 +907,34 @@ type ReportRow = {
|
|
|
888
907
|
// openSqliteManagedDb small. Closes over its prepared statements.
|
|
889
908
|
function reportMethods(stmts: ReturnType<typeof prepareStatements>) {
|
|
890
909
|
const { insertReportStmt, selectReportsStmt, selectReportStmt, deleteReportStmt, setReportRepoStmt, setReportVisibleStmt } = stmts
|
|
910
|
+
async function getReport(id: string): Promise<ReportRecord | null> {
|
|
911
|
+
const row = (await selectReportStmt.get(id)) as ReportRow | undefined
|
|
912
|
+
if (row == null) return null
|
|
913
|
+
return { ...row, repoEmbedded: row.repoEmbedded === 1, visible: row.visible === 1 }
|
|
914
|
+
}
|
|
915
|
+
async function getReportByHash(sha256: string, analyzer: string | null): Promise<ReportRecord | null> {
|
|
916
|
+
const row = await stmts.selectReportByHashStmt.get(sha256, analyzer) as { id: string } | undefined
|
|
917
|
+
return row ? getReport(row.id) : null
|
|
918
|
+
}
|
|
919
|
+
async function insertReport(report: ReportRecordInput, now: number): Promise<void> {
|
|
920
|
+
await insertReportStmt.run(
|
|
921
|
+
report.id, preferredSlug(report.id), report.filename, report.contentType, report.byteSize,
|
|
922
|
+
report.sha256, report.uploadedBy, report.uploadedByLogin ?? null, report.repoId,
|
|
923
|
+
report.repoDirectory ?? '', report.repoEmbedded ? 1 : 0, report.analyzer ?? null, report.visible == null ? 0 : report.visible ? 1 : 0, report.bundleId ?? null,
|
|
924
|
+
report.bundleIntegrity ?? null, now,
|
|
925
|
+
)
|
|
926
|
+
}
|
|
891
927
|
return {
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
)
|
|
928
|
+
insertReport,
|
|
929
|
+
getReport,
|
|
930
|
+
getReportByHash,
|
|
931
|
+
async insertOrReuseReport(report: ReportRecordInput, now: number): Promise<ReportRecord> {
|
|
932
|
+
// The driver holds its writer lock for this whole operation. Checking
|
|
933
|
+
// again here closes the race between the HTTP lookup and blob upload.
|
|
934
|
+
const existing = await getReportByHash(report.sha256, report.analyzer ?? null)
|
|
935
|
+
if (existing) return existing
|
|
936
|
+
await insertReport(report, now)
|
|
937
|
+
return (await getReport(report.id))!
|
|
899
938
|
},
|
|
900
939
|
async listReports(userId?: string): Promise<AdminReport[]> {
|
|
901
940
|
const rows = (await selectReportsStmt.all(userId ?? null, userId ?? null, userId ?? null)) as ReportListRow[]
|
|
@@ -908,16 +947,6 @@ function reportMethods(stmts: ReturnType<typeof prepareStatements>) {
|
|
|
908
947
|
uploadedAt: r.uploadedAt,
|
|
909
948
|
}))
|
|
910
949
|
},
|
|
911
|
-
async getReport(id: string): Promise<ReportRecord | null> {
|
|
912
|
-
const row = (await selectReportStmt.get(id)) as ReportRow | undefined
|
|
913
|
-
if (row == null) return null
|
|
914
|
-
return {
|
|
915
|
-
id: row.id, slug: row.slug, filename: row.filename, contentType: row.contentType, byteSize: row.byteSize,
|
|
916
|
-
sha256: row.sha256, uploadedBy: row.uploadedBy, uploadedAt: row.uploadedAt,
|
|
917
|
-
repoId: row.repoId, repoDirectory: row.repoDirectory, repoEmbedded: row.repoEmbedded === 1, analyzer: row.analyzer, visible: row.visible === 1,
|
|
918
|
-
bundleId: row.bundleId,
|
|
919
|
-
}
|
|
920
|
-
},
|
|
921
950
|
async getTeamReportAccessSnapshot(sessionId: string, now: number, teamId: string): Promise<TeamReportAccessSnapshot | null> {
|
|
922
951
|
const session = await stmts.selectSessionStmt.get(sessionId, now) as SessionRow | undefined
|
|
923
952
|
if (!session) return null
|
|
@@ -1320,6 +1349,7 @@ export function createManagedMethods(db: ManagedSql, options: ManagedDbOptions =
|
|
|
1320
1349
|
return row?.githubId ?? null
|
|
1321
1350
|
},
|
|
1322
1351
|
...githubMetadataMethods(db),
|
|
1352
|
+
...managedIssueMethods(db),
|
|
1323
1353
|
...selectedRepoMethods(stmts),
|
|
1324
1354
|
...activity,
|
|
1325
1355
|
...comments,
|
|
@@ -3,6 +3,7 @@ import { WebSocket } from 'ws'
|
|
|
3
3
|
import { type ManagedDb, type ManagedDbOptions, createManagedMethods } from './db-methods.ts'
|
|
4
4
|
import { MANAGED_SCHEMA } from './db-schema.ts'
|
|
5
5
|
import { GITHUB_METADATA_SCHEMA, GITHUB_STATE_REASON_COLUMN } from './github-metadata.ts'
|
|
6
|
+
import { MANAGED_ISSUE_SCHEMA } from './managed-issues.ts'
|
|
6
7
|
import { COMMENT_SCHEMA } from './comments.ts'
|
|
7
8
|
import { ACTIVITY_SCHEMA } from './activity.ts'
|
|
8
9
|
import { type ManagedSqlDriver, scopeManagedMethods } from './sql.ts'
|
|
@@ -56,7 +57,7 @@ async function initialize(db: PgConnection): Promise<void> {
|
|
|
56
57
|
await db.query(postgresSchema(WORKSPACE_SHARE_SCHEMA))
|
|
57
58
|
await db.query('INSERT INTO managed_schema_version VALUES (4)')
|
|
58
59
|
}
|
|
59
|
-
await db.query(postgresSchema(GITHUB_METADATA_SCHEMA))
|
|
60
|
+
await db.query(postgresSchema(GITHUB_METADATA_SCHEMA + MANAGED_ISSUE_SCHEMA))
|
|
60
61
|
await db.query(`ALTER TABLE managed_github_metadata ADD COLUMN IF NOT EXISTS state_reason ${GITHUB_STATE_REASON_COLUMN}`)
|
|
61
62
|
await db.query('ALTER TABLE managed_github_metadata ADD COLUMN IF NOT EXISTS attempted_at BIGINT')
|
|
62
63
|
if ((await db.query('SELECT version FROM managed_schema_version WHERE version = 5')).rows.length === 0) {
|
|
@@ -78,6 +79,10 @@ async function initialize(db: PgConnection): Promise<void> {
|
|
|
78
79
|
await db.query("ALTER TABLE managed_bundle ADD COLUMN IF NOT EXISTS repo_directory TEXT NOT NULL DEFAULT ''")
|
|
79
80
|
await db.query('INSERT INTO managed_schema_version VALUES (7)')
|
|
80
81
|
}
|
|
82
|
+
if ((await db.query('SELECT version FROM managed_schema_version WHERE version = 8')).rows.length === 0) {
|
|
83
|
+
await db.query('CREATE INDEX IF NOT EXISTS managed_report_hash_idx ON managed_report(sha256, uploaded_at, id)')
|
|
84
|
+
await db.query('INSERT INTO managed_schema_version VALUES (8)')
|
|
85
|
+
}
|
|
81
86
|
await db.query('COMMIT')
|
|
82
87
|
} catch (err) {
|
|
83
88
|
await db.query('ROLLBACK')
|
|
@@ -108,6 +108,7 @@ CREATE TABLE IF NOT EXISTS managed_report (
|
|
|
108
108
|
) STRICT;
|
|
109
109
|
|
|
110
110
|
CREATE INDEX IF NOT EXISTS managed_report_uploaded_at_idx ON managed_report(uploaded_at);
|
|
111
|
+
CREATE INDEX IF NOT EXISTS managed_report_hash_idx ON managed_report(sha256, uploaded_at, id);
|
|
111
112
|
CREATE INDEX IF NOT EXISTS managed_report_bundle_integrity_idx ON managed_report(bundle_integrity);
|
|
112
113
|
CREATE INDEX IF NOT EXISTS managed_report_bundle_hash_idx ON managed_report(bundle_id, sha256);
|
|
113
114
|
|
package/server-managed/db.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { initCommentMethods } from './comments.ts'
|
|
|
8
8
|
import { initActivityMethods } from './activity.ts'
|
|
9
9
|
import { MANAGED_SCHEMA } from './db-schema.ts'
|
|
10
10
|
import { GITHUB_METADATA_SCHEMA, GITHUB_STATE_REASON_COLUMN } from './github-metadata.ts'
|
|
11
|
+
import { MANAGED_ISSUE_SCHEMA } from './managed-issues.ts'
|
|
11
12
|
import { managedTableRenames } from './db-table-names.ts'
|
|
12
13
|
import { type ManagedDb, type ManagedDbOptions, createManagedMethods } from './db-methods.ts'
|
|
13
14
|
import { createSqliteDriver, scopeManagedMethods } from './sql.ts'
|
|
@@ -64,7 +65,7 @@ export function openSqliteManagedDb(path: string, options: ManagedDbOptions = {}
|
|
|
64
65
|
db.exec('PRAGMA synchronous = FULL;')
|
|
65
66
|
db.exec('PRAGMA foreign_keys = ON;')
|
|
66
67
|
migrateManagedTableNames(db)
|
|
67
|
-
db.exec(MANAGED_SCHEMA + GITHUB_METADATA_SCHEMA)
|
|
68
|
+
db.exec(MANAGED_SCHEMA + GITHUB_METADATA_SCHEMA + MANAGED_ISSUE_SCHEMA)
|
|
68
69
|
ensureColumn(db, 'managed_github_metadata', 'state_reason', GITHUB_STATE_REASON_COLUMN)
|
|
69
70
|
ensureColumn(db, 'managed_github_metadata', 'attempted_at', 'INTEGER')
|
|
70
71
|
// Existing public links must opt in too; never preserve an implicit grant.
|
|
@@ -1,23 +1,29 @@
|
|
|
1
|
-
// Repository
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
// identity-only login token. No installation; the login App needs no
|
|
6
|
-
// repository permissions, so login never shows "Act on your behalf".
|
|
7
|
-
// PRIVATE — a SEPARATE GitHub App (Contents: Read) the user installs on the
|
|
8
|
-
// repos they want; read server-side via installation tokens. That
|
|
9
|
-
// App's Contents permission lives on IT, never on the login App, so
|
|
10
|
-
// login stays clean. "Connect a repository" installs it (installUrl).
|
|
11
|
-
//
|
|
12
|
-
// Archived repos are skipped. Nothing here writes.
|
|
1
|
+
// Repository discovery uses the signed-in user token and App installation
|
|
2
|
+
// tokens. Login and repository installation can use one GitHub App: user
|
|
3
|
+
// authorization requests account permissions, installation grants repository
|
|
4
|
+
// permissions. Discovery itself is read-only and skips archived repositories.
|
|
13
5
|
import { Buffer } from 'node:buffer'
|
|
14
|
-
import { createSign } from 'node:crypto'
|
|
6
|
+
import { createHash, createSign } from 'node:crypto'
|
|
15
7
|
import type { ManagedConfig } from './config.ts'
|
|
16
8
|
|
|
17
9
|
const GITHUB_API = 'https://api.github.com'
|
|
18
10
|
const API_VERSION = '2022-11-28'
|
|
19
11
|
const USER_AGENT = 'deepview-triage'
|
|
20
12
|
const PER_PAGE = 100
|
|
13
|
+
const INSTALLATION_TOKEN_TTL_MS = 5 * 60_000
|
|
14
|
+
const INSTALLATION_TOKEN_EXPIRY_MARGIN_MS = 60_000
|
|
15
|
+
const MAX_INSTALLATION_TOKENS = 256
|
|
16
|
+
interface InstallationTokenEntry { token: string | null; expiresAt: number; pending: Promise<string> | null }
|
|
17
|
+
// Credentials only: visibility, user identities, and permissions are never
|
|
18
|
+
// cached here. Bound memory per transport and separate App/key/install scopes.
|
|
19
|
+
const installationTokens = new WeakMap<typeof fetch, Map<string, InstallationTokenEntry>>()
|
|
20
|
+
|
|
21
|
+
function discardInstallationToken(token: string | null, fetchImpl: typeof fetch): void {
|
|
22
|
+
if (!token) return
|
|
23
|
+
const cache = installationTokens.get(fetchImpl)
|
|
24
|
+
if (!cache) return
|
|
25
|
+
for (const [key, entry] of cache) if (entry.token === token) cache.delete(key)
|
|
26
|
+
}
|
|
21
27
|
|
|
22
28
|
// One listed repository. Carries the context to read its contents later:
|
|
23
29
|
// `installationId` mints an App installation token (Contents: Read) for a repo
|
|
@@ -44,7 +50,7 @@ export class GithubApiError extends Error {
|
|
|
44
50
|
}
|
|
45
51
|
}
|
|
46
52
|
|
|
47
|
-
//
|
|
53
|
+
// App installation credentials are configured (id + private key present) →
|
|
48
54
|
// private repos can be listed via its installation tokens.
|
|
49
55
|
export function githubAppConfigured(config: ManagedConfig): boolean {
|
|
50
56
|
return config.githubAppId != null && config.githubAppPrivateKey != null
|
|
@@ -70,7 +76,7 @@ export function mergeRepos(...lists: ConnectedRepo[][]): ConnectedRepo[] {
|
|
|
70
76
|
// One GitHub API call returning parsed JSON, optionally Bearer-authed by a
|
|
71
77
|
// user token, App JWT, or installation token. Network / non-2xx / malformed
|
|
72
78
|
// fold into a GithubApiError (401 passes through for the caller to handle).
|
|
73
|
-
async function githubJson(url: string, token: string | null, fetchImpl: typeof fetch, method: 'GET' | 'POST' = 'GET'): Promise<unknown> {
|
|
79
|
+
export async function githubJson(url: string, token: string | null, fetchImpl: typeof fetch, method: 'GET' | 'POST' = 'GET'): Promise<unknown> {
|
|
74
80
|
let res: Response
|
|
75
81
|
try {
|
|
76
82
|
res = await fetchImpl(url, {
|
|
@@ -83,7 +89,10 @@ async function githubJson(url: string, token: string | null, fetchImpl: typeof f
|
|
|
83
89
|
},
|
|
84
90
|
})
|
|
85
91
|
} catch { throw new GithubApiError(502, 'github-unreachable') }
|
|
86
|
-
if (res.status === 401)
|
|
92
|
+
if (res.status === 401) {
|
|
93
|
+
discardInstallationToken(token, fetchImpl)
|
|
94
|
+
throw new GithubApiError(401, 'github-unauthorized')
|
|
95
|
+
}
|
|
87
96
|
if (res.status === 404) throw new GithubApiError(404, 'github-not-found')
|
|
88
97
|
if (!res.ok) throw new GithubApiError(502, `github-status-${res.status}`)
|
|
89
98
|
try { return await res.json() } catch { throw new GithubApiError(502, 'github-malformed') }
|
|
@@ -156,7 +165,7 @@ export async function listUserRepos(accessToken: string, fetchImpl: typeof fetch
|
|
|
156
165
|
return [...byName.values()].toSorted((a, b) => a.fullName.localeCompare(b.fullName))
|
|
157
166
|
}
|
|
158
167
|
|
|
159
|
-
// ── PRIVATE:
|
|
168
|
+
// ── PRIVATE: App installations via installation tokens ──
|
|
160
169
|
|
|
161
170
|
function base64url(input: Buffer | string): string {
|
|
162
171
|
return Buffer.from(input).toString('base64url')
|
|
@@ -188,13 +197,65 @@ async function listInstallationIds(jwt: string, fetchImpl: typeof fetch): Promis
|
|
|
188
197
|
return ids
|
|
189
198
|
}
|
|
190
199
|
|
|
191
|
-
//
|
|
192
|
-
//
|
|
193
|
-
async function
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
200
|
+
// Reuse installation credentials only in this process. Refresh well before
|
|
201
|
+
// expiry and coalesce concurrent mints; failed or undated tokens are not cached.
|
|
202
|
+
async function installationAccessToken(appId: string, privateKey: string, installId: number, fetchImpl: typeof fetch): Promise<string> {
|
|
203
|
+
let cache = installationTokens.get(fetchImpl)
|
|
204
|
+
if (!cache) { cache = new Map(); installationTokens.set(fetchImpl, cache) }
|
|
205
|
+
const now = Date.now()
|
|
206
|
+
for (const [key, entry] of cache) if (!entry.pending && entry.expiresAt <= now) cache.delete(key)
|
|
207
|
+
const key = JSON.stringify([appId, createHash('sha256').update(privateKey).digest('hex'), installId])
|
|
208
|
+
const cached = cache.get(key)
|
|
209
|
+
if (cached) {
|
|
210
|
+
cache.delete(key)
|
|
211
|
+
cache.set(key, cached)
|
|
212
|
+
if (cached.pending) return cached.pending
|
|
213
|
+
return cached.token!
|
|
214
|
+
}
|
|
215
|
+
const entry: InstallationTokenEntry = { token: null, expiresAt: 0, pending: null }
|
|
216
|
+
const pending = Promise.resolve().then(async () => {
|
|
217
|
+
try {
|
|
218
|
+
const jwt = appJwt(appId, privateKey)
|
|
219
|
+
const body = await githubJson(`${GITHUB_API}/app/installations/${installId}/access_tokens`, jwt, fetchImpl, 'POST') as { token?: unknown; expires_at?: unknown }
|
|
220
|
+
if (typeof body?.token !== 'string' || body.token === '') throw new GithubApiError(502, 'github-install-token-denied')
|
|
221
|
+
entry.token = body.token
|
|
222
|
+
const expiresAt = typeof body.expires_at === 'string' ? Date.parse(body.expires_at) : NaN
|
|
223
|
+
entry.expiresAt = Number.isFinite(expiresAt) ? Math.min(now + INSTALLATION_TOKEN_TTL_MS, expiresAt - INSTALLATION_TOKEN_EXPIRY_MARGIN_MS) : 0
|
|
224
|
+
if (entry.expiresAt <= Date.now() && cache.get(key) === entry) cache.delete(key)
|
|
225
|
+
return body.token
|
|
226
|
+
} catch (err) {
|
|
227
|
+
if (cache.get(key) === entry) cache.delete(key)
|
|
228
|
+
throw err
|
|
229
|
+
} finally { entry.pending = null }
|
|
230
|
+
})
|
|
231
|
+
entry.pending = pending
|
|
232
|
+
cache.set(key, entry)
|
|
233
|
+
if (cache.size > MAX_INSTALLATION_TOKENS) cache.delete(cache.keys().next().value!)
|
|
234
|
+
return await pending
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Resolve this exact repository with the App's identity, then confirm the
|
|
238
|
+
// installation token reaches the same numeric repository. A missing grant is
|
|
239
|
+
// actionable through installation; transport/auth/rate errors must stay errors.
|
|
240
|
+
export async function repositoryInstallation(config: ManagedConfig, repoId: number, fullName: string, fetchImpl: typeof fetch = globalThis.fetch): Promise<number | null> {
|
|
241
|
+
const { githubAppId, githubAppPrivateKey } = config
|
|
242
|
+
if (githubAppId == null || githubAppPrivateKey == null) throw new GithubApiError(503, 'github-app-not-configured')
|
|
243
|
+
if (publicRepositoryName(fullName) !== fullName) throw new GithubApiError(409, 'repo-identity-changed')
|
|
244
|
+
const path = fullName.split('/').map(encodeURIComponent).join('/')
|
|
245
|
+
const jwt = appJwt(githubAppId, githubAppPrivateKey)
|
|
246
|
+
let raw
|
|
247
|
+
try { raw = await githubJson(`${GITHUB_API}/repos/${path}/installation`, jwt, fetchImpl) } catch (err) {
|
|
248
|
+
if (err instanceof GithubApiError && err.status === 404) return null
|
|
249
|
+
throw err
|
|
250
|
+
}
|
|
251
|
+
const installation = raw as { id?: unknown; suspended_at?: unknown; permissions?: { contents?: unknown } } | null
|
|
252
|
+
const id = installation?.id
|
|
253
|
+
if (typeof id !== 'number' || !Number.isSafeInteger(id) || id <= 0) throw new GithubApiError(502, 'github-malformed')
|
|
254
|
+
if (installation?.suspended_at != null || !['read', 'write'].includes(String(installation?.permissions?.contents))) return null
|
|
255
|
+
const token = await installationAccessToken(githubAppId, githubAppPrivateKey, id, fetchImpl)
|
|
256
|
+
const repo = parseRepo(await githubJson(`${GITHUB_API}/repos/${path}`, token, fetchImpl), id)
|
|
257
|
+
if (repo == null || repo.id !== repoId || repo.fullName.toLowerCase() !== fullName.toLowerCase()) throw new GithubApiError(409, 'repo-identity-changed')
|
|
258
|
+
return id
|
|
198
259
|
}
|
|
199
260
|
|
|
200
261
|
// The `repositories` array of one /installation/repositories page → ConnectedRepo[],
|
|
@@ -232,7 +293,7 @@ export async function listInstalledRepos(config: ManagedConfig, fetchImpl: typeo
|
|
|
232
293
|
if (githubAppId == null || githubAppPrivateKey == null) return []
|
|
233
294
|
const jwt = appJwt(githubAppId, githubAppPrivateKey)
|
|
234
295
|
const lists = await mapGithubRequests(await listInstallationIds(jwt, fetchImpl), async (id) => {
|
|
235
|
-
const token = await
|
|
296
|
+
const token = await installationAccessToken(githubAppId, githubAppPrivateKey, id, fetchImpl)
|
|
236
297
|
return listInstallationRepos(token, id, fetchImpl)
|
|
237
298
|
})
|
|
238
299
|
return mergeRepos(...lists)
|
|
@@ -260,9 +321,25 @@ async function mapGithubRequests<T, U>(items: T[], work: (item: T) => Promise<U>
|
|
|
260
321
|
// https://docs.github.com/en/rest/collaborators/collaborators#get-repository-permissions-for-a-user
|
|
261
322
|
// Resolve the current login from /user so renamed handles cannot check someone
|
|
262
323
|
// else's access. All repo paths come from GitHub's installation catalogue.
|
|
263
|
-
export async function
|
|
324
|
+
export async function githubUserIdentity(userToken: string, fetchImpl: typeof fetch = globalThis.fetch): Promise<{ id: number; login: string }> {
|
|
264
325
|
const identity = await githubJson(`${GITHUB_API}/user`, userToken, fetchImpl) as { id?: unknown; login?: unknown }
|
|
265
326
|
if (typeof identity?.login !== 'string' || !identity.login || typeof identity.id !== 'number' || !Number.isSafeInteger(identity.id) || identity.id <= 0) throw new GithubApiError(502, 'github-malformed')
|
|
327
|
+
return { id: identity.id, login: identity.login }
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
export async function githubRepoReadPermission(fullName: string, token: string, identity: { id: number; login: string }, fetchImpl: typeof fetch = globalThis.fetch): Promise<boolean> {
|
|
331
|
+
const path = fullName.split('/').map(encodeURIComponent).join('/')
|
|
332
|
+
try {
|
|
333
|
+
const body = await githubJson(`${GITHUB_API}/repos/${path}/collaborators/${encodeURIComponent(identity.login)}/permission`, token, fetchImpl) as { permission?: unknown; user?: { id?: unknown } }
|
|
334
|
+
return body?.user?.id === identity.id && ['read', 'write', 'admin'].includes(String(body?.permission))
|
|
335
|
+
} catch (err) {
|
|
336
|
+
if (err instanceof GithubApiError && err.status === 404) return false
|
|
337
|
+
throw err // Never broaden access when GitHub cannot verify permission.
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
export async function filterInstalledRepos(config: ManagedConfig, repositories: ConnectedRepo[], userToken: string, fetchImpl: typeof fetch = globalThis.fetch): Promise<ConnectedRepo[]> {
|
|
342
|
+
const identity = await githubUserIdentity(userToken, fetchImpl)
|
|
266
343
|
const tokens = new Map<number, Promise<string | null>>()
|
|
267
344
|
const allowed = await mapGithubRequests(repositories, async (repo) => {
|
|
268
345
|
// Internal repos may have private=false; missing visibility is not proof
|
|
@@ -276,26 +353,19 @@ export async function filterInstalledRepos(config: ManagedConfig, repositories:
|
|
|
276
353
|
}
|
|
277
354
|
const accessToken = await token
|
|
278
355
|
if (!accessToken) return false
|
|
279
|
-
|
|
280
|
-
try {
|
|
281
|
-
const body = await githubJson(`${GITHUB_API}/repos/${path}/collaborators/${encodeURIComponent(identity.login as string)}/permission`, accessToken, fetchImpl) as { permission?: unknown; user?: { id?: unknown } }
|
|
282
|
-
return body?.user?.id === identity.id && ['read', 'write', 'admin'].includes(String(body?.permission))
|
|
283
|
-
} catch (err) {
|
|
284
|
-
if (err instanceof GithubApiError && err.status === 404) return false
|
|
285
|
-
throw err // Never broaden the list when GitHub cannot verify access.
|
|
286
|
-
}
|
|
356
|
+
return githubRepoReadPermission(repo.fullName, accessToken, identity, fetchImpl)
|
|
287
357
|
})
|
|
288
358
|
return repositories.filter((_, index) => allowed[index])
|
|
289
359
|
}
|
|
290
360
|
|
|
291
|
-
//
|
|
361
|
+
// Get a token to READ a selected repo's contents: an installation token (the
|
|
292
362
|
// App's Contents: Read) when the repo was reached through an installation, or
|
|
293
363
|
// null for a public repo readable unauthenticated. This is the whole point of
|
|
294
364
|
// persisting `installationId` — the stored context is enough to read.
|
|
295
365
|
export async function repoAccessToken(config: ManagedConfig, installationId: number | null, fetchImpl: typeof fetch = globalThis.fetch): Promise<string | null> {
|
|
296
366
|
const { githubAppId, githubAppPrivateKey } = config
|
|
297
367
|
if (installationId == null || githubAppId == null || githubAppPrivateKey == null) return null
|
|
298
|
-
return await
|
|
368
|
+
return await installationAccessToken(githubAppId, githubAppPrivateKey, installationId, fetchImpl)
|
|
299
369
|
}
|
|
300
370
|
|
|
301
371
|
// ── merged listing ──
|
|
@@ -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
|
+
}
|