@preventive/triage 1.0.0-alpha.2 → 1.0.0-alpha.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (168) hide show
  1. package/api/reap.ts +17 -0
  2. package/cli.js +6 -0
  3. package/client/finding-link.js +305 -0
  4. package/client/linked-findings.d.ts +1 -0
  5. package/client/linked-findings.js +111 -0
  6. package/common/bundle-metadata.d.ts +10 -0
  7. package/common/bundle-metadata.js +177 -0
  8. package/common/bundle-reasons.d.ts +2 -0
  9. package/common/bundle-reasons.js +21 -0
  10. package/common/bundle-sources.d.ts +3 -0
  11. package/common/bundle-sources.js +284 -0
  12. package/common/bundle-stats.js +41 -0
  13. package/common/bundle-tabs.js +1 -0
  14. package/common/code-language.js +36 -0
  15. package/common/default-scan-models.ts +30 -0
  16. package/common/finding-id.js +47 -0
  17. package/common/github-pr.ts +56 -0
  18. package/common/managed/comments.ts +34 -0
  19. package/common/managed/permissions.ts +35 -0
  20. package/common/managed/report-content.ts +42 -0
  21. package/common/managed/report-filter.ts +108 -0
  22. package/common/managed/roles.ts +28 -0
  23. package/common/managed/routes.d.ts +2 -0
  24. package/common/managed/routes.js +121 -0
  25. package/common/managed/scan-models.ts +6 -0
  26. package/common/managed/triage.ts +83 -0
  27. package/common/save-error-reason.ts +20 -7
  28. package/common/scan-server.ts +13 -0
  29. package/common/server-info.ts +33 -0
  30. package/common/utf8.d.ts +3 -0
  31. package/common/utf8.js +45 -0
  32. package/out/brotli-fallback.js +3 -3
  33. package/out/client-managed-import.js +81 -0
  34. package/out/client-managed.js +110 -0
  35. package/out/client-sync.js +16 -13
  36. package/out/graph.js +30 -4
  37. package/out/index.html +55 -8
  38. package/out/prism.js +2 -2
  39. package/out/stasis.svg +45 -0
  40. package/out/terminal.js +273 -39
  41. package/out/view.css +1 -1
  42. package/out/view.js +198 -62
  43. package/package.json +179 -55
  44. package/report/index.js +254 -0
  45. package/report/src/finding-id.js +80 -0
  46. package/report/src/finding.js +312 -0
  47. package/report/src/labels.js +33 -0
  48. package/report/src/md-structure.js +471 -0
  49. package/report/src/md-text.js +167 -0
  50. package/report/src/meta.js +76 -0
  51. package/report/src/parse-codex.js +147 -0
  52. package/report/src/parse-deepsec.js +197 -0
  53. package/report/src/parse-deepview-fields.js +375 -0
  54. package/report/src/parse-deepview-md.js +185 -0
  55. package/report/src/parse-md-id.js +137 -0
  56. package/report/src/parse-md.js +322 -0
  57. package/report/src/parse-piolium-id.js +79 -0
  58. package/report/src/parse-piolium-rows.js +131 -0
  59. package/report/src/parse-piolium-tokens.js +175 -0
  60. package/report/src/parse-piolium.js +400 -0
  61. package/report/src/security.js +63 -0
  62. package/report/src/utf8.js +21 -0
  63. package/report/src/write-md-finding.js +273 -0
  64. package/report/src/write-md.js +291 -0
  65. package/server-common/database-config.ts +16 -0
  66. package/server-common/initialize.ts +18 -0
  67. package/server-common/npm-advisories.ts +101 -0
  68. package/{server → server-common}/origin.ts +5 -5
  69. package/server-common/reap.ts +48 -0
  70. package/server-common/scan-config.ts +19 -0
  71. package/server-common/standalone.ts +29 -0
  72. package/server-common/storage-log.ts +34 -0
  73. package/server-common/vercel-blob.ts +110 -0
  74. package/server-e2e/app.ts +485 -0
  75. package/{server → server-e2e}/auth.ts +5 -1
  76. package/{server → server-e2e}/bus-receiver.ts +9 -8
  77. package/{server → server-e2e}/cli.js +9 -4
  78. package/{server → server-e2e}/config.ts +54 -39
  79. package/{server → server-e2e}/db-neon.ts +2 -2
  80. package/{server → server-e2e}/db-revision-sql.ts +7 -10
  81. package/{server → server-e2e}/db-stmt.ts +2 -2
  82. package/{server → server-e2e}/db.ts +96 -135
  83. package/{server → server-e2e}/http.ts +110 -12
  84. package/{server → server-e2e}/hub.ts +44 -14
  85. package/server-e2e/index.ts +17 -0
  86. package/server-e2e/lifecycle.ts +95 -0
  87. package/{server → server-e2e}/neon-driver.ts +2 -2
  88. package/{server → server-e2e}/npm-proxy.ts +11 -144
  89. package/{server → server-e2e}/objstore/blob-fs.ts +6 -8
  90. package/{server → server-e2e}/objstore/blob-vercel.ts +49 -141
  91. package/{server → server-e2e}/objstore/blob.ts +24 -9
  92. package/server-e2e/objstore/fetch-mint-guard.ts +74 -0
  93. package/{server → server-e2e}/objstore/handlers.ts +19 -20
  94. package/{server → server-e2e}/objstore/init.ts +53 -27
  95. package/{server → server-e2e}/objstore/reaper.ts +31 -11
  96. package/server-e2e/objstore/rest-deny.ts +28 -0
  97. package/server-e2e/objstore/rest-mint.ts +224 -0
  98. package/{server → server-e2e}/objstore/rest.ts +110 -93
  99. package/{server → server-e2e}/objstore/sign.ts +105 -0
  100. package/{server → server-e2e}/objstore/store-neon.ts +10 -14
  101. package/{server → server-e2e}/objstore/store.ts +98 -118
  102. package/{server → server-e2e}/objstore/tokens.ts +9 -12
  103. package/{server → server-e2e}/peer.ts +7 -9
  104. package/{server → server-e2e}/pubsub.ts +29 -36
  105. package/{server → server-e2e}/sign.ts +12 -14
  106. package/{server → server-e2e}/sse-server.ts +105 -73
  107. package/{server → server-e2e}/sse-session.ts +30 -16
  108. package/{server → server-e2e}/static.ts +36 -29
  109. package/server-e2e/sync-handlers.ts +408 -0
  110. package/{server → server-e2e}/util.ts +9 -0
  111. package/{server → server-e2e}/ws-server.ts +29 -23
  112. package/server-managed/activity.ts +231 -0
  113. package/server-managed/avatar-store.ts +51 -0
  114. package/server-managed/blob-store.ts +66 -0
  115. package/server-managed/blob-vercel.ts +125 -0
  116. package/server-managed/brotli.ts +10 -0
  117. package/server-managed/bundle-cache.ts +185 -0
  118. package/server-managed/bundle-catalog.ts +29 -0
  119. package/server-managed/bundle-store.ts +28 -0
  120. package/server-managed/bundle-summary-cache.ts +97 -0
  121. package/server-managed/bundle.ts +39 -0
  122. package/server-managed/cache-storage.ts +40 -0
  123. package/server-managed/cli.js +13 -0
  124. package/server-managed/combined.ts +46 -0
  125. package/server-managed/comments.ts +151 -0
  126. package/server-managed/config.ts +144 -0
  127. package/server-managed/content-access.ts +15 -0
  128. package/server-managed/crypto.ts +25 -0
  129. package/server-managed/db-methods.ts +1330 -0
  130. package/server-managed/db-neon.ts +171 -0
  131. package/server-managed/db-schema.ts +203 -0
  132. package/server-managed/db-table-names.ts +22 -0
  133. package/server-managed/db.ts +109 -0
  134. package/server-managed/github-app.ts +332 -0
  135. package/server-managed/github-metadata.ts +65 -0
  136. package/server-managed/github-oauth.ts +215 -0
  137. package/server-managed/github-pulls.ts +115 -0
  138. package/server-managed/http-response.ts +18 -0
  139. package/server-managed/http.ts +2043 -0
  140. package/server-managed/import-triage.ts +48 -0
  141. package/server-managed/index.ts +135 -0
  142. package/server-managed/public-workspace.ts +150 -0
  143. package/server-managed/repo-path.ts +21 -0
  144. package/server-managed/report-migration.ts +35 -0
  145. package/server-managed/report-query.ts +4 -0
  146. package/server-managed/report-response.ts +16 -0
  147. package/server-managed/report-sources.ts +154 -0
  148. package/server-managed/repository-discovery.ts +82 -0
  149. package/server-managed/repository-policy.ts +25 -0
  150. package/server-managed/session.ts +78 -0
  151. package/server-managed/slugs.ts +38 -0
  152. package/server-managed/sql-postgres.ts +30 -0
  153. package/server-managed/sql.ts +61 -0
  154. package/server-managed/static.ts +28 -0
  155. package/server-managed/storage.ts +34 -0
  156. package/server-managed/team-catalog.ts +7 -0
  157. package/server-managed/team-feed.ts +128 -0
  158. package/server-managed/team-reports.ts +156 -0
  159. package/server-managed/triage-response.ts +16 -0
  160. package/server-managed/uploads.ts +47 -0
  161. package/server-managed/workspace-shares.ts +150 -0
  162. package/server.ts +50 -0
  163. package/server/index.ts +0 -481
  164. package/server/lifecycle.ts +0 -204
  165. package/server/sync-handlers.ts +0 -327
  166. /package/{server → server-e2e}/config.example.json +0 -0
  167. /package/{server → server-e2e}/objstore/fs.ts +0 -0
  168. /package/{server → server-e2e}/validation.ts +0 -0
