@preventive/triage 1.0.0-alpha.26 → 1.0.0-alpha.27

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.
Files changed (35) hide show
  1. package/out/client-managed-import.js +18 -18
  2. package/out/client-managed.js +47 -47
  3. package/out/view.css +1 -1
  4. package/out/view.js +159 -159
  5. package/package.json +12 -2
  6. package/server-common/storage-crypto.ts +182 -0
  7. package/server-common/storage-log.ts +5 -1
  8. package/server-common/vercel-blob.ts +6 -3
  9. package/server-managed/avatar-store.ts +1 -40
  10. package/server-managed/blob-store.ts +3 -52
  11. package/server-managed/bundle-cache.ts +0 -28
  12. package/server-managed/bundle-store.ts +3 -7
  13. package/server-managed/cache-storage.ts +0 -25
  14. package/server-managed/cli.js +10 -2
  15. package/server-managed/config.ts +12 -1
  16. package/server-managed/db-methods.ts +42 -21
  17. package/server-managed/db-neon.ts +9 -4
  18. package/server-managed/db.ts +7 -1
  19. package/server-managed/http.ts +37 -39
  20. package/server-managed/index.ts +3 -2
  21. package/server-managed/object-storage-disk.ts +144 -0
  22. package/server-managed/object-storage-vercel.ts +60 -0
  23. package/server-managed/object-storage.ts +71 -0
  24. package/server-managed/repository-browser.ts +5 -1
  25. package/server-managed/solidity-entry-points.ts +69 -0
  26. package/server-managed/sql.ts +0 -5
  27. package/server-managed/storage-command.ts +16 -0
  28. package/server-managed/storage-db.ts +168 -0
  29. package/server-managed/storage-encryption.ts +211 -0
  30. package/server-managed/storage-maintenance.ts +182 -0
  31. package/server-managed/storage-payload.ts +60 -0
  32. package/server-managed/storage-stores.ts +81 -0
  33. package/server-managed/storage.ts +46 -24
  34. package/server-managed/uploads.ts +2 -2
  35. package/server-managed/blob-vercel.ts +0 -127
@@ -12,6 +12,8 @@ import { type ImportTriageStore, importTriageMethods } from './import-triage.ts'
12
12
  import type { ManagedSql } from './sql.ts'
13
13
  import { type WorkspaceShareStore, workspaceShareMethods } from './workspace-shares.ts'
14
14
  import { ManagedMutationError, type ManagementStore, managementMethods } from './management.ts'
15
+ import { type StorageKey } from '../server-common/storage-crypto.ts'
16
+ import { type StorageDb, type StoredTokens, decodeStorageTokens, encodeStorageTokens, storageMethods, storageState, validateStorageUpload } from './storage-db.ts'
15
17
 
16
18
  // A managed user identity (the subset of GitHub's `GET /user` we keep). Input
17
19
  // to the upsert; `githubUserId` is the provider lookup key, never exposed to
