@preventive/triage 1.0.0-alpha.5 → 1.0.0-alpha.50
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/README.md +16 -0
- package/api/reap.ts +17 -0
- package/cli.js +6 -0
- package/client/dependency-paths.d.ts +3 -0
- package/client/dependency-paths.js +36 -0
- package/client/finding-link.js +305 -0
- package/client/linked-findings.d.ts +1 -0
- package/client/linked-findings.js +115 -0
- package/common/bundle-commit.d.ts +1 -0
- package/common/bundle-commit.js +4 -0
- package/common/bundle-compare-diff.d.ts +3 -0
- package/common/bundle-compare-diff.js +357 -0
- package/common/bundle-compare-inputs.d.ts +2 -0
- package/common/bundle-compare-inputs.js +65 -0
- package/common/bundle-compare-versions.js +172 -0
- package/common/bundle-comparison.d.ts +8 -0
- package/common/bundle-comparison.js +62 -0
- package/common/bundle-metadata.d.ts +15 -0
- package/common/bundle-metadata.js +226 -0
- package/common/bundle-pkg-of.d.ts +1 -0
- package/common/bundle-pkg-of.js +90 -0
- package/common/bundle-reasons.d.ts +2 -0
- package/common/bundle-reasons.js +21 -0
- package/common/bundle-source-links.d.ts +6 -0
- package/common/bundle-source-links.js +216 -0
- package/common/bundle-sourcemap.d.ts +10 -0
- package/common/bundle-sourcemap.js +100 -0
- package/common/bundle-sources.d.ts +7 -0
- package/common/bundle-sources.js +333 -0
- package/common/bundle-stats.js +41 -0
- package/common/bundle-tabs.js +1 -0
- package/common/code-language.js +65 -0
- package/common/default-scan-models.ts +31 -0
- package/common/dependency-reviews.d.ts +18 -0
- package/common/dependency-reviews.js +64 -0
- package/common/finding-duplicates.js +58 -0
- package/common/finding-filters.js +274 -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/gzip.js +59 -0
- package/common/line-diff.d.ts +8 -0
- package/common/line-diff.js +57 -0
- package/common/linked-workspace-groups.js +63 -0
- package/common/managed/agent-tasks.ts +292 -0
- package/common/managed/comments.ts +34 -0
- package/common/managed/finding-detail.d.ts +2 -0
- package/common/managed/finding-detail.js +32 -0
- package/common/managed/github-agents.ts +114 -0
- package/common/managed/github-logins.ts +49 -0
- package/common/managed/npm-packages.d.ts +5 -0
- package/common/managed/npm-packages.js +34 -0
- package/common/managed/permissions.ts +31 -0
- package/common/managed/report-content.ts +72 -0
- package/common/managed/report-filter.ts +109 -0
- package/common/managed/repository-alias.ts +65 -0
- package/common/managed/roles.ts +35 -0
- package/common/managed/routes.d.ts +2 -0
- package/common/managed/routes.js +347 -0
- package/common/managed/scan-models.ts +6 -0
- package/common/managed/team-app.d.ts +5 -0
- package/common/managed/team-app.js +31 -0
- package/common/managed/triage.ts +83 -0
- package/common/managed/upload-seal.ts +126 -0
- package/common/pretty-print.d.ts +5 -0
- package/common/pretty-print.js +24 -0
- package/common/revalidation-conflicts.js +48 -0
- package/common/revalidation-input-groups.js +56 -0
- package/common/save-error-reason.ts +20 -7
- package/common/scan-server.ts +13 -0
- package/common/security-links.d.ts +5 -0
- package/common/security-links.js +33 -0
- package/common/server-info.ts +46 -0
- package/common/utf8.d.ts +3 -0
- package/common/utf8.js +45 -0
- package/common/workspace-app.js +39 -0
- package/common/workspace-groups.js +176 -0
- package/out/activity.js +4 -0
- package/out/brotli-fallback.js +3 -3
- package/out/chunk-2WDZPXVL.js +3 -0
- package/out/chunk-2XUEZHIP.js +28 -0
- package/out/chunk-2ZDZWLFY.js +1 -0
- package/out/chunk-4HI2MSAV.js +17 -0
- package/out/chunk-4XMP7YVH.js +36 -0
- package/out/chunk-6LNQTKAV.js +52 -0
- package/out/chunk-6S2U2UYG.js +232 -0
- package/out/chunk-75YLHBCM.js +3 -0
- package/out/chunk-7BKKYVWR.js +7 -0
- package/out/chunk-7C7DWL7I.js +1 -0
- package/out/chunk-7VAWINQF.js +6 -0
- package/out/chunk-C4Z6ZBXV.js +10 -0
- package/out/chunk-FC65TPUB.js +1 -0
- package/out/chunk-FKBXNUW4.js +1 -0
- package/out/chunk-GDPCLSOS.js +2 -0
- package/out/chunk-GO7FT4OD.js +1 -0
- package/out/chunk-HTBYHSPG.js +1 -0
- package/out/chunk-IYPY47MC.js +1 -0
- package/out/chunk-IZ7POWI3.js +18 -0
- package/out/chunk-JCIIBLKR.js +1 -0
- package/out/chunk-NRPC74AM.js +1 -0
- package/out/chunk-OOJM4CTU.js +1 -0
- package/out/chunk-PBZPYIG7.js +1 -0
- package/out/chunk-PNHBZGDD.js +1 -0
- package/out/chunk-PWFUOLZK.js +17 -0
- package/out/chunk-PXK7EU4V.js +1 -0
- package/out/chunk-QB4CBOX2.js +1 -0
- package/out/chunk-R3NZFXVR.js +1 -0
- package/out/chunk-R5O6FAW4.js +2 -0
- package/out/chunk-SXV2T5C4.js +80 -0
- package/out/chunk-TQJDYHVE.js +2 -0
- package/out/chunk-TUII34K2.js +1 -0
- package/out/chunk-UK7RGJT6.js +3 -0
- package/out/chunk-VFWMQ3LG.js +2 -0
- package/out/chunk-VH4XMZIW.js +2 -0
- package/out/chunk-WCPJDAYA.js +10 -0
- package/out/chunk-WFYLMF6Z.js +10 -0
- package/out/chunk-WYLQWYLA.js +1 -0
- package/out/client-managed-import.js +4 -0
- package/out/client-managed.js +33 -0
- package/out/client-sync.js +11 -17
- package/out/compare.js +35 -0
- package/out/dialogs.js +55 -0
- package/out/github-issue-authorized.html +3 -0
- package/out/github-issue-authorized.js +1 -0
- package/out/graph.js +17 -4
- package/out/index.html +90 -8
- package/out/prism.js +14 -10
- package/out/scan.js +2 -0
- package/out/stasis.svg +20 -0
- package/out/terminal.js +366 -39
- package/out/view.css +1 -1
- package/out/view.js +19 -144
- package/package.json +362 -59
- package/risk/actions/findings.js +161 -0
- package/risk/actions/resolve.js +219 -0
- package/risk/actions/scripts/commands.js +182 -0
- package/risk/actions/scripts/grade.js +113 -0
- package/risk/actions/scripts/packages.js +123 -0
- package/risk/actions/scripts/shell.js +169 -0
- package/risk/actions/uses.js +76 -0
- package/risk/actions/workflow.js +110 -0
- package/risk/files/characters.js +196 -0
- package/risk/files/hosts.js +205 -0
- package/risk/files/readability.js +177 -0
- package/risk/files/runs-code.js +615 -0
- package/risk/files/script.js +118 -0
- package/risk/files/stamps.js +47 -0
- package/risk/index.d.ts +254 -0
- package/risk/index.js +11 -0
- package/risk/npm/assess.js +285 -0
- package/risk/npm/combined.js +68 -0
- package/risk/npm/compare.js +257 -0
- package/risk/npm/dependencies.js +172 -0
- package/risk/npm/diff.js +79 -0
- package/risk/npm/downloads.js +84 -0
- package/risk/npm/files.js +445 -0
- package/risk/npm/held.js +63 -0
- package/risk/npm/install-scripts.js +34 -0
- package/risk/npm/malware.js +36 -0
- package/risk/npm/manifest.js +65 -0
- package/risk/npm/new-dependency.js +60 -0
- package/risk/npm/old-version.js +108 -0
- package/risk/npm/registry.js +388 -0
- package/risk/npm/release.js +85 -0
- package/risk/npm/score.js +59 -0
- package/risk/npm/specs.js +124 -0
- package/risk/npm/tarball.js +96 -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-crypto.ts +182 -0
- package/server-common/storage-log.ts +38 -0
- package/server-common/vercel-blob.ts +103 -0
- package/server-e2e/app.ts +487 -0
- package/{server → server-e2e}/bus-receiver.ts +2 -1
- package/{server → server-e2e}/cli.js +9 -4
- package/{server → server-e2e}/config.ts +50 -42
- 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 +44 -13
- 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 +12 -145
- package/{server → server-e2e}/objstore/blob-vercel.ts +10 -114
- package/{server → server-e2e}/objstore/blob.ts +1 -1
- 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 +19 -27
- package/{server → server-e2e}/objstore/reaper.ts +1 -1
- 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 +3 -3
- 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 +2 -2
- package/{server → server-e2e}/pubsub.ts +12 -9
- package/{server → server-e2e}/sign.ts +4 -4
- package/{server → server-e2e}/sse-server.ts +60 -33
- package/{server → server-e2e}/static.ts +31 -21
- package/{server → server-e2e}/sync-handlers.ts +64 -30
- package/{server → server-e2e}/ws-server.ts +20 -9
- package/server-managed/activity.ts +258 -0
- package/server-managed/agent-merges.ts +109 -0
- package/server-managed/app-agents.ts +21 -0
- package/server-managed/avatar-store.ts +13 -0
- package/server-managed/blob-store.ts +17 -0
- package/server-managed/brotli.ts +10 -0
- package/server-managed/bundle-advisories.ts +120 -0
- package/server-managed/bundle-advisory-inventory.ts +77 -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 +115 -0
- package/server-managed/bundle-build.ts +200 -0
- package/server-managed/bundle-cache.ts +211 -0
- package/server-managed/bundle-catalog.ts +29 -0
- package/server-managed/bundle-commits.ts +140 -0
- package/server-managed/bundle-store.ts +24 -0
- package/server-managed/bundle-summary-cache.ts +130 -0
- package/server-managed/bundle.ts +119 -0
- package/server-managed/cache-storage.ts +20 -0
- package/server-managed/cli.js +21 -0
- package/server-managed/combined.ts +47 -0
- package/server-managed/comments.ts +151 -0
- package/server-managed/commit-coauthors.ts +106 -0
- package/server-managed/commit-lockfiles.ts +73 -0
- package/server-managed/comparison.ts +41 -0
- package/server-managed/config.ts +164 -0
- package/server-managed/content-access.ts +15 -0
- package/server-managed/crypto.ts +25 -0
- package/server-managed/db-methods.ts +1923 -0
- package/server-managed/db-neon.ts +458 -0
- package/server-managed/db-schema.ts +238 -0
- package/server-managed/db-table-names.ts +22 -0
- package/server-managed/db.ts +142 -0
- package/server-managed/dependabot-access.ts +60 -0
- package/server-managed/dependabot-alerts.ts +185 -0
- package/server-managed/dependency-review-comments.ts +100 -0
- package/server-managed/dependency-reviews.ts +346 -0
- package/server-managed/github-accounts.ts +60 -0
- package/server-managed/github-actions.ts +71 -0
- package/server-managed/github-app.ts +415 -0
- package/server-managed/github-errors.ts +70 -0
- package/server-managed/github-graphql.ts +39 -0
- package/server-managed/github-issue-links.ts +140 -0
- package/server-managed/github-issue-oauth.ts +47 -0
- package/server-managed/github-issues.ts +152 -0
- package/server-managed/github-link-oauth.ts +69 -0
- package/server-managed/github-links.ts +204 -0
- package/server-managed/github-metadata.ts +213 -0
- package/server-managed/github-oauth.ts +238 -0
- package/server-managed/github-pulls.ts +227 -0
- package/server-managed/github-requests.ts +39 -0
- package/server-managed/github-users.ts +30 -0
- package/server-managed/http-response.ts +76 -0
- package/server-managed/http.ts +5329 -0
- package/server-managed/import-triage.ts +51 -0
- package/server-managed/index.ts +192 -0
- package/server-managed/kept-briefly.ts +37 -0
- package/server-managed/kept.ts +28 -0
- package/server-managed/link-reports.ts +162 -0
- package/server-managed/locked-packages.ts +129 -0
- package/server-managed/lockfile-scope.ts +49 -0
- package/server-managed/lru.ts +30 -0
- package/server-managed/managed-issues.ts +78 -0
- package/server-managed/management-catalog.ts +45 -0
- package/server-managed/management.ts +99 -0
- package/server-managed/npm-delta-worker.js +23 -0
- package/server-managed/npm-delta.ts +194 -0
- package/server-managed/npm-insights.ts +449 -0
- package/server-managed/npm-kept.ts +215 -0
- package/server-managed/npm-loads.ts +264 -0
- package/server-managed/npm-manifest.ts +115 -0
- package/server-managed/npm-packages.ts +454 -0
- package/server-managed/npm-provenance.ts +123 -0
- package/server-managed/npm-risk.ts +285 -0
- package/server-managed/object-storage-disk.ts +148 -0
- package/server-managed/object-storage-vercel.ts +67 -0
- package/server-managed/object-storage.ts +75 -0
- package/server-managed/package-entry-points.ts +51 -0
- package/server-managed/pretty-print.ts +434 -0
- package/server-managed/public-workspace.ts +238 -0
- package/server-managed/pull-comment-reviews.ts +161 -0
- package/server-managed/pull-dependencies.ts +348 -0
- package/server-managed/pull-dependency-reviews.ts +328 -0
- package/server-managed/pull-reviews.ts +205 -0
- package/server-managed/queue.ts +39 -0
- package/server-managed/release-commits.ts +84 -0
- package/server-managed/rename-diff-worker.js +26 -0
- package/server-managed/rename-diff.ts +279 -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 +178 -0
- package/server-managed/report-store.ts +85 -0
- package/server-managed/repository-actions.ts +218 -0
- package/server-managed/repository-agents.ts +355 -0
- package/server-managed/repository-aliases.ts +91 -0
- package/server-managed/repository-browser.ts +294 -0
- package/server-managed/repository-changes.ts +284 -0
- package/server-managed/repository-code.ts +215 -0
- package/server-managed/repository-dependency-reviews.ts +165 -0
- package/server-managed/repository-discovery.ts +82 -0
- package/server-managed/repository-history.ts +432 -0
- package/server-managed/repository-issues.ts +195 -0
- package/server-managed/repository-policy.ts +27 -0
- package/server-managed/repository-pulls.ts +156 -0
- package/server-managed/repository-pulse.ts +460 -0
- package/server-managed/repository-risk-assess.ts +105 -0
- package/server-managed/repository-risk-audit.ts +79 -0
- package/server-managed/repository-risk-read.ts +170 -0
- package/server-managed/repository-risk.ts +491 -0
- package/server-managed/repository-squashes.ts +168 -0
- package/server-managed/request-timing.ts +63 -0
- package/server-managed/reviewed-dependencies.ts +150 -0
- package/server-managed/revisions.ts +35 -0
- package/server-managed/scan-downloads.ts +87 -0
- package/server-managed/session.ts +132 -0
- package/server-managed/slugs.ts +38 -0
- package/server-managed/solidity-entry-points.ts +71 -0
- package/server-managed/sql-postgres.ts +34 -0
- package/server-managed/sql.ts +88 -0
- package/server-managed/static.ts +28 -0
- package/server-managed/storage-command.ts +16 -0
- package/server-managed/storage-db.ts +183 -0
- package/server-managed/storage-encryption.ts +290 -0
- package/server-managed/storage-maintenance.ts +259 -0
- package/server-managed/storage-payload.ts +121 -0
- package/server-managed/storage-stores.ts +91 -0
- package/server-managed/storage.ts +82 -0
- package/server-managed/team-app.ts +134 -0
- package/server-managed/team-catalog.ts +7 -0
- package/server-managed/team-feed.ts +180 -0
- package/server-managed/team-npm-scopes.ts +106 -0
- package/server-managed/team-reports.ts +307 -0
- package/server-managed/triage-response.ts +16 -0
- package/server-managed/type-only-worker.js +19 -0
- package/server-managed/type-only.ts +105 -0
- package/server-managed/uploads.ts +106 -0
- package/server-managed/upstream-cache.ts +243 -0
- package/server-managed/view-as.ts +58 -0
- package/server-managed/workspace-shares.ts +169 -0
- package/server.ts +70 -0
- package/server/index.ts +0 -480
- package/server/lifecycle.ts +0 -208
- /package/{LICENSE → LICENSE-MIT} +0 -0
- /package/{server → server-e2e}/auth.ts +0 -0
- /package/{server → server-e2e}/config.example.json +0 -0
- /package/{server → server-e2e}/objstore/blob-fs.ts +0 -0
- /package/{server → server-e2e}/objstore/fs.ts +0 -0
- /package/{server → server-e2e}/objstore/sign.ts +0 -0
- /package/{server → server-e2e}/peer.ts +0 -0
- /package/{server → server-e2e}/sse-session.ts +0 -0
- /package/{server → server-e2e}/util.ts +0 -0
- /package/{server → server-e2e}/validation.ts +0 -0
|
@@ -15,12 +15,12 @@ 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
|
-
//
|
|
21
|
+
// Automatic-sweep switch (OBJSTORE_REAP_DISABLED). When true, NEITHER the boot
|
|
22
22
|
// sweep nor the periodic timer runs: `startupReap` resolves immediately
|
|
23
|
-
// and
|
|
23
|
+
// and no timer is installed. Orphaned/superseded blobs and stale
|
|
24
24
|
// staging rows then accumulate unbounded — only safe if an external job
|
|
25
25
|
// handles GC. Omitted/false → reaper runs (the default). See config.ts.
|
|
26
26
|
reapDisabled?: boolean
|
|
@@ -28,9 +28,9 @@ export type ObjstoreInitDeps = {
|
|
|
28
28
|
broadcast: (tag: string, msg: object, except: WebSocket | null) => void
|
|
29
29
|
// Cross-instance pub/sub publishers. SQLite mode passes no-ops; Neon
|
|
30
30
|
// mode passes Postgres LISTEN/NOTIFY-backed implementations. See
|
|
31
|
-
// server/pubsub.ts for the bus design.
|
|
31
|
+
// server-e2e/pubsub.ts for the bus design.
|
|
32
32
|
publishObjPut: (tag: string, resourceTag: string) => void
|
|
33
|
-
publishObjDeleted: (tag: string, resourceTag: string, version: number) => void
|
|
33
|
+
publishObjDeleted: (tag: string, resourceTag: string, version: number, incarnation: string) => void
|
|
34
34
|
getNonce: (socket: WebSocket) => string | undefined
|
|
35
35
|
debug: boolean
|
|
36
36
|
// Auth gate for the FIRST objstore-put-begin against a workspace
|
|
@@ -45,7 +45,7 @@ export type ObjstoreInitDeps = {
|
|
|
45
45
|
// process behaviour). Multi-replica deployments MUST supply a
|
|
46
46
|
// shared secret here so a token minted on one replica's WS plane
|
|
47
47
|
// validates on another replica's REST plane (the WS-to-REST hop
|
|
48
|
-
// is not load-balancer-pinned). See server/index.ts boot logic
|
|
48
|
+
// is not load-balancer-pinned). See server-e2e/index.ts boot logic
|
|
49
49
|
// for the env var (`OBJSTORE_TOKEN_SECRET`).
|
|
50
50
|
tokenSecret?: TokenSecret
|
|
51
51
|
// New-workspace operator gate for the REST put-begin mint — returns
|
|
@@ -59,6 +59,7 @@ export type ObjstoreInitDeps = {
|
|
|
59
59
|
export type ObjstoreInit = {
|
|
60
60
|
handlers: ObjstoreHandlers
|
|
61
61
|
restDeps: ObjstoreRestDeps
|
|
62
|
+
reap: () => Promise<void>
|
|
62
63
|
startupReap: Promise<void>
|
|
63
64
|
// Cancels the periodic timer AND resolves only after any
|
|
64
65
|
// currently-in-flight sweep has finished, so shutdown can safely
|
|
@@ -102,31 +103,22 @@ export function initObjstore(deps: ObjstoreInitDeps): ObjstoreInit {
|
|
|
102
103
|
let inFlight: Promise<void> | null = null
|
|
103
104
|
function enqueueSweep(): Promise<void> {
|
|
104
105
|
if (inFlight) return inFlight
|
|
105
|
-
//
|
|
106
|
-
|
|
107
|
-
// cleaned, and operators need to see it. Inner catch makes the
|
|
108
|
-
// promise resolve so callers (startupReap awaiter + setInterval)
|
|
109
|
-
// don't have to handle rejection.
|
|
110
|
-
const p = reapOrphans(handle).catch((err) => {
|
|
111
|
-
console.warn('objstore reaper error:', errStack(err))
|
|
112
|
-
}).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 })
|
|
113
108
|
inFlight = p
|
|
114
109
|
return p
|
|
115
110
|
}
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
//
|
|
119
|
-
// nothing to clear or drain. Loud, unconditional warning — with GC off,
|
|
120
|
-
// orphaned/superseded blobs and stale staging rows are never reclaimed
|
|
121
|
-
// (they accumulate until an external job, if any, collects them).
|
|
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.
|
|
122
114
|
if (deps.reapDisabled) {
|
|
123
|
-
console.warn('objstore: reaper DISABLED (OBJSTORE_REAP_DISABLED) —
|
|
124
|
-
return { handlers, restDeps, startupReap: Promise.resolve(), stopReaper:
|
|
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 }
|
|
125
117
|
}
|
|
126
118
|
// Caller awaits this before accepting traffic so a fresh boot
|
|
127
119
|
// can't hand out list / fetch / put-begin against a tag whose
|
|
128
120
|
// on-disk state still has stranded files from a prior crash.
|
|
129
|
-
const startupReap =
|
|
121
|
+
const startupReap = automaticSweep()
|
|
130
122
|
// Jittered start of the periodic timer. Multi-replica deploys
|
|
131
123
|
// (Neon + Vercel Blob) commonly boot N replicas in tight lock-step
|
|
132
124
|
// (deploy rollout, cluster restart), which would otherwise sync every
|
|
@@ -139,20 +131,20 @@ export function initObjstore(deps: ObjstoreInitDeps): ObjstoreInit {
|
|
|
139
131
|
let reapTimer: ReturnType<typeof setInterval> | null = null
|
|
140
132
|
const jitterMs = Math.floor(Math.random() * deps.reapIntervalMs)
|
|
141
133
|
const firstTimer = setTimeout(() => {
|
|
142
|
-
|
|
143
|
-
reapTimer = setInterval(
|
|
134
|
+
void automaticSweep()
|
|
135
|
+
reapTimer = setInterval(automaticSweep, deps.reapIntervalMs)
|
|
144
136
|
reapTimer.unref?.()
|
|
145
137
|
}, deps.reapIntervalMs + jitterMs)
|
|
146
138
|
firstTimer.unref?.()
|
|
147
139
|
return {
|
|
148
|
-
handlers, restDeps, startupReap,
|
|
140
|
+
handlers, restDeps, reap: enqueueSweep, startupReap,
|
|
149
141
|
stopReaper: async () => {
|
|
150
142
|
clearTimeout(firstTimer)
|
|
151
143
|
if (reapTimer) clearInterval(reapTimer)
|
|
152
144
|
// Drain whichever sweep is currently running (startup or
|
|
153
145
|
// periodic) — either could be mid-readdir/unlink at SIGTERM
|
|
154
146
|
// time and would otherwise outlive `handle.close()`.
|
|
155
|
-
|
|
147
|
+
await drain()
|
|
156
148
|
},
|
|
157
149
|
}
|
|
158
150
|
}
|
|
@@ -121,7 +121,7 @@ async function reapStaleStagingRows(handle: Handle, now: number, stagingTtlMs: n
|
|
|
121
121
|
// Push the staleness filter into SQL so the
|
|
122
122
|
// `workspace_object_staging_begun_at_idx` index handles the scan.
|
|
123
123
|
// The snapshot is O(stale-rows) cluster-wide. DB-layout audit
|
|
124
|
-
// `server/objstore/store.ts`.
|
|
124
|
+
// `server-e2e/objstore/store.ts`.
|
|
125
125
|
const staleBefore = now - stagingTtlMs
|
|
126
126
|
const staging = await handle.listAllStaging.all(staleBefore) as StagingRow[]
|
|
127
127
|
for (const s of staging) {
|
|
@@ -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
|
+
}
|
|
@@ -71,8 +71,8 @@ export type ObjstoreRestDeps = {
|
|
|
71
71
|
// Cross-instance pub/sub for objstore-deleted — fired alongside the local
|
|
72
72
|
// `broadcast` after a successful REST delete mint, so peers on OTHER
|
|
73
73
|
// instances drop the resource in real time. Carries (tag, resourceTag,
|
|
74
|
-
// version) inline (the row is gone post-delete). SQLite mode passes a no-op.
|
|
75
|
-
publishObjDeleted: (tag: string, resourceTag: string, version: number) => void
|
|
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
76
|
// New-workspace operator gate for the REST put-begin mint — the
|
|
77
77
|
// connection-independent analog of the WS path's `authGate`. Returns
|
|
78
78
|
// `true` to DENY (a password is configured AND the workspace is
|
|
@@ -105,7 +105,7 @@ export type ObjstoreRestDeps = {
|
|
|
105
105
|
// concurrent same-token PUT is rejected (409) before it can open a
|
|
106
106
|
// second writer. The slot is held only across body + commit, released
|
|
107
107
|
// in a `finally`, and bounded by the REST idle-body timeout in
|
|
108
|
-
// 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 —
|
|
109
109
|
// commits stay lock-free on the version-CAS above. Per-process: the
|
|
110
110
|
// Vercel multi-replica path leans on `allowOverwrite: true` + the
|
|
111
111
|
// commit CAS + content-addressing for cross-replica correctness (a
|
|
@@ -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.
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto'
|
|
25
25
|
import { Buffer } from 'node:buffer'
|
|
26
26
|
|
|
27
|
-
|
|
27
|
+
const DEFAULT_TOKEN_TTL_MS = 5 * 60 * 1000
|
|
28
28
|
|
|
29
29
|
export type PutTokenPayload = {
|
|
30
30
|
op: 'put'
|
|
@@ -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/
|
|
160
|
+
// NOT used by `server-e2e/sync-handlers.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.
|