@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.
Files changed (183) hide show
  1. package/LICENSE-APACHE +202 -0
  2. package/api/reap.ts +17 -0
  3. package/cli.js +6 -0
  4. package/client/finding-link.js +305 -0
  5. package/client/linked-findings.d.ts +1 -0
  6. package/client/linked-findings.js +111 -0
  7. package/common/bundle-commit.d.ts +1 -0
  8. package/common/bundle-commit.js +4 -0
  9. package/common/bundle-metadata.d.ts +10 -0
  10. package/common/bundle-metadata.js +182 -0
  11. package/common/bundle-reasons.d.ts +2 -0
  12. package/common/bundle-reasons.js +21 -0
  13. package/common/bundle-source-links.d.ts +5 -0
  14. package/common/bundle-source-links.js +82 -0
  15. package/common/bundle-sources.d.ts +3 -0
  16. package/common/bundle-sources.js +284 -0
  17. package/common/bundle-stats.js +41 -0
  18. package/common/bundle-tabs.js +1 -0
  19. package/common/code-language.js +51 -0
  20. package/common/default-scan-models.ts +30 -0
  21. package/common/finding-id.js +47 -0
  22. package/common/github-issue-labels.d.ts +1 -0
  23. package/common/github-issue-labels.js +11 -0
  24. package/common/github-pr.ts +56 -0
  25. package/common/managed/comments.ts +34 -0
  26. package/common/managed/permissions.ts +35 -0
  27. package/common/managed/report-content.ts +42 -0
  28. package/common/managed/report-filter.ts +108 -0
  29. package/common/managed/roles.ts +28 -0
  30. package/common/managed/routes.d.ts +2 -0
  31. package/common/managed/routes.js +124 -0
  32. package/common/managed/scan-models.ts +6 -0
  33. package/common/managed/triage.ts +83 -0
  34. package/common/save-error-reason.ts +20 -7
  35. package/common/scan-server.ts +13 -0
  36. package/common/server-info.ts +35 -0
  37. package/common/utf8.d.ts +3 -0
  38. package/common/utf8.js +45 -0
  39. package/out/brotli-fallback.js +3 -3
  40. package/out/client-managed-import.js +83 -0
  41. package/out/client-managed.js +169 -0
  42. package/out/client-sync.js +16 -13
  43. package/out/github-issue-authorized.html +3 -0
  44. package/out/github-issue-authorized.js +1 -0
  45. package/out/graph.js +30 -4
  46. package/out/index.html +55 -8
  47. package/out/prism.js +2 -2
  48. package/out/stasis.svg +20 -0
  49. package/out/terminal.js +273 -39
  50. package/out/view.css +1 -1
  51. package/out/view.js +259 -63
  52. package/package.json +205 -57
  53. package/server-common/database-config.ts +16 -0
  54. package/server-common/initialize.ts +18 -0
  55. package/server-common/npm-advisories.ts +101 -0
  56. package/{server → server-common}/origin.ts +5 -5
  57. package/server-common/reap.ts +48 -0
  58. package/server-common/scan-config.ts +19 -0
  59. package/server-common/standalone.ts +29 -0
  60. package/server-common/storage-crypto.ts +182 -0
  61. package/server-common/storage-log.ts +38 -0
  62. package/server-common/vercel-blob.ts +103 -0
  63. package/server-e2e/app.ts +486 -0
  64. package/{server → server-e2e}/auth.ts +5 -1
  65. package/{server → server-e2e}/bus-receiver.ts +2 -1
  66. package/{server → server-e2e}/cli.js +9 -4
  67. package/{server → server-e2e}/config.ts +56 -35
  68. package/{server → server-e2e}/db-neon.ts +2 -2
  69. package/{server → server-e2e}/db-revision-sql.ts +1 -1
  70. package/{server → server-e2e}/db-stmt.ts +2 -2
  71. package/{server → server-e2e}/db.ts +7 -7
  72. package/{server → server-e2e}/http.ts +110 -12
  73. package/{server → server-e2e}/hub.ts +40 -9
  74. package/server-e2e/index.ts +17 -0
  75. package/server-e2e/lifecycle.ts +95 -0
  76. package/{server → server-e2e}/neon-driver.ts +2 -2
  77. package/{server → server-e2e}/npm-proxy.ts +11 -144
  78. package/{server → server-e2e}/objstore/blob-fs.ts +2 -2
  79. package/{server → server-e2e}/objstore/blob-vercel.ts +13 -117
  80. package/{server → server-e2e}/objstore/blob.ts +9 -2
  81. package/server-e2e/objstore/fetch-mint-guard.ts +74 -0
  82. package/{server → server-e2e}/objstore/handlers.ts +9 -8
  83. package/{server → server-e2e}/objstore/init.ts +35 -18
  84. package/{server → server-e2e}/objstore/reaper.ts +31 -11
  85. package/server-e2e/objstore/rest-deny.ts +28 -0
  86. package/server-e2e/objstore/rest-mint.ts +224 -0
  87. package/{server → server-e2e}/objstore/rest.ts +58 -33
  88. package/{server → server-e2e}/objstore/sign.ts +105 -0
  89. package/{server → server-e2e}/objstore/store-neon.ts +6 -10
  90. package/{server → server-e2e}/objstore/store.ts +7 -7
  91. package/{server → server-e2e}/objstore/tokens.ts +1 -1
  92. package/{server → server-e2e}/pubsub.ts +12 -9
  93. package/{server → server-e2e}/sign.ts +4 -4
  94. package/{server → server-e2e}/sse-server.ts +102 -68
  95. package/{server → server-e2e}/sse-session.ts +28 -8
  96. package/{server → server-e2e}/static.ts +31 -21
  97. package/server-e2e/sync-handlers.ts +408 -0
  98. package/{server → server-e2e}/util.ts +9 -0
  99. package/{server → server-e2e}/ws-server.ts +20 -9
  100. package/server-managed/activity.ts +231 -0
  101. package/server-managed/avatar-store.ts +12 -0
  102. package/server-managed/blob-store.ts +17 -0
  103. package/server-managed/brotli.ts +10 -0
  104. package/server-managed/bundle-advisories.ts +53 -0
  105. package/server-managed/bundle-advisory-inventory.ts +75 -0
  106. package/server-managed/bundle-build-diagnostics.d.ts +10 -0
  107. package/server-managed/bundle-build-diagnostics.js +23 -0
  108. package/server-managed/bundle-build-leases.ts +41 -0
  109. package/server-managed/bundle-build-worker.js +54 -0
  110. package/server-managed/bundle-build.ts +168 -0
  111. package/server-managed/bundle-cache.ts +166 -0
  112. package/server-managed/bundle-catalog.ts +29 -0
  113. package/server-managed/bundle-store.ts +24 -0
  114. package/server-managed/bundle-summary-cache.ts +116 -0
  115. package/server-managed/bundle.ts +104 -0
  116. package/server-managed/cache-storage.ts +19 -0
  117. package/server-managed/cli.js +21 -0
  118. package/server-managed/combined.ts +46 -0
  119. package/server-managed/comments.ts +151 -0
  120. package/server-managed/config.ts +149 -0
  121. package/server-managed/content-access.ts +15 -0
  122. package/server-managed/crypto.ts +25 -0
  123. package/server-managed/db-methods.ts +1517 -0
  124. package/server-managed/db-neon.ts +277 -0
  125. package/server-managed/db-schema.ts +206 -0
  126. package/server-managed/db-table-names.ts +22 -0
  127. package/server-managed/db.ts +120 -0
  128. package/server-managed/github-app.ts +385 -0
  129. package/server-managed/github-errors.ts +49 -0
  130. package/server-managed/github-issue-oauth.ts +48 -0
  131. package/server-managed/github-issues.ts +152 -0
  132. package/server-managed/github-metadata.ts +98 -0
  133. package/server-managed/github-oauth.ts +199 -0
  134. package/server-managed/github-pulls.ts +227 -0
  135. package/server-managed/http-response.ts +18 -0
  136. package/server-managed/http.ts +2350 -0
  137. package/server-managed/import-triage.ts +51 -0
  138. package/server-managed/index.ts +175 -0
  139. package/server-managed/managed-issues.ts +47 -0
  140. package/server-managed/management-catalog.ts +45 -0
  141. package/server-managed/management.ts +86 -0
  142. package/server-managed/object-storage-disk.ts +144 -0
  143. package/server-managed/object-storage-vercel.ts +63 -0
  144. package/server-managed/object-storage.ts +71 -0
  145. package/server-managed/package-entry-points.ts +51 -0
  146. package/server-managed/public-workspace.ts +146 -0
  147. package/server-managed/repo-path.ts +21 -0
  148. package/server-managed/report-migration.ts +35 -0
  149. package/server-managed/report-query.ts +10 -0
  150. package/server-managed/report-response.ts +16 -0
  151. package/server-managed/report-sources.ts +166 -0
  152. package/server-managed/repository-browser.ts +139 -0
  153. package/server-managed/repository-discovery.ts +82 -0
  154. package/server-managed/repository-policy.ts +27 -0
  155. package/server-managed/revisions.ts +35 -0
  156. package/server-managed/session.ts +78 -0
  157. package/server-managed/slugs.ts +38 -0
  158. package/server-managed/solidity-entry-points.ts +69 -0
  159. package/server-managed/sql-postgres.ts +31 -0
  160. package/server-managed/sql.ts +72 -0
  161. package/server-managed/static.ts +28 -0
  162. package/server-managed/storage-command.ts +16 -0
  163. package/server-managed/storage-db.ts +175 -0
  164. package/server-managed/storage-encryption.ts +211 -0
  165. package/server-managed/storage-maintenance.ts +223 -0
  166. package/server-managed/storage-payload.ts +72 -0
  167. package/server-managed/storage-stores.ts +81 -0
  168. package/server-managed/storage.ts +58 -0
  169. package/server-managed/team-catalog.ts +7 -0
  170. package/server-managed/team-feed.ts +173 -0
  171. package/server-managed/team-reports.ts +219 -0
  172. package/server-managed/triage-response.ts +16 -0
  173. package/server-managed/uploads.ts +65 -0
  174. package/server-managed/workspace-shares.ts +162 -0
  175. package/server.ts +50 -0
  176. package/server/index.ts +0 -477
  177. package/server/lifecycle.ts +0 -204
  178. package/server/sync-handlers.ts +0 -320
  179. /package/{LICENSE → LICENSE-MIT} +0 -0
  180. /package/{server → server-e2e}/config.example.json +0 -0
  181. /package/{server → server-e2e}/objstore/fs.ts +0 -0
  182. /package/{server → server-e2e}/peer.ts +0 -0
  183. /package/{server → server-e2e}/validation.ts +0 -0