@@ -131,6 +133,7 @@ export interface ReportAccessSnapshot {
131
133
  // bundle links; `bundleIntegrity` is the report's declared primary bundle (kept
132
134
  // so a later bundle upload of that integrity re-links it).
133
135
  export interface ReportRecordInput {
136
+ dataKey?: string | null
134
137
  id: string
135
138
  filename: string
136
139
  contentType: string
@@ -228,7 +231,7 @@ export interface ManagedBundle {
228
231
 
229
232
  // What the upload handler supplies to record a bundle; the store stamps
230
233
  // uploaded_at. `uploadedByLogin` is the durable uploader-login snapshot.
231
- export type BundleInput = Omit<ManagedBundle, 'uploadedAt' | 'slug' | 'repoDirectory'> & { uploadedByLogin: string | null; repoDirectory?: string }
234
+ export type BundleInput = Omit<ManagedBundle, 'uploadedAt' | 'slug' | 'repoDirectory'> & { uploadedByLogin: string | null; repoDirectory?: string; dataKey?: string | null }
232
235
 
233
236
  // A bundle row for the "Manage bundles" list — adds the uploader login + repo
234
237
  // full name display joins (null when absent / since removed).
@@ -310,7 +313,7 @@ export interface UserTeam {
310
313
  }
311
314
 
312
315
  // Backend-agnostic store surface (SQLite + PostgreSQL implementations).
313
- export interface ManagedDb extends ActivityStore, CommentStore, GithubMetadataStore, ManagedIssueStore, WorkspaceShareStore, ImportTriageStore, ManagementStore {
316
+ export interface ManagedDb extends ActivityStore, CommentStore, GithubMetadataStore, ManagedIssueStore, WorkspaceShareStore, ImportTriageStore, ManagementStore, StorageDb {
314
317
  // Upsert the identity; returns the user's opaque id (stable across logins).
315
318
  // Initial-admin approval comes only from trusted login configuration. It
316
319
  // promotes a matching No access identity only while it is the sole user.
@@ -358,6 +361,8 @@ export interface ManagedDb extends ActivityStore, CommentStore, GithubMetadataSt
358
361
  listReports(userId?: string): Promise<AdminReport[]>
359
362
  listFindingCatalogReports(after: string, limit: number): Promise<Pick<ReportRecord, 'id' | 'byteSize'>[]>
360
363
  getReport(id: string): Promise<ReportRecord | null>
364
+ // Writer-locked reconciliation waits for an uncertain earlier insert.
365
+ resolveReportUpload(id: string): Promise<ReportRecord | null>
361
366
  getTeamReportAccessSnapshot(sessionId: string, now: number, teamId: string): Promise<TeamReportAccessSnapshot | null>
362
367
  getReportAccessSnapshot(sessionId: string, now: number, ids: readonly string[]): Promise<ReportAccessSnapshot | null>
363
368
  listReportFilenamesWithBundleHash(bundleId: string, sha256: string): Promise<string[]>
@@ -392,6 +397,7 @@ export interface ManagedDb extends ActivityStore, CommentStore, GithubMetadataSt
392
397
  // declared its integrity.
393
398
  insertBundle(bundle: BundleInput, now: number, sessionId?: string): Promise<void>
394
399
  getBundleByIntegrity(integrity: string): Promise<ManagedBundle | null>
400
+ resolveBundleUpload(integrity: string): Promise<ManagedBundle | null>
395
401
  getBundle(id: string): Promise<ManagedBundle | null>
396
402
  listBundles(userId?: string): Promise<AdminBundle[]>
397
403
  userCanReadBundle(userId: string, id: string): Promise<boolean>
@@ -499,11 +505,11 @@ function prepareStatements(db: ManagedSql) {
499
505
  updateRoleStmt: db.prepare(`UPDATE managed_user SET role = ?, updated_at = ? WHERE id = ?`),
500
506
  updateTokensStmt: db.prepare(
501
507
  `UPDATE managed_user
502
- SET gh_access_token = ?, gh_refresh_token = ?, gh_token_expires_at = ?, updated_at = ?
508
+ SET gh_access_token = ?, gh_refresh_token = ?, gh_token_expires_at = ?, gh_tokens_encrypted = ?, updated_at = ?
503
509
  WHERE id = ?`,
504
510
  ),
505
511
  selectTokensStmt: db.prepare(
506
- `SELECT gh_access_token AS access, gh_refresh_token AS refresh, gh_token_expires_at AS exp
512
+ `SELECT gh_access_token AS access, gh_refresh_token AS refresh, gh_token_expires_at AS exp, gh_tokens_encrypted AS encrypted
507
513
  FROM managed_user WHERE id = ?`,
508
514
  ),
509
515
  deleteSessionStmt: db.prepare(`DELETE FROM managed_session WHERE id = ?`),
@@ -551,9 +557,9 @@ function prepareStatements(db: ManagedSql) {
551
557
  ),
552
558
  insertReportStmt: db.prepare(
553
559
  `WITH candidate(id, slug) AS (VALUES (?, ?))
554
- INSERT INTO managed_report (id, slug, filename, content_type, byte_size, sha256, uploaded_by, uploaded_by_login, repo_id, repo_directory, repo_embedded, analyzer, visible, bundle_id, bundle_integrity, uploaded_at)
560
+ INSERT INTO managed_report (id, slug, filename, content_type, byte_size, sha256, uploaded_by, uploaded_by_login, repo_id, repo_directory, repo_embedded, analyzer, visible, bundle_id, bundle_integrity, uploaded_at, data_key, storage_encrypted)
555
561
  SELECT id, CASE WHEN EXISTS (SELECT 1 FROM managed_report WHERE slug = candidate.slug) THEN id ELSE slug END,
556
- ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? FROM candidate`,
562
+ ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? FROM candidate`,
557
563
  ),
558
564
  // LEFT JOINs so a report whose uploader / repo / bundle was removed (the FK
559
565
  // nulled) still lists, with null display fields.
@@ -680,9 +686,9 @@ function prepareStatements(db: ManagedSql) {
680
686
  WHERE finding_id IN (SELECT value FROM json_each(?)) ORDER BY id`),
681
687
  insertBundleStmt: db.prepare(
682
688
  `WITH candidate(id, slug) AS (VALUES (?, ?))
683
- INSERT INTO managed_bundle (id, slug, integrity, filename, kind, byte_size, uploaded_by, uploaded_by_login, repo_id, repo_directory, uploaded_at)
689
+ INSERT INTO managed_bundle (id, slug, integrity, filename, kind, byte_size, uploaded_by, uploaded_by_login, repo_id, repo_directory, uploaded_at, data_key, storage_encrypted)
684
690
  SELECT id, CASE WHEN EXISTS (SELECT 1 FROM managed_bundle WHERE slug = candidate.slug) THEN id ELSE slug END,
685
- ?, ?, ?, ?, ?, ?, ?, ?, ? FROM candidate`,
691
+ ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? FROM candidate`,
686
692
  ),
687
693
  selectBundleByIntegrityStmt: db.prepare(
688
694
  `SELECT id, slug, integrity, filename, kind, byte_size AS byteSize,
@@ -924,7 +930,7 @@ async function authorizeUpload(stmts: ReturnType<typeof prepareStatements>, sess
924
930
  }
925
931
 
926
932
  // The report slice of ManagedDb closes over its prepared statements.
927
- function reportMethods(stmts: ReturnType<typeof prepareStatements>) {
933
+ function reportMethods(stmts: ReturnType<typeof prepareStatements>, db: ManagedSql, key: StorageKey | null) {
928
934
  const { insertReportStmt, selectReportsStmt, selectReportStmt, deleteReportStmt, setReportRepoStmt, setReportVisibleStmt } = stmts
929
935
  async function getReport(id: string): Promise<ReportRecord | null> {
930
936
  const row = (await selectReportStmt.get(id)) as ReportRow | undefined
@@ -936,16 +942,18 @@ function reportMethods(stmts: ReturnType<typeof prepareStatements>) {
936
942
  return row ? getReport(row.id) : null
937
943
  }
938
944
  async function insertReport(report: ReportRecordInput, now: number): Promise<void> {
945
+ await validateStorageUpload(db, key, 'report', report.id, report.dataKey)
939
946
  await insertReportStmt.run(
940
947
  report.id, preferredSlug(report.id), report.filename, report.contentType, report.byteSize,
941
948
  report.sha256, report.uploadedBy, report.uploadedByLogin ?? null, report.repoId,
942
949
  report.repoDirectory ?? '', report.repoEmbedded ? 1 : 0, report.analyzer ?? null, report.visible == null ? 0 : report.visible ? 1 : 0, report.bundleId ?? null,
943
- report.bundleIntegrity ?? null, now,
950
+ report.bundleIntegrity ?? null, now, report.dataKey ?? null, report.dataKey ? 1 : 0,
944
951
  )
945
952
  }
946
953
  return {
947
954
  insertReport,
948
955
  getReport,
956
+ resolveReportUpload: getReport,
949
957
  getReportByHash,
950
958
  async insertOrReuseReport(report: ReportRecordInput, now: number, sessionId?: string): Promise<ReportRecord> {
951
959
  await authorizeUpload(stmts, sessionId, report)
@@ -1126,23 +1134,27 @@ function mapBundle(r: BundleRow): ManagedBundle {
1126
1134
  }
1127
1135
 
1128
1136
  // The bundle slice of ManagedDb. Closes over its prepared statements.
1129
- function bundleMethods(stmts: ReturnType<typeof prepareStatements>) {
1137
+ function bundleMethods(stmts: ReturnType<typeof prepareStatements>, db: ManagedSql, key: StorageKey | null) {
1130
1138
  const {
1131
1139
  insertBundleStmt, selectBundleByIntegrityStmt, selectBundleStmt,
1132
1140
  selectBundlesStmt, deleteBundleStmt, setBundleRepoStmt, linkReportsToBundleStmt, selectBundleReadableStmt, selectRepoReadableStmt, selectRepoPathReadableStmt,
1133
1141
  } = stmts
1142
+ async function getBundleByIntegrity(integrity: string): Promise<ManagedBundle | null> {
1143
+ const row = (await selectBundleByIntegrityStmt.get(integrity)) as BundleRow | undefined
1144
+ return row == null ? null : mapBundle(row)
1145
+ }
1134
1146
  return {
1135
1147
  async insertBundle(bundle: BundleInput, now: number, sessionId?: string): Promise<void> {
1136
1148
  await authorizeUpload(stmts, sessionId, bundle)
1149
+ await validateStorageUpload(db, key, 'bundle', bundle.id, bundle.dataKey)
1137
1150
  await insertBundleStmt.run(
1138
1151
  bundle.id, preferredSlug(bundle.id), bundle.integrity, bundle.filename, bundle.kind,
1139
- bundle.byteSize, bundle.uploadedBy, bundle.uploadedByLogin ?? null, bundle.repoId, bundle.repoId == null ? '' : bundle.repoDirectory ?? '', now,
1152
+ bundle.byteSize, bundle.uploadedBy, bundle.uploadedByLogin ?? null, bundle.repoId, bundle.repoId == null ? '' : bundle.repoDirectory ?? '', now, bundle.dataKey ?? null, bundle.dataKey ? 1 : 0,
1140
1153
  )
1141
1154
  },
1142
- async getBundleByIntegrity(integrity: string): Promise<ManagedBundle | null> {
1143
- const row = (await selectBundleByIntegrityStmt.get(integrity)) as BundleRow | undefined
1144
- return row == null ? null : mapBundle(row)
1145
- },
1155
+ getBundleByIntegrity,
1156
+ // The distinct method name selects a writer-locked reconciliation scope.
1157
+ resolveBundleUpload: getBundleByIntegrity,
1146
1158
  async getBundle(id: string): Promise<ManagedBundle | null> {
1147
1159
  const row = (await selectBundleStmt.get(id)) as BundleRow | undefined
1148
1160
  return row == null ? null : mapBundle(row)
@@ -1309,14 +1321,16 @@ function teamMethods( stmts: ReturnType<typeof prepareStatements>) {
1309
1321
  // `triageHistoryLimit`: events kept per finding in the triage trail; 0 (the
1310
1322
  // default) keeps everything. See ManagedConfig.
1311
1323
  export interface ManagedDbOptions {
1324
+ storageEncryptionKey?: StorageKey | null
1312
1325
  triageHistoryLimit?: number
1313
1326
  }
1314
1327
 
1315
1328
  export function createManagedMethods(db: ManagedSql, options: ManagedDbOptions = {}): ManagedDb {
1329
+ const key = options.storageEncryptionKey ?? null
1316
1330
  const comments = commentMethods(db)
1317
1331
  const activity = activityMethods(db)
1318
1332
  const stmts = prepareStatements(db)
1319
- const reports = reportMethods(stmts)
1333
+ const reports = reportMethods(stmts, db, key)
1320
1334
  const triage = triageMethods(stmts, options.triageHistoryLimit ?? 0)
1321
1335
  const {
1322
1336
  upsertUserStmt, selectUserIdStmt, promoteInitialAdminStmt, selectGithubIdStmt, insertSessionStmt, selectSessionStmt, selectUsersStmt,
@@ -1324,6 +1338,7 @@ export function createManagedMethods(db: ManagedSql, options: ManagedDbOptions =
1324
1338
  } = stmts
1325
1339
 
1326
1340
  const methods: Omit<ManagedDb, keyof ManagementStore> = {
1341
+ ...storageMethods(db, key),
1327
1342
  async upsertUser(user, now, initialAdminGithubId = null) {
1328
1343
  // New row → a fresh id; ON CONFLICT(github_user_id) keeps an existing
1329
1344
  // user's id (DO UPDATE leaves it untouched), so re-read to return it.
@@ -1364,12 +1379,18 @@ export function createManagedMethods(db: ManagedSql, options: ManagedDbOptions =
1364
1379
  return Number((await updateRoleStmt.run(role, Date.now(), id)).changes) > 0
1365
1380
  },
1366
1381
  async setUserTokens(id, tokens) {
1367
- await updateTokensStmt.run(tokens.accessToken, tokens.refreshToken, tokens.expiresAt, Date.now(), id)
1382
+ const state = await storageState(db, key)
1383
+ const value = state ? encodeStorageTokens(key!, id, tokens.accessToken, tokens.refreshToken) : { access: tokens.accessToken, refresh: tokens.refreshToken }
1384
+ await updateTokensStmt.run(value.access, value.refresh, tokens.expiresAt, state ? 1 : 0, Date.now(), id)
1368
1385
  },
1369
1386
  async getUserTokens(id) {
1370
- const row = (await selectTokensStmt.get(id)) as { access: string | null; refresh: string | null; exp: number | null } | undefined
1387
+ const state = await storageState(db, key)
1388
+ const row = (await selectTokensStmt.get(id)) as StoredTokens | undefined
1371
1389
  if (row == null || row.access == null) return null
1372
- return { accessToken: row.access, refreshToken: row.refresh, expiresAt: row.exp }
1390
+ if (row.encrypted && !state) throw new Error('Encrypted tokens without storage encryption state')
1391
+ if (!row.encrypted && state?.complete) throw new Error('Unexpected plaintext GitHub tokens')
1392
+ const value = row.encrypted ? decodeStorageTokens(key!, id, row) : row
1393
+ return { accessToken: value.access!, refreshToken: value.refresh, expiresAt: row.exp }
1373
1394
  },
1374
1395
  async getUserGithubId(id) {
1375
1396
  const row = (await selectGithubIdStmt.get(id)) as { githubId: number } | undefined
@@ -1384,7 +1405,7 @@ export function createManagedMethods(db: ManagedSql, options: ManagedDbOptions =
1384
1405
  ...reports,
1385
1406
  ...triage,
1386
1407
  ...importTriageMethods({ getReport: reports.getReport, ...triage, ...comments }),
1387
- ...bundleMethods(stmts),
1408
+ ...bundleMethods(stmts, db, key),
1388
1409
  ...teamMethods(stmts),
1389
1410
  async close() {
1390
1411
  await db.close()
@@ -2,11 +2,12 @@ import { AsyncLocalStorage } from 'node:async_hooks'
2
2
  import { WebSocket } from 'ws'
3
3
  import { type ManagedDb, type ManagedDbOptions, createManagedMethods } from './db-methods.ts'
4
4
  import { MANAGED_SCHEMA } from './db-schema.ts'
5
+ import { STORAGE_SCHEMA } from './storage-db.ts'
5
6
  import { GITHUB_METADATA_SCHEMA, GITHUB_STATE_REASON_COLUMN } from './github-metadata.ts'
6
7
  import { MANAGED_ISSUE_SCHEMA } from './managed-issues.ts'
7
8
  import { COMMENT_SCHEMA } from './comments.ts'
8
9
  import { ACTIVITY_SCHEMA } from './activity.ts'
9
- import { ManagedCommitError, type ManagedSqlDriver, scopeManagedMethods } from './sql.ts'
10
+ import { type ManagedSqlDriver, scopeManagedMethods } from './sql.ts'
10
11
  import { postgresSchema, postgresSql } from './sql-postgres.ts'
11
12
  import { managedTableRenames } from './db-table-names.ts'
12
13
  import { WORKSPACE_SHARE_SCHEMA } from './workspace-shares.ts'
@@ -57,7 +58,7 @@ async function initialize(db: PgConnection): Promise<void> {
57
58
  await db.query(postgresSchema(WORKSPACE_SHARE_SCHEMA))
58
59
  await db.query('INSERT INTO managed_schema_version VALUES (4)')
59
60
  }
60
- await db.query(postgresSchema(GITHUB_METADATA_SCHEMA + MANAGED_ISSUE_SCHEMA))
61
+ await db.query(postgresSchema(GITHUB_METADATA_SCHEMA + MANAGED_ISSUE_SCHEMA + STORAGE_SCHEMA))
61
62
  await db.query(`ALTER TABLE managed_github_metadata ADD COLUMN IF NOT EXISTS state_reason ${GITHUB_STATE_REASON_COLUMN}`)
62
63
  await db.query('ALTER TABLE managed_github_metadata ADD COLUMN IF NOT EXISTS attempted_at BIGINT')
63
64
  if ((await db.query('SELECT version FROM managed_schema_version WHERE version = 5')).rows.length === 0) {
@@ -83,6 +84,10 @@ async function initialize(db: PgConnection): Promise<void> {
83
84
  await db.query('CREATE INDEX IF NOT EXISTS managed_report_hash_idx ON managed_report(sha256, uploaded_at, id)')
84
85
  await db.query('INSERT INTO managed_schema_version VALUES (8)')
85
86
  }
87
+ for (const table of ['managed_report', 'managed_bundle']) {
88
+ await db.query(`ALTER TABLE ${table} ADD COLUMN IF NOT EXISTS data_key TEXT, ADD COLUMN IF NOT EXISTS storage_encrypted INTEGER NOT NULL DEFAULT 0`)
89
+ }
90
+ await db.query('ALTER TABLE managed_user ADD COLUMN IF NOT EXISTS gh_tokens_encrypted INTEGER NOT NULL DEFAULT 0')
86
91
  await db.query('COMMIT')
87
92
  } catch (err) {
88
93
  await db.query('ROLLBACK')
@@ -155,11 +160,11 @@ export async function openPostgresManagedDb(connect: PgConnect, options: Managed
155
160
  } catch (err) {
156
161
  await db.query('ROLLBACK').catch(() => {})
157
162
  await db.release().catch(() => {})
158
- if (write && commitAttempted) throw new ManagedCommitError(err)
163
+ if (write && commitAttempted) throw new Error('Managed commit outcome is uncertain', { cause: err })
159
164
  throw err
160
165
  }
161
166
  try { await db.release() }
162
- catch (err) { if (write) throw new ManagedCommitError(err); throw err }
167
+ catch (err) { if (write) throw new Error('Managed commit outcome is uncertain', { cause: err }); throw err }
163
168
  return result
164
169
  },
165
170
  close() { closed = true },
@@ -7,6 +7,7 @@ import { migrateReportLocations } from './report-migration.ts'
7
7
  import { initCommentMethods } from './comments.ts'
8
8
  import { initActivityMethods } from './activity.ts'
9
9
  import { MANAGED_SCHEMA } from './db-schema.ts'
10
+ import { STORAGE_SCHEMA } from './storage-db.ts'
10
11
  import { GITHUB_METADATA_SCHEMA, GITHUB_STATE_REASON_COLUMN } from './github-metadata.ts'
11
12
  import { MANAGED_ISSUE_SCHEMA } from './managed-issues.ts'
12
13
  import { managedTableRenames } from './db-table-names.ts'
@@ -65,7 +66,12 @@ export function openSqliteManagedDb(path: string, options: ManagedDbOptions = {}
65
66
  db.exec('PRAGMA synchronous = FULL;')
66
67
  db.exec('PRAGMA foreign_keys = ON;')
67
68
  migrateManagedTableNames(db)
68
- db.exec(MANAGED_SCHEMA + GITHUB_METADATA_SCHEMA + MANAGED_ISSUE_SCHEMA)
69
+ db.exec(MANAGED_SCHEMA + GITHUB_METADATA_SCHEMA + MANAGED_ISSUE_SCHEMA + STORAGE_SCHEMA)
70
+ for (const table of ['managed_report', 'managed_bundle']) {
71
+ ensureColumn(db, table, 'data_key', 'TEXT')
72
+ ensureColumn(db, table, 'storage_encrypted', 'INTEGER NOT NULL DEFAULT 0')
73
+ }
74
+ ensureColumn(db, 'managed_user', 'gh_tokens_encrypted', 'INTEGER NOT NULL DEFAULT 0')
69
75
  ensureColumn(db, 'managed_github_metadata', 'state_reason', GITHUB_STATE_REASON_COLUMN)
70
76
  ensureColumn(db, 'managed_github_metadata', 'attempted_at', 'INTEGER')
71
77
  // Existing public links must opt in too; never preserve an implicit grant.
@@ -59,7 +59,7 @@ import type { AvatarStore } from './avatar-store.ts'
59
59
  import type { BlobStore } from './blob-store.ts'
60
60
  import { bundleIntegrity, bundleKind, reportBundleHashes } from './bundle.ts'
61
61
  import type { ManagedConfig } from './config.ts'
62
- import type { ManagedDb, ManagedSession, ReportRecord, StoredUser, TriageEventRow } from './db.ts'
62
+ import type { ManagedBundle, ManagedDb, ManagedSession, ReportRecord, StoredUser, TriageEventRow } from './db.ts'
63
63
  import type { OriginGate } from '../server-common/origin.ts'
64
64
  import { isRole, roleAtLeast } from '../common/managed/roles.ts'
65
65
  import { VISIBILITY_PERMISSIONS, parseTeamUserPermissions } from '../common/managed/permissions.ts'
@@ -92,7 +92,6 @@ import { handlePublicWorkspace } from './public-workspace.ts'
92
92
  import { serveUserTeamFeed } from './team-feed.ts'
93
93
  import { hashToken, randomToken } from './crypto.ts'
94
94
  import { canDeleteComment, parseCommentBody } from '../common/managed/comments.ts'
95
- import { ManagedCommitError } from './sql.ts'
96
95
  import { ManagedMutationError, reportReferenceSnapshot } from './management.ts'
97
96
 
98
97
  const SESSION_PATH = '/api/auth/session'
@@ -914,18 +913,22 @@ async function handleUploadReport(req: IncomingMessage, res: ServerResponse, dep
914
913
  const id = randomUUID()
915
914
  const contentType = (firstHeader(req.headers['content-type']) ?? '').split(';', 1)[0]!.trim() || 'application/json'
916
915
  const { bundleId, integrity } = await resolveReportBundle(deps, s.user, bytes)
917
- await deps.reportStore.put(id, bytes)
916
+ const dataKey = await deps.reportStore.put(id, bytes)
918
917
  let report: ReportRecord
919
918
  try {
920
919
  report = await deps.db.insertOrReuseReport({
921
- id, filename, contentType, byteSize: bytes.length, sha256,
920
+ id, filename, contentType, byteSize: bytes.length, sha256, dataKey,
922
921
  uploadedBy: s.user.id, uploadedByLogin: s.user.login, repoId: matchedRepo?.repoId ?? null,
923
922
  repoDirectory: directory, repoEmbedded, analyzer, visible: false, bundleId, bundleIntegrity: integrity,
924
923
  }, Date.now(), s.session.id)
925
924
  } catch (err) {
926
- // Losing the commit acknowledgement cannot turn primary bytes into garbage.
927
- if (!(err instanceof ManagedCommitError)) await deps.reportStore.delete(id).catch(() => {})
928
- throw err
925
+ // A successful COMMIT can lose its acknowledgement. Never delete a file
926
+ // until a writer-locked read confirms that this candidate did not commit.
927
+ let committed: ReportRecord | null
928
+ try { committed = await deps.db.resolveReportUpload(id) }
929
+ catch (lookup) { throw new AggregateError([err, lookup], 'Report upload reconciliation failed', { cause: lookup }) }
930
+ if (committed) report = committed
931
+ else { await deps.reportStore.delete(id).catch(() => {}); throw err }
929
932
  }
930
933
  if (report.id !== id) await deps.reportStore.delete(id).catch(() => {})
931
934
  await sendUploadedReport(req, res, deps, cookie, report, report.id !== id)
@@ -1043,7 +1046,7 @@ async function handleRepositoryBrowser(res: ServerResponse, deps: ManagedHttpDep
1043
1046
  if (!current) return
1044
1047
  if (JSON.stringify(current.repo) !== JSON.stringify(access.repo)) { sendJson(res, 409, { error: 'repository-changed' }); return }
1045
1048
  sendJson(res, 200, refs ? result : {
1046
- ...result, ...(current.virtualEntries ? { entries: current.virtualEntries, limited: false, packageEntryPoints: [] } : {}), path, commit,
1049
+ ...result, ...(current.virtualEntries ? { entries: current.virtualEntries, limited: false, packageEntryPoints: [], solidityEntryPoints: [], soliditySuggestionsLimited: false } : {}), path, commit,
1047
1050
  })
1048
1051
  } catch (err) {
1049
1052
  if (err instanceof GithubApiError) {
@@ -1155,6 +1158,18 @@ async function handleListBundles(res: ServerResponse, deps: ManagedHttpDeps, coo
1155
1158
  await backfillBundleSummaries(bundles, deps.bundleCache)
1156
1159
  }
1157
1160
 
1161
+ type UploadedBundle = Pick<ManagedBundle, 'id' | 'integrity' | 'filename' | 'byteSize' | 'repoId' | 'repoDirectory'>
1162
+ async function sendUploadedBundle(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, bundle: UploadedBundle, deduped: boolean): Promise<void> {
1163
+ const s = await manageMutation(req, res, deps, cookie)
1164
+ if (!s) return
1165
+ if (deduped && !(await canAccessBundle(deps, s.user, bundle.id))) { sendJson(res, 409, { error: 'bundle-conflict' }); return }
1166
+ const { id, integrity, filename, byteSize, repoId, repoDirectory } = bundle
1167
+ // Re-uploading also repairs reports uploaded while the bundle was inaccessible.
1168
+ await deps.db.linkReportsToBundle(integrity, id, s.user.role === 'admin' ? undefined : s.user.id)
1169
+ prebuildBundle(deps, id)
1170
+ sendJson(res, deduped ? 200 : 201, { id, integrity, filename, repoId, repoDirectory, ...(deduped ? { deduped: true } : { byteSize }) })
1171
+ }
1172
+
1158
1173
  // POST /api/admin/bundles — upload a bundle. Mutation: same-origin + CSRF,
1159
1174
  // admin|manage. Raw bytes; X-Bundle-Filename names it, optional X-Repo-Id and
1160
1175
  // X-Repo-Directory assign a repository location. The bundle's identity is its content hash (sha512), UNIQUE — a
@@ -1191,48 +1206,31 @@ async function handleUploadBundle(req: IncomingMessage, res: ServerResponse, dep
1191
1206
  const integrity = bundleIntegrity(bytes)
1192
1207
  const filename = sanitizeFilename(firstHeader(req.headers['x-bundle-filename']), 'bundle')
1193
1208
  const existing = await deps.db.getBundleByIntegrity(integrity)
1194
- if (existing != null) {
1195
- s = await manageMutation(req, res, deps, cookie)
1196
- if (!s) return
1197
- if (!(await canAccessBundle(deps, s.user, existing.id))) { sendJson(res, 409, { error: 'bundle-conflict' }); return }
1198
- // Reports uploaded while this bundle was inaccessible retain only its
1199
- // integrity. An authorized re-upload repairs those pending links too.
1200
- await deps.db.linkReportsToBundle(integrity, existing.id, s.user.role === 'admin' ? undefined : s.user.id)
1201
- prebuildBundle(deps, existing.id)
1202
- sendJson(res, 200, { id: existing.id, integrity, filename: existing.filename, repoId: existing.repoId, repoDirectory: existing.repoDirectory, deduped: true })
1203
- return
1204
- }
1209
+ if (existing) { await sendUploadedBundle(req, res, deps, cookie, existing, true); return }
1205
1210
  const id = randomUUID()
1206
1211
  const kind = bundleKind(filename)
1207
- await deps.bundleStore.put(id, bytes, kind)
1212
+ const dataKey = await deps.bundleStore.put(id, bytes, kind)
1208
1213
  try {
1209
1214
  await deps.db.insertBundle({
1210
- id, integrity, filename, kind,
1215
+ id, integrity, filename, kind, dataKey,
1211
1216
  byteSize: bytes.length, uploadedBy: s.user.id, uploadedByLogin: s.user.login, repoId: repo.repoId, repoDirectory: directory,
1212
1217
  }, Date.now(), s.session.id)
1213
1218
  } catch (err) {
1214
- // A committed row may already point here even though insertion rejected.
1215
- if (!(err instanceof ManagedCommitError)) await deps.bundleStore.delete(id).catch(() => {})
1216
- if (err instanceof ManagedMutationError) throw err
1217
1219
  // A concurrent upload of identical bytes can insert this integrity (UNIQUE)
1218
1220
  // between our dedup check and this insert — treat that as a dedup, not a 500.
1219
1221
  // Any other failure rethrows.
1220
- const raced = await deps.db.getBundleByIntegrity(integrity)
1221
- if (raced != null) {
1222
- s = await manageMutation(req, res, deps, cookie)
1223
- if (!s) return
1224
- if (!(await canAccessBundle(deps, s.user, raced.id))) { sendJson(res, 409, { error: 'bundle-conflict' }); return }
1225
- await deps.db.linkReportsToBundle(integrity, raced.id, s.user.role === 'admin' ? undefined : s.user.id)
1226
- prebuildBundle(deps, raced.id)
1227
- sendJson(res, 200, { id: raced.id, integrity, filename: raced.filename, repoId: raced.repoId, repoDirectory: raced.repoDirectory, deduped: true }); return }
1228
- throw err
1222
+ let raced: ManagedBundle | null
1223
+ try { raced = await deps.db.resolveBundleUpload(integrity) }
1224
+ catch (lookup) { throw new AggregateError([err, lookup], 'Bundle upload reconciliation failed', { cause: lookup }) }
1225
+ // Preserve a committed candidate after a lost acknowledgement. If this
1226
+ // read fails too, leave the bytes for later reconciliation.
1227
+ if (raced?.id !== id) await deps.bundleStore.delete(id).catch(() => {})
1228
+ if (err instanceof ManagedMutationError) throw err
1229
+ if (!raced) throw err
1230
+ if (raced.id !== id) { await sendUploadedBundle(req, res, deps, cookie, raced, true); return }
1231
+ // Our insert committed: continue with the ordinary creation response.
1229
1232
  }
1230
- // Auto-link reports that declared this integrity before the bundle existed.
1231
- s = await manageMutation(req, res, deps, cookie)
1232
- if (!s) return
1233
- await deps.db.linkReportsToBundle(integrity, id, s.user.role === 'admin' ? undefined : s.user.id)
1234
- prebuildBundle(deps, id)
1235
- sendJson(res, 201, { id, integrity, filename, byteSize: bytes.length, repoId: repo.repoId, repoDirectory: directory })
1233
+ await sendUploadedBundle(req, res, deps, cookie, { id, integrity, filename, byteSize: bytes.length, repoId: repo.repoId, repoDirectory: directory }, false)
1236
1234
  }
1237
1235
 
1238
1236
  // GET /api/admin/bundles/<id> — download a stored bundle (admin|manage). Bytes
@@ -42,7 +42,7 @@ async function assembleManagedApp(config: ManagedConfig, options: ManagedAppOpti
42
42
  })
43
43
  const routeRequest = createManagedRequestHandler({
44
44
  ...options, config, db, avatarStore, reportStore, bundleStore, bundleCache, reportSourcesCache, originGate, serveStatic,
45
- ...('uploadStore' in storage ? { uploadStore: storage.uploadStore } : {}),
45
+ ...(storage.uploadStore ? { uploadStore: storage.uploadStore } : {}),
46
46
  isShuttingDown, track,
47
47
  })
48
48
 
@@ -55,7 +55,8 @@ async function assembleManagedApp(config: ManagedConfig, options: ManagedAppOpti
55
55
  let sessions = 0, uploads = 0
56
56
  cleanup = runReapers({
57
57
  sessions: async () => { sessions = await db.deleteExpiredSessions(Date.now()) },
58
- ...('reapUploads' in storage ? { uploads: async () => { uploads = await storage.reapUploads() } } : {}),
58
+ ...(storage.reapUploads ? { uploads: async () => { uploads = await storage.reapUploads!() } } : {}),
59
+ ...('reapStorage' in storage ? { storage: () => storage.reapStorage() } : {}),
59
60
  }).then(() => {
60
61
  nextCleanupAt = Date.now() + REAP_INTERVAL_MS
61
62
  return console.info(`managed-reaper: removed ${sessions} expired session(s), ${uploads} stale upload part(s) in ${Date.now() - startedAt}ms`)
@@ -0,0 +1,144 @@
1
+ import { randomUUID } from 'node:crypto'
2
+ import { lstat, mkdir, open, readdir, rename, rm, rmdir, stat } from 'node:fs/promises'
3
+ import { dirname, join } from 'node:path'
4
+ import { type ObjectPage, type RawObjectStorage, objectPath } from './object-storage.ts'
5
+
6
+ function missing(err: unknown): boolean { return (err as NodeJS.ErrnoException).code === 'ENOENT' }
7
+ function version(info: { dev: number; ino: number; size: number; mtimeMs: number; ctimeMs: number }): string {
8
+ return JSON.stringify([info.dev, info.ino, info.size, info.mtimeMs, info.ctimeMs])
9
+ }
10
+
11
+ async function syncDirectory(dir: string): Promise<void> {
12
+ try {
13
+ const directory = await open(dir, 'r')
14
+ try { await directory.sync() } finally { await directory.close() }
15
+ } catch (err) {
16
+ // Windows and some mounted filesystems cannot open/fsync directories.
17
+ // Preserve real I/O failures; file fsync remains mandatory on every write.
18
+ if (!['EINVAL', 'ENOTSUP', 'EOPNOTSUPP', 'ENOSYS', 'EISDIR', 'EBADF', 'EPERM'].includes((err as NodeJS.ErrnoException).code ?? '')) throw err
19
+ }
20
+ }
21
+
22
+ // Keyset pagination survives concurrent file creation/deletion. A final sweep
23
+ // catches objects inserted before the cursor during migration.
24
+ async function listFiles(dir: string, prefix: string, cursor: string | null, limit: number, signal?: AbortSignal): Promise<ObjectPage> {
25
+ const objects: ObjectPage['objects'] = []
26
+ async function visit(key: string): Promise<void> {
27
+ signal?.throwIfAborted()
28
+ let entries
29
+ try {
30
+ if (!(await lstat(join(dir, key))).isDirectory()) {
31
+ console.warn('managed-storage: skipping unsupported directory', JSON.stringify(key))
32
+ return
33
+ }
34
+ entries = await readdir(join(dir, key), { withFileTypes: true })
35
+ }
36
+ catch (err) { if (missing(err)) return; throw err }
37
+ const order = (entry: typeof entries[number]) => `${entry.name}${entry.isDirectory() ? '/' : ''}`
38
+ entries.sort((a, b) => order(a) < order(b) ? -1 : order(a) > order(b) ? 1 : 0)
39
+ for (const entry of entries) {
40
+ signal?.throwIfAborted()
41
+ const child = `${key}${entry.name}`
42
+ if (entry.isSymbolicLink() || (!entry.isDirectory() && !entry.isFile())) {
43
+ console.warn('managed-storage: skipping unsupported entry', JSON.stringify(child))
44
+ continue
45
+ }
46
+ try { objectPath(child) }
47
+ catch { console.warn('managed-storage: skipping invalid entry', JSON.stringify(child)); continue }
48
+ if (entry.isDirectory()) {
49
+ const folder = `${child}/`
50
+ if (cursor && folder < cursor && !cursor.startsWith(folder)) continue
51
+ await visit(folder)
52
+ if (objects.length > limit) return
53
+ }
54
+ else if (entry.isFile() && (!cursor || child > cursor)) {
55
+ try { objects.push({ key: objectPath(child), modifiedAt: (await stat(join(dir, child))).mtimeMs }) }
56
+ catch (err) { if (!missing(err)) throw err }
57
+ if (objects.length > limit) return
58
+ }
59
+ }
60
+ // Another migration worker can observe an unlink before its writer has
61
+ // synced the directory. Persist those observed removals before an empty
62
+ // inventory can mark the shared migration complete.
63
+ try { await syncDirectory(join(dir, key)) } catch (err) { if (!missing(err)) throw err }
64
+ }
65
+ if (!prefix.endsWith('/') || !Number.isInteger(limit) || limit < 1) throw new Error('Invalid storage page')
66
+ objectPath(`${prefix}validate`)
67
+ await visit(prefix)
68
+ return { objects: objects.slice(0, limit), cursor: objects.length > limit ? objects[limit - 1]!.key : null }
69
+ }
70
+
71
+ export function createDiskObjectStorage(dir: string): RawObjectStorage {
72
+ const path = (key: string) => join(dir, objectPath(key))
73
+ return {
74
+ async head(key, signal) {
75
+ signal?.throwIfAborted()
76
+ try {
77
+ const info = await stat(path(key))
78
+ return { size: info.size, version: version(info), modifiedAt: info.mtimeMs }
79
+ } catch (err) { if (missing(err)) return null; throw err }
80
+ },
81
+ async open(key, signal) {
82
+ signal?.throwIfAborted()
83
+ let file
84
+ try { file = await open(path(key), 'r') }
85
+ catch (err) { if (missing(err)) return null; throw err }
86
+ try {
87
+ const info = await file.stat()
88
+ return { size: info.size, version: version(info), modifiedAt: info.mtimeMs,
89
+ stream: file.createReadStream(signal ? { signal } : {}) }
90
+ } catch (err) { await file.close(); throw err }
91
+ },
92
+ async put(key, bytes, signal, expected) {
93
+ const target = path(key)
94
+ // Temp-file + rename makes replacement atomic to readers. Concurrent
95
+ // migrations use the same persisted data key and immutable plaintext.
96
+ const temp = `${target}.${randomUUID()}.tmp`
97
+ await mkdir(dirname(target), { recursive: true })
98
+ const file = await open(temp, 'wx', 0o600)
99
+ try {
100
+ await file.writeFile(bytes, signal ? { signal } : {})
101
+ await file.sync(); await file.close()
102
+ signal?.throwIfAborted()
103
+ if (expected !== undefined) {
104
+ try { if (version(await stat(target)) !== expected) return false }
105
+ catch (err) { if (missing(err)) return false; throw err }
106
+ }
107
+ await rename(temp, target)
108
+ await syncDirectory(dirname(target))
109
+ return true
110
+ } finally { await file.close(); await rm(temp, { force: true }) }
111
+ },
112
+ async delete(key, expected, signal) {
113
+ signal?.throwIfAborted()
114
+ try {
115
+ if (expected !== undefined && version(await stat(path(key))) !== expected) return false
116
+ await rm(path(key), { force: true })
117
+ // Persist plaintext removal before migration checkpoints completion.
118
+ // Otherwise a crash could restore the directory entry after SQL has
119
+ // durably disabled further plaintext inventory sweeps.
120
+ await syncDirectory(dirname(path(key)))
121
+ return true
122
+ } catch (err) { if (missing(err)) return true; throw err }
123
+ },
124
+ async sync(key, signal) { signal?.throwIfAborted(); await syncDirectory(dirname(path(key))) },
125
+ async prune(prefix, signal) {
126
+ if (!prefix.endsWith('/')) throw new Error('Invalid storage prefix')
127
+ objectPath(`${prefix}validate`)
128
+ async function visit(folder: string): Promise<void> {
129
+ signal?.throwIfAborted()
130
+ let entries
131
+ try {
132
+ if (!(await lstat(folder)).isDirectory()) return
133
+ entries = await readdir(folder, { withFileTypes: true })
134
+ }
135
+ catch (err) { if (missing(err)) return; throw err }
136
+ for (const entry of entries) if (entry.isDirectory()) await visit(join(folder, entry.name))
137
+ try { await rmdir(folder); await syncDirectory(dirname(folder)) }
138
+ catch (err) { if (!['ENOENT', 'ENOTEMPTY', 'EEXIST'].includes((err as NodeJS.ErrnoException).code ?? '')) throw err }
139
+ }
140
+ await visit(join(dir, prefix))
141
+ },
142
+ list: (prefix, cursor, limit, signal) => listFiles(dir, prefix, cursor, limit, signal),
143
+ }
144
+ }
@@ -0,0 +1,60 @@
1
+ import { Readable } from 'node:stream'
2
+ import { Buffer } from 'node:buffer'
3
+ import { type VercelBlobSdk, isNotFound, loadVercelBlobSdk } from '../server-common/vercel-blob.ts'
4
+ import { type RawObjectStorage, objectPath } from './object-storage.ts'
5
+
6
+ export async function openVercelObjectStorage(token: string, sdk?: VercelBlobSdk): Promise<RawObjectStorage> {
7
+ const blobs = sdk ?? await loadVercelBlobSdk()
8
+ const path = (key: string) => `.managed/${objectPath(key)}`
9
+ return {
10
+ async head(key, signal) {
11
+ try {
12
+ const meta = await blobs.head(path(key), { token, ...(signal ? { abortSignal: signal } : {}) })
13
+ if (!meta.etag) throw new Error('Missing object version')
14
+ return { size: meta.size, version: meta.etag, modifiedAt: new Date(meta.uploadedAt ?? Date.now()).getTime() }
15
+ } catch (err) { if (isNotFound(err, blobs)) return null; throw err }
16
+ },
17
+ async open(key, signal) {
18
+ try {
19
+ const result = await blobs.get(path(key), { token, access: 'private', useCache: false, ...(signal ? { abortSignal: signal } : {}) })
20
+ if (!result) return null
21
+ if (result.statusCode !== 200 || !result.stream || !result.blob.etag) {
22
+ await result.stream?.cancel()
23
+ throw new Error('Unexpected blob response or missing object version')
24
+ }
25
+ return { size: result.blob.size === 0 ? null : result.blob.size, version: result.blob.etag,
26
+ modifiedAt: new Date(result.blob.uploadedAt ?? Date.now()).getTime(),
27
+ stream: Readable.fromWeb(result.stream as Parameters<typeof Readable.fromWeb>[0]) }
28
+ } catch (err) { if (isNotFound(err, blobs)) return null; throw err }
29
+ },
30
+ async put(key, bytes, signal, expected, sizeHint) {
31
+ const multipart = (sizeHint ?? (Buffer.isBuffer(bytes) ? bytes.length : Infinity)) >= 5 * 1024 * 1024
32
+ try { await blobs.put(path(key), bytes, { token, access: 'private', addRandomSuffix: false, allowOverwrite: true,
33
+ ...(expected ? { ifMatch: expected } : {}), multipart, contentType: 'application/octet-stream', cacheControlMaxAge: 60, ...(signal ? { abortSignal: signal } : {}) })
34
+ return true
35
+ } catch (err) {
36
+ if (blobs.BlobPreconditionFailedError && err instanceof blobs.BlobPreconditionFailedError) return false
37
+ throw err
38
+ }
39
+ },
40
+ async delete(key, version, signal) {
41
+ signal?.throwIfAborted()
42
+ try { await blobs.del(path(key), { token, ...(version ? { ifMatch: version } : {}), ...(signal ? { abortSignal: signal } : {}) }); return true }
43
+ catch (err) {
44
+ if (isNotFound(err, blobs)) return true
45
+ if (blobs.BlobPreconditionFailedError && err instanceof blobs.BlobPreconditionFailedError) return false
46
+ throw err
47
+ }
48
+ },
49
+ async list(prefix, cursor, limit, signal) {
50
+ signal?.throwIfAborted()
51
+ if (!prefix.endsWith('/') || !Number.isInteger(limit) || limit < 1) throw new Error('Invalid storage page')
52
+ objectPath(`${prefix}validate`)
53
+ const page = await blobs.list({ prefix: `.managed/${prefix}`, token, limit, ...(cursor ? { cursor } : {}), ...(signal ? { abortSignal: signal } : {}) })
54
+ if (page.hasMore && (!page.cursor || page.cursor === cursor)) throw new Error('Invalid blob pagination')
55
+ return { objects: page.blobs.filter(blob => blob.pathname.startsWith(`.managed/${prefix}`)).map(blob => ({ key: blob.pathname.slice('.managed/'.length),
56
+ modifiedAt: new Date(blob.uploadedAt ?? Date.now()).getTime() })),
57
+ cursor: page.hasMore ? page.cursor! : null }
58
+ },
59
+ }
60
+ }