@@ -0,0 +1,171 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks'
2
+ import { WebSocket } from 'ws'
3
+ import { type ManagedDb, type ManagedDbOptions, createManagedMethods } from './db-methods.ts'
4
+ import { MANAGED_SCHEMA } from './db-schema.ts'
5
+ import { GITHUB_METADATA_SCHEMA, GITHUB_STATE_REASON_COLUMN } from './github-metadata.ts'
6
+ import { COMMENT_SCHEMA } from './comments.ts'
7
+ import { ACTIVITY_SCHEMA } from './activity.ts'
8
+ import { type ManagedSqlDriver, scopeManagedMethods } from './sql.ts'
9
+ import { postgresSchema, postgresSql } from './sql-postgres.ts'
10
+ import { managedTableRenames } from './db-table-names.ts'
11
+ import { WORKSPACE_SHARE_SCHEMA } from './workspace-shares.ts'
12
+ import { allocateMissingSlugs } from './slugs.ts'
13
+
14
+ export interface PgConnection {
15
+ query(sql: string, params?: unknown[]): Promise<{
16
+ rows: Record<string, unknown>[]; rowCount?: number | null
17
+ fields?: { name: string; dataTypeID: number }[]
18
+ }>
19
+ release(): Promise<void>
20
+ }
21
+ export type PgConnect = () => Promise<PgConnection>
22
+ // All managed writers cooperate on this transaction-scoped lock. Reads use a
23
+ // consistent snapshot without taking it. Also protects concurrent cold starts.
24
+ const LOCK = 'SELECT pg_advisory_xact_lock(1937006964, 1835101793)'
25
+
26
+ async function initialize(db: PgConnection): Promise<void> {
27
+ await db.query('BEGIN')
28
+ try {
29
+ await db.query(LOCK)
30
+ await db.query('CREATE TABLE IF NOT EXISTS managed_schema_version (version INTEGER PRIMARY KEY)')
31
+ const initialized = (await db.query('SELECT version FROM managed_schema_version WHERE version = 1')).rows.length > 0
32
+ const prefixed = (await db.query('SELECT version FROM managed_schema_version WHERE version = 3')).rows.length > 0
33
+ if (initialized && !prefixed) {
34
+ const { rows } = await db.query("SELECT relname AS name FROM pg_class WHERE relkind IN ('r', 'p') AND pg_table_is_visible(oid)")
35
+ for (const [from, to] of managedTableRenames(new Set(rows.map(row => String(row['name']))))) {
36
+ await db.query(`ALTER TABLE ${from} RENAME TO ${to}`)
37
+ }
38
+ // PL/pgSQL function bodies retain literal table names after ALTER TABLE.
39
+ for (const type of ['report', 'bundle']) await createUploadTrigger(db, type, false)
40
+ }
41
+ if (!initialized) {
42
+ await db.query(postgresSchema(MANAGED_SCHEMA + COMMENT_SCHEMA + ACTIVITY_SCHEMA))
43
+ await db.query(`ALTER TABLE managed_finding_triage_event ADD COLUMN report_id TEXT, ADD COLUMN report TEXT, ADD COLUMN repo TEXT;
44
+ CREATE UNIQUE INDEX managed_team_slug_idx ON managed_team(slug);
45
+ CREATE UNIQUE INDEX managed_report_slug_idx ON managed_report(slug);
46
+ CREATE INDEX managed_activity_actor_at_idx ON managed_activity(actor_id, at);`)
47
+ for (const type of ['report', 'bundle']) await createUploadTrigger(db, type)
48
+ await db.query('INSERT INTO managed_schema_version VALUES (1)')
49
+ }
50
+ if ((await db.query('SELECT version FROM managed_schema_version WHERE version = 2')).rows.length === 0) {
51
+ await db.query('CREATE INDEX IF NOT EXISTS managed_report_bundle_hash_idx ON managed_report(bundle_id, sha256)')
52
+ await db.query('INSERT INTO managed_schema_version VALUES (2)')
53
+ }
54
+ if (!prefixed) await db.query('INSERT INTO managed_schema_version VALUES (3)')
55
+ if ((await db.query('SELECT version FROM managed_schema_version WHERE version = 4')).rows.length === 0) {
56
+ await db.query(postgresSchema(WORKSPACE_SHARE_SCHEMA))
57
+ await db.query('INSERT INTO managed_schema_version VALUES (4)')
58
+ }
59
+ await db.query(postgresSchema(GITHUB_METADATA_SCHEMA))
60
+ await db.query(`ALTER TABLE managed_github_metadata ADD COLUMN IF NOT EXISTS state_reason ${GITHUB_STATE_REASON_COLUMN}`)
61
+ await db.query('ALTER TABLE managed_github_metadata ADD COLUMN IF NOT EXISTS attempted_at BIGINT')
62
+ if ((await db.query('SELECT version FROM managed_schema_version WHERE version = 5')).rows.length === 0) {
63
+ await db.query(`ALTER TABLE managed_workspace_share ADD COLUMN IF NOT EXISTS dependencies INTEGER NOT NULL DEFAULT 0,
64
+ ADD COLUMN IF NOT EXISTS security INTEGER NOT NULL DEFAULT 0`)
65
+ await db.query('INSERT INTO managed_schema_version VALUES (5)')
66
+ }
67
+ if ((await db.query('SELECT version FROM managed_schema_version WHERE version = 6')).rows.length === 0) {
68
+ await db.query('ALTER TABLE managed_bundle ADD COLUMN IF NOT EXISTS slug TEXT')
69
+ const { rows } = await db.query('SELECT id, slug FROM managed_bundle ORDER BY id')
70
+ for (const row of allocateMissingSlugs(rows as { id: string; slug: string | null }[])) {
71
+ await db.query('UPDATE managed_bundle SET slug = $1 WHERE id = $2', [row.slug, row.id])
72
+ }
73
+ await db.query(`ALTER TABLE managed_bundle ALTER COLUMN slug SET NOT NULL;
74
+ CREATE UNIQUE INDEX IF NOT EXISTS managed_bundle_slug_idx ON managed_bundle(slug);
75
+ INSERT INTO managed_schema_version VALUES (6)`)
76
+ }
77
+ if ((await db.query('SELECT version FROM managed_schema_version WHERE version = 7')).rows.length === 0) {
78
+ await db.query("ALTER TABLE managed_bundle ADD COLUMN IF NOT EXISTS repo_directory TEXT NOT NULL DEFAULT ''")
79
+ await db.query('INSERT INTO managed_schema_version VALUES (7)')
80
+ }
81
+ await db.query('COMMIT')
82
+ } catch (err) {
83
+ await db.query('ROLLBACK')
84
+ throw err
85
+ }
86
+ }
87
+
88
+ async function createUploadTrigger(db: PgConnection, type: string, createTrigger = true): Promise<void> {
89
+ await db.query(`CREATE OR REPLACE FUNCTION managed_${type}_activity_insert() RETURNS trigger LANGUAGE plpgsql AS $$
90
+ BEGIN
91
+ INSERT INTO managed_activity (id, kind, actor, action, repo, report_id, report, at, bundle_id, actor_id)
92
+ VALUES ('${type}-upload:' || NEW.id, 'upload',
93
+ COALESCE(NEW.uploaded_by_login, (SELECT login FROM managed_user WHERE id = NEW.uploaded_by)),
94
+ 'uploaded a ${type}', (SELECT full_name FROM managed_selected_repo WHERE repo_id = NEW.repo_id),
95
+ ${type === 'report' ? 'NEW.id' : 'NULL'}, NEW.filename, NEW.uploaded_at,
96
+ ${type === 'bundle' ? 'NEW.id' : 'NULL'}, NEW.uploaded_by);
97
+ RETURN NEW;
98
+ END $$;`)
99
+ if (createTrigger) {
100
+ await db.query(`CREATE TRIGGER managed_${type}_activity AFTER INSERT ON managed_${type}
101
+ FOR EACH ROW EXECUTE FUNCTION managed_${type}_activity_insert();`)
102
+ }
103
+ }
104
+
105
+ // Exposed for parity tests against real PostgreSQL semantics via PGlite.
106
+ export async function openPostgresManagedDb(connect: PgConnect, options: ManagedDbOptions = {}): Promise<ManagedDb> {
107
+ const initial = await connect()
108
+ try { await initialize(initial) } finally { await initial.release() }
109
+ const context = new AsyncLocalStorage<PgConnection>()
110
+ let closed = false
111
+ const driver: ManagedSqlDriver = {
112
+ prepare(source) {
113
+ const { sql, names } = postgresSql(source)
114
+ async function query(args: unknown[]) {
115
+ const db = context.getStore()
116
+ if (!db) throw new Error('Managed query outside transaction')
117
+ const values = names.length > 0 ? names.map(name => (args[0] as Record<string, unknown>)[name])
118
+ : args.length === 1 && typeof args[0] === 'object' && args[0] !== null ? [] : args
119
+ const result = await db.query(sql, values)
120
+ // Postgres int8 is returned as text by node-postgres/Neon. All store
121
+ // numbers are JS safe integers (timestamps, IDs, sizes and sequences).
122
+ for (const field of result.fields ?? []) {
123
+ if (field.dataTypeID !== 20) continue
124
+ for (const row of result.rows) {
125
+ if (row[field.name] == null) continue
126
+ const value = Number(row[field.name])
127
+ if (!Number.isSafeInteger(value)) throw new Error('Managed integer exceeds safe range')
128
+ row[field.name] = value
129
+ }
130
+ }
131
+ return result
132
+ }
133
+ return {
134
+ get: async (...args) => (await query(args)).rows[0],
135
+ all: async (...args) => (await query(args)).rows,
136
+ run: async (...args) => ({ changes: (await query(args)).rowCount ?? 0 }),
137
+ }
138
+ },
139
+ async scope(write, work) {
140
+ if (closed) throw new Error('Managed database is closed')
141
+ const db = await connect()
142
+ try {
143
+ await db.query(write ? 'BEGIN' : 'BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY')
144
+ if (write) await db.query(LOCK)
145
+ const result = await context.run(db, work)
146
+ await db.query('COMMIT')
147
+ return result
148
+ } catch (err) {
149
+ await db.query('ROLLBACK').catch(() => {})
150
+ throw err
151
+ } finally { await db.release() }
152
+ },
153
+ close() { closed = true },
154
+ }
155
+ return scopeManagedMethods(createManagedMethods(driver, options), driver)
156
+ }
157
+
158
+ export async function openNeonManagedDb(url: string, options: ManagedDbOptions = {}): Promise<ManagedDb> {
159
+ // Reuse the e2e optional-driver boundary. Each operation closes its socket
160
+ // before returning; no open connection has to survive a frozen invocation.
161
+ const { Client, neonConfig } = await import('../server-e2e/neon-driver.ts') as unknown as {
162
+ Client: new (url: string) => { connect(): Promise<void>; end(): Promise<void>; query: PgConnection['query'] }
163
+ neonConfig: { webSocketConstructor: typeof WebSocket }
164
+ }
165
+ neonConfig.webSocketConstructor = WebSocket
166
+ return openPostgresManagedDb(async () => {
167
+ const client = new Client(url)
168
+ try { await client.connect() } catch (err) { await client.end().catch(() => {}); throw err }
169
+ return { query: (sql, params) => client.query(sql, params), release: () => client.end() }
170
+ }, options)
171
+ }
@@ -0,0 +1,203 @@
1
+ import { WORKSPACE_SHARE_SCHEMA } from './workspace-shares.ts'
2
+
3
+ export const MANAGED_SCHEMA = `
4
+ CREATE TABLE IF NOT EXISTS managed_user (
5
+ id TEXT PRIMARY KEY,
6
+ github_user_id INTEGER NOT NULL UNIQUE,
7
+ login TEXT NOT NULL,
8
+ name TEXT,
9
+ avatar_url TEXT,
10
+ role TEXT NOT NULL DEFAULT 'none',
11
+ -- GitHub user-to-server token, persisted so the repositories page can list
12
+ -- the user's repos (GET /user/repos) on demand. Refresh token + expiry are
13
+ -- null for non-expiring tokens (GitHub App with expiring tokens disabled).
14
+ gh_access_token TEXT,
15
+ gh_refresh_token TEXT,
16
+ gh_token_expires_at INTEGER,
17
+ created_at INTEGER NOT NULL,
18
+ updated_at INTEGER NOT NULL,
19
+ -- Presence is independent of admin edits and background token refreshes.
20
+ -- NULL for legacy accounts until a known session authenticates them.
21
+ last_seen_at INTEGER
22
+ ) STRICT;
23
+
24
+ CREATE TABLE IF NOT EXISTS managed_session (
25
+ id TEXT PRIMARY KEY,
26
+ user_id TEXT NOT NULL REFERENCES managed_user(id) ON DELETE CASCADE,
27
+ csrf_token TEXT NOT NULL,
28
+ created_at INTEGER NOT NULL,
29
+ expires_at INTEGER NOT NULL
30
+ ) STRICT;
31
+
32
+ CREATE INDEX IF NOT EXISTS managed_session_user_idx ON managed_session(user_id);
33
+ CREATE INDEX IF NOT EXISTS managed_session_expires_idx ON managed_session(expires_at);
34
+
35
+ -- Repositories selected to operate on. Keyed by GitHub's numeric repo id
36
+ -- (stable across renames). The row carries everything needed to read the repo's
37
+ -- contents server-side later: installation_id mints an App installation token
38
+ -- (Contents: Read) for PRIVATE repos — NULL means a PUBLIC repo readable without
39
+ -- the App — and full_name + default_branch locate the contents. added_by is the
40
+ -- selector, nulled (not cascaded) if that user is removed so the selection
41
+ -- survives.
42
+ CREATE TABLE IF NOT EXISTS managed_selected_repo (
43
+ repo_id INTEGER PRIMARY KEY,
44
+ full_name TEXT NOT NULL,
45
+ is_private INTEGER NOT NULL,
46
+ installation_id INTEGER,
47
+ default_branch TEXT NOT NULL,
48
+ html_url TEXT NOT NULL,
49
+ added_by TEXT REFERENCES managed_user(id) ON DELETE SET NULL,
50
+ added_at INTEGER NOT NULL,
51
+ updated_at INTEGER NOT NULL,
52
+ active INTEGER NOT NULL DEFAULT 1
53
+ ) STRICT;
54
+
55
+ CREATE INDEX IF NOT EXISTS selected_repo_full_name_idx ON managed_selected_repo(full_name);
56
+
57
+ -- Bundles uploaded to the server (the "Manage bundles" page). Like reports, the
58
+ -- bytes are stored in the clear (blob-store, keyed by this opaque uuid id) for
59
+ -- the server to operate on. integrity is the content hash (sha512-<base64>,
60
+ -- byte-identical to the client's) so re-uploading the same bytes dedupes (UNIQUE);
61
+ -- it is also the key a report's bundleHashes match to auto-link.
62
+ -- uploaded_by / repo_id are the (nullable) user + repo links, both nulled (not
63
+ -- cascaded) when the referenced user / selected repo goes away.
64
+ CREATE TABLE IF NOT EXISTS managed_bundle (
65
+ id TEXT PRIMARY KEY,
66
+ slug TEXT NOT NULL,
67
+ integrity TEXT NOT NULL UNIQUE,
68
+ filename TEXT NOT NULL,
69
+ kind TEXT,
70
+ byte_size INTEGER NOT NULL,
71
+ uploaded_by TEXT REFERENCES managed_user(id) ON DELETE SET NULL,
72
+ -- Durable snapshot of the uploader's login at upload time, so "who uploaded
73
+ -- this" survives the uploader being removed (when uploaded_by nulls out).
74
+ uploaded_by_login TEXT,
75
+ repo_id INTEGER REFERENCES managed_selected_repo(repo_id) ON DELETE SET NULL,
76
+ repo_directory TEXT NOT NULL DEFAULT '',
77
+ uploaded_at INTEGER NOT NULL
78
+ ) STRICT;
79
+
80
+ CREATE INDEX IF NOT EXISTS managed_bundle_uploaded_at_idx ON managed_bundle(uploaded_at);
81
+
82
+ -- Reports uploaded to the server (the "Manage reports" page). A managed server
83
+ -- is TRUSTED, so the bytes are stored in the clear (blob-store, keyed by this
84
+ -- opaque uuid id) for the server to operate on later; this row carries the
85
+ -- metadata + attribution. uploaded_by / repo_id are the (nullable) user + repo
86
+ -- links, nulled (not cascaded) when the user / selected repo goes away. bundle_id
87
+ -- is the (nullable) auto-resolved link to a stored bundle; bundle_integrity is
88
+ -- the report's declared primary bundle (from its bundleHashes), kept so a later
89
+ -- bundle upload of that integrity can re-link.
90
+ CREATE TABLE IF NOT EXISTS managed_report (
91
+ id TEXT PRIMARY KEY,
92
+ slug TEXT NOT NULL,
93
+ filename TEXT NOT NULL,
94
+ content_type TEXT NOT NULL,
95
+ byte_size INTEGER NOT NULL,
96
+ sha256 TEXT NOT NULL,
97
+ uploaded_by TEXT REFERENCES managed_user(id) ON DELETE SET NULL,
98
+ -- Durable snapshot of the uploader's login (see managed_bundle).
99
+ uploaded_by_login TEXT,
100
+ repo_id INTEGER REFERENCES managed_selected_repo(repo_id) ON DELETE SET NULL,
101
+ repo_directory TEXT NOT NULL DEFAULT '',
102
+ repo_embedded INTEGER NOT NULL DEFAULT 0,
103
+ analyzer TEXT,
104
+ visible INTEGER NOT NULL DEFAULT 0,
105
+ bundle_id TEXT REFERENCES managed_bundle(id) ON DELETE SET NULL,
106
+ bundle_integrity TEXT,
107
+ uploaded_at INTEGER NOT NULL
108
+ ) STRICT;
109
+
110
+ CREATE INDEX IF NOT EXISTS managed_report_uploaded_at_idx ON managed_report(uploaded_at);
111
+ CREATE INDEX IF NOT EXISTS managed_report_bundle_integrity_idx ON managed_report(bundle_integrity);
112
+ CREATE INDEX IF NOT EXISTS managed_report_bundle_hash_idx ON managed_report(bundle_id, sha256);
113
+
114
+ -- Per-finding triage annotations — the managed (trusted-plaintext) counterpart
115
+ -- of the client's localStorage triage map, keyed the same way: by finding id
116
+ -- alone, not by report. Reports mostly repeat one another (a re-scan of the
117
+ -- same code carries the same finding ids) and a finding's triage is shared by
118
+ -- every report that carries it; which ids a viewer may read or write is
119
+ -- decided per report at the endpoint, not here. The wire shape lives in
120
+ -- common/managed/triage.ts. Whole-entry replace, NULL columns are unset;
121
+ -- flagged is tri-state: NULL unset / 1 flagged / 0 an explicit un-flag
122
+ -- tombstone. A cleared entry keeps its row with every field NULL — a
123
+ -- tombstone a reader adopts as "cleared", where a missing row means "never
124
+ -- annotated" (so a stale client copy can't resurrect a teammate's clear).
125
+ -- updated_by is the last writer (nulled when that user is removed);
126
+ -- updated_by_login is the durable login snapshot (see managed_bundle).
127
+ CREATE TABLE IF NOT EXISTS managed_finding_triage (
128
+ finding_id TEXT PRIMARY KEY,
129
+ color TEXT,
130
+ triage TEXT,
131
+ comment TEXT,
132
+ fix TEXT,
133
+ flagged INTEGER,
134
+ updated_by TEXT REFERENCES managed_user(id) ON DELETE SET NULL,
135
+ updated_by_login TEXT,
136
+ updated_at INTEGER NOT NULL
137
+ ) STRICT;
138
+
139
+ -- The trail behind managed_finding_triage: one row per write that CHANGED an entry,
140
+ -- holding the entry as written (every field NULL = a clear), who wrote it and
141
+ -- when. managed_finding_triage stays the current-state projection reads hit; this is
142
+ -- walked only for a finding's history. The wire is whole-entry replace, so a
143
+ -- snapshot per write is exactly what arrived and "what changed" is the diff
144
+ -- against the previous row for the same id, computed on read. batch_id groups
145
+ -- the rows of one request. seq is the rowid: a total order that breaks ties
146
+ -- on at. actor_id / actor_login follow the uploaded_by / uploaded_by_login
147
+ -- convention (live account, durable login snapshot). Everything is kept by
148
+ -- default — the trail is the record; an operator may bound it per finding
149
+ -- (TRIAGE_HISTORY_LIMIT), in which case a finding's older events are trimmed
150
+ -- as new ones land. An id no report carries any more is the tombstone GC's
151
+ -- concern.
152
+ CREATE TABLE IF NOT EXISTS managed_finding_triage_event (
153
+ seq INTEGER PRIMARY KEY,
154
+ finding_id TEXT NOT NULL,
155
+ batch_id TEXT NOT NULL,
156
+ color TEXT,
157
+ triage TEXT,
158
+ comment TEXT,
159
+ fix TEXT,
160
+ flagged INTEGER,
161
+ actor_id TEXT REFERENCES managed_user(id) ON DELETE SET NULL,
162
+ actor_login TEXT,
163
+ at INTEGER NOT NULL
164
+ ) STRICT;
165
+ CREATE INDEX IF NOT EXISTS finding_triage_event_finding_idx ON managed_finding_triage_event(finding_id, seq);
166
+ CREATE INDEX IF NOT EXISTS finding_triage_event_actor_at_idx ON managed_finding_triage_event(actor_id, at);
167
+
168
+ -- Teams group users + repos for access scoping. A team has just a name here;
169
+ -- the two link tables below carry the many-many relations.
170
+ CREATE TABLE IF NOT EXISTS managed_team (
171
+ id TEXT PRIMARY KEY,
172
+ slug TEXT NOT NULL,
173
+ name TEXT NOT NULL UNIQUE,
174
+ created_at INTEGER NOT NULL,
175
+ updated_at INTEGER NOT NULL
176
+ ) STRICT;
177
+
178
+ -- Team <-> repo, many-many, with an OPTIONAL path (a subpath of the repo the
179
+ -- team is scoped to; empty path = the whole repo). Distinct paths can coexist;
180
+ -- adding the whole repo replaces them. CASCADE removes links with either side. repo_id
181
+ -- references the selected (operate-on) repos.
182
+ CREATE TABLE IF NOT EXISTS managed_team_repo (
183
+ team_id TEXT NOT NULL REFERENCES managed_team(id) ON DELETE CASCADE,
184
+ repo_id INTEGER NOT NULL REFERENCES managed_selected_repo(repo_id) ON DELETE CASCADE,
185
+ path TEXT NOT NULL DEFAULT '',
186
+ PRIMARY KEY (team_id, repo_id, path)
187
+ ) STRICT;
188
+
189
+ CREATE INDEX IF NOT EXISTS team_repo_repo_idx ON managed_team_repo(repo_id);
190
+
191
+ -- Team <-> user, many-many, with per-membership visibility permissions (see
192
+ -- common/managed/permissions.ts) — view_dependencies / view_security, both
193
+ -- default 0 (off). CASCADE so the membership dies with either side.
194
+ CREATE TABLE IF NOT EXISTS managed_team_user (
195
+ team_id TEXT NOT NULL REFERENCES managed_team(id) ON DELETE CASCADE,
196
+ user_id TEXT NOT NULL REFERENCES managed_user(id) ON DELETE CASCADE,
197
+ view_dependencies INTEGER NOT NULL DEFAULT 0,
198
+ view_security INTEGER NOT NULL DEFAULT 0,
199
+ PRIMARY KEY (team_id, user_id)
200
+ ) STRICT;
201
+
202
+ CREATE INDEX IF NOT EXISTS team_user_user_idx ON managed_team_user(user_id);
203
+ ` + WORKSPACE_SHARE_SCHEMA
@@ -0,0 +1,22 @@
1
+ // Table identifiers are code constants. Renames retain rows and foreign keys;
2
+ // an ambiguous old/new pair must be resolved before the server can start.
3
+ export const MANAGED_TABLE_RENAMES = [
4
+ ['selected_repo', 'managed_selected_repo'],
5
+ ['team_repo', 'managed_team_repo'],
6
+ ['team_user', 'managed_team_user'],
7
+ ['finding_triage', 'managed_finding_triage'],
8
+ ['finding_triage_event', 'managed_finding_triage_event'],
9
+ ['finding_comment', 'managed_finding_comment'],
10
+ ['finding_comment_event', 'managed_finding_comment_event'],
11
+ ] as const
12
+
13
+ export function managedTableRenames(tables: Set<string>) {
14
+ // A fresh installation must not claim similarly named unrelated tables in
15
+ // a shared database. managed_user anchors an existing managed installation.
16
+ if (!tables.has('managed_user')) return []
17
+ const renames = MANAGED_TABLE_RENAMES.filter(([from]) => tables.has(from))
18
+ for (const [from, to] of renames) {
19
+ if (tables.has(to)) throw new Error(`Cannot migrate managed tables: both ${from} and ${to} exist`)
20
+ }
21
+ return renames
22
+ }
@@ -0,0 +1,109 @@
1
+ // SQLite bootstrap and legacy upgrades. Runtime methods are shared with Neon.
2
+ import { mkdirSync } from 'node:fs'
3
+ import { dirname, join } from 'node:path'
4
+ import { DatabaseSync } from 'node:sqlite'
5
+ import { migrateSlugs } from './slugs.ts'
6
+ import { migrateReportLocations } from './report-migration.ts'
7
+ import { initCommentMethods } from './comments.ts'
8
+ import { initActivityMethods } from './activity.ts'
9
+ import { MANAGED_SCHEMA } from './db-schema.ts'
10
+ import { GITHUB_METADATA_SCHEMA, GITHUB_STATE_REASON_COLUMN } from './github-metadata.ts'
11
+ import { managedTableRenames } from './db-table-names.ts'
12
+ import { type ManagedDb, type ManagedDbOptions, createManagedMethods } from './db-methods.ts'
13
+ import { createSqliteDriver, scopeManagedMethods } from './sql.ts'
14
+ export type * from './db-methods.ts'
15
+
16
+ function migrateManagedTableNames(db: DatabaseSync): void {
17
+ db.exec('BEGIN IMMEDIATE')
18
+ try {
19
+ db.exec('CREATE TABLE IF NOT EXISTS managed_schema_version (version INTEGER PRIMARY KEY) STRICT;')
20
+ if (!db.prepare('SELECT version FROM managed_schema_version WHERE version = 3').get()) {
21
+ const rows = db.prepare("SELECT name FROM sqlite_schema WHERE type = 'table'").all() as { name: string }[]
22
+ for (const [from, to] of managedTableRenames(new Set(rows.map(row => row.name)))) {
23
+ db.exec(`ALTER TABLE ${from} RENAME TO ${to}`)
24
+ }
25
+ db.exec('INSERT INTO managed_schema_version VALUES (3)')
26
+ }
27
+ db.exec('COMMIT')
28
+ } catch (err) { db.exec('ROLLBACK'); throw err }
29
+ }
30
+
31
+ // Preserve the legacy single-path links while adding path to their identity.
32
+ function migrateTeamRepoPaths(db: DatabaseSync): void {
33
+ const columns = db.prepare('PRAGMA table_info(managed_team_repo)').all() as { name: string; pk: number }[]
34
+ if (columns.some(column => column.name === 'path' && column.pk === 3)) return
35
+ db.exec('BEGIN IMMEDIATE')
36
+ try {
37
+ db.exec(`CREATE TABLE managed_team_repo_paths (
38
+ team_id TEXT NOT NULL REFERENCES managed_team(id) ON DELETE CASCADE,
39
+ repo_id INTEGER NOT NULL REFERENCES managed_selected_repo(repo_id) ON DELETE CASCADE,
40
+ path TEXT NOT NULL DEFAULT '', PRIMARY KEY (team_id, repo_id, path)
41
+ ) STRICT;
42
+ INSERT INTO managed_team_repo_paths SELECT team_id, repo_id, COALESCE(path, '') FROM managed_team_repo;
43
+ DROP TABLE managed_team_repo;
44
+ ALTER TABLE managed_team_repo_paths RENAME TO managed_team_repo;
45
+ CREATE INDEX team_repo_repo_idx ON managed_team_repo(repo_id);`)
46
+ db.exec('COMMIT')
47
+ } catch (err) { db.exec('ROLLBACK'); throw err }
48
+ }
49
+
50
+ // Add `column` to `table` if it's missing (a lightweight migration for DBs that
51
+ // predate the column; `table`/`column` are code constants, never user input).
52
+ function ensureColumn(db: DatabaseSync, table: string, column: string, type: string): boolean {
53
+ const cols = db.prepare(`PRAGMA table_info(${table})`).all() as { name: string }[]
54
+ if (cols.some((c) => c.name === column)) return false
55
+ db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${type}`)
56
+ return true
57
+ }
58
+
59
+ export function openSqliteManagedDb(path: string, options: ManagedDbOptions = {}): ManagedDb {
60
+ mkdirSync(dirname(path), { recursive: true })
61
+ const db = new DatabaseSync(path)
62
+ try {
63
+ db.exec('PRAGMA journal_mode = WAL;')
64
+ db.exec('PRAGMA synchronous = FULL;')
65
+ db.exec('PRAGMA foreign_keys = ON;')
66
+ migrateManagedTableNames(db)
67
+ db.exec(MANAGED_SCHEMA + GITHUB_METADATA_SCHEMA)
68
+ ensureColumn(db, 'managed_github_metadata', 'state_reason', GITHUB_STATE_REASON_COLUMN)
69
+ ensureColumn(db, 'managed_github_metadata', 'attempted_at', 'INTEGER')
70
+ // Existing public links must opt in too; never preserve an implicit grant.
71
+ ensureColumn(db, 'managed_workspace_share', 'dependencies', 'INTEGER NOT NULL DEFAULT 0')
72
+ ensureColumn(db, 'managed_workspace_share', 'security', 'INTEGER NOT NULL DEFAULT 0')
73
+ migrateTeamRepoPaths(db)
74
+ migrateSlugs(db)
75
+ // Migrate DBs created before a column existed (CREATE TABLE IF NOT EXISTS
76
+ // never alters an already-present table). Idempotent — skipped on fresh DBs.
77
+ const addedLastSeen = ensureColumn(db, 'managed_user', 'last_seen_at', 'INTEGER')
78
+ if (addedLastSeen) {
79
+ // Session creation is known authentication activity. updated_at is not:
80
+ // role changes and token refreshes also wrote it. Keep unknowns NULL.
81
+ db.exec(`UPDATE managed_user SET last_seen_at = (
82
+ SELECT MAX(s.created_at) FROM managed_session s WHERE s.user_id = managed_user.id
83
+ )`)
84
+ }
85
+ ensureColumn(db, 'managed_report', 'uploaded_by_login', 'TEXT')
86
+ ensureColumn(db, 'managed_report', 'repo_embedded', 'INTEGER NOT NULL DEFAULT 0')
87
+ migrateReportLocations(db, join(dirname(path), 'reports'))
88
+ ensureColumn(db, 'managed_report', 'analyzer', 'TEXT')
89
+ // Existing rows predate publication controls and were already visible.
90
+ // Backfill only when adding the column; newly inserted rows still use the
91
+ // hidden default and must be explicitly published.
92
+ const addedVisible = ensureColumn(db, 'managed_report', 'visible', 'INTEGER NOT NULL DEFAULT 0')
93
+ if (addedVisible) db.prepare('UPDATE managed_report SET visible = 1 WHERE visible = 0').run()
94
+ ensureColumn(db, 'managed_bundle', 'uploaded_by_login', 'TEXT')
95
+ ensureColumn(db, 'managed_bundle', 'repo_directory', "TEXT NOT NULL DEFAULT ''")
96
+ ensureColumn(db, 'managed_selected_repo', 'active', 'INTEGER NOT NULL DEFAULT 1')
97
+ ensureColumn(db, 'managed_finding_triage_event', 'report_id', 'TEXT')
98
+ ensureColumn(db, 'managed_finding_triage_event', 'report', 'TEXT')
99
+ ensureColumn(db, 'managed_finding_triage_event', 'repo', 'TEXT')
100
+ initCommentMethods(db)
101
+ initActivityMethods(db)
102
+ } catch (err) {
103
+ try { db.close() } catch {}
104
+ throw err
105
+ }
106
+
107
+ const driver = createSqliteDriver(db)
108
+ return scopeManagedMethods(createManagedMethods(driver, options), driver)
109
+ }