@preventive/triage 1.0.0-alpha.2 → 1.0.0-alpha.20
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 +1330 -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 +25 -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,25 @@
|
|
|
1
|
+
import type { StoredUser } from './db.ts'
|
|
2
|
+
|
|
3
|
+
const WHITEHAT = [
|
|
4
|
+
291301, // ChALkeR
|
|
5
|
+
]
|
|
6
|
+
const whitehatIds = new Set(WHITEHAT)
|
|
7
|
+
|
|
8
|
+
// (1) Server permission to add repositories. Keep this independent of GitHub
|
|
9
|
+
// identity and ecosystem exceptions if other install modes broaden it later.
|
|
10
|
+
export function canAddRepositories(user: StoredUser): boolean {
|
|
11
|
+
return user.role === 'admin'
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// (3b) Only the ecosystem safeguard for public repositories is bypassed by
|
|
15
|
+
// WHITEHAT. Callers must separately establish (0) server readability, (1) add
|
|
16
|
+
// permission, and (2) server admin or GitHub access to this repository.
|
|
17
|
+
export function passesPublicRepositorySafeguard(githubUserId: number | null, involved: boolean): boolean {
|
|
18
|
+
return involved || (githubUserId != null && whitehatIds.has(githubUserId))
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Arbitrary public repositories have no involvement evidence, so (2) requires
|
|
22
|
+
// admin even if (1) expands in the future. This capability is also the UI gate.
|
|
23
|
+
export function canAddAnyPublicRepository(user: StoredUser, githubUserId: number | null): boolean {
|
|
24
|
+
return canAddRepositories(user) && user.role === 'admin' && passesPublicRepositorySafeguard(githubUserId, false)
|
|
25
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// Session + cookie plumbing for the managed auth server. The raw session
|
|
2
|
+
// token lives ONLY in the cookie; the DB row id is its SHA-256 (see crypto.ts)
|
|
3
|
+
// so a DB read can't reconstruct a live cookie. A double-submit CSRF token is
|
|
4
|
+
// minted per session and returned to the client (echoed back on mutations).
|
|
5
|
+
//
|
|
6
|
+
// Async throughout — the store (db.ts) is async so a future non-SQLite
|
|
7
|
+
// backend slots in without touching these helpers.
|
|
8
|
+
import type { ManagedConfig } from './config.ts'
|
|
9
|
+
import type { ManagedDb, ManagedSession, ManagedUser, StoredUser } from './db.ts'
|
|
10
|
+
import { hashToken, randomToken } from './crypto.ts'
|
|
11
|
+
|
|
12
|
+
// Base name for the short-lived OAuth-state cookie; `__Host-` is prefixed via
|
|
13
|
+
// `cookieName` when cookies are Secure.
|
|
14
|
+
export const STATE_COOKIE = 'dvstate'
|
|
15
|
+
|
|
16
|
+
// `__Host-`-prefix the base name when cookies are Secure (the prefix mandates
|
|
17
|
+
// Secure + Path=/ + no Domain, which `buildCookie` already emits).
|
|
18
|
+
export function cookieName(config: ManagedConfig, base: string): string {
|
|
19
|
+
return config.cookieSecure ? `__Host-${base}` : base
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function buildCookie(
|
|
23
|
+
name: string, value: string,
|
|
24
|
+
opts: { maxAgeS: number; secure: boolean; sameSite?: 'Lax' | 'Strict' },
|
|
25
|
+
): string {
|
|
26
|
+
const parts = [`${name}=${value}`, 'Path=/', 'HttpOnly', `SameSite=${opts.sameSite ?? 'Lax'}`, `Max-Age=${opts.maxAgeS}`]
|
|
27
|
+
if (opts.secure) parts.push('Secure')
|
|
28
|
+
return parts.join('; ')
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Expire a cookie now (Max-Age=0).
|
|
32
|
+
export function clearCookie(name: string, secure: boolean): string {
|
|
33
|
+
return buildCookie(name, '', { maxAgeS: 0, secure })
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function parseCookies(header: string | undefined): Map<string, string> {
|
|
37
|
+
const out = new Map<string, string>()
|
|
38
|
+
if (!header) return out
|
|
39
|
+
for (const part of header.split(';')) {
|
|
40
|
+
const eq = part.indexOf('=')
|
|
41
|
+
if (eq < 0) continue
|
|
42
|
+
const k = part.slice(0, eq).trim()
|
|
43
|
+
if (k) out.set(k, part.slice(eq + 1).trim())
|
|
44
|
+
}
|
|
45
|
+
return out
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Mint a session for a freshly-authenticated user. Upserts the identity, then
|
|
49
|
+
// stores the session keyed by the cookie token's hash. Returns the cookie to
|
|
50
|
+
// set and the CSRF token (also persisted on the row).
|
|
51
|
+
export async function createSession(
|
|
52
|
+
config: ManagedConfig, db: ManagedDb, user: ManagedUser, now: number,
|
|
53
|
+
): Promise<{ setCookie: string; csrfToken: string; userId: string }> {
|
|
54
|
+
const token = randomToken()
|
|
55
|
+
const csrfToken = randomToken()
|
|
56
|
+
const userId = await db.upsertUser(user, now, config.initialAdminGithubId)
|
|
57
|
+
await db.createSession({ id: hashToken(token), userId, csrfToken, expiresAt: now + config.sessionTtlMs }, now)
|
|
58
|
+
const setCookie = buildCookie(config.sessionCookieName, token, {
|
|
59
|
+
maxAgeS: Math.floor(config.sessionTtlMs / 1000), secure: config.cookieSecure, sameSite: 'Lax',
|
|
60
|
+
})
|
|
61
|
+
return { setCookie, csrfToken, userId }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Resolve the current session (+ user) from the request's cookies, or null.
|
|
65
|
+
export function readSession(
|
|
66
|
+
config: ManagedConfig, db: ManagedDb, cookieHeader: string | undefined, now: number,
|
|
67
|
+
): Promise<{ session: ManagedSession; user: StoredUser } | null> {
|
|
68
|
+
const token = parseCookies(cookieHeader).get(config.sessionCookieName)
|
|
69
|
+
if (token == null || token === '') return Promise.resolve(null)
|
|
70
|
+
return db.sessionWithUser(hashToken(token), now)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Drop the session named by the request's cookie (logout). The caller also
|
|
74
|
+
// clears the cookie client-side.
|
|
75
|
+
export async function endSession(config: ManagedConfig, db: ManagedDb, cookieHeader: string | undefined): Promise<void> {
|
|
76
|
+
const token = parseCookies(cookieHeader).get(config.sessionCookieName)
|
|
77
|
+
if (token != null && token !== '') await db.deleteSession(hashToken(token))
|
|
78
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { DatabaseSync } from 'node:sqlite'
|
|
2
|
+
|
|
3
|
+
// Legacy non-UUID IDs stay intact. Production IDs are UUIDs; only their final
|
|
4
|
+
// component is proposed as a slug, never an arbitrary prefix or abbreviation.
|
|
5
|
+
export function preferredSlug(id: string): string {
|
|
6
|
+
return /^[a-f\d]{8}(?:-[a-f\d]{4}){3}-([a-f\d]{12})$/iu.exec(id)?.[1] ?? id
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function allocateMissingSlugs(rows: { id: string; slug: string | null }[]): { id: string; slug: string }[] {
|
|
10
|
+
// Reserve legacy IDs before UUID suffixes can claim them.
|
|
11
|
+
const used = new Set(rows.flatMap(row => row.slug == null ? [row.id] : [row.id, row.slug]))
|
|
12
|
+
const allocated = []
|
|
13
|
+
for (const row of rows) {
|
|
14
|
+
if (row.slug != null) continue
|
|
15
|
+
const candidate = preferredSlug(row.id)
|
|
16
|
+
const slug = used.has(candidate) ? row.id : candidate
|
|
17
|
+
allocated.push({ id: row.id, slug })
|
|
18
|
+
used.add(slug)
|
|
19
|
+
}
|
|
20
|
+
return allocated
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Allocate once, across the entire namespace rather than a viewer's teams.
|
|
24
|
+
// Keep existing slugs stable when names, memberships, or collisions change.
|
|
25
|
+
export function migrateSlugs(db: DatabaseSync): void {
|
|
26
|
+
db.exec('BEGIN IMMEDIATE')
|
|
27
|
+
try {
|
|
28
|
+
for (const table of ['managed_team', 'managed_report', 'managed_bundle']) {
|
|
29
|
+
const columns = db.prepare(`PRAGMA table_info(${table})`).all() as { name: string }[]
|
|
30
|
+
if (!columns.some(column => column.name === 'slug')) db.exec(`ALTER TABLE ${table} ADD COLUMN slug TEXT`)
|
|
31
|
+
const rows = db.prepare(`SELECT id, slug FROM ${table} ORDER BY id`).all() as { id: string; slug: string | null }[]
|
|
32
|
+
const update = db.prepare(`UPDATE ${table} SET slug = ? WHERE id = ?`)
|
|
33
|
+
for (const row of allocateMissingSlugs(rows)) update.run(row.slug, row.id)
|
|
34
|
+
db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS ${table}_slug_idx ON ${table}(slug)`)
|
|
35
|
+
}
|
|
36
|
+
db.exec('COMMIT')
|
|
37
|
+
} catch (err) { db.exec('ROLLBACK'); throw err }
|
|
38
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// The small SQL dialect used by the shared store. Parameter values never pass
|
|
2
|
+
// through these rewrites; only our own SQL does. Keep aliases case-sensitive.
|
|
3
|
+
export function postgresSql(source: string): { sql: string; names: string[] } {
|
|
4
|
+
const names: string[] = []
|
|
5
|
+
let position = 0
|
|
6
|
+
let sql = source.replaceAll('json_each(:contexts)', 'jsonb_array_elements(:contexts::jsonb)')
|
|
7
|
+
.replaceAll(/json_extract\((\w+\.value), '\$\.(\w+)'\)/gu, "($1->>'$2')")
|
|
8
|
+
.replaceAll('json_each(?)', 'jsonb_array_elements_text(?::jsonb)')
|
|
9
|
+
.replaceAll('INSERT OR IGNORE INTO', 'INSERT INTO')
|
|
10
|
+
.replaceAll('CROSS JOIN', 'JOIN')
|
|
11
|
+
.replaceAll(/\binstr\(/gu, 'strpos(')
|
|
12
|
+
// Quoted literals (including ':') are never placeholders or identifiers.
|
|
13
|
+
sql = sql.split(/('(?:''|[^'])*')/u).map((part, i) => i % 2 ? part : part
|
|
14
|
+
.replaceAll(/\?|(?<!:):([a-zA-Z]\w*)/gu, (_match, name: string | undefined) => {
|
|
15
|
+
if (!name) return `$${++position}`
|
|
16
|
+
let index = names.indexOf(name)
|
|
17
|
+
if (index < 0) { index = names.length; names.push(name) }
|
|
18
|
+
return `$${index + 1}`
|
|
19
|
+
})
|
|
20
|
+
.replaceAll(/\b[a-z]+[A-Z]\w*\b/gu, name => `"${name}"`)
|
|
21
|
+
).join('')
|
|
22
|
+
sql = sql.replaceAll(/(\$\d+) IS NULL/gu, '$1::text IS NULL')
|
|
23
|
+
if (source.includes('INSERT OR IGNORE')) sql += ' ON CONFLICT DO NOTHING'
|
|
24
|
+
return { sql, names }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function postgresSchema(sql: string): string {
|
|
28
|
+
return sql.replaceAll(') STRICT;', ');').replaceAll(/\bINTEGER\b/gu, 'BIGINT')
|
|
29
|
+
.replaceAll(/seq\s+BIGINT PRIMARY KEY/gu, 'seq BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY')
|
|
30
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// Each public store operation owns a transaction. SQLite serializes access to
|
|
2
|
+
// its single connection across awaits; Neon binds one connection per operation.
|
|
3
|
+
import type { DatabaseSync, SQLInputValue } from 'node:sqlite'
|
|
4
|
+
import type { ManagedDb } from './db-methods.ts'
|
|
5
|
+
|
|
6
|
+
type MaybePromise<T> = T | Promise<T>
|
|
7
|
+
export interface ManagedSql {
|
|
8
|
+
prepare(sql: string): {
|
|
9
|
+
get(...params: unknown[]): MaybePromise<unknown>
|
|
10
|
+
all(...params: unknown[]): MaybePromise<unknown[]>
|
|
11
|
+
run(...params: unknown[]): MaybePromise<{ changes: number | bigint }>
|
|
12
|
+
}
|
|
13
|
+
close(): MaybePromise<void>
|
|
14
|
+
}
|
|
15
|
+
export interface ManagedSqlDriver extends ManagedSql {
|
|
16
|
+
scope<T>(write: boolean, work: () => Promise<T>): Promise<T>
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function scopeManagedMethods(methods: ManagedDb, driver: ManagedSqlDriver): ManagedDb {
|
|
20
|
+
const entries = Object.entries(methods).map(([name, method]) => {
|
|
21
|
+
if (name === 'close') return [name, async () => { await driver.close() }]
|
|
22
|
+
const write = !/^(?:get|list|userCanRead|reportPermissionsFor)/u.test(name)
|
|
23
|
+
return [name, (...args: unknown[]) => driver.scope(write, () => method(...args))]
|
|
24
|
+
})
|
|
25
|
+
return Object.fromEntries(entries) as ManagedDb
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function createSqliteDriver(db: DatabaseSync): ManagedSqlDriver {
|
|
29
|
+
let queue = Promise.resolve()
|
|
30
|
+
let closed = false
|
|
31
|
+
function enqueue<T>(work: () => Promise<T>): Promise<T> {
|
|
32
|
+
const result = queue.then(work)
|
|
33
|
+
queue = result.then(() => undefined, () => undefined)
|
|
34
|
+
return result
|
|
35
|
+
}
|
|
36
|
+
return {
|
|
37
|
+
prepare(sql) {
|
|
38
|
+
const stmt = db.prepare(sql)
|
|
39
|
+
return {
|
|
40
|
+
get: (...args) => stmt.get(...args as SQLInputValue[]),
|
|
41
|
+
all: (...args) => stmt.all(...args as SQLInputValue[]),
|
|
42
|
+
run: (...args) => stmt.run(...args as SQLInputValue[]),
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
scope(write, work) {
|
|
46
|
+
if (closed) return Promise.reject(new Error('Managed database is closed'))
|
|
47
|
+
return enqueue(async () => {
|
|
48
|
+
db.exec(write ? 'BEGIN IMMEDIATE' : 'BEGIN')
|
|
49
|
+
try {
|
|
50
|
+
const result = await work()
|
|
51
|
+
db.exec('COMMIT')
|
|
52
|
+
return result
|
|
53
|
+
} catch (err) {
|
|
54
|
+
db.exec('ROLLBACK')
|
|
55
|
+
throw err
|
|
56
|
+
}
|
|
57
|
+
})
|
|
58
|
+
},
|
|
59
|
+
async close() { closed = true; await queue; db.close() },
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { type StaticHandler, loadStatic } from '../server-e2e/static.ts'
|
|
2
|
+
import { parseManagedRoute } from '../common/managed/routes.js'
|
|
3
|
+
|
|
4
|
+
// A deep page URL must still resolve every asset at the app root. Preload
|
|
5
|
+
// headers resolve against the request URL, so their hrefs must be absolute too.
|
|
6
|
+
export function managedPageHtml(html: string): string {
|
|
7
|
+
return html.replaceAll(/(href|src)="\.\//gu, '$1="/')
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function loadManagedStatic(staticDir: string, { indexOnly = false, scanServer = null }: { indexOnly?: boolean, scanServer?: string | null } = {}): StaticHandler {
|
|
11
|
+
const serve = loadStatic(staticDir, scanServer, { transformIndex: managedPageHtml, indexOnly })
|
|
12
|
+
return (req, res) => {
|
|
13
|
+
if (!req.url?.startsWith('/') || req.url.startsWith('//')) return false
|
|
14
|
+
const url = new URL(req.url, 'http://localhost')
|
|
15
|
+
let path
|
|
16
|
+
try { path = decodeURIComponent(url.pathname) } catch { return false }
|
|
17
|
+
// No API request, including an unknown API route, may receive app HTML.
|
|
18
|
+
if (path === '/api' || path.startsWith('/api/')) return false
|
|
19
|
+
if (serve(req, res)) return true
|
|
20
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') return false
|
|
21
|
+
// Finding IDs can contain dots (including URL-shaped IDs). Recognised
|
|
22
|
+
// pages still receive HTML; missing assets retain a 404.
|
|
23
|
+
if (path.split('/').at(-1)?.includes('.') && !parseManagedRoute(url)) return false
|
|
24
|
+
const original = req.url
|
|
25
|
+
req.url = '/'
|
|
26
|
+
try { return serve(req, res) } finally { req.url = original }
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Backend selection is shared by standalone, combined and function entrypoints.
|
|
2
|
+
import { dirname, join } from 'node:path'
|
|
3
|
+
import { createDiskAvatarStore } from './avatar-store.ts'
|
|
4
|
+
import { createDiskBlobStore } from './blob-store.ts'
|
|
5
|
+
import { createDiskBundleStore } from './bundle-store.ts'
|
|
6
|
+
import { createBundleCache, createDiskBundleCache } from './bundle-cache.ts'
|
|
7
|
+
import { createDiskCacheStorage } from './cache-storage.ts'
|
|
8
|
+
import { createReportSourcesCache } from './report-sources.ts'
|
|
9
|
+
import { openManagedVercelStorage } from './blob-vercel.ts'
|
|
10
|
+
import { openNeonManagedDb } from './db-neon.ts'
|
|
11
|
+
import type { ManagedConfig } from './config.ts'
|
|
12
|
+
|
|
13
|
+
export async function openManagedStorage(config: ManagedConfig) {
|
|
14
|
+
const options = { triageHistoryLimit: config.triageHistoryLimit }
|
|
15
|
+
if (config.neonUrl) {
|
|
16
|
+
if (!config.blobToken) throw new Error('Managed Neon mode requires BLOB_READ_WRITE_TOKEN')
|
|
17
|
+
const storage = await openManagedVercelStorage(config.blobToken)
|
|
18
|
+
const db = await openNeonManagedDb(config.neonUrl, options)
|
|
19
|
+
return { ...storage, db, bundleCache: createBundleCache(storage.cacheStorage, db, storage.bundleStore),
|
|
20
|
+
reportSourcesCache: createReportSourcesCache(storage.reportSourcesStorage, db, storage.reportStore, storage.bundleStore),
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
if (config.serverless) throw new Error('Serverless managed storage requires Neon')
|
|
24
|
+
const { openSqliteManagedDb } = await import('./db.ts')
|
|
25
|
+
const db = openSqliteManagedDb(config.dbPath, options)
|
|
26
|
+
const dir = dirname(config.dbPath)
|
|
27
|
+
const reportStore = createDiskBlobStore(join(dir, 'reports'))
|
|
28
|
+
const bundleStore = createDiskBundleStore(join(dir, 'bundles'))
|
|
29
|
+
return { db, reportStore, bundleStore,
|
|
30
|
+
reportSourcesCache: createReportSourcesCache(createDiskCacheStorage(join(dir, 'cache', 'report-sources')), db, reportStore, bundleStore),
|
|
31
|
+
avatarStore: createDiskAvatarStore(join(dir, 'avatars')),
|
|
32
|
+
bundleCache: createDiskBundleCache(join(dir, 'cache', 'bundles'), db, bundleStore),
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
|
|
3
|
+
// Version only this user's visible catalog, including access and content keys.
|
|
4
|
+
// REST and the live feed must describe exactly the same snapshot.
|
|
5
|
+
export function teamCatalogRevision(teams: readonly unknown[]): string {
|
|
6
|
+
return createHash('sha256').update(JSON.stringify(teams)).digest('base64url')
|
|
7
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import type { ServerResponse } from 'node:http'
|
|
2
|
+
import { setTimeout as delay } from 'node:timers/promises'
|
|
3
|
+
import type { ManagedHttpDeps } from './http.ts'
|
|
4
|
+
import type { StoredUser, TeamReportAccessSnapshot } from './db.ts'
|
|
5
|
+
import { TeamReportsError, teamSnapshotKey, teamWorkspaceFindingIds } from './team-reports.ts'
|
|
6
|
+
|
|
7
|
+
// Short database reads work across instances without pinning a Neon connection.
|
|
8
|
+
// All polling belongs to the awaited request; nothing runs after it completes.
|
|
9
|
+
export const TEAM_FEED_POLL_MS = 3_000
|
|
10
|
+
export const TEAM_FEED_LIFETIME_MS = 240_000 // below the configured Vercel 300s
|
|
11
|
+
const HEARTBEAT_MS = 15_000
|
|
12
|
+
|
|
13
|
+
type FeedOptions = { pollMs?: number; lifetimeMs?: number }
|
|
14
|
+
type Publish = (event: 'teams' | 'triage', revision: string | undefined) => boolean
|
|
15
|
+
|
|
16
|
+
// Public capabilities remain scoped to their single shared workspace.
|
|
17
|
+
export async function serveTeamFeed(res: ServerResponse, deps: ManagedHttpDeps,
|
|
18
|
+
snapshot: TeamReportAccessSnapshot, recheck: () => Promise<void>, options: FeedOptions = {}): Promise<void> {
|
|
19
|
+
let ids: string[] | undefined
|
|
20
|
+
await serveFeed(res, deps, async publish => {
|
|
21
|
+
await recheck()
|
|
22
|
+
ids ??= [...await teamWorkspaceFindingIds(deps.db, deps.reportStore, snapshot)]
|
|
23
|
+
const triage = await deps.db.getAnnotationRevision(ids)
|
|
24
|
+
await recheck()
|
|
25
|
+
publish('triage', triage)
|
|
26
|
+
}, options)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Membership and content notifications cover all of the user's teams. Only
|
|
30
|
+
// the focused team needs report parsing or annotation reads; null is the
|
|
31
|
+
// catalog-only subscription used on landing and management pages.
|
|
32
|
+
export async function serveUserTeamFeed(res: ServerResponse, deps: ManagedHttpDeps,
|
|
33
|
+
sessionId: string, user: StoredUser, teamId: string | null, options: FeedOptions = {}): Promise<void> {
|
|
34
|
+
let ids: string[] = [], visibilityKey: string | undefined
|
|
35
|
+
const checkUser = (current: { user: Pick<StoredUser, 'id' | 'role'> } | null) => {
|
|
36
|
+
if (!current) throw new TeamReportsError(401, 'unauthenticated')
|
|
37
|
+
if (current.user.id !== user.id || current.user.role !== user.role || current.user.role === 'none') {
|
|
38
|
+
throw new TeamReportsError(403, 'access-changed')
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
await serveFeed(res, deps, async publish => {
|
|
42
|
+
const catalog = await deps.db.getUserTeamFeedSnapshot(sessionId, Date.now())
|
|
43
|
+
checkUser(catalog)
|
|
44
|
+
checkUser(await deps.db.getReportAccessSnapshot(sessionId, Date.now(), []))
|
|
45
|
+
// Publish catalog state before loading any report blobs. A slow or broken
|
|
46
|
+
// focused report must not hide membership/content changes from the client.
|
|
47
|
+
if (!publish('teams', catalog!.revision) || !teamId) return
|
|
48
|
+
let triage: string | undefined
|
|
49
|
+
const snapshot = await deps.db.getTeamReportAccessSnapshot(sessionId, Date.now(), teamId)
|
|
50
|
+
if (!snapshot) throw new TeamReportsError(401, 'unauthenticated')
|
|
51
|
+
checkUser(snapshot)
|
|
52
|
+
const key = teamSnapshotKey(snapshot)
|
|
53
|
+
if (key !== visibilityKey) {
|
|
54
|
+
try {
|
|
55
|
+
ids = snapshot.teamId ? [...await teamWorkspaceFindingIds(deps.db, deps.reportStore, snapshot)] : []
|
|
56
|
+
visibilityKey = key
|
|
57
|
+
} catch {
|
|
58
|
+
// Missing, malformed or temporarily unavailable blobs only suspend
|
|
59
|
+
// triage. Retry visibility on the next poll, even for the same key.
|
|
60
|
+
publish('triage', undefined)
|
|
61
|
+
return
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
if (snapshot.teamId) triage = JSON.stringify([key, await deps.db.getAnnotationRevision(ids)])
|
|
65
|
+
const current = await deps.db.getTeamReportAccessSnapshot(sessionId, Date.now(), teamId)
|
|
66
|
+
// A concurrent access/content change discards only the annotation read.
|
|
67
|
+
checkUser(current)
|
|
68
|
+
if (!current || teamSnapshotKey(current) !== key) return
|
|
69
|
+
publish('triage', triage)
|
|
70
|
+
}, options)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function serveFeed(res: ServerResponse, deps: ManagedHttpDeps, read: (publish: Publish) => Promise<void>,
|
|
74
|
+
{ pollMs = TEAM_FEED_POLL_MS, lifetimeMs = TEAM_FEED_LIFETIME_MS }: FeedOptions): Promise<void> {
|
|
75
|
+
const controller = new AbortController()
|
|
76
|
+
const close = () => controller.abort()
|
|
77
|
+
res.once('close', close)
|
|
78
|
+
const deadline = setTimeout(close, lifetimeMs)
|
|
79
|
+
const stopped = () => controller.signal.aborted || res.destroyed || deps.isShuttingDown()
|
|
80
|
+
// Notifications carry no annotation bodies, finding IDs, or global cursors.
|
|
81
|
+
// The catalog's opaque version lets clients reuse an identical REST snapshot;
|
|
82
|
+
// every connection still confirms current access, including after reconnects.
|
|
83
|
+
function write(frame: string): boolean {
|
|
84
|
+
if (stopped()) return false
|
|
85
|
+
if (res.write(frame)) return true
|
|
86
|
+
// Slow consumers reconnect and read current state; never queue a backlog.
|
|
87
|
+
res.destroy()
|
|
88
|
+
return false
|
|
89
|
+
}
|
|
90
|
+
try {
|
|
91
|
+
if (stopped()) return
|
|
92
|
+
res.writeHead(200, { 'content-type': 'text/event-stream; charset=utf-8',
|
|
93
|
+
'cache-control': 'private, no-store, no-transform', 'x-accel-buffering': 'no',
|
|
94
|
+
'x-content-type-options': 'nosniff', 'referrer-policy': 'no-referrer' })
|
|
95
|
+
res.flushHeaders()
|
|
96
|
+
let heartbeat = Date.now()
|
|
97
|
+
const previous = new Map<string, string>()
|
|
98
|
+
const publish: Publish = (event, revision) => {
|
|
99
|
+
if (stopped()) return false
|
|
100
|
+
if (revision === undefined) { previous.delete(event); return true }
|
|
101
|
+
if (revision === previous.get(event)) return true
|
|
102
|
+
const data = event === 'teams' ? JSON.stringify({ revision }) : '{}'
|
|
103
|
+
if (!write(`event: ${event}\ndata: ${data}\n\n`)) return false
|
|
104
|
+
previous.set(event, revision)
|
|
105
|
+
heartbeat = Date.now()
|
|
106
|
+
return true
|
|
107
|
+
}
|
|
108
|
+
while (!stopped()) {
|
|
109
|
+
await read(publish)
|
|
110
|
+
if (stopped()) break
|
|
111
|
+
if (Date.now() - heartbeat >= HEARTBEAT_MS) {
|
|
112
|
+
if (!write(': keepalive\n\n')) break
|
|
113
|
+
heartbeat = Date.now()
|
|
114
|
+
}
|
|
115
|
+
await delay(pollMs, undefined, { signal: controller.signal }).catch(error => {
|
|
116
|
+
if (!controller.signal.aborted) throw error
|
|
117
|
+
})
|
|
118
|
+
}
|
|
119
|
+
} catch (error) {
|
|
120
|
+
if (!res.headersSent) throw error
|
|
121
|
+
if (error instanceof TeamReportsError) write('event: close\ndata: {}\n\n')
|
|
122
|
+
else if (!stopped()) throw error
|
|
123
|
+
} finally {
|
|
124
|
+
clearTimeout(deadline)
|
|
125
|
+
res.off('close', close)
|
|
126
|
+
if (res.headersSent && !res.destroyed) res.end()
|
|
127
|
+
}
|
|
128
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// Team content is filtered as one workspace, with separate report envelopes.
|
|
2
|
+
// Only the visibility index is retained in memory for annotations/source reads.
|
|
3
|
+
import { Buffer } from 'node:buffer'
|
|
4
|
+
import { backfillFindingIds, reportEntries, stampSecurityGroups } from '../report/index.js'
|
|
5
|
+
import { managedFindingSourcePaths, readManagedReport } from '../common/managed/report-content.ts'
|
|
6
|
+
import { filterReportData, projectFinding } from '../common/managed/report-filter.ts'
|
|
7
|
+
import type { BlobStore } from './blob-store.ts'
|
|
8
|
+
import type { ManagedDb, TeamReportAccessSnapshot } from './db.ts'
|
|
9
|
+
import { MAX_REPORT_QUERY_BYTES, MAX_REPORT_QUERY_COUNT } from './report-query.ts'
|
|
10
|
+
|
|
11
|
+
type Finding = Record<string, unknown>
|
|
12
|
+
type TeamReport = { id: string; filename: string; data: unknown; repo: { github: string | null; directory: string } }
|
|
13
|
+
type ReportVisibility = { ids: Set<string>; sourcePaths: Set<string> }
|
|
14
|
+
type Visibility = Map<string, ReportVisibility>
|
|
15
|
+
const VISIBILITY_CACHE_MAX_ITEMS = 250_000
|
|
16
|
+
const VISIBILITY_CACHE_MAX_SNAPSHOTS = 32
|
|
17
|
+
const caches = new WeakMap<ManagedDb, Map<string, Visibility>>()
|
|
18
|
+
function visibilityWeight(visible: Visibility): number {
|
|
19
|
+
return [...visible.values()].reduce((sum, v) => sum + v.ids.size + v.sourcePaths.size, 0)
|
|
20
|
+
}
|
|
21
|
+
function retainVisibility(db: ManagedDb, key: string, visible: Visibility): void {
|
|
22
|
+
let cache = caches.get(db)
|
|
23
|
+
// Replacement contributes its weight once and does not evict a peer merely
|
|
24
|
+
// because the cache already holds the maximum number of snapshots.
|
|
25
|
+
cache?.delete(key)
|
|
26
|
+
const incoming = visibilityWeight(visible)
|
|
27
|
+
if (incoming > VISIBILITY_CACHE_MAX_ITEMS) return
|
|
28
|
+
if (!cache) { cache = new Map(); caches.set(db, cache) }
|
|
29
|
+
let total = incoming + [...cache.values()].reduce((sum, entry) => sum + visibilityWeight(entry), 0)
|
|
30
|
+
for (const [oldest, entry] of cache) {
|
|
31
|
+
if (cache.size < VISIBILITY_CACHE_MAX_SNAPSHOTS && total <= VISIBILITY_CACHE_MAX_ITEMS) break
|
|
32
|
+
cache.delete(oldest)
|
|
33
|
+
total -= visibilityWeight(entry)
|
|
34
|
+
}
|
|
35
|
+
cache.set(key, visible)
|
|
36
|
+
}
|
|
37
|
+
export class TeamReportsError extends Error {
|
|
38
|
+
status: number
|
|
39
|
+
constructor(status: number, error: string) { super(error); this.status = status }
|
|
40
|
+
}
|
|
41
|
+
export function teamSnapshotKey(snapshot: TeamReportAccessSnapshot): string {
|
|
42
|
+
return JSON.stringify([snapshot.user.id, snapshot.user.role, snapshot.teamId, snapshot.reports, snapshot.repositories])
|
|
43
|
+
}
|
|
44
|
+
export async function teamSnapshot(db: ManagedDb, sessionId: string, teamId: string): Promise<TeamReportAccessSnapshot> {
|
|
45
|
+
const snapshot = await db.getTeamReportAccessSnapshot(sessionId, Date.now(), teamId)
|
|
46
|
+
if (!snapshot) throw new TeamReportsError(401, 'unauthenticated')
|
|
47
|
+
if (!snapshot.teamId) throw new TeamReportsError(404, 'no-team')
|
|
48
|
+
return snapshot
|
|
49
|
+
}
|
|
50
|
+
export async function recheckTeam(db: ManagedDb, sessionId: string, snapshot: TeamReportAccessSnapshot): Promise<void> {
|
|
51
|
+
if (teamSnapshotKey(await teamSnapshot(db, sessionId, snapshot.teamId!)) !== teamSnapshotKey(snapshot)) throw new TeamReportsError(404, 'workspace-changed')
|
|
52
|
+
}
|
|
53
|
+
function groupsOf(data: unknown): Finding[][] {
|
|
54
|
+
return (reportEntries(data) ?? []).map(entry => (Array.isArray(entry) ? entry : [entry]).filter(
|
|
55
|
+
(finding): finding is Finding => finding !== null && typeof finding === 'object' && !Array.isArray(finding),
|
|
56
|
+
))
|
|
57
|
+
}
|
|
58
|
+
function linksOf(data: unknown): string[][] | null {
|
|
59
|
+
const record = data as { source?: string; links?: string[][] }
|
|
60
|
+
if (record?.source !== 'links' || !Array.isArray(record.links)) return null
|
|
61
|
+
if (!record.links.every(ids => Array.isArray(ids) && ids.every(id => typeof id === 'string'))) throw new TeamReportsError(422, 'unreadable-links')
|
|
62
|
+
return record.links
|
|
63
|
+
}
|
|
64
|
+
async function loadTeamWorkspace(db: ManagedDb, store: BlobStore, snapshot: TeamReportAccessSnapshot): Promise<{ reports: TeamReport[]; visible: Visibility }> {
|
|
65
|
+
if (snapshot.reports.length > MAX_REPORT_QUERY_COUNT || snapshot.reports.reduce((n, r) => n + r.byteSize, 0) > MAX_REPORT_QUERY_BYTES) throw new TeamReportsError(413, 'batch-too-large')
|
|
66
|
+
const reports: TeamReport[] = []
|
|
67
|
+
let inputBytes = 0
|
|
68
|
+
// Retain parsed reports for cross-report classification; release raw bytes
|
|
69
|
+
// after each parse. No unbounded burst of remote reads or duplicate buffers.
|
|
70
|
+
for (const access of snapshot.reports) {
|
|
71
|
+
const bytes = await store.get(access.id)
|
|
72
|
+
if (!bytes) throw new TeamReportsError(503, 'unavailable')
|
|
73
|
+
inputBytes += bytes.length
|
|
74
|
+
if (inputBytes > MAX_REPORT_QUERY_BYTES) throw new TeamReportsError(413, 'batch-too-large')
|
|
75
|
+
const parsed = readManagedReport(bytes.toString('utf8'), access.filename)
|
|
76
|
+
if (!parsed.data) throw new TeamReportsError(422, 'unreadable-report')
|
|
77
|
+
// Backfill before filtering so links, annotations and the client agree on
|
|
78
|
+
// IDs even when hiding a component would change the original row shape.
|
|
79
|
+
await backfillFindingIds(groupsOf(parsed.data).flat())
|
|
80
|
+
reports.push({ id: access.id, filename: access.filename, data: parsed.data, repo: access.repo })
|
|
81
|
+
}
|
|
82
|
+
const edges = new Map<string, Set<string>>()
|
|
83
|
+
const connect = (a: string, b: string) => {
|
|
84
|
+
if (!edges.has(a)) edges.set(a, new Set())
|
|
85
|
+
edges.get(a)!.add(b)
|
|
86
|
+
}
|
|
87
|
+
// A star is enough for transitive security propagation, without a quadratic
|
|
88
|
+
// expansion for large links. Unknown IDs can connect known findings.
|
|
89
|
+
for (const report of reports) {
|
|
90
|
+
for (const ids of linksOf(report.data) ?? []) {
|
|
91
|
+
for (const id of ids.slice(1)) { connect(ids[0]!, id); connect(id, ids[0]!) }
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
const groups = reports.flatMap(report => groupsOf(report.data).map(group => group.map(f => projectFinding(f, report.data as Finding)!)))
|
|
95
|
+
stampSecurityGroups(groups, { linkedIds: id => edges.get(id) ?? [] })
|
|
96
|
+
const securityIds = new Set(groups.flat().filter(f => f['isSecurity'] === true).map(f => String(f['id'])))
|
|
97
|
+
const allIds = new Set<string>(), visible: Visibility = new Map()
|
|
98
|
+
for (let i = 0; i < reports.length; i++) {
|
|
99
|
+
const access = snapshot.reports[i]!, report = reports[i]!
|
|
100
|
+
report.data = filterReportData(report.data, access.permissions, access.repo, securityIds)
|
|
101
|
+
const ids = new Set<string>()
|
|
102
|
+
for (const finding of groupsOf(report.data).flat()) {
|
|
103
|
+
// Preserve linked security classification for the UI lens, including a
|
|
104
|
+
// linked component that a dependency permission will hide from the UI.
|
|
105
|
+
finding['isSecurity'] = securityIds.has(String(finding['id']))
|
|
106
|
+
if (typeof finding['id'] === 'string') { ids.add(finding['id']); allIds.add(finding['id']) }
|
|
107
|
+
}
|
|
108
|
+
visible.set(report.id, { ids, sourcePaths: managedFindingSourcePaths(groupsOf(report.data).flat()) })
|
|
109
|
+
}
|
|
110
|
+
for (const report of reports) {
|
|
111
|
+
const links = linksOf(report.data)
|
|
112
|
+
if (links) report.data = { source: 'links', findings: [], links: links.map(ids => [...new Set(ids.filter(id => allIds.has(id)))]).filter(ids => ids.length >= 2) }
|
|
113
|
+
}
|
|
114
|
+
let outputBytes = 14
|
|
115
|
+
for (const report of reports) {
|
|
116
|
+
outputBytes += Buffer.byteLength(JSON.stringify(report)) + 1
|
|
117
|
+
if (outputBytes > MAX_REPORT_QUERY_BYTES) throw new TeamReportsError(413, 'batch-too-large')
|
|
118
|
+
}
|
|
119
|
+
retainVisibility(db, teamSnapshotKey(snapshot), visible)
|
|
120
|
+
return { reports, visible }
|
|
121
|
+
}
|
|
122
|
+
export async function loadTeamReports(db: ManagedDb, store: BlobStore, snapshot: TeamReportAccessSnapshot): Promise<TeamReport[]> {
|
|
123
|
+
return (await loadTeamWorkspace(db, store, snapshot)).reports
|
|
124
|
+
}
|
|
125
|
+
async function teamVisibility(db: ManagedDb, store: BlobStore, snapshot: TeamReportAccessSnapshot): Promise<Visibility> {
|
|
126
|
+
const key = teamSnapshotKey(snapshot)
|
|
127
|
+
let visible = caches.get(db)?.get(key)
|
|
128
|
+
if (!visible) {
|
|
129
|
+
// A large snapshot may deliberately remain uncached, or be evicted by
|
|
130
|
+
// another request. Authorization uses this load's result in either case.
|
|
131
|
+
visible = (await loadTeamWorkspace(db, store, snapshot)).visible
|
|
132
|
+
}
|
|
133
|
+
return visible
|
|
134
|
+
}
|
|
135
|
+
export async function teamWorkspaceFindingIds(db: ManagedDb, store: BlobStore, snapshot: TeamReportAccessSnapshot): Promise<Set<string>> {
|
|
136
|
+
const ids = new Set<string>()
|
|
137
|
+
for (const report of (await teamVisibility(db, store, snapshot)).values()) for (const id of report.ids) ids.add(id)
|
|
138
|
+
return ids
|
|
139
|
+
}
|
|
140
|
+
export async function teamReportVisibility(db: ManagedDb, store: BlobStore, snapshot: TeamReportAccessSnapshot, reportId: string): Promise<ReportVisibility> {
|
|
141
|
+
if (!snapshot.reports.some(r => r.id === reportId)) throw new TeamReportsError(404, 'no-report')
|
|
142
|
+
const visible = await teamVisibility(db, store, snapshot)
|
|
143
|
+
return visible.get(reportId)!
|
|
144
|
+
}
|
|
145
|
+
export async function teamFindingIds(db: ManagedDb, store: BlobStore, sessionId: string, teamId: string, reportId: string): Promise<Set<string>> {
|
|
146
|
+
const snapshot = await teamSnapshot(db, sessionId, teamId)
|
|
147
|
+
const visible = await teamReportVisibility(db, store, snapshot, reportId)
|
|
148
|
+
await recheckTeam(db, sessionId, snapshot)
|
|
149
|
+
return visible.ids
|
|
150
|
+
}
|
|
151
|
+
export async function teamSourcePaths(db: ManagedDb, store: BlobStore, sessionId: string, teamId: string, reportId: string): Promise<Set<string>> {
|
|
152
|
+
const snapshot = await teamSnapshot(db, sessionId, teamId)
|
|
153
|
+
const visible = await teamReportVisibility(db, store, snapshot, reportId)
|
|
154
|
+
await recheckTeam(db, sessionId, snapshot)
|
|
155
|
+
return visible.sourcePaths
|
|
156
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { TriageRow } from './db.ts'
|
|
2
|
+
import { type TriageEntryPatch, isTriageBucket } from '../common/managed/triage.ts'
|
|
3
|
+
|
|
4
|
+
// A stored triage row (current state or a trail event) → its wire entry: only
|
|
5
|
+
// set fields present, `false` kept for flagged (the explicit un-flag tombstone
|
|
6
|
+
// must round-trip), and null for the row of a cleared entry (every field
|
|
7
|
+
// null) — the reader adopts the clear.
|
|
8
|
+
export function triageWireEntry(row: Pick<TriageRow, 'color' | 'triage' | 'comment' | 'fix' | 'flagged'>, legacyHistory = false): TriageEntryPatch | null {
|
|
9
|
+
const e: TriageEntryPatch = {}
|
|
10
|
+
if (row.color != null) e.color = row.color
|
|
11
|
+
if (isTriageBucket(row.triage)) e.triage = row.triage
|
|
12
|
+
if (legacyHistory && row.comment != null) e.comment = row.comment
|
|
13
|
+
if (row.fix != null) e.fix = row.fix
|
|
14
|
+
if (row.flagged != null) e.flagged = row.flagged
|
|
15
|
+
return Object.keys(e).length > 0 ? e : null
|
|
16
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// Staging is shared storage, never process memory. Each part is bound to the
|
|
2
|
+
// authenticated session, destination and random upload ID. No bearer URL is
|
|
3
|
+
// exposed; all reads/writes still pass the managed role/origin/CSRF gates.
|
|
4
|
+
import { Buffer } from 'node:buffer'
|
|
5
|
+
import { createHash } from 'node:crypto'
|
|
6
|
+
import type { IncomingMessage } from 'node:http'
|
|
7
|
+
import type { BlobStore } from './blob-store.ts'
|
|
8
|
+
|
|
9
|
+
export const UPLOAD_CHUNK_BYTES = 3 * 1024 * 1024
|
|
10
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/u
|
|
11
|
+
export type UploadKind = 'reports' | 'bundles'
|
|
12
|
+
|
|
13
|
+
function partId(session: string, kind: UploadKind, id: string, index: number): string {
|
|
14
|
+
const hash = createHash('sha256').update(JSON.stringify([session, kind, id, index])).digest('hex').slice(0, 32)
|
|
15
|
+
return `${hash.slice(0, 8)}-${hash.slice(8, 12)}-${hash.slice(12, 16)}-${hash.slice(16, 20)}-${hash.slice(20)}`
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function validUploadPart(id: string, index: number, maxBytes: number): boolean {
|
|
19
|
+
return UUID.test(id) && Number.isSafeInteger(index) && index >= 0 && index < Math.ceil(maxBytes / UPLOAD_CHUNK_BYTES)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function putUploadPart(store: BlobStore, session: string, kind: UploadKind, id: string, index: number, bytes: Buffer): Promise<void> {
|
|
23
|
+
return store.put(partId(session, kind, id, index), bytes)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function readUpload(store: BlobStore, req: IncomingMessage, session: string, kind: UploadKind, maxBytes: number): Promise<Buffer> {
|
|
27
|
+
const id = req.headers['x-upload-id']
|
|
28
|
+
const count = Number(req.headers['x-upload-parts']), size = Number(req.headers['x-upload-size'])
|
|
29
|
+
if (typeof id !== 'string' || !UUID.test(id) || !Number.isSafeInteger(size) || size <= 0
|
|
30
|
+
|| !Number.isSafeInteger(count) || count !== Math.ceil(size / UPLOAD_CHUNK_BYTES)) throw new Error('bad-upload')
|
|
31
|
+
if (size > maxBytes) throw new Error('too-large')
|
|
32
|
+
const ids = Array.from({ length: count }, (_, index) => partId(session, kind, id, index))
|
|
33
|
+
const parts: Buffer[] = []
|
|
34
|
+
try {
|
|
35
|
+
for (const [index, key] of ids.entries()) {
|
|
36
|
+
const bytes = await store.get(key)
|
|
37
|
+
const expected = Math.min(UPLOAD_CHUNK_BYTES, size - index * UPLOAD_CHUNK_BYTES)
|
|
38
|
+
if (!bytes || bytes.length !== expected) throw new Error('bad-upload')
|
|
39
|
+
parts.push(bytes)
|
|
40
|
+
}
|
|
41
|
+
return Buffer.concat(parts, size)
|
|
42
|
+
} finally {
|
|
43
|
+
// These bytes can no longer be finalized. Failed/abandoned chunk uploads
|
|
44
|
+
// are also covered by the daily staging sweep.
|
|
45
|
+
for (const key of ids) await store.delete(key).catch(err => console.warn('managed: upload cleanup failed:', err))
|
|
46
|
+
}
|
|
47
|
+
}
|