@preventive/triage 1.0.0-alpha.43 → 1.0.0-alpha.45
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/common/bundle-compare-diff.js +2 -167
- package/common/bundle-compare-versions.js +172 -0
- package/common/bundle-comparison.d.ts +8 -0
- package/common/bundle-comparison.js +62 -0
- package/common/bundle-sources.js +20 -1
- package/common/default-scan-models.ts +1 -1
- package/common/dependency-reviews.d.ts +0 -5
- package/common/dependency-reviews.js +0 -17
- package/common/managed/agent-tasks.ts +292 -0
- package/common/managed/github-agents.ts +92 -0
- package/common/managed/github-logins.ts +49 -0
- package/common/managed/roles.ts +7 -0
- package/common/managed/routes.js +91 -10
- package/common/managed/team-app.d.ts +5 -2
- package/common/managed/team-app.js +2 -2
- package/common/server-info.ts +11 -0
- package/common/workspace-app.js +19 -3
- package/out/brotli-fallback.js +3 -3
- package/out/chunk-2XUEZHIP.js +18 -18
- package/out/chunk-2ZDZWLFY.js +1 -0
- package/out/chunk-3PS4KWD2.js +10 -0
- package/out/chunk-5DHL5NG3.js +2 -0
- package/out/chunk-6HNXNIQY.js +52 -0
- package/out/chunk-7C7DWL7I.js +1 -0
- package/out/chunk-AFLZH7WG.js +1 -0
- package/out/chunk-BC2UCLFJ.js +1 -0
- package/out/chunk-C4Z6ZBXV.js +6 -6
- package/out/chunk-GDPCLSOS.js +2 -0
- package/out/chunk-GO7FT4OD.js +1 -0
- package/out/chunk-HMAJDVXN.js +1 -0
- package/out/chunk-I2MDGSQX.js +3 -0
- package/out/chunk-IYPY47MC.js +1 -0
- package/out/chunk-IZ7POWI3.js +18 -0
- package/out/chunk-JCIIBLKR.js +1 -0
- package/out/chunk-NPW52BM2.js +17 -0
- package/out/chunk-NRPC74AM.js +1 -1
- package/out/chunk-NV7ES4EW.js +1 -0
- package/out/chunk-OKRLLE5I.js +17 -0
- package/out/chunk-OOJM4CTU.js +1 -1
- package/out/chunk-PNHBZGDD.js +1 -1
- package/out/chunk-PTGDYLNS.js +36 -0
- package/out/chunk-PXK7EU4V.js +1 -0
- package/out/chunk-Q6MVYGYV.js +3 -0
- package/out/chunk-QB4CBOX2.js +1 -0
- package/out/chunk-QRD6GIG5.js +229 -0
- package/out/chunk-R3NZFXVR.js +1 -0
- package/out/chunk-R5O6FAW4.js +1 -1
- package/out/chunk-SXV2T5C4.js +80 -0
- package/out/chunk-TUII34K2.js +1 -1
- package/out/chunk-VFWMQ3LG.js +2 -0
- package/out/chunk-VH4XMZIW.js +2 -0
- package/out/chunk-WFYLMF6Z.js +10 -0
- package/out/chunk-WI7Z5A2T.js +6 -0
- package/out/chunk-WQ74ZBZF.js +7 -0
- package/out/chunk-XVBEOGOS.js +3 -0
- package/out/client-managed-import.js +3 -3
- package/out/client-managed.js +31 -35
- package/out/client-sync.js +11 -11
- package/out/compare.js +35 -0
- package/out/dialogs.js +51 -0
- package/out/github-issue-authorized.html +1 -1
- package/out/github-issue-authorized.js +1 -1
- package/out/graph.js +17 -17
- package/out/index.html +31 -31
- package/out/prism.js +14 -14
- package/out/scan.js +2 -0
- package/out/terminal.js +294 -293
- package/out/view.css +1 -1
- package/out/view.js +16 -107
- package/package.json +55 -6
- package/risk/actions/findings.js +156 -0
- package/risk/actions/resolve.js +219 -0
- package/risk/actions/scripts/commands.js +182 -0
- package/risk/actions/scripts/grade.js +113 -0
- package/risk/actions/scripts/packages.js +123 -0
- package/risk/actions/scripts/shell.js +165 -0
- package/risk/actions/uses.js +76 -0
- package/risk/actions/workflow.js +110 -0
- package/risk/files/hosts.js +6 -6
- package/risk/files/readability.js +23 -17
- package/risk/files/runs-code.js +18 -6
- package/risk/files/script.js +7 -3
- package/risk/files/stamps.js +8 -7
- package/risk/index.d.ts +68 -1
- package/risk/index.js +6 -2
- package/risk/npm/assess.js +13 -8
- package/risk/npm/compare.js +34 -10
- package/risk/npm/dependencies.js +11 -0
- package/risk/npm/files.js +413 -2
- package/risk/npm/tarball.js +56 -73
- package/server-managed/agent-merges.ts +198 -0
- package/server-managed/app-agents.ts +30 -0
- package/server-managed/avatar-store.ts +1 -0
- package/server-managed/commit-coauthors.ts +83 -0
- package/server-managed/commit-lockfiles.ts +62 -0
- package/server-managed/comparison.ts +41 -0
- package/server-managed/db-methods.ts +29 -10
- package/server-managed/db-neon.ts +30 -2
- package/server-managed/db-schema.ts +9 -2
- package/server-managed/db.ts +7 -0
- package/server-managed/dependabot-access.ts +60 -0
- package/server-managed/dependabot-alerts.ts +185 -0
- package/server-managed/dependency-review-comments.ts +97 -0
- package/server-managed/dependency-reviews.ts +65 -15
- package/server-managed/github-accounts.ts +33 -0
- package/server-managed/github-actions.ts +71 -0
- package/server-managed/github-app.ts +23 -9
- package/server-managed/github-errors.ts +2 -0
- package/server-managed/github-link-oauth.ts +69 -0
- package/server-managed/github-links.ts +204 -0
- package/server-managed/github-oauth.ts +5 -2
- package/server-managed/github-users.ts +30 -0
- package/server-managed/http.ts +1219 -81
- package/server-managed/index.ts +6 -2
- package/server-managed/locked-packages.ts +48 -16
- package/server-managed/lockfile-scope.ts +49 -0
- package/server-managed/npm-delta-worker.js +23 -0
- package/server-managed/npm-delta.ts +173 -0
- package/server-managed/npm-insights.ts +20 -11
- package/server-managed/npm-kept.ts +51 -4
- package/server-managed/npm-loads.ts +20 -13
- package/server-managed/npm-packages.ts +24 -64
- package/server-managed/pretty-print.ts +14 -9
- package/server-managed/public-workspace.ts +26 -8
- package/server-managed/pull-comment-reviews.ts +161 -0
- package/server-managed/pull-dependencies.ts +280 -0
- package/server-managed/pull-dependency-reviews.ts +318 -0
- package/server-managed/pull-reviews.ts +205 -0
- package/server-managed/release-commits.ts +84 -0
- package/server-managed/repository-actions.ts +209 -0
- package/server-managed/repository-agents.ts +220 -0
- package/server-managed/repository-browser.ts +54 -13
- package/server-managed/repository-changes.ts +284 -0
- package/server-managed/repository-code.ts +52 -0
- package/server-managed/repository-dependency-reviews.ts +135 -0
- package/server-managed/repository-history.ts +445 -0
- package/server-managed/repository-issues.ts +181 -0
- package/server-managed/repository-pulse.ts +432 -0
- package/server-managed/repository-risk-read.ts +165 -0
- package/server-managed/repository-risk.ts +116 -106
- package/server-managed/reviewed-dependencies.ts +149 -0
- package/server-managed/session.ts +9 -5
- package/server-managed/sql.ts +1 -1
- package/server-managed/storage-stores.ts +5 -0
- package/server-managed/team-app.ts +54 -14
- package/server-managed/workspace-shares.ts +4 -1
- package/server.ts +11 -0
- package/out/chunk-2KBF5TZ7.js +0 -2
- package/out/chunk-35VE6CDH.js +0 -2
- package/out/chunk-3BZ7JERB.js +0 -1
- package/out/chunk-4F6JSF57.js +0 -3
- package/out/chunk-4XCDFGDP.js +0 -10
- package/out/chunk-7RU5EDRO.js +0 -226
- package/out/chunk-A7M4GV7M.js +0 -1
- package/out/chunk-CLPKNNC7.js +0 -51
- package/out/chunk-CMQA4DM6.js +0 -1
- package/out/chunk-CVVJAXVY.js +0 -2
- package/out/chunk-DIJJNTRH.js +0 -10
- package/out/chunk-GJ4IUCNS.js +0 -1
- package/out/chunk-GNXOKIGP.js +0 -80
- package/out/chunk-GZ4MS3TE.js +0 -13
- package/out/chunk-IU3CCINR.js +0 -3
- package/out/chunk-K3X5EJBQ.js +0 -2
- package/out/chunk-KEX2ATEW.js +0 -2
- package/out/chunk-KYCNJVB7.js +0 -1
- package/out/chunk-LAAB5SZT.js +0 -2
- package/out/chunk-NWZ4PVPN.js +0 -1
- package/out/chunk-OSIMGFUF.js +0 -1
- package/out/chunk-RFZQQXSG.js +0 -17
- package/out/chunk-RSHMVYNS.js +0 -1
- package/out/chunk-TZ5KB2Y5.js +0 -10
- package/out/chunk-UNTRGE26.js +0 -2
- package/out/chunk-UXNUAIIC.js +0 -33
- package/out/chunk-UYAXVRVL.js +0 -1
- package/out/chunk-V3ME6FI5.js +0 -2
- package/out/chunk-V5O7M2YT.js +0 -1
- package/out/chunk-VIHGO77Y.js +0 -1
- package/out/chunk-VQJCXGIY.js +0 -7
- package/out/chunk-W4J3MGLB.js +0 -1
- package/out/chunk-WGG7KQUH.js +0 -1
- package/out/chunk-WZDTZE64.js +0 -1
- package/out/chunk-XOCAPDCM.js +0 -1
- package/out/chunk-XYFXXB2G.js +0 -10
- package/out/chunk-YVZH5SHP.js +0 -1
- package/out/chunk-ZBBOBQ43.js +0 -1
- package/out/chunk-ZMQQCGF6.js +0 -2
|
@@ -25,6 +25,14 @@ export interface DependencyReviewStore {
|
|
|
25
25
|
listDependencyReviews(context: ReviewContext, user: string, allBases?: boolean): Promise<DependencyReview[]>
|
|
26
26
|
getLatestOwnDependencyReview(user: string, target: Pick<ReviewContext, 'ecosystem' | 'package' | 'version' | 'integrity'>): Promise<DependencyReview | null>
|
|
27
27
|
listOwnDependencyReviews(user: string, before: { time: number; id: string } | null): Promise<{ reviews: DependencyReview[]; next: { time: number; id: string } | null }>
|
|
28
|
+
// `user`'s own reviews of `ids`, none another's.
|
|
29
|
+
listOwnDependencyReviewsById(user: string, ids: string[]): Promise<DependencyReview[]>
|
|
30
|
+
// The reviews published to team `teamId`, revoked ones too, the latest
|
|
31
|
+
// published first, a page on from `before` (when it was published, and
|
|
32
|
+
// its id), for one who reads them there (reviewsIn); null for another.
|
|
33
|
+
listTeamDependencyReviews(teamId: string, user: string, before: { time: number; id: string } | null): Promise<{ reviews: DependencyReview[]; next: { time: number; id: string } | null } | null>
|
|
34
|
+
// Whether `user` reads the reviews published to team `teamId` (reviewsIn).
|
|
35
|
+
readsTeamDependencyReviews(teamId: string, user: string): Promise<boolean>
|
|
28
36
|
listDependencyReviewTeams(user: string): Promise<ReviewTeam[]>
|
|
29
37
|
listDependencyReviewEnabledTeams(): Promise<string[]>
|
|
30
38
|
setTeamDependencyReview(sessionId: string, teamId: string, enabled: boolean): Promise<boolean>
|
|
@@ -62,6 +70,7 @@ CREATE TABLE IF NOT EXISTS managed_dependency_review_team (
|
|
|
62
70
|
team_id TEXT NOT NULL REFERENCES managed_team(id) ON DELETE CASCADE,
|
|
63
71
|
PRIMARY KEY (review_id, team_id)
|
|
64
72
|
) STRICT;
|
|
73
|
+
CREATE INDEX IF NOT EXISTS managed_dependency_review_team_idx ON managed_dependency_review_team(team_id, review_id);
|
|
65
74
|
`
|
|
66
75
|
const values = (c: ReviewContext) => [c.ecosystem, c.package, c.version, c.integrity, c.basePackage ?? '', c.baseVersion ?? '', c.baseIntegrity ?? '']
|
|
67
76
|
const targetWhere = 'ecosystem = ? AND package = ? AND version = ? AND integrity = ?'
|
|
@@ -71,6 +80,16 @@ const columns = `id, user_id AS user, user_login AS userLogin, ecosystem, packag
|
|
|
71
80
|
time, revision, published_at AS publishedAt, revoked_at AS revokedAt, files, status, comment`
|
|
72
81
|
const conflict = () => { throw new ManagedMutationError(409, 'review-conflict') }
|
|
73
82
|
|
|
83
|
+
// Who reviews dependencies in team `t`, user `u` as the query joins them: an
|
|
84
|
+
// admin, in any team; a member who manages, in theirs; a member who triages,
|
|
85
|
+
// in theirs whose dependencies they may see (`view_dependencies`). Publishing
|
|
86
|
+
// (`publishing`) needs the team to review dependencies; reading what was
|
|
87
|
+
// published there, not, so what it published stays read once it stops. A
|
|
88
|
+
// hidden team is no one's.
|
|
89
|
+
export const reviewsIn = (publishing: boolean) => `t.hidden = 0${publishing ? ' AND t.dependency_review = 1' : ''} AND (u.role = 'admin' OR EXISTS (
|
|
90
|
+
SELECT 1 FROM managed_team_user m WHERE m.team_id = t.id AND m.user_id = u.id
|
|
91
|
+
AND (u.role = 'manage' OR (u.role = 'triage' AND m.view_dependencies = 1))))`
|
|
92
|
+
|
|
74
93
|
// Fold legacy multi-note files into one text field without changing a frozen
|
|
75
94
|
// snapshot's stored rows. Continuing or saving uses one row per file.
|
|
76
95
|
function fileEntries(entries: ReviewEntry[]): ReviewEntry[] {
|
|
@@ -93,11 +112,18 @@ async function actor(db: ManagedSql, sessionId: string, admin = false) {
|
|
|
93
112
|
if (!(admin ? ['admin'] : ['triage', 'manage', 'admin']).includes(user.role)) throw new ManagedMutationError(403, 'forbidden')
|
|
94
113
|
return user
|
|
95
114
|
}
|
|
115
|
+
// The teams `user` publishes reviews to (reviewsIn).
|
|
96
116
|
async function teams(db: ManagedSql, user: string): Promise<ReviewTeam[]> {
|
|
97
|
-
return await db.prepare(`SELECT t.id, t.name FROM managed_team t
|
|
98
|
-
|
|
99
|
-
|
|
117
|
+
return await db.prepare(`SELECT t.id, t.name FROM managed_team t JOIN managed_user u ON u.id = ?
|
|
118
|
+
WHERE ${reviewsIn(true)} ORDER BY t.name, t.id`).all(user) as ReviewTeam[]
|
|
119
|
+
}
|
|
120
|
+
// A draft is begun or edited by one who publishes to some team; their own
|
|
121
|
+
// published review revoked, or published to more teams, as `publish` checks
|
|
122
|
+
// each, whoever they are now.
|
|
123
|
+
async function requireReviewer(db: ManagedSql, user: string) {
|
|
124
|
+
if ((await teams(db, user)).length === 0) throw new ManagedMutationError(403, 'review-access-required')
|
|
100
125
|
}
|
|
126
|
+
|
|
101
127
|
async function expandReviews(db: ManagedSql, rows: unknown[], user: string): Promise<DependencyReview[]> {
|
|
102
128
|
const reviews = rows as (Omit<DependencyReview, 'files'> & { files: string | null })[]
|
|
103
129
|
if (reviews.length === 0) return []
|
|
@@ -105,10 +131,9 @@ async function expandReviews(db: ManagedSql, rows: unknown[], user: string): Pro
|
|
|
105
131
|
const entries = await db.prepare(`SELECT review_id AS reviewId, id, file, comment, status, time
|
|
106
132
|
FROM managed_dependency_file_review WHERE review_id IN (${placeholders}) ORDER BY time, id`).all(...ids) as (ReviewEntry & { reviewId: string })[]
|
|
107
133
|
const linked = await db.prepare(`SELECT rt.review_id AS reviewId, t.id, t.name FROM managed_dependency_review_team rt
|
|
108
|
-
JOIN managed_dependency_review r ON r.id = rt.review_id JOIN managed_team t ON t.id = rt.team_id
|
|
109
|
-
WHERE rt.review_id IN (${placeholders}) AND (r.user_id =
|
|
110
|
-
|
|
111
|
-
ORDER BY t.name, t.id`).all(...ids, user, user) as (ReviewTeam & { reviewId: string })[]
|
|
134
|
+
JOIN managed_dependency_review r ON r.id = rt.review_id JOIN managed_team t ON t.id = rt.team_id JOIN managed_user u ON u.id = ?
|
|
135
|
+
WHERE rt.review_id IN (${placeholders}) AND (r.user_id = u.id OR (${reviewsIn(false)}))
|
|
136
|
+
ORDER BY t.name, t.id`).all(user, ...ids) as (ReviewTeam & { reviewId: string })[]
|
|
112
137
|
return reviews.map(review => {
|
|
113
138
|
const snapshot = review.files === null ? null : JSON.parse(review.files) as string[] | { paths: string[]; nonTextFiles: string[] }
|
|
114
139
|
const files = Array.isArray(snapshot) ? snapshot : snapshot?.paths ?? null
|
|
@@ -147,6 +172,30 @@ async function listOwn(db: ManagedSql, user: string, before: { time: number; id:
|
|
|
147
172
|
return { reviews: await expandReviews(db, page, user), next: rows.length > 50 && last ? { time: last.time, id: last.id } : null }
|
|
148
173
|
}
|
|
149
174
|
|
|
175
|
+
// A version's reviews, of its base (`context`), or of any (`allBases`), that
|
|
176
|
+
// `user` reads: their own, and those published to a team they read in.
|
|
177
|
+
async function listContext(db: ManagedSql, context: ReviewContext, user: string, allBases: boolean) {
|
|
178
|
+
const rows = await db.prepare(`SELECT ${columns} FROM managed_dependency_review r WHERE ${allBases ? targetWhere : contextWhere}
|
|
179
|
+
AND (user_id = ? OR (published_at IS NOT NULL AND EXISTS (
|
|
180
|
+
SELECT 1 FROM managed_dependency_review_team rt JOIN managed_team t ON t.id = rt.team_id JOIN managed_user u ON u.id = ?
|
|
181
|
+
WHERE rt.review_id = r.id AND ${reviewsIn(false)})))
|
|
182
|
+
ORDER BY time, id`).all(...values(context).slice(0, allBases ? 4 : 7), user, user)
|
|
183
|
+
return expandReviews(db, rows, user)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const readsTeam = async (db: ManagedSql, teamId: string, user: string) =>
|
|
187
|
+
Boolean(await db.prepare(`SELECT 1 FROM managed_team t JOIN managed_user u ON u.id = ? WHERE t.id = ? AND ${reviewsIn(false)}`).get(user, teamId))
|
|
188
|
+
|
|
189
|
+
async function listTeam(db: ManagedSql, teamId: string, user: string, before: { time: number; id: string } | null) {
|
|
190
|
+
if (!await readsTeam(db, teamId, user)) return null
|
|
191
|
+
const rows = await db.prepare(`SELECT ${columns} FROM managed_dependency_review r JOIN managed_dependency_review_team rt ON rt.review_id = r.id
|
|
192
|
+
WHERE rt.team_id = ? AND r.published_at IS NOT NULL ${before ? 'AND (r.published_at < ? OR (r.published_at = ? AND r.id < ?))' : ''}
|
|
193
|
+
ORDER BY r.published_at DESC, r.id DESC LIMIT 51`).all(teamId, ...before ? [before.time, before.time, before.id] : []) as DependencyReview[]
|
|
194
|
+
const page = rows.slice(0, 50)
|
|
195
|
+
const last = page.at(-1)
|
|
196
|
+
return { reviews: await expandReviews(db, page, user), next: rows.length > 50 && last ? { time: Number(last.publishedAt), id: last.id } : null }
|
|
197
|
+
}
|
|
198
|
+
|
|
150
199
|
async function saveFile(db: ManagedSql, reviewId: string, entry: ReviewInput, requestedId: string | null, now: number) {
|
|
151
200
|
const existing = await db.prepare(`SELECT id FROM managed_dependency_file_review
|
|
152
201
|
WHERE review_id = ? AND file = ? ORDER BY time, id`).all(reviewId, entry.file) as { id: string }[]
|
|
@@ -171,6 +220,13 @@ export function dependencyReviewMethods(db: ManagedSql): DependencyReviewStore {
|
|
|
171
220
|
const read = (id: string) => db.prepare(`SELECT ${columns} FROM managed_dependency_review WHERE id = ?`).get(id)
|
|
172
221
|
return {
|
|
173
222
|
listOwnDependencyReviews: (user, before) => listOwn(db, user, before),
|
|
223
|
+
async listOwnDependencyReviewsById(user, ids) {
|
|
224
|
+
if (ids.length === 0) return []
|
|
225
|
+
return expandReviews(db, await db.prepare(`SELECT ${columns} FROM managed_dependency_review WHERE user_id = ? AND id IN (${ids.map(() => '?').join(',')})
|
|
226
|
+
ORDER BY time, id`).all(user, ...ids), user)
|
|
227
|
+
},
|
|
228
|
+
listTeamDependencyReviews: (teamId, user, before) => listTeam(db, teamId, user, before),
|
|
229
|
+
readsTeamDependencyReviews: (teamId, user) => readsTeam(db, teamId, user),
|
|
174
230
|
async getLatestOwnDependencyReview(user, target) {
|
|
175
231
|
const row = await db.prepare(`SELECT ${columns} FROM managed_dependency_review
|
|
176
232
|
WHERE user_id = ? AND ecosystem = ? AND package = ? AND version = ? AND integrity = ? ORDER BY time DESC, id DESC LIMIT 1`)
|
|
@@ -191,16 +247,10 @@ export function dependencyReviewMethods(db: ManagedSql): DependencyReviewStore {
|
|
|
191
247
|
return true
|
|
192
248
|
},
|
|
193
249
|
listDependencyReviewTeams: user => teams(db, user),
|
|
194
|
-
|
|
195
|
-
const rows = await db.prepare(`SELECT ${columns} FROM managed_dependency_review r WHERE ${allBases ? targetWhere : contextWhere}
|
|
196
|
-
AND (user_id = ? OR (published_at IS NOT NULL AND EXISTS (
|
|
197
|
-
SELECT 1 FROM managed_dependency_review_team rt JOIN managed_team t ON t.id = rt.team_id AND t.hidden = 0
|
|
198
|
-
JOIN managed_team_user m ON m.team_id = rt.team_id WHERE rt.review_id = r.id AND m.user_id = ?)))
|
|
199
|
-
ORDER BY time, id`).all(...values(context).slice(0, allBases ? 4 : 7), user, user)
|
|
200
|
-
return expandReviews(db, rows, user)
|
|
201
|
-
},
|
|
250
|
+
listDependencyReviews: (context, user, allBases = false) => listContext(db, context, user, allBases),
|
|
202
251
|
async changeDependencyReview(context, sessionId, change, now) {
|
|
203
252
|
const user = await actor(db, sessionId)
|
|
253
|
+
if (change.action !== 'revoke' && change.action !== 'publish') await requireReviewer(db, user.id)
|
|
204
254
|
let id = change.reviewId
|
|
205
255
|
let current: DependencyReview | undefined
|
|
206
256
|
if (id) {
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// GitHub accounts as GitHub's REST and GraphQL APIs name them, where a
|
|
2
|
+
// repository's activity names its authors (repository-history.ts and the
|
|
3
|
+
// Commits, Issues, Agents and Pulse tabs' readers).
|
|
4
|
+
const LOGIN = /^[A-Za-z\d][A-Za-z\d-]{0,38}(?:\[bot\])?$/u
|
|
5
|
+
|
|
6
|
+
// An account as GitHub names it: its login, else null, and its numeric id
|
|
7
|
+
// where GitHub names one with it.
|
|
8
|
+
export interface NamedAccount { login: string | null; id: number | null }
|
|
9
|
+
export function accountOf(account: { login?: unknown; id?: unknown } | null | undefined): NamedAccount {
|
|
10
|
+
const login = typeof account?.login === 'string' && LOGIN.test(account.login) ? account.login : null
|
|
11
|
+
const id = account?.id
|
|
12
|
+
return { login, id: login && Number.isSafeInteger(id) && (id as number) > 0 ? id as number : null }
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// An actor (the author of an issue or a pull request, one who merged one) as
|
|
16
|
+
// GitHub's GraphQL API names it: its login, and its account's numeric id, a
|
|
17
|
+
// user's or a GitHub App's bot's.
|
|
18
|
+
export const ACTOR = 'login ... on User { databaseId } ... on Bot { databaseId }'
|
|
19
|
+
export type GraphAccount = { login?: unknown; databaseId?: unknown } | null | undefined
|
|
20
|
+
export const actorOf = (actor: GraphAccount) => accountOf({ login: actor?.login, id: actor?.databaseId })
|
|
21
|
+
|
|
22
|
+
// A commit's author's account, as GitHub's GraphQL API names it: the user
|
|
23
|
+
// GitHub knows its address for; else, as GitHub's REST API does, the account
|
|
24
|
+
// whose noreply address its address is, `<id>+<login>@users.noreply.github.com`:
|
|
25
|
+
// a GitHub App's bot's, which is no user (GraphQL's `user` is a User's, never
|
|
26
|
+
// a bot's).
|
|
27
|
+
const NOREPLY = /^([1-9]\d{0,11})\+([^@]+)@users\.noreply\.github\.com$/iu
|
|
28
|
+
export type GraphActor = { email?: unknown; user?: GraphAccount | null }
|
|
29
|
+
export function commitAccount(author: GraphActor | null | undefined): NamedAccount {
|
|
30
|
+
const user = actorOf(author?.user)
|
|
31
|
+
const noreply = user.id === null && typeof author?.email === 'string' ? NOREPLY.exec(author.email) : null
|
|
32
|
+
return noreply ? accountOf({ login: noreply[2], id: Number(noreply[1]) }) : user
|
|
33
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// GitHub as a risk scan resolves actions with (risk ActionsSource), read with
|
|
2
|
+
// an admin's own token, or none for public repositories alone: what GitHub
|
|
3
|
+
// has none of reads as none, anything else is thrown as a GithubApiError.
|
|
4
|
+
import { createClient } from '@preventive/upstream/github.js'
|
|
5
|
+
import type { ActionsSource } from '../risk/index.js'
|
|
6
|
+
import { GithubApiError, upstreamGithub } from './github-errors.ts'
|
|
7
|
+
import { githubJson } from './github-app.ts'
|
|
8
|
+
|
|
9
|
+
// How many pages of 100 tags are listed; a repository with more is told by
|
|
10
|
+
// its tags one at a time.
|
|
11
|
+
const MAX_TAG_PAGES = 10
|
|
12
|
+
const BRANCHES_PAGE = 100
|
|
13
|
+
|
|
14
|
+
const api = (name: string, suffix: string) => `https://api.github.com/repos/${name.split('/').map(encodeURIComponent).join('/')}${suffix}`
|
|
15
|
+
|
|
16
|
+
// Whether upstream refused what it was asked before asking GitHub, as a ref
|
|
17
|
+
// git wouldn't take: its arguments' checks say what each must be.
|
|
18
|
+
const isRefused = (error: unknown) => error instanceof Error && error.name === 'AssertionError' && / must be /u.test(error.message)
|
|
19
|
+
|
|
20
|
+
// What `read` answers, null where GitHub has none (a 404), or upstream
|
|
21
|
+
// refused to ask.
|
|
22
|
+
async function orNone<T>(read: () => Promise<T>): Promise<T | null> {
|
|
23
|
+
try {
|
|
24
|
+
return await upstreamGithub(() => read().catch((error: unknown) => { throw isRefused(error) ? new GithubApiError(404, 'github-not-found') : error }))
|
|
25
|
+
} catch (error) {
|
|
26
|
+
if (error instanceof GithubApiError && error.status === 404) return null
|
|
27
|
+
throw error
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface RepoObject { full_name?: unknown; default_branch?: unknown; fork?: unknown; parent?: { full_name?: unknown }; archived?: unknown; private?: unknown }
|
|
32
|
+
|
|
33
|
+
export function githubActionsSource(token: string | null, fetchImpl: typeof fetch = globalThis.fetch): ActionsSource {
|
|
34
|
+
const client = createClient({ token })
|
|
35
|
+
return {
|
|
36
|
+
async repo(name) {
|
|
37
|
+
const info = await orNone(() => client.getRepo({ repo: name, followRedirect: true })) as RepoObject | null
|
|
38
|
+
if (!info) return null
|
|
39
|
+
if (typeof info.full_name !== 'string' || typeof info.default_branch !== 'string') throw new GithubApiError(502, 'github-malformed')
|
|
40
|
+
return { fullName: info.full_name, defaultBranch: info.default_branch, archived: info.archived === true, private: info.private !== false,
|
|
41
|
+
fork: info.fork === true ? (typeof info.parent?.full_name === 'string' ? info.parent.full_name : '') : null }
|
|
42
|
+
},
|
|
43
|
+
tags(name) {
|
|
44
|
+
// More than MAX_TAG_PAGES pages, which upstream refuses to list whole.
|
|
45
|
+
return upstreamGithub(() => client.listRepoTags({ repo: name, maxPages: MAX_TAG_PAGES })
|
|
46
|
+
.catch((error: unknown) => { if (error instanceof Error && /more than \d+ pages/u.test(error.message)) return null; throw error }))
|
|
47
|
+
},
|
|
48
|
+
tag: async (name, tag) => (await orNone(() => client.getRepoTag({ repo: name, tag })))?.oid ?? null,
|
|
49
|
+
branch: async (name, branch) => (await orNone(() => client.getRepoHead({ repo: name, branch })))?.oid ?? null,
|
|
50
|
+
async branches(name) {
|
|
51
|
+
const listed = await githubJson(api(name, `/branches?per_page=${BRANCHES_PAGE}`), token, fetchImpl)
|
|
52
|
+
if (!Array.isArray(listed)) throw new GithubApiError(502, 'github-malformed')
|
|
53
|
+
const branches = (listed as { name?: unknown; commit?: { sha?: unknown } }[]).flatMap(item => typeof item?.name === 'string'
|
|
54
|
+
&& typeof item.commit?.sha === 'string' && /^[\da-f]{40}$/u.test(item.commit.sha) ? [{ branch: item.name, oid: item.commit.sha }] : [])
|
|
55
|
+
return { branches, complete: listed.length < BRANCHES_PAGE }
|
|
56
|
+
},
|
|
57
|
+
// `base...sha`: `sha` is `base` or in its history where GitHub tells it
|
|
58
|
+
// behind or identical, a commit of a fork in the same network too.
|
|
59
|
+
async contains(name, base, sha) {
|
|
60
|
+
if (!/^[\da-f]{40}$/u.test(sha)) return null
|
|
61
|
+
let compared
|
|
62
|
+
try { compared = await githubJson(api(name, `/compare/${base.split('/').map(encodeURIComponent).join('/')}...${sha}?per_page=1`), token, fetchImpl) as { status?: unknown } } catch (error) {
|
|
63
|
+
if (error instanceof GithubApiError && (error.status === 404 || error.message === 'github-status-422')) return null
|
|
64
|
+
throw error
|
|
65
|
+
}
|
|
66
|
+
if (typeof compared?.status !== 'string') throw new GithubApiError(502, 'github-malformed')
|
|
67
|
+
return compared.status === 'behind' || compared.status === 'identical'
|
|
68
|
+
},
|
|
69
|
+
file: (name, path, commit) => orNone(() => client.getRepoFile({ repo: name, path, ref: commit })),
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -67,20 +67,22 @@ export function mergeRepos(...lists: ConnectedRepo[][]): ConnectedRepo[] {
|
|
|
67
67
|
}
|
|
68
68
|
|
|
69
69
|
// One GitHub API call returning parsed JSON, optionally Bearer-authed by a
|
|
70
|
-
// user token, App JWT, or installation token
|
|
71
|
-
//
|
|
72
|
-
|
|
70
|
+
// user token, App JWT, or installation token, `body` sent as JSON (a GraphQL
|
|
71
|
+
// query's, given longer to answer). Network / non-2xx / malformed fold into a
|
|
72
|
+
// GithubApiError (401 passes through for the caller to handle).
|
|
73
|
+
export async function githubJson(url: string, token: string | null, fetchImpl: typeof fetch, method: 'GET' | 'POST' = 'GET', body?: unknown): Promise<unknown> {
|
|
73
74
|
let res: Response
|
|
74
75
|
const started = Date.now()
|
|
75
76
|
try {
|
|
76
77
|
res = await fetchImpl(url, {
|
|
77
78
|
method,
|
|
78
|
-
signal: AbortSignal.timeout(10_000),
|
|
79
|
+
signal: AbortSignal.timeout(body === undefined ? 10_000 : 20_000),
|
|
79
80
|
redirect: 'error',
|
|
80
81
|
headers: {
|
|
81
82
|
...(token == null ? {} : { authorization: `Bearer ${token}` }), 'accept': 'application/vnd.github+json',
|
|
82
|
-
'user-agent': USER_AGENT, 'x-github-api-version': API_VERSION,
|
|
83
|
+
...(body === undefined ? {} : { 'content-type': 'application/json' }), 'user-agent': USER_AGENT, 'x-github-api-version': API_VERSION,
|
|
83
84
|
},
|
|
85
|
+
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
84
86
|
})
|
|
85
87
|
} catch (error) { throw githubUnreachable(error, started) }
|
|
86
88
|
if (res.status === 401) discardInstallationToken(token, fetchImpl)
|
|
@@ -302,6 +304,9 @@ async function mapGithubRequests<T, U>(items: T[], work: (item: T) => Promise<U>
|
|
|
302
304
|
return results
|
|
303
305
|
}
|
|
304
306
|
|
|
307
|
+
// A GitHub account, as GitHub's /user tells it.
|
|
308
|
+
export interface GithubIdentity { id: number; login: string }
|
|
309
|
+
|
|
305
310
|
// The login App and repository App are separate: /user/repos with the login
|
|
306
311
|
// token is not evidence of private-repo access. Ask the repository App for the
|
|
307
312
|
// user's effective GitHub permission (including teams/org/enterprise grants).
|
|
@@ -310,23 +315,32 @@ async function mapGithubRequests<T, U>(items: T[], work: (item: T) => Promise<U>
|
|
|
310
315
|
// https://docs.github.com/en/rest/collaborators/collaborators#get-repository-permissions-for-a-user
|
|
311
316
|
// Resolve the current login from /user so renamed handles cannot check someone
|
|
312
317
|
// else's access. All repo paths come from GitHub's installation catalogue.
|
|
313
|
-
export async function githubUserIdentity(userToken: string, fetchImpl: typeof fetch = globalThis.fetch): Promise<
|
|
318
|
+
export async function githubUserIdentity(userToken: string, fetchImpl: typeof fetch = globalThis.fetch): Promise<GithubIdentity> {
|
|
314
319
|
const identity = await githubJson(`${GITHUB_API}/user`, userToken, fetchImpl) as { id?: unknown; login?: unknown }
|
|
315
320
|
if (typeof identity?.login !== 'string' || !identity.login || typeof identity.id !== 'number' || !Number.isSafeInteger(identity.id) || identity.id <= 0) throw new GithubApiError(502, 'github-malformed')
|
|
316
321
|
return { id: identity.id, login: identity.login }
|
|
317
322
|
}
|
|
318
323
|
|
|
319
|
-
|
|
324
|
+
// The permission's level (`admin`, `write`, `read`), as GitHub tells it of
|
|
325
|
+
// `identity` alone; `none` where it tells of none, or of another account.
|
|
326
|
+
export async function githubRepoPermission(fullName: string, token: string, identity: GithubIdentity, fetchImpl: typeof fetch = globalThis.fetch): Promise<string> {
|
|
320
327
|
const path = fullName.split('/').map(encodeURIComponent).join('/')
|
|
321
328
|
try {
|
|
322
329
|
const body = await githubJson(`${GITHUB_API}/repos/${path}/collaborators/${encodeURIComponent(identity.login)}/permission`, token, fetchImpl) as { permission?: unknown; user?: { id?: unknown } }
|
|
323
|
-
return body?.user?.id === identity.id &&
|
|
330
|
+
return body?.user?.id === identity.id && typeof body.permission === 'string' ? body.permission : 'none'
|
|
324
331
|
} catch (err) {
|
|
325
|
-
if (err instanceof GithubApiError && err.status === 404) return
|
|
332
|
+
if (err instanceof GithubApiError && err.status === 404) return 'none'
|
|
326
333
|
throw err // Never broaden access when GitHub cannot verify permission.
|
|
327
334
|
}
|
|
328
335
|
}
|
|
329
336
|
|
|
337
|
+
// The levels that read a repository.
|
|
338
|
+
export const READ_PERMISSIONS: ReadonlySet<string> = new Set(['read', 'write', 'admin'])
|
|
339
|
+
|
|
340
|
+
export async function githubRepoReadPermission(fullName: string, token: string, identity: GithubIdentity, fetchImpl: typeof fetch = globalThis.fetch): Promise<boolean> {
|
|
341
|
+
return READ_PERMISSIONS.has(await githubRepoPermission(fullName, token, identity, fetchImpl))
|
|
342
|
+
}
|
|
343
|
+
|
|
330
344
|
export async function filterInstalledRepos(config: ManagedConfig, repositories: ConnectedRepo[], userToken: string, fetchImpl: typeof fetch = globalThis.fetch): Promise<ConnectedRepo[]> {
|
|
331
345
|
const identity = await githubUserIdentity(userToken, fetchImpl)
|
|
332
346
|
const tokens = new Map<number, Promise<string | null>>()
|
|
@@ -50,6 +50,8 @@ export async function throwGithubResponseError(res: Response): Promise<never> {
|
|
|
50
50
|
export async function upstreamGithub<T>(request: () => Promise<T>): Promise<T> {
|
|
51
51
|
const started = Date.now()
|
|
52
52
|
try { return await request() } catch (error) {
|
|
53
|
+
// Already told as the router surfaces it.
|
|
54
|
+
if (error instanceof GithubApiError) throw error
|
|
53
55
|
if (error instanceof HttpError) {
|
|
54
56
|
// The upstream error includes GitHub's response text. Retain managed
|
|
55
57
|
// status handling, including rate limits, without exposing that text.
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// Linking another GitHub account to the signed-in user (github-links.ts): the
|
|
2
|
+
// App's user authorization again, completed by the account to link. GitHub is
|
|
3
|
+
// asked for its account picker, as the browser is usually signed in to the
|
|
4
|
+
// account the user signs in here with. The token GitHub grants reads the
|
|
5
|
+
// account's identity and avatar; it is never stored.
|
|
6
|
+
import type { AvatarStore } from './avatar-store.ts'
|
|
7
|
+
import type { ManagedConfig } from './config.ts'
|
|
8
|
+
import type { ManagedDb, ManagedSession } from './db.ts'
|
|
9
|
+
import { randomToken, safeEqual } from './crypto.ts'
|
|
10
|
+
import { LINK_STATE_COOKIE, buildCookie, clearCookie, cookieName, parseCookies } from './session.ts'
|
|
11
|
+
import { OAuthError, cacheAvatar, exchangeCode, fetchIdentity } from './github-oauth.ts'
|
|
12
|
+
import { ManagedMutationError } from './management.ts'
|
|
13
|
+
|
|
14
|
+
export const LINK_LOGIN_PATH = '/api/oauth/github/link'
|
|
15
|
+
// The profile page, where linking starts and its callback lands.
|
|
16
|
+
const PROFILE_PATH = '/my/profile'
|
|
17
|
+
const STATE_TTL_MS = 600_000
|
|
18
|
+
|
|
19
|
+
export function isLinkOAuthCallback(config: ManagedConfig, query: URLSearchParams, cookie: string | undefined): boolean {
|
|
20
|
+
const expected = parseCookies(cookie).get(cookieName(config, LINK_STATE_COOKIE))?.split('.')[0]
|
|
21
|
+
return !!expected && safeEqual(query.get('state') ?? '', expected)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// The state is bound to the session that started linking, so the account
|
|
25
|
+
// links to the user who asked, never to whoever is signed in on return.
|
|
26
|
+
export function linkLoginRedirect(config: ManagedConfig, session: ManagedSession, now = Date.now()): { location: string; setCookie: string } {
|
|
27
|
+
const state = randomToken()
|
|
28
|
+
const url = new URL('https://github.com/login/oauth/authorize')
|
|
29
|
+
url.searchParams.set('client_id', config.githubClientId)
|
|
30
|
+
url.searchParams.set('redirect_uri', config.oauthCallbackUrl)
|
|
31
|
+
url.searchParams.set('state', state)
|
|
32
|
+
url.searchParams.set('allow_signup', 'false')
|
|
33
|
+
url.searchParams.set('prompt', 'select_account')
|
|
34
|
+
return { location: url.href, setCookie: buildCookie(cookieName(config, LINK_STATE_COOKIE), `${state}.${session.id}.${now}`,
|
|
35
|
+
{ secure: config.cookieSecure, maxAgeS: STATE_TTL_MS / 1000, sameSite: 'Lax' }) }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Back from GitHub: link the account that authorized, then land on the
|
|
39
|
+
// profile, naming the refusal (`link-error`) when it was refused.
|
|
40
|
+
// `linked` is the login of an account linked anew.
|
|
41
|
+
export async function linkOAuthCallback(config: ManagedConfig, db: ManagedDb, session: ManagedSession,
|
|
42
|
+
query: URLSearchParams, cookie: string | undefined,
|
|
43
|
+
{ avatarStore, fetchImpl = fetch, now = Date.now() }: { avatarStore?: AvatarStore; fetchImpl?: typeof fetch; now?: number } = {},
|
|
44
|
+
): Promise<{ location: string; setCookie: string; linked: string | null }> {
|
|
45
|
+
const setCookie = clearCookie(cookieName(config, LINK_STATE_COOKIE), config.cookieSecure)
|
|
46
|
+
try {
|
|
47
|
+
const [expected, sessionId, timestamp] = (parseCookies(cookie).get(cookieName(config, LINK_STATE_COOKIE)) ?? '').split('.')
|
|
48
|
+
const code = query.get('code'), started = Number(timestamp), state = query.get('state')
|
|
49
|
+
if (!state || !expected || !safeEqual(state, expected) || sessionId !== session.id
|
|
50
|
+
|| !timestamp || !Number.isFinite(started) || now < started || now - started > STATE_TTL_MS) {
|
|
51
|
+
throw new OAuthError(400, 'invalid-oauth-state')
|
|
52
|
+
}
|
|
53
|
+
// Declined on GitHub's side, which sends an error in place of a code.
|
|
54
|
+
if (!code) throw new OAuthError(400, query.get('error') ? 'github-authorization-declined' : 'invalid-oauth-state')
|
|
55
|
+
const tokens = await exchangeCode(config, code, now, fetchImpl)
|
|
56
|
+
const account = await fetchIdentity(tokens.accessToken, fetchImpl)
|
|
57
|
+
const link = await db.linkGithubAccount(session.id, account, now)
|
|
58
|
+
// Best-effort, as at sign-in: the avatar falls back to an initial.
|
|
59
|
+
if (avatarStore != null && account.avatarUrl != null) {
|
|
60
|
+
await cacheAvatar(avatarStore, link.id, account.avatarUrl, fetchImpl).catch((err) => {
|
|
61
|
+
console.warn('managed: linked avatar cache failed:', err)
|
|
62
|
+
})
|
|
63
|
+
}
|
|
64
|
+
return { location: PROFILE_PATH, setCookie, linked: link.created ? account.login : null }
|
|
65
|
+
} catch (err) {
|
|
66
|
+
if (!(err instanceof OAuthError || err instanceof ManagedMutationError)) throw err
|
|
67
|
+
return { location: `${PROFILE_PATH}?${new URLSearchParams({ 'link-error': err.message })}`, setCookie, linked: null }
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
// GitHub accounts a user links to their own on the profile page. A linked
|
|
2
|
+
// account's identity is reserved for that user: it can neither sign in nor
|
|
3
|
+
// register (upsertUser refuses it in the transaction that would create the
|
|
4
|
+
// user), and an account that has registered cannot be linked. The user's
|
|
5
|
+
// login and GitHub id stay the sign-in account's; the displayed name and
|
|
6
|
+
// avatar may come from any of their accounts.
|
|
7
|
+
import { randomUUID } from 'node:crypto'
|
|
8
|
+
import type { ManagedUser } from './db-methods.ts'
|
|
9
|
+
import { ManagedMutationError } from './management.ts'
|
|
10
|
+
import type { ManagedSql } from './sql.ts'
|
|
11
|
+
|
|
12
|
+
// login/name are snapshots of the account as it was last linked; linking it
|
|
13
|
+
// again refreshes them. Its avatar is cached under the link id (avatar-store.ts).
|
|
14
|
+
export const GITHUB_LINK_SCHEMA = `
|
|
15
|
+
CREATE TABLE IF NOT EXISTS managed_github_link (
|
|
16
|
+
id TEXT PRIMARY KEY,
|
|
17
|
+
user_id TEXT NOT NULL REFERENCES managed_user(id) ON DELETE CASCADE,
|
|
18
|
+
github_user_id INTEGER NOT NULL UNIQUE,
|
|
19
|
+
login TEXT NOT NULL,
|
|
20
|
+
name TEXT,
|
|
21
|
+
linked_at INTEGER NOT NULL
|
|
22
|
+
) STRICT;
|
|
23
|
+
|
|
24
|
+
CREATE INDEX IF NOT EXISTS managed_github_link_user_idx ON managed_github_link(user_id);
|
|
25
|
+
`
|
|
26
|
+
|
|
27
|
+
// managed_user columns added with the table. managed_user.name is the
|
|
28
|
+
// displayed name; github_name keeps the sign-in account's own. name_link and
|
|
29
|
+
// avatar_link name the linked account each comes from, NULL the sign-in one.
|
|
30
|
+
export const GITHUB_LINK_USER_COLUMNS: readonly (readonly [string, string])[] = [
|
|
31
|
+
['github_name', 'TEXT'],
|
|
32
|
+
['name_link', 'TEXT REFERENCES managed_github_link(id) ON DELETE SET NULL'],
|
|
33
|
+
['avatar_link', 'TEXT REFERENCES managed_github_link(id) ON DELETE SET NULL'],
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
export interface LinkedGithubAccount {
|
|
37
|
+
id: string
|
|
38
|
+
// Its GitHub account's numeric id, which GitHub never gives another.
|
|
39
|
+
githubId: number
|
|
40
|
+
login: string
|
|
41
|
+
name: string | null
|
|
42
|
+
linkedAt: number
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface ManagedProfile {
|
|
46
|
+
// Always the sign-in account's.
|
|
47
|
+
login: string
|
|
48
|
+
// The displayed name, and the sign-in account's own GitHub name.
|
|
49
|
+
name: string | null
|
|
50
|
+
githubName: string | null
|
|
51
|
+
// The linked account the displayed name or avatar comes from, null the sign-in one.
|
|
52
|
+
nameAccount: string | null
|
|
53
|
+
avatarAccount: string | null
|
|
54
|
+
accounts: LinkedGithubAccount[]
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// A user as the app shows them where a GitHub login of theirs is named: by
|
|
58
|
+
// their id, whose avatar /api/avatar/<id> serves (the one they chose), their
|
|
59
|
+
// sign-in login and the name they chose.
|
|
60
|
+
export interface AppUser {
|
|
61
|
+
id: string
|
|
62
|
+
login: string
|
|
63
|
+
name: string | null
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Which account the displayed name and avatar each come from: a linked
|
|
67
|
+
// account's id, or null for the sign-in account. An absent key is unchanged.
|
|
68
|
+
export interface ProfileChoice {
|
|
69
|
+
name?: string | null
|
|
70
|
+
avatar?: string | null
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface GithubLinkStore {
|
|
74
|
+
getProfile(userId: string): Promise<ManagedProfile | null>
|
|
75
|
+
// Link `account` to the session's own user, or refresh the snapshot of one
|
|
76
|
+
// they linked before. Refused for the sign-in account itself, an account
|
|
77
|
+
// another user linked, and any account that has registered.
|
|
78
|
+
linkGithubAccount(sessionId: string, account: ManagedUser, now: number): Promise<{ id: string; created: boolean }>
|
|
79
|
+
// Unlink one of the session user's accounts, which may then sign in or
|
|
80
|
+
// register again; a name or avatar it gave falls back to the sign-in
|
|
81
|
+
// account's. Null when the user has no such link.
|
|
82
|
+
unlinkGithubAccount(sessionId: string, id: string, now: number): Promise<LinkedGithubAccount | null>
|
|
83
|
+
setProfileChoice(sessionId: string, choice: ProfileChoice, now: number): Promise<void>
|
|
84
|
+
// The avatar to serve for `id`: a user's chosen linked account's, else `id`'s own.
|
|
85
|
+
getAvatarKey(id: string): Promise<string>
|
|
86
|
+
// The approved users signing in with, or having linked, each of
|
|
87
|
+
// `accounts`, by the login as asked: by the account's numeric id where it
|
|
88
|
+
// is known, which GitHub never gives another; else by its login (matched
|
|
89
|
+
// regardless of case, as GitHub matches them), one more than one user
|
|
90
|
+
// claims, renamed on GitHub since another signed in with it or linked it,
|
|
91
|
+
// left out. No access users are left out.
|
|
92
|
+
listUsersByAccount(accounts: readonly GithubAccount[]): Promise<Record<string, AppUser>>
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// A GitHub account a repository's activity names: by its login, and its
|
|
96
|
+
// numeric id where GitHub told it.
|
|
97
|
+
export interface GithubAccount { login: string; githubId: number | null }
|
|
98
|
+
|
|
99
|
+
type LinkRow = { id: string; githubId: number; login: string; name: string | null; linkedAt: number }
|
|
100
|
+
const accountOf = (row: LinkRow): LinkedGithubAccount => ({ id: row.id, githubId: row.githubId, login: row.login, name: row.name, linkedAt: row.linkedAt })
|
|
101
|
+
|
|
102
|
+
// GithubLinkStore.listUsersByAccount.
|
|
103
|
+
async function usersByAccount(db: ManagedSql, accounts: readonly GithubAccount[]): Promise<Record<string, AppUser>> {
|
|
104
|
+
if (accounts.length === 0) return {}
|
|
105
|
+
const ids = accounts.flatMap(account => account.githubId ?? [])
|
|
106
|
+
const logins = accounts.flatMap(account => account.githubId === null ? [account.login] : [])
|
|
107
|
+
const rows = await db.prepare(`SELECT a.login, a.githubId, u.id, u.login AS appLogin, u.name, u.role FROM (
|
|
108
|
+
SELECT login, github_user_id AS githubId, id AS claimant FROM managed_user
|
|
109
|
+
UNION ALL SELECT login, github_user_id AS githubId, user_id AS claimant FROM managed_github_link
|
|
110
|
+
) a JOIN managed_user u ON u.id = a.claimant
|
|
111
|
+
WHERE a.githubId IN (SELECT CAST(value AS BIGINT) FROM json_each(?)) OR lower(a.login) IN (SELECT lower(value) FROM json_each(?))`)
|
|
112
|
+
.all(JSON.stringify(ids), JSON.stringify(logins)) as { login: string; githubId: number; id: string; appLogin: string; name: string | null; role: string }[]
|
|
113
|
+
const byId = new Map(rows.map(row => [row.githubId, row]))
|
|
114
|
+
const claims = new Map<string, typeof rows>()
|
|
115
|
+
for (const row of rows) claims.set(row.login.toLowerCase(), [...claims.get(row.login.toLowerCase()) ?? [], row])
|
|
116
|
+
const users: Record<string, AppUser> = {}
|
|
117
|
+
for (const { login, githubId } of accounts) {
|
|
118
|
+
const [user, ...others] = githubId === null ? claims.get(login.toLowerCase()) ?? [] : byId.has(githubId) ? [byId.get(githubId)!] : []
|
|
119
|
+
if (!user || user.role === 'none' || others.some(other => other.id !== user.id)) continue
|
|
120
|
+
users[login] = { id: user.id, login: user.appLogin, name: user.name }
|
|
121
|
+
}
|
|
122
|
+
return users
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function githubLinkMethods(db: ManagedSql): GithubLinkStore {
|
|
126
|
+
// Linking is the session user's own act: never an admin's view as them,
|
|
127
|
+
// nor an account that has not been approved for the workspace.
|
|
128
|
+
async function sessionUser(sessionId: string, now: number): Promise<{ id: string; githubId: number }> {
|
|
129
|
+
const row = await db.prepare(`SELECT u.id, u.github_user_id AS githubId, u.role FROM managed_session s JOIN managed_user u ON u.id = s.user_id
|
|
130
|
+
WHERE s.id = ? AND s.expires_at > ? AND s.viewer_session IS NULL`).get(sessionId, now) as { id: string; githubId: number; role: string } | undefined
|
|
131
|
+
if (!row) throw new ManagedMutationError(401, 'unauthenticated')
|
|
132
|
+
if (row.role === 'none') throw new ManagedMutationError(403, 'forbidden')
|
|
133
|
+
return row
|
|
134
|
+
}
|
|
135
|
+
const linkOf = (userId: string, id: string) => db.prepare(`SELECT id, github_user_id AS githubId, login, name, linked_at AS linkedAt FROM managed_github_link
|
|
136
|
+
WHERE id = ? AND user_id = ?`).get(id, userId) as Promise<LinkRow | undefined>
|
|
137
|
+
|
|
138
|
+
return {
|
|
139
|
+
async getProfile(userId) {
|
|
140
|
+
const user = await db.prepare(`SELECT login, name, github_name AS githubName, name_link AS nameAccount, avatar_link AS avatarAccount
|
|
141
|
+
FROM managed_user WHERE id = ?`).get(userId) as Omit<ManagedProfile, 'accounts'> | undefined
|
|
142
|
+
if (!user) return null
|
|
143
|
+
const accounts = await db.prepare(`SELECT id, github_user_id AS githubId, login, name, linked_at AS linkedAt FROM managed_github_link
|
|
144
|
+
WHERE user_id = ? ORDER BY linked_at, id`).all(userId) as LinkRow[]
|
|
145
|
+
return { ...user, accounts: accounts.map(accountOf) }
|
|
146
|
+
},
|
|
147
|
+
async linkGithubAccount(sessionId, account, now) {
|
|
148
|
+
const user = await sessionUser(sessionId, now)
|
|
149
|
+
if (account.githubUserId === user.githubId) throw new ManagedMutationError(409, 'github-account-own')
|
|
150
|
+
if (await db.prepare('SELECT 1 AS found FROM managed_user WHERE github_user_id = ?').get(account.githubUserId)) {
|
|
151
|
+
throw new ManagedMutationError(409, 'github-account-registered')
|
|
152
|
+
}
|
|
153
|
+
const existing = await db.prepare('SELECT id, user_id AS userId FROM managed_github_link WHERE github_user_id = ?')
|
|
154
|
+
.get(account.githubUserId) as { id: string; userId: string } | undefined
|
|
155
|
+
if (existing && existing.userId !== user.id) throw new ManagedMutationError(409, 'github-account-linked')
|
|
156
|
+
if (existing) {
|
|
157
|
+
await db.prepare('UPDATE managed_github_link SET login = ?, name = ? WHERE id = ?').run(account.login, account.name, existing.id)
|
|
158
|
+
await db.prepare('UPDATE managed_user SET name = ?, updated_at = ? WHERE id = ? AND name_link = ?').run(account.name, now, user.id, existing.id)
|
|
159
|
+
return { id: existing.id, created: false }
|
|
160
|
+
}
|
|
161
|
+
const id = randomUUID()
|
|
162
|
+
await db.prepare(`INSERT INTO managed_github_link (id, user_id, github_user_id, login, name, linked_at)
|
|
163
|
+
VALUES (?, ?, ?, ?, ?, ?)`).run(id, user.id, account.githubUserId, account.login, account.name, now)
|
|
164
|
+
return { id, created: true }
|
|
165
|
+
},
|
|
166
|
+
async unlinkGithubAccount(sessionId, id, now) {
|
|
167
|
+
const user = await sessionUser(sessionId, now)
|
|
168
|
+
const link = await linkOf(user.id, id)
|
|
169
|
+
if (!link) return null
|
|
170
|
+
await db.prepare(`UPDATE managed_user SET
|
|
171
|
+
name = CASE WHEN name_link = :link THEN github_name ELSE name END,
|
|
172
|
+
name_link = CASE WHEN name_link = :link THEN NULL ELSE name_link END,
|
|
173
|
+
avatar_link = CASE WHEN avatar_link = :link THEN NULL ELSE avatar_link END,
|
|
174
|
+
updated_at = :now
|
|
175
|
+
WHERE id = :user`).run({ link: id, now, user: user.id })
|
|
176
|
+
await db.prepare('DELETE FROM managed_github_link WHERE id = ?').run(id)
|
|
177
|
+
return accountOf(link)
|
|
178
|
+
},
|
|
179
|
+
async setProfileChoice(sessionId, choice, now) {
|
|
180
|
+
const user = await sessionUser(sessionId, now)
|
|
181
|
+
const links = new Map<string, LinkRow>()
|
|
182
|
+
for (const id of [choice.name, choice.avatar]) {
|
|
183
|
+
if (id == null || links.has(id)) continue
|
|
184
|
+
const link = await linkOf(user.id, id)
|
|
185
|
+
if (!link) throw new ManagedMutationError(404, 'no-account')
|
|
186
|
+
links.set(id, link)
|
|
187
|
+
}
|
|
188
|
+
if (choice.name !== undefined) {
|
|
189
|
+
// A linked account without a name leaves the user without one too.
|
|
190
|
+
await db.prepare(`UPDATE managed_user SET name_link = :link,
|
|
191
|
+
name = CASE WHEN :link IS NULL THEN github_name ELSE :name END, updated_at = :now
|
|
192
|
+
WHERE id = :user`).run({ link: choice.name, name: choice.name === null ? null : links.get(choice.name)!.name, now, user: user.id })
|
|
193
|
+
}
|
|
194
|
+
if (choice.avatar !== undefined) {
|
|
195
|
+
await db.prepare('UPDATE managed_user SET avatar_link = ?, updated_at = ? WHERE id = ?').run(choice.avatar, now, user.id)
|
|
196
|
+
}
|
|
197
|
+
},
|
|
198
|
+
async getAvatarKey(id) {
|
|
199
|
+
const row = await db.prepare('SELECT avatar_link AS link FROM managed_user WHERE id = ?').get(id) as { link: string | null } | undefined
|
|
200
|
+
return row?.link ?? id
|
|
201
|
+
},
|
|
202
|
+
listUsersByAccount: accounts => usersByAccount(db, accounts),
|
|
203
|
+
}
|
|
204
|
+
}
|
|
@@ -63,7 +63,10 @@ export async function handleCallback(
|
|
|
63
63
|
}
|
|
64
64
|
const tokens = await exchangeCode(config, code, now, fetchImpl)
|
|
65
65
|
const user = await fetchIdentity(tokens.accessToken, fetchImpl)
|
|
66
|
-
const
|
|
66
|
+
const session = await createSession(config, db, user, now)
|
|
67
|
+
// Linked to another user's account on their profile (github-links.ts).
|
|
68
|
+
if (session == null) throw new OAuthError(403, 'github-account-linked')
|
|
69
|
+
const { setCookie, userId } = session
|
|
67
70
|
// Persist the user token so the repositories page can list repos on demand.
|
|
68
71
|
await db.setUserTokens(userId, tokens)
|
|
69
72
|
// Cache the avatar for same-origin serving (the page's CSP forbids loading
|
|
@@ -216,7 +219,7 @@ function parseGithubUser(body: unknown): ManagedUser | null {
|
|
|
216
219
|
// Fetch the user's GitHub avatar and cache it for same-origin serving. The host
|
|
217
220
|
// is validated (githubusercontent.com over https) as SSRF defence-in-depth,
|
|
218
221
|
// even though the URL comes from GitHub's own /user response.
|
|
219
|
-
async function cacheAvatar(store: AvatarStore, id: string, avatarUrl: string, fetchImpl: typeof fetch): Promise<void> {
|
|
222
|
+
export async function cacheAvatar(store: AvatarStore, id: string, avatarUrl: string, fetchImpl: typeof fetch): Promise<void> {
|
|
220
223
|
if (!isGithubAvatarUrl(avatarUrl)) return
|
|
221
224
|
// `redirect: 'manual'` so a 3xx off the (validated) avatars host can't bounce
|
|
222
225
|
// the fetch to an internal address — an opaque-redirect / 3xx fails the
|