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

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 (37) hide show
  1. package/common/server-info.ts +1 -0
  2. package/out/client-managed-import.js +41 -41
  3. package/out/client-managed.js +79 -63
  4. package/out/graph.js +6 -6
  5. package/out/prism.js +2 -2
  6. package/out/view.css +1 -1
  7. package/out/view.js +187 -186
  8. package/package.json +4 -1
  9. package/report/index.js +7 -1
  10. package/report/src/finding-id.js +1 -1
  11. package/report/src/finding.js +6 -4
  12. package/report/src/labels.js +3 -1
  13. package/report/src/parse-codex.js +3 -1
  14. package/report/src/parse-deepsec.js +3 -1
  15. package/report/src/parse-deepview-md.js +2 -1
  16. package/report/src/parse-md.js +3 -2
  17. package/report/src/parse-piolium-rows.js +7 -12
  18. package/report/src/parse-piolium-tokens.js +12 -2
  19. package/report/src/parse-piolium.js +7 -8
  20. package/report/src/severity.js +18 -0
  21. package/server-common/vercel-blob.ts +7 -18
  22. package/server-e2e/config.ts +4 -0
  23. package/server-e2e/objstore/blob-vercel.ts +4 -4
  24. package/server-managed/blob-vercel.ts +10 -8
  25. package/server-managed/config.ts +2 -1
  26. package/server-managed/db-methods.ts +41 -14
  27. package/server-managed/db-neon.ts +11 -4
  28. package/server-managed/http.ts +112 -90
  29. package/server-managed/import-triage.ts +4 -1
  30. package/server-managed/index.ts +30 -8
  31. package/server-managed/management.ts +86 -0
  32. package/server-managed/package-entry-points.ts +51 -0
  33. package/server-managed/public-workspace.ts +1 -11
  34. package/server-managed/report-query.ts +6 -0
  35. package/server-managed/repository-browser.ts +6 -1
  36. package/server-managed/sql.ts +5 -0
  37. package/server-managed/uploads.ts +28 -10
@@ -1,6 +1,6 @@
1
1
  import { createHash, randomUUID } from 'node:crypto'
2
2
  import { teamCatalogRevision } from './team-catalog.ts'
3
- import type { Role } from '../common/managed/roles.ts'
3
+ import { type Role, roleAtLeast } from '../common/managed/roles.ts'
4
4
  import type { TeamUserPermissions } from '../common/managed/permissions.ts'
5
5
  import type { TriageEntryPatch } from '../common/managed/triage.ts'
6
6
  import { preferredSlug } from './slugs.ts'
@@ -11,6 +11,7 @@ import { type ManagedIssueStore, managedIssueMethods } from './managed-issues.ts
11
11
  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
+ import { ManagedMutationError, type ManagementStore, managementMethods } from './management.ts'
14
15
 
15
16
  // A managed user identity (the subset of GitHub's `GET /user` we keep). Input
16
17
  // to the upsert; `githubUserId` is the provider lookup key, never exposed to
@@ -203,7 +204,9 @@ export interface TriageEventRow {
203
204
  comment: string | null
204
205
  fix: string | null
205
206
  flagged: boolean | null
207
+ actorId: string | null
206
208
  actorLogin: string | null
209
+ actorName: string | null
207
210
  at: number
208
211
  }
209
212
 
@@ -307,7 +310,7 @@ export interface UserTeam {
307
310
  }
308
311
 
309
312
  // Backend-agnostic store surface (SQLite + PostgreSQL implementations).
