@preventive/triage 1.0.0-alpha.32 → 1.0.0-alpha.34
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/client/linked-findings.js +5 -4
- package/common/bundle-metadata.js +4 -3
- package/common/bundle-source-links.d.ts +2 -1
- package/common/bundle-source-links.js +133 -6
- package/common/bundle-sources.d.ts +2 -0
- package/common/bundle-sources.js +2 -4
- package/common/managed/repository-alias.ts +65 -0
- package/common/managed/routes.js +20 -1
- package/common/managed/triage.ts +2 -2
- package/out/client-managed-import.js +51 -46
- package/out/client-managed.js +252 -72
- package/out/client-sync.js +14 -14
- package/out/graph.js +11 -11
- package/out/index.html +1 -0
- package/out/terminal.js +169 -169
- package/out/view.css +1 -1
- package/out/view.js +417 -201
- package/package.json +23 -17
- package/server-managed/activity.ts +15 -1
- package/server-managed/brotli.ts +3 -3
- package/server-managed/bundle-advisories.ts +8 -3
- package/server-managed/bundle-advisory-inventory.ts +27 -25
- package/server-managed/bundle-build-worker.js +45 -9
- package/server-managed/bundle-build.ts +35 -11
- package/server-managed/bundle-cache.ts +1 -1
- package/server-managed/bundle.ts +17 -1
- package/server-managed/config.ts +7 -2
- package/server-managed/db-methods.ts +170 -49
- package/server-managed/db-neon.ts +59 -4
- package/server-managed/db-schema.ts +12 -3
- package/server-managed/db.ts +9 -1
- package/server-managed/github-issue-links.ts +127 -0
- package/server-managed/github-issues.ts +1 -1
- package/server-managed/http-response.ts +7 -1
- package/server-managed/http.ts +248 -80
- package/server-managed/index.ts +4 -0
- package/server-managed/link-reports.ts +158 -0
- package/server-managed/managed-issues.ts +31 -3
- package/server-managed/management.ts +17 -4
- package/server-managed/object-storage-disk.ts +7 -3
- package/server-managed/object-storage-vercel.ts +6 -2
- package/server-managed/object-storage.ts +3 -2
- package/server-managed/public-workspace.ts +5 -2
- package/server-managed/report-sources.ts +6 -5
- package/server-managed/report-store.ts +47 -0
- package/server-managed/repository-aliases.ts +91 -0
- package/server-managed/revisions.ts +2 -2
- package/server-managed/sql.ts +3 -2
- package/server-managed/storage-encryption.ts +72 -6
- package/server-managed/storage-maintenance.ts +64 -49
- package/server-managed/storage-payload.ts +11 -10
- package/server-managed/storage-stores.ts +3 -1
- package/server-managed/storage.ts +17 -0
- package/server-managed/team-feed.ts +3 -3
- package/server-managed/team-reports.ts +24 -15
- package/server-managed/upstream-cache.ts +73 -0
- package/server-managed/workspace-shares.ts +16 -12
package/server-managed/index.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { type ManagedConfig, loadManagedConfig } from './config.ts'
|
|
|
12
12
|
import { type ManagedHttpDeps, createManagedRequestHandler } from './http.ts'
|
|
13
13
|
import { loadManagedStatic } from './static.ts'
|
|
14
14
|
import { openManagedStorage } from './storage.ts'
|
|
15
|
+
import { setCacheDir } from '@preventive/upstream/npm.js'
|
|
15
16
|
|
|
16
17
|
// Housekeeping runs on ordinary traffic too, including serverless instances
|
|
17
18
|
// without a timer or cron. Failed sweeps retry on later traffic after a minute.
|
|
@@ -21,6 +22,9 @@ const REAP_RETRY_MS = 60_000
|
|
|
21
22
|
type ManagedAppOptions = Partial<Pick<ManagedHttpDeps, 'next' | 'serverInfo' | 'isShuttingDown'>>
|
|
22
23
|
|
|
23
24
|
export async function createManagedApp(config: ManagedConfig, options: ManagedAppOptions = {}) {
|
|
25
|
+
// Audits keep upstream's records on disk where the config has a cache
|
|
26
|
+
// directory; bundle builds set it in their own worker.
|
|
27
|
+
setCacheDir(config.upstreamCacheDir ?? false)
|
|
24
28
|
// Load the optional peer only for serverless managed deployments. Embedding
|
|
25
29
|
// hosts can discard listener promises, so retain maintenance inside the app.
|
|
26
30
|
const waitUntil = config.serverless ? (await import('@vercel/functions')).waitUntil : undefined
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// Global deduplication reports live entirely in SQL. The payload is authenticated
|
|
2
|
+
// ciphertext bound to its row, using the deployment's existing storage key.
|
|
3
|
+
import { Buffer } from 'node:buffer'
|
|
4
|
+
import { createHash, randomUUID } from 'node:crypto'
|
|
5
|
+
import { type StorageKey, unwrapStorageValue, wrapStorageValue } from '../server-common/storage-crypto.ts'
|
|
6
|
+
import { parseLinkedFindings } from '../client/linked-findings.js'
|
|
7
|
+
import type { ManagedSql } from './sql.ts'
|
|
8
|
+
import { ManagedMutationError } from './management.ts'
|
|
9
|
+
|
|
10
|
+
export const LINK_REPORT_SCHEMA = `
|
|
11
|
+
CREATE TABLE IF NOT EXISTS managed_link_report (
|
|
12
|
+
id TEXT PRIMARY KEY, filename TEXT NOT NULL, sha256 TEXT NOT NULL UNIQUE,
|
|
13
|
+
encrypted_groups TEXT NOT NULL, enabled INTEGER NOT NULL DEFAULT 1,
|
|
14
|
+
group_count INTEGER NOT NULL, finding_count INTEGER NOT NULL,
|
|
15
|
+
uploaded_by TEXT REFERENCES managed_user(id) ON DELETE SET NULL,
|
|
16
|
+
uploaded_by_login TEXT, uploaded_at INTEGER NOT NULL
|
|
17
|
+
) STRICT;
|
|
18
|
+
`
|
|
19
|
+
export interface LinkReport {
|
|
20
|
+
id: string; filename: string; enabled: boolean; groupCount: number; findingCount: number
|
|
21
|
+
uploadedByLogin: string | null; uploadedAt: number
|
|
22
|
+
}
|
|
23
|
+
export interface LinkReportStore {
|
|
24
|
+
listLegacyLinkReports(): Promise<{ id: string; filename: string }[]>
|
|
25
|
+
migrateLinkReport(id: string, content: string): Promise<boolean>
|
|
26
|
+
listLinkReports(): Promise<LinkReport[]>
|
|
27
|
+
getLinkReport(sessionId: string, id: string): Promise<LinkReport & { groups: string[][] }>
|
|
28
|
+
getLinkRevision(): Promise<string>
|
|
29
|
+
getEnabledLinkGroups(revision: string): Promise<string[][]>
|
|
30
|
+
importLinkReport(sessionId: string, filename: string, content: string): Promise<{ report: LinkReport; reused: boolean }>
|
|
31
|
+
setLinkReportEnabled(sessionId: string, id: string, enabled: boolean): Promise<void>
|
|
32
|
+
}
|
|
33
|
+
const columns = `id, filename, enabled, group_count AS groupCount, finding_count AS findingCount,
|
|
34
|
+
uploaded_by_login AS uploadedByLogin, uploaded_at AS uploadedAt`
|
|
35
|
+
const identity = (id: string) => `managed_link_report.encrypted_groups:${id}`
|
|
36
|
+
function decryptGroups(key: StorageKey | null, id: string, payload: string): string[][] {
|
|
37
|
+
if (!key) throw new ManagedMutationError(503, 'storage-encryption-required')
|
|
38
|
+
const plain = unwrapStorageValue(key, identity(id), payload)
|
|
39
|
+
try { return JSON.parse(plain.toString('utf8')) as string[][] } finally { plain.fill(0) }
|
|
40
|
+
}
|
|
41
|
+
export async function linkRevision(db: ManagedSql): Promise<string> {
|
|
42
|
+
const rows = await db.prepare('SELECT id, sha256 FROM managed_link_report WHERE enabled = 1 ORDER BY id').all()
|
|
43
|
+
return rows.length > 0 ? createHash('sha256').update(JSON.stringify(rows)).digest('base64url') : ''
|
|
44
|
+
}
|
|
45
|
+
function parseLinkReport(content: string) {
|
|
46
|
+
const parsed = parseLinkedFindings(content)
|
|
47
|
+
if (parsed) return parsed
|
|
48
|
+
// Older managed exports may carry the normalized links envelope.
|
|
49
|
+
try {
|
|
50
|
+
const data = JSON.parse(content)
|
|
51
|
+
if (data?.source !== 'links' || !Array.isArray(data.links) || !Array.isArray(data.findings) || data.findings.length > 0) return null
|
|
52
|
+
return data.links.length === 0 ? { groups: [], skipped: 0 } : parseLinkedFindings(JSON.stringify(data.links))
|
|
53
|
+
} catch { return null }
|
|
54
|
+
}
|
|
55
|
+
export function linkReportMethods(db: ManagedSql, key: StorageKey | null): LinkReportStore {
|
|
56
|
+
const map = (row: Omit<LinkReport, 'enabled'> & { enabled: number }): LinkReport => ({ ...row, enabled: row.enabled === 1 })
|
|
57
|
+
async function authorize(sessionId: string) {
|
|
58
|
+
const user = await db.prepare(`SELECT u.id, u.login, u.role FROM managed_session s JOIN managed_user u ON u.id = s.user_id
|
|
59
|
+
WHERE s.id = ? AND s.expires_at > ?`).get(sessionId, Date.now()) as { id: string; login: string; role: string } | undefined
|
|
60
|
+
if (!user) throw new ManagedMutationError(401, 'unauthenticated')
|
|
61
|
+
if (user.role !== 'admin') throw new ManagedMutationError(403, 'forbidden')
|
|
62
|
+
return user
|
|
63
|
+
}
|
|
64
|
+
return {
|
|
65
|
+
async listLegacyLinkReports() {
|
|
66
|
+
return await db.prepare("SELECT id, filename FROM managed_report WHERE analyzer = 'links' ORDER BY id").all() as { id: string; filename: string }[]
|
|
67
|
+
},
|
|
68
|
+
async migrateLinkReport(id, content) {
|
|
69
|
+
const row = await db.prepare(`SELECT filename, sha256, visible, uploaded_by AS uploadedBy,
|
|
70
|
+
uploaded_by_login AS uploadedByLogin, uploaded_at AS uploadedAt FROM managed_report WHERE id = ? AND analyzer = 'links'`).get(id) as {
|
|
71
|
+
filename: string; sha256: string; visible: number; uploadedBy: string | null; uploadedByLogin: string | null; uploadedAt: number
|
|
72
|
+
} | undefined
|
|
73
|
+
if (!row) return false
|
|
74
|
+
if (!key) throw new Error('Managed link reports require MANAGED_STORAGE_ENCRYPTION_KEY')
|
|
75
|
+
if (createHash('sha256').update(content).digest('base64url') !== row.sha256) throw new Error('Legacy link report content changed')
|
|
76
|
+
const parsed = parseLinkReport(content)
|
|
77
|
+
if (!parsed) throw new Error('Invalid legacy link report')
|
|
78
|
+
const plain = Buffer.from(JSON.stringify(parsed.groups))
|
|
79
|
+
try {
|
|
80
|
+
const sha256 = createHash('sha256').update(plain).digest('base64url')
|
|
81
|
+
await db.prepare(`INSERT INTO managed_link_report
|
|
82
|
+
(id, filename, sha256, encrypted_groups, enabled, group_count, finding_count, uploaded_by, uploaded_by_login, uploaded_at)
|
|
83
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(sha256) DO NOTHING`).run(
|
|
84
|
+
id, row.filename, sha256, wrapStorageValue(key, identity(id), plain), row.visible,
|
|
85
|
+
parsed.groups.length, new Set(parsed.groups.flat()).size, row.uploadedBy, row.uploadedByLogin, row.uploadedAt)
|
|
86
|
+
await db.prepare('DELETE FROM managed_report WHERE id = ?').run(id)
|
|
87
|
+
} finally { plain.fill(0) }
|
|
88
|
+
return true
|
|
89
|
+
},
|
|
90
|
+
async listLinkReports() {
|
|
91
|
+
return (await db.prepare(`SELECT ${columns} FROM managed_link_report ORDER BY uploaded_at DESC, id`).all() as Parameters<typeof map>[0][]).map(map)
|
|
92
|
+
},
|
|
93
|
+
async getLinkReport(sessionId, id) {
|
|
94
|
+
await authorize(sessionId)
|
|
95
|
+
const row = await db.prepare(`SELECT ${columns}, encrypted_groups AS payload FROM managed_link_report WHERE id = ?`).get(id) as (Parameters<typeof map>[0] & { payload: string }) | undefined
|
|
96
|
+
if (!row) throw new ManagedMutationError(404, 'no-link-report')
|
|
97
|
+
const { payload, ...report } = row
|
|
98
|
+
return { ...map(report), groups: decryptGroups(key, id, payload) }
|
|
99
|
+
},
|
|
100
|
+
getLinkRevision: () => linkRevision(db),
|
|
101
|
+
async getEnabledLinkGroups(revision) {
|
|
102
|
+
if (await linkRevision(db) !== revision) throw new ManagedMutationError(409, 'deduplication-changed')
|
|
103
|
+
const rows = await db.prepare('SELECT id, encrypted_groups AS payload FROM managed_link_report WHERE enabled = 1 ORDER BY id').all() as { id: string; payload: string }[]
|
|
104
|
+
if (rows.length > 0 && !key) throw new Error('Managed link reports require MANAGED_STORAGE_ENCRYPTION_KEY')
|
|
105
|
+
return rows.flatMap(row => decryptGroups(key, row.id, row.payload))
|
|
106
|
+
},
|
|
107
|
+
async importLinkReport(sessionId, filename, content) {
|
|
108
|
+
const user = await authorize(sessionId)
|
|
109
|
+
if (!key) throw new ManagedMutationError(503, 'storage-encryption-required')
|
|
110
|
+
const parsed = parseLinkReport(content)
|
|
111
|
+
if (!parsed || parsed.skipped) throw new ManagedMutationError(400, 'invalid-link-report')
|
|
112
|
+
const plain = Buffer.from(JSON.stringify(parsed.groups))
|
|
113
|
+
const sha256 = createHash('sha256').update(plain).digest('base64url')
|
|
114
|
+
try {
|
|
115
|
+
const existing = await db.prepare(`SELECT ${columns} FROM managed_link_report WHERE sha256 = ?`).get(sha256) as Parameters<typeof map>[0] | undefined
|
|
116
|
+
if (existing) return { report: map(existing), reused: true }
|
|
117
|
+
const id = randomUUID(), uploadedAt = Date.now()
|
|
118
|
+
const findingCount = new Set(parsed.groups.flat()).size, groupCount = parsed.groups.length
|
|
119
|
+
await db.prepare(`INSERT INTO managed_link_report
|
|
120
|
+
(id, filename, sha256, encrypted_groups, group_count, finding_count, uploaded_by, uploaded_by_login, uploaded_at)
|
|
121
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(id, filename, sha256, wrapStorageValue(key, identity(id), plain), groupCount, findingCount, user.id, user.login, uploadedAt)
|
|
122
|
+
return { report: { id, filename, enabled: true, groupCount, findingCount, uploadedByLogin: user.login, uploadedAt }, reused: false }
|
|
123
|
+
} finally { plain.fill(0) }
|
|
124
|
+
},
|
|
125
|
+
async setLinkReportEnabled(sessionId, id, enabled) {
|
|
126
|
+
await authorize(sessionId)
|
|
127
|
+
if (!(await db.prepare('UPDATE managed_link_report SET enabled = ? WHERE id = ?').run(+enabled, id)).changes) throw new ManagedMutationError(404, 'no-link-report')
|
|
128
|
+
},
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Merge complete components BEFORE projecting them onto the response. An ID
|
|
133
|
+
// absent from every returned report can still bridge two visible findings.
|
|
134
|
+
export function mergeLinkGroups(groups: readonly string[][], present?: ReadonlySet<string>): string[][] {
|
|
135
|
+
const parents = new Map<string, string>()
|
|
136
|
+
const root = (id: string): string => {
|
|
137
|
+
if (!parents.has(id)) parents.set(id, id)
|
|
138
|
+
let value = id
|
|
139
|
+
while (parents.get(value) !== value) {
|
|
140
|
+
parents.set(value, parents.get(parents.get(value)!)!)
|
|
141
|
+
value = parents.get(value)!
|
|
142
|
+
}
|
|
143
|
+
return value
|
|
144
|
+
}
|
|
145
|
+
for (const ids of groups) {
|
|
146
|
+
if (ids.length === 0) continue
|
|
147
|
+
const first = root(ids[0]!)
|
|
148
|
+
for (const id of ids.slice(1)) { const other = root(id); if (other !== first) parents.set(other, first) }
|
|
149
|
+
}
|
|
150
|
+
const merged = new Map<string, string[]>()
|
|
151
|
+
for (const id of parents.keys()) {
|
|
152
|
+
if (present && !present.has(id)) continue
|
|
153
|
+
const key = root(id)
|
|
154
|
+
const ids = merged.get(key) ?? []
|
|
155
|
+
ids.push(id); merged.set(key, ids)
|
|
156
|
+
}
|
|
157
|
+
return [...merged.values()].filter(ids => ids.length >= 2)
|
|
158
|
+
}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import type { ManagedSql } from './sql.ts'
|
|
2
|
+
import type { TeamReportAccessSnapshot } from './db.ts'
|
|
3
|
+
import { parseGithubPrUrl } from '../common/github-pr.ts'
|
|
2
4
|
|
|
3
5
|
// One immutable reference per finding, shared across reports and teams. Keep
|
|
4
6
|
// reservations across crashes/uncertain GitHub responses to prevent duplicates.
|
|
@@ -10,6 +12,8 @@ CREATE TABLE IF NOT EXISTS managed_finding_issue (
|
|
|
10
12
|
repository TEXT NOT NULL,
|
|
11
13
|
request_id TEXT NOT NULL,
|
|
12
14
|
issue_url TEXT,
|
|
15
|
+
auto_fix_url TEXT,
|
|
16
|
+
auto_fix_checked_at INTEGER,
|
|
13
17
|
created_by TEXT REFERENCES managed_user(id) ON DELETE SET NULL,
|
|
14
18
|
created_at INTEGER NOT NULL
|
|
15
19
|
) STRICT;
|
|
@@ -20,24 +24,48 @@ export interface ManagedIssue {
|
|
|
20
24
|
repository: string
|
|
21
25
|
requestId: string
|
|
22
26
|
issueUrl: string | null
|
|
27
|
+
autoFixUrl: string | null
|
|
28
|
+
autoFixCheckedAt: number | null
|
|
23
29
|
createdBy: string | null
|
|
24
30
|
createdAt: number
|
|
25
31
|
}
|
|
26
32
|
export interface ManagedIssueStore {
|
|
27
33
|
getManagedIssue(findingId: string): Promise<ManagedIssue | null>
|
|
28
|
-
|
|
34
|
+
listManagedIssues(findingIds: readonly string[]): Promise<ManagedIssue[]>
|
|
35
|
+
claimManagedIssue(issue: Omit<ManagedIssue, 'issueUrl' | 'autoFixUrl' | 'autoFixCheckedAt'>): Promise<boolean>
|
|
29
36
|
finishManagedIssue(findingId: string, requestId: string, url: string): Promise<boolean>
|
|
30
37
|
releaseManagedIssue(findingId: string, requestId: string): Promise<void>
|
|
38
|
+
setManagedIssueAutoFix(findingId: string, issueUrl: string, previous: string | null, next: string | null, checkedAt: number): Promise<boolean>
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// A repeated finding ID never grants access to another team's repository.
|
|
42
|
+
export function visibleManagedIssues(issues: ManagedIssue[], snapshot: TeamReportAccessSnapshot) {
|
|
43
|
+
if (snapshot.user.id.startsWith('share:')) return []
|
|
44
|
+
const repos = new Set(snapshot.repositories.map(repo => repo.repoId))
|
|
45
|
+
const names = new Set(snapshot.repositories.map(repo => repo.github.toLowerCase()))
|
|
46
|
+
return issues.filter(issue => issue.issueUrl && repos.has(issue.repoId)).map(issue => {
|
|
47
|
+
const ref = parseGithubPrUrl(issue.autoFixUrl)
|
|
48
|
+
return { ...issue, autoFixUrl: ref && names.has(ref.repo.toLowerCase()) ? issue.autoFixUrl : null }
|
|
49
|
+
})
|
|
31
50
|
}
|
|
32
51
|
export function managedIssueMethods(db: ManagedSql): ManagedIssueStore {
|
|
33
|
-
const
|
|
34
|
-
issue_url AS issueUrl,
|
|
52
|
+
const fields = `finding_id AS findingId, repo_id AS repoId, repository, request_id AS requestId,
|
|
53
|
+
issue_url AS issueUrl, auto_fix_url AS autoFixUrl, auto_fix_checked_at AS autoFixCheckedAt, created_by AS createdBy, created_at AS createdAt`
|
|
54
|
+
const get = db.prepare(`SELECT ${fields} FROM managed_finding_issue WHERE finding_id = ?`)
|
|
55
|
+
const list = db.prepare(`SELECT ${fields} FROM managed_finding_issue WHERE finding_id IN (SELECT value FROM json_each(?)) ORDER BY finding_id`)
|
|
56
|
+
const autoFix = db.prepare(`UPDATE managed_finding_issue SET auto_fix_url = ?, auto_fix_checked_at = ?
|
|
57
|
+
WHERE finding_id = ? AND issue_url = ? AND COALESCE(auto_fix_url, '') = COALESCE(?, '')
|
|
58
|
+
AND (auto_fix_checked_at IS NULL OR auto_fix_checked_at <= ?)`)
|
|
35
59
|
const claim = db.prepare(`INSERT INTO managed_finding_issue (finding_id, repo_id, repository, request_id, created_by, created_at)
|
|
36
60
|
VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT (finding_id) DO NOTHING`)
|
|
37
61
|
const finish = db.prepare(`UPDATE managed_finding_issue SET issue_url = ? WHERE finding_id = ? AND request_id = ? AND issue_url IS NULL`)
|
|
38
62
|
const release = db.prepare(`DELETE FROM managed_finding_issue WHERE finding_id = ? AND request_id = ? AND issue_url IS NULL`)
|
|
39
63
|
return {
|
|
40
64
|
async getManagedIssue(findingId) { return (await get.get(findingId) as ManagedIssue | undefined) ?? null },
|
|
65
|
+
async listManagedIssues(ids) { return await list.all(JSON.stringify(ids)) as ManagedIssue[] },
|
|
66
|
+
async setManagedIssueAutoFix(findingId, issueUrl, previous, next, checkedAt) {
|
|
67
|
+
return Number((await autoFix.run(next, checkedAt, findingId, issueUrl, previous, checkedAt)).changes) > 0
|
|
68
|
+
},
|
|
41
69
|
async claimManagedIssue(issue) {
|
|
42
70
|
return Number((await claim.run(issue.findingId, issue.repoId, issue.repository, issue.requestId, issue.createdBy, issue.createdAt)).changes) > 0
|
|
43
71
|
},
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { AdminReport, ManagedBundle, ManagedDb, ManagedRepo, RepoDataItem, RepoReportItem, ReportRecord, StoredUser } from './db-methods.ts'
|
|
1
|
+
import type { AdminReport, BundleReuse, ManagedBundle, ManagedDb, ManagedRepo, RepoDataItem, RepoReportItem, ReportRecord, StoredUser } from './db-methods.ts'
|
|
2
2
|
import { roleAtLeast } from '../common/managed/roles.ts'
|
|
3
3
|
|
|
4
4
|
export class ManagedMutationError extends Error {
|
|
@@ -6,20 +6,23 @@ export class ManagedMutationError extends Error {
|
|
|
6
6
|
constructor(status: number, message: string) { super(message); this.status = status }
|
|
7
7
|
}
|
|
8
8
|
|
|
9
|
-
export type ContentMutation = { type: 'delete' } | { type: 'repo'; repoId: number | null; directory: string }
|
|
10
|
-
export type ReportMutation = ContentMutation
|
|
9
|
+
export type ContentMutation = { type: 'delete' } | { type: 'repo'; repoId: number | null; directory: string } | { type: 'visibility'; visible: boolean }
|
|
10
|
+
export type ReportMutation = ContentMutation
|
|
11
11
|
type RemovalRepo = Pick<ManagedRepo, 'repoId' | 'fullName' | 'addedAt'>
|
|
12
12
|
type RemovalAnnotations = { reports: string; ids: string[] }
|
|
13
13
|
export interface ManagementStore {
|
|
14
14
|
mutateReport(sessionId: string, id: string, change: ReportMutation): Promise<{ report: ReportRecord; user: StoredUser }>
|
|
15
15
|
mutateBundle(sessionId: string, id: string, change: ContentMutation): Promise<{ bundle: ManagedBundle; user: StoredUser }>
|
|
16
|
+
// A repeated upload/build of stored bytes (see reuseBundle). Renames need the
|
|
17
|
+
// access other bundle mutations need; a server build labels the row anyway.
|
|
18
|
+
reuseBundleUpload(sessionId: string, id: string, reuse: Omit<BundleReuse, 'rename'>): Promise<boolean>
|
|
16
19
|
removeRepository(sessionId: string, expected: RemovalRepo, annotations: RemovalAnnotations | null): Promise<{
|
|
17
20
|
reports: RepoReportItem[]; bundles: RepoDataItem[]; deletedReports: number; deletedBundles: number; deletedTriage: number
|
|
18
21
|
}>
|
|
19
22
|
}
|
|
20
23
|
type ManagementDb = Pick<ManagedDb, 'sessionWithUser' | 'getReport' | 'getBundle' | 'listReports' | 'listReportsForRepo'
|
|
21
24
|
| 'listBundlesForRepo' | 'listAllRepos' | 'listSelectedRepos' | 'userCanReadReport' | 'userCanReadBundle' | 'userCanReadRepoPath'
|
|
22
|
-
| 'deleteReport' | 'setReportRepo' | 'setReportVisible' | 'deleteBundle' | 'setBundleRepo'
|
|
25
|
+
| 'deleteReport' | 'setReportRepo' | 'setReportVisible' | 'deleteBundle' | 'setBundleRepo' | 'setBundleVisible' | 'reuseBundle'
|
|
23
26
|
| 'deleteReportsForRepo' | 'deleteBundlesForRepo' | 'deleteRepo' | 'deleteTriage' | 'recordActivity'>
|
|
24
27
|
|
|
25
28
|
// Only immutable bytes/format and repository membership affect the overlap
|
|
@@ -63,9 +66,19 @@ export function managementMethods(db: ManagementDb): ManagementStore {
|
|
|
63
66
|
if (user.role !== 'admin' && bundle.repoId !== null && !await db.userCanReadRepoPath(user.id, bundle.repoId, bundle.repoDirectory)) throw new ManagedMutationError(403, 'repo-forbidden')
|
|
64
67
|
await destination(user, change)
|
|
65
68
|
if (change.type === 'delete') await db.deleteBundle(id)
|
|
69
|
+
else if (change.type === 'visibility') await db.setBundleVisible(id, change.visible)
|
|
66
70
|
else await db.setBundleRepo(id, change.repoId, change.directory)
|
|
67
71
|
return { bundle, user }
|
|
68
72
|
},
|
|
73
|
+
async reuseBundleUpload(sessionId: string, id: string, reuse: Omit<BundleReuse, 'rename'>) {
|
|
74
|
+
const user = await authorize(sessionId, 'manage')
|
|
75
|
+
const bundle = await db.getBundle(id)
|
|
76
|
+
if (!bundle) return false
|
|
77
|
+
const rename = user.role === 'admin' || (await db.userCanReadBundle(user.id, id)
|
|
78
|
+
&& (bundle.repoId === null || await db.userCanReadRepoPath(user.id, bundle.repoId, bundle.repoDirectory)))
|
|
79
|
+
if (!rename && reuse.provenance !== 'build') return false
|
|
80
|
+
return db.reuseBundle(id, { ...reuse, rename })
|
|
81
|
+
},
|
|
69
82
|
async removeRepository(sessionId: string, expected: RemovalRepo, annotations: RemovalAnnotations | null) {
|
|
70
83
|
const user = await authorize(sessionId, 'admin')
|
|
71
84
|
const repo = (await db.listAllRepos()).find(r => r.repoId === expected.repoId)
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto'
|
|
2
|
-
import { lstat, mkdir, open, readdir, rename, rm, rmdir, stat } from 'node:fs/promises'
|
|
2
|
+
import { link, lstat, mkdir, open, readdir, rename, rm, rmdir, stat } from 'node:fs/promises'
|
|
3
3
|
import { dirname, join } from 'node:path'
|
|
4
4
|
import { type ObjectPage, type RawObjectStorage, objectPath } from './object-storage.ts'
|
|
5
5
|
|
|
@@ -100,11 +100,15 @@ export function createDiskObjectStorage(dir: string): RawObjectStorage {
|
|
|
100
100
|
await file.writeFile(bytes, signal ? { signal } : {})
|
|
101
101
|
await file.sync(); await file.close()
|
|
102
102
|
signal?.throwIfAborted()
|
|
103
|
-
if (expected
|
|
103
|
+
if (expected === null) {
|
|
104
|
+
try { await link(temp, target) }
|
|
105
|
+
catch (err) { if ((err as NodeJS.ErrnoException).code === 'EEXIST') return false; throw err }
|
|
106
|
+
} else if (expected === undefined) await rename(temp, target)
|
|
107
|
+
else {
|
|
104
108
|
try { if (version(await stat(target)) !== expected) return false }
|
|
105
109
|
catch (err) { if (missing(err)) return false; throw err }
|
|
110
|
+
await rename(temp, target)
|
|
106
111
|
}
|
|
107
|
-
await rename(temp, target)
|
|
108
112
|
await syncDirectory(dirname(target))
|
|
109
113
|
return true
|
|
110
114
|
} finally { await file.close(); await rm(temp, { force: true }) }
|
|
@@ -6,7 +6,7 @@ import { type RawObjectStorage, objectPath } from './object-storage.ts'
|
|
|
6
6
|
export async function openVercelObjectStorage(token: string, sdk?: VercelBlobSdk): Promise<RawObjectStorage> {
|
|
7
7
|
const blobs = sdk ?? await loadVercelBlobSdk()
|
|
8
8
|
const path = (key: string) => `.managed/${objectPath(key)}`
|
|
9
|
-
|
|
9
|
+
const storage: RawObjectStorage = {
|
|
10
10
|
async head(key, signal) {
|
|
11
11
|
try {
|
|
12
12
|
const meta = await blobs.head(path(key), { token, ...(signal ? { abortSignal: signal } : {}) })
|
|
@@ -32,11 +32,14 @@ export async function openVercelObjectStorage(token: string, sdk?: VercelBlobSdk
|
|
|
32
32
|
},
|
|
33
33
|
async put(key, bytes, signal, expected, sizeHint) {
|
|
34
34
|
const multipart = (sizeHint ?? (Buffer.isBuffer(bytes) ? bytes.length : Infinity)) >= 5 * 1024 * 1024
|
|
35
|
-
try { await blobs.put(path(key), bytes, { token, access: 'private', addRandomSuffix: false, allowOverwrite:
|
|
35
|
+
try { await blobs.put(path(key), bytes, { token, access: 'private', addRandomSuffix: false, allowOverwrite: expected !== null,
|
|
36
36
|
...(expected ? { ifMatch: expected } : {}), multipart, contentType: 'application/octet-stream', cacheControlMaxAge: 60, ...(signal ? { abortSignal: signal } : {}) })
|
|
37
37
|
return true
|
|
38
38
|
} catch (err) {
|
|
39
39
|
if (blobs.BlobPreconditionFailedError && err instanceof blobs.BlobPreconditionFailedError) return false
|
|
40
|
+
// The SDK uses a generic error for an occupied pathname. Reconcile a
|
|
41
|
+
// create-only failure against storage; the caller verifies the winner.
|
|
42
|
+
if (expected === null && await storage.head(key, signal)) return false
|
|
40
43
|
throw err
|
|
41
44
|
}
|
|
42
45
|
},
|
|
@@ -60,4 +63,5 @@ export async function openVercelObjectStorage(token: string, sdk?: VercelBlobSdk
|
|
|
60
63
|
cursor: page.hasMore ? page.cursor! : null }
|
|
61
64
|
},
|
|
62
65
|
}
|
|
66
|
+
return storage
|
|
63
67
|
}
|
|
@@ -18,7 +18,8 @@ export interface RawObjectStorage {
|
|
|
18
18
|
// A plaintext size hint selects multipart uploads without buffering streams.
|
|
19
19
|
// Publish atomically only after consuming the body through clean EOF; a body
|
|
20
20
|
// error must preserve the existing object (migration verifies while streaming).
|
|
21
|
-
|
|
21
|
+
// A null expected version creates only if absent, without replacing a winner.
|
|
22
|
+
put(key: string, bytes: Buffer | Readable, signal?: AbortSignal, expected?: string | null, sizeHint?: number): Promise<boolean>
|
|
22
23
|
// Return false if the original version changed; do not delete its replacement.
|
|
23
24
|
delete(key: string, version?: string, signal?: AbortSignal): Promise<boolean>
|
|
24
25
|
list(prefix: string, cursor: string | null, limit: number, signal?: AbortSignal): Promise<ObjectPage>
|
|
@@ -27,7 +28,7 @@ export interface ObjectStorage {
|
|
|
27
28
|
open(key: string): Promise<OpenedBlob | null>
|
|
28
29
|
get(key: string): Promise<Buffer | null>
|
|
29
30
|
exists(key: string): Promise<boolean>
|
|
30
|
-
put(key: string, bytes: Buffer): Promise<string | null>
|
|
31
|
+
put(key: string, bytes: Buffer, options?: { replaceReport?: boolean }): Promise<string | null>
|
|
31
32
|
delete(key: string): Promise<void>
|
|
32
33
|
deletePrefix(prefix: string): Promise<void>
|
|
33
34
|
}
|
|
@@ -12,6 +12,7 @@ import { triageWireEntry } from './triage-response.ts'
|
|
|
12
12
|
import { MAX_PACKAGE_INVENTORY_BYTES } from './bundle-cache.ts'
|
|
13
13
|
import { backfillBundleSummaries, bundleSummaries } from './bundle-catalog.ts'
|
|
14
14
|
import { ADVISORIES_TIMEOUT_MS, fetchBundleAdvisories } from './bundle-advisories.ts'
|
|
15
|
+
import { auditCache } from './upstream-cache.ts'
|
|
15
16
|
import { serveTeamFeed } from './team-feed.ts'
|
|
16
17
|
|
|
17
18
|
function json(res: ServerResponse, status: number, body: unknown): void {
|
|
@@ -114,8 +115,10 @@ async function serveBundle(res: ServerResponse, deps: ManagedHttpDeps, bundle: M
|
|
|
114
115
|
if (inventory === null) { json(res, 413, { error: 'payload-too-large' }); return }
|
|
115
116
|
if (inventory === undefined) { json(res, 400, { error: 'unknown-reason' }); return }
|
|
116
117
|
if (Buffer.byteLength(JSON.stringify(inventory)) > MAX_PACKAGE_INVENTORY_BYTES) { json(res, 413, { error: 'payload-too-large' }); return }
|
|
117
|
-
const
|
|
118
|
-
|
|
118
|
+
const signal = AbortSignal.timeout(ADVISORIES_TIMEOUT_MS)
|
|
119
|
+
const result = await fetchBundleAdvisories(inventory.packages, signal, {
|
|
120
|
+
debug: deps.config.debug, repoAdvisories: url.searchParams.get('repoAdvisories') === 'true', details: url.searchParams.get('details') === 'true',
|
|
121
|
+
cache: auditCache(deps.config.upstreamCacheDir, deps.db, signal, deps.config.debug),
|
|
119
122
|
})
|
|
120
123
|
await recheck()
|
|
121
124
|
json(res, result.status, result.status === 200 ? { ...inventory, advisories: result.body } : result.body); return
|
|
@@ -3,8 +3,8 @@ import { createHash } from 'node:crypto'
|
|
|
3
3
|
import { extname } from 'node:path'
|
|
4
4
|
import { promisify } from 'node:util'
|
|
5
5
|
import { gzip } from 'node:zlib'
|
|
6
|
-
import { bundleSourcesAsMap } from '../common/bundle-sources.js'
|
|
7
|
-
import {
|
|
6
|
+
import { bundlePackageDirs, bundleSourcesAsMap } from '../common/bundle-sources.js'
|
|
7
|
+
import { bundleSourceLinkImports } from '../common/bundle-source-links.js'
|
|
8
8
|
import type { BundleDetails } from '../common/bundle-metadata.js'
|
|
9
9
|
import { loadManagedFindings, managedFindingSourcePaths } from '../common/managed/report-content.ts'
|
|
10
10
|
import { type ViewerPermissions, filterReportContent } from '../common/managed/report-filter.ts'
|
|
@@ -29,7 +29,7 @@ function formatDirectory(bundleId: string, sha256: string, name: string) {
|
|
|
29
29
|
}
|
|
30
30
|
function sourceCacheFilename(report: ReportRecord, bundle: ManagedBundle, permissions: ViewerPermissions, repo: { github: string | null }, sourcePaths?: Set<string>) {
|
|
31
31
|
const key = createHash('sha256').update(JSON.stringify([
|
|
32
|
-
'finding-access-
|
|
32
|
+
'finding-access-v8', sourcePaths ? [...sourcePaths].toSorted() : null, bundle.integrity, bundle.kind, permissions.dependencies, permissions.security, repo,
|
|
33
33
|
])).digest('hex')
|
|
34
34
|
return `${formatDirectory(bundle.id, report.sha256, report.filename)}/${key}.json.gz`
|
|
35
35
|
}
|
|
@@ -63,10 +63,11 @@ function selectSources(sourcePaths: Set<string>, sources: Map<string, string>) {
|
|
|
63
63
|
|
|
64
64
|
function encodeSources(integrity: string, details: BundleDetails, selection: ReturnType<typeof selectSources>) {
|
|
65
65
|
const sources = new Map(selection.files)
|
|
66
|
-
const imports = [...
|
|
66
|
+
const imports = [...bundleSourceLinkImports(details, sources)].map(([parent, targets]) => [parent, [...targets]])
|
|
67
67
|
const bundle = details.kind === 'stasis' ? details.bundle as { formats: Map<string, string> } : null
|
|
68
68
|
const formats = [...(bundle?.formats ?? [])].filter(([file]) => sources.has(file))
|
|
69
|
-
|
|
69
|
+
const packageDirs = [...(bundlePackageDirs(details) ?? [])].filter(([file]) => sources.has(file))
|
|
70
|
+
return compress(Buffer.from(JSON.stringify({ integrity, ...selection, imports, formats, packageDirs })), { level: 6 })
|
|
70
71
|
}
|
|
71
72
|
|
|
72
73
|
// Immutable report hashes share a derivative across duplicate uploads. Bundle
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// Compress before the object-storage encryption boundary. Separate paths let
|
|
2
|
+
// legacy arbitrary report bytes remain readable without format sniffing.
|
|
3
|
+
import { Buffer } from 'node:buffer'
|
|
4
|
+
import { Readable } from 'node:stream'
|
|
5
|
+
import { pipeline } from 'node:stream/promises'
|
|
6
|
+
import { createBrotliDecompress } from 'node:zlib'
|
|
7
|
+
import type { BlobStore, OpenedBlob } from './blob-store.ts'
|
|
8
|
+
import { encodeBrotli } from './brotli.ts'
|
|
9
|
+
|
|
10
|
+
async function readBytes(stored: OpenedBlob): Promise<Buffer> {
|
|
11
|
+
const parts: Buffer[] = []
|
|
12
|
+
try {
|
|
13
|
+
for await (const part of stored.stream) parts.push(Buffer.from(part))
|
|
14
|
+
return Buffer.concat(parts)
|
|
15
|
+
} finally { stored.stream.destroy() }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function createReportStore(legacy: BlobStore, compressed: BlobStore,
|
|
19
|
+
replaceLegacy: (id: string, bytes: Buffer) => Promise<unknown>): BlobStore {
|
|
20
|
+
async function open(id: string): Promise<OpenedBlob | null> {
|
|
21
|
+
// Keep the original available until conversion succeeds. Concurrent reads
|
|
22
|
+
// can finish using an already-open stream while another reader converts it.
|
|
23
|
+
const original = await legacy.open(id)
|
|
24
|
+
if (original) {
|
|
25
|
+
const bytes = await readBytes(original)
|
|
26
|
+
await replaceLegacy(id, await encodeBrotli(bytes, 9))
|
|
27
|
+
return { size: bytes.length, stream: Readable.from([bytes]) }
|
|
28
|
+
}
|
|
29
|
+
const stored = await compressed.open(id)
|
|
30
|
+
if (!stored) return null
|
|
31
|
+
const stream = createBrotliDecompress()
|
|
32
|
+
// pipeline carries storage/authentication errors into the decoded stream
|
|
33
|
+
// and closes the stored source when a reader leaves before EOF.
|
|
34
|
+
void pipeline(stored.stream, stream).catch(() => {})
|
|
35
|
+
return { size: null, stream }
|
|
36
|
+
}
|
|
37
|
+
return {
|
|
38
|
+
put: async (id, bytes) => compressed.put(id, await encodeBrotli(bytes, 9)),
|
|
39
|
+
open,
|
|
40
|
+
async get(id) {
|
|
41
|
+
const stored = await open(id)
|
|
42
|
+
if (!stored) return null
|
|
43
|
+
return readBytes(stored)
|
|
44
|
+
},
|
|
45
|
+
async delete(id) { await Promise.all([compressed.delete(id), legacy.delete(id)]) },
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto'
|
|
2
|
+
import { reportRepoGithub } from '@preventive/report'
|
|
3
|
+
import { type RepositoryAliasInput, matchRepositoryAlias, repositoryAliasNeedsFilePrefix } from '../common/managed/repository-alias.ts'
|
|
4
|
+
import type { ManagedDb } from './db.ts'
|
|
5
|
+
import { ManagedMutationError } from './management.ts'
|
|
6
|
+
import { normalizeTeamPath } from './repo-path.ts'
|
|
7
|
+
import type { ManagedSql } from './sql.ts'
|
|
8
|
+
|
|
9
|
+
export const REPOSITORY_ALIAS_SCHEMA = `
|
|
10
|
+
CREATE TABLE IF NOT EXISTS managed_repository_alias (
|
|
11
|
+
id TEXT PRIMARY KEY,
|
|
12
|
+
old_repo TEXT NOT NULL,
|
|
13
|
+
old_path TEXT NOT NULL DEFAULT '',
|
|
14
|
+
repo_id INTEGER NOT NULL REFERENCES managed_selected_repo(repo_id) ON DELETE CASCADE,
|
|
15
|
+
new_path TEXT NOT NULL DEFAULT '',
|
|
16
|
+
UNIQUE(old_repo, old_path)
|
|
17
|
+
) STRICT;
|
|
18
|
+
`
|
|
19
|
+
export interface RepositoryAlias extends RepositoryAliasInput { id: string }
|
|
20
|
+
export interface RepositoryAliasStore {
|
|
21
|
+
listRepositoryAliases(): Promise<RepositoryAlias[]>
|
|
22
|
+
saveRepositoryAlias(sessionId: string, id: string | null, input: unknown): Promise<RepositoryAlias>
|
|
23
|
+
deleteRepositoryAlias(sessionId: string, id: string): Promise<void>
|
|
24
|
+
getRepositoryImportLocation(github: string, directory: string, filePrefix?: string): Promise<{ repoId: number | null; directory: string }>
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Keep expensive inventory reads outside database transactions and skip them
|
|
28
|
+
// unless a currently connected alias can use the bundle's existing paths.
|
|
29
|
+
export async function resolveRepositoryImportLocation(db: ManagedDb, github: string, directory: string, loadFilePrefix: () => Promise<string>) {
|
|
30
|
+
const aliases = await db.listRepositoryAliases()
|
|
31
|
+
let filePrefix = ''
|
|
32
|
+
if (repositoryAliasNeedsFilePrefix(github, directory, aliases)) {
|
|
33
|
+
const active = new Set((await db.listSelectedRepos()).map(repo => repo.repoId))
|
|
34
|
+
if (repositoryAliasNeedsFilePrefix(github, directory, aliases.filter(alias => active.has(alias.repoId)))) filePrefix = await loadFilePrefix()
|
|
35
|
+
}
|
|
36
|
+
return db.getRepositoryImportLocation(github, directory, filePrefix)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function parseAlias(input: unknown): RepositoryAliasInput {
|
|
40
|
+
if (!input || typeof input !== 'object') throw new ManagedMutationError(400, 'bad-alias')
|
|
41
|
+
const { oldRepo, oldPath, repoId, newPath } = input as RepositoryAliasInput
|
|
42
|
+
const github = typeof oldRepo === 'string' ? reportRepoGithub({ repo: { github: oldRepo.trim() } }) : null
|
|
43
|
+
if (!github) throw new ManagedMutationError(400, 'bad-old-repo')
|
|
44
|
+
if (typeof oldPath !== 'string' || typeof newPath !== 'string') throw new ManagedMutationError(400, 'bad-directory')
|
|
45
|
+
const old = normalizeTeamPath(oldPath), target = normalizeTeamPath(newPath)
|
|
46
|
+
if (!old.ok || !target.ok) throw new ManagedMutationError(400, 'bad-directory')
|
|
47
|
+
if (!Number.isSafeInteger(repoId) || repoId <= 0) throw new ManagedMutationError(400, 'bad-repo')
|
|
48
|
+
return { oldRepo: github.toLowerCase(), oldPath: old.path ?? '', repoId, newPath: target.path ?? '' }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function repositoryAliasMethods(db: ManagedSql): RepositoryAliasStore {
|
|
52
|
+
async function authorize(sessionId: string) {
|
|
53
|
+
const user = await db.prepare(`SELECT u.role FROM managed_session s JOIN managed_user u ON u.id = s.user_id
|
|
54
|
+
WHERE s.id = ? AND s.expires_at > ?`).get(sessionId, Date.now()) as { role: string } | undefined
|
|
55
|
+
if (!user) throw new ManagedMutationError(401, 'unauthenticated')
|
|
56
|
+
if (user.role !== 'admin') throw new ManagedMutationError(403, 'forbidden')
|
|
57
|
+
}
|
|
58
|
+
async function listRepositoryAliases() {
|
|
59
|
+
return await db.prepare(`SELECT id, old_repo AS oldRepo, old_path AS oldPath, repo_id AS repoId, new_path AS newPath
|
|
60
|
+
FROM managed_repository_alias ORDER BY old_repo, old_path, id`).all() as RepositoryAlias[]
|
|
61
|
+
}
|
|
62
|
+
return {
|
|
63
|
+
listRepositoryAliases,
|
|
64
|
+
async saveRepositoryAlias(sessionId, id, input) {
|
|
65
|
+
await authorize(sessionId)
|
|
66
|
+
const alias = parseAlias(input)
|
|
67
|
+
if (!await db.prepare('SELECT repo_id FROM managed_selected_repo WHERE repo_id = ? AND active = 1').get(alias.repoId)) throw new ManagedMutationError(400, 'bad-repo')
|
|
68
|
+
if (id && !await db.prepare('SELECT id FROM managed_repository_alias WHERE id = ?').get(id)) throw new ManagedMutationError(404, 'no-alias')
|
|
69
|
+
const duplicate = await db.prepare('SELECT id FROM managed_repository_alias WHERE old_repo = ? AND old_path = ?').get(alias.oldRepo, alias.oldPath) as { id: string } | undefined
|
|
70
|
+
if (duplicate && duplicate.id !== id) throw new ManagedMutationError(409, 'alias-exists')
|
|
71
|
+
const key = id ?? randomUUID()
|
|
72
|
+
await db.prepare(`INSERT INTO managed_repository_alias (id, old_repo, old_path, repo_id, new_path) VALUES (?, ?, ?, ?, ?)
|
|
73
|
+
ON CONFLICT(id) DO UPDATE SET old_repo = excluded.old_repo, old_path = excluded.old_path, repo_id = excluded.repo_id, new_path = excluded.new_path`)
|
|
74
|
+
.run(key, alias.oldRepo, alias.oldPath, alias.repoId, alias.newPath)
|
|
75
|
+
return { id: key, ...alias }
|
|
76
|
+
},
|
|
77
|
+
async deleteRepositoryAlias(sessionId, id) {
|
|
78
|
+
await authorize(sessionId)
|
|
79
|
+
if (!(await db.prepare('DELETE FROM managed_repository_alias WHERE id = ?').run(id)).changes) throw new ManagedMutationError(404, 'no-alias')
|
|
80
|
+
},
|
|
81
|
+
async getRepositoryImportLocation(github, directory, filePrefix = '') {
|
|
82
|
+
const alias = matchRepositoryAlias(github, directory, await listRepositoryAliases(), filePrefix)
|
|
83
|
+
const normalized = normalizeTeamPath(alias?.directory ?? directory)
|
|
84
|
+
if (!normalized.ok) throw new ManagedMutationError(400, 'bad-directory')
|
|
85
|
+
const repo = await (alias
|
|
86
|
+
? db.prepare('SELECT repo_id AS repoId FROM managed_selected_repo WHERE active = 1 AND repo_id = ?').get(alias.repoId)
|
|
87
|
+
: db.prepare('SELECT repo_id AS repoId FROM managed_selected_repo WHERE active = 1 AND LOWER(full_name) = ?').get(github.toLowerCase())) as { repoId: number } | undefined
|
|
88
|
+
return { repoId: repo?.repoId ?? null, directory: normalized.path ?? '' }
|
|
89
|
+
},
|
|
90
|
+
}
|
|
91
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Internal invalidation counters. Never expose these global values to clients:
|
|
2
2
|
// feeds still publish only the existing user/visibility-scoped revisions.
|
|
3
|
-
const catalogTables = ['managed_team', 'managed_team_user', 'managed_team_repo', 'managed_report', 'managed_bundle', 'managed_selected_repo']
|
|
4
|
-
const annotationTables = ['managed_finding_triage', 'managed_finding_triage_event', 'managed_finding_comment']
|
|
3
|
+
const catalogTables = ['managed_link_report', 'managed_team', 'managed_team_user', 'managed_team_repo', 'managed_report', 'managed_bundle', 'managed_selected_repo']
|
|
4
|
+
const annotationTables = ['managed_finding_triage', 'managed_finding_triage_event', 'managed_finding_comment', 'managed_finding_issue']
|
|
5
5
|
export function revisionSchema(postgres = false): string {
|
|
6
6
|
let sql = `CREATE TABLE IF NOT EXISTS managed_maintenance_lease (
|
|
7
7
|
id INTEGER PRIMARY KEY, owner TEXT NOT NULL, expires_at INTEGER NOT NULL
|
package/server-managed/sql.ts
CHANGED
|
@@ -18,10 +18,11 @@ export interface ManagedSqlDriver extends ManagedSql {
|
|
|
18
18
|
|
|
19
19
|
// These operations execute exactly one SQL statement. Postgres already gives
|
|
20
20
|
// that statement an atomic snapshot; an explicit BEGIN/COMMIT adds only trips.
|
|
21
|
-
// Lease writes coordinate through their conditional UPSERT/UPDATE alone.
|
|
21
|
+
// Lease and cache writes coordinate through their conditional UPSERT/UPDATE alone.
|
|
22
22
|
const SINGLE_STATEMENTS = new Set(['getReport', 'getBundle', 'getStorageRow', 'getStorageEncryption',
|
|
23
23
|
'listReports', 'listBundles', 'listReadableBundleIds', 'listAllRepos', 'listSelectedRepos', 'listRepoScopesForUser', 'listTriage',
|
|
24
|
-
'getFeedState', 'getWorkspaceShareFeedState', 'claimMaintenanceLease', 'finishMaintenanceLease', 'cacheRepoDefaultBranch', 'releaseBundleBuildLease'
|
|
24
|
+
'getFeedState', 'getWorkspaceShareFeedState', 'claimMaintenanceLease', 'finishMaintenanceLease', 'cacheRepoDefaultBranch', 'releaseBundleBuildLease',
|
|
25
|
+
'getUpstreamCacheEntry', 'setUpstreamCacheEntry'])
|
|
25
26
|
|
|
26
27
|
export function scopeManagedMethods(methods: ManagedDb, driver: ManagedSqlDriver): ManagedDb {
|
|
27
28
|
const entries = Object.entries(methods).map(([name, method]) => {
|