@preventive/triage 1.0.0-alpha.3 → 1.0.0-alpha.30
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 +81 -0
- package/out/client-managed.js +167 -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 +102 -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 +60 -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,139 @@
|
|
|
1
|
+
import type { ManagedConfig } from './config.ts'
|
|
2
|
+
import type { SelectedRepo } from './db.ts'
|
|
3
|
+
import { GithubApiError, githubJson, githubRepoReadPermission, githubUserIdentity, repoAccessToken } from './github-app.ts'
|
|
4
|
+
import { MAX_PACKAGE_BYTES, readPackageEntryPoints } from './package-entry-points.ts'
|
|
5
|
+
import { readSolidityEntryPoints } from './solidity-entry-points.ts'
|
|
6
|
+
|
|
7
|
+
export interface RepositoryEntry { name: string; path: string; type: 'dir' | 'file' | 'symlink' | 'submodule' }
|
|
8
|
+
|
|
9
|
+
// Restricted managers can walk the ancestors of their grants, without seeing
|
|
10
|
+
// sibling names or files outside those grants. null means the entire repo.
|
|
11
|
+
export function scopedDirectory(path: string, scopes: (string | null)[]): RepositoryEntry[] | null {
|
|
12
|
+
if (scopes.some(scope => !scope || path === scope || path.startsWith(scope + '/'))) return null
|
|
13
|
+
const prefix = path ? path + '/' : ''
|
|
14
|
+
const names = new Set(scopes.filter((scope): scope is string => !!scope && scope.startsWith(prefix)).map(scope => scope.slice(prefix.length).split('/')[0]!))
|
|
15
|
+
return [...names].toSorted().map(name => ({ name, path: prefix + name, type: 'dir' }))
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface RepositoryBrowserUser { githubUserId: number | null; token: string | null }
|
|
19
|
+
type GithubIdentity = Awaited<ReturnType<typeof githubUserIdentity>>
|
|
20
|
+
|
|
21
|
+
function credentialFailure(err: unknown): err is GithubApiError {
|
|
22
|
+
return err instanceof GithubApiError && ([401, 404].includes(err.status) || err.message === 'github-status-403')
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Create once per HTTP request. Coalesce concurrent credential lookups without
|
|
26
|
+
// caching repository visibility or permissions, or sharing credentials across users.
|
|
27
|
+
export function createRepositoryBrowser(config: ManagedConfig, user: RepositoryBrowserUser, fetchImpl: typeof fetch = globalThis.fetch) {
|
|
28
|
+
const tokens = new Map<number, Promise<string | null>>()
|
|
29
|
+
let identity: Promise<GithubIdentity> | undefined
|
|
30
|
+
const getIdentity = (fresh = false): Promise<GithubIdentity> => {
|
|
31
|
+
if (!user.token || user.githubUserId == null) return Promise.reject(new GithubApiError(404, 'no-repository'))
|
|
32
|
+
if (!identity || fresh) {
|
|
33
|
+
identity = githubUserIdentity(user.token, fetchImpl).then(value => {
|
|
34
|
+
if (value.id !== user.githubUserId) throw new GithubApiError(404, 'no-repository')
|
|
35
|
+
return value
|
|
36
|
+
})
|
|
37
|
+
}
|
|
38
|
+
return identity
|
|
39
|
+
}
|
|
40
|
+
return {
|
|
41
|
+
reader(repo: SelectedRepo) {
|
|
42
|
+
const installationToken = () => {
|
|
43
|
+
if (repo.installationId == null) return Promise.resolve(null)
|
|
44
|
+
const cached = tokens.get(repo.installationId)
|
|
45
|
+
const token = cached ?? repoAccessToken(config, repo.installationId, fetchImpl).catch((err: unknown) => {
|
|
46
|
+
if (!credentialFailure(err)) throw err
|
|
47
|
+
// Stale installations cannot prevent verified public access.
|
|
48
|
+
return null
|
|
49
|
+
})
|
|
50
|
+
if (!cached) tokens.set(repo.installationId, token)
|
|
51
|
+
return token
|
|
52
|
+
}
|
|
53
|
+
return repositoryReader(repo, user.token, installationToken, getIdentity, fetchImpl)
|
|
54
|
+
},
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function repositoryReader(repo: SelectedRepo, userToken: string | null, installationToken: () => Promise<string | null>, getIdentity: (fresh?: boolean) => Promise<GithubIdentity>, fetchImpl: typeof fetch) {
|
|
59
|
+
const base = `https://api.github.com/repos/${repo.fullName.split('/').map(encodeURIComponent).join('/')}`
|
|
60
|
+
// Public reads can use the signed-in user's quota without repository grants.
|
|
61
|
+
// Nonpublic reads still require the repository App and the effective-user gate.
|
|
62
|
+
let token = userToken ?? await installationToken()
|
|
63
|
+
const read = (suffix: string) => githubJson(base + suffix, token, fetchImpl)
|
|
64
|
+
const metadata = async () => {
|
|
65
|
+
try { return await read('') }
|
|
66
|
+
catch (err) {
|
|
67
|
+
if (!token || !credentialFailure(err)) throw err
|
|
68
|
+
const fallback = await installationToken()
|
|
69
|
+
if (fallback && fallback !== token) {
|
|
70
|
+
token = fallback
|
|
71
|
+
try { return await read('') } catch (fallbackError) { if (!credentialFailure(fallbackError)) throw fallbackError }
|
|
72
|
+
}
|
|
73
|
+
// Anonymous fallback is only for unavailable credentials, never rate
|
|
74
|
+
// limits, network errors, or GitHub outages.
|
|
75
|
+
token = null
|
|
76
|
+
return read('')
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
const checkAccess = async (freshIdentity = false) => {
|
|
80
|
+
// Stored visibility can be stale (or internal with private=false). Require
|
|
81
|
+
// current, explicit public visibility or the caller's effective permission.
|
|
82
|
+
const data = await metadata() as { id?: unknown; full_name?: unknown; private?: unknown; visibility?: unknown; default_branch?: unknown }
|
|
83
|
+
if (data?.id !== repo.repoId || typeof data.full_name !== 'string' || data.full_name.toLowerCase() !== repo.fullName.toLowerCase()) {
|
|
84
|
+
throw new GithubApiError(404, 'no-repository')
|
|
85
|
+
}
|
|
86
|
+
if (data.private === false && data.visibility === 'public') return data
|
|
87
|
+
if (!token) throw new GithubApiError(404, 'no-repository')
|
|
88
|
+
const appToken = await installationToken()
|
|
89
|
+
if (!appToken) throw new GithubApiError(404, 'no-repository')
|
|
90
|
+
if (!(await githubRepoReadPermission(repo.fullName, appToken, await getIdentity(freshIdentity), fetchImpl))) {
|
|
91
|
+
throw new GithubApiError(404, 'no-repository')
|
|
92
|
+
}
|
|
93
|
+
token = appToken
|
|
94
|
+
return data
|
|
95
|
+
}
|
|
96
|
+
const current = await checkAccess()
|
|
97
|
+
const defaultBranch = typeof current.default_branch === 'string' ? current.default_branch : ''
|
|
98
|
+
return {
|
|
99
|
+
recheckAccess: () => checkAccess(true),
|
|
100
|
+
// Server-only credential, selected by the same live visibility/permission
|
|
101
|
+
// gate as directory browsing. Never included in an HTTP response.
|
|
102
|
+
readToken: () => token,
|
|
103
|
+
async refs() {
|
|
104
|
+
// Suggestions are bounded; the input also accepts any branch or tag name.
|
|
105
|
+
const results = await Promise.all(['branches', 'tags'].map(async kind => {
|
|
106
|
+
const data = await read(`/${kind}?per_page=100`)
|
|
107
|
+
if (!Array.isArray(data)) throw new GithubApiError(502, 'github-malformed')
|
|
108
|
+
return data.flatMap(item => typeof item?.name === 'string' ? [item.name] : [])
|
|
109
|
+
}))
|
|
110
|
+
return { defaultBranch, branches: results[0], tags: results[1] }
|
|
111
|
+
},
|
|
112
|
+
async commit(ref: string) {
|
|
113
|
+
if (/^[a-f\d]{40}$/iu.test(ref)) return ref
|
|
114
|
+
const data = await read(`/commits/${encodeURIComponent(ref || (defaultBranch ? `heads/${defaultBranch}` : 'HEAD'))}`) as { sha?: unknown }
|
|
115
|
+
if (typeof data?.sha !== 'string' || !/^[a-f\d]{40}$/iu.test(data.sha)) throw new GithubApiError(502, 'github-malformed')
|
|
116
|
+
return data.sha
|
|
117
|
+
},
|
|
118
|
+
async directory(path: string, commit: string) {
|
|
119
|
+
const data = await read(`/contents/${path.split('/').map(encodeURIComponent).join('/')}?ref=${encodeURIComponent(commit)}`)
|
|
120
|
+
if (!Array.isArray(data)) throw new GithubApiError(400, 'not-a-directory')
|
|
121
|
+
const entries: RepositoryEntry[] = []
|
|
122
|
+
for (const item of data) {
|
|
123
|
+
if (typeof item?.name !== 'string' || item.name.includes('/') || item.name === '.' || item.name === '..') continue
|
|
124
|
+
const expectedPath = path ? `${path}/${item.name}` : item.name
|
|
125
|
+
if (item.path !== expectedPath) continue
|
|
126
|
+
const type = item.submodule_git_url ? 'submodule' : item.type
|
|
127
|
+
if (['dir', 'file', 'symlink', 'submodule'].includes(type)) entries.push({ name: item.name, path: expectedPath, type })
|
|
128
|
+
}
|
|
129
|
+
const manifestPath = path ? `${path}/package.json` : 'package.json'
|
|
130
|
+
const manifest = data.find(item => item?.path === manifestPath && item.name === 'package.json' && item.type === 'file' && !item.submodule_git_url
|
|
131
|
+
&& typeof item.size === 'number' && item.size >= 0 && item.size <= MAX_PACKAGE_BYTES && typeof item.sha === 'string' && /^[a-f\d]{40}$/iu.test(item.sha))
|
|
132
|
+
const packageEntryPoints = manifest ? await readPackageEntryPoints(manifest.sha, path, read) : undefined
|
|
133
|
+
const solidity = await readSolidityEntryPoints(path, data, read)
|
|
134
|
+
return { entries, limited: data.length >= 1000, ...(packageEntryPoints ? { packageEntryPoints } : {}),
|
|
135
|
+
...(solidity.paths.length > 0 || solidity.limited ? { solidityEntryPoints: solidity.paths, soliditySuggestionsLimited: solidity.limited } : {}),
|
|
136
|
+
}
|
|
137
|
+
},
|
|
138
|
+
}
|
|
139
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import type { ManagedConfig } from './config.ts'
|
|
3
|
+
import { GithubApiError, filterInstalledRepos, listInstalledRepos, listUserRepos } from './github-app.ts'
|
|
4
|
+
import type { ConnectedRepo, RepoListing } from './github-app.ts'
|
|
5
|
+
|
|
6
|
+
const CACHE_MS = 60_000
|
|
7
|
+
const MAX_ENTRIES = 100
|
|
8
|
+
interface Entry {
|
|
9
|
+
expires: number
|
|
10
|
+
pending: boolean
|
|
11
|
+
value: Promise<ConnectedRepo[]>
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// One directory per HTTP handler. Searches/pages reuse discovery, but selected
|
|
15
|
+
// flags still come from the database on every request. User results are scoped
|
|
16
|
+
// to both the managed identity and its current GitHub credential. This cache is
|
|
17
|
+
// only for discovery; connecting a repo still verifies fresh installation access.
|
|
18
|
+
export class RepositoryDiscovery {
|
|
19
|
+
private readonly cache = new Map<string, Entry>()
|
|
20
|
+
private readonly config: ManagedConfig
|
|
21
|
+
private readonly fetchImpl: typeof fetch
|
|
22
|
+
private readonly now: () => number
|
|
23
|
+
|
|
24
|
+
constructor(config: ManagedConfig, fetchImpl: typeof fetch = (...args) => globalThis.fetch(...args), now: () => number = Date.now) {
|
|
25
|
+
this.config = config
|
|
26
|
+
this.fetchImpl = fetchImpl
|
|
27
|
+
this.now = now
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
private read(key: string, load: () => Promise<ConnectedRepo[]>): Promise<ConnectedRepo[]> {
|
|
31
|
+
const cached = this.cache.get(key)
|
|
32
|
+
if (cached && (cached.pending || cached.expires > this.now())) return cached.value
|
|
33
|
+
this.cache.delete(key)
|
|
34
|
+
while (this.cache.size >= MAX_ENTRIES) this.cache.delete(this.cache.keys().next().value!)
|
|
35
|
+
const entry: Entry = { expires: 0, pending: true, value: Promise.resolve([]) }
|
|
36
|
+
entry.value = load().then(repos => {
|
|
37
|
+
entry.expires = this.now() + CACHE_MS
|
|
38
|
+
entry.pending = false
|
|
39
|
+
return repos
|
|
40
|
+
}, (err: unknown) => {
|
|
41
|
+
if (this.cache.get(key) === entry) this.cache.delete(key)
|
|
42
|
+
throw err
|
|
43
|
+
})
|
|
44
|
+
this.cache.set(key, entry)
|
|
45
|
+
return entry.value
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async list(scope: 'installed' | 'public', userId: string, token: string | null, showAll = false, refresh = false): Promise<RepoListing> {
|
|
49
|
+
// Invalidate derived user results along with the installation catalogue.
|
|
50
|
+
// An older in-flight read cannot repopulate this map after refresh.
|
|
51
|
+
if (refresh) this.cache.clear()
|
|
52
|
+
const installed = () => this.read('installed', () => listInstalledRepos(this.config, this.fetchImpl))
|
|
53
|
+
if (scope === 'installed' && showAll) return { repositories: await installed(), tokenMissing: false }
|
|
54
|
+
const publicInstalled = async (): Promise<ConnectedRepo[]> => (await installed()).filter(repo => repo.visibility === 'public')
|
|
55
|
+
if (token == null) return { repositories: scope === 'installed' ? await publicInstalled() : [], tokenMissing: true }
|
|
56
|
+
const credential = createHash('sha256').update(token).digest('hex')
|
|
57
|
+
const key = JSON.stringify([scope, userId, credential])
|
|
58
|
+
try {
|
|
59
|
+
if (scope === 'installed') {
|
|
60
|
+
const repositories = await this.read(key, async () => filterInstalledRepos(this.config, await installed(), token, this.fetchImpl))
|
|
61
|
+
return { repositories, tokenMissing: false }
|
|
62
|
+
}
|
|
63
|
+
// Installations only deduplicate the public picker. Their failure must
|
|
64
|
+
// not block otherwise readable public repos or look like a stale login.
|
|
65
|
+
// Cache the two sources separately so failed installation discovery is
|
|
66
|
+
// retried without caching a degraded combined list for another minute.
|
|
67
|
+
const [userRepos, installedRepos] = await Promise.all([
|
|
68
|
+
this.read(key, () => listUserRepos(token)),
|
|
69
|
+
installed().catch((err: unknown) => {
|
|
70
|
+
console.warn('managed: installed repo deduplication failed:', err)
|
|
71
|
+
return []
|
|
72
|
+
}),
|
|
73
|
+
])
|
|
74
|
+
const installedIds = new Set(installedRepos.map(repo => repo.id))
|
|
75
|
+
const repositories = userRepos.filter(repo => repo.visibility === 'public' && !installedIds.has(repo.id))
|
|
76
|
+
return { repositories, tokenMissing: false }
|
|
77
|
+
} catch (err) {
|
|
78
|
+
if (!(err instanceof GithubApiError) || err.status !== 401) throw err
|
|
79
|
+
return { repositories: scope === 'installed' ? await publicInstalled() : [], tokenMissing: true }
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { StoredUser } from './db.ts'
|
|
2
|
+
|
|
3
|
+
const WHITEHAT = [
|
|
4
|
+
291301, // ChALkeR
|
|
5
|
+
259122746, // exo-nikita
|
|
6
|
+
247161625, // ex0sec
|
|
7
|
+
]
|
|
8
|
+
const whitehatIds = new Set(WHITEHAT)
|
|
9
|
+
|
|
10
|
+
// (1) Server permission to add repositories. Keep this independent of GitHub
|
|
11
|
+
// identity and ecosystem exceptions if other install modes broaden it later.
|
|
12
|
+
export function canAddRepositories(user: StoredUser): boolean {
|
|
13
|
+
return user.role === 'admin'
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// (3b) Only the ecosystem safeguard for public repositories is bypassed by
|
|
17
|
+
// WHITEHAT. Callers must separately establish (0) server readability, (1) add
|
|
18
|
+
// permission, and (2) server admin or GitHub access to this repository.
|
|
19
|
+
export function passesPublicRepositorySafeguard(githubUserId: number | null, involved: boolean): boolean {
|
|
20
|
+
return involved || (githubUserId != null && whitehatIds.has(githubUserId))
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Arbitrary public repositories have no involvement evidence, so (2) requires
|
|
24
|
+
// admin even if (1) expands in the future. This capability is also the UI gate.
|
|
25
|
+
export function canAddAnyPublicRepository(user: StoredUser, githubUserId: number | null): boolean {
|
|
26
|
+
return canAddRepositories(user) && user.role === 'admin' && passesPublicRepositorySafeguard(githubUserId, false)
|
|
27
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// Internal invalidation counters. Never expose these global values to clients:
|
|
2
|
+
// feeds still publish only the existing user/visibility-scoped revisions.
|
|
3
|
+
const catalogTables = ['managed_team', 'managed_team_user', 'managed_team_repo', 'managed_report', 'managed_bundle', 'managed_selected_repo']
|
|
4
|
+
const annotationTables = ['managed_finding_triage', 'managed_finding_triage_event', 'managed_finding_comment']
|
|
5
|
+
export function revisionSchema(postgres = false): string {
|
|
6
|
+
let sql = `CREATE TABLE IF NOT EXISTS managed_maintenance_lease (
|
|
7
|
+
id INTEGER PRIMARY KEY, owner TEXT NOT NULL, expires_at INTEGER NOT NULL
|
|
8
|
+
); CREATE TABLE IF NOT EXISTS managed_change_revision (
|
|
9
|
+
id INTEGER PRIMARY KEY, catalog INTEGER NOT NULL, annotations INTEGER NOT NULL
|
|
10
|
+
); INSERT INTO managed_change_revision VALUES (1, 0, 0) ON CONFLICT(id) DO NOTHING;`
|
|
11
|
+
for (const [column, tables] of [['catalog', catalogTables], ['annotations', annotationTables]] as const) {
|
|
12
|
+
const update = `UPDATE managed_change_revision SET ${column} = ${column} + 1 WHERE id = 1;`
|
|
13
|
+
if (postgres) {
|
|
14
|
+
sql += `CREATE OR REPLACE FUNCTION managed_${column}_changed() RETURNS trigger LANGUAGE plpgsql AS $$
|
|
15
|
+
BEGIN IF EXISTS (SELECT 1 FROM managed_revision_rows) THEN ${update} END IF; RETURN NULL; END $$;`
|
|
16
|
+
}
|
|
17
|
+
for (const table of tables) {
|
|
18
|
+
if (postgres) {
|
|
19
|
+
sql += `DROP TRIGGER IF EXISTS ${table}_revision ON ${table};`
|
|
20
|
+
for (const event of ['INSERT', 'UPDATE', 'DELETE']) {
|
|
21
|
+
sql += `DROP TRIGGER IF EXISTS ${table}_revision_${event} ON ${table};
|
|
22
|
+
CREATE TRIGGER ${table}_revision_${event} AFTER ${event} ON ${table}
|
|
23
|
+
REFERENCING ${event === 'DELETE' ? 'OLD' : 'NEW'} TABLE AS managed_revision_rows
|
|
24
|
+
FOR EACH STATEMENT EXECUTE FUNCTION managed_${column}_changed();`
|
|
25
|
+
}
|
|
26
|
+
} else {
|
|
27
|
+
for (const event of ['INSERT', 'UPDATE', 'DELETE']) {
|
|
28
|
+
sql += `CREATE TRIGGER IF NOT EXISTS ${table}_revision_${event}
|
|
29
|
+
AFTER ${event} ON ${table} BEGIN ${update} END;`
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return sql
|
|
35
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// Session + cookie plumbing for the managed auth server. The raw session
|
|
2
|
+
// token lives ONLY in the cookie; the DB row id is its SHA-256 (see crypto.ts)
|
|
3
|
+
// so a DB read can't reconstruct a live cookie. A double-submit CSRF token is
|
|
4
|
+
// minted per session and returned to the client (echoed back on mutations).
|
|
5
|
+
//
|
|
6
|
+
// Async throughout — the store (db.ts) is async so a future non-SQLite
|
|
7
|
+
// backend slots in without touching these helpers.
|
|
8
|
+
import type { ManagedConfig } from './config.ts'
|
|
9
|
+
import type { ManagedDb, ManagedSession, ManagedUser, StoredUser } from './db.ts'
|
|
10
|
+
import { hashToken, randomToken } from './crypto.ts'
|
|
11
|
+
|
|
12
|
+
// Base name for the short-lived OAuth-state cookie; `__Host-` is prefixed via
|
|
13
|
+
// `cookieName` when cookies are Secure.
|
|
14
|
+
export const STATE_COOKIE = 'dvstate'
|
|
15
|
+
|
|
16
|
+
// `__Host-`-prefix the base name when cookies are Secure (the prefix mandates
|
|
17
|
+
// Secure + Path=/ + no Domain, which `buildCookie` already emits).
|
|
18
|
+
export function cookieName(config: ManagedConfig, base: string): string {
|
|
19
|
+
return config.cookieSecure ? `__Host-${base}` : base
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function buildCookie(
|
|
23
|
+
name: string, value: string,
|
|
24
|
+
opts: { maxAgeS: number; secure: boolean; sameSite?: 'Lax' | 'Strict' },
|
|
25
|
+
): string {
|
|
26
|
+
const parts = [`${name}=${value}`, 'Path=/', 'HttpOnly', `SameSite=${opts.sameSite ?? 'Lax'}`, `Max-Age=${opts.maxAgeS}`]
|
|
27
|
+
if (opts.secure) parts.push('Secure')
|
|
28
|
+
return parts.join('; ')
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Expire a cookie now (Max-Age=0).
|
|
32
|
+
export function clearCookie(name: string, secure: boolean): string {
|
|
33
|
+
return buildCookie(name, '', { maxAgeS: 0, secure })
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function parseCookies(header: string | undefined): Map<string, string> {
|
|
37
|
+
const out = new Map<string, string>()
|
|
38
|
+
if (!header) return out
|
|
39
|
+
for (const part of header.split(';')) {
|
|
40
|
+
const eq = part.indexOf('=')
|
|
41
|
+
if (eq < 0) continue
|
|
42
|
+
const k = part.slice(0, eq).trim()
|
|
43
|
+
if (k) out.set(k, part.slice(eq + 1).trim())
|
|
44
|
+
}
|
|
45
|
+
return out
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Mint a session for a freshly-authenticated user. Upserts the identity, then
|
|
49
|
+
// stores the session keyed by the cookie token's hash. Returns the cookie to
|
|
50
|
+
// set and the CSRF token (also persisted on the row).
|
|
51
|
+
export async function createSession(
|
|
52
|
+
config: ManagedConfig, db: ManagedDb, user: ManagedUser, now: number,
|
|
53
|
+
): Promise<{ setCookie: string; csrfToken: string; userId: string }> {
|
|
54
|
+
const token = randomToken()
|
|
55
|
+
const csrfToken = randomToken()
|
|
56
|
+
const userId = await db.upsertUser(user, now, config.initialAdminGithubId)
|
|
57
|
+
await db.createSession({ id: hashToken(token), userId, csrfToken, expiresAt: now + config.sessionTtlMs }, now)
|
|
58
|
+
const setCookie = buildCookie(config.sessionCookieName, token, {
|
|
59
|
+
maxAgeS: Math.floor(config.sessionTtlMs / 1000), secure: config.cookieSecure, sameSite: 'Lax',
|
|
60
|
+
})
|
|
61
|
+
return { setCookie, csrfToken, userId }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Resolve the current session (+ user) from the request's cookies, or null.
|
|
65
|
+
export function readSession(
|
|
66
|
+
config: ManagedConfig, db: ManagedDb, cookieHeader: string | undefined, now: number,
|
|
67
|
+
): Promise<{ session: ManagedSession; user: StoredUser } | null> {
|
|
68
|
+
const token = parseCookies(cookieHeader).get(config.sessionCookieName)
|
|
69
|
+
if (token == null || token === '') return Promise.resolve(null)
|
|
70
|
+
return db.sessionWithUser(hashToken(token), now)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Drop the session named by the request's cookie (logout). The caller also
|
|
74
|
+
// clears the cookie client-side.
|
|
75
|
+
export async function endSession(config: ManagedConfig, db: ManagedDb, cookieHeader: string | undefined): Promise<void> {
|
|
76
|
+
const token = parseCookies(cookieHeader).get(config.sessionCookieName)
|
|
77
|
+
if (token != null && token !== '') await db.deleteSession(hashToken(token))
|
|
78
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { DatabaseSync } from 'node:sqlite'
|
|
2
|
+
|
|
3
|
+
// Legacy non-UUID IDs stay intact. Production IDs are UUIDs; only their final
|
|
4
|
+
// component is proposed as a slug, never an arbitrary prefix or abbreviation.
|
|
5
|
+
export function preferredSlug(id: string): string {
|
|
6
|
+
return /^[a-f\d]{8}(?:-[a-f\d]{4}){3}-([a-f\d]{12})$/iu.exec(id)?.[1] ?? id
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function allocateMissingSlugs(rows: { id: string; slug: string | null }[]): { id: string; slug: string }[] {
|
|
10
|
+
// Reserve legacy IDs before UUID suffixes can claim them.
|
|
11
|
+
const used = new Set(rows.flatMap(row => row.slug == null ? [row.id] : [row.id, row.slug]))
|
|
12
|
+
const allocated = []
|
|
13
|
+
for (const row of rows) {
|
|
14
|
+
if (row.slug != null) continue
|
|
15
|
+
const candidate = preferredSlug(row.id)
|
|
16
|
+
const slug = used.has(candidate) ? row.id : candidate
|
|
17
|
+
allocated.push({ id: row.id, slug })
|
|
18
|
+
used.add(slug)
|
|
19
|
+
}
|
|
20
|
+
return allocated
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Allocate once, across the entire namespace rather than a viewer's teams.
|
|
24
|
+
// Keep existing slugs stable when names, memberships, or collisions change.
|
|
25
|
+
export function migrateSlugs(db: DatabaseSync): void {
|
|
26
|
+
db.exec('BEGIN IMMEDIATE')
|
|
27
|
+
try {
|
|
28
|
+
for (const table of ['managed_team', 'managed_report', 'managed_bundle']) {
|
|
29
|
+
const columns = db.prepare(`PRAGMA table_info(${table})`).all() as { name: string }[]
|
|
30
|
+
if (!columns.some(column => column.name === 'slug')) db.exec(`ALTER TABLE ${table} ADD COLUMN slug TEXT`)
|
|
31
|
+
const rows = db.prepare(`SELECT id, slug FROM ${table} ORDER BY id`).all() as { id: string; slug: string | null }[]
|
|
32
|
+
const update = db.prepare(`UPDATE ${table} SET slug = ? WHERE id = ?`)
|
|
33
|
+
for (const row of allocateMissingSlugs(rows)) update.run(row.slug, row.id)
|
|
34
|
+
db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS ${table}_slug_idx ON ${table}(slug)`)
|
|
35
|
+
}
|
|
36
|
+
db.exec('COMMIT')
|
|
37
|
+
} catch (err) { db.exec('ROLLBACK'); throw err }
|
|
38
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { normalizeTeamPath } from './repo-path.ts'
|
|
2
|
+
|
|
3
|
+
const MAX_SUGGESTIONS = 100
|
|
4
|
+
const EXCLUDED_DIRECTORIES = new Set(['test', 'tests', 'script', 'scripts', 'mock', 'mocks', 'node_modules', 'vendor', 'out', 'artifacts', 'cache', 'build', 'dist', 'broadcast'])
|
|
5
|
+
|
|
6
|
+
function sourcePath(value: unknown): value is string {
|
|
7
|
+
if (typeof value !== 'string' || !value.endsWith('.sol') || /\.(?:t|s|test|spec)\.sol$/iu.test(value)) return false
|
|
8
|
+
const normalized = normalizeTeamPath(value)
|
|
9
|
+
const directories = value.split('/').slice(0, -1).map(part => part.toLowerCase())
|
|
10
|
+
const sourceRoot = directories.findIndex(part => part === 'src' || part === 'contracts')
|
|
11
|
+
return normalized.ok && normalized.path === value && !directories.some((part, index) =>
|
|
12
|
+
EXCLUDED_DIRECTORIES.has(part) || (part === 'lib' && (sourceRoot < 0 || index < sourceRoot)))
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
type ListingEntry = { name?: unknown; path?: unknown; type?: unknown; sha?: unknown; submodule_git_url?: unknown }
|
|
16
|
+
|
|
17
|
+
// Maintain the alphabetically first 100 unique candidates even when GitHub's
|
|
18
|
+
// input is unordered. Return whether a unique candidate exceeds the capacity.
|
|
19
|
+
export function addSoliditySuggestion(paths: string[], path: string): boolean {
|
|
20
|
+
let start = 0
|
|
21
|
+
let end = paths.length
|
|
22
|
+
while (start < end) {
|
|
23
|
+
const middle = Math.floor((start + end) / 2)
|
|
24
|
+
if (paths[middle]! < path) start = middle + 1
|
|
25
|
+
else end = middle
|
|
26
|
+
}
|
|
27
|
+
if (paths[start] === path) return false
|
|
28
|
+
const full = paths.length === MAX_SUGGESTIONS
|
|
29
|
+
if (start < MAX_SUGGESTIONS) {
|
|
30
|
+
// Evict before inserting, so retained candidates never exceed the cap.
|
|
31
|
+
if (full) paths.pop()
|
|
32
|
+
paths.splice(start, 0, path)
|
|
33
|
+
}
|
|
34
|
+
return full
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Suggestions from filenames, not deployability or import analysis. Read at
|
|
38
|
+
// most two source trees, using SHAs from the authorized pinned directory.
|
|
39
|
+
export async function readSolidityEntryPoints(directory: string, listing: unknown[], read: (suffix: string) => Promise<unknown>) {
|
|
40
|
+
const prefix = directory ? directory + '/' : ''
|
|
41
|
+
const entries = listing.filter((entry): entry is ListingEntry => !!entry && typeof entry === 'object').filter(entry =>
|
|
42
|
+
typeof entry.name === 'string' && !entry.name.includes('/') && entry.path === prefix + entry.name && !entry.submodule_git_url)
|
|
43
|
+
const hasFramework = entries.some(entry => entry.type === 'file' && typeof entry.name === 'string'
|
|
44
|
+
&& (entry.name === 'foundry.toml' || /^hardhat\.config\.(?:[cm]?js|ts)$/u.test(entry.name)))
|
|
45
|
+
const roots = entries.filter(entry => entry.type === 'dir' && (entry.name === 'contracts' || (hasFramework && entry.name === 'src'))
|
|
46
|
+
&& typeof entry.sha === 'string' && /^[a-f\d]{40}$/iu.test(entry.sha)).slice(0, 2)
|
|
47
|
+
const paths: string[] = []
|
|
48
|
+
let limited = false
|
|
49
|
+
for (const entry of entries) {
|
|
50
|
+
if (entry.type === 'file' && sourcePath(entry.path) && addSoliditySuggestion(paths, entry.path)) limited = true
|
|
51
|
+
}
|
|
52
|
+
for (const root of roots) {
|
|
53
|
+
if (!sourcePath(`${root.path}/Contract.sol`)) continue
|
|
54
|
+
try {
|
|
55
|
+
const data = await read(`/git/trees/${root.sha}?recursive=1`) as { tree?: unknown; truncated?: unknown }
|
|
56
|
+
if (!Array.isArray(data?.tree)) continue
|
|
57
|
+
limited ||= data.truncated === true
|
|
58
|
+
for (const entry of data.tree) {
|
|
59
|
+
if (entry?.type !== 'blob' || !['100644', '100755'].includes(entry.mode) || typeof entry.path !== 'string') continue
|
|
60
|
+
const path = `${root.path}/${entry.path}`
|
|
61
|
+
if (sourcePath(path) && addSoliditySuggestion(paths, path)) limited = true
|
|
62
|
+
}
|
|
63
|
+
} catch {
|
|
64
|
+
// Optional suggestions must not block browsing. The request handler
|
|
65
|
+
// rechecks GitHub access and local path grants after all reads.
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return { paths, limited }
|
|
69
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// The small SQL dialect used by the shared store. Parameter values never pass
|
|
2
|
+
// through these rewrites; only our own SQL does. Keep aliases case-sensitive.
|
|
3
|
+
export function postgresSql(source: string): { sql: string; names: string[] } {
|
|
4
|
+
const names: string[] = []
|
|
5
|
+
let position = 0
|
|
6
|
+
let sql = source.replaceAll('json_each(:contexts)', 'jsonb_array_elements(:contexts::jsonb)')
|
|
7
|
+
.replaceAll("CAST(unixepoch('subsec') * 1000 AS INTEGER)", 'CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)')
|
|
8
|
+
.replaceAll(/json_extract\((\w+\.value), '\$\.(\w+)'\)/gu, "($1->>'$2')")
|
|
9
|
+
.replaceAll('json_each(?)', 'jsonb_array_elements_text(?::jsonb)')
|
|
10
|
+
.replaceAll('INSERT OR IGNORE INTO', 'INSERT INTO')
|
|
11
|
+
.replaceAll('CROSS JOIN', 'JOIN')
|
|
12
|
+
.replaceAll(/\binstr\(/gu, 'strpos(')
|
|
13
|
+
// Quoted literals (including ':') are never placeholders or identifiers.
|
|
14
|
+
sql = sql.split(/('(?:''|[^'])*')/u).map((part, i) => i % 2 ? part : part
|
|
15
|
+
.replaceAll(/\?|(?<!:):([a-zA-Z]\w*)/gu, (_match, name: string | undefined) => {
|
|
16
|
+
if (!name) return `$${++position}`
|
|
17
|
+
let index = names.indexOf(name)
|
|
18
|
+
if (index < 0) { index = names.length; names.push(name) }
|
|
19
|
+
return `$${index + 1}`
|
|
20
|
+
})
|
|
21
|
+
.replaceAll(/\b[a-z]+[A-Z]\w*\b/gu, name => `"${name}"`)
|
|
22
|
+
).join('')
|
|
23
|
+
sql = sql.replaceAll(/(\$\d+) IS NULL/gu, '$1::text IS NULL')
|
|
24
|
+
if (source.includes('INSERT OR IGNORE')) sql += ' ON CONFLICT DO NOTHING'
|
|
25
|
+
return { sql, names }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function postgresSchema(sql: string): string {
|
|
29
|
+
return sql.replaceAll(') STRICT;', ');').replaceAll(/\bINTEGER\b/gu, 'BIGINT')
|
|
30
|
+
.replaceAll(/seq\s+BIGINT PRIMARY KEY/gu, 'seq BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY')
|
|
31
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// Each public store operation owns a transaction. SQLite serializes access to
|
|
2
|
+
// its single connection across awaits; Neon can reuse a request-owned connection.
|
|
3
|
+
import type { DatabaseSync, SQLInputValue } from 'node:sqlite'
|
|
4
|
+
import type { ManagedDb } from './db-methods.ts'
|
|
5
|
+
|
|
6
|
+
type MaybePromise<T> = T | Promise<T>
|
|
7
|
+
export interface ManagedSql {
|
|
8
|
+
prepare(sql: string): {
|
|
9
|
+
get(...params: unknown[]): MaybePromise<unknown>
|
|
10
|
+
all(...params: unknown[]): MaybePromise<unknown[]>
|
|
11
|
+
run(...params: unknown[]): MaybePromise<{ changes: number | bigint }>
|
|
12
|
+
}
|
|
13
|
+
close(): MaybePromise<void>
|
|
14
|
+
}
|
|
15
|
+
export interface ManagedSqlDriver extends ManagedSql {
|
|
16
|
+
scope<T>(write: boolean, work: () => Promise<T>, options?: { lock?: boolean; statement?: boolean }): Promise<T>
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// These operations execute exactly one SQL statement. Postgres already gives
|
|
20
|
+
// that statement an atomic snapshot; an explicit BEGIN/COMMIT adds only trips.
|
|
21
|
+
// Lease writes coordinate through their conditional UPSERT/UPDATE alone.
|
|
22
|
+
const SINGLE_STATEMENTS = new Set(['getReport', 'getBundle', 'getStorageRow', 'getStorageEncryption',
|
|
23
|
+
'listReports', 'listBundles', 'listReadableBundleIds', 'listAllRepos', 'listSelectedRepos', 'listRepoScopesForUser', 'listTriage',
|
|
24
|
+
'getFeedState', 'getWorkspaceShareFeedState', 'claimMaintenanceLease', 'finishMaintenanceLease', 'cacheRepoDefaultBranch', 'releaseBundleBuildLease'])
|
|
25
|
+
|
|
26
|
+
export function scopeManagedMethods(methods: ManagedDb, driver: ManagedSqlDriver): ManagedDb {
|
|
27
|
+
const entries = Object.entries(methods).map(([name, method]) => {
|
|
28
|
+
if (name === 'close') return [name, async () => { await driver.close() }]
|
|
29
|
+
const write = !/^(?:get|list|userCanRead|reportPermissionsFor)/u.test(name)
|
|
30
|
+
return [name, (...args: unknown[]) => driver.scope(write, () => method(...args), {
|
|
31
|
+
// Presence writes only one user's timestamp; they need no application
|
|
32
|
+
// writer lock. Keep the session/user read and timestamp in one transaction.
|
|
33
|
+
lock: write && name !== 'sessionWithUser', statement: SINGLE_STATEMENTS.has(name),
|
|
34
|
+
})]
|
|
35
|
+
})
|
|
36
|
+
return Object.fromEntries(entries) as ManagedDb
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function createSqliteDriver(db: DatabaseSync): ManagedSqlDriver {
|
|
40
|
+
let queue = Promise.resolve()
|
|
41
|
+
let closed = false
|
|
42
|
+
function enqueue<T>(work: () => Promise<T>): Promise<T> {
|
|
43
|
+
const result = queue.then(work)
|
|
44
|
+
queue = result.then(() => undefined, () => undefined)
|
|
45
|
+
return result
|
|
46
|
+
}
|
|
47
|
+
return {
|
|
48
|
+
prepare(sql) {
|
|
49
|
+
const stmt = db.prepare(sql)
|
|
50
|
+
return {
|
|
51
|
+
get: (...args) => stmt.get(...args as SQLInputValue[]),
|
|
52
|
+
all: (...args) => stmt.all(...args as SQLInputValue[]),
|
|
53
|
+
run: (...args) => stmt.run(...args as SQLInputValue[]),
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
scope(write, work) {
|
|
57
|
+
if (closed) return Promise.reject(new Error('Managed database is closed'))
|
|
58
|
+
return enqueue(async () => {
|
|
59
|
+
db.exec(write ? 'BEGIN IMMEDIATE' : 'BEGIN')
|
|
60
|
+
try {
|
|
61
|
+
const result = await work()
|
|
62
|
+
db.exec('COMMIT')
|
|
63
|
+
return result
|
|
64
|
+
} catch (err) {
|
|
65
|
+
db.exec('ROLLBACK')
|
|
66
|
+
throw err
|
|
67
|
+
}
|
|
68
|
+
})
|
|
69
|
+
},
|
|
70
|
+
async close() { closed = true; await queue; db.close() },
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { type StaticHandler, loadStatic } from '../server-e2e/static.ts'
|
|
2
|
+
import { parseManagedRoute } from '../common/managed/routes.js'
|
|
3
|
+
|
|
4
|
+
// A deep page URL must still resolve every asset at the app root. Preload
|
|
5
|
+
// headers resolve against the request URL, so their hrefs must be absolute too.
|
|
6
|
+
export function managedPageHtml(html: string): string {
|
|
7
|
+
return html.replaceAll(/(href|src)="\.\//gu, '$1="/')
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function loadManagedStatic(staticDir: string, { indexOnly = false, scanServer = null }: { indexOnly?: boolean, scanServer?: string | null } = {}): StaticHandler {
|
|
11
|
+
const serve = loadStatic(staticDir, scanServer, { transformIndex: managedPageHtml, indexOnly })
|
|
12
|
+
return (req, res) => {
|
|
13
|
+
if (!req.url?.startsWith('/') || req.url.startsWith('//')) return false
|
|
14
|
+
const url = new URL(req.url, 'http://localhost')
|
|
15
|
+
let path
|
|
16
|
+
try { path = decodeURIComponent(url.pathname) } catch { return false }
|
|
17
|
+
// No API request, including an unknown API route, may receive app HTML.
|
|
18
|
+
if (path === '/api' || path.startsWith('/api/')) return false
|
|
19
|
+
if (serve(req, res)) return true
|
|
20
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') return false
|
|
21
|
+
// Finding IDs can contain dots (including URL-shaped IDs). Recognised
|
|
22
|
+
// pages still receive HTML; missing assets retain a 404.
|
|
23
|
+
if (path.split('/').at(-1)?.includes('.') && !parseManagedRoute(url)) return false
|
|
24
|
+
const original = req.url
|
|
25
|
+
req.url = '/'
|
|
26
|
+
try { return serve(req, res) } finally { req.url = original }
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { loadManagedConfig } from './config.ts'
|
|
2
|
+
import { openManagedStorageDb } from './storage.ts'
|
|
3
|
+
|
|
4
|
+
// Require the deployment mode: DB_PATH belongs to e2e in combined deployments.
|
|
5
|
+
// Read the database requirement without opening bytes or enabling encryption.
|
|
6
|
+
export async function storageCommand(mode: string | undefined): Promise<void> {
|
|
7
|
+
if (mode !== 'managed' && mode !== 'managed-e2e' && mode !== 'e2e-managed') throw new Error('Storage status requires a deployment mode: --storage-encryption-status managed|managed-e2e|e2e-managed')
|
|
8
|
+
const db = await openManagedStorageDb(loadManagedConfig({ combined: mode !== 'managed' }))
|
|
9
|
+
try {
|
|
10
|
+
const status = await db.getStorageEncryption()
|
|
11
|
+
console.log(JSON.stringify(status ? {
|
|
12
|
+
encryption: 'chacha20-poly1305', complete: status.complete === 1, cleanupComplete: status.cleanupComplete === 1,
|
|
13
|
+
migrated: status.migrated, cursor: status.cursor,
|
|
14
|
+
} : { encryption: 'disabled' }))
|
|
15
|
+
} finally { await db.close() }
|
|
16
|
+
}
|