@preventive/triage 1.0.0-alpha.5 → 1.0.0-alpha.6
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@preventive/triage",
|
|
3
|
-
"version": "1.0.0-alpha.
|
|
3
|
+
"version": "1.0.0-alpha.6",
|
|
4
4
|
"description": "Client & relay server for triaging of automated reports",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": {
|
|
@@ -54,10 +54,13 @@
|
|
|
54
54
|
"server/objstore/blob-fs.ts",
|
|
55
55
|
"server/objstore/blob-vercel.ts",
|
|
56
56
|
"server/objstore/blob.ts",
|
|
57
|
+
"server/objstore/fetch-mint-guard.ts",
|
|
57
58
|
"server/objstore/fs.ts",
|
|
58
59
|
"server/objstore/handlers.ts",
|
|
59
60
|
"server/objstore/init.ts",
|
|
60
61
|
"server/objstore/reaper.ts",
|
|
62
|
+
"server/objstore/rest-deny.ts",
|
|
63
|
+
"server/objstore/rest-mint.ts",
|
|
61
64
|
"server/objstore/rest.ts",
|
|
62
65
|
"server/objstore/sign.ts",
|
|
63
66
|
"server/objstore/store-neon.ts",
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// Anti-replay guard for the REST fetch-mint endpoint (POST
|
|
2
|
+
// /api/objstore/{tag}/{res}, see ./rest.ts). The WS fetch handshake binds
|
|
3
|
+
// the per-connection challenge nonce so a captured frame can't be
|
|
4
|
+
// replayed; the REST mint has no connection, so it binds a client
|
|
5
|
+
// timestamp instead and this guard supplies the matching freshness +
|
|
6
|
+
// dedup the nonce gave for free:
|
|
7
|
+
//
|
|
8
|
+
// - FRESHNESS: reject a request whose `ts` is outside ±`windowMs` of
|
|
9
|
+
// server time (an old captured request, or a future-dated one).
|
|
10
|
+
// - DEDUP: within the window, a signature is accepted at most once — a
|
|
11
|
+
// captured-and-replayed request (same `ts` ⇒ same signature) is
|
|
12
|
+
// rejected on the second presentation.
|
|
13
|
+
//
|
|
14
|
+
// Memory: each accepted signature is held for `windowMs` then pruned.
|
|
15
|
+
// Entries share a uniform TTL and the Map preserves insertion order, so
|
|
16
|
+
// the oldest entries expire first and a cheap front-prune (amortised
|
|
17
|
+
// O(1) per entry) keeps the set bounded; a hard `maxEntries` cap drops
|
|
18
|
+
// the oldest beyond it as a flood backstop.
|
|
19
|
+
//
|
|
20
|
+
// Scope: per-process. In a multi-replica deployment a captured request
|
|
21
|
+
// could be replayed once per replica that hasn't seen its signature yet
|
|
22
|
+
// (bounded by the replica count, within the window) — acceptable because
|
|
23
|
+
// the mint only ever yields a short-TTL GET token over AEAD ciphertext
|
|
24
|
+
// the relay can't read. A shared-store dedup (Redis/Neon) would close
|
|
25
|
+
// that residual gap if ever needed.
|
|
26
|
+
|
|
27
|
+
export type FetchMintVerdict = 'ok' | 'stale' | 'replay'
|
|
28
|
+
|
|
29
|
+
export type FetchMintGuard = {
|
|
30
|
+
// `signature` is the request's Ed25519 signature (unique per
|
|
31
|
+
// (tag, res, ts) tuple, so it doubles as the dedup key). `ts` is the
|
|
32
|
+
// client epoch-ms timestamp the signature commits to; `now` is the
|
|
33
|
+
// server clock (injectable for tests).
|
|
34
|
+
admit: (signature: string, ts: number, now?: number) => FetchMintVerdict
|
|
35
|
+
size: () => number
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export const DEFAULT_FETCH_MINT_WINDOW_MS = 60_000
|
|
39
|
+
export const DEFAULT_FETCH_MINT_MAX_ENTRIES = 50_000
|
|
40
|
+
|
|
41
|
+
export function createFetchMintGuard(
|
|
42
|
+
{ windowMs = DEFAULT_FETCH_MINT_WINDOW_MS, maxEntries = DEFAULT_FETCH_MINT_MAX_ENTRIES }:
|
|
43
|
+
{ windowMs?: number; maxEntries?: number } = {},
|
|
44
|
+
): FetchMintGuard {
|
|
45
|
+
// signature → expiry (ms). Insertion-ordered; uniform TTL ⇒ the head is
|
|
46
|
+
// always the soonest to expire.
|
|
47
|
+
const seen = new Map<string, number>()
|
|
48
|
+
|
|
49
|
+
function admit(signature: string, ts: number, now: number = Date.now()): FetchMintVerdict {
|
|
50
|
+
// Freshness first — a stale/future request never touches the cache, so
|
|
51
|
+
// it can't be used to grow the set.
|
|
52
|
+
if (!Number.isFinite(ts) || Math.abs(now - ts) > windowMs) return 'stale'
|
|
53
|
+
// Front-prune expired entries (contiguous at the head under the
|
|
54
|
+
// uniform TTL). Breaks at the first live entry.
|
|
55
|
+
for (const [key, exp] of seen) {
|
|
56
|
+
if (exp > now) break
|
|
57
|
+
seen.delete(key)
|
|
58
|
+
}
|
|
59
|
+
// Post-prune, any remaining entry is live, so a hit is a genuine replay.
|
|
60
|
+
if (seen.has(signature)) return 'replay'
|
|
61
|
+
seen.set(signature, now + windowMs)
|
|
62
|
+
// Flood backstop: drop the oldest beyond the cap. Those are the
|
|
63
|
+
// closest to expiry anyway; dropping them only shortens their dedup
|
|
64
|
+
// window (still freshness-gated).
|
|
65
|
+
while (seen.size > maxEntries) {
|
|
66
|
+
const oldest = seen.keys().next().value
|
|
67
|
+
if (oldest === undefined) break
|
|
68
|
+
seen.delete(oldest)
|
|
69
|
+
}
|
|
70
|
+
return 'ok'
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return { admit, size: () => seen.size }
|
|
74
|
+
}
|
|
@@ -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/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/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 }, null)
|
|
223
|
+
deps.publishObjDeleted(route.tag, route.resourceTag, result.deletedVersion)
|
|
224
|
+
}
|