@preventive/triage 1.0.0-alpha.2 → 1.0.0-alpha.21
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/api/reap.ts +17 -0
- package/cli.js +6 -0
- package/client/finding-link.js +305 -0
- package/client/linked-findings.d.ts +1 -0
- package/client/linked-findings.js +111 -0
- package/common/bundle-metadata.d.ts +10 -0
- package/common/bundle-metadata.js +177 -0
- package/common/bundle-reasons.d.ts +2 -0
- package/common/bundle-reasons.js +21 -0
- package/common/bundle-sources.d.ts +3 -0
- package/common/bundle-sources.js +284 -0
- package/common/bundle-stats.js +41 -0
- package/common/bundle-tabs.js +1 -0
- package/common/code-language.js +36 -0
- package/common/default-scan-models.ts +30 -0
- package/common/finding-id.js +47 -0
- package/common/github-pr.ts +56 -0
- package/common/managed/comments.ts +34 -0
- package/common/managed/permissions.ts +35 -0
- package/common/managed/report-content.ts +42 -0
- package/common/managed/report-filter.ts +108 -0
- package/common/managed/roles.ts +28 -0
- package/common/managed/routes.d.ts +2 -0
- package/common/managed/routes.js +121 -0
- package/common/managed/scan-models.ts +6 -0
- package/common/managed/triage.ts +83 -0
- package/common/save-error-reason.ts +20 -7
- package/common/scan-server.ts +13 -0
- package/common/server-info.ts +33 -0
- package/common/utf8.d.ts +3 -0
- package/common/utf8.js +45 -0
- package/out/brotli-fallback.js +3 -3
- package/out/client-managed-import.js +81 -0
- package/out/client-managed.js +110 -0
- package/out/client-sync.js +16 -13
- package/out/graph.js +30 -4
- package/out/index.html +55 -8
- package/out/prism.js +2 -2
- package/out/stasis.svg +45 -0
- package/out/terminal.js +273 -39
- package/out/view.css +1 -1
- package/out/view.js +198 -62
- package/package.json +179 -55
- package/report/index.js +254 -0
- package/report/src/finding-id.js +80 -0
- package/report/src/finding.js +312 -0
- package/report/src/labels.js +33 -0
- package/report/src/md-structure.js +471 -0
- package/report/src/md-text.js +167 -0
- package/report/src/meta.js +76 -0
- package/report/src/parse-codex.js +147 -0
- package/report/src/parse-deepsec.js +197 -0
- package/report/src/parse-deepview-fields.js +375 -0
- package/report/src/parse-deepview-md.js +185 -0
- package/report/src/parse-md-id.js +137 -0
- package/report/src/parse-md.js +322 -0
- package/report/src/parse-piolium-id.js +79 -0
- package/report/src/parse-piolium-rows.js +131 -0
- package/report/src/parse-piolium-tokens.js +175 -0
- package/report/src/parse-piolium.js +400 -0
- package/report/src/security.js +63 -0
- package/report/src/utf8.js +21 -0
- package/report/src/write-md-finding.js +273 -0
- package/report/src/write-md.js +291 -0
- package/server-common/database-config.ts +16 -0
- package/server-common/initialize.ts +18 -0
- package/server-common/npm-advisories.ts +101 -0
- package/{server → server-common}/origin.ts +5 -5
- package/server-common/reap.ts +48 -0
- package/server-common/scan-config.ts +19 -0
- package/server-common/standalone.ts +29 -0
- package/server-common/storage-log.ts +34 -0
- package/server-common/vercel-blob.ts +110 -0
- package/server-e2e/app.ts +485 -0
- package/{server → server-e2e}/auth.ts +5 -1
- package/{server → server-e2e}/bus-receiver.ts +9 -8
- package/{server → server-e2e}/cli.js +9 -4
- package/{server → server-e2e}/config.ts +54 -39
- package/{server → server-e2e}/db-neon.ts +2 -2
- package/{server → server-e2e}/db-revision-sql.ts +7 -10
- package/{server → server-e2e}/db-stmt.ts +2 -2
- package/{server → server-e2e}/db.ts +96 -135
- package/{server → server-e2e}/http.ts +110 -12
- package/{server → server-e2e}/hub.ts +44 -14
- package/server-e2e/index.ts +17 -0
- package/server-e2e/lifecycle.ts +95 -0
- package/{server → server-e2e}/neon-driver.ts +2 -2
- package/{server → server-e2e}/npm-proxy.ts +11 -144
- package/{server → server-e2e}/objstore/blob-fs.ts +6 -8
- package/{server → server-e2e}/objstore/blob-vercel.ts +49 -141
- package/{server → server-e2e}/objstore/blob.ts +24 -9
- package/server-e2e/objstore/fetch-mint-guard.ts +74 -0
- package/{server → server-e2e}/objstore/handlers.ts +19 -20
- package/{server → server-e2e}/objstore/init.ts +53 -27
- package/{server → server-e2e}/objstore/reaper.ts +31 -11
- package/server-e2e/objstore/rest-deny.ts +28 -0
- package/server-e2e/objstore/rest-mint.ts +224 -0
- package/{server → server-e2e}/objstore/rest.ts +110 -93
- package/{server → server-e2e}/objstore/sign.ts +105 -0
- package/{server → server-e2e}/objstore/store-neon.ts +10 -14
- package/{server → server-e2e}/objstore/store.ts +98 -118
- package/{server → server-e2e}/objstore/tokens.ts +9 -12
- package/{server → server-e2e}/peer.ts +7 -9
- package/{server → server-e2e}/pubsub.ts +29 -36
- package/{server → server-e2e}/sign.ts +12 -14
- package/{server → server-e2e}/sse-server.ts +105 -73
- package/{server → server-e2e}/sse-session.ts +30 -16
- package/{server → server-e2e}/static.ts +36 -29
- package/server-e2e/sync-handlers.ts +408 -0
- package/{server → server-e2e}/util.ts +9 -0
- package/{server → server-e2e}/ws-server.ts +29 -23
- package/server-managed/activity.ts +231 -0
- package/server-managed/avatar-store.ts +51 -0
- package/server-managed/blob-store.ts +66 -0
- package/server-managed/blob-vercel.ts +125 -0
- package/server-managed/brotli.ts +10 -0
- package/server-managed/bundle-cache.ts +185 -0
- package/server-managed/bundle-catalog.ts +29 -0
- package/server-managed/bundle-store.ts +28 -0
- package/server-managed/bundle-summary-cache.ts +97 -0
- package/server-managed/bundle.ts +39 -0
- package/server-managed/cache-storage.ts +40 -0
- package/server-managed/cli.js +13 -0
- package/server-managed/combined.ts +46 -0
- package/server-managed/comments.ts +151 -0
- package/server-managed/config.ts +144 -0
- package/server-managed/content-access.ts +15 -0
- package/server-managed/crypto.ts +25 -0
- package/server-managed/db-methods.ts +1336 -0
- package/server-managed/db-neon.ts +171 -0
- package/server-managed/db-schema.ts +203 -0
- package/server-managed/db-table-names.ts +22 -0
- package/server-managed/db.ts +109 -0
- package/server-managed/github-app.ts +332 -0
- package/server-managed/github-metadata.ts +65 -0
- package/server-managed/github-oauth.ts +215 -0
- package/server-managed/github-pulls.ts +115 -0
- package/server-managed/http-response.ts +18 -0
- package/server-managed/http.ts +2043 -0
- package/server-managed/import-triage.ts +48 -0
- package/server-managed/index.ts +135 -0
- package/server-managed/public-workspace.ts +150 -0
- package/server-managed/repo-path.ts +21 -0
- package/server-managed/report-migration.ts +35 -0
- package/server-managed/report-query.ts +4 -0
- package/server-managed/report-response.ts +16 -0
- package/server-managed/report-sources.ts +154 -0
- package/server-managed/repository-discovery.ts +82 -0
- package/server-managed/repository-policy.ts +27 -0
- package/server-managed/session.ts +78 -0
- package/server-managed/slugs.ts +38 -0
- package/server-managed/sql-postgres.ts +30 -0
- package/server-managed/sql.ts +61 -0
- package/server-managed/static.ts +28 -0
- package/server-managed/storage.ts +34 -0
- package/server-managed/team-catalog.ts +7 -0
- package/server-managed/team-feed.ts +128 -0
- package/server-managed/team-reports.ts +156 -0
- package/server-managed/triage-response.ts +16 -0
- package/server-managed/uploads.ts +47 -0
- package/server-managed/workspace-shares.ts +150 -0
- package/server.ts +50 -0
- package/server/index.ts +0 -481
- package/server/lifecycle.ts +0 -204
- package/server/sync-handlers.ts +0 -327
- /package/{server → server-e2e}/config.example.json +0 -0
- /package/{server → server-e2e}/objstore/fs.ts +0 -0
- /package/{server → server-e2e}/validation.ts +0 -0
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import type { ManagedDb } from './db-methods.ts'
|
|
3
|
+
import type { ManagedComment } from '../common/managed/comments.ts'
|
|
4
|
+
import type { TriageEntryPatch } from '../common/managed/triage.ts'
|
|
5
|
+
import { triageWireEntry } from './triage-response.ts'
|
|
6
|
+
|
|
7
|
+
export interface ImportTriageSnapshot {
|
|
8
|
+
entry: TriageEntryPatch | null
|
|
9
|
+
comments: ManagedComment[]
|
|
10
|
+
version: string
|
|
11
|
+
}
|
|
12
|
+
export interface ImportTriageStore {
|
|
13
|
+
getImportTriage(ids: readonly string[]): Promise<Record<string, ImportTriageSnapshot>>
|
|
14
|
+
importTriage(entries: [string, TriageEntryPatch | null][], expected: Record<string, string>, actor: { id: string; login: string }, reportId: string, now: number): Promise<boolean>
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Raw store methods share one transaction for comparison, triage, and comments.
|
|
18
|
+
export function importTriageMethods(store: Pick<ManagedDb, 'listTriage' | 'listComments' | 'setTriageEntries' | 'createComment'>): ImportTriageStore {
|
|
19
|
+
async function snapshot(ids: readonly string[]) {
|
|
20
|
+
const rows = new Map((await store.listTriage(ids)).map(row => [row.findingId, row]))
|
|
21
|
+
const comments = await store.listComments(ids)
|
|
22
|
+
return Object.fromEntries(ids.map(id => {
|
|
23
|
+
const row = rows.get(id)
|
|
24
|
+
const entry = row ? triageWireEntry(row) : null
|
|
25
|
+
const own = comments.filter(comment => comment.findingId === id)
|
|
26
|
+
const version = createHash('sha256').update(JSON.stringify([entry, row?.updatedAt, own])).digest('hex')
|
|
27
|
+
return [id, { entry, comments: own, version }]
|
|
28
|
+
}))
|
|
29
|
+
}
|
|
30
|
+
return {
|
|
31
|
+
getImportTriage: snapshot,
|
|
32
|
+
async importTriage(entries, expected, actor, reportId, now) {
|
|
33
|
+
const current = await snapshot(entries.map(([id]) => id))
|
|
34
|
+
if (entries.some(([id]) => current[id]!.version !== expected[id])) return false
|
|
35
|
+
const changes: [string, TriageEntryPatch | null][] = []
|
|
36
|
+
for (const [id, value] of entries) {
|
|
37
|
+
const { comment, ...entry } = value ?? {}
|
|
38
|
+
changes.push([id, Object.keys(entry).length > 0 ? entry : null])
|
|
39
|
+
if (comment && !current[id]!.comments.some(existing => existing.body === comment)) {
|
|
40
|
+
await store.createComment({ findingId: id, body: comment, authorId: null, authorLogin: null,
|
|
41
|
+
actor, createdAt: null, updatedAt: null, reportId }, now)
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
await store.setTriageEntries(changes, actor.id, actor.login, now, reportId)
|
|
45
|
+
return true
|
|
46
|
+
},
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// Managed HTTP app and standalone boot. The combined launcher mounts this
|
|
2
|
+
// same app on e2e's listener; storage, routing and cleanup stay here.
|
|
3
|
+
import { type Server, createServer } from 'node:http'
|
|
4
|
+
import { fileURLToPath } from 'node:url'
|
|
5
|
+
import { createOriginGate } from '../server-common/origin.ts'
|
|
6
|
+
import { managedStorageLines } from '../server-common/storage-log.ts'
|
|
7
|
+
import { runReapers, withReap } from '../server-common/reap.ts'
|
|
8
|
+
import { startServer } from '../server-common/standalone.ts'
|
|
9
|
+
import { initializeApp } from '../server-common/initialize.ts'
|
|
10
|
+
import { type ManagedConfig, loadManagedConfig } from './config.ts'
|
|
11
|
+
import { type ManagedHttpDeps, createManagedRequestHandler } from './http.ts'
|
|
12
|
+
import { loadManagedStatic } from './static.ts'
|
|
13
|
+
import { openManagedStorage } from './storage.ts'
|
|
14
|
+
|
|
15
|
+
// Expired-session sweep period. Lookups already exclude expired rows
|
|
16
|
+
// (`WHERE expires_at > now`), so this is housekeeping, not a security control.
|
|
17
|
+
const SESSION_GC_INTERVAL_MS = 3_600_000
|
|
18
|
+
|
|
19
|
+
type ManagedAppOptions = Partial<Pick<ManagedHttpDeps, 'next' | 'serverInfo' | 'isShuttingDown'>>
|
|
20
|
+
|
|
21
|
+
export async function createManagedApp(config: ManagedConfig, options: ManagedAppOptions = {}) {
|
|
22
|
+
return await initializeApp(rollback => assembleManagedApp(config, options, rollback))
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function assembleManagedApp(config: ManagedConfig, options: ManagedAppOptions, rollback: AsyncDisposableStack) {
|
|
26
|
+
const storage = await openManagedStorage(config)
|
|
27
|
+
const { db, avatarStore, reportStore, bundleStore, bundleCache, reportSourcesCache } = storage
|
|
28
|
+
rollback.defer(() => db.close())
|
|
29
|
+
const originGate = createOriginGate(config.host, config.trustProxyEnv)
|
|
30
|
+
|
|
31
|
+
let shuttingDown = false
|
|
32
|
+
const isShuttingDown = () => shuttingDown || options.isShuttingDown?.() === true
|
|
33
|
+
const inFlight = new Set<Promise<unknown>>()
|
|
34
|
+
function track(p: Promise<unknown>): void {
|
|
35
|
+
inFlight.add(p)
|
|
36
|
+
p.finally(() => inFlight.delete(p)).catch(() => {})
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const serveStatic = loadManagedStatic(fileURLToPath(new URL('../out/', import.meta.url)), {
|
|
40
|
+
indexOnly: options.next != null, scanServer: options.serverInfo?.deepviewScanServer ?? null,
|
|
41
|
+
})
|
|
42
|
+
const handleRequest = createManagedRequestHandler({
|
|
43
|
+
...options, config, db, avatarStore, reportStore, bundleStore, bundleCache, reportSourcesCache, originGate, serveStatic,
|
|
44
|
+
...('uploadStore' in storage ? { uploadStore: storage.uploadStore } : {}),
|
|
45
|
+
isShuttingDown, track,
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
let cleanup: Promise<void> | undefined
|
|
49
|
+
function reap(): Promise<void> {
|
|
50
|
+
if (cleanup) return cleanup
|
|
51
|
+
cleanup = runReapers({
|
|
52
|
+
sessions: () => db.deleteExpiredSessions(Date.now()),
|
|
53
|
+
...('reapUploads' in storage ? { uploads: () => storage.reapUploads() } : {}),
|
|
54
|
+
}).finally(() => { cleanup = undefined })
|
|
55
|
+
track(cleanup)
|
|
56
|
+
return cleanup
|
|
57
|
+
}
|
|
58
|
+
const gcTimer = config.serverless ? null : setInterval(() => {
|
|
59
|
+
void reap().catch(err => console.warn('managed: maintenance failed:', err))
|
|
60
|
+
}, SESSION_GC_INTERVAL_MS)
|
|
61
|
+
rollback.defer(stop)
|
|
62
|
+
|
|
63
|
+
function stop(): void {
|
|
64
|
+
shuttingDown = true
|
|
65
|
+
if (gcTimer) clearInterval(gcTimer)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function close(): Promise<void> {
|
|
69
|
+
stop()
|
|
70
|
+
if (inFlight.size > 0) await Promise.allSettled([...inFlight])
|
|
71
|
+
await db.close()
|
|
72
|
+
}
|
|
73
|
+
return { handleRequest, reap, isShuttingDown, stop, close }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function logManagedStartup(config: ManagedConfig, port: number, mode = 'managed'): void {
|
|
77
|
+
console.log([
|
|
78
|
+
`DeepView managed server (${mode}):`,
|
|
79
|
+
` HTTP: http://${config.host}:${port}/`,
|
|
80
|
+
...managedStorageLines(config),
|
|
81
|
+
].join('\n'))
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export async function init(config = loadManagedConfig()): Promise<Server> {
|
|
85
|
+
const app = await createManagedApp(config)
|
|
86
|
+
const server = createServer(withReap(app.handleRequest, { managed: app.reap }, { isShuttingDown: app.isShuttingDown }))
|
|
87
|
+
let disposal: Promise<void> | undefined
|
|
88
|
+
function dispose(): Promise<void> {
|
|
89
|
+
if (!disposal) {
|
|
90
|
+
app.stop()
|
|
91
|
+
disposal = (async () => {
|
|
92
|
+
try {
|
|
93
|
+
if (server.listening) {
|
|
94
|
+
await new Promise<void>((resolve, reject) => {
|
|
95
|
+
server.close(err => { if (err) reject(err); else resolve() })
|
|
96
|
+
})
|
|
97
|
+
}
|
|
98
|
+
} finally {
|
|
99
|
+
try { await app.close() }
|
|
100
|
+
finally {
|
|
101
|
+
server.off('error', onError)
|
|
102
|
+
server.off('close', onClose)
|
|
103
|
+
server.off('listening', onListening)
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
})()
|
|
107
|
+
}
|
|
108
|
+
return disposal
|
|
109
|
+
}
|
|
110
|
+
// Embedding hosts can await full cleanup even when they only copied the
|
|
111
|
+
// request listener and never bound this server. Repeated disposal is safe.
|
|
112
|
+
server[Symbol.asyncDispose] = dispose
|
|
113
|
+
|
|
114
|
+
function onError(): void { onClose() }
|
|
115
|
+
function onClose(): void {
|
|
116
|
+
if (!disposal) void dispose().catch(err => { console.error('Managed server cleanup failed:', err) })
|
|
117
|
+
}
|
|
118
|
+
function onListening(): void {
|
|
119
|
+
const address = server.address()
|
|
120
|
+
const port = typeof address === 'object' && address ? address.port : config.port
|
|
121
|
+
logManagedStartup(config, port)
|
|
122
|
+
}
|
|
123
|
+
server.on('error', onError)
|
|
124
|
+
server.once('close', onClose)
|
|
125
|
+
server.on('listening', onListening)
|
|
126
|
+
return server
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export async function start(): Promise<void> {
|
|
130
|
+
const config = loadManagedConfig()
|
|
131
|
+
const server = await init(config)
|
|
132
|
+
startServer(server, config)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (import.meta.main) await start()
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
2
|
+
import { pipeline } from 'node:stream/promises'
|
|
3
|
+
import { Buffer } from 'node:buffer'
|
|
4
|
+
import type { ManagedHttpDeps } from './http.ts'
|
|
5
|
+
import type { ManagedBundle } from './db.ts'
|
|
6
|
+
import type { OpenedBlob } from './blob-store.ts'
|
|
7
|
+
import type { WorkspaceShareSnapshot } from './workspace-shares.ts'
|
|
8
|
+
import { sendJson } from './http-response.ts'
|
|
9
|
+
import { hashToken } from './crypto.ts'
|
|
10
|
+
import { TeamReportsError, loadTeamReports, teamReportVisibility } from './team-reports.ts'
|
|
11
|
+
import { MAX_FINDING_ID, MAX_TRIAGE_HISTORY } from '../common/managed/triage.ts'
|
|
12
|
+
import { triageWireEntry } from './triage-response.ts'
|
|
13
|
+
import { MAX_PACKAGE_INVENTORY_BYTES } from './bundle-cache.ts'
|
|
14
|
+
import { backfillBundleSummaries, bundleSummaries } from './bundle-catalog.ts'
|
|
15
|
+
import { NPM_ADVISORIES_TIMEOUT_MS, fetchNpmAdvisories } from '../server-common/npm-advisories.ts'
|
|
16
|
+
import { serveTeamFeed } from './team-feed.ts'
|
|
17
|
+
|
|
18
|
+
function json(res: ServerResponse, status: number, body: unknown): void {
|
|
19
|
+
sendJson(res, status, body, { 'cache-control': 'private, no-store', 'referrer-policy': 'no-referrer' })
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Called before ALL other routing, including OAuth, discovery, static and the
|
|
23
|
+
// combined server fallback. A supplied capability never inherits cookie auth.
|
|
24
|
+
export async function handlePublicWorkspace(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, url: URL): Promise<void> {
|
|
25
|
+
const token = req.headers['x-deepview-share']
|
|
26
|
+
if (!deps.config.allowShare || typeof token !== 'string' || !/^[A-Za-z0-9_-]{43}$/u.test(token)) {
|
|
27
|
+
json(res, 401, { error: 'invalid-share' }); return
|
|
28
|
+
}
|
|
29
|
+
const method = req.method ?? 'GET'
|
|
30
|
+
if (method !== 'GET' && method !== 'HEAD') { json(res, 403, { error: 'share-read-only' }); return }
|
|
31
|
+
const shareRoute = /^\/api\/shares\/([A-Za-z0-9_-]+)\/workspace$/u.exec(url.pathname)
|
|
32
|
+
const teamRoute = /^\/api\/teams\/([^/]+)\/(shared|reports|feed)$/u.exec(url.pathname)
|
|
33
|
+
const reportRoute = /^\/api\/reports\/([^/]+)\/(triage|triage\/history|comments|sources)$/u.exec(url.pathname)
|
|
34
|
+
const bundleRoute = /^\/api\/bundles\/([^/]+)\/(metadata|contents|download|advisories)$/u.exec(url.pathname)
|
|
35
|
+
if (!shareRoute && !teamRoute && !reportRoute && !bundleRoute) { json(res, 403, { error: 'share-scope-required' }); return }
|
|
36
|
+
const tokenHash = hashToken(token)
|
|
37
|
+
const snapshot = await deps.db.getWorkspaceShare(tokenHash)
|
|
38
|
+
if (!snapshot) { json(res, 401, { error: 'invalid-share' }); return }
|
|
39
|
+
const recheck = async () => {
|
|
40
|
+
const current = deps.config.allowShare && await deps.db.getWorkspaceShare(tokenHash)
|
|
41
|
+
if (!current || JSON.stringify(current) !== JSON.stringify(snapshot)) throw new TeamReportsError(404, 'workspace-changed')
|
|
42
|
+
}
|
|
43
|
+
const send = async (body: unknown) => { await recheck(); json(res, 200, body) }
|
|
44
|
+
const sendTeam = async () => {
|
|
45
|
+
const summaries = await bundleSummaries(snapshot.team.bundles, deps.bundleCache)
|
|
46
|
+
await send({ user: snapshot.user, team: { ...snapshot.team,
|
|
47
|
+
bundles: snapshot.team.bundles.map(bundle => ({ ...bundle, ...(summaries.get(bundle.integrity) ?? { summary: null, summaryRetryAt: null }) })),
|
|
48
|
+
} })
|
|
49
|
+
await backfillBundleSummaries(snapshot.team.bundles, deps.bundleCache)
|
|
50
|
+
}
|
|
51
|
+
const stream = async (stored: OpenedBlob, encoding: string | null, contentType = 'application/json') => {
|
|
52
|
+
try { await recheck() } catch (error) { stored.stream.destroy(); throw error }
|
|
53
|
+
res.writeHead(200, { 'content-type': contentType, 'cache-control': 'private, no-store',
|
|
54
|
+
'x-content-type-options': 'nosniff', ...(encoding ? { 'content-encoding': encoding } : {}),
|
|
55
|
+
...(stored.size == null ? {} : { 'content-length': String(stored.size) }),
|
|
56
|
+
})
|
|
57
|
+
if (method === 'HEAD') { stored.stream.destroy(); res.end(); return }
|
|
58
|
+
try { await pipeline(stored.stream, res) } catch { res.destroy() }
|
|
59
|
+
}
|
|
60
|
+
if (url.searchParams.has('team') && (url.searchParams.getAll('team').length !== 1 || url.searchParams.get('team') !== snapshot.teamId)) {
|
|
61
|
+
json(res, 404, { error: 'no-team' }); return
|
|
62
|
+
}
|
|
63
|
+
if (shareRoute) {
|
|
64
|
+
// The short hash is a recognizable link ID, never an access grant. Resolve
|
|
65
|
+
// with the full token so colliding IDs or slugs cannot broaden its scope.
|
|
66
|
+
// Existing links used the team ID as their fragment prefix.
|
|
67
|
+
if (shareRoute[1] !== tokenHash.slice(0, 8) && shareRoute[1] !== snapshot.teamId) {
|
|
68
|
+
json(res, 404, { error: 'no-share' }); return
|
|
69
|
+
}
|
|
70
|
+
await sendTeam(); return
|
|
71
|
+
}
|
|
72
|
+
if (teamRoute) {
|
|
73
|
+
if (teamRoute[1] !== snapshot.teamId) { json(res, 404, { error: 'no-team' }); return }
|
|
74
|
+
if (teamRoute[2] === 'feed') {
|
|
75
|
+
if (method !== 'GET') { json(res, 405, { error: 'method-not-allowed' }); return }
|
|
76
|
+
await serveTeamFeed(res, deps, snapshot, recheck); return
|
|
77
|
+
}
|
|
78
|
+
if (teamRoute[2] === 'shared') { await sendTeam(); return }
|
|
79
|
+
await send({ reports: await loadTeamReports(deps.db, deps.reportStore, snapshot) }); return
|
|
80
|
+
}
|
|
81
|
+
if (reportRoute) {
|
|
82
|
+
const id = reportRoute[1]!, part = reportRoute[2]!
|
|
83
|
+
if (!snapshot.reports.some(report => report.id === id)) { json(res, 404, { error: 'no-report' }); return }
|
|
84
|
+
const visible = await teamReportVisibility(deps.db, deps.reportStore, snapshot, id)
|
|
85
|
+
await recheck()
|
|
86
|
+
if (part === 'sources') { await sources(res, deps, snapshot, id, visible.sourcePaths, recheck, stream); return }
|
|
87
|
+
if (part === 'comments') { await send({ comments: await deps.db.listComments([...visible.ids]) }); return }
|
|
88
|
+
if (part === 'triage/history') {
|
|
89
|
+
const finding = url.searchParams.get('finding') ?? ''
|
|
90
|
+
if (!finding || finding.length > MAX_FINDING_ID) { json(res, 400, { error: 'bad-request' }); return }
|
|
91
|
+
if (!visible.ids.has(finding)) { json(res, 404, { error: 'no-finding' }); return }
|
|
92
|
+
const events = (await deps.db.listTriageHistory(finding, MAX_TRIAGE_HISTORY)).map(row => ({
|
|
93
|
+
seq: row.seq, at: row.at, actorLogin: row.actorLogin, batchId: row.batchId, entry: triageWireEntry(row, true),
|
|
94
|
+
}))
|
|
95
|
+
await send({ finding, events }); return
|
|
96
|
+
}
|
|
97
|
+
const entries = Object.fromEntries((await deps.db.listTriage([...visible.ids])).map(row => [row.findingId, triageWireEntry(row)]))
|
|
98
|
+
await send({ entries }); return
|
|
99
|
+
}
|
|
100
|
+
const bundle = snapshot.bundles.find(item => item.id === bundleRoute![1])
|
|
101
|
+
if (!bundle) { json(res, 404, { error: 'no-bundle' }); return }
|
|
102
|
+
if (bundleRoute![2] === 'advisories' && !snapshot.permissions.security) { json(res, 403, { error: 'security-access-required' }); return }
|
|
103
|
+
await serveBundle(res, deps, bundle, bundleRoute![2]!, url, recheck, stream)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function serveBundle(res: ServerResponse, deps: ManagedHttpDeps, bundle: ManagedBundle, part: string, url: URL,
|
|
107
|
+
recheck: () => Promise<void>, stream: (stored: OpenedBlob, encoding: string | null, contentType?: string) => Promise<void>) {
|
|
108
|
+
if (part === 'download') {
|
|
109
|
+
const stored = await deps.bundleStore.open(bundle.id, bundle.kind)
|
|
110
|
+
if (!stored) { json(res, 503, { error: 'unavailable' }); return }
|
|
111
|
+
await stream(stored, bundle.kind === 'sourcemap' ? 'br' : null, 'application/octet-stream'); return
|
|
112
|
+
}
|
|
113
|
+
if (!deps.bundleCache) { json(res, 503, { error: 'unavailable' }); return }
|
|
114
|
+
if (part === 'advisories') {
|
|
115
|
+
if (bundle.kind !== 'stasis') { json(res, 422, { error: 'unsupported-bundle' }); return }
|
|
116
|
+
const packages = await deps.bundleCache.packageVersions(bundle, url.searchParams.get('reason') ?? '')
|
|
117
|
+
await recheck()
|
|
118
|
+
if (packages === null) { json(res, 413, { error: 'payload-too-large' }); return }
|
|
119
|
+
if (packages === undefined) { json(res, 400, { error: 'unknown-reason' }); return }
|
|
120
|
+
const body = Buffer.from(JSON.stringify(packages))
|
|
121
|
+
if (body.length > MAX_PACKAGE_INVENTORY_BYTES) { json(res, 413, { error: 'payload-too-large' }); return }
|
|
122
|
+
const result = Object.keys(packages).length === 0 ? { status: 200, body: {} }
|
|
123
|
+
: await fetchNpmAdvisories(body, AbortSignal.timeout(NPM_ADVISORIES_TIMEOUT_MS), deps.config.debug)
|
|
124
|
+
await recheck()
|
|
125
|
+
json(res, result.status, result.status === 200 ? { packages, advisories: result.body } : result.body); return
|
|
126
|
+
}
|
|
127
|
+
let cached
|
|
128
|
+
try { cached = await deps.bundleCache.open(bundle, part as 'metadata' | 'contents') }
|
|
129
|
+
catch { json(res, 422, { error: 'bundle-unavailable' }); return }
|
|
130
|
+
await stream(cached, 'br')
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function sources(res: ServerResponse, deps: ManagedHttpDeps, snapshot: WorkspaceShareSnapshot,
|
|
134
|
+
id: string, paths: Set<string>, recheck: () => Promise<void>, stream: (stored: OpenedBlob, encoding: string | null) => Promise<void>) {
|
|
135
|
+
const report = await deps.db.getReport(id)
|
|
136
|
+
const bundle = report?.bundleId ? await deps.db.getBundle(report.bundleId) : null
|
|
137
|
+
const empty = async () => { await recheck(); res.writeHead(204, { 'cache-control': 'private, no-store' }); res.end() }
|
|
138
|
+
if (!report || !bundle || !['stasis', 'sourcemap'].includes(bundle.kind ?? '') || bundle.repoId !== report.repoId) { await empty(); return }
|
|
139
|
+
if (!deps.reportSourcesCache) { json(res, 503, { error: 'unavailable' }); return }
|
|
140
|
+
let cached
|
|
141
|
+
try { cached = await deps.reportSourcesCache.open(report, bundle, snapshot.permissions, paths) }
|
|
142
|
+
catch { await empty(); return }
|
|
143
|
+
if (!cached) { await empty(); return }
|
|
144
|
+
const current = await deps.db.getReport(id), currentBundle = await deps.db.getBundle(bundle.id)
|
|
145
|
+
if (JSON.stringify(current) !== JSON.stringify(report) || JSON.stringify(currentBundle) !== JSON.stringify(bundle)
|
|
146
|
+
|| snapshot.reports.find(item => item.id === id)?.repo.github !== cached.repo.github) {
|
|
147
|
+
cached.stream.destroy(); throw new TeamReportsError(404, 'workspace-changed')
|
|
148
|
+
}
|
|
149
|
+
await stream(cached, 'gzip')
|
|
150
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
const MAX_TEAM_PATH = 500
|
|
2
|
+
|
|
3
|
+
// Canonicalize only equivalent Git paths: '/' separates components and empty
|
|
4
|
+
// or '.' components do not change the directory. Reject ambiguous input rather
|
|
5
|
+
// than deleting characters from directory names used for authorization.
|
|
6
|
+
// `{ ok:false }` makes the handler 400; `{ path:null }` means the whole repo.
|
|
7
|
+
export function normalizeTeamPath(raw: unknown): { ok: true; path: string | null } | { ok: false } {
|
|
8
|
+
if (typeof raw !== 'string') return { ok: true, path: null }
|
|
9
|
+
// Spaces and backslashes can be literal parts of Git directory names.
|
|
10
|
+
// Neither trimming nor treating '\\' as a separator preserves that identity.
|
|
11
|
+
if (/\p{Cc}/u.test(raw) || raw.includes('\\') || raw !== raw.trim()) return { ok: false }
|
|
12
|
+
const segments: string[] = []
|
|
13
|
+
for (const seg of raw.split('/')) {
|
|
14
|
+
if (seg === '' || seg === '.') continue
|
|
15
|
+
if (seg === '..' || seg !== seg.trim()) return { ok: false }
|
|
16
|
+
segments.push(seg)
|
|
17
|
+
}
|
|
18
|
+
const path = segments.join('/')
|
|
19
|
+
if (path.length > MAX_TEAM_PATH) return { ok: false }
|
|
20
|
+
return { ok: true, path: path === '' ? null : path }
|
|
21
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// SQLite deployments store report blobs beside the database. Recover location
|
|
2
|
+
// metadata before introducing directory-based team access. Schema and data move
|
|
3
|
+
// together so a missing/corrupt blob leaves the migration retryable after repair.
|
|
4
|
+
import { readFileSync } from 'node:fs'
|
|
5
|
+
import { join } from 'node:path'
|
|
6
|
+
import type { DatabaseSync } from 'node:sqlite'
|
|
7
|
+
import { reportRepoGithub } from '../report/index.js'
|
|
8
|
+
import { readManagedReport } from '../common/managed/report-content.ts'
|
|
9
|
+
import { normalizeTeamPath } from './repo-path.ts'
|
|
10
|
+
|
|
11
|
+
export function migrateReportLocations(db: DatabaseSync, reportDir: string): void {
|
|
12
|
+
const columns = db.prepare('PRAGMA table_info(managed_report)').all() as { name: string }[]
|
|
13
|
+
if (columns.some((column) => column.name === 'repo_directory')) return
|
|
14
|
+
db.exec('BEGIN IMMEDIATE')
|
|
15
|
+
try {
|
|
16
|
+
db.exec("ALTER TABLE managed_report ADD COLUMN repo_directory TEXT NOT NULL DEFAULT ''")
|
|
17
|
+
const update = db.prepare('UPDATE managed_report SET repo_directory = ?, repo_embedded = ? WHERE id = ?')
|
|
18
|
+
const reports = db.prepare('SELECT id, filename FROM managed_report').all() as { id: string, filename: string }[]
|
|
19
|
+
for (const { id, filename } of reports) {
|
|
20
|
+
// Same opaque UUID boundary as the blob store; never splice arbitrary
|
|
21
|
+
// database text into a filesystem path during an upgrade.
|
|
22
|
+
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/u.test(id)) throw new Error(`Invalid legacy report id: ${id}`)
|
|
23
|
+
const { data } = readManagedReport(readFileSync(join(reportDir, id), 'utf8'), filename)
|
|
24
|
+
if (data == null) throw new Error(`Cannot migrate location of unreadable report ${id}`)
|
|
25
|
+
const embedded = reportRepoGithub(data) != null
|
|
26
|
+
const directory = normalizeTeamPath(data.repo?.directory)
|
|
27
|
+
if (!directory.ok) throw new Error(`Cannot migrate invalid directory of report ${id}`)
|
|
28
|
+
update.run(directory.path ?? '', embedded ? 1 : 0, id)
|
|
29
|
+
}
|
|
30
|
+
db.exec('COMMIT')
|
|
31
|
+
} catch (err) {
|
|
32
|
+
db.exec('ROLLBACK')
|
|
33
|
+
throw err
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { MIMEType } from 'node:util'
|
|
2
|
+
|
|
3
|
+
// The metadata envelope is opt-in. Wildcards keep the legacy raw report;
|
|
4
|
+
// an explicit, acceptable application/json range requests the envelope.
|
|
5
|
+
export function acceptsReportMetadata(accept: string | undefined): boolean {
|
|
6
|
+
// Commas inside quoted media-type parameters do not separate ranges.
|
|
7
|
+
for (const range of accept?.match(/(?:[^",]|"(?:\\.|[^"\\])*")+/gu) ?? []) {
|
|
8
|
+
try {
|
|
9
|
+
const media = new MIMEType(range.trim())
|
|
10
|
+
if (media.essence !== 'application/json') continue
|
|
11
|
+
const quality = media.params.get('q') ?? '1'
|
|
12
|
+
if (/^(?:0(?:\.\d{0,3})?|1(?:\.0{0,3})?)$/u.test(quality) && Number(quality) > 0) return true
|
|
13
|
+
} catch { /* Ignore malformed media ranges. */ }
|
|
14
|
+
}
|
|
15
|
+
return false
|
|
16
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { Buffer } from 'node:buffer'
|
|
2
|
+
import { createHash } from 'node:crypto'
|
|
3
|
+
import { extname } from 'node:path'
|
|
4
|
+
import { promisify } from 'node:util'
|
|
5
|
+
import { gzip } from 'node:zlib'
|
|
6
|
+
import { bundleSourcesAsMap } from '../common/bundle-sources.js'
|
|
7
|
+
import { loadManagedFindings, managedFindingSourcePaths } from '../common/managed/report-content.ts'
|
|
8
|
+
import { type ViewerPermissions, filterReportContent } from '../common/managed/report-filter.ts'
|
|
9
|
+
import type { CacheStorage } from './cache-storage.ts'
|
|
10
|
+
import { readBundleDetails } from './bundle-cache.ts'
|
|
11
|
+
import type { BlobStore } from './blob-store.ts'
|
|
12
|
+
import type { BundleStore } from './bundle-store.ts'
|
|
13
|
+
import type { ManagedBundle, ManagedDb, ReportRecord } from './db.ts'
|
|
14
|
+
|
|
15
|
+
const compress = promisify(gzip)
|
|
16
|
+
const reportFormat = (filename: string) => extname(filename).toLowerCase()
|
|
17
|
+
|
|
18
|
+
function bundleDirectory(id: string) {
|
|
19
|
+
if (!/^[a-f\d-]{36}$/iu.test(id)) throw new Error('Invalid bundle id')
|
|
20
|
+
return id
|
|
21
|
+
}
|
|
22
|
+
function reportDirectory(bundleId: string, sha256: string) {
|
|
23
|
+
return `${bundleDirectory(bundleId)}/v1-${createHash('sha256').update(sha256).digest('hex')}`
|
|
24
|
+
}
|
|
25
|
+
function formatDirectory(bundleId: string, sha256: string, name: string) {
|
|
26
|
+
return `${reportDirectory(bundleId, sha256)}/${createHash('sha256').update(reportFormat(name)).digest('hex')}`
|
|
27
|
+
}
|
|
28
|
+
function sourceCacheFilename(report: ReportRecord, bundle: ManagedBundle, permissions: ViewerPermissions, repo: { github: string | null }, sourcePaths?: Set<string>) {
|
|
29
|
+
const key = createHash('sha256').update(JSON.stringify([
|
|
30
|
+
'finding-access-v4', sourcePaths ? [...sourcePaths].toSorted() : null, bundle.integrity, bundle.kind, permissions.dependencies, permissions.security, repo,
|
|
31
|
+
])).digest('hex')
|
|
32
|
+
return `${formatDirectory(bundle.id, report.sha256, report.filename)}/${key}.json.gz`
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Match the report's spelling exactly, then an unambiguous path suffix. Never
|
|
36
|
+
// guess between duplicate basenames or treat a bundle path as a disk pathname.
|
|
37
|
+
function selectSources(sourcePaths: Set<string>, sources: Map<string, string>) {
|
|
38
|
+
const byBasename = new Map<string, string[]>()
|
|
39
|
+
for (const file of sources.keys()) {
|
|
40
|
+
const basename = file.split('/').at(-1)!
|
|
41
|
+
const candidates = byBasename.get(basename) ?? []
|
|
42
|
+
candidates.push(file)
|
|
43
|
+
byBasename.set(basename, candidates)
|
|
44
|
+
}
|
|
45
|
+
const files = new Map<string, string>(), paths = new Map<string, string>()
|
|
46
|
+
function add(path: unknown) {
|
|
47
|
+
if (typeof path !== 'string' || !path || paths.has(path)) return
|
|
48
|
+
let file = sources.has(path) ? path : null
|
|
49
|
+
if (file === null) {
|
|
50
|
+
const suffix = path.startsWith('/') ? path : `/${path}`
|
|
51
|
+
const matches = byBasename.get(path.split('/').at(-1)!)?.filter(candidate => candidate.endsWith(suffix)) ?? []
|
|
52
|
+
if (matches.length === 1) file = matches[0]!
|
|
53
|
+
}
|
|
54
|
+
if (file === null) return
|
|
55
|
+
paths.set(path, file)
|
|
56
|
+
files.set(file, sources.get(file)!)
|
|
57
|
+
}
|
|
58
|
+
for (const path of sourcePaths) add(path)
|
|
59
|
+
return { files: [...files], paths: [...paths] }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Immutable report hashes share a derivative across duplicate uploads. Bundle
|
|
63
|
+
// identity and visibility are part of the key: a broader viewer's sources must
|
|
64
|
+
// never populate a restricted response. Group derivatives by hash and filename
|
|
65
|
+
// format so each format's permissions can be removed after its last deletion.
|
|
66
|
+
export function createReportSourcesCache(storage: CacheStorage, db: ManagedDb, reports: BlobStore, bundles: BundleStore) {
|
|
67
|
+
const pending = new Map<string, { reportId: string; job: Promise<boolean> }>()
|
|
68
|
+
let queue = Promise.resolve()
|
|
69
|
+
function enqueue<T>(work: () => Promise<T>): Promise<T> {
|
|
70
|
+
const job = queue.then(work)
|
|
71
|
+
queue = job.then(() => undefined, () => undefined)
|
|
72
|
+
return job
|
|
73
|
+
}
|
|
74
|
+
async function referenced(report: ReportRecord, bundle: ManagedBundle) {
|
|
75
|
+
const names = await db.listReportFilenamesWithBundleHash(bundle.id, report.sha256)
|
|
76
|
+
return names.some(name => reportFormat(name) === reportFormat(report.filename)) && await db.getBundle(bundle.id) != null
|
|
77
|
+
}
|
|
78
|
+
async function build(target: string, report: ReportRecord, bundle: ManagedBundle, permissions: ViewerPermissions, repo: { github: string | null }, sourcePaths?: Set<string>) {
|
|
79
|
+
// Team requests supply paths from their already-filtered members. Reopening
|
|
80
|
+
// the original report by ID would conflate visible and hidden occurrences.
|
|
81
|
+
if (!sourcePaths) {
|
|
82
|
+
const bytes = await reports.get(report.id)
|
|
83
|
+
if (!bytes) return false
|
|
84
|
+
const parsed = await loadManagedFindings(filterReportContent(bytes.toString('utf8'), permissions, report.filename, repo), report.filename)
|
|
85
|
+
if (!parsed) return false
|
|
86
|
+
sourcePaths = managedFindingSourcePaths(parsed.findings)
|
|
87
|
+
}
|
|
88
|
+
const details = await readBundleDetails(bundle, bundles)
|
|
89
|
+
if (!details) return false
|
|
90
|
+
const selection = selectSources(sourcePaths, bundleSourcesAsMap(details))
|
|
91
|
+
const body = await compress(Buffer.from(JSON.stringify({ integrity: bundle.integrity, ...selection })), { level: 6 })
|
|
92
|
+
// A duplicate with the same hash AND format can use these parsed bytes.
|
|
93
|
+
// Another format must not keep a deleted variant's late build alive.
|
|
94
|
+
if (!(await referenced(report, bundle))) return false
|
|
95
|
+
await storage.put(target, body)
|
|
96
|
+
// Another instance may finish deletion while this write is in flight.
|
|
97
|
+
// Recheck after publishing so a late builder cannot resurrect its cache.
|
|
98
|
+
if (!(await referenced(report, bundle))) {
|
|
99
|
+
await storage.delete(formatDirectory(bundle.id, report.sha256, report.filename))
|
|
100
|
+
return false
|
|
101
|
+
}
|
|
102
|
+
return true
|
|
103
|
+
}
|
|
104
|
+
async function ensure(target: string, report: ReportRecord, bundle: ManagedBundle, permissions: ViewerPermissions, repo: { github: string | null }, sourcePaths?: Set<string>): Promise<boolean> {
|
|
105
|
+
const existing = pending.get(target)
|
|
106
|
+
if (existing) {
|
|
107
|
+
const ready = await existing.job
|
|
108
|
+
if (ready || existing.reportId === report.id) return ready
|
|
109
|
+
// Deletion can also win before the initiator reads its blob. Retry with
|
|
110
|
+
// this request's row instead of caching that other row's miss as 204.
|
|
111
|
+
const initiator = await db.getReport(existing.reportId)
|
|
112
|
+
if (initiator?.bundleId === bundle.id && initiator.sha256 === report.sha256) return false
|
|
113
|
+
if (pending.get(target) === existing) pending.delete(target)
|
|
114
|
+
return ensure(target, report, bundle, permissions, repo, sourcePaths)
|
|
115
|
+
}
|
|
116
|
+
const job = (async () => {
|
|
117
|
+
if (await storage.exists(target)) return true
|
|
118
|
+
// Serial cold builds bound peak decompression/parsing memory.
|
|
119
|
+
return enqueue(() => build(target, report, bundle, permissions, repo, sourcePaths))
|
|
120
|
+
})()
|
|
121
|
+
const entry = { reportId: report.id, job }
|
|
122
|
+
pending.set(target, entry)
|
|
123
|
+
try { return await job } finally { if (pending.get(target) === entry) pending.delete(target) }
|
|
124
|
+
}
|
|
125
|
+
return {
|
|
126
|
+
async open(report: ReportRecord, bundle: ManagedBundle, permissions: ViewerPermissions, sourcePaths?: Set<string>) {
|
|
127
|
+
const repo = { github: (await db.listAllRepos()).find(entry => entry.repoId === report.repoId)?.fullName ?? null }
|
|
128
|
+
const target = sourceCacheFilename(report, bundle, permissions, repo, sourcePaths)
|
|
129
|
+
if (!(await ensure(target, report, bundle, permissions, repo, sourcePaths))) return null
|
|
130
|
+
const opened = await storage.open(target)
|
|
131
|
+
return { ...opened, repo }
|
|
132
|
+
},
|
|
133
|
+
async deleteBundle(id: string) {
|
|
134
|
+
const prefix = `${bundleDirectory(id)}/`
|
|
135
|
+
await Promise.allSettled([...pending].filter(([key]) => key.startsWith(prefix)).map(([, entry]) => entry.job))
|
|
136
|
+
await storage.delete(bundleDirectory(id))
|
|
137
|
+
},
|
|
138
|
+
async deleteReport(report: Pick<ReportRecord, 'bundleId' | 'sha256' | 'filename'>) {
|
|
139
|
+
const { bundleId, sha256 } = report
|
|
140
|
+
if (bundleId === null) return
|
|
141
|
+
// Called after metadata deletion. Serialize with builders so a cold
|
|
142
|
+
// request cannot recreate a derivative after its cleanup has finished.
|
|
143
|
+
await enqueue(async () => {
|
|
144
|
+
const names = await db.listReportFilenamesWithBundleHash(bundleId, sha256)
|
|
145
|
+
if (names.length === 0) {
|
|
146
|
+
await storage.delete(reportDirectory(bundleId, sha256))
|
|
147
|
+
} else if (!names.some(name => reportFormat(name) === reportFormat(report.filename))) {
|
|
148
|
+
await storage.delete(formatDirectory(bundleId, sha256, report.filename))
|
|
149
|
+
}
|
|
150
|
+
})
|
|
151
|
+
},
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
export type ReportSourcesCache = ReturnType<typeof createReportSourcesCache>
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import type { ManagedConfig } from './config.ts'
|
|
3
|
+
import { GithubApiError, filterInstalledRepos, listInstalledRepos, listUserRepos } from './github-app.ts'
|
|
4
|
+
import type { ConnectedRepo, RepoListing } from './github-app.ts'
|
|
5
|
+
|
|
6
|
+
const CACHE_MS = 60_000
|
|
7
|
+
const MAX_ENTRIES = 100
|
|
8
|
+
interface Entry {
|
|
9
|
+
expires: number
|
|
10
|
+
pending: boolean
|
|
11
|
+
value: Promise<ConnectedRepo[]>
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// One directory per HTTP handler. Searches/pages reuse discovery, but selected
|
|
15
|
+
// flags still come from the database on every request. User results are scoped
|
|
16
|
+
// to both the managed identity and its current GitHub credential. This cache is
|
|
17
|
+
// only for discovery; connecting a repo still verifies fresh installation access.
|
|
18
|
+
export class RepositoryDiscovery {
|
|
19
|
+
private readonly cache = new Map<string, Entry>()
|
|
20
|
+
private readonly config: ManagedConfig
|
|
21
|
+
private readonly fetchImpl: typeof fetch
|
|
22
|
+
private readonly now: () => number
|
|
23
|
+
|
|
24
|
+
constructor(config: ManagedConfig, fetchImpl: typeof fetch = (...args) => globalThis.fetch(...args), now: () => number = Date.now) {
|
|
25
|
+
this.config = config
|
|
26
|
+
this.fetchImpl = fetchImpl
|
|
27
|
+
this.now = now
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
private read(key: string, load: () => Promise<ConnectedRepo[]>): Promise<ConnectedRepo[]> {
|
|
31
|
+
const cached = this.cache.get(key)
|
|
32
|
+
if (cached && (cached.pending || cached.expires > this.now())) return cached.value
|
|
33
|
+
this.cache.delete(key)
|
|
34
|
+
while (this.cache.size >= MAX_ENTRIES) this.cache.delete(this.cache.keys().next().value!)
|
|
35
|
+
const entry: Entry = { expires: 0, pending: true, value: Promise.resolve([]) }
|
|
36
|
+
entry.value = load().then(repos => {
|
|
37
|
+
entry.expires = this.now() + CACHE_MS
|
|
38
|
+
entry.pending = false
|
|
39
|
+
return repos
|
|
40
|
+
}, (err: unknown) => {
|
|
41
|
+
if (this.cache.get(key) === entry) this.cache.delete(key)
|
|
42
|
+
throw err
|
|
43
|
+
})
|
|
44
|
+
this.cache.set(key, entry)
|
|
45
|
+
return entry.value
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async list(scope: 'installed' | 'public', userId: string, token: string | null, showAll = false, refresh = false): Promise<RepoListing> {
|
|
49
|
+
// Invalidate derived user results along with the installation catalogue.
|
|
50
|
+
// An older in-flight read cannot repopulate this map after refresh.
|
|
51
|
+
if (refresh) this.cache.clear()
|
|
52
|
+
const installed = () => this.read('installed', () => listInstalledRepos(this.config, this.fetchImpl))
|
|
53
|
+
if (scope === 'installed' && showAll) return { repositories: await installed(), tokenMissing: false }
|
|
54
|
+
const publicInstalled = async (): Promise<ConnectedRepo[]> => (await installed()).filter(repo => repo.visibility === 'public')
|
|
55
|
+
if (token == null) return { repositories: scope === 'installed' ? await publicInstalled() : [], tokenMissing: true }
|
|
56
|
+
const credential = createHash('sha256').update(token).digest('hex')
|
|
57
|
+
const key = JSON.stringify([scope, userId, credential])
|
|
58
|
+
try {
|
|
59
|
+
if (scope === 'installed') {
|
|
60
|
+
const repositories = await this.read(key, async () => filterInstalledRepos(this.config, await installed(), token, this.fetchImpl))
|
|
61
|
+
return { repositories, tokenMissing: false }
|
|
62
|
+
}
|
|
63
|
+
// Installations only deduplicate the public picker. Their failure must
|
|
64
|
+
// not block otherwise readable public repos or look like a stale login.
|
|
65
|
+
// Cache the two sources separately so failed installation discovery is
|
|
66
|
+
// retried without caching a degraded combined list for another minute.
|
|
67
|
+
const [userRepos, installedRepos] = await Promise.all([
|
|
68
|
+
this.read(key, () => listUserRepos(token, this.fetchImpl)),
|
|
69
|
+
installed().catch((err: unknown) => {
|
|
70
|
+
console.warn('managed: installed repo deduplication failed:', err)
|
|
71
|
+
return []
|
|
72
|
+
}),
|
|
73
|
+
])
|
|
74
|
+
const installedIds = new Set(installedRepos.map(repo => repo.id))
|
|
75
|
+
const repositories = userRepos.filter(repo => repo.visibility === 'public' && !installedIds.has(repo.id))
|
|
76
|
+
return { repositories, tokenMissing: false }
|
|
77
|
+
} catch (err) {
|
|
78
|
+
if (!(err instanceof GithubApiError) || err.status !== 401) throw err
|
|
79
|
+
return { repositories: scope === 'installed' ? await publicInstalled() : [], tokenMissing: true }
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|