@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,29 @@
|
|
|
1
|
+
import type { BundleCache, BundleSummary } from './bundle-cache.ts'
|
|
2
|
+
import type { AdminBundle, UserTeamBundle } from './db.ts'
|
|
3
|
+
|
|
4
|
+
const SUMMARY_READ_CONCURRENCY = 8
|
|
5
|
+
|
|
6
|
+
// Read only existing summaries: cold/malformed bundles must never trigger
|
|
7
|
+
// parsing or wait for metadata builds before the catalog response is sent.
|
|
8
|
+
export async function bundleSummaries(bundles: readonly (AdminBundle | UserTeamBundle)[], cache?: BundleCache) {
|
|
9
|
+
const summaries = new Map<string, { summary: BundleSummary | null; summaryRetryAt: number | null }>()
|
|
10
|
+
if (cache) {
|
|
11
|
+
const unique = [...new Map(bundles.map(bundle => [bundle.integrity, bundle])).values()]
|
|
12
|
+
let next = 0
|
|
13
|
+
// Cold reads may each access remote storage. Bound those requests as well
|
|
14
|
+
// as the subsequent backfill, without dropping entries from large catalogs.
|
|
15
|
+
await Promise.all(Array.from({ length: Math.min(SUMMARY_READ_CONCURRENCY, unique.length) }, async () => {
|
|
16
|
+
while (next < unique.length) {
|
|
17
|
+
const bundle = unique[next++]!
|
|
18
|
+
summaries.set(bundle.integrity, await cache.summaryStatus(bundle).catch(() => ({ summary: null, summaryRetryAt: null })))
|
|
19
|
+
}
|
|
20
|
+
}))
|
|
21
|
+
}
|
|
22
|
+
return summaries
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Called after sending the response. Each cache permits one small batch at a
|
|
26
|
+
// time, deduplicated by content hash, and skips persisted retry-backoff markers.
|
|
27
|
+
export async function backfillBundleSummaries(bundles: readonly (AdminBundle | UserTeamBundle)[], cache?: BundleCache) {
|
|
28
|
+
await cache?.backfillSummaries([...new Map(bundles.map(bundle => [bundle.integrity, bundle])).values()])
|
|
29
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Sourcemaps are stored once as Brotli. The DB retains the uploaded filename,
|
|
2
|
+
// byte size and integrity so compression does not change report links/dedup.
|
|
3
|
+
import type { Buffer } from 'node:buffer'
|
|
4
|
+
import { type BlobStore, type OpenedBlob, createDiskBlobStore } from './blob-store.ts'
|
|
5
|
+
import { encodeBrotli } from './brotli.ts'
|
|
6
|
+
|
|
7
|
+
export interface BundleStore {
|
|
8
|
+
put(id: string, bytes: Buffer, kind: string | null): Promise<void>
|
|
9
|
+
get(id: string, kind: string | null): Promise<Buffer | null>
|
|
10
|
+
open(id: string, kind: string | null): Promise<OpenedBlob | null>
|
|
11
|
+
delete(id: string): Promise<void>
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function createBundleStore(archives: BlobStore, sourcemaps: BlobStore): BundleStore {
|
|
15
|
+
const storage = (kind: string | null) => kind === 'sourcemap' ? sourcemaps : archives
|
|
16
|
+
return {
|
|
17
|
+
async put(id, bytes, kind) {
|
|
18
|
+
await storage(kind).put(id, kind === 'sourcemap' ? await encodeBrotli(bytes) : bytes)
|
|
19
|
+
},
|
|
20
|
+
get: (id, kind) => storage(kind).get(id),
|
|
21
|
+
open: (id, kind) => storage(kind).open(id),
|
|
22
|
+
async delete(id) { await Promise.all([archives.delete(id), sourcemaps.delete(id)]) },
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function createDiskBundleStore(dir: string): BundleStore {
|
|
27
|
+
return createBundleStore(createDiskBlobStore(dir), createDiskBlobStore(dir, '.map.br'))
|
|
28
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { Buffer } from 'node:buffer'
|
|
2
|
+
import { setImmediate } from 'node:timers/promises'
|
|
3
|
+
import { BUNDLE_METADATA_VERSION } from '../common/bundle-metadata.js'
|
|
4
|
+
import { decodeUtf8 } from '../common/utf8.js'
|
|
5
|
+
import type { BundleCacheRecord, BundleCacheStorage, BundleSummary } from './bundle-cache.ts'
|
|
6
|
+
|
|
7
|
+
export const SUMMARY_FILENAME = `v${BUNDLE_METADATA_VERSION}-summary.json`
|
|
8
|
+
const RETRY_MS = 5 * 60_000
|
|
9
|
+
const BACKFILL_LIMIT = 4
|
|
10
|
+
type CachedSummary = BundleSummary | { retryAt: number }
|
|
11
|
+
|
|
12
|
+
async function readSummary(storage: BundleCacheStorage, id: string): Promise<CachedSummary> {
|
|
13
|
+
const cached = await storage.open(id, SUMMARY_FILENAME)
|
|
14
|
+
try {
|
|
15
|
+
const chunks: Buffer[] = []
|
|
16
|
+
let size = 0
|
|
17
|
+
for await (const chunk of cached.stream) {
|
|
18
|
+
size += chunk.length
|
|
19
|
+
if (size > 1024) throw new Error('Invalid bundle summary')
|
|
20
|
+
chunks.push(Buffer.from(chunk))
|
|
21
|
+
}
|
|
22
|
+
const summary = JSON.parse(decodeUtf8(Buffer.concat(chunks))) as CachedSummary
|
|
23
|
+
if (summary && 'retryAt' in summary && Number.isSafeInteger(summary.retryAt) && summary.retryAt > 0) return summary
|
|
24
|
+
const counts = summary as BundleSummary
|
|
25
|
+
if (!counts || ![counts.files, counts.codeFiles, counts.lines].every(value => Number.isSafeInteger(value) && value >= 0)
|
|
26
|
+
|| counts.codeFiles > counts.files) throw new Error('Invalid bundle summary')
|
|
27
|
+
return counts
|
|
28
|
+
} finally { cached.stream.destroy() }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function createBundleSummaryCache(storage: BundleCacheStorage, build: (record: BundleCacheRecord) => Promise<BundleSummary>, exists: (id: string) => Promise<unknown>) {
|
|
32
|
+
const entries = new Map<string, { id: string; value: CachedSummary }>()
|
|
33
|
+
const active = new Map<string, Promise<void>>()
|
|
34
|
+
let pending: Promise<void> | null = null
|
|
35
|
+
function remember(record: BundleCacheRecord, value: CachedSummary) {
|
|
36
|
+
if (entries.size >= 256) entries.delete(entries.keys().next().value!)
|
|
37
|
+
entries.set(record.integrity, { id: record.id, value })
|
|
38
|
+
}
|
|
39
|
+
async function read(record: BundleCacheRecord) {
|
|
40
|
+
const known = entries.get(record.integrity)
|
|
41
|
+
if (known) return known.value
|
|
42
|
+
if (!await storage.exists(record.id, SUMMARY_FILENAME)) return null
|
|
43
|
+
const value = await readSummary(storage, record.id)
|
|
44
|
+
remember(record, value)
|
|
45
|
+
return value
|
|
46
|
+
}
|
|
47
|
+
async function publish(record: BundleCacheRecord, value: CachedSummary) {
|
|
48
|
+
if (!await exists(record.id)) return
|
|
49
|
+
await storage.put(record.id, SUMMARY_FILENAME, Buffer.from(JSON.stringify(value)))
|
|
50
|
+
if (!await exists(record.id)) { await storage.delete(record.id); return }
|
|
51
|
+
remember(record, value)
|
|
52
|
+
}
|
|
53
|
+
async function buildOne(record: BundleCacheRecord) {
|
|
54
|
+
try { await publish(record, await build(record)) }
|
|
55
|
+
catch { await publish(record, { retryAt: Date.now() + RETRY_MS }).catch(() => {}) }
|
|
56
|
+
}
|
|
57
|
+
async function summaryStatus(record: BundleCacheRecord) {
|
|
58
|
+
const value = ['stasis', 'sourcemap'].includes(record.kind ?? '') ? await read(record) : null
|
|
59
|
+
return { summary: value && !('retryAt' in value) ? value : null,
|
|
60
|
+
summaryRetryAt: value && 'retryAt' in value ? value.retryAt : null }
|
|
61
|
+
}
|
|
62
|
+
async function backfill(records: readonly BundleCacheRecord[]) {
|
|
63
|
+
// Start after the catalog has been sent. Keep this work awaited by the
|
|
64
|
+
// request owner (including serverless), with no detached tasks or backlog.
|
|
65
|
+
await setImmediate()
|
|
66
|
+
let attempts = 0
|
|
67
|
+
for (const record of records) {
|
|
68
|
+
if (!['stasis', 'sourcemap'].includes(record.kind ?? '')) continue
|
|
69
|
+
const cached = await read(record).catch(() => null)
|
|
70
|
+
if (cached && (!('retryAt' in cached) || cached.retryAt > Date.now())) continue
|
|
71
|
+
if (!await exists(record.id)) continue
|
|
72
|
+
if (attempts++ >= BACKFILL_LIMIT) break
|
|
73
|
+
const job = buildOne(record)
|
|
74
|
+
active.set(record.id, job)
|
|
75
|
+
try { await job } finally { active.delete(record.id) }
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return {
|
|
79
|
+
remember,
|
|
80
|
+
summaryStatus,
|
|
81
|
+
async summary(record: BundleCacheRecord): Promise<BundleSummary | null> {
|
|
82
|
+
return (await summaryStatus(record)).summary
|
|
83
|
+
},
|
|
84
|
+
backfill(records: readonly BundleCacheRecord[]) {
|
|
85
|
+
if (pending) return Promise.resolve() // Another catalog already owns the bounded batch.
|
|
86
|
+
const job = backfill(records).finally(() => { if (pending === job) pending = null })
|
|
87
|
+
pending = job
|
|
88
|
+
return job
|
|
89
|
+
},
|
|
90
|
+
async forget(id: string) {
|
|
91
|
+
// Only this bundle can republish its cache during deletion. A stalled
|
|
92
|
+
// backfill elsewhere must not delay removing these bytes or responding.
|
|
93
|
+
await active.get(id)
|
|
94
|
+
for (const [hash, entry] of entries) if (entry.id === id) entries.delete(hash)
|
|
95
|
+
},
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// Bundle helpers for the managed server: the content-addressed identity used to
|
|
2
|
+
// dedupe stored bundles and to auto-link reports to them.
|
|
3
|
+
//
|
|
4
|
+
// A report's top-level `bundleHashes` lists the `sha512-<base64>` integrities
|
|
5
|
+
// the analyzer ran against (see ui/view/ingest.js); a stored bundle's identity
|
|
6
|
+
// is that same integrity computed from its bytes. Matching the two is how a
|
|
7
|
+
// report auto-links to its bundle.
|
|
8
|
+
import { createHash } from 'node:crypto'
|
|
9
|
+
import type { Buffer } from 'node:buffer'
|
|
10
|
+
|
|
11
|
+
// `sha512-<base64>` identity for a bundle's bytes. MUST stay byte-identical to
|
|
12
|
+
// the client's common/integrity.js (SHA-512 → standard base64 WITH padding) so
|
|
13
|
+
// a report's `bundleHashes` entries match a stored bundle's computed integrity.
|
|
14
|
+
// (The client uses `Uint8Array.toBase64()`, whose default is standard base64 +
|
|
15
|
+
// padding — the same bytes node:crypto's base64 digest emits.)
|
|
16
|
+
export function bundleIntegrity(bytes: Buffer): string {
|
|
17
|
+
return `sha512-${createHash('sha512').update(bytes).digest('base64')}`
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Classify an uploaded bundle by filename — mirrors ui/view/ingest.js
|
|
21
|
+
// `bundleKind`: sourcemap (.map) or stasis (stasis.code.br / .stasis.code.br).
|
|
22
|
+
// null for anything else (stored anyway; the kind is informational).
|
|
23
|
+
export function bundleKind(filename: string): 'sourcemap' | 'stasis' | null {
|
|
24
|
+
const lower = filename.toLowerCase()
|
|
25
|
+
if (lower.endsWith('.map')) return 'sourcemap'
|
|
26
|
+
if (lower === 'stasis.code.br' || lower.endsWith('.stasis.code.br')) return 'stasis'
|
|
27
|
+
return null
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Extract the bundle integrities a report declares (its top-level
|
|
31
|
+
// `bundleHashes`). Non-JSON reports (markdown / CSV) or a missing field → [].
|
|
32
|
+
export function reportBundleHashes(bytes: Buffer): string[] {
|
|
33
|
+
let data: unknown
|
|
34
|
+
try { data = JSON.parse(bytes.toString('utf8')) } catch { return [] }
|
|
35
|
+
if (data == null || typeof data !== 'object') return []
|
|
36
|
+
const hashes = (data as { bundleHashes?: unknown }).bundleHashes
|
|
37
|
+
if (!Array.isArray(hashes)) return []
|
|
38
|
+
return hashes.filter((x): x is string => typeof x === 'string' && x !== '')
|
|
39
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { Buffer } from 'node:buffer'
|
|
2
|
+
import { randomUUID } from 'node:crypto'
|
|
3
|
+
import { mkdir, open, rename, rm, stat, writeFile } from 'node:fs/promises'
|
|
4
|
+
import { dirname, join } from 'node:path'
|
|
5
|
+
import type { OpenedBlob } from './blob-store.ts'
|
|
6
|
+
|
|
7
|
+
// Keys are internal relative paths; delete removes a whole directory of variants.
|
|
8
|
+
export interface CacheStorage {
|
|
9
|
+
exists(key: string): Promise<boolean>
|
|
10
|
+
put(key: string, bytes: Buffer): Promise<void>
|
|
11
|
+
open(key: string): Promise<OpenedBlob>
|
|
12
|
+
delete(prefix: string): Promise<void>
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function validateCacheKey(key: string): string {
|
|
16
|
+
if (!key.split('/').every(part => /^[a-z0-9][a-z0-9.-]*$/iu.test(part))) throw new Error('Invalid cache key')
|
|
17
|
+
return key
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function createDiskCacheStorage(dir: string): CacheStorage {
|
|
21
|
+
const path = (key: string) => join(dir, validateCacheKey(key))
|
|
22
|
+
return {
|
|
23
|
+
async exists(key) {
|
|
24
|
+
try { await stat(path(key)); return true }
|
|
25
|
+
catch (err) { if ((err as NodeJS.ErrnoException).code === 'ENOENT') return false; throw err }
|
|
26
|
+
},
|
|
27
|
+
async put(key, bytes) {
|
|
28
|
+
const target = path(key), temp = `${target}.${randomUUID()}.tmp`
|
|
29
|
+
await mkdir(dirname(target), { recursive: true })
|
|
30
|
+
try { await writeFile(temp, bytes); await rename(temp, target) }
|
|
31
|
+
finally { await rm(temp, { force: true }) }
|
|
32
|
+
},
|
|
33
|
+
async open(key) {
|
|
34
|
+
const file = await open(path(key), 'r')
|
|
35
|
+
try { return { size: (await file.stat()).size, stream: file.createReadStream() } }
|
|
36
|
+
catch (err) { await file.close(); throw err }
|
|
37
|
+
},
|
|
38
|
+
async delete(prefix) { await rm(path(prefix), { recursive: true, force: true }) },
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Executable entry for the managed-server bin. See server-e2e/cli.js for the
|
|
3
|
+
// full rationale: Node refuses to strip TypeScript types from files under
|
|
4
|
+
// node_modules, so once this package is installed as a dependency
|
|
5
|
+
// server-managed/index.ts can't be executed directly. Register the in-thread
|
|
6
|
+
// type-stripping hook FIRST, then load the server via a dynamic import (a
|
|
7
|
+
// static import would be fetched before the hook is active) and start it.
|
|
8
|
+
//
|
|
9
|
+
// Imported here rather than run as the process entry, so index.ts's
|
|
10
|
+
// `import.meta.main` auto-start gate stays off and we call `start()` ourselves.
|
|
11
|
+
import '../strip-types-loader.js'
|
|
12
|
+
const { start } = await import('./index.ts')
|
|
13
|
+
await start()
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// Compose the existing HTTP apps on e2e's listener, including its WS/SSE
|
|
2
|
+
// transports and static UI. Managed handles its API, discovery and page HTML;
|
|
3
|
+
// assets and other requests fall through to the unchanged e2e HTTP handler.
|
|
4
|
+
import { resolve } from 'node:path'
|
|
5
|
+
import type { Server } from 'node:http'
|
|
6
|
+
import { withReap } from '../server-common/reap.ts'
|
|
7
|
+
import { loadConfig } from '../server-e2e/config.ts'
|
|
8
|
+
import { createE2eApp } from '../server-e2e/app.ts'
|
|
9
|
+
import { loadManagedConfig } from './config.ts'
|
|
10
|
+
import { LOGIN_PATH } from './github-oauth.ts'
|
|
11
|
+
import { createManagedApp, logManagedStartup } from './index.ts'
|
|
12
|
+
|
|
13
|
+
export async function init(mode: 'managed+e2e' | 'e2e+managed'): Promise<Server> {
|
|
14
|
+
const config = loadManagedConfig({ combined: true })
|
|
15
|
+
const e2eConfig = loadConfig()
|
|
16
|
+
if (!e2eConfig.neonUrl && !config.neonUrl && config.dbPath !== ':memory:' && resolve(config.dbPath) === resolve(e2eConfig.dbPath)) {
|
|
17
|
+
throw new Error('MANAGED_DB_PATH must differ from e2e DB_PATH in combined mode.')
|
|
18
|
+
}
|
|
19
|
+
const e2e = await createE2eApp(e2eConfig)
|
|
20
|
+
// Replace the assembled server's single
|
|
21
|
+
// request listener; adding a second would write two responses per request.
|
|
22
|
+
const handlers = e2e.httpServer.listeners('request')
|
|
23
|
+
const next = handlers[0]
|
|
24
|
+
if (handlers.length !== 1 || !next) throw new Error('Expected one e2e HTTP request handler')
|
|
25
|
+
const managed = await createManagedApp(config, {
|
|
26
|
+
next: e2e.handleRequest,
|
|
27
|
+
isShuttingDown: e2e.isShuttingDown,
|
|
28
|
+
serverInfo: {
|
|
29
|
+
mode, managed: { loginPath: LOGIN_PATH, cookieName: config.sessionCookieName },
|
|
30
|
+
...(e2eConfig.deepviewScanServer ? { deepviewScanServer: e2eConfig.deepviewScanServer } : {}),
|
|
31
|
+
},
|
|
32
|
+
}).catch(async err => {
|
|
33
|
+
await e2e.httpServer[Symbol.asyncDispose]()
|
|
34
|
+
throw err
|
|
35
|
+
})
|
|
36
|
+
e2e.onShutdown(managed.close)
|
|
37
|
+
e2e.httpServer.removeListener('request', next)
|
|
38
|
+
e2e.httpServer.on('request', withReap(managed.handleRequest,
|
|
39
|
+
{ e2e: e2e.reap, managed: managed.reap }, { isShuttingDown: e2e.isShuttingDown }))
|
|
40
|
+
e2e.httpServer.once('listening', () => {
|
|
41
|
+
const address = e2e.httpServer.address()
|
|
42
|
+
const port = typeof address === 'object' && address ? address.port : config.port
|
|
43
|
+
logManagedStartup(config, port, mode)
|
|
44
|
+
})
|
|
45
|
+
return e2e.httpServer
|
|
46
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto'
|
|
2
|
+
import type { DatabaseSync } from 'node:sqlite'
|
|
3
|
+
import { type ManagedComment, canDeleteComment } from '../common/managed/comments.ts'
|
|
4
|
+
import type { ManagedSql } from './sql.ts'
|
|
5
|
+
|
|
6
|
+
export interface CommentInput {
|
|
7
|
+
findingId: string
|
|
8
|
+
body: string
|
|
9
|
+
// Runtime imports may omit authorship, including future admin UI imports.
|
|
10
|
+
// Ordinary comment posts always supply the authenticated user as author.
|
|
11
|
+
authorId: string | null
|
|
12
|
+
authorLogin: string | null
|
|
13
|
+
// The user performing an import need not be the author of its comments.
|
|
14
|
+
// Omitted for ordinary posts, where the author is also the acting user.
|
|
15
|
+
actor?: { id: string; login: string }
|
|
16
|
+
// Omitted dates default to the action time; null preserves unknown dates.
|
|
17
|
+
createdAt?: number | null
|
|
18
|
+
updatedAt?: number | null
|
|
19
|
+
reportId?: string | null
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface CommentStore {
|
|
23
|
+
listComments(findingIds: readonly string[]): Promise<ManagedComment[]>
|
|
24
|
+
listCommentedFindingIds(findingIds: readonly string[]): Promise<string[]>
|
|
25
|
+
getComment(id: string): Promise<ManagedComment | null>
|
|
26
|
+
createComment(input: CommentInput, now: number): Promise<ManagedComment>
|
|
27
|
+
editComment(id: string, authorId: string, authorLogin: string, body: string, version: number, reportId: string, now: number): Promise<ManagedComment | 'conflict' | 'forbidden' | null>
|
|
28
|
+
deleteComment(id: string, actorId: string, actorLogin: string, version: number, reportId: string, now: number): Promise<'deleted' | 'conflict' | 'forbidden' | null>
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const COMMENT_COLUMNS = `id TEXT PRIMARY KEY, finding_id TEXT NOT NULL, body TEXT NOT NULL,
|
|
32
|
+
author_id TEXT REFERENCES managed_user(id) ON DELETE SET NULL,
|
|
33
|
+
author_login TEXT, created_at INTEGER, updated_at INTEGER,
|
|
34
|
+
version INTEGER NOT NULL DEFAULT 1`
|
|
35
|
+
|
|
36
|
+
export const COMMENT_SCHEMA = `
|
|
37
|
+
CREATE TABLE IF NOT EXISTS managed_finding_comment (${COMMENT_COLUMNS}) STRICT;
|
|
38
|
+
CREATE INDEX IF NOT EXISTS finding_comment_finding_idx ON managed_finding_comment(finding_id, created_at, id);
|
|
39
|
+
CREATE TABLE IF NOT EXISTS managed_finding_comment_event (
|
|
40
|
+
seq INTEGER PRIMARY KEY, comment_id TEXT NOT NULL, finding_id TEXT NOT NULL,
|
|
41
|
+
actor_id TEXT REFERENCES managed_user(id) ON DELETE SET NULL,
|
|
42
|
+
actor_login TEXT, action TEXT NOT NULL, at INTEGER NOT NULL,
|
|
43
|
+
report_id TEXT, report TEXT, repo TEXT
|
|
44
|
+
) STRICT;
|
|
45
|
+
CREATE INDEX IF NOT EXISTS finding_comment_event_finding_idx ON managed_finding_comment_event(finding_id, seq);
|
|
46
|
+
CREATE INDEX IF NOT EXISTS finding_comment_event_actor_at_idx ON managed_finding_comment_event(actor_id, at);
|
|
47
|
+
CREATE INDEX IF NOT EXISTS finding_comment_event_at_idx ON managed_finding_comment_event(at, seq);
|
|
48
|
+
`
|
|
49
|
+
|
|
50
|
+
export function initCommentMethods(db: DatabaseSync): void {
|
|
51
|
+
db.exec(COMMENT_SCHEMA)
|
|
52
|
+
migrateCommentDates(db)
|
|
53
|
+
// updated_by belongs to the whole triage row, not necessarily its comment.
|
|
54
|
+
// Preserve legacy text without guessing an author. Clearing the old column
|
|
55
|
+
// in the same transaction makes the migration safe to restart. Use a fresh
|
|
56
|
+
// ID so a later legacy-server write cannot collide with an earlier import.
|
|
57
|
+
db.exec('BEGIN')
|
|
58
|
+
try {
|
|
59
|
+
db.exec(`INSERT INTO managed_finding_comment (id, finding_id, body, created_at, updated_at)
|
|
60
|
+
SELECT 'legacy:' || lower(hex(randomblob(16))), finding_id, comment, updated_at, updated_at
|
|
61
|
+
FROM managed_finding_triage WHERE comment IS NOT NULL AND comment != '';
|
|
62
|
+
UPDATE managed_finding_triage SET comment = NULL WHERE comment IS NOT NULL;`)
|
|
63
|
+
db.exec('COMMIT')
|
|
64
|
+
} catch (err) {
|
|
65
|
+
db.exec('ROLLBACK')
|
|
66
|
+
throw err
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function commentMethods(db: ManagedSql): CommentStore {
|
|
71
|
+
const fields = `c.id, c.finding_id AS findingId, c.body, c.author_id AS authorId,
|
|
72
|
+
COALESCE(u.login, c.author_login) AS authorLogin,
|
|
73
|
+
c.created_at AS createdAt, c.updated_at AS updatedAt, c.version`
|
|
74
|
+
const select = db.prepare(`SELECT ${fields} FROM managed_finding_comment c
|
|
75
|
+
LEFT JOIN managed_user u ON u.id = c.author_id WHERE c.id = ?`)
|
|
76
|
+
const list = db.prepare(`SELECT ${fields} FROM managed_finding_comment c
|
|
77
|
+
LEFT JOIN managed_user u ON u.id = c.author_id
|
|
78
|
+
WHERE c.finding_id IN (SELECT value FROM json_each(?)) ORDER BY c.created_at NULLS FIRST, c.id`)
|
|
79
|
+
const insert = db.prepare(`INSERT INTO managed_finding_comment
|
|
80
|
+
(id, finding_id, body, author_id, author_login, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)`)
|
|
81
|
+
const update = db.prepare(`UPDATE managed_finding_comment SET body = ?, updated_at = ?, version = version + 1
|
|
82
|
+
WHERE id = ? AND author_id = ? AND version = ?`)
|
|
83
|
+
const remove = db.prepare('DELETE FROM managed_finding_comment WHERE id = ? AND (author_id = ? OR (author_id IS NULL AND ? IS NULL)) AND version = ?')
|
|
84
|
+
const userRole = db.prepare('SELECT role FROM managed_user WHERE id = ?')
|
|
85
|
+
// Deleted comments still have audit events which explicit annotation purges
|
|
86
|
+
// must find, including when no current triage/comment record remains.
|
|
87
|
+
const annotated = db.prepare(`SELECT finding_id AS id FROM managed_finding_comment WHERE finding_id IN (SELECT value FROM json_each(?))
|
|
88
|
+
UNION SELECT finding_id AS id FROM managed_finding_comment_event WHERE finding_id IN (SELECT value FROM json_each(?))`)
|
|
89
|
+
const event = db.prepare(`INSERT INTO managed_finding_comment_event
|
|
90
|
+
(comment_id, finding_id, actor_id, actor_login, action, at, report_id, report, repo)
|
|
91
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, (SELECT filename FROM managed_report WHERE id = ?),
|
|
92
|
+
(SELECT p.full_name FROM managed_report r JOIN managed_selected_repo p ON p.repo_id = r.repo_id WHERE r.id = ?))`)
|
|
93
|
+
const read = async (id: string) => (await select.get(id)) as ManagedComment | undefined
|
|
94
|
+
return {
|
|
95
|
+
async listComments(ids) { return ids.length > 0 ? (await list.all(JSON.stringify(ids))) as unknown as ManagedComment[] : [] },
|
|
96
|
+
async listCommentedFindingIds(ids) {
|
|
97
|
+
const json = JSON.stringify(ids)
|
|
98
|
+
return ((await annotated.all(json, json)) as { id: string }[]).map(row => row.id)
|
|
99
|
+
},
|
|
100
|
+
async getComment(id) { return (await read(id)) ?? null },
|
|
101
|
+
async createComment(input, now) {
|
|
102
|
+
const id = randomUUID()
|
|
103
|
+
const createdAt = input.createdAt === undefined ? now : input.createdAt
|
|
104
|
+
const updatedAt = input.updatedAt === undefined ? createdAt : input.updatedAt
|
|
105
|
+
await insert.run(id, input.findingId, input.body, input.authorId, input.authorLogin, createdAt, updatedAt)
|
|
106
|
+
await event.run(id, input.findingId, input.actor?.id ?? input.authorId, input.actor?.login ?? input.authorLogin, 'added a comment', now,
|
|
107
|
+
input.reportId ?? null, input.reportId ?? null, input.reportId ?? null)
|
|
108
|
+
return (await read(id))!
|
|
109
|
+
},
|
|
110
|
+
async editComment(id, authorId, authorLogin, body, version, reportId, now) {
|
|
111
|
+
// Identity/version checks and the event share the operation transaction.
|
|
112
|
+
const current = await read(id)
|
|
113
|
+
if (!current || current.authorId !== authorId || current.version !== version || current.body === body) {
|
|
114
|
+
if (!current) return null
|
|
115
|
+
if (current.authorId !== authorId) return 'forbidden'
|
|
116
|
+
if (current.version !== version) return 'conflict'
|
|
117
|
+
return current
|
|
118
|
+
}
|
|
119
|
+
await update.run(body, now, id, authorId, version)
|
|
120
|
+
await event.run(id, current.findingId, authorId, authorLogin, 'edited a comment', now, reportId, reportId, reportId)
|
|
121
|
+
return (await read(id))!
|
|
122
|
+
},
|
|
123
|
+
async deleteComment(id, actorId, actorLogin, version, reportId, now) {
|
|
124
|
+
const current = await read(id)
|
|
125
|
+
const role = (await userRole.get(actorId) as { role: string } | undefined)?.role ?? 'none'
|
|
126
|
+
const allowed = current != null && canDeleteComment(current, { id: actorId, role })
|
|
127
|
+
if (!current || !allowed || current.version !== version) {
|
|
128
|
+
if (!current) return null
|
|
129
|
+
return allowed ? 'conflict' : 'forbidden'
|
|
130
|
+
}
|
|
131
|
+
await remove.run(id, current.authorId, current.authorId, version)
|
|
132
|
+
await event.run(id, current.findingId, actorId, actorLogin, 'deleted a comment', now, reportId, reportId, reportId)
|
|
133
|
+
return 'deleted'
|
|
134
|
+
},
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function migrateCommentDates(db: DatabaseSync): void {
|
|
139
|
+
const columns = db.prepare('PRAGMA table_info(managed_finding_comment)').all() as { name: string; notnull: number }[]
|
|
140
|
+
if (!columns.some(column => ['created_at', 'updated_at'].includes(column.name) && column.notnull)) return
|
|
141
|
+
db.exec('BEGIN')
|
|
142
|
+
try {
|
|
143
|
+
db.exec(`CREATE TABLE managed_finding_comment_nullable_dates (${COMMENT_COLUMNS}) STRICT;
|
|
144
|
+
INSERT INTO managed_finding_comment_nullable_dates (id, finding_id, body, author_id, author_login, created_at, updated_at, version)
|
|
145
|
+
SELECT id, finding_id, body, author_id, author_login, created_at, updated_at, version FROM managed_finding_comment;
|
|
146
|
+
DROP TABLE managed_finding_comment;
|
|
147
|
+
ALTER TABLE managed_finding_comment_nullable_dates RENAME TO managed_finding_comment;
|
|
148
|
+
CREATE INDEX finding_comment_finding_idx ON managed_finding_comment(finding_id, created_at, id);`)
|
|
149
|
+
db.exec('COMMIT')
|
|
150
|
+
} catch (err) { db.exec('ROLLBACK'); throw err }
|
|
151
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// Managed-server boot config, parsed once at startup and
|
|
2
|
+
// failing fast on a missing / invalid required value — the same discipline as
|
|
3
|
+
// server-e2e/config.ts. Auth (GitHub identity + sessions) is required. An
|
|
4
|
+
// optional, SEPARATE GitHub App (id + private key + slug) enables PRIVATE repo
|
|
5
|
+
// listing via installation tokens — kept distinct from the login App so its
|
|
6
|
+
// Contents permission never lands on the login consent. The sync protocol is
|
|
7
|
+
// not parsed here yet.
|
|
8
|
+
import { env } from 'node:process'
|
|
9
|
+
import { databaseUrls } from '../server-common/database-config.ts'
|
|
10
|
+
|
|
11
|
+
const LOOPBACK_HOSTS = new Set(['127.0.0.1', '::1', 'localhost'])
|
|
12
|
+
|
|
13
|
+
export interface ManagedConfig {
|
|
14
|
+
port: number
|
|
15
|
+
host: string
|
|
16
|
+
dbPath: string
|
|
17
|
+
neonUrl?: string | null
|
|
18
|
+
blobToken?: string | null
|
|
19
|
+
serverless?: boolean
|
|
20
|
+
debug: boolean
|
|
21
|
+
allowShare: boolean
|
|
22
|
+
trustProxyEnv: string | undefined
|
|
23
|
+
// GitHub App user-authorization (identity) credentials — the App's client id
|
|
24
|
+
// + secret, used by the login flow.
|
|
25
|
+
githubClientId: string
|
|
26
|
+
githubClientSecret: string
|
|
27
|
+
// Preapproved GitHub identity promoted on login when it is the only user
|
|
28
|
+
// and its role is No access, including a login after registration.
|
|
29
|
+
initialAdminGithubId: number | null
|
|
30
|
+
// Absolute callback registered with GitHub, e.g.
|
|
31
|
+
// 'https://triage.example.com/api/oauth/github/callback'.
|
|
32
|
+
oauthCallbackUrl: string
|
|
33
|
+
// Whether cookies carry `Secure` (callback is https). Also gates the
|
|
34
|
+
// `__Host-` prefix, so loopback http dev can run with a plain cookie name.
|
|
35
|
+
cookieSecure: boolean
|
|
36
|
+
sessionCookieName: string
|
|
37
|
+
sessionTtlMs: number
|
|
38
|
+
// Optional SEPARATE GitHub App for PRIVATE repository access — distinct from
|
|
39
|
+
// the login App above. It carries the Contents: Read permission (so its
|
|
40
|
+
// consent never touches login) and is read via installation tokens: the id +
|
|
41
|
+
// PEM private key mint them; the slug builds the "Connect a repository"
|
|
42
|
+
// install URL. id/key absent → private repos are off (public-only listing);
|
|
43
|
+
// slug absent → no install link.
|
|
44
|
+
githubAppId: string | null
|
|
45
|
+
githubAppPrivateKey: string | null
|
|
46
|
+
githubAppSlug: string | null
|
|
47
|
+
// Max accepted size (bytes) for an uploaded report on the "Manage reports"
|
|
48
|
+
// page. Reports are findings dumps (JSON / markdown / CSV), small to a few MB.
|
|
49
|
+
maxReportBytes: number
|
|
50
|
+
// Max accepted size (bytes) for an uploaded bundle (sourcemap / stasis
|
|
51
|
+
// archive). Bundles run larger than reports, so a higher cap (default 100 MiB,
|
|
52
|
+
// matching the e2e objstore per-upload cap).
|
|
53
|
+
maxBundleBytes: number
|
|
54
|
+
// How many triage-trail events to keep per finding (managed_finding_triage_event):
|
|
55
|
+
// 0, the default, keeps everything — the trail is the record. An operator
|
|
56
|
+
// who would rather bound the store sets a positive count; older events of a
|
|
57
|
+
// finding are then trimmed as new ones land.
|
|
58
|
+
triageHistoryLimit: number
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function fail(msg: string): never {
|
|
62
|
+
throw new Error(msg)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function requireStr(name: string): string {
|
|
66
|
+
const v = env[name]
|
|
67
|
+
if (v == null || v === '') fail(`Missing required env ${name} (managed mode).`)
|
|
68
|
+
return v
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function intEnv(name: string, def: number, min: number, max: number): number {
|
|
72
|
+
const raw = env[name]
|
|
73
|
+
const n = raw == null ? def : Number(raw)
|
|
74
|
+
if (!Number.isSafeInteger(n) || n < min || n > max) {
|
|
75
|
+
fail(`Invalid ${name}: ${raw} — must be an integer in [${min}, ${max}].`)
|
|
76
|
+
}
|
|
77
|
+
return n
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function optionalGithubId(name: string): number | null {
|
|
81
|
+
const raw = env[name]
|
|
82
|
+
if (raw == null || raw === '') return null
|
|
83
|
+
const id = Number(raw)
|
|
84
|
+
if (!/^[1-9]\d*$/u.test(raw) || !Number.isSafeInteger(id)) {
|
|
85
|
+
fail(`Invalid ${name}: must be a single positive numeric GitHub user ID.`)
|
|
86
|
+
}
|
|
87
|
+
return id
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function urlOrFail(name: string, raw: string): URL {
|
|
91
|
+
try { return new URL(raw) } catch { fail(`${name} is not a valid URL: ${raw}`) }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function loadManagedConfig({ combined = false } = {}): ManagedConfig {
|
|
95
|
+
const serverless = env['VERCEL'] === '1'
|
|
96
|
+
const neonUrl = databaseUrls({ combined }).managed
|
|
97
|
+
const blobToken = env['BLOB_READ_WRITE_TOKEN'] || null
|
|
98
|
+
if (serverless && !neonUrl) fail('Vercel managed mode requires DATABASE_URL or MANAGED_DATABASE_URL.')
|
|
99
|
+
if (neonUrl && !blobToken) fail('Managed Neon mode requires BLOB_READ_WRITE_TOKEN.')
|
|
100
|
+
const host = env['HOST'] ?? '127.0.0.1'
|
|
101
|
+
const oauthCallbackUrl = requireStr('OAUTH_CALLBACK_URL')
|
|
102
|
+
const callback = urlOrFail('OAUTH_CALLBACK_URL', oauthCallbackUrl)
|
|
103
|
+
const cookieSecure = callback.protocol === 'https:'
|
|
104
|
+
// A `__Host-` cookie mandates `Secure`, so any non-loopback bind must be
|
|
105
|
+
// HTTPS — fail fast (mirrors server-e2e's boot check). Loopback dev over
|
|
106
|
+
// http is allowed with a non-prefixed cookie name.
|
|
107
|
+
if ((serverless || !LOOPBACK_HOSTS.has(host)) && !cookieSecure) {
|
|
108
|
+
fail(`Vercel or non-loopback HOST=${host} requires an https OAUTH_CALLBACK_URL — the session cookie needs Secure.`)
|
|
109
|
+
}
|
|
110
|
+
const sessionCookieName = env['SESSION_COOKIE_NAME'] ?? '__Host-dvsid'
|
|
111
|
+
if (sessionCookieName.startsWith('__Host-') && !cookieSecure) {
|
|
112
|
+
fail(`SESSION_COOKIE_NAME=${sessionCookieName} uses the __Host- prefix but the callback is not https. Use a non-prefixed name for loopback http dev.`)
|
|
113
|
+
}
|
|
114
|
+
return {
|
|
115
|
+
neonUrl, blobToken, serverless,
|
|
116
|
+
port: intEnv('PORT', 8765, 0, 65535),
|
|
117
|
+
host,
|
|
118
|
+
// DB_PATH belongs to e2e in a combined process. Keep the two stores apart.
|
|
119
|
+
dbPath: env['MANAGED_DB_PATH'] ?? (combined ? undefined : env['DB_PATH']) ?? 'server-managed/data/managed.db',
|
|
120
|
+
debug: env['DEBUG'] === '1' || env['DEBUG'] === 'true',
|
|
121
|
+
allowShare: env['DEEPVIEW_ALLOW_SHARE'] === '1',
|
|
122
|
+
trustProxyEnv: env['TRUST_PROXY'] ?? (serverless ? '1' : undefined),
|
|
123
|
+
githubClientId: requireStr('GITHUB_CLIENT_ID'),
|
|
124
|
+
githubClientSecret: requireStr('GITHUB_CLIENT_SECRET'),
|
|
125
|
+
initialAdminGithubId: optionalGithubId('MANAGED_INITIAL_ADMIN_GITHUB_ID'),
|
|
126
|
+
oauthCallbackUrl,
|
|
127
|
+
cookieSecure,
|
|
128
|
+
sessionCookieName,
|
|
129
|
+
sessionTtlMs: intEnv('SESSION_TTL_MS', 1_209_600_000, 60_000, 7_776_000_000),
|
|
130
|
+
githubAppId: env['GITHUB_APP_ID'] ?? null,
|
|
131
|
+
githubAppPrivateKey: normalizePem(env['GITHUB_APP_PRIVATE_KEY']),
|
|
132
|
+
githubAppSlug: env['GITHUB_APP_SLUG'] ?? null,
|
|
133
|
+
maxReportBytes: intEnv('MAX_REPORT_BYTES', 10_485_760, 1, 104_857_600),
|
|
134
|
+
maxBundleBytes: intEnv('MAX_BUNDLE_BYTES', 104_857_600, 1, 1_073_741_824),
|
|
135
|
+
triageHistoryLimit: intEnv('TRIAGE_HISTORY_LIMIT', 0, 0, 1_000_000_000),
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// PEM private keys are awkward in env vars; accept a literal multi-line value or
|
|
140
|
+
// one with escaped newlines (`\n`). Empty/absent → null (private repos off).
|
|
141
|
+
function normalizePem(raw: string | undefined): string | null {
|
|
142
|
+
if (raw == null || raw === '') return null
|
|
143
|
+
return raw.includes('\\n') ? raw.replaceAll('\\n', '\n') : raw
|
|
144
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { ManagedDb, StoredUser } from './db.ts'
|
|
2
|
+
|
|
3
|
+
export type RepoScope = { repoId: number; path: string | null }
|
|
4
|
+
type Content = { repoId: number | null; repoDirectory?: string }
|
|
5
|
+
|
|
6
|
+
// Read grants for each request: neither the manager role nor a cached page
|
|
7
|
+
// confers repository access. Bundles have repository scope; reports also have
|
|
8
|
+
// a directory scope. Unassigned content is only available to administrators.
|
|
9
|
+
export async function contentAccess(db: ManagedDb, user: StoredUser) {
|
|
10
|
+
const scopes = user.role === 'admin' ? null : await db.listRepoScopesForUser(user.id)
|
|
11
|
+
const bundle = (item: Content) => scopes == null || scopes.some(scope => scope.repoId === item.repoId)
|
|
12
|
+
const report = (item: Content) => scopes == null || scopes.some(scope => scope.repoId === item.repoId
|
|
13
|
+
&& (!scope.path || item.repoDirectory === scope.path || item.repoDirectory?.startsWith(scope.path + '/')))
|
|
14
|
+
return { scopes, bundle, report }
|
|
15
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// Token helpers for the managed auth server. The raw session token lives ONLY
|
|
2
|
+
// in the cookie; the DB row id is its SHA-256 (see db.ts / session.ts), so a
|
|
3
|
+
// DB read can't reconstruct a live cookie. No HMAC pepper is needed — the
|
|
4
|
+
// tokens are 256-bit random, not guessable or reversible from their hash.
|
|
5
|
+
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'
|
|
6
|
+
|
|
7
|
+
// 256-bit URL-safe random token: the session cookie value and the OAuth CSRF
|
|
8
|
+
// `state` nonce.
|
|
9
|
+
export function randomToken(): string {
|
|
10
|
+
return randomBytes(32).toString('base64url')
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// At-rest id for a session cookie token (stored as the managed_session PK).
|
|
14
|
+
export function hashToken(token: string): string {
|
|
15
|
+
return createHash('sha256').update(token, 'utf8').digest('base64url')
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Constant-time equality for CSRF / OAuth-state checks. A length mismatch
|
|
19
|
+
// short-circuits (the length of these fixed-width tokens isn't a secret).
|
|
20
|
+
export function safeEqual(a: string, b: string): boolean {
|
|
21
|
+
const ab = Buffer.from(a, 'utf8')
|
|
22
|
+
const bb = Buffer.from(b, 'utf8')
|
|
23
|
+
if (ab.length !== bb.length) return false
|
|
24
|
+
return timingSafeEqual(ab, bb)
|
|
25
|
+
}
|