@@ -0,0 +1,46 @@
1
+ // Compose the existing HTTP apps on e2e's listener, including its WS/SSE
2
+ // transports and static UI. Managed handles its API, discovery and page HTML;
3
+ // assets and other requests fall through to the unchanged e2e HTTP handler.
4
+ import { resolve } from 'node:path'
5
+ import type { Server } from 'node:http'
6
+ import { withReap } from '../server-common/reap.ts'
7
+ import { loadConfig } from '../server-e2e/config.ts'
8
+ import { createE2eApp } from '../server-e2e/app.ts'
9
+ import { loadManagedConfig } from './config.ts'
10
+ import { LOGIN_PATH } from './github-oauth.ts'
11
+ import { createManagedApp, logManagedStartup } from './index.ts'
12
+
13
+ export async function init(mode: 'managed+e2e' | 'e2e+managed'): Promise<Server> {
14
+ const config = loadManagedConfig({ combined: true })
15
+ const e2eConfig = loadConfig()
16
+ if (!e2eConfig.neonUrl && !config.neonUrl && config.dbPath !== ':memory:' && resolve(config.dbPath) === resolve(e2eConfig.dbPath)) {
17
+ throw new Error('MANAGED_DB_PATH must differ from e2e DB_PATH in combined mode.')
18
+ }
19
+ const e2e = await createE2eApp(e2eConfig)
20
+ // Replace the assembled server's single
21
+ // request listener; adding a second would write two responses per request.
22
+ const handlers = e2e.httpServer.listeners('request')
23
+ const next = handlers[0]
24
+ if (handlers.length !== 1 || !next) throw new Error('Expected one e2e HTTP request handler')
25
+ const managed = await createManagedApp(config, {
26
+ next: e2e.handleRequest,
27
+ isShuttingDown: e2e.isShuttingDown,
28
+ serverInfo: {
29
+ mode, managed: { loginPath: LOGIN_PATH, cookieName: config.sessionCookieName },
30
+ ...(e2eConfig.deepviewScanServer ? { deepviewScanServer: e2eConfig.deepviewScanServer } : {}),
31
+ },
32
+ }).catch(async err => {
33
+ await e2e.httpServer[Symbol.asyncDispose]()
34
+ throw err
35
+ })
36
+ e2e.onShutdown(managed.close)
37
+ e2e.httpServer.removeListener('request', next)
38
+ e2e.httpServer.on('request', withReap(managed.handleRequest,
39
+ { e2e: e2e.reap, managed: managed.reap }, { isShuttingDown: e2e.isShuttingDown }))
40
+ e2e.httpServer.once('listening', () => {
41
+ const address = e2e.httpServer.address()
42
+ const port = typeof address === 'object' && address ? address.port : config.port
43
+ logManagedStartup(config, port, mode)
44
+ })
45
+ return e2e.httpServer
46
+ }
@@ -0,0 +1,151 @@
1
+ import { randomUUID } from 'node:crypto'
2
+ import type { DatabaseSync } from 'node:sqlite'
3
+ import { type ManagedComment, canDeleteComment } from '../common/managed/comments.ts'
4
+ import type { ManagedSql } from './sql.ts'
5
+
6
+ export interface CommentInput {
7
+ findingId: string
8
+ body: string
9
+ // Runtime imports may omit authorship, including future admin UI imports.
10
+ // Ordinary comment posts always supply the authenticated user as author.
11
+ authorId: string | null
12
+ authorLogin: string | null
13
+ // The user performing an import need not be the author of its comments.
14
+ // Omitted for ordinary posts, where the author is also the acting user.
15
+ actor?: { id: string; login: string }
16
+ // Omitted dates default to the action time; null preserves unknown dates.
17
+ createdAt?: number | null
18
+ updatedAt?: number | null
19
+ reportId?: string | null
20
+ }
21
+
22
+ export interface CommentStore {
23
+ listComments(findingIds: readonly string[]): Promise<ManagedComment[]>
24
+ listCommentedFindingIds(findingIds: readonly string[]): Promise<string[]>
25
+ getComment(id: string): Promise<ManagedComment | null>
26
+ createComment(input: CommentInput, now: number): Promise<ManagedComment>
27
+ editComment(id: string, authorId: string, authorLogin: string, body: string, version: number, reportId: string, now: number): Promise<ManagedComment | 'conflict' | 'forbidden' | null>
28
+ deleteComment(id: string, actorId: string, actorLogin: string, version: number, reportId: string, now: number): Promise<'deleted' | 'conflict' | 'forbidden' | null>
29
+ }
30
+
31
+ const COMMENT_COLUMNS = `id TEXT PRIMARY KEY, finding_id TEXT NOT NULL, body TEXT NOT NULL,
32
+ author_id TEXT REFERENCES managed_user(id) ON DELETE SET NULL,
33
+ author_login TEXT, created_at INTEGER, updated_at INTEGER,
34
+ version INTEGER NOT NULL DEFAULT 1`
35
+
36
+ export const COMMENT_SCHEMA = `
37
+ CREATE TABLE IF NOT EXISTS managed_finding_comment (${COMMENT_COLUMNS}) STRICT;
38
+ CREATE INDEX IF NOT EXISTS finding_comment_finding_idx ON managed_finding_comment(finding_id, created_at, id);
39
+ CREATE TABLE IF NOT EXISTS managed_finding_comment_event (
40
+ seq INTEGER PRIMARY KEY, comment_id TEXT NOT NULL, finding_id TEXT NOT NULL,
41
+ actor_id TEXT REFERENCES managed_user(id) ON DELETE SET NULL,
42
+ actor_login TEXT, action TEXT NOT NULL, at INTEGER NOT NULL,
43
+ report_id TEXT, report TEXT, repo TEXT
44
+ ) STRICT;
45
+ CREATE INDEX IF NOT EXISTS finding_comment_event_finding_idx ON managed_finding_comment_event(finding_id, seq);
46
+ CREATE INDEX IF NOT EXISTS finding_comment_event_actor_at_idx ON managed_finding_comment_event(actor_id, at);
47
+ CREATE INDEX IF NOT EXISTS finding_comment_event_at_idx ON managed_finding_comment_event(at, seq);
48
+ `
49
+
50
+ export function initCommentMethods(db: DatabaseSync): void {
51
+ db.exec(COMMENT_SCHEMA)
52
+ migrateCommentDates(db)
53
+ // updated_by belongs to the whole triage row, not necessarily its comment.
54
+ // Preserve legacy text without guessing an author. Clearing the old column
55
+ // in the same transaction makes the migration safe to restart. Use a fresh
56
+ // ID so a later legacy-server write cannot collide with an earlier import.
57
+ db.exec('BEGIN')
58
+ try {
59
+ db.exec(`INSERT INTO managed_finding_comment (id, finding_id, body, created_at, updated_at)
60
+ SELECT 'legacy:' || lower(hex(randomblob(16))), finding_id, comment, updated_at, updated_at
61
+ FROM managed_finding_triage WHERE comment IS NOT NULL AND comment != '';
62
+ UPDATE managed_finding_triage SET comment = NULL WHERE comment IS NOT NULL;`)
63
+ db.exec('COMMIT')
64
+ } catch (err) {
65
+ db.exec('ROLLBACK')
66
+ throw err
67
+ }
68
+ }
69
+
70
+ export function commentMethods(db: ManagedSql): CommentStore {
71
+ const fields = `c.id, c.finding_id AS findingId, c.body, c.author_id AS authorId,
72
+ COALESCE(u.login, c.author_login) AS authorLogin,
73
+ c.created_at AS createdAt, c.updated_at AS updatedAt, c.version`
74
+ const select = db.prepare(`SELECT ${fields} FROM managed_finding_comment c
75
+ LEFT JOIN managed_user u ON u.id = c.author_id WHERE c.id = ?`)
76
+ const list = db.prepare(`SELECT ${fields} FROM managed_finding_comment c
77
+ LEFT JOIN managed_user u ON u.id = c.author_id
78
+ WHERE c.finding_id IN (SELECT value FROM json_each(?)) ORDER BY c.created_at NULLS FIRST, c.id`)
79
+ const insert = db.prepare(`INSERT INTO managed_finding_comment
80
+ (id, finding_id, body, author_id, author_login, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)`)
81
+ const update = db.prepare(`UPDATE managed_finding_comment SET body = ?, updated_at = ?, version = version + 1
82
+ WHERE id = ? AND author_id = ? AND version = ?`)
83
+ const remove = db.prepare('DELETE FROM managed_finding_comment WHERE id = ? AND (author_id = ? OR (author_id IS NULL AND ? IS NULL)) AND version = ?')
84
+ const userRole = db.prepare('SELECT role FROM managed_user WHERE id = ?')
85
+ // Deleted comments still have audit events which explicit annotation purges
86
+ // must find, including when no current triage/comment record remains.
87
+ const annotated = db.prepare(`SELECT finding_id AS id FROM managed_finding_comment WHERE finding_id IN (SELECT value FROM json_each(?))
88
+ UNION SELECT finding_id AS id FROM managed_finding_comment_event WHERE finding_id IN (SELECT value FROM json_each(?))`)
89
+ const event = db.prepare(`INSERT INTO managed_finding_comment_event
90
+ (comment_id, finding_id, actor_id, actor_login, action, at, report_id, report, repo)
91
+ VALUES (?, ?, ?, ?, ?, ?, ?, (SELECT filename FROM managed_report WHERE id = ?),
92
+ (SELECT p.full_name FROM managed_report r JOIN managed_selected_repo p ON p.repo_id = r.repo_id WHERE r.id = ?))`)
93
+ const read = async (id: string) => (await select.get(id)) as ManagedComment | undefined
94
+ return {
95
+ async listComments(ids) { return ids.length > 0 ? (await list.all(JSON.stringify(ids))) as unknown as ManagedComment[] : [] },
96
+ async listCommentedFindingIds(ids) {
97
+ const json = JSON.stringify(ids)
98
+ return ((await annotated.all(json, json)) as { id: string }[]).map(row => row.id)
99
+ },
100
+ async getComment(id) { return (await read(id)) ?? null },
101
+ async createComment(input, now) {
102
+ const id = randomUUID()
103
+ const createdAt = input.createdAt === undefined ? now : input.createdAt
104
+ const updatedAt = input.updatedAt === undefined ? createdAt : input.updatedAt
105
+ await insert.run(id, input.findingId, input.body, input.authorId, input.authorLogin, createdAt, updatedAt)
106
+ await event.run(id, input.findingId, input.actor?.id ?? input.authorId, input.actor?.login ?? input.authorLogin, 'added a comment', now,
107
+ input.reportId ?? null, input.reportId ?? null, input.reportId ?? null)
108
+ return (await read(id))!
109
+ },
110
+ async editComment(id, authorId, authorLogin, body, version, reportId, now) {
111
+ // Identity/version checks and the event share the operation transaction.
112
+ const current = await read(id)
113
+ if (!current || current.authorId !== authorId || current.version !== version || current.body === body) {
114
+ if (!current) return null
115
+ if (current.authorId !== authorId) return 'forbidden'
116
+ if (current.version !== version) return 'conflict'
117
+ return current
118
+ }
119
+ await update.run(body, now, id, authorId, version)
120
+ await event.run(id, current.findingId, authorId, authorLogin, 'edited a comment', now, reportId, reportId, reportId)
121
+ return (await read(id))!
122
+ },
123
+ async deleteComment(id, actorId, actorLogin, version, reportId, now) {
124
+ const current = await read(id)
125
+ const role = (await userRole.get(actorId) as { role: string } | undefined)?.role ?? 'none'
126
+ const allowed = current != null && canDeleteComment(current, { id: actorId, role })
127
+ if (!current || !allowed || current.version !== version) {
128
+ if (!current) return null
129
+ return allowed ? 'conflict' : 'forbidden'
130
+ }
131
+ await remove.run(id, current.authorId, current.authorId, version)
132
+ await event.run(id, current.findingId, actorId, actorLogin, 'deleted a comment', now, reportId, reportId, reportId)
133
+ return 'deleted'
134
+ },
135
+ }
136
+ }
137
+
138
+ function migrateCommentDates(db: DatabaseSync): void {
139
+ const columns = db.prepare('PRAGMA table_info(managed_finding_comment)').all() as { name: string; notnull: number }[]
140
+ if (!columns.some(column => ['created_at', 'updated_at'].includes(column.name) && column.notnull)) return
141
+ db.exec('BEGIN')
142
+ try {
143
+ db.exec(`CREATE TABLE managed_finding_comment_nullable_dates (${COMMENT_COLUMNS}) STRICT;
144
+ INSERT INTO managed_finding_comment_nullable_dates (id, finding_id, body, author_id, author_login, created_at, updated_at, version)
145
+ SELECT id, finding_id, body, author_id, author_login, created_at, updated_at, version FROM managed_finding_comment;
146
+ DROP TABLE managed_finding_comment;
147
+ ALTER TABLE managed_finding_comment_nullable_dates RENAME TO managed_finding_comment;
148
+ CREATE INDEX finding_comment_finding_idx ON managed_finding_comment(finding_id, created_at, id);`)
149
+ db.exec('COMMIT')
150
+ } catch (err) { db.exec('ROLLBACK'); throw err }
151
+ }
@@ -0,0 +1,149 @@
1
+ // Managed-server boot config. GitHub user authorization establishes identity;
2
+ // optional App installation credentials enable repository access. A single
3
+ // GitHub App can provide both flows, with repository grants at installation.
4
+ import { env } from 'node:process'
5
+ import { databaseUrls } from '../server-common/database-config.ts'
6
+ import { MAX_UPLOAD_BYTES } from './uploads.ts'
7
+ import { parseStorageKey } from '../server-common/storage-crypto.ts'
8
+
9
+ const LOOPBACK_HOSTS = new Set(['127.0.0.1', '::1', 'localhost'])
10
+
11
+ export interface ManagedConfig {
12
+ port: number
13
+ host: string
14
+ dbPath: string
15
+ neonUrl?: string | null
16
+ blobToken?: string | null
17
+ storageEncryptionKey?: string | null
18
+ storageEncryptionMigrate?: boolean
19
+ storageEncryptionMigrateMaxMs?: number
20
+ vercelPreview?: boolean
21
+ serverless?: boolean
22
+ debug: boolean
23
+ allowShare: boolean
24
+ trustProxyEnv: string | undefined
25
+ // GitHub App user-authorization (identity) credentials — the App's client id
26
+ // + secret, used by the login flow.
27
+ githubClientId: string
28
+ githubClientSecret: string
29
+ // Preapproved GitHub identity promoted on login when it is the only user
30
+ // and its role is No access, including a login after registration.
31
+ initialAdminGithubId: number | null
32
+ // Absolute callback registered with GitHub, e.g.
33
+ // 'https://triage.example.com/api/oauth/github/callback'.
34
+ oauthCallbackUrl: string
35
+ // Whether cookies carry `Secure` (callback is https). Also gates the
36
+ // `__Host-` prefix, so loopback http dev can run with a plain cookie name.
37
+ cookieSecure: boolean
38
+ sessionCookieName: string
39
+ sessionTtlMs: number
40
+ // Optional installation credentials for the same GitHub App as login.
41
+ // Repository permissions are approved when connecting repositories.
42
+ githubAppId: string | null
43
+ githubAppPrivateKey: string | null
44
+ githubAppSlug: string | null
45
+ githubNewIssueLabels?: string
46
+ // Max accepted size (bytes) for an uploaded report on the "Manage reports"
47
+ // page. Reports are findings dumps (JSON / markdown / CSV), small to a few MB.
48
+ maxReportBytes: number
49
+ // Max accepted size (bytes) for an uploaded bundle (sourcemap / stasis
50
+ // archive). Bundles run larger than reports, so a higher cap (default 200 MiB).
51
+ maxBundleBytes: number
52
+ // How many triage-trail events to keep per finding (managed_finding_triage_event):
53
+ // 0, the default, keeps everything — the trail is the record. An operator
54
+ // who would rather bound the store sets a positive count; older events of a
55
+ // finding are then trimmed as new ones land.
56
+ triageHistoryLimit: number
57
+ }
58
+
59
+ function fail(msg: string): never {
60
+ throw new Error(msg)
61
+ }
62
+
63
+ function requireStr(name: string): string {
64
+ const v = env[name]
65
+ if (v == null || v === '') fail(`Missing required env ${name} (managed mode).`)
66
+ return v
67
+ }
68
+
69
+ function intEnv(name: string, def: number, min: number, max: number): number {
70
+ const raw = env[name]
71
+ const n = raw == null ? def : Number(raw)
72
+ if (!Number.isSafeInteger(n) || n < min || n > max) {
73
+ fail(`Invalid ${name}: ${raw} — must be an integer in [${min}, ${max}].`)
74
+ }
75
+ return n
76
+ }
77
+
78
+ function optionalGithubId(name: string): number | null {
79
+ const raw = env[name]
80
+ if (raw == null || raw === '') return null
81
+ const id = Number(raw)
82
+ if (!/^[1-9]\d*$/u.test(raw) || !Number.isSafeInteger(id)) {
83
+ fail(`Invalid ${name}: must be a single positive numeric GitHub user ID.`)
84
+ }
85
+ return id
86
+ }
87
+
88
+ function urlOrFail(name: string, raw: string): URL {
89
+ try { return new URL(raw) } catch { fail(`${name} is not a valid URL: ${raw}`) }
90
+ }
91
+
92
+ export function loadManagedConfig({ combined = false } = {}): ManagedConfig {
93
+ const serverless = env['VERCEL'] === '1'
94
+ const neonUrl = databaseUrls({ combined }).managed
95
+ const blobToken = env['BLOB_READ_WRITE_TOKEN'] || null
96
+ const storageEncryptionKey = env['MANAGED_STORAGE_ENCRYPTION_KEY'] || null
97
+ parseStorageKey(storageEncryptionKey)
98
+ const storageEncryptionMigrate = env['MANAGED_STORAGE_ENCRYPTION_MIGRATE'] === '1'
99
+ if (storageEncryptionMigrate && !storageEncryptionKey) fail('MANAGED_STORAGE_ENCRYPTION_MIGRATE requires MANAGED_STORAGE_ENCRYPTION_KEY.')
100
+ if (serverless && !neonUrl) fail('Vercel managed mode requires DATABASE_URL or MANAGED_DATABASE_URL.')
101
+ if (neonUrl && !blobToken) fail('Managed Neon mode requires BLOB_READ_WRITE_TOKEN.')
102
+ const host = env['HOST'] ?? '127.0.0.1'
103
+ const oauthCallbackUrl = requireStr('OAUTH_CALLBACK_URL')
104
+ const callback = urlOrFail('OAUTH_CALLBACK_URL', oauthCallbackUrl)
105
+ const cookieSecure = callback.protocol === 'https:'
106
+ // A `__Host-` cookie mandates `Secure`, so any non-loopback bind must be
107
+ // HTTPS — fail fast (mirrors server-e2e's boot check). Loopback dev over
108
+ // http is allowed with a non-prefixed cookie name.
109
+ if ((serverless || !LOOPBACK_HOSTS.has(host)) && !cookieSecure) {
110
+ fail(`Vercel or non-loopback HOST=${host} requires an https OAUTH_CALLBACK_URL — the session cookie needs Secure.`)
111
+ }
112
+ const sessionCookieName = env['SESSION_COOKIE_NAME'] ?? '__Host-dvsid'
113
+ if (sessionCookieName.startsWith('__Host-') && !cookieSecure) {
114
+ fail(`SESSION_COOKIE_NAME=${sessionCookieName} uses the __Host- prefix but the callback is not https. Use a non-prefixed name for loopback http dev.`)
115
+ }
116
+ return {
117
+ neonUrl, blobToken, serverless, storageEncryptionKey, storageEncryptionMigrate,
118
+ storageEncryptionMigrateMaxMs: intEnv('MANAGED_STORAGE_ENCRYPTION_MIGRATE_MAX_MS', 150_000, 1, serverless ? 240_000 : 3_600_000),
119
+ vercelPreview: env['VERCEL_ENV'] === 'preview',
120
+ port: intEnv('PORT', 8765, 0, 65535),
121
+ host,
122
+ // DB_PATH belongs to e2e in a combined process. Keep the two stores apart.
123
+ dbPath: env['MANAGED_DB_PATH'] ?? (combined ? undefined : env['DB_PATH']) ?? 'server-managed/data/managed.db',
124
+ debug: env['DEBUG'] === '1' || env['DEBUG'] === 'true',
125
+ allowShare: env['DEEPVIEW_ALLOW_SHARE'] === '1',
126
+ trustProxyEnv: env['TRUST_PROXY'] ?? (serverless ? '1' : undefined),
127
+ githubClientId: requireStr('GITHUB_CLIENT_ID'),
128
+ githubClientSecret: requireStr('GITHUB_CLIENT_SECRET'),
129
+ initialAdminGithubId: optionalGithubId('MANAGED_INITIAL_ADMIN_GITHUB_ID'),
130
+ oauthCallbackUrl,
131
+ cookieSecure,
132
+ sessionCookieName,
133
+ sessionTtlMs: intEnv('SESSION_TTL_MS', 1_209_600_000, 60_000, 7_776_000_000),
134
+ githubAppId: env['GITHUB_APP_ID'] ?? null,
135
+ githubAppPrivateKey: normalizePem(env['GITHUB_APP_PRIVATE_KEY']),
136
+ githubAppSlug: env['GITHUB_APP_SLUG'] ?? null,
137
+ githubNewIssueLabels: env['GITHUB_NEW_ISSUE_LABELS'] ?? '',
138
+ maxReportBytes: intEnv('MAX_REPORT_BYTES', 10_485_760, 1, 104_857_600),
139
+ maxBundleBytes: intEnv('MAX_BUNDLE_BYTES', 209_715_200, 1, MAX_UPLOAD_BYTES),
140
+ triageHistoryLimit: intEnv('TRIAGE_HISTORY_LIMIT', 0, 0, 1_000_000_000),
141
+ }
142
+ }
143
+
144
+ // PEM private keys are awkward in env vars; accept a literal multi-line value or
145
+ // one with escaped newlines (`\n`). Empty/absent → null (private repos off).
146
+ function normalizePem(raw: string | undefined): string | null {
147
+ if (raw == null || raw === '') return null
148
+ return raw.includes('\\n') ? raw.replaceAll('\\n', '\n') : raw
149
+ }
@@ -0,0 +1,15 @@
1
+ import type { ManagedDb, StoredUser } from './db.ts'
2
+
3
+ export type RepoScope = { repoId: number; path: string | null }
4
+ type Content = { repoId: number | null; repoDirectory?: string }
5
+
6
+ // Read grants for each request: neither the manager role nor a cached page
7
+ // confers repository access. Bundles have repository scope; reports also have
8
+ // a directory scope. Unassigned content is only available to administrators.
9
+ export async function contentAccess(db: Pick<ManagedDb, 'listRepoScopesForUser'>, user: StoredUser) {
10
+ const scopes = user.role === 'admin' ? null : await db.listRepoScopesForUser(user.id)
11
+ const bundle = (item: Content) => scopes == null || scopes.some(scope => scope.repoId === item.repoId)
12
+ const report = (item: Content) => scopes == null || scopes.some(scope => scope.repoId === item.repoId
13
+ && (!scope.path || item.repoDirectory === scope.path || item.repoDirectory?.startsWith(scope.path + '/')))
14
+ return { scopes, bundle, report }
15
+ }
@@ -0,0 +1,25 @@
1
+ // Token helpers for the managed auth server. The raw session token lives ONLY
2
+ // in the cookie; the DB row id is its SHA-256 (see db.ts / session.ts), so a
3
+ // DB read can't reconstruct a live cookie. No HMAC pepper is needed — the
4
+ // tokens are 256-bit random, not guessable or reversible from their hash.
5
+ import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'
6
+
7
+ // 256-bit URL-safe random token: the session cookie value and the OAuth CSRF
8
+ // `state` nonce.
9
+ export function randomToken(): string {
10
+ return randomBytes(32).toString('base64url')
11
+ }
12
+
13
+ // At-rest id for a session cookie token (stored as the managed_session PK).
14
+ export function hashToken(token: string): string {
15
+ return createHash('sha256').update(token, 'utf8').digest('base64url')
16
+ }
17
+
18
+ // Constant-time equality for CSRF / OAuth-state checks. A length mismatch
19
+ // short-circuits (the length of these fixed-width tokens isn't a secret).
20
+ export function safeEqual(a: string, b: string): boolean {
21
+ const ab = Buffer.from(a, 'utf8')
22
+ const bb = Buffer.from(b, 'utf8')
23
+ if (ab.length !== bb.length) return false
24
+ return timingSafeEqual(ab, bb)
25
+ }