@preventive/triage 1.0.0-alpha.2 → 1.0.0-alpha.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/api/reap.ts +17 -0
- package/cli.js +6 -0
- package/client/finding-link.js +305 -0
- package/client/linked-findings.d.ts +1 -0
- package/client/linked-findings.js +111 -0
- package/common/bundle-metadata.d.ts +10 -0
- package/common/bundle-metadata.js +177 -0
- package/common/bundle-reasons.d.ts +2 -0
- package/common/bundle-reasons.js +21 -0
- package/common/bundle-sources.d.ts +3 -0
- package/common/bundle-sources.js +284 -0
- package/common/bundle-stats.js +41 -0
- package/common/bundle-tabs.js +1 -0
- package/common/code-language.js +36 -0
- package/common/default-scan-models.ts +30 -0
- package/common/finding-id.js +47 -0
- package/common/github-pr.ts +56 -0
- package/common/managed/comments.ts +34 -0
- package/common/managed/permissions.ts +35 -0
- package/common/managed/report-content.ts +42 -0
- package/common/managed/report-filter.ts +108 -0
- package/common/managed/roles.ts +28 -0
- package/common/managed/routes.d.ts +2 -0
- package/common/managed/routes.js +121 -0
- package/common/managed/scan-models.ts +6 -0
- package/common/managed/triage.ts +83 -0
- package/common/save-error-reason.ts +20 -7
- package/common/scan-server.ts +13 -0
- package/common/server-info.ts +33 -0
- package/common/utf8.d.ts +3 -0
- package/common/utf8.js +45 -0
- package/out/brotli-fallback.js +3 -3
- package/out/client-managed-import.js +81 -0
- package/out/client-managed.js +110 -0
- package/out/client-sync.js +16 -13
- package/out/graph.js +30 -4
- package/out/index.html +55 -8
- package/out/prism.js +2 -2
- package/out/stasis.svg +45 -0
- package/out/terminal.js +273 -39
- package/out/view.css +1 -1
- package/out/view.js +198 -62
- package/package.json +179 -55
- package/report/index.js +254 -0
- package/report/src/finding-id.js +80 -0
- package/report/src/finding.js +312 -0
- package/report/src/labels.js +33 -0
- package/report/src/md-structure.js +471 -0
- package/report/src/md-text.js +167 -0
- package/report/src/meta.js +76 -0
- package/report/src/parse-codex.js +147 -0
- package/report/src/parse-deepsec.js +197 -0
- package/report/src/parse-deepview-fields.js +375 -0
- package/report/src/parse-deepview-md.js +185 -0
- package/report/src/parse-md-id.js +137 -0
- package/report/src/parse-md.js +322 -0
- package/report/src/parse-piolium-id.js +79 -0
- package/report/src/parse-piolium-rows.js +131 -0
- package/report/src/parse-piolium-tokens.js +175 -0
- package/report/src/parse-piolium.js +400 -0
- package/report/src/security.js +63 -0
- package/report/src/utf8.js +21 -0
- package/report/src/write-md-finding.js +273 -0
- package/report/src/write-md.js +291 -0
- package/server-common/database-config.ts +16 -0
- package/server-common/initialize.ts +18 -0
- package/server-common/npm-advisories.ts +101 -0
- package/{server → server-common}/origin.ts +5 -5
- package/server-common/reap.ts +48 -0
- package/server-common/scan-config.ts +19 -0
- package/server-common/standalone.ts +29 -0
- package/server-common/storage-log.ts +34 -0
- package/server-common/vercel-blob.ts +110 -0
- package/server-e2e/app.ts +485 -0
- package/{server → server-e2e}/auth.ts +5 -1
- package/{server → server-e2e}/bus-receiver.ts +9 -8
- package/{server → server-e2e}/cli.js +9 -4
- package/{server → server-e2e}/config.ts +54 -39
- package/{server → server-e2e}/db-neon.ts +2 -2
- package/{server → server-e2e}/db-revision-sql.ts +7 -10
- package/{server → server-e2e}/db-stmt.ts +2 -2
- package/{server → server-e2e}/db.ts +96 -135
- package/{server → server-e2e}/http.ts +110 -12
- package/{server → server-e2e}/hub.ts +44 -14
- package/server-e2e/index.ts +17 -0
- package/server-e2e/lifecycle.ts +95 -0
- package/{server → server-e2e}/neon-driver.ts +2 -2
- package/{server → server-e2e}/npm-proxy.ts +11 -144
- package/{server → server-e2e}/objstore/blob-fs.ts +6 -8
- package/{server → server-e2e}/objstore/blob-vercel.ts +49 -141
- package/{server → server-e2e}/objstore/blob.ts +24 -9
- package/server-e2e/objstore/fetch-mint-guard.ts +74 -0
- package/{server → server-e2e}/objstore/handlers.ts +19 -20
- package/{server → server-e2e}/objstore/init.ts +53 -27
- package/{server → server-e2e}/objstore/reaper.ts +31 -11
- package/server-e2e/objstore/rest-deny.ts +28 -0
- package/server-e2e/objstore/rest-mint.ts +224 -0
- package/{server → server-e2e}/objstore/rest.ts +110 -93
- package/{server → server-e2e}/objstore/sign.ts +105 -0
- package/{server → server-e2e}/objstore/store-neon.ts +10 -14
- package/{server → server-e2e}/objstore/store.ts +98 -118
- package/{server → server-e2e}/objstore/tokens.ts +9 -12
- package/{server → server-e2e}/peer.ts +7 -9
- package/{server → server-e2e}/pubsub.ts +29 -36
- package/{server → server-e2e}/sign.ts +12 -14
- package/{server → server-e2e}/sse-server.ts +105 -73
- package/{server → server-e2e}/sse-session.ts +30 -16
- package/{server → server-e2e}/static.ts +36 -29
- package/server-e2e/sync-handlers.ts +408 -0
- package/{server → server-e2e}/util.ts +9 -0
- package/{server → server-e2e}/ws-server.ts +29 -23
- package/server-managed/activity.ts +231 -0
- package/server-managed/avatar-store.ts +51 -0
- package/server-managed/blob-store.ts +66 -0
- package/server-managed/blob-vercel.ts +125 -0
- package/server-managed/brotli.ts +10 -0
- package/server-managed/bundle-cache.ts +185 -0
- package/server-managed/bundle-catalog.ts +29 -0
- package/server-managed/bundle-store.ts +28 -0
- package/server-managed/bundle-summary-cache.ts +97 -0
- package/server-managed/bundle.ts +39 -0
- package/server-managed/cache-storage.ts +40 -0
- package/server-managed/cli.js +13 -0
- package/server-managed/combined.ts +46 -0
- package/server-managed/comments.ts +151 -0
- package/server-managed/config.ts +144 -0
- package/server-managed/content-access.ts +15 -0
- package/server-managed/crypto.ts +25 -0
- package/server-managed/db-methods.ts +1330 -0
- package/server-managed/db-neon.ts +171 -0
- package/server-managed/db-schema.ts +203 -0
- package/server-managed/db-table-names.ts +22 -0
- package/server-managed/db.ts +109 -0
- package/server-managed/github-app.ts +332 -0
- package/server-managed/github-metadata.ts +65 -0
- package/server-managed/github-oauth.ts +215 -0
- package/server-managed/github-pulls.ts +115 -0
- package/server-managed/http-response.ts +18 -0
- package/server-managed/http.ts +2043 -0
- package/server-managed/import-triage.ts +48 -0
- package/server-managed/index.ts +135 -0
- package/server-managed/public-workspace.ts +150 -0
- package/server-managed/repo-path.ts +21 -0
- package/server-managed/report-migration.ts +35 -0
- package/server-managed/report-query.ts +4 -0
- package/server-managed/report-response.ts +16 -0
- package/server-managed/report-sources.ts +154 -0
- package/server-managed/repository-discovery.ts +82 -0
- package/server-managed/repository-policy.ts +25 -0
- package/server-managed/session.ts +78 -0
- package/server-managed/slugs.ts +38 -0
- package/server-managed/sql-postgres.ts +30 -0
- package/server-managed/sql.ts +61 -0
- package/server-managed/static.ts +28 -0
- package/server-managed/storage.ts +34 -0
- package/server-managed/team-catalog.ts +7 -0
- package/server-managed/team-feed.ts +128 -0
- package/server-managed/team-reports.ts +156 -0
- package/server-managed/triage-response.ts +16 -0
- package/server-managed/uploads.ts +47 -0
- package/server-managed/workspace-shares.ts +150 -0
- package/server.ts +50 -0
- package/server/index.ts +0 -481
- package/server/lifecycle.ts +0 -204
- package/server/sync-handlers.ts +0 -327
- /package/{server → server-e2e}/config.example.json +0 -0
- /package/{server → server-e2e}/objstore/fs.ts +0 -0
- /package/{server → server-e2e}/validation.ts +0 -0
|
@@ -0,0 +1,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
|
+
}
|