310
- export interface ManagedDb extends ActivityStore, CommentStore, GithubMetadataStore, ManagedIssueStore, WorkspaceShareStore, ImportTriageStore {
313
+ export interface ManagedDb extends ActivityStore, CommentStore, GithubMetadataStore, ManagedIssueStore, WorkspaceShareStore, ImportTriageStore, ManagementStore {
311
314
  // Upsert the identity; returns the user's opaque id (stable across logins).
312
315
  // Initial-admin approval comes only from trusted login configuration. It
313
316
  // promotes a matching No access identity only while it is the sole user.
@@ -349,9 +352,11 @@ export interface ManagedDb extends ActivityStore, CommentStore, GithubMetadataSt
349
352
  insertReport(report: ReportRecordInput, now: number): Promise<void>
350
353
  // Content reuse is atomic with insertion, including across server instances.
351
354
  // Existing copies are kept; the oldest matching row is the stable identity.
352
- insertOrReuseReport(report: ReportRecordInput, now: number): Promise<ReportRecord>
355
+ // HTTP uploads pass sessionId to recheck role/destination in that transaction.
356
+ insertOrReuseReport(report: ReportRecordInput, now: number, sessionId?: string): Promise<ReportRecord>
353
357
  getReportByHash(sha256: string, analyzer: string | null): Promise<ReportRecord | null>
354
358
  listReports(userId?: string): Promise<AdminReport[]>
359
+ listFindingCatalogReports(after: string, limit: number): Promise<Pick<ReportRecord, 'id' | 'byteSize'>[]>
355
360
  getReport(id: string): Promise<ReportRecord | null>
356
361
  getTeamReportAccessSnapshot(sessionId: string, now: number, teamId: string): Promise<TeamReportAccessSnapshot | null>
357
362
  getReportAccessSnapshot(sessionId: string, now: number, ids: readonly string[]): Promise<ReportAccessSnapshot | null>
@@ -385,7 +390,7 @@ export interface ManagedDb extends ActivityStore, CommentStore, GithubMetadataSt
385
390
  // (referencing reports' bundle_id null out via the FK). linkReportsToBundle
386
391
  // attaches a freshly-stored bundle to the (still-unlinked) reports that
387
392
  // declared its integrity.
388
- insertBundle(bundle: BundleInput, now: number): Promise<void>
393
+ insertBundle(bundle: BundleInput, now: number, sessionId?: string): Promise<void>
389
394
  getBundleByIntegrity(integrity: string): Promise<ManagedBundle | null>
390
395
  getBundle(id: string): Promise<ManagedBundle | null>
391
396
  listBundles(userId?: string): Promise<AdminBundle[]>
@@ -569,6 +574,7 @@ function prepareStatements(db: ManagedSql) {
569
574
  WHERE tr.repo_id = r.repo_id AND tu.user_id = ? AND ${REPORT_IN_TEAM_PATH_SQL}))
570
575
  ORDER BY r.uploaded_at DESC, r.filename ASC`,
571
576
  ),
577
+ findingCatalogReportsStmt: db.prepare(`SELECT id, byte_size AS byteSize FROM managed_report WHERE id > ? ORDER BY id LIMIT ?`),
572
578
  selectReportStmt: db.prepare(
573
579
  `SELECT id, slug, filename, content_type AS contentType, byte_size AS byteSize,
574
580
  sha256, uploaded_by AS uploadedBy, uploaded_at AS uploadedAt,
@@ -644,7 +650,7 @@ function prepareStatements(db: ManagedSql) {
644
650
  selectTriageHistoryStmt: db.prepare(
645
651
  `SELECT e.seq AS seq, e.finding_id AS findingId, e.batch_id AS batchId, e.color AS color, e.triage AS triage,
646
652
  e.comment AS comment, e.fix AS fix, e.flagged AS flagged,
647
- COALESCE(u.login, e.actor_login) AS actorLogin, e.at AS at
653
+ u.id AS actorId, COALESCE(u.login, e.actor_login) AS actorLogin, u.name AS actorName, e.at AS at
648
654
  FROM managed_finding_triage_event e
649
655
  LEFT JOIN managed_user u ON u.id = e.actor_id
650
656
  WHERE e.finding_id = ?
@@ -904,8 +910,20 @@ type ReportRow = {
904
910
  bundleId: string | null
905
911
  }
906
912
 
907
- // The report slice of ManagedDb, split out (like selectedRepoMethods) to keep
908
- // openSqliteManagedDb small. Closes over its prepared statements.
913
+ // Byte storage may outlast a logout or grant change. HTTP uploads reauthorize
914
+ // inside the metadata writer transaction after their bytes have been stored.
915
+ async function authorizeUpload(stmts: ReturnType<typeof prepareStatements>, sessionId: string | undefined,
916
+ item: { uploadedBy: string | null; repoId: number | null; repoDirectory?: string }) {
917
+ if (sessionId === undefined) return // Trusted imports and fixtures use the raw store API.
918
+ const user = await stmts.selectSessionStmt.get(sessionId, Date.now()) as SessionRow | undefined
919
+ if (!user) throw new ManagedMutationError(401, 'unauthenticated')
920
+ if (user.uid !== item.uploadedBy || !roleAtLeast(user.role, 'manage')) throw new ManagedMutationError(403, 'forbidden')
921
+ if (item.repoId === null) return
922
+ if (!(await stmts.selectReposStmt.all() as RepoRow[]).some(repo => repo.repoId === item.repoId)) throw new ManagedMutationError(400, 'repo-not-selected')
923
+ if (user.role !== 'admin' && !await stmts.selectRepoPathReadableStmt.get(item.repoDirectory ?? '', item.repoId, user.uid)) throw new ManagedMutationError(403, 'repo-forbidden')
924
+ }
925
+
926
+ // The report slice of ManagedDb closes over its prepared statements.
909
927
  function reportMethods(stmts: ReturnType<typeof prepareStatements>) {
910
928
  const { insertReportStmt, selectReportsStmt, selectReportStmt, deleteReportStmt, setReportRepoStmt, setReportVisibleStmt } = stmts
911
929
  async function getReport(id: string): Promise<ReportRecord | null> {
@@ -929,7 +947,8 @@ function reportMethods(stmts: ReturnType<typeof prepareStatements>) {
929
947
  insertReport,
930
948
  getReport,
931
949
  getReportByHash,
932
- async insertOrReuseReport(report: ReportRecordInput, now: number): Promise<ReportRecord> {
950
+ async insertOrReuseReport(report: ReportRecordInput, now: number, sessionId?: string): Promise<ReportRecord> {
951
+ await authorizeUpload(stmts, sessionId, report)
933
952
  // The driver holds its writer lock for this whole operation. Checking
934
953
  // again here closes the race between the HTTP lookup and blob upload.
935
954
  const existing = await getReportByHash(report.sha256, report.analyzer ?? null)
@@ -948,6 +967,9 @@ function reportMethods(stmts: ReturnType<typeof prepareStatements>) {
948
967
  uploadedAt: r.uploadedAt,
949
968
  }))
950
969
  },
970
+ async listFindingCatalogReports(after: string, limit: number): Promise<Pick<ReportRecord, 'id' | 'byteSize'>[]> {
971
+ return await stmts.findingCatalogReportsStmt.all(after, limit) as Pick<ReportRecord, 'id' | 'byteSize'>[]
972
+ },
951
973
  async getTeamReportAccessSnapshot(sessionId: string, now: number, teamId: string): Promise<TeamReportAccessSnapshot | null> {
952
974
  const session = await stmts.selectSessionStmt.get(sessionId, now) as SessionRow | undefined
953
975
  if (!session) return null
@@ -1004,7 +1026,9 @@ type TriageDbRow = {
1004
1026
  updatedByLogin: string | null; updatedAt: number
1005
1027
  }
1006
1028
  type TriageStateDbRow = { color: string | null; triage: string | null; comment: string | null; fix: string | null; flagged: number | null }
1007
- type TriageEventDbRow = TriageStateDbRow & { seq: number; findingId: string; batchId: string; actorLogin: string | null; at: number }
1029
+ type TriageEventDbRow = TriageStateDbRow & {
1030
+ seq: number; findingId: string; batchId: string; actorId: string | null; actorLogin: string | null; actorName: string | null; at: number
1031
+ }
1008
1032
 
1009
1033
  // The per-finding triage slice of ManagedDb. Closes over its prepared
1010
1034
  // statements (and the handle, for the batch write's transaction). A
@@ -1059,7 +1083,7 @@ function triageMethods( stmts: ReturnType<typeof prepareStatements>, historyLimi
1059
1083
  seq: r.seq, findingId: r.findingId, batchId: r.batchId,
1060
1084
  color: r.color, triage: r.triage, comment: r.comment, fix: r.fix,
1061
1085
  flagged: r.flagged == null ? null : r.flagged === 1,
1062
- actorLogin: r.actorLogin, at: r.at,
1086
+ actorId: r.actorId, actorLogin: r.actorLogin, actorName: r.actorName, at: r.at,
1063
1087
  }))
1064
1088
  },
1065
1089
  async listTriage(findingIds: readonly string[]): Promise<TriageRow[]> {
@@ -1108,7 +1132,8 @@ function bundleMethods(stmts: ReturnType<typeof prepareStatements>) {
1108
1132
  selectBundlesStmt, deleteBundleStmt, setBundleRepoStmt, linkReportsToBundleStmt, selectBundleReadableStmt, selectRepoReadableStmt, selectRepoPathReadableStmt,
1109
1133
  } = stmts
1110
1134
  return {
1111
- async insertBundle(bundle: BundleInput, now: number): Promise<void> {
1135
+ async insertBundle(bundle: BundleInput, now: number, sessionId?: string): Promise<void> {
1136
+ await authorizeUpload(stmts, sessionId, bundle)
1112
1137
  await insertBundleStmt.run(
1113
1138
  bundle.id, preferredSlug(bundle.id), bundle.integrity, bundle.filename, bundle.kind,
1114
1139
  bundle.byteSize, bundle.uploadedBy, bundle.uploadedByLogin ?? null, bundle.repoId, bundle.repoId == null ? '' : bundle.repoDirectory ?? '', now,
@@ -1291,13 +1316,14 @@ export function createManagedMethods(db: ManagedSql, options: ManagedDbOptions =
1291
1316
  const comments = commentMethods(db)
1292
1317
  const activity = activityMethods(db)
1293
1318
  const stmts = prepareStatements(db)
1319
+ const reports = reportMethods(stmts)
1294
1320
  const triage = triageMethods(stmts, options.triageHistoryLimit ?? 0)
1295
1321
  const {
1296
1322
  upsertUserStmt, selectUserIdStmt, promoteInitialAdminStmt, selectGithubIdStmt, insertSessionStmt, selectSessionStmt, selectUsersStmt,
1297
1323
  touchUserSeenStmt, updateRoleStmt, updateTokensStmt, selectTokensStmt, deleteSessionStmt, deleteExpiredStmt,
1298
1324
  } = stmts
1299
1325
 
1300
- return {
1326
+ const methods: Omit<ManagedDb, keyof ManagementStore> = {
1301
1327
  async upsertUser(user, now, initialAdminGithubId = null) {
1302
1328
  // New row → a fresh id; ON CONFLICT(github_user_id) keeps an existing
1303
1329
  // user's id (DO UPDATE leaves it untouched), so re-read to return it.
@@ -1355,13 +1381,14 @@ export function createManagedMethods(db: ManagedSql, options: ManagedDbOptions =
1355
1381
  ...activity,
1356
1382
  ...comments,
1357
1383
  ...workspaceShareMethods(db),
1358
- ...reportMethods(stmts),
1384
+ ...reports,
1359
1385
  ...triage,
1360
- ...importTriageMethods({ ...triage, ...comments }),
1386
+ ...importTriageMethods({ getReport: reports.getReport, ...triage, ...comments }),
1361
1387
  ...bundleMethods(stmts),
1362
1388
  ...teamMethods(stmts),
1363
1389
  async close() {
1364
1390
  await db.close()
1365
1391
  },
1366
1392
  }
1393
+ return { ...methods, ...managementMethods(methods) }
1367
1394
  }
@@ -6,7 +6,7 @@ import { GITHUB_METADATA_SCHEMA, GITHUB_STATE_REASON_COLUMN } from './github-met
6
6
  import { MANAGED_ISSUE_SCHEMA } from './managed-issues.ts'
7
7
  import { COMMENT_SCHEMA } from './comments.ts'
8
8
  import { ACTIVITY_SCHEMA } from './activity.ts'
9
- import { type ManagedSqlDriver, scopeManagedMethods } from './sql.ts'
9
+ import { ManagedCommitError, type ManagedSqlDriver, scopeManagedMethods } from './sql.ts'
10
10
  import { postgresSchema, postgresSql } from './sql-postgres.ts'
11
11
  import { managedTableRenames } from './db-table-names.ts'
12
12
  import { WORKSPACE_SHARE_SCHEMA } from './workspace-shares.ts'
@@ -144,16 +144,23 @@ export async function openPostgresManagedDb(connect: PgConnect, options: Managed
144
144
  async scope(write, work) {
145
145
  if (closed) throw new Error('Managed database is closed')
146
146
  const db = await connect()
147
+ let commitAttempted = false
148
+ let result
147
149
  try {
148
150
  await db.query(write ? 'BEGIN' : 'BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY')
149
151
  if (write) await db.query(LOCK)
150
- const result = await context.run(db, work)
152
+ result = await context.run(db, work)
153
+ commitAttempted = true
151
154
  await db.query('COMMIT')
152
- return result
153
155
  } catch (err) {
154
156
  await db.query('ROLLBACK').catch(() => {})
157
+ await db.release().catch(() => {})
158
+ if (write && commitAttempted) throw new ManagedCommitError(err)
155
159
  throw err
156
- } finally { await db.release() }
160
+ }
161
+ try { await db.release() }
162
+ catch (err) { if (write) throw new ManagedCommitError(err); throw err }
163
+ return result
157
164
  },
158
165
  close() { closed = true },
159
166
  }