@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
|
@@ -41,7 +41,9 @@ import {
|
|
|
41
41
|
} from './store.ts'
|
|
42
42
|
import type { LiveReader } from './blob.ts'
|
|
43
43
|
import { type TokenSecret, extractBearer, verifyToken } from './tokens.ts'
|
|
44
|
-
import {
|
|
44
|
+
import { deny, denyConflict } from './rest-deny.ts'
|
|
45
|
+
import { handleRestMint } from './rest-mint.ts'
|
|
46
|
+
import { debugId, debugTag, errMsg, errStack } from '../util.ts'
|
|
45
47
|
|
|
46
48
|
// Server-side fault codes that should surface as 500 `io-error`
|
|
47
49
|
// rather than 400 `aborted`. `pipeline(req, ws)` rejects with the
|
|
@@ -66,6 +68,18 @@ export type ObjstoreRestDeps = {
|
|
|
66
68
|
// workspace_object for the full metadata (version, hash, length,
|
|
67
69
|
// signature). SQLite mode passes a no-op.
|
|
68
70
|
publishObjPut: (tag: string, resourceTag: string) => void
|
|
71
|
+
// Cross-instance pub/sub for objstore-deleted — fired alongside the local
|
|
72
|
+
// `broadcast` after a successful REST delete mint, so peers on OTHER
|
|
73
|
+
// instances drop the resource in real time. Carries (tag, resourceTag,
|
|
74
|
+
// version, incarnation) inline (the row is gone post-delete). SQLite mode passes a no-op.
|
|
75
|
+
publishObjDeleted: (tag: string, resourceTag: string, version: number, incarnation: string) => void
|
|
76
|
+
// New-workspace operator gate for the REST put-begin mint — the
|
|
77
|
+
// connection-independent analog of the WS path's `authGate`. Returns
|
|
78
|
+
// `true` to DENY (a password is configured AND the workspace is
|
|
79
|
+
// never-before-seen), which routes the client to its in-band WS
|
|
80
|
+
// put-begin fallback (REST has no socket auth state to consult).
|
|
81
|
+
// No-config default is open (never deny), matching the WS authGate.
|
|
82
|
+
restPutGate: (workspaceTag: string) => Promise<boolean>
|
|
69
83
|
debug: boolean
|
|
70
84
|
}
|
|
71
85
|
|
|
@@ -91,7 +105,7 @@ export type ObjstoreRestDeps = {
|
|
|
91
105
|
// concurrent same-token PUT is rejected (409) before it can open a
|
|
92
106
|
// second writer. The slot is held only across body + commit, released
|
|
93
107
|
// in a `finally`, and bounded by the REST idle-body timeout in
|
|
94
|
-
// server/http.ts so a slow-loris can't pin it. NOT a commit lock —
|
|
108
|
+
// server-e2e/http.ts so a slow-loris can't pin it. NOT a commit lock —
|
|
95
109
|
// commits stay lock-free on the version-CAS above. Per-process: the
|
|
96
110
|
// Vercel multi-replica path leans on `allowOverwrite: true` + the
|
|
97
111
|
// commit CAS + content-addressing for cross-replica correctness (a
|
|
@@ -116,34 +130,21 @@ export function matchRoute(url: string | undefined): RouteMatch | null {
|
|
|
116
130
|
return { tag: tag!, resourceTag: resourceTag! }
|
|
117
131
|
}
|
|
118
132
|
|
|
119
|
-
function deny(res: ServerResponse, status: number, body: string): void {
|
|
120
|
-
// Uniform `{ error: <reason> }` JSON envelope for every failure so
|
|
121
|
-
// clients parse one shape. Status + reason are NOT intentionally
|
|
122
|
-
// indistinguishable across causes — 401/404/405/410/411/500 each map
|
|
123
|
-
// to a documented reason in server/README.md and the client decides
|
|
124
|
-
// recovery from the code. Probe-distinguishing defense isn't a goal:
|
|
125
|
-
// every reason is reachable only after the route + bearer-token check
|
|
126
|
-
// passes (or as 401/404 from the public surface), so a probe gains no
|
|
127
|
-
// signal it couldn't otherwise enumerate.
|
|
128
|
-
res.writeHead(status, { 'content-type': 'application/json' })
|
|
129
|
-
res.end(JSON.stringify({ error: body }))
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
// Variant of `deny` that augments the JSON envelope with the live
|
|
133
|
-
// row's `currentVersion` + `currentIncarnation` so a REST PUT 409 lets
|
|
134
|
-
// the caller rebase onto the right precondition token. Without this the
|
|
135
|
-
// client only learns the slot is occupied — not at what (version,
|
|
136
|
-
// incarnation) — and retries blindly against a non-empty slot, looping
|
|
137
|
-
// indefinitely against a live row. Symmetric with the WS plane's
|
|
138
|
-
// `objstore-conflict` envelope.
|
|
139
|
-
function denyConflict(res: ServerResponse, currentVersion: number | null, currentIncarnation: string | null): void {
|
|
140
|
-
res.writeHead(409, { 'content-type': 'application/json' })
|
|
141
|
-
res.end(JSON.stringify({ error: 'conflict', currentVersion, currentIncarnation }))
|
|
142
|
-
}
|
|
143
|
-
|
|
144
133
|
export async function handleRest(deps: ObjstoreRestDeps, req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|
145
134
|
const route = matchRoute(req.url)
|
|
146
135
|
if (!route) { deny(res, 404, 'not-found'); return }
|
|
136
|
+
// POST = REST mint (fetch or put-begin, by body `op`; signature-authed
|
|
137
|
+
// via the JSON body, no bearer token). Dispatched before the bearer-token
|
|
138
|
+
// gate below, which guards the token-authed GET/PUT byte transfers.
|
|
139
|
+
if (req.method === 'POST') {
|
|
140
|
+
try { await handleRestMint(deps, req, res, route) }
|
|
141
|
+
catch (err: unknown) {
|
|
142
|
+
if (deps.debug) console.warn('objstore POST error:', errStack(err))
|
|
143
|
+
if (res.headersSent) res.destroy()
|
|
144
|
+
else deny(res, 500, 'internal')
|
|
145
|
+
}
|
|
146
|
+
return
|
|
147
|
+
}
|
|
147
148
|
const token = extractBearer(req.headers['authorization'])
|
|
148
149
|
if (!token) { deny(res, 401, 'unauthorized'); return }
|
|
149
150
|
const payload = verifyToken(deps.secret, token)
|
|
@@ -431,7 +432,10 @@ async function runUploadAndCommit(
|
|
|
431
432
|
type GetOpened =
|
|
432
433
|
| { reason: 'ok'; reader: LiveReader }
|
|
433
434
|
| { reason: 'not-found' }
|
|
434
|
-
|
|
435
|
+
// `detail` is a short non-sensitive cause tag (backend sub-reason +
|
|
436
|
+
// content-hash prefix) the GET handler logs so a 503 is diagnosable —
|
|
437
|
+
// every byte-side failure collapses to the same wire 503 otherwise.
|
|
438
|
+
| { reason: 'unavailable'; detail: string }
|
|
435
439
|
|
|
436
440
|
async function openLiveSnapshot(
|
|
437
441
|
deps: ObjstoreRestDeps, route: RouteMatch, payload: { ver: number; inc: string },
|
|
@@ -448,17 +452,27 @@ async function openLiveSnapshot(
|
|
|
448
452
|
// returns a pinned fd; for the Vercel backend a fetch-backed stream.)
|
|
449
453
|
const live = await deps.handle.selectLiveOne.get(route.tag, route.resourceTag)
|
|
450
454
|
if (!live || live.version !== payload.ver || live.incarnation !== payload.inc) return { reason: 'not-found' }
|
|
455
|
+
// Tag the content hash into every `unavailable` detail so an operator
|
|
456
|
+
// can go check the byte store directly for THIS blob (gone → reaper /
|
|
457
|
+
// deletion; present → transient read fault).
|
|
458
|
+
const hashTag = `hash=${debugId(live.content_hash)}`
|
|
451
459
|
let opened
|
|
452
460
|
try { opened = await deps.handle.blob.openLiveReader(route.tag, live.content_hash) }
|
|
453
|
-
|
|
454
|
-
|
|
461
|
+
// Length-cap the thrown error text: today a non-BlobNotFound SDK throw
|
|
462
|
+
// (BlobServiceNotAvailable / store-not-found) carries no credential
|
|
463
|
+
// (the RW token rides the Authorization header, never `.message`), but
|
|
464
|
+
// a future SDK could embed a signed URL / token fragment — bound the
|
|
465
|
+
// log line so it can't dump one verbatim. Mirrors the `.slice(0, 200)`
|
|
466
|
+
// cap used on SDK error text in blob-vercel.ts.
|
|
467
|
+
catch (err) { return { reason: 'unavailable', detail: `open-threw ${hashTag} ${String(errMsg(err)).slice(0, 200)}` } }
|
|
468
|
+
if (!opened.ok) return { reason: 'unavailable', detail: `${opened.detail ?? 'backend'} ${hashTag}` }
|
|
455
469
|
// Size mismatch between the live row and the on-storage bytes
|
|
456
470
|
// is a transient inconsistency — reaper will reconcile. Close
|
|
457
471
|
// the reader before returning so we don't leak the fd / fetch
|
|
458
472
|
// reader. PR #4 review H8.
|
|
459
473
|
if (opened.reader.size !== live.content_length) {
|
|
460
474
|
await opened.reader.close().catch(() => {})
|
|
461
|
-
return { reason: 'unavailable' }
|
|
475
|
+
return { reason: 'unavailable', detail: `size-mismatch row=${live.content_length} blob=${opened.reader.size} ${hashTag}` }
|
|
462
476
|
}
|
|
463
477
|
return { reason: 'ok', reader: opened.reader }
|
|
464
478
|
}
|
|
@@ -478,8 +492,19 @@ async function handleRestGet(
|
|
|
478
492
|
// If the live row is there but the bytes are missing / wrong size,
|
|
479
493
|
// it's a transient inconsistency the reaper will sort out — 503
|
|
480
494
|
// (vs 404) tells the client this is a server-side state, not a
|
|
481
|
-
// "the resource truly isn't there" answer.
|
|
482
|
-
|
|
495
|
+
// "the resource truly isn't there" answer. Log the cause
|
|
496
|
+
// UNCONDITIONALLY (not behind `debug`): a 503 means a live row whose
|
|
497
|
+
// bytes can't be served, and the wire response can't distinguish a
|
|
498
|
+
// permanent loss (reaper GC'd referenced bytes) from a transient read
|
|
499
|
+
// fault. `detail` carries the backend sub-reason + content-hash prefix
|
|
500
|
+
// so an operator can tell which — the only server-side breadcrumb for
|
|
501
|
+
// the "all data turned into 503" failure. (Volume is bounded: a 503 is
|
|
502
|
+
// an error path; a workspace-wide outage is exactly when these are
|
|
503
|
+
// wanted.)
|
|
504
|
+
if (opened.reason === 'unavailable') {
|
|
505
|
+
console.warn(`objstore-get: 503 unavailable ${debugTag(route.tag)}/${route.resourceTag.slice(0, 8)}… v${payload.ver} ${opened.detail}`)
|
|
506
|
+
deny(res, 503, 'unavailable'); return
|
|
507
|
+
}
|
|
483
508
|
res.writeHead(200, {
|
|
484
509
|
'content-type': 'application/octet-stream',
|
|
485
510
|
'content-length': String(opened.reader.size),
|
|
@@ -10,6 +10,18 @@ import { isValidIncarnation } from './store.ts'
|
|
|
10
10
|
const OBJSTORE_PUT_DOMAIN = 'deepview-objstore.v1.put'
|
|
11
11
|
const OBJSTORE_DELETE_DOMAIN = 'deepview-objstore.v1.delete'
|
|
12
12
|
const OBJSTORE_FETCH_DOMAIN = 'deepview-objstore.v1.fetch'
|
|
13
|
+
// REST fetch-mint domain — MUST match the client's FETCH_REST_DOMAIN
|
|
14
|
+
// (client/sync/objstore-crypto.ts). Distinct from OBJSTORE_FETCH_DOMAIN so
|
|
15
|
+
// a WS-fetch signature can't be replayed against the REST mint endpoint.
|
|
16
|
+
const OBJSTORE_FETCH_REST_DOMAIN = 'deepview-objstore.v1.fetch-rest'
|
|
17
|
+
// REST put-begin domain — MUST match the client's PUT_REST_DOMAIN. Distinct
|
|
18
|
+
// from OBJSTORE_PUT_DOMAIN so a WS put-begin signature can't be replayed
|
|
19
|
+
// against the REST mint endpoint.
|
|
20
|
+
const OBJSTORE_PUT_REST_DOMAIN = 'deepview-objstore.v1.put-rest'
|
|
21
|
+
// REST delete domain — MUST match the client's DELETE_REST_DOMAIN. Distinct
|
|
22
|
+
// from OBJSTORE_DELETE_DOMAIN so a WS delete signature can't be replayed
|
|
23
|
+
// against the REST mint endpoint.
|
|
24
|
+
const OBJSTORE_DELETE_REST_DOMAIN = 'deepview-objstore.v1.delete-rest'
|
|
13
25
|
|
|
14
26
|
// Wire shapes the verifiers accept. Fields land here post-
|
|
15
27
|
// `JSON.parse`, so every value starts life as `unknown` — strict
|
|
@@ -95,6 +107,38 @@ function canonicalObjstoreFetch(msg: ObjstoreFetchMsg, connectionNonce: string):
|
|
|
95
107
|
].join('\n'))
|
|
96
108
|
}
|
|
97
109
|
|
|
110
|
+
// REST fetch-mint canonical. Binds a client epoch-ms timestamp (string-
|
|
111
|
+
// encoded to match the client) in place of the connection nonce; the REST
|
|
112
|
+
// handler enforces the freshness window + replay dedup.
|
|
113
|
+
function canonicalObjstoreFetchRest(workspaceTag: string, resourceTag: string, ts: number): Uint8Array<ArrayBuffer> {
|
|
114
|
+
return encodeUtf8([
|
|
115
|
+
OBJSTORE_FETCH_REST_DOMAIN,
|
|
116
|
+
workspaceTag,
|
|
117
|
+
resourceTag,
|
|
118
|
+
String(ts),
|
|
119
|
+
].join('\n'))
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// REST put-begin canonical — the WS `canonicalObjstorePut` fields in the
|
|
123
|
+
// same order/coercion (intOrEmpty / strOrEmpty), under the put-rest domain
|
|
124
|
+
// and binding the client `ts` in place of the connection nonce.
|
|
125
|
+
function canonicalObjstorePutRest(
|
|
126
|
+
workspaceTag: string, resourceTag: string,
|
|
127
|
+
prevVersion: number | null, prevIncarnation: string | null,
|
|
128
|
+
contentHash: string, expectedLength: number, ts: number,
|
|
129
|
+
): Uint8Array<ArrayBuffer> {
|
|
130
|
+
return encodeUtf8([
|
|
131
|
+
OBJSTORE_PUT_REST_DOMAIN,
|
|
132
|
+
workspaceTag,
|
|
133
|
+
resourceTag,
|
|
134
|
+
intOrEmpty(prevVersion),
|
|
135
|
+
strOrEmpty(prevIncarnation),
|
|
136
|
+
contentHash,
|
|
137
|
+
String(expectedLength),
|
|
138
|
+
String(ts),
|
|
139
|
+
].join('\n'))
|
|
140
|
+
}
|
|
141
|
+
|
|
98
142
|
// `Number.isSafeInteger` rather than `Number.isInteger`: JSON numbers
|
|
99
143
|
// are IEEE-754 and integers above 2^53-1 aren't precisely
|
|
100
144
|
// representable. Accepting non-safe integers would let a signed
|
|
@@ -162,3 +206,64 @@ export function verifyObjstoreFetchSig(msg: ObjstoreFetchMsg, connectionNonce: u
|
|
|
162
206
|
if (typeof msg.resourceTag !== 'string') return Promise.resolve(false)
|
|
163
207
|
return verifyObjstoreSig(msg, connectionNonce, (nonce) => canonicalObjstoreFetch(msg, nonce))
|
|
164
208
|
}
|
|
209
|
+
|
|
210
|
+
// Verify a REST fetch-mint signature. Unlike the WS verifiers this takes
|
|
211
|
+
// the already-validated fields directly (the REST handler parsed +
|
|
212
|
+
// range-checked `ts` and `signature`) rather than a wire `msg` + socket
|
|
213
|
+
// nonce. The workspaceTag IS the Ed25519 public key, so verification is
|
|
214
|
+
// fully self-contained — no session or stored key needed. A throw from
|
|
215
|
+
// the canonical build (lone surrogate, etc.) is treated as a verify
|
|
216
|
+
// failure, never an escaping exception.
|
|
217
|
+
export function verifyObjstoreFetchRestSig(
|
|
218
|
+
workspaceTag: string, resourceTag: string, ts: number, signature: string,
|
|
219
|
+
): Promise<boolean> {
|
|
220
|
+
let payload: Uint8Array<ArrayBuffer>
|
|
221
|
+
try { payload = canonicalObjstoreFetchRest(workspaceTag, resourceTag, ts) }
|
|
222
|
+
catch { return Promise.resolve(false) }
|
|
223
|
+
return verifyEd25519(workspaceTag, payload, signature)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Verify a REST put-begin signature. Same self-contained shape as
|
|
227
|
+
// `verifyObjstoreFetchRestSig` — the caller (rest.ts) has already
|
|
228
|
+
// range-checked the put fields + `ts`. The workspaceTag IS the pubkey.
|
|
229
|
+
export function verifyObjstorePutBeginRestSig(
|
|
230
|
+
fields: { workspaceTag: string; resourceTag: string; prevVersion: number | null; prevIncarnation: string | null; contentHash: string; expectedLength: number },
|
|
231
|
+
ts: number, signature: string,
|
|
232
|
+
): Promise<boolean> {
|
|
233
|
+
let payload: Uint8Array<ArrayBuffer>
|
|
234
|
+
try {
|
|
235
|
+
payload = canonicalObjstorePutRest(
|
|
236
|
+
fields.workspaceTag, fields.resourceTag, fields.prevVersion, fields.prevIncarnation,
|
|
237
|
+
fields.contentHash, fields.expectedLength, ts,
|
|
238
|
+
)
|
|
239
|
+
} catch { return Promise.resolve(false) }
|
|
240
|
+
return verifyEd25519(fields.workspaceTag, payload, signature)
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// REST delete-mint canonical. WS `canonicalObjstoreDelete` fields, under the
|
|
244
|
+
// delete-rest domain, binding the client `ts` in place of the nonce.
|
|
245
|
+
function canonicalObjstoreDeleteRest(
|
|
246
|
+
workspaceTag: string, resourceTag: string,
|
|
247
|
+
prevVersion: number | null, prevIncarnation: string | null, ts: number,
|
|
248
|
+
): Uint8Array<ArrayBuffer> {
|
|
249
|
+
return encodeUtf8([
|
|
250
|
+
OBJSTORE_DELETE_REST_DOMAIN,
|
|
251
|
+
workspaceTag,
|
|
252
|
+
resourceTag,
|
|
253
|
+
intOrEmpty(prevVersion),
|
|
254
|
+
strOrEmpty(prevIncarnation),
|
|
255
|
+
String(ts),
|
|
256
|
+
].join('\n'))
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Verify a REST delete-mint signature. Self-contained (workspaceTag IS the
|
|
260
|
+
// pubkey); the caller (rest.ts) has range-checked `ts` + the prev pair.
|
|
261
|
+
export function verifyObjstoreDeleteRestSig(
|
|
262
|
+
fields: { workspaceTag: string; resourceTag: string; prevVersion: number | null; prevIncarnation: string | null },
|
|
263
|
+
ts: number, signature: string,
|
|
264
|
+
): Promise<boolean> {
|
|
265
|
+
let payload: Uint8Array<ArrayBuffer>
|
|
266
|
+
try { payload = canonicalObjstoreDeleteRest(fields.workspaceTag, fields.resourceTag, fields.prevVersion, fields.prevIncarnation, ts) }
|
|
267
|
+
catch { return Promise.resolve(false) }
|
|
268
|
+
return verifyEd25519(fields.workspaceTag, payload, signature)
|
|
269
|
+
}
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// dialect on the wire.
|
|
5
5
|
//
|
|
6
6
|
// `@neondatabase/serverless` is an OPTIONAL peer dep — selected by
|
|
7
|
-
// the
|
|
7
|
+
// the configured database URL branch in `server-e2e/index.ts`. The peer dep
|
|
8
8
|
// itself is loaded lazily inside `openNeonObjstore` below. A single
|
|
9
9
|
// Neon project / database holds both `workspace_revision` and the
|
|
10
10
|
// two objstore tables; each plane opens its own stateless
|
|
@@ -13,14 +13,10 @@
|
|
|
13
13
|
// release.
|
|
14
14
|
//
|
|
15
15
|
// Byte plane: passed in as a `BlobBackend` (./blob.ts). The
|
|
16
|
-
// supported
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
// alongside DATABASE_URL.
|
|
21
|
-
// - Neon + local FS (development / operator escape hatch — bytes
|
|
22
|
-
// still need to live on a shared filesystem if you run more
|
|
23
|
-
// than one replica).
|
|
16
|
+
// supported Neon pairing selected by server-e2e/index.ts is Vercel
|
|
17
|
+
// Blob Private Storage. DATABASE_URL or E2E_DATABASE_URL plus BLOB_READ_WRITE_TOKEN
|
|
18
|
+
// is required; the launcher never selects Neon + local FS. Tests
|
|
19
|
+
// can inject a local-FS backend directly through this adapter.
|
|
24
20
|
|
|
25
21
|
import type { BlobBackend } from './blob.ts'
|
|
26
22
|
import type { AllStmt, GetStmt, RunStmt } from '../db-stmt.ts'
|
|
@@ -309,7 +305,7 @@ export async function openNeonObjstore(connectionString: string, blob: BlobBacke
|
|
|
309
305
|
// local `../neon-driver.ts` re-export wrapper so the peer dep stays
|
|
310
306
|
// optional AND tests can mock the driver (a local path is always
|
|
311
307
|
// resolvable; the bare specifier isn't when the dep is absent). See
|
|
312
|
-
// `server/neon-driver.ts`. Cast through `unknown` because the
|
|
308
|
+
// `server-e2e/neon-driver.ts`. Cast through `unknown` because the
|
|
313
309
|
// wrapper's `export *` re-exports a `@ts-ignore`'d module.
|
|
314
310
|
const mod = (await import('../neon-driver.ts')) as unknown as { neon: (url: string) => NeonSql }
|
|
315
311
|
const sql: NeonSql = mod.neon(connectionString)
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// SQLite + filesystem-backed object store for the v1.objstore
|
|
2
|
-
// protocol extension. Sibling of `server/db.ts`; shares the
|
|
2
|
+
// protocol extension. Sibling of `server-e2e/db.ts`; shares the
|
|
3
3
|
// underlying DatabaseSync handle but keeps its own tables.
|
|
4
4
|
//
|
|
5
5
|
// workspace_object — one row per LIVE resource (no
|
|
@@ -150,7 +150,7 @@ export type DeleteResult =
|
|
|
150
150
|
| { ok: true; deletedVersion: number }
|
|
151
151
|
| { ok: false; reason: 'not-found' | 'conflict'; conflict?: ObjectRow }
|
|
152
152
|
|
|
153
|
-
// Async statement shapes are shared with server/db.ts via
|
|
153
|
+
// Async statement shapes are shared with server-e2e/db.ts via
|
|
154
154
|
// ../db-stmt.ts — same `.get/.all/.run` → Promise contract across
|
|
155
155
|
// both planes.
|
|
156
156
|
|
|
@@ -186,7 +186,7 @@ export type Handle = {
|
|
|
186
186
|
db?: DatabaseSync
|
|
187
187
|
// Byte-plane backend (local FS or Vercel Blob). All bytes-side
|
|
188
188
|
// operations go through this — there is no direct fs.* call in
|
|
189
|
-
// store / rest / reaper. Selected at boot in server/index.ts.
|
|
189
|
+
// store / rest / reaper. Selected at boot in server-e2e/index.ts.
|
|
190
190
|
blob: BlobBackend
|
|
191
191
|
// Storage root for the FS backend — set only when `blob` was
|
|
192
192
|
// constructed from `openFsBlobBackend(dir)`. Production code
|
|
@@ -247,7 +247,7 @@ export type Handle = {
|
|
|
247
247
|
// returned. The reaper passes `Date.now() - stagingTtlMs` so the
|
|
248
248
|
// index `workspace_object_staging_begun_at_idx` is used and the
|
|
249
249
|
// sweep is O(stale-rows) instead of O(in-flight-uploads-cluster-
|
|
250
|
-
// wide). DB-layout audit `server/objstore/store.ts:312`.
|
|
250
|
+
// wide). DB-layout audit `server-e2e/objstore/store.ts:312`.
|
|
251
251
|
listAllStaging: AllStmt<[number], { workspace_tag: string; resource_tag: string; staging_id: string; begun_at: number }>
|
|
252
252
|
listLiveTags: AllStmt<[], { workspace_tag: string }>
|
|
253
253
|
countLive: GetStmt<[string], { c: number }>
|
|
@@ -255,7 +255,7 @@ export type Handle = {
|
|
|
255
255
|
|
|
256
256
|
// Narrowing alias for the SQLite-backed Handle: `db` is guaranteed
|
|
257
257
|
// to be set. `openObjstore` returns this so call sites (production
|
|
258
|
-
// shutdown plumbing in `server/index.ts` + the entire SQLite-only
|
|
258
|
+
// shutdown plumbing in `server-e2e/index.ts` + the entire SQLite-only
|
|
259
259
|
// test suite in `tests/server-objstore.test.js`) can reach
|
|
260
260
|
// `handle.db.prepare(...)` without an optional-chain or non-null
|
|
261
261
|
// assertion. A Neon-backed Handle (`openNeonObjstore`) keeps the
|
|
@@ -352,7 +352,7 @@ export function openObjstore(db: DatabaseSync, dir: string): SqliteHandle {
|
|
|
352
352
|
mkdirSync(dir, { recursive: true })
|
|
353
353
|
db.exec(SCHEMA)
|
|
354
354
|
// Fail-loud on a pre-existing non-STRICT table — same rationale as
|
|
355
|
-
// server/db.ts: `CREATE TABLE IF NOT EXISTS … STRICT` doesn't
|
|
355
|
+
// server-e2e/db.ts: `CREATE TABLE IF NOT EXISTS … STRICT` doesn't
|
|
356
356
|
// upgrade an existing non-STRICT table, and dropping strict type
|
|
357
357
|
// affinity opens an operator-attack path. PR #4 review F3.
|
|
358
358
|
for (const name of ['workspace_object', 'workspace_object_staging']) {
|
|
@@ -362,7 +362,7 @@ export function openObjstore(db: DatabaseSync, dir: string): SqliteHandle {
|
|
|
362
362
|
const blob = openFsBlobBackend(dir)
|
|
363
363
|
// No `close` method on the returned Handle: the underlying
|
|
364
364
|
// `DatabaseSync` is owned by the caller (in production, the
|
|
365
|
-
// workspace_revision handle in `server/db.ts`, closed from
|
|
365
|
+
// workspace_revision handle in `server-e2e/db.ts`, closed from
|
|
366
366
|
// `shutdown()`; tests close their own DB). A `close()` here would
|
|
367
367
|
// mislead — it could only no-op or leak, never close the caller-
|
|
368
368
|
// owned connection.
|
|
@@ -119,7 +119,7 @@ function isValidPayload(v: unknown): v is TokenPayload {
|
|
|
119
119
|
// different actual value, or — for `exp` — claim a
|
|
120
120
|
// `Number.MAX_SAFE_INTEGER + 1` expiry that compares ambiguously near
|
|
121
121
|
// the IEEE-754 boundary. Matches the codebase's other safe-int gates
|
|
122
|
-
// (server/objstore/sign.ts, rest.ts, handlers.ts).
|
|
122
|
+
// (server-e2e/objstore/sign.ts, rest.ts, handlers.ts).
|
|
123
123
|
if (!Number.isSafeInteger(o['exp']) || (o['exp'] as number) < 0) return false
|
|
124
124
|
if (o['op'] === 'put') {
|
|
125
125
|
return typeof o['sid'] === 'string'
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Cross-instance pub/sub for real-time WS broadcasts. The triage-sync
|
|
2
|
-
// fan-out is an in-memory subscriber map (server/hub.ts) by design — it
|
|
2
|
+
// fan-out is an in-memory subscriber map (server-e2e/hub.ts) by design — it
|
|
3
3
|
// routes a commit only to peers on the SAME instance. A multi-instance
|
|
4
4
|
// deployment behind a load balancer needs commit-landed-on-A to reach
|
|
5
5
|
// peers-on-B with the same latency the hub gives same-instance peers.
|
|
@@ -57,15 +57,15 @@ function newSenderId(): string {
|
|
|
57
57
|
// - 'objput': objstore-put broadcast. `res` is the resource tag; the
|
|
58
58
|
// receiver SELECTs the live row by (tag, res) for the rest of the
|
|
59
59
|
// metadata fields.
|
|
60
|
-
// - 'objdel': objstore-deleted broadcast. `ver`
|
|
61
|
-
//
|
|
62
|
-
//
|
|
60
|
+
// - 'objdel': objstore-deleted broadcast. `ver` and `incarnation` identify
|
|
61
|
+
// the deleted row, which is gone from workspace_object after commit.
|
|
62
|
+
// Incarnation is optional only for messages from older relay instances.
|
|
63
63
|
export type BusMessage =
|
|
64
64
|
| { kind: 'rev'; tag: string; id: string }
|
|
65
65
|
| { kind: 'objput'; tag: string; res: string }
|
|
66
|
-
| { kind: 'objdel'; tag: string; res: string; ver: number }
|
|
66
|
+
| { kind: 'objdel'; tag: string; res: string; ver: number; incarnation?: string }
|
|
67
67
|
|
|
68
|
-
// Receiver wired up by the hub layer (see server/index.ts). Each handler
|
|
68
|
+
// Receiver wired up by the hub layer (see server-e2e/index.ts). Each handler
|
|
69
69
|
// runs once per remote message; failures are logged but don't crash the
|
|
70
70
|
// LISTEN loop — a missed broadcast surfaces to clients on reconnect
|
|
71
71
|
// (chain re-pull). Async because the workspace-revision handler does a
|
|
@@ -164,7 +164,7 @@ type NeonState = {
|
|
|
164
164
|
// In-flight bus-message handler promises. `dispatchNotification`
|
|
165
165
|
// fires handlers fire-and-forget, but `stop()` awaits this set before
|
|
166
166
|
// returning so the lifecycle's `handle.close()` (which runs after
|
|
167
|
-
// `pubsub.stop()` — see closeDb in server/index.ts) can't race a
|
|
167
|
+
// `pubsub.stop()` — see closeDb in server-e2e/index.ts) can't race a
|
|
168
168
|
// handler mid-`handle.revisionById.get` / `getLive`.
|
|
169
169
|
pendingHandlers: Set<Promise<void>>
|
|
170
170
|
}
|
|
@@ -333,7 +333,7 @@ export function createNeonPubSub(deps: NeonPubSubDeps): PubSub {
|
|
|
333
333
|
if (state.connectAttempt) { try { await state.connectAttempt } catch {} }
|
|
334
334
|
// Drain in-flight bus-message handlers BEFORE returning. The
|
|
335
335
|
// lifecycle teardown runs `pubsub.stop()` and THEN
|
|
336
|
-
// `handle.close()` (see closeDb in server/index.ts); a handler
|
|
336
|
+
// `handle.close()` (see closeDb in server-e2e/index.ts); a handler
|
|
337
337
|
// still in `handle.revisionById.get` / `getLive` would otherwise
|
|
338
338
|
// throw against a closed DB. `allSettled` so one handler's
|
|
339
339
|
// rejection doesn't abort the drain.
|
|
@@ -388,7 +388,10 @@ function parseBusMessage(raw: Record<string, unknown>): BusMessage | null {
|
|
|
388
388
|
const ver = raw['ver']
|
|
389
389
|
if (typeof res !== 'string') return null
|
|
390
390
|
if (typeof ver !== 'number' || !Number.isSafeInteger(ver)) return null
|
|
391
|
-
|
|
391
|
+
const incarnation = raw['incarnation']
|
|
392
|
+
if (incarnation === undefined) return { kind: 'objdel', tag, res, ver }
|
|
393
|
+
if (typeof incarnation !== 'string' || incarnation.length === 0) return null
|
|
394
|
+
return { kind: 'objdel', tag, res, ver, incarnation }
|
|
392
395
|
}
|
|
393
396
|
return null
|
|
394
397
|
}
|
|
@@ -92,7 +92,7 @@ export function canonicalSave(
|
|
|
92
92
|
// confusing diff in the canonical bytes rather than a clean drop.
|
|
93
93
|
// Mirrors the `isSafeNonNegativeInt` rigor that the objstore
|
|
94
94
|
// canonical builders apply. Input-validation audit
|
|
95
|
-
// `server/sign.ts:88`.
|
|
95
|
+
// `server-e2e/sign.ts:88`.
|
|
96
96
|
if (typeof workspaceTag !== 'string') throw new TypeError('canonicalSave: workspaceTag must be string')
|
|
97
97
|
if (typeof nonce !== 'string') throw new TypeError('canonicalSave: nonce must be string')
|
|
98
98
|
if (typeof ciphertext !== 'string') throw new TypeError('canonicalSave: ciphertext must be string')
|
|
@@ -122,7 +122,7 @@ function canonicalSubscribe(
|
|
|
122
122
|
return encodeUtf8([SUBSCRIBE_DOMAIN, workspaceTag as string, fromStr, connectionNonce].join('\n'))
|
|
123
123
|
}
|
|
124
124
|
|
|
125
|
-
// Exported because the v1.objstore signing module (server/objstore/sign.ts)
|
|
125
|
+
// Exported because the v1.objstore signing module (server-e2e/objstore/sign.ts)
|
|
126
126
|
// reuses it for its four verifiers — same workspaceTag-as-pubkey contract,
|
|
127
127
|
// same domain-separated canonical bytes. Keeping the WebCrypto plumbing
|
|
128
128
|
// in one place avoids drift between the triage-sync and objstore verify
|
|
@@ -157,7 +157,7 @@ export async function verifyEd25519(
|
|
|
157
157
|
// `{ ok: false, canonical: null }` on bad shape / bad sig;
|
|
158
158
|
// `{ ok: true, canonical: <bytes> }` on success.
|
|
159
159
|
//
|
|
160
|
-
// NOT used by `server/index.ts handleSave`. Production composes
|
|
160
|
+
// NOT used by `server-e2e/index.ts handleSave`. Production composes
|
|
161
161
|
// the smaller helpers (`canonicalSave`, `computeRevisionIdFromCanonical`,
|
|
162
162
|
// `verifyEd25519`) separately so the dup-precheck via
|
|
163
163
|
// `revisionExists` can fire BEFORE Ed25519-verify and skip the
|
|
@@ -204,7 +204,7 @@ export async function computeRevisionIdFromCanonical(canonical: Uint8Array<Array
|
|
|
204
204
|
|
|
205
205
|
// `connectionNonce` is the per-socket challenge the server issued
|
|
206
206
|
// to the originating connection (see `Peer.challenge` in
|
|
207
|
-
// server/peer.ts). The client signs a canonical that includes the
|
|
207
|
+
// server-e2e/peer.ts). The client signs a canonical that includes the
|
|
208
208
|
// nonce; verifying against the SAME nonce here is what blocks
|
|
209
209
|
// cross-connection replay of a captured subscribe frame. Audit
|
|
210
210
|
// round-9 H2.
|