@preventive/triage 1.0.0-alpha.3 → 1.0.0-alpha.31
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/LICENSE-APACHE +202 -0
- 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-commit.d.ts +1 -0
- package/common/bundle-commit.js +4 -0
- package/common/bundle-metadata.d.ts +10 -0
- package/common/bundle-metadata.js +182 -0
- package/common/bundle-reasons.d.ts +2 -0
- package/common/bundle-reasons.js +21 -0
- package/common/bundle-source-links.d.ts +5 -0
- package/common/bundle-source-links.js +82 -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 +51 -0
- package/common/default-scan-models.ts +30 -0
- package/common/finding-id.js +47 -0
- package/common/github-issue-labels.d.ts +1 -0
- package/common/github-issue-labels.js +11 -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 +124 -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 +35 -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 +83 -0
- package/out/client-managed.js +169 -0
- package/out/client-sync.js +16 -13
- package/out/github-issue-authorized.html +3 -0
- package/out/github-issue-authorized.js +1 -0
- package/out/graph.js +30 -4
- package/out/index.html +55 -8
- package/out/prism.js +2 -2
- package/out/stasis.svg +20 -0
- package/out/terminal.js +273 -39
- package/out/view.css +1 -1
- package/out/view.js +259 -63
- package/package.json +205 -57
- 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-crypto.ts +182 -0
- package/server-common/storage-log.ts +38 -0
- package/server-common/vercel-blob.ts +103 -0
- package/server-e2e/app.ts +486 -0
- package/{server → server-e2e}/auth.ts +5 -1
- package/{server → server-e2e}/bus-receiver.ts +2 -1
- package/{server → server-e2e}/cli.js +9 -4
- package/{server → server-e2e}/config.ts +56 -35
- package/{server → server-e2e}/db-neon.ts +2 -2
- package/{server → server-e2e}/db-revision-sql.ts +1 -1
- package/{server → server-e2e}/db-stmt.ts +2 -2
- package/{server → server-e2e}/db.ts +7 -7
- package/{server → server-e2e}/http.ts +110 -12
- package/{server → server-e2e}/hub.ts +40 -9
- 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 +2 -2
- package/{server → server-e2e}/objstore/blob-vercel.ts +13 -117
- package/{server → server-e2e}/objstore/blob.ts +9 -2
- package/server-e2e/objstore/fetch-mint-guard.ts +74 -0
- package/{server → server-e2e}/objstore/handlers.ts +9 -8
- package/{server → server-e2e}/objstore/init.ts +35 -18
- 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 +58 -33
- package/{server → server-e2e}/objstore/sign.ts +105 -0
- package/{server → server-e2e}/objstore/store-neon.ts +6 -10
- package/{server → server-e2e}/objstore/store.ts +7 -7
- package/{server → server-e2e}/objstore/tokens.ts +1 -1
- package/{server → server-e2e}/pubsub.ts +12 -9
- package/{server → server-e2e}/sign.ts +4 -4
- package/{server → server-e2e}/sse-server.ts +102 -68
- package/{server → server-e2e}/sse-session.ts +28 -8
- package/{server → server-e2e}/static.ts +31 -21
- package/server-e2e/sync-handlers.ts +408 -0
- package/{server → server-e2e}/util.ts +9 -0
- package/{server → server-e2e}/ws-server.ts +20 -9
- package/server-managed/activity.ts +231 -0
- package/server-managed/avatar-store.ts +12 -0
- package/server-managed/blob-store.ts +17 -0
- package/server-managed/brotli.ts +10 -0
- package/server-managed/bundle-advisories.ts +53 -0
- package/server-managed/bundle-advisory-inventory.ts +75 -0
- package/server-managed/bundle-build-diagnostics.d.ts +10 -0
- package/server-managed/bundle-build-diagnostics.js +23 -0
- package/server-managed/bundle-build-leases.ts +41 -0
- package/server-managed/bundle-build-worker.js +54 -0
- package/server-managed/bundle-build.ts +168 -0
- package/server-managed/bundle-cache.ts +166 -0
- package/server-managed/bundle-catalog.ts +29 -0
- package/server-managed/bundle-store.ts +24 -0
- package/server-managed/bundle-summary-cache.ts +116 -0
- package/server-managed/bundle.ts +104 -0
- package/server-managed/cache-storage.ts +19 -0
- package/server-managed/cli.js +21 -0
- package/server-managed/combined.ts +46 -0
- package/server-managed/comments.ts +151 -0
- package/server-managed/config.ts +149 -0
- package/server-managed/content-access.ts +15 -0
- package/server-managed/crypto.ts +25 -0
- package/server-managed/db-methods.ts +1517 -0
- package/server-managed/db-neon.ts +277 -0
- package/server-managed/db-schema.ts +206 -0
- package/server-managed/db-table-names.ts +22 -0
- package/server-managed/db.ts +120 -0
- package/server-managed/github-app.ts +385 -0
- package/server-managed/github-errors.ts +49 -0
- package/server-managed/github-issue-oauth.ts +48 -0
- package/server-managed/github-issues.ts +152 -0
- package/server-managed/github-metadata.ts +98 -0
- package/server-managed/github-oauth.ts +199 -0
- package/server-managed/github-pulls.ts +227 -0
- package/server-managed/http-response.ts +18 -0
- package/server-managed/http.ts +2350 -0
- package/server-managed/import-triage.ts +51 -0
- package/server-managed/index.ts +175 -0
- package/server-managed/managed-issues.ts +47 -0
- package/server-managed/management-catalog.ts +45 -0
- package/server-managed/management.ts +86 -0
- package/server-managed/object-storage-disk.ts +144 -0
- package/server-managed/object-storage-vercel.ts +63 -0
- package/server-managed/object-storage.ts +71 -0
- package/server-managed/package-entry-points.ts +51 -0
- package/server-managed/public-workspace.ts +146 -0
- package/server-managed/repo-path.ts +21 -0
- package/server-managed/report-migration.ts +35 -0
- package/server-managed/report-query.ts +10 -0
- package/server-managed/report-response.ts +16 -0
- package/server-managed/report-sources.ts +166 -0
- package/server-managed/repository-browser.ts +139 -0
- package/server-managed/repository-discovery.ts +82 -0
- package/server-managed/repository-policy.ts +27 -0
- package/server-managed/revisions.ts +35 -0
- package/server-managed/session.ts +78 -0
- package/server-managed/slugs.ts +38 -0
- package/server-managed/solidity-entry-points.ts +69 -0
- package/server-managed/sql-postgres.ts +31 -0
- package/server-managed/sql.ts +72 -0
- package/server-managed/static.ts +28 -0
- package/server-managed/storage-command.ts +16 -0
- package/server-managed/storage-db.ts +175 -0
- package/server-managed/storage-encryption.ts +211 -0
- package/server-managed/storage-maintenance.ts +223 -0
- package/server-managed/storage-payload.ts +72 -0
- package/server-managed/storage-stores.ts +81 -0
- package/server-managed/storage.ts +58 -0
- package/server-managed/team-catalog.ts +7 -0
- package/server-managed/team-feed.ts +173 -0
- package/server-managed/team-reports.ts +219 -0
- package/server-managed/triage-response.ts +16 -0
- package/server-managed/uploads.ts +65 -0
- package/server-managed/workspace-shares.ts +162 -0
- package/server.ts +50 -0
- package/server/index.ts +0 -477
- package/server/lifecycle.ts +0 -204
- package/server/sync-handlers.ts +0 -320
- /package/{LICENSE → LICENSE-MIT} +0 -0
- /package/{server → server-e2e}/config.example.json +0 -0
- /package/{server → server-e2e}/objstore/fs.ts +0 -0
- /package/{server → server-e2e}/peer.ts +0 -0
- /package/{server → server-e2e}/validation.ts +0 -0
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
// Persistent workspace activity. Uploads are recorded in the same transaction
|
|
2
|
+
// as their metadata; triage reuses its existing immutable trail, including
|
|
3
|
+
// legacy entries. Never copy annotation bodies or credentials into this feed.
|
|
4
|
+
import { randomUUID } from 'node:crypto'
|
|
5
|
+
import type { DatabaseSync } from 'node:sqlite'
|
|
6
|
+
import type { ManagedSql } from './sql.ts'
|
|
7
|
+
|
|
8
|
+
export interface ActivityInput {
|
|
9
|
+
kind: 'access' | 'repository' | 'visibility' | 'delete'
|
|
10
|
+
actor: string
|
|
11
|
+
actorId?: string | null
|
|
12
|
+
action: string
|
|
13
|
+
repo?: string | null
|
|
14
|
+
reportId?: string | null
|
|
15
|
+
bundleId?: string | null
|
|
16
|
+
repoId?: number | null
|
|
17
|
+
repoDirectory?: string | null
|
|
18
|
+
report?: string | null
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface ActivityContext {
|
|
22
|
+
finding: string
|
|
23
|
+
reportId: string
|
|
24
|
+
report: string
|
|
25
|
+
repo: string | null
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type ActivityEntry = {
|
|
29
|
+
id: string; kind: string; actor: string | null; action: string
|
|
30
|
+
repo: string | null; reportId: string | null; report: string | null
|
|
31
|
+
finding: string | null; at: number
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface ActivityQuery {
|
|
35
|
+
page: number
|
|
36
|
+
limit: number
|
|
37
|
+
kind: string
|
|
38
|
+
query: string
|
|
39
|
+
repo?: string
|
|
40
|
+
actor?: string
|
|
41
|
+
// null = admin; [] = no accessible findings. Context is supplied by the
|
|
42
|
+
// server, never by the client. It also replaces potentially private context
|
|
43
|
+
// from an old report carrying the same globally shared finding.
|
|
44
|
+
contexts: ActivityContext[] | null
|
|
45
|
+
userId?: string
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface ActivityFilters {
|
|
49
|
+
repos: string[]
|
|
50
|
+
users: { id: string; login: string; detail: string | null }[]
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface ActivityStore {
|
|
54
|
+
recordActivity(entry: ActivityInput, at: number): Promise<void>
|
|
55
|
+
listActivityReports(userId: string): Promise<Omit<ActivityContext, 'finding'>[]>
|
|
56
|
+
listActivity(query: ActivityQuery): Promise<{ history: ActivityEntry[]; total: number; page: number; limit: number; filters: ActivityFilters }>
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const triageFields = `'triage:' || e.seq AS id, 'triage' AS kind,
|
|
60
|
+
COALESCE(u.login, e.actor_login) AS actor, e.actor_id AS actorId,
|
|
61
|
+
CASE WHEN e.color IS NULL AND e.triage IS NULL AND e.comment IS NULL AND e.fix IS NULL AND e.flagged IS NULL
|
|
62
|
+
THEN 'cleared triage' ELSE 'updated triage' END AS action`
|
|
63
|
+
const triage = `SELECT ${triageFields}, e.repo, e.report_id AS reportId, e.report, e.finding_id AS finding, e.at
|
|
64
|
+
FROM managed_finding_triage_event e LEFT JOIN managed_user u ON u.id = e.actor_id`
|
|
65
|
+
const commentFields = `'comment:' || e.seq AS id, 'triage' AS kind,
|
|
66
|
+
COALESCE(u.login, e.actor_login) AS actor, e.actor_id AS actorId, e.action`
|
|
67
|
+
const comments = `SELECT ${commentFields}, e.repo, e.report_id AS reportId, e.report, e.finding_id AS finding, e.at
|
|
68
|
+
FROM managed_finding_comment_event e LEFT JOIN managed_user u ON u.id = e.actor_id`
|
|
69
|
+
const adminSource = `${triage} UNION ALL ${comments} UNION ALL
|
|
70
|
+
SELECT id, kind, actor, actor_id AS actorId, action, repo, report_id AS reportId, report, NULL AS finding, at FROM managed_activity`
|
|
71
|
+
// Keep the finite set of accessible findings outside the indexed event
|
|
72
|
+
// lookup. Scanning all events against json_each for every row is quadratic.
|
|
73
|
+
const managerSource = `SELECT ${triageFields},
|
|
74
|
+
json_extract(c.value, '$.repo') AS repo, json_extract(c.value, '$.reportId') AS reportId,
|
|
75
|
+
json_extract(c.value, '$.report') AS report, e.finding_id AS finding, e.at
|
|
76
|
+
FROM json_each(:contexts) c
|
|
77
|
+
CROSS JOIN managed_finding_triage_event e ON e.finding_id = json_extract(c.value, '$.finding')
|
|
78
|
+
LEFT JOIN managed_user u ON u.id = e.actor_id
|
|
79
|
+
UNION ALL SELECT ${commentFields},
|
|
80
|
+
json_extract(c.value, '$.repo') AS repo, json_extract(c.value, '$.reportId') AS reportId,
|
|
81
|
+
json_extract(c.value, '$.report') AS report, e.finding_id AS finding, e.at
|
|
82
|
+
FROM json_each(:contexts) c
|
|
83
|
+
CROSS JOIN managed_finding_comment_event e ON e.finding_id = json_extract(c.value, '$.finding')
|
|
84
|
+
LEFT JOIN managed_user u ON u.id = e.actor_id
|
|
85
|
+
UNION ALL SELECT a.id, a.kind, a.actor, a.actor_id AS actorId,
|
|
86
|
+
CASE WHEN a.kind = 'repository' THEN 'changed a report repository assignment' ELSE a.action END AS action,
|
|
87
|
+
p.full_name AS repo, r.id AS reportId, r.filename AS report, NULL AS finding, a.at
|
|
88
|
+
FROM managed_activity a JOIN managed_report r ON r.id = a.report_id
|
|
89
|
+
LEFT JOIN managed_selected_repo p ON p.repo_id = r.repo_id
|
|
90
|
+
WHERE EXISTS (
|
|
91
|
+
SELECT 1 FROM managed_team_repo tr JOIN managed_team_user tu ON tu.team_id = tr.team_id
|
|
92
|
+
WHERE tu.user_id = :userId AND tr.repo_id = r.repo_id AND ${withinTeamPath('r.repo_directory')}
|
|
93
|
+
)
|
|
94
|
+
UNION ALL SELECT a.id, a.kind, a.actor, a.actor_id AS actorId,
|
|
95
|
+
CASE WHEN a.kind = 'repository' THEN 'changed a bundle repository assignment' ELSE a.action END AS action,
|
|
96
|
+
p.full_name AS repo, NULL AS reportId, b.filename AS report, NULL AS finding, a.at
|
|
97
|
+
FROM managed_activity a JOIN managed_bundle b ON b.id = a.bundle_id
|
|
98
|
+
LEFT JOIN managed_selected_repo p ON p.repo_id = b.repo_id
|
|
99
|
+
WHERE a.report_id IS NULL AND EXISTS (
|
|
100
|
+
SELECT 1 FROM managed_team_repo tr JOIN managed_team_user tu ON tu.team_id = tr.team_id
|
|
101
|
+
WHERE tu.user_id = :userId AND tr.repo_id = b.repo_id AND ${withinTeamPath('b.repo_directory')}
|
|
102
|
+
)
|
|
103
|
+
UNION ALL SELECT a.id, a.kind, a.actor, a.actor_id AS actorId, a.action, a.repo, a.report_id AS reportId,
|
|
104
|
+
a.report, NULL AS finding, a.at FROM managed_activity a
|
|
105
|
+
WHERE a.kind = 'delete' AND (a.report_id IS NOT NULL OR a.bundle_id IS NOT NULL)
|
|
106
|
+
AND NOT EXISTS (SELECT 1 FROM managed_report WHERE id = a.report_id)
|
|
107
|
+
AND NOT EXISTS (SELECT 1 FROM managed_bundle WHERE id = a.bundle_id)
|
|
108
|
+
AND EXISTS (SELECT 1 FROM managed_team_repo tr JOIN managed_team_user tu ON tu.team_id = tr.team_id
|
|
109
|
+
WHERE tu.user_id = :userId AND tr.repo_id = a.repo_id
|
|
110
|
+
AND ${withinTeamPath('a.repo_directory')})`
|
|
111
|
+
|
|
112
|
+
export const ACTIVITY_SCHEMA = `
|
|
113
|
+
CREATE TABLE IF NOT EXISTS managed_activity (
|
|
114
|
+
id TEXT PRIMARY KEY, kind TEXT NOT NULL, actor TEXT, action TEXT NOT NULL,
|
|
115
|
+
repo TEXT, report_id TEXT, report TEXT, at INTEGER NOT NULL,
|
|
116
|
+
bundle_id TEXT, repo_id INTEGER, repo_directory TEXT, actor_id TEXT
|
|
117
|
+
) STRICT;
|
|
118
|
+
CREATE INDEX IF NOT EXISTS managed_activity_at_idx ON managed_activity(at, id);
|
|
119
|
+
CREATE INDEX IF NOT EXISTS finding_triage_event_at_idx ON managed_finding_triage_event(at, seq);
|
|
120
|
+
`
|
|
121
|
+
|
|
122
|
+
export function initActivityMethods(db: DatabaseSync): void {
|
|
123
|
+
db.exec(ACTIVITY_SCHEMA)
|
|
124
|
+
migrateActivityScope(db)
|
|
125
|
+
db.exec('CREATE INDEX IF NOT EXISTS managed_activity_actor_at_idx ON managed_activity(actor_id, at)')
|
|
126
|
+
// Stable upload IDs make backfill idempotent, including after restarts.
|
|
127
|
+
// Snapshots deliberately have no cascading FKs: deletion is itself activity.
|
|
128
|
+
for (const type of ['report', 'bundle']) {
|
|
129
|
+
const columns = `id, kind, actor, action, repo, report_id, report, at, bundle_id, actor_id`
|
|
130
|
+
const values = (alias: string) => `'${type}-upload:' || ${alias}.id, 'upload',
|
|
131
|
+
COALESCE(${alias}.uploaded_by_login, (SELECT login FROM managed_user WHERE id = ${alias}.uploaded_by)), 'uploaded a ${type}',
|
|
132
|
+
(SELECT full_name FROM managed_selected_repo WHERE repo_id = ${alias}.repo_id),
|
|
133
|
+
${type === 'report' ? `${alias}.id` : 'NULL'}, ${alias}.filename, ${alias}.uploaded_at,
|
|
134
|
+
${type === 'bundle' ? `${alias}.id` : 'NULL'}, ${alias}.uploaded_by`
|
|
135
|
+
db.exec(`
|
|
136
|
+
CREATE TRIGGER IF NOT EXISTS managed_${type}_activity AFTER INSERT ON managed_${type}
|
|
137
|
+
BEGIN INSERT INTO managed_activity (${columns}) SELECT ${values('NEW')}; END;
|
|
138
|
+
INSERT OR IGNORE INTO managed_activity (${columns}) SELECT ${values('source')} FROM managed_${type} source;
|
|
139
|
+
`)
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function activityMethods(db: ManagedSql): ActivityStore {
|
|
144
|
+
const insert = db.prepare(`INSERT INTO managed_activity (id, kind, actor, action, repo, report_id, report, at, bundle_id, repo_id, repo_directory, actor_id)
|
|
145
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
146
|
+
const admin = activityStatements(db, adminSource)
|
|
147
|
+
const manager = activityStatements(db, managerSource)
|
|
148
|
+
const reports = db.prepare(`SELECT r.id AS reportId, r.filename AS report, p.full_name AS repo
|
|
149
|
+
FROM managed_report r JOIN managed_selected_repo p ON p.repo_id = r.repo_id
|
|
150
|
+
WHERE EXISTS (SELECT 1 FROM managed_team_repo tr JOIN managed_team_user tu ON tu.team_id = tr.team_id
|
|
151
|
+
WHERE tu.user_id = ? AND tr.repo_id = r.repo_id AND ${withinTeamPath('r.repo_directory')})
|
|
152
|
+
ORDER BY r.uploaded_at DESC, r.id`)
|
|
153
|
+
return {
|
|
154
|
+
async recordActivity(entry, at) {
|
|
155
|
+
await insert.run(randomUUID(), entry.kind, entry.actor, entry.action, entry.repo ?? null, entry.reportId ?? null, entry.report ?? null, at,
|
|
156
|
+
entry.bundleId ?? null, entry.repoId ?? null, entry.repoDirectory ?? null, entry.actorId ?? null)
|
|
157
|
+
},
|
|
158
|
+
async listActivityReports(userId) {
|
|
159
|
+
return (await reports.all(userId)) as Omit<ActivityContext, 'finding'>[]
|
|
160
|
+
},
|
|
161
|
+
async listActivity({ page, limit, kind, query, repo = '', actor = '', contexts, userId }) {
|
|
162
|
+
const stmts = contexts == null ? admin : manager
|
|
163
|
+
const scope = contexts == null ? {} : { contexts: JSON.stringify(contexts), userId: userId ?? '' }
|
|
164
|
+
const params = { ...scope, kind, query, repo, actor }
|
|
165
|
+
const { total } = (await stmts.count.get(params)) as { total: number }
|
|
166
|
+
const currentPage = Math.min(page, Math.max(1, Math.ceil(total / limit)))
|
|
167
|
+
const history = (await stmts.rows.all({ ...params, limit, offset: (currentPage - 1) * limit })) as ActivityEntry[]
|
|
168
|
+
const filters: ActivityFilters = {
|
|
169
|
+
repos: ((await stmts.repos.all(scope)) as { repo: string }[]).map(row => row.repo),
|
|
170
|
+
users: ((await stmts.users.all(scope)) as ActivityFilters['users']).map(row => ({ id: row.id, login: row.login, detail: row.detail })),
|
|
171
|
+
}
|
|
172
|
+
return { history, total, page: currentPage, limit, filters }
|
|
173
|
+
},
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Both rows and selector choices come from the authorized source. Keep legacy
|
|
178
|
+
// login-only actors separate: a historical login is not proof of user identity.
|
|
179
|
+
function activityStatements(db: ManagedSql, source: string) {
|
|
180
|
+
const activity = `WITH events AS (${source}), activity AS (
|
|
181
|
+
SELECT events.*, COALESCE('user:' || actorId, 'legacy:' || actor) AS actorKey FROM events
|
|
182
|
+
)`
|
|
183
|
+
const filtered = `${activity} SELECT id, kind, actor, action, repo, reportId, report, finding, at FROM activity
|
|
184
|
+
WHERE (:kind = 'all' OR kind = :kind)
|
|
185
|
+
AND (:repo = '' OR repo = :repo) AND (:actor = '' OR actorKey = :actor)
|
|
186
|
+
AND (:query = '' OR instr(lower(coalesce(actor, '') || ' ' || action || ' ' ||
|
|
187
|
+
coalesce(repo, '') || ' ' || coalesce(report, '') || ' ' || coalesce(finding, '')), lower(:query)) > 0)`
|
|
188
|
+
return {
|
|
189
|
+
count: db.prepare(`SELECT count(*) AS total FROM (${filtered}) AS filtered`),
|
|
190
|
+
rows: db.prepare(`${filtered} ORDER BY at DESC,
|
|
191
|
+
CASE WHEN kind = 'triage' THEN CAST(substr(id, instr(id, ':') + 1) AS INTEGER) ELSE 0 END DESC,
|
|
192
|
+
id DESC LIMIT :limit OFFSET :offset`),
|
|
193
|
+
repos: db.prepare(`${activity} SELECT DISTINCT repo FROM activity WHERE repo IS NOT NULL AND repo <> '' ORDER BY repo`),
|
|
194
|
+
users: db.prepare(`${activity} SELECT a.actorKey AS id, COALESCE(u.login, a.actor, a.actorId) AS login,
|
|
195
|
+
CASE WHEN a.actorId IS NULL THEN 'Legacy actor' ELSE NULL END AS detail FROM (
|
|
196
|
+
SELECT actorKey, actorId, actor, ROW_NUMBER() OVER (PARTITION BY actorKey ORDER BY at DESC, id DESC) AS position
|
|
197
|
+
FROM activity WHERE actorKey IS NOT NULL AND actorKey <> 'legacy:'
|
|
198
|
+
) a LEFT JOIN managed_user u ON u.id = a.actorId WHERE a.position = 1 ORDER BY login, a.actorKey`),
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Literal path comparison preserves separators, case and SQL wildcard chars.
|
|
203
|
+
function withinTeamPath(path: string): string {
|
|
204
|
+
return `(tr.path IS NULL OR tr.path = '' OR ${path} = tr.path OR substr(${path}, 1, length(tr.path) + 1) = tr.path || '/')`
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function migrateActivityScope(db: DatabaseSync): void {
|
|
208
|
+
const columns = new Set((db.prepare('PRAGMA table_info(managed_activity)').all() as { name: string }[]).map(column => column.name))
|
|
209
|
+
const missing = [['bundle_id', 'TEXT'], ['repo_id', 'INTEGER'], ['repo_directory', 'TEXT'], ['actor_id', 'TEXT']].filter(([name]) => !columns.has(name!))
|
|
210
|
+
if (missing.length === 0) return
|
|
211
|
+
db.exec('BEGIN')
|
|
212
|
+
try {
|
|
213
|
+
for (const [name, type] of missing) db.exec(`ALTER TABLE managed_activity ADD COLUMN ${name} ${type}`)
|
|
214
|
+
if (!columns.has('bundle_id') || !columns.has('actor_id')) {
|
|
215
|
+
db.exec('DROP TRIGGER IF EXISTS managed_report_activity; DROP TRIGGER IF EXISTS managed_bundle_activity;')
|
|
216
|
+
}
|
|
217
|
+
if (!columns.has('bundle_id')) db.exec("UPDATE managed_activity SET bundle_id = substr(id, 15) WHERE id LIKE 'bundle-upload:%'")
|
|
218
|
+
if (!columns.has('actor_id')) {
|
|
219
|
+
// Existing upload targets retain a reliable uploader identity. Do not
|
|
220
|
+
// guess identities from historical logins: names can change or be reused.
|
|
221
|
+
for (const type of ['report', 'bundle']) {
|
|
222
|
+
db.exec(`UPDATE managed_activity SET actor_id = (
|
|
223
|
+
SELECT uploaded_by FROM managed_${type} WHERE id = substr(managed_activity.id, 15)
|
|
224
|
+
) WHERE id LIKE '${type}-upload:%'`)
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
// Legacy deletions/assignments without a durable target/scope remain
|
|
228
|
+
// admin-only; neither filenames nor repository names authorize access.
|
|
229
|
+
db.exec('COMMIT')
|
|
230
|
+
} catch (err) { db.exec('ROLLBACK'); throw err }
|
|
231
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// Shared avatar cache interface, implemented by storage-stores.ts.
|
|
2
|
+
import type { Buffer } from 'node:buffer'
|
|
3
|
+
|
|
4
|
+
export interface CachedAvatar {
|
|
5
|
+
contentType: string
|
|
6
|
+
bytes: Buffer
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface AvatarStore {
|
|
10
|
+
put(id: string, contentType: string, bytes: Buffer): Promise<void>
|
|
11
|
+
get(id: string): Promise<CachedAvatar | null>
|
|
12
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Logical upload-store interfaces shared by disk and Vercel storage.
|
|
2
|
+
import type { Buffer } from 'node:buffer'
|
|
3
|
+
import type { Readable } from 'node:stream'
|
|
4
|
+
|
|
5
|
+
export interface OpenedBlob {
|
|
6
|
+
// Remote streams may omit their size; callers then omit Content-Length.
|
|
7
|
+
size: number | null
|
|
8
|
+
stream: Readable
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface BlobStore {
|
|
12
|
+
// Encrypted upload stores return the wrapped data key for the SQL insert.
|
|
13
|
+
put(id: string, bytes: Buffer): Promise<string | null>
|
|
14
|
+
get(id: string): Promise<Buffer | null>
|
|
15
|
+
open(id: string): Promise<OpenedBlob | null>
|
|
16
|
+
delete(id: string): Promise<void>
|
|
17
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { brotliCompress, constants } from 'node:zlib'
|
|
2
|
+
import { promisify } from 'node:util'
|
|
3
|
+
import type { Buffer } from 'node:buffer'
|
|
4
|
+
|
|
5
|
+
const compress = promisify(brotliCompress)
|
|
6
|
+
|
|
7
|
+
// Quality 4 avoids Brotli's expensive default (11) for uploads and cold caches.
|
|
8
|
+
export function encodeBrotli(bytes: Uint8Array): Promise<Buffer> {
|
|
9
|
+
return compress(bytes, { params: { [constants.BROTLI_PARAM_QUALITY]: 4 } })
|
|
10
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { type Advisory, type Package, advisories } from '@preventive/upstream/advisories.js'
|
|
2
|
+
import { type Client, HttpError, createClient } from '@preventive/upstream/github.js'
|
|
3
|
+
|
|
4
|
+
export const ADVISORIES_TIMEOUT_MS = 30_000
|
|
5
|
+
|
|
6
|
+
function advisoryGithubClient(token: string | null, signal: AbortSignal): Client {
|
|
7
|
+
const authenticated = createClient({ token, userAgent: 'deepview-triage' })
|
|
8
|
+
if (token === null) return authenticated
|
|
9
|
+
let client = authenticated
|
|
10
|
+
return {
|
|
11
|
+
...authenticated,
|
|
12
|
+
async listRepoAdvisories(options) {
|
|
13
|
+
const current = client
|
|
14
|
+
try { return await current.listRepoAdvisories(options) } catch (error) {
|
|
15
|
+
if (current !== authenticated || !(error instanceof HttpError) || error.status !== 401) throw error
|
|
16
|
+
signal.throwIfAborted()
|
|
17
|
+
// Published advisories remain readable after a token is revoked. Reuse
|
|
18
|
+
// anonymous access for subsequent repositories in this audit as well.
|
|
19
|
+
if (client === authenticated) client = createClient({ token: null, userAgent: 'deepview-triage' })
|
|
20
|
+
return client.listRepoAdvisories(options)
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// The managed caller supplies its user's token when available; public shares
|
|
27
|
+
// remain anonymous. Upstream sends this credential only to GitHub and returns
|
|
28
|
+
// published advisories, matching versions across npm, OSV and repository rows.
|
|
29
|
+
export async function fetchBundleAdvisories(packages: Package[], signal: AbortSignal,
|
|
30
|
+
{ debug = false, repoAdvisories = false, githubToken = null }: { debug?: boolean; repoAdvisories?: boolean; githubToken?: string | null } = {}): Promise<
|
|
31
|
+
{ status: 200; body: Advisory[] } | { status: 502; body: { error: string } }
|
|
32
|
+
> {
|
|
33
|
+
let onAbort: (() => void) | undefined
|
|
34
|
+
try {
|
|
35
|
+
signal.throwIfAborted()
|
|
36
|
+
if (packages.length === 0) return { status: 200, body: [] }
|
|
37
|
+
// Upstream owns each transport's timeout and exposes no cancellation
|
|
38
|
+
// option. Bound the caller's wait for the multi-request audit as well.
|
|
39
|
+
const deadline = new Promise<never>((_resolve, reject) => {
|
|
40
|
+
onAbort = () => reject(signal.reason)
|
|
41
|
+
signal.addEventListener('abort', onAbort, { once: true })
|
|
42
|
+
})
|
|
43
|
+
const result = await Promise.race([
|
|
44
|
+
advisories(packages, { github: advisoryGithubClient(githubToken, signal), repoAdvisories }), deadline,
|
|
45
|
+
])
|
|
46
|
+
return { status: 200, body: result }
|
|
47
|
+
} catch (error) {
|
|
48
|
+
if (debug) console.warn('managed: advisories failed:', error)
|
|
49
|
+
return { status: 502, body: { error: 'upstream-unavailable' } }
|
|
50
|
+
} finally {
|
|
51
|
+
if (onAbort) signal.removeEventListener('abort', onAbort)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { Package } from '@preventive/upstream/advisories.js'
|
|
2
|
+
import { satisfies, valid } from '@preventive/upstream/semver.js'
|
|
3
|
+
import type { BundleDetails } from '../common/bundle-metadata.js'
|
|
4
|
+
|
|
5
|
+
export interface SkippedAdvisoryPackage {
|
|
6
|
+
ecosystem: string
|
|
7
|
+
name: string
|
|
8
|
+
version: string
|
|
9
|
+
because: string
|
|
10
|
+
}
|
|
11
|
+
export interface BundleAdvisoryInventory { packages: Package[]; skipped: SkippedAdvisoryPackage[] }
|
|
12
|
+
interface BundleModule { ecosystem?: string; name?: string; version?: string; files: Record<string, unknown> }
|
|
13
|
+
|
|
14
|
+
// Match Stasis's audit evidence rules, including their verified version bounds:
|
|
15
|
+
// https://github.com/PreventiveMeasures/stasis/blob/2bd4c14354da9888c00ab45fa89740413099b5dc/stasis/src/audit-corrections.js
|
|
16
|
+
// Keep this on the server: upstream's semver implementation uses Node's npm.
|
|
17
|
+
const cargoManifests = ['Cargo.toml', 'Cargo.lock', '.cargo-checksum.json']
|
|
18
|
+
const solidityManifests = ['package.json', 'foundry.toml', 'remappings.txt', 'soldeer.toml']
|
|
19
|
+
const manifests = new Map([
|
|
20
|
+
['npm', ['package.json']],
|
|
21
|
+
['cargo', cargoManifests], ['cargo-git', cargoManifests], ['cargo-unknown', cargoManifests],
|
|
22
|
+
['composer', ['composer.json', 'composer.lock']],
|
|
23
|
+
['soldeer', solidityManifests], ['github', solidityManifests],
|
|
24
|
+
])
|
|
25
|
+
const browserStubRanges = new Map([['ws', '<=8.21.1'], ['node-fetch', '<=2.7.0']])
|
|
26
|
+
|
|
27
|
+
function isEvidence(ecosystem: string, name: string, version: string, file: string): boolean {
|
|
28
|
+
if (manifests.get(ecosystem)?.includes(file.slice(file.lastIndexOf('/') + 1))) return false
|
|
29
|
+
const stubRange = ecosystem === 'npm' && file === 'browser.js' ? browserStubRanges.get(name) : undefined
|
|
30
|
+
return !stubRange || !valid(version) || !satisfies(version, stubRange)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function supported(ecosystem: string): ecosystem is Package['ecosystem'] {
|
|
34
|
+
return ['npm', 'cargo', 'composer', 'soldeer', 'github'].includes(ecosystem)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function skipReason(ecosystem: string, version: string): string | undefined {
|
|
38
|
+
if (ecosystem === 'cargo-git') return 'Crate vendored from git; its identity is not a crates.io package.'
|
|
39
|
+
if (ecosystem === 'cargo-unknown') return 'Crate source is unknown because .cargo-checksum.json is missing.'
|
|
40
|
+
if (ecosystem === 'composer' && /^dev-|-dev$/iu.test(version.replace(/#.*$/su, ''))) return 'Composer dev versions cannot be matched against release advisories.'
|
|
41
|
+
return undefined
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Package and reason presence both require a recorded evidence file. Entry and
|
|
45
|
+
// manually added files count even when they have no incoming import edge.
|
|
46
|
+
export function bundleAdvisoryInventory(details: BundleDetails, paths: Iterable<string> | null = null): BundleAdvisoryInventory {
|
|
47
|
+
const modules = (details.bundle as { modules?: ReadonlyMap<string, BundleModule> } | undefined)?.modules
|
|
48
|
+
if (details.kind !== 'stasis' || !modules) return { packages: [], skipped: [] }
|
|
49
|
+
const selected = paths === null ? null : new Set(paths)
|
|
50
|
+
const packages = new Map<string, { ecosystem: Package['ecosystem']; name: string; versions: Set<string> }>()
|
|
51
|
+
const skipped = new Map<string, SkippedAdvisoryPackage>()
|
|
52
|
+
for (const [dir, info] of modules) {
|
|
53
|
+
if (dir === '.') continue
|
|
54
|
+
const ecosystem = info.ecosystem ?? (/(?:^|\/)node_modules\//u.test(dir) ? 'npm' : undefined)
|
|
55
|
+
const { name, version } = info
|
|
56
|
+
if (ecosystem === undefined || typeof name !== 'string' || !name || typeof version !== 'string' || !version) continue
|
|
57
|
+
if (!Object.keys(info.files).some(file => (!selected || selected.has(`${dir}/${file}`)) && isEvidence(ecosystem, name, version, file))) continue
|
|
58
|
+
const because = skipReason(ecosystem, version)
|
|
59
|
+
if (!supported(ecosystem) || because !== undefined) {
|
|
60
|
+
skipped.set(JSON.stringify([ecosystem, name, version]), { ecosystem, name, version, because: because ?? `No advisory source is supported for ${ecosystem}.` })
|
|
61
|
+
continue
|
|
62
|
+
}
|
|
63
|
+
// Foundry's branch '.' means the superproject's branch. As in Stasis,
|
|
64
|
+
// 0.0.0 tells upstream its actual version is unknown, so every range covers it.
|
|
65
|
+
const auditedVersion = ecosystem === 'github' && version === '.' ? '0.0.0' : version
|
|
66
|
+
const key = JSON.stringify([ecosystem, ecosystem === 'github' ? name.toLowerCase() : name])
|
|
67
|
+
if (!packages.has(key)) packages.set(key, { ecosystem, name, versions: new Set() })
|
|
68
|
+
packages.get(key)!.versions.add(auditedVersion)
|
|
69
|
+
}
|
|
70
|
+
const byPackage = (a: { ecosystem: string; name: string }, b: { ecosystem: string; name: string }) => a.ecosystem.localeCompare(b.ecosystem) || a.name.localeCompare(b.name)
|
|
71
|
+
return {
|
|
72
|
+
packages: [...packages.values()].map(pkg => ({ ...pkg, versions: [...pkg.versions].toSorted() })).toSorted(byPackage),
|
|
73
|
+
skipped: [...skipped.values()].toSorted((a, b) => byPackage(a, b) || a.version.localeCompare(b.version)),
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export interface BundleBuildDiagnostic {
|
|
2
|
+
message: string
|
|
3
|
+
name?: string
|
|
4
|
+
code?: string
|
|
5
|
+
status?: number
|
|
6
|
+
stack?: string
|
|
7
|
+
cause?: BundleBuildDiagnostic
|
|
8
|
+
}
|
|
9
|
+
export function bundleBuildDiagnostic(error: unknown, token: string | null): BundleBuildDiagnostic
|
|
10
|
+
export type BundleBuildStage = 'worker-start' | 'build' | 'scope' | 'serialize' | 'compress'
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Whitelist diagnostic fields; never inspect/serialize an entire upstream
|
|
2
|
+
// error, request, response, or workerData (which contains the GitHub token).
|
|
3
|
+
export function bundleBuildDiagnostic(error, token, depth = 0) {
|
|
4
|
+
const redact = value => {
|
|
5
|
+
let text = String(value)
|
|
6
|
+
if (token) text = text.replaceAll(token, '[redacted]')
|
|
7
|
+
return text.replaceAll(/\b(?:github_pat_|gh[pousr]_)[\w]+/gu, '[redacted]')
|
|
8
|
+
.replaceAll(/(https?:\/\/)[^\s/@]+:[^\s/@]+@/gu, '$1[redacted]@')
|
|
9
|
+
.replaceAll(/([?&](?:access_token|token|key)=)[^\s&#]*/giu, '$1[redacted]')
|
|
10
|
+
}
|
|
11
|
+
if (!error || typeof error !== 'object') return { message: redact(error).slice(0, 2048) }
|
|
12
|
+
// Upstream HttpError messages append the response body after the status.
|
|
13
|
+
// Keep the method, URL and status, but omit that body from message and stack.
|
|
14
|
+
const raw = typeof error.message === 'string' ? error.message : 'Unknown error'
|
|
15
|
+
const message = error.name === 'HttpError' ? raw.replace(/(\s\d{3}):[\s\S]*$/u, '$1') : raw
|
|
16
|
+
const result = { message: redact(message).slice(0, 2048) }
|
|
17
|
+
if (typeof error.name === 'string') result.name = redact(error.name).slice(0, 128)
|
|
18
|
+
if (typeof error.code === 'string') result.code = redact(error.code).slice(0, 128)
|
|
19
|
+
if (typeof error.status === 'number') result.status = error.status
|
|
20
|
+
if (typeof error.stack === 'string') result.stack = redact(error.stack.replace(raw, message)).slice(0, 8192)
|
|
21
|
+
if (error.cause && depth < 3) result.cause = bundleBuildDiagnostic(error.cause, token, depth + 1)
|
|
22
|
+
return result
|
|
23
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { ManagedSql } from './sql.ts'
|
|
2
|
+
|
|
3
|
+
export const BUNDLE_BUILD_TIMEOUT_MS = 180_000
|
|
4
|
+
// Leave time to terminate a timed-out worker before a crashed owner's slot
|
|
5
|
+
// becomes reusable. Expiration uses the database clock, not an instance's clock.
|
|
6
|
+
const LEASE_MS = BUNDLE_BUILD_TIMEOUT_MS + 60_000
|
|
7
|
+
export const BUNDLE_BUILD_LEASE_SCHEMA = `
|
|
8
|
+
CREATE TABLE IF NOT EXISTS managed_bundle_build_lease (
|
|
9
|
+
slot INTEGER PRIMARY KEY CHECK (slot IN (1, 2)),
|
|
10
|
+
owner TEXT NOT NULL UNIQUE,
|
|
11
|
+
user_id TEXT NOT NULL UNIQUE,
|
|
12
|
+
expires_at INTEGER NOT NULL
|
|
13
|
+
) STRICT;
|
|
14
|
+
`
|
|
15
|
+
|
|
16
|
+
export interface BundleBuildLeaseStore {
|
|
17
|
+
claimBundleBuildLease(userId: string, owner: string): Promise<boolean>
|
|
18
|
+
releaseBundleBuildLease(owner: string): Promise<void>
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function bundleBuildLeaseMethods(db: ManagedSql): BundleBuildLeaseStore {
|
|
22
|
+
return {
|
|
23
|
+
// scopeManagedMethods holds SQLite's write transaction / PostgreSQL's
|
|
24
|
+
// advisory transaction lock across cleanup, inspection, and admission.
|
|
25
|
+
async claimBundleBuildLease(userId, owner) {
|
|
26
|
+
const { now } = await db.prepare("SELECT CAST(unixepoch('subsec') * 1000 AS INTEGER) AS now").get() as { now: number }
|
|
27
|
+
await db.prepare('DELETE FROM managed_bundle_build_lease WHERE expires_at <= ?').run(now)
|
|
28
|
+
const leases = await db.prepare('SELECT slot, user_id AS userId FROM managed_bundle_build_lease').all() as { slot: number; userId: string }[]
|
|
29
|
+
if (leases.length >= 2 || leases.some(lease => lease.userId === userId)) return false
|
|
30
|
+
const slot = leases.some(lease => lease.slot === 1) ? 2 : 1
|
|
31
|
+
await db.prepare('INSERT INTO managed_bundle_build_lease (slot, owner, user_id, expires_at) VALUES (?, ?, ?, ?)')
|
|
32
|
+
.run(slot, owner, userId, now + LEASE_MS)
|
|
33
|
+
return true
|
|
34
|
+
},
|
|
35
|
+
// No user FK: deleting an account must not free a still-running worker's
|
|
36
|
+
// slot. A delayed/crashed owner's cleanup cannot release its successor.
|
|
37
|
+
async releaseBundleBuildLease(owner) {
|
|
38
|
+
await db.prepare('DELETE FROM managed_bundle_build_lease WHERE owner = ?').run(owner)
|
|
39
|
+
},
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { parentPort, workerData } from 'node:worker_threads'
|
|
2
|
+
import { Buffer } from 'node:buffer'
|
|
3
|
+
import { posix } from 'node:path'
|
|
4
|
+
import { brotliCompressSync } from 'node:zlib'
|
|
5
|
+
import { buildGitHubBundle } from '@exodus/stasis/vfs-bundle'
|
|
6
|
+
import { brotliOptions } from '@exodus/stasis-core/brotli'
|
|
7
|
+
import { HttpError, createClient } from '@preventive/upstream/github.js'
|
|
8
|
+
import { githubBundleFilename } from './bundle-build.ts'
|
|
9
|
+
import { bundleBuildDiagnostic } from './bundle-build-diagnostics.js'
|
|
10
|
+
|
|
11
|
+
export async function buildStasisBundle({ input, github, token, maxBytes, scopes }, client = createClient({ token }), progress = () => {}) {
|
|
12
|
+
const allowed = path => scopes.some(scope => !scope || path === scope || path.startsWith(scope + '/'))
|
|
13
|
+
// Stasis/upstream cache writes are disabled by default; this fresh worker
|
|
14
|
+
// never enables a cache directory or inherits application credentials.
|
|
15
|
+
progress('build')
|
|
16
|
+
const { bundle } = await buildGitHubBundle({
|
|
17
|
+
github, sha: input.commit, directory: input.directory || undefined,
|
|
18
|
+
entries: input.entries.map(entry => posix.relative(input.directory || '.', entry)),
|
|
19
|
+
...input.options, client,
|
|
20
|
+
})
|
|
21
|
+
const directory = bundle.repo?.directory ?? ''
|
|
22
|
+
progress('scope')
|
|
23
|
+
// A workspace lockfile may widen the build root. Never publish it under a
|
|
24
|
+
// narrower team grant: the storage location must cover the actual bundle.
|
|
25
|
+
if (!allowed(directory)) throw new Error('build-scope')
|
|
26
|
+
progress('serialize')
|
|
27
|
+
const serialized = bundle.serialize()
|
|
28
|
+
if (Buffer.byteLength(serialized) > 200 * 1024 * 1024) throw new Error('too-large')
|
|
29
|
+
progress('compress')
|
|
30
|
+
const bytes = brotliCompressSync(serialized, brotliOptions())
|
|
31
|
+
if (bytes.length > maxBytes) throw new Error('too-large')
|
|
32
|
+
return { bytes, directory, filename: githubBundleFilename(github, input.directory, input.commit) }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (parentPort && workerData?.type === 'managed-bundle-build') {
|
|
36
|
+
// Send diagnostics to the request's parent thread, which writes them before
|
|
37
|
+
// returning the HTTP response. Worker stdout can be lost on termination.
|
|
38
|
+
const send = message => {
|
|
39
|
+
// Node worker messages have no browser target origin.
|
|
40
|
+
// oxlint-disable-next-line unicorn/require-post-message-target-origin
|
|
41
|
+
parentPort.postMessage(message)
|
|
42
|
+
}
|
|
43
|
+
try {
|
|
44
|
+
send(await buildStasisBundle(workerData, undefined, stage => send({ type: 'progress', stage })))
|
|
45
|
+
} catch (error) {
|
|
46
|
+
const diagnostic = bundleBuildDiagnostic(error, workerData.token)
|
|
47
|
+
// Do not return upstream bodies or paths outside the caller's grants.
|
|
48
|
+
const code = ['build-scope', 'too-large'].includes(diagnostic.message) ? diagnostic.message
|
|
49
|
+
: error instanceof HttpError ? (error.status === 429 ? 'github-rate-limited' : 'github-build-failed')
|
|
50
|
+
: /no packageManager given|lockfile installs/u.test(diagnostic.message) ? 'build-lockfile'
|
|
51
|
+
: 'build-failed'
|
|
52
|
+
send({ error: code, status: code === 'too-large' ? 413 : code === 'build-scope' ? 403 : 422, diagnostic })
|
|
53
|
+
}
|
|
54
|
+
}
|