@preventive/triage 1.0.0-alpha.2 → 1.0.0-alpha.21
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/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-metadata.d.ts +10 -0
- package/common/bundle-metadata.js +177 -0
- package/common/bundle-reasons.d.ts +2 -0
- package/common/bundle-reasons.js +21 -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 +36 -0
- package/common/default-scan-models.ts +30 -0
- package/common/finding-id.js +47 -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 +121 -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 +33 -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 +110 -0
- package/out/client-sync.js +16 -13
- package/out/graph.js +30 -4
- package/out/index.html +55 -8
- package/out/prism.js +2 -2
- package/out/stasis.svg +45 -0
- package/out/terminal.js +273 -39
- package/out/view.css +1 -1
- package/out/view.js +198 -62
- package/package.json +179 -55
- package/report/index.js +254 -0
- package/report/src/finding-id.js +80 -0
- package/report/src/finding.js +312 -0
- package/report/src/labels.js +33 -0
- package/report/src/md-structure.js +471 -0
- package/report/src/md-text.js +167 -0
- package/report/src/meta.js +76 -0
- package/report/src/parse-codex.js +147 -0
- package/report/src/parse-deepsec.js +197 -0
- package/report/src/parse-deepview-fields.js +375 -0
- package/report/src/parse-deepview-md.js +185 -0
- package/report/src/parse-md-id.js +137 -0
- package/report/src/parse-md.js +322 -0
- package/report/src/parse-piolium-id.js +79 -0
- package/report/src/parse-piolium-rows.js +131 -0
- package/report/src/parse-piolium-tokens.js +175 -0
- package/report/src/parse-piolium.js +400 -0
- package/report/src/security.js +63 -0
- package/report/src/utf8.js +21 -0
- package/report/src/write-md-finding.js +273 -0
- package/report/src/write-md.js +291 -0
- 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-log.ts +34 -0
- package/server-common/vercel-blob.ts +110 -0
- package/server-e2e/app.ts +485 -0
- package/{server → server-e2e}/auth.ts +5 -1
- package/{server → server-e2e}/bus-receiver.ts +9 -8
- package/{server → server-e2e}/cli.js +9 -4
- package/{server → server-e2e}/config.ts +54 -39
- package/{server → server-e2e}/db-neon.ts +2 -2
- package/{server → server-e2e}/db-revision-sql.ts +7 -10
- package/{server → server-e2e}/db-stmt.ts +2 -2
- package/{server → server-e2e}/db.ts +96 -135
- package/{server → server-e2e}/http.ts +110 -12
- package/{server → server-e2e}/hub.ts +44 -14
- 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 +6 -8
- package/{server → server-e2e}/objstore/blob-vercel.ts +49 -141
- package/{server → server-e2e}/objstore/blob.ts +24 -9
- package/server-e2e/objstore/fetch-mint-guard.ts +74 -0
- package/{server → server-e2e}/objstore/handlers.ts +19 -20
- package/{server → server-e2e}/objstore/init.ts +53 -27
- 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 +110 -93
- package/{server → server-e2e}/objstore/sign.ts +105 -0
- package/{server → server-e2e}/objstore/store-neon.ts +10 -14
- package/{server → server-e2e}/objstore/store.ts +98 -118
- package/{server → server-e2e}/objstore/tokens.ts +9 -12
- package/{server → server-e2e}/peer.ts +7 -9
- package/{server → server-e2e}/pubsub.ts +29 -36
- package/{server → server-e2e}/sign.ts +12 -14
- package/{server → server-e2e}/sse-server.ts +105 -73
- package/{server → server-e2e}/sse-session.ts +30 -16
- package/{server → server-e2e}/static.ts +36 -29
- package/server-e2e/sync-handlers.ts +408 -0
- package/{server → server-e2e}/util.ts +9 -0
- package/{server → server-e2e}/ws-server.ts +29 -23
- package/server-managed/activity.ts +231 -0
- package/server-managed/avatar-store.ts +51 -0
- package/server-managed/blob-store.ts +66 -0
- package/server-managed/blob-vercel.ts +125 -0
- package/server-managed/brotli.ts +10 -0
- package/server-managed/bundle-cache.ts +185 -0
- package/server-managed/bundle-catalog.ts +29 -0
- package/server-managed/bundle-store.ts +28 -0
- package/server-managed/bundle-summary-cache.ts +97 -0
- package/server-managed/bundle.ts +39 -0
- package/server-managed/cache-storage.ts +40 -0
- package/server-managed/cli.js +13 -0
- package/server-managed/combined.ts +46 -0
- package/server-managed/comments.ts +151 -0
- package/server-managed/config.ts +144 -0
- package/server-managed/content-access.ts +15 -0
- package/server-managed/crypto.ts +25 -0
- package/server-managed/db-methods.ts +1336 -0
- package/server-managed/db-neon.ts +171 -0
- package/server-managed/db-schema.ts +203 -0
- package/server-managed/db-table-names.ts +22 -0
- package/server-managed/db.ts +109 -0
- package/server-managed/github-app.ts +332 -0
- package/server-managed/github-metadata.ts +65 -0
- package/server-managed/github-oauth.ts +215 -0
- package/server-managed/github-pulls.ts +115 -0
- package/server-managed/http-response.ts +18 -0
- package/server-managed/http.ts +2043 -0
- package/server-managed/import-triage.ts +48 -0
- package/server-managed/index.ts +135 -0
- package/server-managed/public-workspace.ts +150 -0
- package/server-managed/repo-path.ts +21 -0
- package/server-managed/report-migration.ts +35 -0
- package/server-managed/report-query.ts +4 -0
- package/server-managed/report-response.ts +16 -0
- package/server-managed/report-sources.ts +154 -0
- package/server-managed/repository-discovery.ts +82 -0
- package/server-managed/repository-policy.ts +27 -0
- package/server-managed/session.ts +78 -0
- package/server-managed/slugs.ts +38 -0
- package/server-managed/sql-postgres.ts +30 -0
- package/server-managed/sql.ts +61 -0
- package/server-managed/static.ts +28 -0
- package/server-managed/storage.ts +34 -0
- package/server-managed/team-catalog.ts +7 -0
- package/server-managed/team-feed.ts +128 -0
- package/server-managed/team-reports.ts +156 -0
- package/server-managed/triage-response.ts +16 -0
- package/server-managed/uploads.ts +47 -0
- package/server-managed/workspace-shares.ts +150 -0
- package/server.ts +50 -0
- package/server/index.ts +0 -481
- package/server/lifecycle.ts +0 -204
- package/server/sync-handlers.ts +0 -327
- /package/{server → server-e2e}/config.example.json +0 -0
- /package/{server → server-e2e}/objstore/fs.ts +0 -0
- /package/{server → server-e2e}/validation.ts +0 -0
|
@@ -15,16 +15,22 @@ import { errStack } from '../util.ts'
|
|
|
15
15
|
|
|
16
16
|
export type ObjstoreInitDeps = {
|
|
17
17
|
// Pre-opened storage handle, sharing the SQLite connection with
|
|
18
|
-
// the workspace_revision handle in server/db.ts.
|
|
18
|
+
// the workspace_revision handle in server-e2e/db.ts.
|
|
19
19
|
handle: Handle
|
|
20
20
|
reapIntervalMs: number
|
|
21
|
+
// Automatic-sweep switch (OBJSTORE_REAP_DISABLED). When true, NEITHER the boot
|
|
22
|
+
// sweep nor the periodic timer runs: `startupReap` resolves immediately
|
|
23
|
+
// and no timer is installed. Orphaned/superseded blobs and stale
|
|
24
|
+
// staging rows then accumulate unbounded — only safe if an external job
|
|
25
|
+
// handles GC. Omitted/false → reaper runs (the default). See config.ts.
|
|
26
|
+
reapDisabled?: boolean
|
|
21
27
|
send: (socket: WebSocket, msg: object) => void
|
|
22
28
|
broadcast: (tag: string, msg: object, except: WebSocket | null) => void
|
|
23
29
|
// Cross-instance pub/sub publishers. SQLite mode passes no-ops; Neon
|
|
24
30
|
// mode passes Postgres LISTEN/NOTIFY-backed implementations. See
|
|
25
|
-
// server/pubsub.ts for the bus design.
|
|
31
|
+
// server-e2e/pubsub.ts for the bus design.
|
|
26
32
|
publishObjPut: (tag: string, resourceTag: string) => void
|
|
27
|
-
publishObjDeleted: (tag: string, resourceTag: string, version: number) => void
|
|
33
|
+
publishObjDeleted: (tag: string, resourceTag: string, version: number, incarnation: string) => void
|
|
28
34
|
getNonce: (socket: WebSocket) => string | undefined
|
|
29
35
|
debug: boolean
|
|
30
36
|
// Auth gate for the FIRST objstore-put-begin against a workspace
|
|
@@ -39,14 +45,21 @@ export type ObjstoreInitDeps = {
|
|
|
39
45
|
// process behaviour). Multi-replica deployments MUST supply a
|
|
40
46
|
// shared secret here so a token minted on one replica's WS plane
|
|
41
47
|
// validates on another replica's REST plane (the WS-to-REST hop
|
|
42
|
-
// is not load-balancer-pinned). See server/index.ts boot logic
|
|
48
|
+
// is not load-balancer-pinned). See server-e2e/index.ts boot logic
|
|
43
49
|
// for the env var (`OBJSTORE_TOKEN_SECRET`).
|
|
44
50
|
tokenSecret?: TokenSecret
|
|
51
|
+
// New-workspace operator gate for the REST put-begin mint — returns
|
|
52
|
+
// `true` to DENY (password configured AND workspace new). The
|
|
53
|
+
// connection-independent analog of `authGate`; the client falls back to
|
|
54
|
+
// the in-band WS put-begin on a deny. Omitted → open (never deny),
|
|
55
|
+
// matching `authGate`'s no-config default.
|
|
56
|
+
restPutGate?: (workspaceTag: string) => Promise<boolean>
|
|
45
57
|
}
|
|
46
58
|
|
|
47
59
|
export type ObjstoreInit = {
|
|
48
60
|
handlers: ObjstoreHandlers
|
|
49
61
|
restDeps: ObjstoreRestDeps
|
|
62
|
+
reap: () => Promise<void>
|
|
50
63
|
startupReap: Promise<void>
|
|
51
64
|
// Cancels the periodic timer AND resolves only after any
|
|
52
65
|
// currently-in-flight sweep has finished, so shutdown can safely
|
|
@@ -56,6 +69,16 @@ export type ObjstoreInit = {
|
|
|
56
69
|
}
|
|
57
70
|
|
|
58
71
|
export function initObjstore(deps: ObjstoreInitDeps): ObjstoreInit {
|
|
72
|
+
// Fail loud on a lopsided auth config. The put-begin gate in
|
|
73
|
+
// handlers.ts only fires when BOTH authGate and sendUnauthorized are
|
|
74
|
+
// present (it needs the reporter to emit the `unauthorized` frame),
|
|
75
|
+
// so wiring authGate WITHOUT sendUnauthorized silently fails OPEN —
|
|
76
|
+
// unauthenticated first-writes to unknown workspaces would be accepted
|
|
77
|
+
// despite the operator's intent to gate them. Reject at boot rather
|
|
78
|
+
// than regress access control silently.
|
|
79
|
+
if (deps.authGate && !deps.sendUnauthorized) {
|
|
80
|
+
throw new Error('initObjstore: authGate requires sendUnauthorized (the put-begin gate needs it to emit the unauthorized frame)')
|
|
81
|
+
}
|
|
59
82
|
const handle = deps.handle
|
|
60
83
|
const secret = deps.tokenSecret ?? newTokenSecret()
|
|
61
84
|
const handlers = createObjstoreHandlers({
|
|
@@ -67,7 +90,10 @@ export function initObjstore(deps: ObjstoreInitDeps): ObjstoreInit {
|
|
|
67
90
|
...(deps.sendUnauthorized ? { sendUnauthorized: deps.sendUnauthorized } : {}),
|
|
68
91
|
})
|
|
69
92
|
const restDeps: ObjstoreRestDeps = {
|
|
70
|
-
handle, secret, broadcast: deps.broadcast,
|
|
93
|
+
handle, secret, broadcast: deps.broadcast,
|
|
94
|
+
publishObjPut: deps.publishObjPut, publishObjDeleted: deps.publishObjDeleted,
|
|
95
|
+
restPutGate: deps.restPutGate ?? (() => Promise.resolve(false)),
|
|
96
|
+
debug: deps.debug,
|
|
71
97
|
}
|
|
72
98
|
// Re-entrancy guard for periodic + startup sweeps. Kicking the
|
|
73
99
|
// startup sweep through the same `enqueueSweep` path means the
|
|
@@ -77,48 +103,48 @@ export function initObjstore(deps: ObjstoreInitDeps): ObjstoreInit {
|
|
|
77
103
|
let inFlight: Promise<void> | null = null
|
|
78
104
|
function enqueueSweep(): Promise<void> {
|
|
79
105
|
if (inFlight) return inFlight
|
|
80
|
-
//
|
|
81
|
-
|
|
82
|
-
// cleaned, and operators need to see it. Inner catch makes the
|
|
83
|
-
// promise resolve so callers (startupReap awaiter + setInterval)
|
|
84
|
-
// don't have to handle rejection.
|
|
85
|
-
const p = reapOrphans(handle).catch((err) => {
|
|
86
|
-
console.warn('objstore reaper error:', errStack(err))
|
|
87
|
-
}).finally(() => { if (inFlight === p) inFlight = null })
|
|
106
|
+
// Keep failures visible to explicit callers; automatic sweeps log them.
|
|
107
|
+
const p = reapOrphans(handle).finally(() => { if (inFlight === p) inFlight = null })
|
|
88
108
|
inFlight = p
|
|
89
109
|
return p
|
|
90
110
|
}
|
|
111
|
+
const drain = async () => { if (inFlight) await inFlight.catch(() => {}) }
|
|
112
|
+
const automaticSweep = () => enqueueSweep().catch(err => console.warn('objstore reaper error:', errStack(err)))
|
|
113
|
+
// Explicit cleanup remains available when automatic sweeps are disabled.
|
|
114
|
+
if (deps.reapDisabled) {
|
|
115
|
+
console.warn('objstore: automatic reaper DISABLED (OBJSTORE_REAP_DISABLED) — use /api/reap or an external cleanup job')
|
|
116
|
+
return { handlers, restDeps, reap: enqueueSweep, startupReap: Promise.resolve(), stopReaper: drain }
|
|
117
|
+
}
|
|
91
118
|
// Caller awaits this before accepting traffic so a fresh boot
|
|
92
119
|
// can't hand out list / fetch / put-begin against a tag whose
|
|
93
120
|
// on-disk state still has stranded files from a prior crash.
|
|
94
|
-
const startupReap =
|
|
121
|
+
const startupReap = automaticSweep()
|
|
95
122
|
// Jittered start of the periodic timer. Multi-replica deploys
|
|
96
|
-
// (Neon + Vercel Blob) commonly boot N replicas in tight lock-
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
//
|
|
100
|
-
//
|
|
101
|
-
//
|
|
102
|
-
//
|
|
103
|
-
//
|
|
104
|
-
// sweeps stay at exactly `reapIntervalMs` apart.
|
|
123
|
+
// (Neon + Vercel Blob) commonly boot N replicas in tight lock-step
|
|
124
|
+
// (deploy rollout, cluster restart), which would otherwise sync every
|
|
125
|
+
// replica's reaper to the same wall-clock tick and hammer the DB +
|
|
126
|
+
// blob store with N×readdir+lock-acquire bursts. A random
|
|
127
|
+
// first-interval delay spreads the cluster out without changing the
|
|
128
|
+
// long-term cadence. Jitter is 0…1× reapIntervalMs (first sweep at
|
|
129
|
+
// [interval, 2×interval] after boot); subsequent sweeps stay exactly
|
|
130
|
+
// `reapIntervalMs` apart.
|
|
105
131
|
let reapTimer: ReturnType<typeof setInterval> | null = null
|
|
106
132
|
const jitterMs = Math.floor(Math.random() * deps.reapIntervalMs)
|
|
107
133
|
const firstTimer = setTimeout(() => {
|
|
108
|
-
|
|
109
|
-
reapTimer = setInterval(
|
|
134
|
+
void automaticSweep()
|
|
135
|
+
reapTimer = setInterval(automaticSweep, deps.reapIntervalMs)
|
|
110
136
|
reapTimer.unref?.()
|
|
111
137
|
}, deps.reapIntervalMs + jitterMs)
|
|
112
138
|
firstTimer.unref?.()
|
|
113
139
|
return {
|
|
114
|
-
handlers, restDeps, startupReap,
|
|
140
|
+
handlers, restDeps, reap: enqueueSweep, startupReap,
|
|
115
141
|
stopReaper: async () => {
|
|
116
142
|
clearTimeout(firstTimer)
|
|
117
143
|
if (reapTimer) clearInterval(reapTimer)
|
|
118
144
|
// Drain whichever sweep is currently running (startup or
|
|
119
145
|
// periodic) — either could be mid-readdir/unlink at SIGTERM
|
|
120
146
|
// time and would otherwise outlive `handle.close()`.
|
|
121
|
-
|
|
147
|
+
await drain()
|
|
122
148
|
},
|
|
123
149
|
}
|
|
124
150
|
}
|
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
// predicate can't match a row a concurrent upload just refreshed.
|
|
28
28
|
|
|
29
29
|
import { type Handle, STAGING_TTL_MS_DEFAULT, isValidContentHash, isValidStagingId, isValidTag } from './store.ts'
|
|
30
|
+
import { debugId, debugTag } from '../util.ts'
|
|
30
31
|
|
|
31
32
|
type StagingRow = {
|
|
32
33
|
workspace_tag: string
|
|
@@ -67,12 +68,19 @@ type StagingRow = {
|
|
|
67
68
|
// live-set re-read, not via mutual exclusion.
|
|
68
69
|
async function gcBlobIfUnreferenced(
|
|
69
70
|
handle: Handle, tag: string, hash: string, modifiedMs: number, now: number, grace: number,
|
|
70
|
-
): Promise<
|
|
71
|
-
if (!isValidContentHash(hash)) return
|
|
72
|
-
if (now - modifiedMs < grace) return
|
|
71
|
+
): Promise<boolean> {
|
|
72
|
+
if (!isValidContentHash(hash)) return false
|
|
73
|
+
if (now - modifiedMs < grace) return false
|
|
73
74
|
const refs = await liveHashSet(handle, tag)
|
|
74
|
-
if (refs.has(hash)) return
|
|
75
|
+
if (refs.has(hash)) return false
|
|
75
76
|
await handle.blob.unlinkLive(tag, hash)
|
|
77
|
+
// Log EVERY live-blob deletion unconditionally (not behind `debug`):
|
|
78
|
+
// this is the only record that the GC removed bytes. A handful per
|
|
79
|
+
// sweep is normal (superseded versions aging out); a burst across many
|
|
80
|
+
// workspaces is the smoking gun for the "all uploaded data went
|
|
81
|
+
// missing" failure — pair it with the sweep summary in `reapOrphans`.
|
|
82
|
+
console.warn(`objstore-reaper: GC live blob ${debugTag(tag)}/${debugId(hash)} (unreferenced, age ${Math.round((now - modifiedMs) / 1000)}s ≥ grace ${Math.round(grace / 1000)}s)`)
|
|
83
|
+
return true
|
|
76
84
|
}
|
|
77
85
|
|
|
78
86
|
// The set of content hashes referenced by the workspace's live rows.
|
|
@@ -86,18 +94,22 @@ async function liveHashSet(handle: Handle, tag: string): Promise<Set<string>> {
|
|
|
86
94
|
// snapshot we read up front can race a concurrent commit; the
|
|
87
95
|
// reference re-read inside `gcBlobIfUnreferenced` (plus the grace
|
|
88
96
|
// window) ensures we never unlink a blob a live row names.
|
|
89
|
-
|
|
90
|
-
|
|
97
|
+
// Returns the number of live blobs GC'd for this tag, so `reapOrphans`
|
|
98
|
+
// can surface a sweep-wide total (the headline signal for mass loss).
|
|
99
|
+
async function reapUnreferencedForTag(handle: Handle, tag: string, now: number, grace: number): Promise<number> {
|
|
100
|
+
if (!isValidTag(tag)) return 0
|
|
91
101
|
const blobs = await handle.blob.listLiveBlobs(tag)
|
|
92
|
-
if (blobs.length === 0) return
|
|
102
|
+
if (blobs.length === 0) return 0
|
|
93
103
|
const referenced = await liveHashSet(handle, tag)
|
|
104
|
+
let gc = 0
|
|
94
105
|
for (const { hash, modifiedMs } of blobs) {
|
|
95
106
|
if (!isValidContentHash(hash)) continue
|
|
96
107
|
// Referenced in our snapshot → skip the grace + re-read path
|
|
97
108
|
// entirely; only unreferenced blobs need it.
|
|
98
109
|
if (referenced.has(hash)) continue
|
|
99
|
-
await gcBlobIfUnreferenced(handle, tag, hash, modifiedMs, now, grace)
|
|
110
|
+
if (await gcBlobIfUnreferenced(handle, tag, hash, modifiedMs, now, grace)) gc++
|
|
100
111
|
}
|
|
112
|
+
return gc
|
|
101
113
|
}
|
|
102
114
|
|
|
103
115
|
// Drop staging rows older than the TTL and unlink their on-storage
|
|
@@ -109,7 +121,7 @@ async function reapStaleStagingRows(handle: Handle, now: number, stagingTtlMs: n
|
|
|
109
121
|
// Push the staleness filter into SQL so the
|
|
110
122
|
// `workspace_object_staging_begun_at_idx` index handles the scan.
|
|
111
123
|
// The snapshot is O(stale-rows) cluster-wide. DB-layout audit
|
|
112
|
-
// `server/objstore/store.ts`.
|
|
124
|
+
// `server-e2e/objstore/store.ts`.
|
|
113
125
|
const staleBefore = now - stagingTtlMs
|
|
114
126
|
const staging = await handle.listAllStaging.all(staleBefore) as StagingRow[]
|
|
115
127
|
for (const s of staging) {
|
|
@@ -172,9 +184,10 @@ export async function reapOrphans(handle: Handle, stagingTtlMs: number = STAGING
|
|
|
172
184
|
const grace = stagingTtlMs
|
|
173
185
|
// Pass 1: tags the live table knows about — GC unreferenced live
|
|
174
186
|
// blobs (past the grace window) against the referenced-hash set.
|
|
187
|
+
let liveGc = 0
|
|
175
188
|
const liveTagsRows = await handle.listLiveTags.all()
|
|
176
189
|
const liveTags = liveTagsRows.map((r) => r.workspace_tag)
|
|
177
|
-
for (const tag of liveTags) await reapUnreferencedForTag(handle, tag, now, grace)
|
|
190
|
+
for (const tag of liveTags) liveGc += await reapUnreferencedForTag(handle, tag, now, grace)
|
|
178
191
|
// Whole-workspace deletes leave residue (dirs / blob-prefixes) the
|
|
179
192
|
// live table no longer lists. Walk the backend's top-level workspace
|
|
180
193
|
// listing to find them; for each straggler tag, GC its unreferenced
|
|
@@ -186,7 +199,14 @@ export async function reapOrphans(handle: Handle, stagingTtlMs: number = STAGING
|
|
|
186
199
|
const liveSet = new Set(liveTags)
|
|
187
200
|
for (const tag of topLevel) {
|
|
188
201
|
if (liveSet.has(tag) || !isValidTag(tag)) continue
|
|
189
|
-
await reapUnreferencedForTag(handle, tag, now, grace)
|
|
202
|
+
liveGc += await reapUnreferencedForTag(handle, tag, now, grace)
|
|
203
|
+
}
|
|
204
|
+
// Sweep-wide total. A nonzero count means the GC deleted live bytes
|
|
205
|
+
// this pass — logged unconditionally so "all data went missing"
|
|
206
|
+
// leaves an obvious server-side trail (a large count over a short
|
|
207
|
+
// window is the signature). Per-blob lines above carry which/why.
|
|
208
|
+
if (liveGc > 0) {
|
|
209
|
+
console.warn(`objstore-reaper: swept ${liveTags.length} live + ${topLevel.length} store tag(s); GC'd ${liveGc} live blob(s)`)
|
|
190
210
|
}
|
|
191
211
|
// Pass 2: stale staging rows + orphan staging blobs. The orphan
|
|
192
212
|
// sweep does per-blob row lookups (no caller-side snapshot), so a
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Shared failure-response helpers for the objstore REST plane
|
|
2
|
+
// (./rest.ts + ./rest-mint.ts). A uniform `{ error: <reason> }` JSON
|
|
3
|
+
// envelope so clients parse one shape; the 409 variant additionally
|
|
4
|
+
// carries the live version/incarnation so a PUT / put-begin can rebase.
|
|
5
|
+
// Reasons (400/401/403/404/405/409/410/411/500/503) are documented in
|
|
6
|
+
// server-e2e/README.md and the client decides recovery from the code —
|
|
7
|
+
// probe-distinguishing defense isn't a goal: the richer PUT/GET reasons are
|
|
8
|
+
// reachable only after the bearer-token check, while the public surfaces (an
|
|
9
|
+
// unauthed GET/PUT, or the signature-authed POST mint) expose only
|
|
10
|
+
// 400/401/404 — none of which gives a probe signal it couldn't already
|
|
11
|
+
// enumerate.
|
|
12
|
+
|
|
13
|
+
import type { ServerResponse } from 'node:http'
|
|
14
|
+
|
|
15
|
+
export function deny(res: ServerResponse, status: number, body: string): void {
|
|
16
|
+
res.writeHead(status, { 'content-type': 'application/json' })
|
|
17
|
+
res.end(JSON.stringify({ error: body }))
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Variant of `deny` that augments the envelope with the live row's
|
|
21
|
+
// `currentVersion` + `currentIncarnation` so a 409'd PUT / put-begin can
|
|
22
|
+
// rebase onto the right precondition token rather than retry blindly
|
|
23
|
+
// against an occupied slot. Symmetric with the WS plane's
|
|
24
|
+
// `objstore-conflict` frame.
|
|
25
|
+
export function denyConflict(res: ServerResponse, currentVersion: number | null, currentIncarnation: string | null): void {
|
|
26
|
+
res.writeHead(409, { 'content-type': 'application/json' })
|
|
27
|
+
res.end(JSON.stringify({ error: 'conflict', currentVersion, currentIncarnation }))
|
|
28
|
+
}
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
// REST endpoints for the objstore plane — the single-round-trip,
|
|
2
|
+
// SSE-session-independent alternative to the WS `objstore-fetch` /
|
|
3
|
+
// `objstore-put-begin` / `objstore-delete` handshakes (so these ops can't
|
|
4
|
+
// be interrupted by an SSE replica hop). One route, `POST
|
|
5
|
+
// /api/objstore/{tag}/{res}`, with a JSON body `{ op, ts, signature, ... }`:
|
|
6
|
+
// the workspace signs the request (the workspaceTag IS the Ed25519 pubkey,
|
|
7
|
+
// so verification is self-contained — no socket, no stored key), the server
|
|
8
|
+
// verifies it + freshness/replay-guards (the connection-nonce stand-in).
|
|
9
|
+
// fetch/put return the SAME token shape the WS path sends, for the UNCHANGED
|
|
10
|
+
// bearer-token GET / PUT byte transfers; delete mutates in place and returns
|
|
11
|
+
// `{ deletedVersion }` (no token — there are no bytes to move). See
|
|
12
|
+
// server-e2e/README.md "REST endpoints & tokens".
|
|
13
|
+
|
|
14
|
+
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
15
|
+
import { Buffer } from 'node:buffer'
|
|
16
|
+
|
|
17
|
+
import type { ObjstoreRestDeps, RouteMatch } from './rest.ts'
|
|
18
|
+
import { deny, denyConflict } from './rest-deny.ts'
|
|
19
|
+
import { MAX_CONTENT_LENGTH, beginPut, deleteObject, getLive, isValidContentHash, isValidIncarnation, isValidSignature, objectMetaWire } from './store.ts'
|
|
20
|
+
import { mintGetToken, mintPutToken } from './tokens.ts'
|
|
21
|
+
import { verifyObjstoreDeleteRestSig, verifyObjstoreFetchRestSig, verifyObjstorePutBeginRestSig } from './sign.ts'
|
|
22
|
+
import { createFetchMintGuard } from './fetch-mint-guard.ts'
|
|
23
|
+
|
|
24
|
+
// Per-process freshness + replay guard for the REST POSTs (fetch, put-begin,
|
|
25
|
+
// delete). They have no connection nonce to bind, so they bind a client
|
|
26
|
+
// timestamp — see ./fetch-mint-guard.ts. The three ops' signatures are
|
|
27
|
+
// globally unique (distinct canonical domains), so one guard dedups all three.
|
|
28
|
+
const mintGuard = createFetchMintGuard()
|
|
29
|
+
|
|
30
|
+
// Hard cap on a mint JSON body. The put-begin body (op, ts, signature,
|
|
31
|
+
// prevVersion, prevIncarnation, expectedLength, contentHash) is the larger
|
|
32
|
+
// of the two and still only a few hundred bytes; 4 KiB is comfortable
|
|
33
|
+
// headroom. Bounds the read so a hostile client can't stream an unbounded
|
|
34
|
+
// body into memory before the parse.
|
|
35
|
+
const MINT_BODY_MAX = 4096
|
|
36
|
+
|
|
37
|
+
// Read a small JSON request body up to `maxBytes`, returning the parsed
|
|
38
|
+
// value or null on overflow / parse failure / read error. Used only by
|
|
39
|
+
// the mint POST; the PUT body is the raw blob and streams to disk via a
|
|
40
|
+
// different path.
|
|
41
|
+
async function readJsonBody(req: IncomingMessage, maxBytes: number): Promise<unknown> {
|
|
42
|
+
const chunks: Buffer[] = []
|
|
43
|
+
let total = 0
|
|
44
|
+
try {
|
|
45
|
+
for await (const chunk of req) {
|
|
46
|
+
const buf = chunk as Buffer
|
|
47
|
+
total += buf.length
|
|
48
|
+
if (total > maxBytes) return null
|
|
49
|
+
chunks.push(buf)
|
|
50
|
+
}
|
|
51
|
+
} catch { return null }
|
|
52
|
+
try { return JSON.parse(Buffer.concat(chunks).toString('utf8')) }
|
|
53
|
+
catch { return null }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Extract + range-check the auth fields common to both mint ops. Gates the
|
|
57
|
+
// signature to the wire shape (`isValidSignature`, matching the WS plane) so
|
|
58
|
+
// an obviously-malformed sig is rejected up front rather than burning an
|
|
59
|
+
// Ed25519 verify — and, since this runs before `mintGuard.admit`, a bad sig
|
|
60
|
+
// never reaches the replay cache regardless.
|
|
61
|
+
function parseMintAuth(body: object): { ts: number; signature: string } | null {
|
|
62
|
+
const ts = (body as { ts?: unknown }).ts
|
|
63
|
+
const signature = (body as { signature?: unknown }).signature
|
|
64
|
+
if (!Number.isSafeInteger(ts) || (ts as number) < 0) return null
|
|
65
|
+
if (!isValidSignature(signature)) return null
|
|
66
|
+
return { ts: ts as number, signature }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Dispatch the mint by body `op`. Auth-proxy friendly: the signature rides
|
|
70
|
+
// the JSON body (not a header or cookie, so it never collides with a
|
|
71
|
+
// cookie-based proxy), and the response is JSON — no redirect, no token in
|
|
72
|
+
// any URL. Client note: a replayed signature is rejected, so a retry MUST
|
|
73
|
+
// re-sign with a fresh `ts` rather than resend the same body.
|
|
74
|
+
export async function handleRestMint(
|
|
75
|
+
deps: ObjstoreRestDeps, req: IncomingMessage, res: ServerResponse, route: RouteMatch,
|
|
76
|
+
): Promise<void> {
|
|
77
|
+
const body = await readJsonBody(req, MINT_BODY_MAX)
|
|
78
|
+
if (!body || typeof body !== 'object') { deny(res, 400, 'bad-request'); return }
|
|
79
|
+
const op = (body as { op?: unknown }).op
|
|
80
|
+
if (op === 'fetch') { await handleRestFetchMint(deps, res, route, body); return }
|
|
81
|
+
if (op === 'put') { await handleRestPutBegin(deps, res, route, body); return }
|
|
82
|
+
if (op === 'delete') { await handleRestDelete(deps, res, route, body); return }
|
|
83
|
+
deny(res, 400, 'bad-request')
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// op:'fetch' — mirrors the WS `objstore-fetch` → `objstore-fetch-token`
|
|
87
|
+
// handshake; returns `{ ...meta, urlPath, token, expiresAt }` for the
|
|
88
|
+
// UNCHANGED bearer-token GET.
|
|
89
|
+
async function handleRestFetchMint(
|
|
90
|
+
deps: ObjstoreRestDeps, res: ServerResponse, route: RouteMatch, body: object,
|
|
91
|
+
): Promise<void> {
|
|
92
|
+
const auth = parseMintAuth(body)
|
|
93
|
+
if (!auth) { deny(res, 400, 'bad-request'); return }
|
|
94
|
+
// Verify the signature BEFORE touching the replay guard so a bad-sig
|
|
95
|
+
// request can't consume cache space. The signature commits to THIS `ts`.
|
|
96
|
+
if (!await verifyObjstoreFetchRestSig(route.tag, route.resourceTag, auth.ts, auth.signature)) {
|
|
97
|
+
deny(res, 401, 'unauthorized'); return
|
|
98
|
+
}
|
|
99
|
+
// Freshness window + single-use dedup (the connection-nonce stand-in).
|
|
100
|
+
// 'stale'/'replay' are both opaque 401s — the client re-signs with a
|
|
101
|
+
// fresh `ts` and retries.
|
|
102
|
+
if (mintGuard.admit(auth.signature, auth.ts) !== 'ok') { deny(res, 401, 'unauthorized'); return }
|
|
103
|
+
const row = await getLive(deps.handle, route.tag, route.resourceTag)
|
|
104
|
+
if (!row) { deny(res, 404, 'not-found'); return }
|
|
105
|
+
const { token, exp } = mintGetToken(deps.secret, route.tag, route.resourceTag, row.version, row.incarnation)
|
|
106
|
+
res.writeHead(200, { 'content-type': 'application/json' })
|
|
107
|
+
res.end(JSON.stringify({
|
|
108
|
+
...objectMetaWire(row),
|
|
109
|
+
urlPath: `/api/objstore/${route.tag}/${route.resourceTag}`,
|
|
110
|
+
token,
|
|
111
|
+
expiresAt: exp,
|
|
112
|
+
}))
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// op:'put' — mirrors the WS `handlePutBegin`: verify the signed put fields,
|
|
116
|
+
// freshness/replay-guard, the new-workspace operator gate, then `beginPut`
|
|
117
|
+
// (advisory prev-check + staging row) and mint the put-token. Returns
|
|
118
|
+
// `{ stagingId, urlPath, token, expiresAt }` for the UNCHANGED bearer-token
|
|
119
|
+
// PUT; `409 conflict` (with current version/incarnation to rebase on) or
|
|
120
|
+
// `403 workspace-full` map `beginPut`'s refusals.
|
|
121
|
+
//
|
|
122
|
+
// New-workspace gate: REST can't read a socket's operator-auth state, so
|
|
123
|
+
// when a password is configured AND the workspace is never-before-seen this
|
|
124
|
+
// returns 401 — the client falls back to the in-band WS put-begin (which
|
|
125
|
+
// runs the operator auth flow). No-op for password-less deployments or
|
|
126
|
+
// existing workspaces (the common case).
|
|
127
|
+
async function handleRestPutBegin(
|
|
128
|
+
deps: ObjstoreRestDeps, res: ServerResponse, route: RouteMatch, body: object,
|
|
129
|
+
): Promise<void> {
|
|
130
|
+
const auth = parseMintAuth(body)
|
|
131
|
+
if (!auth) { deny(res, 400, 'bad-request'); return }
|
|
132
|
+
// Field gates mirror `handlePutBegin`'s up-front rejects.
|
|
133
|
+
const expectedLength = (body as { expectedLength?: unknown }).expectedLength
|
|
134
|
+
if (!Number.isSafeInteger(expectedLength) || (expectedLength as number) < 0 || (expectedLength as number) > MAX_CONTENT_LENGTH) {
|
|
135
|
+
deny(res, 400, 'bad-request'); return
|
|
136
|
+
}
|
|
137
|
+
const prevVersionRaw = (body as { prevVersion?: unknown }).prevVersion
|
|
138
|
+
if (prevVersionRaw != null && (typeof prevVersionRaw !== 'number' || !Number.isSafeInteger(prevVersionRaw))) {
|
|
139
|
+
deny(res, 400, 'bad-request'); return
|
|
140
|
+
}
|
|
141
|
+
const prevVersion = typeof prevVersionRaw === 'number' ? prevVersionRaw : null
|
|
142
|
+
const prevIncarnationRaw = (body as { prevIncarnation?: unknown }).prevIncarnation
|
|
143
|
+
const prevIncarnation = typeof prevIncarnationRaw === 'string' ? prevIncarnationRaw : null
|
|
144
|
+
// prevVersion/prevIncarnation travel as a null-iff-null pair (matches the
|
|
145
|
+
// WS `validPrevPair`): a half-pair is malformed.
|
|
146
|
+
if ((prevVersion === null) !== (prevIncarnation === null)) { deny(res, 400, 'bad-request'); return }
|
|
147
|
+
// A non-null incarnation must be the wire shape (matches the WS path's
|
|
148
|
+
// `isValidIncarnation` gate). Signature-covered + CAS-checked regardless,
|
|
149
|
+
// but reject the obviously-malformed up front.
|
|
150
|
+
if (prevIncarnation !== null && !isValidIncarnation(prevIncarnation)) { deny(res, 400, 'bad-request'); return }
|
|
151
|
+
const contentHash = (body as { contentHash?: unknown }).contentHash
|
|
152
|
+
if (!isValidContentHash(contentHash)) { deny(res, 400, 'bad-request'); return }
|
|
153
|
+
|
|
154
|
+
const fields = {
|
|
155
|
+
workspaceTag: route.tag, resourceTag: route.resourceTag,
|
|
156
|
+
prevVersion, prevIncarnation, contentHash, expectedLength: expectedLength as number,
|
|
157
|
+
}
|
|
158
|
+
if (!await verifyObjstorePutBeginRestSig(fields, auth.ts, auth.signature)) { deny(res, 401, 'unauthorized'); return }
|
|
159
|
+
if (mintGuard.admit(auth.signature, auth.ts) !== 'ok') { deny(res, 401, 'unauthorized'); return }
|
|
160
|
+
// New-workspace operator gate — see the function header. Refusing here
|
|
161
|
+
// routes the client to its in-band WS put-begin fallback.
|
|
162
|
+
if (await deps.restPutGate(route.tag)) { deny(res, 401, 'unauthorized'); return }
|
|
163
|
+
const result = await beginPut(deps.handle, {
|
|
164
|
+
workspaceTag: route.tag, resourceTag: route.resourceTag,
|
|
165
|
+
prevVersion, prevIncarnation, expectedLength: expectedLength as number,
|
|
166
|
+
contentHash, signature: auth.signature,
|
|
167
|
+
})
|
|
168
|
+
if (!result.ok) {
|
|
169
|
+
if (result.reason === 'workspace-full') { deny(res, 403, 'workspace-full'); return }
|
|
170
|
+
denyConflict(res, result.conflict?.version ?? null, result.conflict?.incarnation ?? null); return
|
|
171
|
+
}
|
|
172
|
+
const { token, exp } = mintPutToken(deps.secret, route.tag, route.resourceTag, result.stagingId, expectedLength as number)
|
|
173
|
+
res.writeHead(200, { 'content-type': 'application/json' })
|
|
174
|
+
res.end(JSON.stringify({
|
|
175
|
+
stagingId: result.stagingId,
|
|
176
|
+
urlPath: `/api/objstore/${route.tag}/${route.resourceTag}`,
|
|
177
|
+
token,
|
|
178
|
+
expiresAt: exp,
|
|
179
|
+
}))
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// op:'delete' — mirrors the WS `handleDelete`: verify the signed delete
|
|
183
|
+
// fields, freshness/replay-guard, then `deleteObject` (a precondition-checked
|
|
184
|
+
// version-CAS drop). Unlike put, there is NO operator gate — delete is
|
|
185
|
+
// signature-gated, idempotent, and creates nothing (the WS handleDelete has
|
|
186
|
+
// no authGate either). Returns 200 `{ deletedVersion }` (0 = idempotent
|
|
187
|
+
// no-op on a missing row with a null precondition); 409 conflict (stale
|
|
188
|
+
// prevVersion/incarnation); 404 not-found (a non-null precondition against a
|
|
189
|
+
// missing row). On a real drop it broadcasts `objstore-deleted` to
|
|
190
|
+
// subscribers (+ cross-instance), exactly like the WS path.
|
|
191
|
+
async function handleRestDelete(
|
|
192
|
+
deps: ObjstoreRestDeps, res: ServerResponse, route: RouteMatch, body: object,
|
|
193
|
+
): Promise<void> {
|
|
194
|
+
const auth = parseMintAuth(body)
|
|
195
|
+
if (!auth) { deny(res, 400, 'bad-request'); return }
|
|
196
|
+
const prevVersionRaw = (body as { prevVersion?: unknown }).prevVersion
|
|
197
|
+
if (prevVersionRaw != null && (typeof prevVersionRaw !== 'number' || !Number.isSafeInteger(prevVersionRaw))) {
|
|
198
|
+
deny(res, 400, 'bad-request'); return
|
|
199
|
+
}
|
|
200
|
+
const prevVersion = typeof prevVersionRaw === 'number' ? prevVersionRaw : null
|
|
201
|
+
const prevIncarnationRaw = (body as { prevIncarnation?: unknown }).prevIncarnation
|
|
202
|
+
const prevIncarnation = typeof prevIncarnationRaw === 'string' ? prevIncarnationRaw : null
|
|
203
|
+
// null-iff-null pair (matches the WS `validPrevPair`); a non-null
|
|
204
|
+
// incarnation must be the wire shape.
|
|
205
|
+
if ((prevVersion === null) !== (prevIncarnation === null)) { deny(res, 400, 'bad-request'); return }
|
|
206
|
+
if (prevIncarnation !== null && !isValidIncarnation(prevIncarnation)) { deny(res, 400, 'bad-request'); return }
|
|
207
|
+
|
|
208
|
+
const fields = { workspaceTag: route.tag, resourceTag: route.resourceTag, prevVersion, prevIncarnation }
|
|
209
|
+
if (!await verifyObjstoreDeleteRestSig(fields, auth.ts, auth.signature)) { deny(res, 401, 'unauthorized'); return }
|
|
210
|
+
if (mintGuard.admit(auth.signature, auth.ts) !== 'ok') { deny(res, 401, 'unauthorized'); return }
|
|
211
|
+
const result = await deleteObject(deps.handle, route.tag, route.resourceTag, prevVersion, prevIncarnation)
|
|
212
|
+
if (!result.ok) {
|
|
213
|
+
if (result.reason === 'conflict') { denyConflict(res, result.conflict?.version ?? null, result.conflict?.incarnation ?? null); return }
|
|
214
|
+
deny(res, 404, 'not-found'); return
|
|
215
|
+
}
|
|
216
|
+
res.writeHead(200, { 'content-type': 'application/json' })
|
|
217
|
+
res.end(JSON.stringify({ deletedVersion: result.deletedVersion }))
|
|
218
|
+
// deletedVersion 0 = nothing was live → nothing to broadcast. A real drop
|
|
219
|
+
// fans out to subscribers (including the originator, matching the WS path's
|
|
220
|
+
// `except: null`) so peers' `onDeleted` fire.
|
|
221
|
+
if (result.deletedVersion === 0) return
|
|
222
|
+
deps.broadcast(route.tag, { type: 'objstore-deleted', workspaceTag: route.tag, resourceTag: route.resourceTag, version: result.deletedVersion, incarnation: prevIncarnation! }, null)
|
|
223
|
+
deps.publishObjDeleted(route.tag, route.resourceTag, result.deletedVersion, prevIncarnation!)
|
|
224
|
+
}
|