diff --git a/AGENTS.md b/AGENTS.md index 217f1ce..e2a83e6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -262,7 +262,8 @@ The project uses `vendor: true` in deno.json. Some dependencies are vendored. Ex Build process injects: - `import.meta.VERSION` - Package version from package.json -- `import.meta.workerDir` - Worker directory path +- `import.meta.ownerSizeTotalWorker` - 'Owner size total' worker path +- `import.meta.creditsWorker` - 'Credits' worker path ### 6. No Network After Key Loading diff --git a/build/main.js b/build/main.js index b00c4b7..cbe029c 100644 --- a/build/main.js +++ b/build/main.js @@ -3092,7 +3092,6 @@ import { Buffer as Buffer15 } from "node:buffer"; import { isIP } from "node:net"; import path6 from "node:path"; import process9 from "node:process"; -import { join as join72 } from "node:path"; import process10 from "node:process"; import process11 from "node:process"; import process13 from "node:process"; @@ -9730,7 +9729,7 @@ var require_require_directory = __commonJS({ "node_modules/.deno/require-directory@2.1.1/node_modules/require-directory/index.js"(exports2, module14) { "use strict"; var fs = __require2("fs"); - var join92 = __require2("path").join; + var join82 = __require2("path").join; var resolve82 = __require2("path").resolve; var dirname72 = __require2("path").dirname; var defaultOptions4 = { @@ -9767,7 +9766,7 @@ var require_require_directory = __commonJS({ } path8 = !path8 ? dirname72(m3.filename) : resolve82(dirname72(m3.filename), path8); fs.readdirSync(path8).forEach(function(filename) { - var joined = join92(path8, filename), files, key, obj; + var joined = join82(path8, filename), files, key, obj; if (fs.statSync(joined).isDirectory() && options2.recurse) { files = requireDirectory(m3, joined, options2); if (Object.keys(files).length) { @@ -87652,7 +87651,7 @@ var require_thread_stream = __commonJS({ var { version: version3 } = require_package6(); var { EventEmitter } = __require2("events"); var { Worker: Worker2 } = __require2("worker_threads"); - var { join: join92 } = __require2("path"); + var { join: join82 } = __require2("path"); var { pathToFileURL } = __require2("url"); var { wait } = require_wait2(); var { @@ -87688,7 +87687,7 @@ var require_thread_stream = __commonJS({ function createWorker2(stream, opts) { const { filename, workerData } = opts; const bundlerOverrides = "__bundlerPathsOverrides" in globalThis ? globalThis.__bundlerPathsOverrides : {}; - const toExecute = bundlerOverrides["thread-stream-worker"] || join92(__dirname, "lib", "worker.js"); + const toExecute = bundlerOverrides["thread-stream-worker"] || join82(__dirname, "lib", "worker.js"); const worker = new Worker2(toExecute, { ...opts.workerOpts, trackUnmanagedFds: false, @@ -88072,7 +88071,7 @@ var require_transport = __commonJS({ "use strict"; var { createRequire } = __require2("module"); var getCallers = require_caller(); - var { join: join92, isAbsolute: isAbsolute8, sep } = __require2("path"); + var { join: join82, isAbsolute: isAbsolute8, sep } = __require2("path"); var sleep = require_atomic_sleep(); var onExit = require_on_exit_leak_free(); var ThreadStream = require_thread_stream(); @@ -88131,7 +88130,7 @@ var require_transport = __commonJS({ throw new Error("only one of target or targets can be specified"); } if (targets) { - target = bundlerOverrides["pino-worker"] || join92(__dirname, "worker.js"); + target = bundlerOverrides["pino-worker"] || join82(__dirname, "worker.js"); options2.targets = targets.map((dest) => { return { ...dest, @@ -88139,7 +88138,7 @@ var require_transport = __commonJS({ }; }); } else if (pipeline) { - target = bundlerOverrides["pino-pipeline-worker"] || join92(__dirname, "worker-pipeline.js"); + target = bundlerOverrides["pino-pipeline-worker"] || join82(__dirname, "worker-pipeline.js"); options2.targets = pipeline.map((dest) => { return { ...dest, @@ -88160,7 +88159,7 @@ var require_transport = __commonJS({ return origin; } if (origin === "pino/file") { - return join92(__dirname, "..", "file.js"); + return join82(__dirname, "..", "file.js"); } let fixTarget2; for (const filePath of callers) { @@ -89098,7 +89097,7 @@ var require_safe_stable_stringify = __commonJS({ return circularValue; } let res = ""; - let join92 = ","; + let join82 = ","; const originalIndentation = indentation; if (Array.isArray(value)) { if (value.length === 0) { @@ -89112,7 +89111,7 @@ var require_safe_stable_stringify = __commonJS({ indentation += spacer; res += ` ${indentation}`; - join92 = `, + join82 = `, ${indentation}`; } const maximumValuesToStringify = Math.min(value.length, maximumBreadth); @@ -89120,13 +89119,13 @@ ${indentation}`; for (; i2 < maximumValuesToStringify - 1; i2++) { const tmp2 = stringifyFnReplacer(String(i2), value, stack, replacer, spacer, indentation); res += tmp2 !== void 0 ? tmp2 : "null"; - res += join92; + res += join82; } const tmp = stringifyFnReplacer(String(i2), value, stack, replacer, spacer, indentation); res += tmp !== void 0 ? tmp : "null"; if (value.length - 1 > maximumBreadth) { const removedKeys = value.length - maximumBreadth - 1; - res += `${join92}"... ${getItemCount(removedKeys)} not stringified"`; + res += `${join82}"... ${getItemCount(removedKeys)} not stringified"`; } if (spacer !== "") { res += ` @@ -89147,7 +89146,7 @@ ${originalIndentation}`; let separator = ""; if (spacer !== "") { indentation += spacer; - join92 = `, + join82 = `, ${indentation}`; whitespace = " "; } @@ -89161,13 +89160,13 @@ ${indentation}`; const tmp = stringifyFnReplacer(key2, value, stack, replacer, spacer, indentation); if (tmp !== void 0) { res += `${separator}${strEscape(key2)}:${whitespace}${tmp}`; - separator = join92; + separator = join82; } } if (keyLength > maximumBreadth) { const removedKeys = keyLength - maximumBreadth; res += `${separator}"...":${whitespace}"${getItemCount(removedKeys)} not stringified"`; - separator = join92; + separator = join82; } if (spacer !== "" && separator.length > 1) { res = ` @@ -89208,7 +89207,7 @@ ${originalIndentation}`; } const originalIndentation = indentation; let res = ""; - let join92 = ","; + let join82 = ","; if (Array.isArray(value)) { if (value.length === 0) { return "[]"; @@ -89221,7 +89220,7 @@ ${originalIndentation}`; indentation += spacer; res += ` ${indentation}`; - join92 = `, + join82 = `, ${indentation}`; } const maximumValuesToStringify = Math.min(value.length, maximumBreadth); @@ -89229,13 +89228,13 @@ ${indentation}`; for (; i2 < maximumValuesToStringify - 1; i2++) { const tmp2 = stringifyArrayReplacer(String(i2), value[i2], stack, replacer, spacer, indentation); res += tmp2 !== void 0 ? tmp2 : "null"; - res += join92; + res += join82; } const tmp = stringifyArrayReplacer(String(i2), value[i2], stack, replacer, spacer, indentation); res += tmp !== void 0 ? tmp : "null"; if (value.length - 1 > maximumBreadth) { const removedKeys = value.length - maximumBreadth - 1; - res += `${join92}"... ${getItemCount(removedKeys)} not stringified"`; + res += `${join82}"... ${getItemCount(removedKeys)} not stringified"`; } if (spacer !== "") { res += ` @@ -89248,7 +89247,7 @@ ${originalIndentation}`; let whitespace = ""; if (spacer !== "") { indentation += spacer; - join92 = `, + join82 = `, ${indentation}`; whitespace = " "; } @@ -89257,7 +89256,7 @@ ${indentation}`; const tmp = stringifyArrayReplacer(key2, value[key2], stack, replacer, spacer, indentation); if (tmp !== void 0) { res += `${separator}${strEscape(key2)}:${whitespace}${tmp}`; - separator = join92; + separator = join82; } } if (spacer !== "" && separator.length > 1) { @@ -89315,20 +89314,20 @@ ${originalIndentation}`; indentation += spacer; let res2 = ` ${indentation}`; - const join10 = `, + const join92 = `, ${indentation}`; const maximumValuesToStringify = Math.min(value.length, maximumBreadth); let i2 = 0; for (; i2 < maximumValuesToStringify - 1; i2++) { const tmp2 = stringifyIndent(String(i2), value[i2], stack, spacer, indentation); res2 += tmp2 !== void 0 ? tmp2 : "null"; - res2 += join10; + res2 += join92; } const tmp = stringifyIndent(String(i2), value[i2], stack, spacer, indentation); res2 += tmp !== void 0 ? tmp : "null"; if (value.length - 1 > maximumBreadth) { const removedKeys = value.length - maximumBreadth - 1; - res2 += `${join10}"... ${getItemCount(removedKeys)} not stringified"`; + res2 += `${join92}"... ${getItemCount(removedKeys)} not stringified"`; } res2 += ` ${originalIndentation}`; @@ -89344,16 +89343,16 @@ ${originalIndentation}`; return '"[Object]"'; } indentation += spacer; - const join92 = `, + const join82 = `, ${indentation}`; let res = ""; let separator = ""; let maximumPropertiesToStringify = Math.min(keyLength, maximumBreadth); if (isTypedArrayWithEntries(value)) { - res += stringifyTypedArray(value, join92, maximumBreadth); + res += stringifyTypedArray(value, join82, maximumBreadth); keys = keys.slice(value.length); maximumPropertiesToStringify -= value.length; - separator = join92; + separator = join82; } if (deterministic) { keys = sort(keys, comparator); @@ -89364,13 +89363,13 @@ ${indentation}`; const tmp = stringifyIndent(key2, value[key2], stack, spacer, indentation); if (tmp !== void 0) { res += `${separator}${strEscape(key2)}: ${tmp}`; - separator = join92; + separator = join82; } } if (keyLength > maximumBreadth) { const removedKeys = keyLength - maximumBreadth; res += `${separator}"...": "${getItemCount(removedKeys)} not stringified"`; - separator = join92; + separator = join82; } if (separator !== "") { res = ` @@ -94122,6 +94121,7 @@ var init_pubsub2 = __esm({ log.error = (error2, ...args) => logger_default.error(error2, bold.red(tag2, ...args)); defaultServerHandlers = { close() { + clearInterval(this.pingIntervalID); log("Server closed"); }, /** @@ -107713,6 +107713,18 @@ var init_routes = __esm({ reservoirRefreshInterval: 24 * 60 * 60 * SECOND, reservoirRefreshAmount: SIGNUP_LIMIT_DAY }); + esm_default("sbp/selectors/register", { + "backend/server/stopRateLimiters": async function() { + await Promise.allSettled([ + limiterPerMinute.disconnect(), + limiterPerHour.disconnect(), + limiterPerDay.disconnect() + ]); + clearInterval(limiterPerMinute.interval); + clearInterval(limiterPerHour.interval); + clearInterval(limiterPerDay.interval); + } + }); cidLookupTable = { [multicodes.SHELTER_CONTRACT_MANIFEST]: "application/vnd.shelter.contractmanifest+json", [multicodes.SHELTER_CONTRACT_TEXT]: "application/vnd.shelter.contracttext", @@ -108562,6 +108574,7 @@ var CONTRACTS_VERSION; var GI_VERSION; var hapi; var appendToOrphanedNamesIndex; +var pushHeartbeatIntervalID; var init_server = __esm({ "src/serve/server.ts"() { "use strict"; @@ -108590,8 +108603,8 @@ var init_server = __esm({ process10.stderr.write("The size calculation worker must run more frequently than the credits worker for accurate billing"); process10.exit(1); } - ownerSizeTotalWorker = ARCHIVE_MODE2 || !OWNER_SIZE_TOTAL_WORKER_TASK_TIME_INTERVAL ? void 0 : createWorker_default(join72(import.meta.dirname || ".", "serve", "ownerSizeTotalWorker.js")); - creditsWorker = ARCHIVE_MODE2 || !CREDITS_WORKER_TASK_TIME_INTERVAL ? void 0 : createWorker_default(join72(import.meta.dirname || ".", "serve", "creditsWorker.js")); + ownerSizeTotalWorker = ARCHIVE_MODE2 || !OWNER_SIZE_TOTAL_WORKER_TASK_TIME_INTERVAL ? void 0 : createWorker_default("./serve/ownerSizeTotalWorker.js"); + creditsWorker = ARCHIVE_MODE2 || !CREDITS_WORKER_TASK_TIME_INTERVAL ? void 0 : createWorker_default("./serve/creditsWorker.js"); ({ CONTRACTS_VERSION, GI_VERSION } = process10.env); hapi = new Hapi2.Server({ // debug: false, // <- Hapi v16 was outputing too many unnecessary debug statements @@ -108727,8 +108740,16 @@ var init_server = __esm({ const sizeKey = `_private_contractFilesTotalSize_${resourceID}`; return updateSize(resourceID, sizeKey, size, true); }, - "backend/server/stop": function() { - return hapi.stop(); + "backend/server/stop": async function() { + clearInterval(pushHeartbeatIntervalID); + if (esm_default("sbp/selectors/fn", "backend/server/stopRateLimiters")) { + await esm_default("backend/server/stopRateLimiters"); + } + await hapi.stop(); + await Promise.all([ + ownerSizeTotalWorker?.terminate(), + creditsWorker?.terminate() + ]); }, async "backend/deleteFile"(cid, ultimateOwnerID, skipIfDeleted) { const owner = await esm_default("chelonia.db/get", `_private_owner_${cid}`); @@ -109013,7 +109034,7 @@ var init_server = __esm({ })(); (() => { const map = /* @__PURE__ */ new WeakMap(); - setInterval(() => { + pushHeartbeatIntervalID = setInterval(() => { const now = Date.now(); const pubsub = esm_default("okTurtles.data/get", PUBSUB_INSTANCE); const notification = JSON.stringify({ type: "recurring" }); @@ -109034,7 +109055,8 @@ var init_server = __esm({ }); var serve_exports = {}; __export(serve_exports, { - default: () => serve_default + default: () => serve_default, + removeSignalHandlers: () => removeSignalHandlers }); function logSBP(_domain, selector, data) { if (!dontLog[selector]) { @@ -109045,10 +109067,17 @@ function logSBP(_domain, selector, data) { } } } +function removeSignalHandlers() { + for (const [signal, handler] of signalHandlers) { + process11.removeListener(signal, handler); + } + signalHandlers.length = 0; +} var import_npm_chalk4; var dontLog; var serve_default; var exit2; +var signalHandlers; var handleSignal; var init_serve = __esm({ "src/serve/index.ts"() { @@ -109082,6 +109111,8 @@ var init_serve = __esm({ return new Promise((resolve82) => { pubsub.on("close", async function() { try { + removeSignalHandlers(); + await esm_default("chelonia.persistentActions/unload"); await esm_default("backend/server/stop"); console.info("Hapi server down"); } catch (err) { @@ -109113,11 +109144,14 @@ var init_serve = __esm({ }); esm_default("okTurtles.events/emit", SERVER_EXITING); }; + signalHandlers = []; handleSignal = (signal, code2) => { - process11.on(signal, () => { + const handler = () => { console.error(`Exiting upon receiving ${signal} (${code2})`); exit2(128 + code2); - }); + }; + signalHandlers.push([signal, handler]); + process11.on(signal, handler); }; [ ["SIGHUP", 1], diff --git a/scripts/build.ts b/scripts/build.ts index e58af9f..0003783 100755 --- a/scripts/build.ts +++ b/scripts/build.ts @@ -14,7 +14,8 @@ const options: esbuild.BuildOptions = { bundle: true, define: { 'import.meta.VERSION': JSON.stringify(version), - 'import.meta.workerDir': '"serve"' + 'import.meta.ownerSizeTotalWorker': '"./serve/ownerSizeTotalWorker.js"', + 'import.meta.creditsWorker': '"./serve/creditsWorker.js"', }, format: 'esm', platform: 'node', diff --git a/src/serve/index.ts b/src/serve/index.ts index 66414ba..39783d6 100644 --- a/src/serve/index.ts +++ b/src/serve/index.ts @@ -45,6 +45,8 @@ sbp('okTurtles.events/once', SERVER_EXITING, () => { return new Promise((resolve) => { pubsub.on('close', async function () { try { + removeSignalHandlers() + await sbp('chelonia.persistentActions/unload') await sbp('backend/server/stop') console.info('Hapi server down') } catch (err) { @@ -86,13 +88,17 @@ const exit = (code: number) => { sbp('okTurtles.events/emit', SERVER_EXITING) } +const signalHandlers: Array<[string, () => void]> = [] + const handleSignal = (signal: string, code: number) => { - process.on(signal, () => { + const handler = () => { console.error(`Exiting upon receiving ${signal} (${code})`) // Exit codes follow the 128 + signal code convention. // See exit(128 + code) - }) + } + signalHandlers.push([signal, handler]) + process.on(signal, handler) } // Codes from @@ -104,3 +110,10 @@ const handleSignal = (signal: string, code: number) => { ['SIGUSR1', 10], ['SIGUSR2', 11] ] as [string, number][]).forEach(([signal, code]) => handleSignal(signal, code)) + +export function removeSignalHandlers () { + for (const [signal, handler] of signalHandlers) { + process.removeListener(signal, handler) + } + signalHandlers.length = 0 +} diff --git a/src/serve/pubsub.ts b/src/serve/pubsub.ts index a607b62..479a72b 100644 --- a/src/serve/pubsub.ts +++ b/src/serve/pubsub.ts @@ -191,7 +191,7 @@ export interface WSS extends Omit { customServerEventHandlers: Partial; customSocketEventHandlers: Partial; customMessageHandlers: Partial; - pingIntervalID?: ReturnType + pingIntervalID?: ReturnType subscribersByChannelID: Record> pushSubscriptions: Record; options: ServerOptions @@ -274,6 +274,7 @@ export function createServer (httpServer: import('node:http').Server, options: S // The `this` binding refers to the server object. const defaultServerHandlers: ServerHandlers = { close () { + clearInterval(this.pingIntervalID) log('Server closed') }, /** diff --git a/src/serve/routes-kv.test.ts b/src/serve/routes-kv.test.ts new file mode 100644 index 0000000..f0c3d65 --- /dev/null +++ b/src/serve/routes-kv.test.ts @@ -0,0 +1,225 @@ +import 'jsr:@db/sqlite' +import { + buildShelterAuthHeader, + buildSignedKvPayload, + createCID, + createTestIdentity, + multicodes, + sbp, + startTestServer, + stopTestServer +} from './routes-test-helpers.ts' + +Deno.test({ + name: 'routes: KV store endpoints', + async fn (t: Deno.TestContext) { + const baseURL = await startTestServer() + + try { + const owner = createTestIdentity() + + await t.step('setup: register billable entity for owner', async () => { + await sbp('chelonia.db/set', owner.contractID, 'identity-contract-data') + await sbp('chelonia.db/set', `head=${owner.contractID}`, JSON.stringify({ + HEAD: createCID('owner-head', multicodes.SHELTER_CONTRACT_DATA), + previousKeyOp: null, + height: 0 + })) + }) + + await t.step('POST /kv without auth returns 401', async () => { + const cid = createCID('kv-contract', multicodes.SHELTER_CONTRACT_DATA) + const res = await fetch(`${baseURL}/kv/${cid}/mykey`, { + method: 'POST', + headers: { 'content-type': 'application/octet-stream', 'if-match': '*' }, + body: 'test' + }) + await res.body?.cancel() + if (res.status !== 401) throw new Error(`Expected 401 but got ${res.status}`) + }) + + await t.step('POST /kv with auth but mismatched contractID returns 401', async () => { + const other = createTestIdentity() + const otherAuth = buildShelterAuthHeader(other.contractID, other.SAK) + const res = await fetch(`${baseURL}/kv/${owner.contractID}/mykey`, { + method: 'POST', + headers: { + authorization: otherAuth, + 'content-type': 'application/octet-stream', + 'if-match': '*' + }, + body: 'test' + }) + await res.body?.cancel() + if (res.status !== 401) throw new Error(`Expected 401 but got ${res.status}`) + }) + + await t.step('POST /kv without If-Match header returns 400', async () => { + const auth = buildShelterAuthHeader(owner.contractID, owner.SAK) + const res = await fetch(`${baseURL}/kv/${owner.contractID}/mykey`, { + method: 'POST', + headers: { authorization: auth, 'content-type': 'application/octet-stream' }, + body: 'test' + }) + await res.body?.cancel() + if (res.status !== 400) throw new Error(`Expected 400 but got ${res.status}`) + }) + + await t.step('POST /kv with invalid key (_private prefix) returns 400', async () => { + const auth = buildShelterAuthHeader(owner.contractID, owner.SAK) + const res = await fetch(`${baseURL}/kv/${owner.contractID}/_private_secret`, { + method: 'POST', + headers: { + authorization: auth, + 'content-type': 'application/octet-stream', + 'if-match': '*' + }, + body: 'test' + }) + await res.body?.cancel() + if (res.status !== 400) throw new Error(`Expected 400 but got ${res.status}`) + }) + + await t.step('POST /kv with wrong CID type returns 400', async () => { + const fileCID = createCID('not-contract', multicodes.SHELTER_FILE_MANIFEST) + const auth = buildShelterAuthHeader(owner.contractID, owner.SAK) + const res = await fetch(`${baseURL}/kv/${fileCID}/mykey`, { + method: 'POST', + headers: { + authorization: auth, + 'content-type': 'application/octet-stream', + 'if-match': '*' + }, + body: 'test' + }) + await res.body?.cancel() + if (res.status !== 400) throw new Error(`Expected 400 but got ${res.status}`) + }) + + await t.step('POST /kv with valid signed payload stores value and returns 204', async () => { + const auth = buildShelterAuthHeader(owner.contractID, owner.SAK) + const payload = buildSignedKvPayload(owner.contractID, 'testkey', 0, { hello: 'world' }, owner.SAK) + const res = await fetch(`${baseURL}/kv/${owner.contractID}/testkey`, { + method: 'POST', + headers: { + authorization: auth, + 'content-type': 'application/octet-stream', + 'if-match': '*' + }, + body: payload + }) + await res.body?.cancel() + if (res.status !== 204) throw new Error(`Expected 204 but got ${res.status}`) + }) + + await t.step('GET /kv without auth returns 401', async () => { + const res = await fetch(`${baseURL}/kv/${owner.contractID}/testkey`) + await res.body?.cancel() + if (res.status !== 401) throw new Error(`Expected 401 but got ${res.status}`) + }) + + await t.step('GET /kv with valid auth returns stored value with ETag', async () => { + const auth = buildShelterAuthHeader(owner.contractID, owner.SAK) + const res = await fetch(`${baseURL}/kv/${owner.contractID}/testkey`, { + headers: { authorization: auth } + }) + if (res.status !== 200) throw new Error(`Expected 200 but got ${res.status}`) + const body = await res.text() + if (!body) throw new Error('Expected non-empty body') + const etag = res.headers.get('etag') + if (!etag) throw new Error('Expected ETag header') + const xcid = res.headers.get('x-cid') + if (!xcid) throw new Error('Expected x-cid header') + if (!etag.startsWith('W/') && etag !== xcid) throw new Error('Expected x-cid to match ETag') + }) + + await t.step('GET /kv with valid auth for nonexistent key returns 404', async () => { + const auth = buildShelterAuthHeader(owner.contractID, owner.SAK) + const res = await fetch(`${baseURL}/kv/${owner.contractID}/nonexistent`, { + headers: { authorization: auth } + }) + await res.body?.cancel() + if (res.status !== 404) throw new Error(`Expected 404 but got ${res.status}`) + }) + + await t.step('POST /kv with mismatched ETag returns 412 with current value', async () => { + const auth = buildShelterAuthHeader(owner.contractID, owner.SAK) + const payload = buildSignedKvPayload(owner.contractID, 'testkey', 0, { updated: true }, owner.SAK) + const res = await fetch(`${baseURL}/kv/${owner.contractID}/testkey`, { + method: 'POST', + headers: { + authorization: auth, + 'content-type': 'application/octet-stream', + 'if-match': '"wrong-etag"' + }, + body: payload + }) + if (res.status !== 412) throw new Error(`Expected 412 but got ${res.status}`) + const body = await res.text() + if (!body) throw new Error('Expected response body with current value') + const xcid = res.headers.get('x-cid') + if (!xcid) throw new Error('Expected x-cid header on 412') + }) + + await t.step('POST /kv with matching ETag updates value', async () => { + const auth1 = buildShelterAuthHeader(owner.contractID, owner.SAK) + const getRes = await fetch(`${baseURL}/kv/${owner.contractID}/testkey`, { + headers: { authorization: auth1 } + }) + const etag = getRes.headers.get('etag') + const xcid = getRes.headers.get('x-cid') + await getRes.body?.cancel() + if (!xcid) throw new Error('Expected x-cid from GET') + if (!etag) throw new Error('Expected ETag from GET') + if (!etag.startsWith('W/') && etag !== xcid) throw new Error('Expected x-cid to match ETag') + + const auth2 = buildShelterAuthHeader(owner.contractID, owner.SAK) + const payload = buildSignedKvPayload(owner.contractID, 'testkey', 0, { updated: true }, owner.SAK) + const res = await fetch(`${baseURL}/kv/${owner.contractID}/testkey`, { + method: 'POST', + headers: { + authorization: auth2, + 'content-type': 'application/octet-stream', + 'if-match': xcid + }, + body: payload + }) + await res.body?.cancel() + if (res.status !== 204) throw new Error(`Expected 204 but got ${res.status}`) + }) + + await t.step('POST /kv with wrong height returns 409', async () => { + const auth = buildShelterAuthHeader(owner.contractID, owner.SAK) + const payload = buildSignedKvPayload(owner.contractID, 'testkey', 999, { bad: true }, owner.SAK) + const res = await fetch(`${baseURL}/kv/${owner.contractID}/testkey`, { + method: 'POST', + headers: { + authorization: auth, + 'content-type': 'application/octet-stream', + 'if-match': '*' + }, + body: payload + }) + if (res.status !== 409) throw new Error(`Expected 409 but got ${res.status}`) + await res.body?.cancel() + }) + + await t.step('POST /kv with invalid payload returns 422', async () => { + const auth = buildShelterAuthHeader(owner.contractID, owner.SAK) + const res = await fetch(`${baseURL}/kv/${owner.contractID}/testkey`, { + method: 'POST', + headers: { + authorization: auth, + 'content-type': 'application/octet-stream', + 'if-match': '*' + }, + body: 'not-valid-json' + }) + await res.body?.cancel() + if (res.status !== 422) throw new Error(`Expected 422 but got ${res.status}`) + }) + } finally { + await stopTestServer() + } + } +}) diff --git a/src/serve/routes-reads.test.ts b/src/serve/routes-reads.test.ts new file mode 100644 index 0000000..564abd4 --- /dev/null +++ b/src/serve/routes-reads.test.ts @@ -0,0 +1,262 @@ +import 'jsr:@db/sqlite' +import { + createCID, + multicodes, + sbp, + startTestServer, + stopTestServer +} from './routes-test-helpers.ts' + +Deno.test({ + name: 'routes: state-dependent reads', + async fn (t: Deno.TestContext) { + const baseURL = await startTestServer() + + try { + const testContractData = 'test-contract-data-content' + const testContractID = createCID(testContractData, multicodes.SHELTER_CONTRACT_DATA) + const testFileContent = '{"version":"1.0.0","cipher":"aes256gcm","chunks":[]}' + const testFileManifestHash = createCID(testFileContent, multicodes.SHELTER_FILE_MANIFEST) + const testChunkContent = 'binary-chunk-data' + const testChunkHash = createCID(testChunkContent, multicodes.SHELTER_FILE_CHUNK) + const testEntryContent = JSON.stringify({ + head: JSON.stringify({ + version: '1.0.0', + previousHEAD: null, + contractID: testContractID, + op: 'c', + manifest: testContractID, + height: 0 + }), + signedMessageData: 'test-signed-data' + }) + const testEntryHash = createCID(testEntryContent, multicodes.SHELTER_CONTRACT_DATA) + + await t.step('setup: seed DB with test data', async () => { + await sbp('backend/db/registerName', 'testuser', testContractID) + await sbp('chelonia.db/set', testContractID, testContractData) + await sbp('chelonia.db/set', testFileManifestHash, testFileContent) + await sbp('chelonia.db/set', testChunkHash, testChunkContent) + await sbp('chelonia.db/set', `head=${testContractID}`, JSON.stringify({ + HEAD: testEntryHash, + previousKeyOp: null, + height: 0 + })) + await sbp('chelonia.db/set', testEntryHash, testEntryContent) + await sbp('chelonia.db/set', `_private_hidx=${testContractID}#0`, JSON.stringify({ + hash: testEntryHash, + date: new Date().toISOString() + })) + }) + + await t.step('GET /name/{name} returns contract ID for registered name', async () => { + const res = await fetch(`${baseURL}/name/testuser`) + if (res.status !== 200) throw new Error(`Expected 200 but got ${res.status}`) + const body = await res.text() + if (body !== testContractID) { + throw new Error(`Expected ${testContractID} but got ${body}`) + } + const contentType = res.headers.get('content-type') + if (!contentType || !contentType.includes('text/plain')) { + throw new Error(`Expected text/plain but got ${contentType}`) + } + }) + + await t.step('GET /latestHEADinfo with non-CID param returns 400', async () => { + const res = await fetch(`${baseURL}/latestHEADinfo/not-a-valid-cid`) + await res.body?.cancel() + if (res.status !== 400) throw new Error(`Expected 400 but got ${res.status}`) + }) + + await t.step('GET /latestHEADinfo with wrong CID type returns 400', async () => { + const wrongTypeCID = createCID('test', multicodes.SHELTER_FILE_MANIFEST) + const res = await fetch(`${baseURL}/latestHEADinfo/${wrongTypeCID}`) + await res.body?.cancel() + if (res.status !== 400) throw new Error(`Expected 400 but got ${res.status}`) + }) + + await t.step('GET /latestHEADinfo with unknown contractID returns 404', async () => { + const unknownCID = createCID('unknown-contract', multicodes.SHELTER_CONTRACT_DATA) + const res = await fetch(`${baseURL}/latestHEADinfo/${unknownCID}`) + await res.body?.cancel() + if (res.status !== 404) throw new Error(`Expected 404 but got ${res.status}`) + }) + + await t.step('GET /latestHEADinfo returns HEADinfo for known contract', async () => { + const res = await fetch(`${baseURL}/latestHEADinfo/${testContractID}`) + if (res.status !== 200) throw new Error(`Expected 200 but got ${res.status}`) + const body = await res.json() + if (body.HEAD !== testEntryHash) { + throw new Error(`Expected HEAD ${testEntryHash} but got ${body.HEAD}`) + } + if (body.height !== 0) { + throw new Error(`Expected height 0 but got ${body.height}`) + } + }) + + await t.step('GET /latestHEADinfo has Cache-Control: no-store', async () => { + const res = await fetch(`${baseURL}/latestHEADinfo/${testContractID}`) + await res.body?.cancel() + const cacheControl = res.headers.get('cache-control') + if (!cacheControl || !cacheControl.includes('no-store')) { + throw new Error(`Expected Cache-Control no-store but got ${cacheControl}`) + } + }) + + await t.step('GET /latestHEADinfo returns 410 for deleted contract', async () => { + const deletedContractData = 'deleted-contract-data' + const deletedContractID = createCID(deletedContractData, multicodes.SHELTER_CONTRACT_DATA) + await sbp('chelonia.db/set', `head=${deletedContractID}`, '') + const res = await fetch(`${baseURL}/latestHEADinfo/${deletedContractID}`) + await res.body?.cancel() + if (res.status !== 410) throw new Error(`Expected 410 but got ${res.status}`) + }) + + await t.step('GET /file/{hash} with unknown hash returns 404', async () => { + const unknownHash = createCID('nonexistent-file', multicodes.SHELTER_FILE_MANIFEST) + const res = await fetch(`${baseURL}/file/${unknownHash}`) + await res.body?.cancel() + if (res.status !== 404) throw new Error(`Expected 404 but got ${res.status}`) + }) + + await t.step('GET /file/{hash} with invalid CID returns 400', async () => { + const res = await fetch(`${baseURL}/file/not-a-valid-cid`) + await res.body?.cancel() + if (res.status !== 400) throw new Error(`Expected 400 but got ${res.status}`) + }) + + await t.step('GET /file/{hash} returns file manifest with correct content-type', async () => { + const res = await fetch(`${baseURL}/file/${testFileManifestHash}`) + if (res.status !== 200) throw new Error(`Expected 200 but got ${res.status}`) + const body = await res.text() + if (body !== testFileContent) { + throw new Error(`Expected file content but got ${body}`) + } + const contentType = res.headers.get('content-type') + if (!contentType || !contentType.includes('application/vnd.shelter.filemanifest+json')) { + throw new Error(`Expected filemanifest content-type but got ${contentType}`) + } + }) + + await t.step('GET /file/{hash} returns chunk with correct content-type', async () => { + const res = await fetch(`${baseURL}/file/${testChunkHash}`) + if (res.status !== 200) throw new Error(`Expected 200 but got ${res.status}`) + const body = await res.text() + if (body !== testChunkContent) { + throw new Error(`Expected chunk content but got ${body}`) + } + const contentType = res.headers.get('content-type') + if (!contentType || !contentType.includes('application/vnd.shelter.filechunk+octet-stream')) { + throw new Error(`Expected filechunk content-type but got ${contentType}`) + } + }) + + await t.step('GET /file/{hash} has immutable Cache-Control and ETag', async () => { + const res = await fetch(`${baseURL}/file/${testFileManifestHash}`) + await res.body?.cancel() + const cacheControl = res.headers.get('cache-control') + if (!cacheControl || !cacheControl.includes('immutable')) { + throw new Error(`Expected immutable Cache-Control but got ${cacheControl}`) + } + const etag = res.headers.get('etag') + if (!etag) { + throw new Error('Expected ETag header but none found') + } + }) + + await t.step('GET /file/{hash} has CSP and nosniff headers', async () => { + const res = await fetch(`${baseURL}/file/${testFileManifestHash}`) + await res.body?.cancel() + const csp = res.headers.get('content-security-policy') + if (!csp || !csp.includes('default-src \'none\'')) { + throw new Error(`Expected restrictive CSP but got ${csp}`) + } + const nosniff = res.headers.get('x-content-type-options') + if (nosniff !== 'nosniff') { + throw new Error(`Expected nosniff but got ${nosniff}`) + } + }) + + await t.step('GET /file/{hash} returns 410 for deleted file', async () => { + const deletedFileData = 'deleted-file-data' + const deletedFileHash = createCID(deletedFileData, multicodes.SHELTER_FILE_MANIFEST) + await sbp('chelonia.db/set', deletedFileHash, '') + const res = await fetch(`${baseURL}/file/${deletedFileHash}`) + await res.body?.cancel() + if (res.status !== 410) throw new Error(`Expected 410 but got ${res.status}`) + }) + + await t.step('GET /file/{hash} returns contract data content-type', async () => { + const res = await fetch(`${baseURL}/file/${testContractID}`) + if (res.status !== 200) throw new Error(`Expected 200 but got ${res.status}`) + const contentType = res.headers.get('content-type') + if (!contentType || !contentType.includes('application/vnd.shelter.contractdata+json')) { + throw new Error(`Expected contractdata content-type but got ${contentType}`) + } + await res.body?.cancel() + }) + + await t.step('GET /eventsAfter with invalid contractID returns 400', async () => { + const res = await fetch(`${baseURL}/eventsAfter/not-a-cid/0`) + await res.body?.cancel() + if (res.status !== 400) throw new Error(`Expected 400 but got ${res.status}`) + }) + + await t.step('GET /eventsAfter with wrong CID type returns 400', async () => { + const wrongTypeCID = createCID('test', multicodes.SHELTER_FILE_MANIFEST) + const res = await fetch(`${baseURL}/eventsAfter/${wrongTypeCID}/0`) + await res.body?.cancel() + if (res.status !== 400) throw new Error(`Expected 400 but got ${res.status}`) + }) + + await t.step('GET /eventsAfter with unknown contractID returns 404', async () => { + const unknownCID = createCID('unknown-events', multicodes.SHELTER_CONTRACT_DATA) + const res = await fetch(`${baseURL}/eventsAfter/${unknownCID}/0`) + if (res.status !== 404) throw new Error(`Expected 404 but got ${res.status}`) + await res.body?.cancel() + }) + + await t.step('GET /eventsAfter returns JSON array with HEADinfo headers for known contract', async () => { + const res = await fetch(`${baseURL}/eventsAfter/${testContractID}/0`) + if (res.status !== 200) throw new Error(`Expected 200 but got ${res.status}`) + const body = await res.text() + const parsed = JSON.parse(body) + if (!Array.isArray(parsed)) throw new Error('Expected JSON array response') + const headinfoHead = res.headers.get('shelter-headinfo-head') + if (headinfoHead !== testEntryHash) { + throw new Error(`Expected shelter-headinfo-head ${testEntryHash} but got ${headinfoHead}`) + } + const headinfoHeight = res.headers.get('shelter-headinfo-height') + if (headinfoHeight !== '0') { + throw new Error(`Expected shelter-headinfo-height 0 but got ${headinfoHeight}`) + } + }) + + await t.step('GET /eventsAfter with height beyond contract returns empty array', async () => { + const res = await fetch(`${baseURL}/eventsAfter/${testContractID}/999`) + if (res.status !== 200) throw new Error(`Expected 200 but got ${res.status}`) + const body = await res.text() + const parsed = JSON.parse(body) + if (!Array.isArray(parsed)) throw new Error('Expected JSON array response') + if (parsed.length !== 0) throw new Error(`Expected 0 entries but got ${parsed.length}`) + }) + + await t.step('GET /eventsAfter with invalid since param returns 400', async () => { + const res = await fetch(`${baseURL}/eventsAfter/${testContractID}/not-a-number`) + await res.body?.cancel() + if (res.status !== 400) throw new Error(`Expected 400 but got ${res.status}`) + }) + + await t.step('GET /eventsAfter returns 410 for deleted contract', async () => { + const deletedData = 'deleted-events-contract' + const deletedCID = createCID(deletedData, multicodes.SHELTER_CONTRACT_DATA) + await sbp('chelonia.db/set', `head=${deletedCID}`, '') + const res = await fetch(`${baseURL}/eventsAfter/${deletedCID}/0`) + if (res.status !== 410) throw new Error(`Expected 410 but got ${res.status}`) + await res.body?.cancel() + }) + } finally { + await stopTestServer() + } + } +}) diff --git a/src/serve/routes-stateless.test.ts b/src/serve/routes-stateless.test.ts new file mode 100644 index 0000000..b45c74f --- /dev/null +++ b/src/serve/routes-stateless.test.ts @@ -0,0 +1,134 @@ +import 'jsr:@db/sqlite' +import { startTestServer, stopTestServer } from './routes-test-helpers.ts' + +Deno.test({ + name: 'routes: stateless endpoints', + async fn (t: Deno.TestContext) { + const baseURL = await startTestServer() + + try { + await t.step('GET /time returns ISO timestamp', async () => { + const res = await fetch(`${baseURL}/time`) + if (res.status !== 200) throw new Error(`Expected 200 but got ${res.status}`) + const body = await res.text() + const parsed = new Date(body) + if (isNaN(parsed.getTime())) throw new Error(`Response is not a valid ISO date: ${body}`) + const contentType = res.headers.get('content-type') + if (!contentType || !contentType.includes('text/plain')) { + throw new Error(`Expected text/plain content-type but got ${contentType}`) + } + }) + + await t.step('GET /time has Cache-Control: no-store', async () => { + const res = await fetch(`${baseURL}/time`) + await res.body?.cancel() + const cacheControl = res.headers.get('cache-control') + if (!cacheControl || !cacheControl.includes('no-store')) { + throw new Error(`Expected Cache-Control no-store but got ${cacheControl}`) + } + }) + + await t.step('GET /time has X-Frame-Options: DENY', async () => { + const res = await fetch(`${baseURL}/time`) + await res.body?.cancel() + const xfo = res.headers.get('x-frame-options') + if (xfo !== 'DENY') { + throw new Error(`Expected X-Frame-Options DENY but got ${xfo}`) + } + }) + + await t.step('GET /serverMessages returns configured messages', async () => { + const res = await fetch(`${baseURL}/serverMessages`) + if (res.status !== 200) throw new Error(`Expected 200 but got ${res.status}`) + const body = await res.json() + if (!Array.isArray(body)) throw new Error('Expected array response') + if (body.length !== 1) throw new Error(`Expected 1 message but got ${body.length}`) + if (body[0].type !== 'info') throw new Error(`Expected type 'info' but got ${body[0].type}`) + if (body[0].text !== 'test message') throw new Error(`Expected text 'test message' but got ${body[0].text}`) + }) + + await t.step('GET /serverMessages has Cache-Control: no-store', async () => { + const res = await fetch(`${baseURL}/serverMessages`) + await res.body?.cancel() + const cacheControl = res.headers.get('cache-control') + if (!cacheControl || !cacheControl.includes('no-store')) { + throw new Error(`Expected Cache-Control no-store but got ${cacheControl}`) + } + }) + + await t.step('POST /streams-test with "ok" returns 204', async () => { + const res = await fetch(`${baseURL}/streams-test`, { + method: 'POST', + body: 'ok', + headers: { 'content-type': 'application/octet-stream' } + }) + await res.body?.cancel() + if (res.status !== 204) throw new Error(`Expected 204 but got ${res.status}`) + }) + + await t.step('POST /streams-test with wrong body returns 400', async () => { + const res = await fetch(`${baseURL}/streams-test`, { + method: 'POST', + body: 'wrong', + headers: { 'content-type': 'application/octet-stream' } + }) + await res.body?.cancel() + if (res.status !== 400) throw new Error(`Expected 400 but got ${res.status}`) + }) + + await t.step('POST /streams-test with empty body returns 400', async () => { + const res = await fetch(`${baseURL}/streams-test`, { + method: 'POST', + body: '', + headers: { 'content-type': 'application/octet-stream' } + }) + await res.body?.cancel() + if (res.status !== 400) throw new Error(`Expected 400 but got ${res.status}`) + }) + + await t.step('POST /log with valid payload returns 200', async () => { + const res = await fetch(`${baseURL}/log`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ level: 'info', value: 'test log message' }) + }) + await res.body?.cancel() + if (res.status !== 200) throw new Error(`Expected 200 but got ${res.status}`) + }) + + await t.step('POST /log with missing level returns 400', async () => { + const res = await fetch(`${baseURL}/log`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ value: 'test log message' }) + }) + await res.body?.cancel() + if (res.status !== 400) throw new Error(`Expected 400 but got ${res.status}`) + }) + + await t.step('POST /log with missing value returns 400', async () => { + const res = await fetch(`${baseURL}/log`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ level: 'info' }) + }) + await res.body?.cancel() + if (res.status !== 400) throw new Error(`Expected 400 but got ${res.status}`) + }) + + await t.step('GET /name with invalid name format returns 400', async () => { + const res = await fetch(`${baseURL}/name/INVALID_NAME!!`) + await res.body?.cancel() + if (res.status !== 400) throw new Error(`Expected 400 but got ${res.status}`) + }) + + await t.step('GET /name with valid but unregistered name returns 404', async () => { + const res = await fetch(`${baseURL}/name/nonexistentuser`) + await res.body?.cancel() + if (res.status !== 404) throw new Error(`Expected 404 but got ${res.status}`) + }) + } finally { + await stopTestServer() + } + } +}) diff --git a/src/serve/routes-test-helpers.ts b/src/serve/routes-test-helpers.ts new file mode 100644 index 0000000..091e5e9 --- /dev/null +++ b/src/serve/routes-test-helpers.ts @@ -0,0 +1,187 @@ +import { Buffer } from 'node:buffer' +import process from 'node:process' +// @deno-types="npm:@types/nconf" +import nconf from 'npm:nconf' +import sbp from 'npm:@sbp/sbp' +import 'npm:@sbp/okturtles.data' +import 'npm:@sbp/okturtles.events' +import 'npm:@sbp/okturtles.eventqueue' +import { blake32Hash, createCID, multicodes } from 'npm:@chelonia/lib/functions' +import { EDWARDS25519SHA512BATCH, keygen, keyId, serializeKey, sign } from 'npm:@chelonia/crypto' +import { AUTHSALT, CONTRACTSALT, CS, SALT_LENGTH_IN_OCTETS } from 'npm:@chelonia/lib/zkppConstants' +import tweetnacl from 'npm:tweetnacl' +import { SERVER_EXITING, SERVER_RUNNING } from './events.ts' + +export { blake32Hash, createCID, multicodes } from 'npm:@chelonia/lib/functions' +export { EDWARDS25519SHA512BATCH, keygen, keyId, serializeKey, sign } from 'npm:@chelonia/crypto' +export { default as sbp } from 'npm:@sbp/sbp' + +export const nacl = tweetnacl + +const TEST_PORT = 0 + +export function buildSignedKvPayload ( + _contractID: string, + key: string, + height: number, + data: unknown, + SAK: ReturnType +) { + const SAKid = keyId(SAK) + const heightStr = String(height) + const serializedMessage = JSON.stringify(data) + const additionalData = key + heightStr + const sig = sign(SAK, blake32Hash(blake32Hash(additionalData) + blake32Hash(serializedMessage))) + return JSON.stringify({ + height: heightStr, + _signedData: [serializedMessage, SAKid, sig] + }) +} + +export function saltsAndEncryptedHashedPassword (p: string, secretKey: Uint8Array, hash: string) { + const nonce = nacl.randomBytes(nacl.secretbox.nonceLength) + const dhKey = nacl.hash(nacl.box.before(Buffer.from(p, 'base64url'), secretKey)) + const authSalt = Buffer.from(nacl.hash(Buffer.concat([nacl.hash(Buffer.from(AUTHSALT)), dhKey]))).subarray(0, SALT_LENGTH_IN_OCTETS).toString('base64') + const contractSalt = Buffer.from(nacl.hash(Buffer.concat([nacl.hash(Buffer.from(CONTRACTSALT)), dhKey]))).subarray(0, SALT_LENGTH_IN_OCTETS).toString('base64') + const encryptionKey = nacl.hash(Buffer.from(authSalt + contractSalt)).subarray(0, nacl.secretbox.keyLength) + const encryptedHashedPassword = Buffer.concat([nonce, nacl.secretbox(Buffer.from(hash), nonce, encryptionKey)]).toString('base64url') + return [authSalt, contractSalt, encryptedHashedPassword] +} + +export function decryptRegistrationRedemptionToken (p: string, secretKey: Uint8Array, encryptedToken: string) { + const dhKey = nacl.hash(nacl.box.before(Buffer.from(p, 'base64url'), secretKey)) + const authSalt = Buffer.from(nacl.hash(Buffer.concat([nacl.hash(Buffer.from(AUTHSALT)), dhKey]))).subarray(0, SALT_LENGTH_IN_OCTETS).toString('base64') + const contractSalt = Buffer.from(nacl.hash(Buffer.concat([nacl.hash(Buffer.from(CONTRACTSALT)), dhKey]))).subarray(0, SALT_LENGTH_IN_OCTETS).toString('base64') + const encryptionKey = nacl.hash(Buffer.concat([Buffer.from(CS), nacl.hash(Buffer.from(authSalt + contractSalt)).subarray(0, nacl.secretbox.keyLength)])).subarray(0, nacl.secretbox.keyLength) + const encryptedTokenBuf = Buffer.from(encryptedToken, 'base64url') + const nonce = encryptedTokenBuf.subarray(0, nacl.secretbox.nonceLength) + const ciphertext = encryptedTokenBuf.subarray(nacl.secretbox.nonceLength) + const decrypted = nacl.secretbox.open(ciphertext, nonce, encryptionKey) + if (!decrypted) throw new Error('Failed to decrypt token') + return Buffer.from(decrypted).toString() +} + +export function createTestIdentity () { + const SAK = keygen(EDWARDS25519SHA512BATCH) + const SAKid = keyId(SAK) + const SAKpublic = serializeKey(SAK, false) + const contractData = `identity-${SAKid}-${Date.now()}` + const contractID = createCID(contractData, multicodes.SHELTER_CONTRACT_DATA) + + const rootState = sbp('chelonia/rootState') + rootState[contractID] = { + _vm: { + authorizedKeys: { + [SAKid]: { + id: SAKid, + name: '#sak', + purpose: ['sak'], + ringLevel: 0, + permissions: [], + allowedActions: [], + data: SAKpublic, + _notBeforeHeight: 0, + _notAfterHeight: null + } + } + } + } + rootState.contracts = rootState.contracts || Object.create(null) + rootState.contracts[contractID] = { + type: 'gi.contracts/identity', + HEAD: createCID(contractData + '-head', multicodes.SHELTER_CONTRACT_DATA), + height: 0 + } + + return { contractID, SAK, SAKid } +} + +export function buildShelterAuthHeader (contractID: string, SAK: ReturnType) { + const nonceBytes = new Uint8Array(15) + crypto.getRandomValues(nonceBytes) + const data = `${contractID} ${Date.now()}.${Buffer.from(nonceBytes).toString('base64')}` + return `shelter ${data}.${sign(SAK, data)}` +} + +let cachedServerAddress: Promise | undefined +let serverStartRefCount: number = 0 +export function startTestServer (): Promise { + serverStartRefCount++ + if (cachedServerAddress !== undefined) { + return cachedServerAddress + } + + const internal = async () => { + process.env.NODE_ENV = 'development' + process.env.CI = 'true' + + nconf.defaults({ + server: { + host: '127.0.0.1', + port: TEST_PORT, + appDir: '.', + fileUploadMaxBytes: 31457280, + signup: { + disabled: false, + limit: { disabled: false, minute: 100, hour: 1000, day: 10000 } + }, + logLevel: 'error', + messages: [{ type: 'info', text: 'test message' }], + maxEventsBatchSize: 500, + archiveMode: false + }, + database: { + lruNumItems: 100, + backend: 'mem', + backendOptions: {} + } + }) + + const serverAddress = await new Promise((resolve, reject) => { + const unregister = sbp('okTurtles.events/once', SERVER_RUNNING, function (hapi: { info: { uri: string } }) { + resolve(hapi.info.uri) + }) + import('./index.ts').then(({ default: start }) => { + return start() + }).catch((e) => { + unregister() + reject(e) + }) + }) + + return serverAddress + } + + cachedServerAddress = internal().catch(e => { + cachedServerAddress = undefined + serverStartRefCount = 0 + throw e + }) + + return cachedServerAddress +} + +export async function stopTestServer (): Promise { + if (cachedServerAddress === undefined) { + throw new Error('Server has not yet started') + } + try { + await cachedServerAddress + } catch { + // If the server was starting and it encountered an error, this function + // technically succeeded (server is not runnign). + return + } + if (--serverStartRefCount > 0) { + return + } + await new Promise((resolve) => { + sbp('okTurtles.events/once', SERVER_EXITING, () => { + sbp('okTurtles.eventQueue/queueEvent', SERVER_EXITING, () => { + resolve() + }) + }) + sbp('okTurtles.events/emit', SERVER_EXITING) + }) + cachedServerAddress = undefined +} diff --git a/src/serve/routes-writes.test.ts b/src/serve/routes-writes.test.ts new file mode 100644 index 0000000..9ecd4de --- /dev/null +++ b/src/serve/routes-writes.test.ts @@ -0,0 +1,498 @@ +import 'jsr:@db/sqlite' +import { + blake32Hash, + buildShelterAuthHeader, + createCID, + createTestIdentity, + multicodes, + sbp, + startTestServer, + stopTestServer +} from './routes-test-helpers.ts' + +Deno.test({ + name: 'routes: write endpoints', + async fn (t: Deno.TestContext) { + const baseURL = await startTestServer() + + try { + const owner = createTestIdentity() + + await t.step('setup: register billable entity for owner', async () => { + await sbp('chelonia.db/set', owner.contractID, 'identity-contract-data') + await sbp('chelonia.db/set', `head=${owner.contractID}`, JSON.stringify({ + HEAD: createCID('owner-head', multicodes.SHELTER_CONTRACT_DATA), + previousKeyOp: null, + height: 0 + })) + }) + + await t.step('POST /dev-file with valid hash and data returns file path', async () => { + const data = 'dev-file-test-content' + const hash = createCID(data, multicodes.SHELTER_FILE_CHUNK) + const form = new FormData() + form.append('hash', hash) + form.append('data', data) + const res = await fetch(`${baseURL}/dev-file`, { + method: 'POST', + body: form + }) + const body = await res.text() + if (res.status !== 200) throw new Error(`Expected 200 but got ${res.status}: ${body}`) + if (body !== `/file/${hash}`) { + throw new Error(`Expected /file/${hash} but got ${body}`) + } + }) + + await t.step('POST /dev-file with missing hash returns 400', async () => { + const form = new FormData() + form.append('data', 'some-data') + const res = await fetch(`${baseURL}/dev-file`, { + method: 'POST', + body: form + }) + await res.body?.cancel() + if (res.status !== 400) throw new Error(`Expected 400 but got ${res.status}`) + }) + + await t.step('POST /dev-file with missing data returns 400', async () => { + const form = new FormData() + form.append('hash', createCID('test', multicodes.SHELTER_FILE_CHUNK)) + const res = await fetch(`${baseURL}/dev-file`, { + method: 'POST', + body: form + }) + await res.body?.cancel() + if (res.status !== 400) throw new Error(`Expected 400 but got ${res.status}`) + }) + + await t.step('POST /dev-file with mismatched hash returns 400', async () => { + const data = 'actual-data' + const wrongHash = createCID('different-data', multicodes.SHELTER_FILE_CHUNK) + const form = new FormData() + form.append('hash', wrongHash) + form.append('data', data) + const res = await fetch(`${baseURL}/dev-file`, { + method: 'POST', + body: form + }) + await res.body?.cancel() + if (res.status !== 400) throw new Error(`Expected 400 but got ${res.status}`) + }) + + await t.step('POST /dev-file with invalid hash returns 400', async () => { + const form = new FormData() + form.append('hash', 'not-a-valid-cid') + form.append('data', 'some-data') + const res = await fetch(`${baseURL}/dev-file`, { + method: 'POST', + body: form + }) + await res.body?.cancel() + if (res.status !== 400) throw new Error(`Expected 400 but got ${res.status}`) + }) + + await t.step('POST /dev-file stored file is retrievable via GET /file', async () => { + const data = 'retrievable-dev-file' + const hash = createCID(data, multicodes.SHELTER_FILE_MANIFEST) + const form = new FormData() + form.append('hash', hash) + form.append('data', data) + const uploadRes = await fetch(`${baseURL}/dev-file`, { + method: 'POST', + body: form + }) + if (uploadRes.status !== 200) throw new Error(`Upload failed: ${uploadRes.status}`) + await uploadRes.body?.cancel() + + const getRes = await fetch(`${baseURL}/file/${hash}`) + if (getRes.status !== 200) throw new Error(`GET failed: ${getRes.status}`) + const body = await getRes.text() + if (body !== data) throw new Error(`Expected ${data} but got ${body}`) + }) + + await t.step('GET /ownResources without auth returns 401', async () => { + const res = await fetch(`${baseURL}/ownResources`) + await res.body?.cancel() + if (res.status !== 401) throw new Error(`Expected 401 but got ${res.status}`) + }) + + await t.step('GET /ownResources with invalid auth returns 401', async () => { + const res = await fetch(`${baseURL}/ownResources`, { + headers: { authorization: 'shelter invalid-token' } + }) + await res.body?.cancel() + if (res.status !== 401) throw new Error(`Expected 401 but got ${res.status}`) + }) + + await t.step('GET /ownResources with valid auth returns empty array initially', async () => { + const auth = buildShelterAuthHeader(owner.contractID, owner.SAK) + const res = await fetch(`${baseURL}/ownResources`, { + headers: { authorization: auth } + }) + if (res.status !== 200) throw new Error(`Expected 200 but got ${res.status}`) + const body = await res.json() + if (!Array.isArray(body)) throw new Error('Expected array') + }) + + await t.step('GET /ownResources returns owned resources after seeding', async () => { + const resourceID = createCID('owned-resource-1', multicodes.SHELTER_FILE_MANIFEST) + await sbp('chelonia.db/set', `_private_owner_${resourceID}`, owner.contractID) + await sbp('chelonia.db/set', `_private_resources_${owner.contractID}`, resourceID) + + const auth = buildShelterAuthHeader(owner.contractID, owner.SAK) + const res = await fetch(`${baseURL}/ownResources`, { + headers: { authorization: auth } + }) + if (res.status !== 200) throw new Error(`Expected 200 but got ${res.status}`) + const body = await res.json() + if (!Array.isArray(body)) throw new Error('Expected array') + if (!body.includes(resourceID)) { + throw new Error(`Expected resources to include ${resourceID}`) + } + }) + + await t.step('POST /deleteFile without auth returns 401', async () => { + const hash = createCID('some-file', multicodes.SHELTER_FILE_MANIFEST) + const res = await fetch(`${baseURL}/deleteFile/${hash}`, { method: 'POST' }) + await res.body?.cancel() + if (res.status !== 401) throw new Error(`Expected 401 but got ${res.status}`) + }) + + await t.step('POST /deleteFile with wrong CID type returns 400', async () => { + const hash = createCID('test', multicodes.SHELTER_CONTRACT_DATA) + const auth = buildShelterAuthHeader(owner.contractID, owner.SAK) + const res = await fetch(`${baseURL}/deleteFile/${hash}`, { + method: 'POST', + headers: { authorization: auth } + }) + await res.body?.cancel() + if (res.status !== 400) throw new Error(`Expected 400 but got ${res.status}`) + }) + + await t.step('POST /deleteFile with nonexistent file returns 404', async () => { + const hash = createCID('nonexistent-file', multicodes.SHELTER_FILE_MANIFEST) + const auth = buildShelterAuthHeader(owner.contractID, owner.SAK) + const res = await fetch(`${baseURL}/deleteFile/${hash}`, { + method: 'POST', + headers: { authorization: auth } + }) + await res.body?.cancel() + if (res.status !== 404) throw new Error(`Expected 404 but got ${res.status}`) + }) + + await t.step('POST /deleteFile with shelter auth deletes owned file', async () => { + const chunkData = 'chunk-to-delete' + const chunkHash = createCID(chunkData, multicodes.SHELTER_FILE_CHUNK) + await sbp('chelonia.db/set', chunkHash, chunkData) + + const manifestData = JSON.stringify({ + version: '1.0.0', + cipher: 'aes256gcm', + size: chunkData.length, + chunks: [[chunkData.length, chunkHash]] + }) + const manifestHash = createCID(manifestData, multicodes.SHELTER_FILE_MANIFEST) + await sbp('chelonia.db/set', manifestHash, manifestData) + await sbp('chelonia.db/set', `_private_owner_${manifestHash}`, owner.contractID) + await sbp('chelonia.db/set', `_private_size_${manifestHash}`, String(chunkData.length + manifestData.length)) + + const auth = buildShelterAuthHeader(owner.contractID, owner.SAK) + const res = await fetch(`${baseURL}/deleteFile/${manifestHash}`, { + method: 'POST', + headers: { authorization: auth } + }) + await res.body?.cancel() + if (res.status < 200 || res.status > 204) throw new Error(`Expected 2xx but got ${res.status}`) + + const getRes = await fetch(`${baseURL}/file/${manifestHash}`) + await getRes.body?.cancel() + if (getRes.status !== 410) throw new Error(`Expected 410 after deletion but got ${getRes.status}`) + }) + + await t.step('POST /deleteFile with bearer token deletes file', async () => { + const chunkData2 = 'chunk-to-delete-bearer' + const chunkHash2 = createCID(chunkData2, multicodes.SHELTER_FILE_CHUNK) + await sbp('chelonia.db/set', chunkHash2, chunkData2) + + const manifestData2 = JSON.stringify({ + version: '1.0.0', + cipher: 'aes256gcm', + size: chunkData2.length, + chunks: [[chunkData2.length, chunkHash2]] + }) + const manifestHash2 = createCID(manifestData2, multicodes.SHELTER_FILE_MANIFEST) + await sbp('chelonia.db/set', manifestHash2, manifestData2) + await sbp('chelonia.db/set', `_private_owner_${manifestHash2}`, owner.contractID) + await sbp('chelonia.db/set', `_private_size_${manifestHash2}`, String(chunkData2.length + manifestData2.length)) + + const deletionToken = 'my-secret-deletion-token' + const tokenDigest = blake32Hash(deletionToken) + await sbp('chelonia.db/set', `_private_deletionTokenDgst_${manifestHash2}`, tokenDigest) + + const res = await fetch(`${baseURL}/deleteFile/${manifestHash2}`, { + method: 'POST', + headers: { authorization: `bearer ${deletionToken}` } + }) + await res.body?.cancel() + if (res.status < 200 || res.status > 204) throw new Error(`Expected 2xx but got ${res.status}`) + }) + + await t.step('POST /deleteFile with wrong bearer token returns 401', async () => { + const chunkData3 = 'chunk-bearer-wrong' + const chunkHash3 = createCID(chunkData3, multicodes.SHELTER_FILE_CHUNK) + await sbp('chelonia.db/set', chunkHash3, chunkData3) + + const manifestData3 = JSON.stringify({ + version: '1.0.0', + cipher: 'aes256gcm', + size: chunkData3.length, + chunks: [[chunkData3.length, chunkHash3]] + }) + const manifestHash3 = createCID(manifestData3, multicodes.SHELTER_FILE_MANIFEST) + await sbp('chelonia.db/set', manifestHash3, manifestData3) + await sbp('chelonia.db/set', `_private_owner_${manifestHash3}`, owner.contractID) + const tokenDigest3 = blake32Hash('correct-token') + await sbp('chelonia.db/set', `_private_deletionTokenDgst_${manifestHash3}`, tokenDigest3) + + const res = await fetch(`${baseURL}/deleteFile/${manifestHash3}`, { + method: 'POST', + headers: { authorization: 'bearer wrong-token' } + }) + await res.body?.cancel() + if (res.status !== 401) throw new Error(`Expected 401 but got ${res.status}`) + }) + + await t.step('POST /deleteFile with non-owner shelter auth returns 401', async () => { + const nonOwner = createTestIdentity() + const chunkData4 = 'chunk-non-owner' + const chunkHash4 = createCID(chunkData4, multicodes.SHELTER_FILE_CHUNK) + await sbp('chelonia.db/set', chunkHash4, chunkData4) + + const manifestData4 = JSON.stringify({ + version: '1.0.0', + cipher: 'aes256gcm', + size: chunkData4.length, + chunks: [[chunkData4.length, chunkHash4]] + }) + const manifestHash4 = createCID(manifestData4, multicodes.SHELTER_FILE_MANIFEST) + await sbp('chelonia.db/set', manifestHash4, manifestData4) + await sbp('chelonia.db/set', `_private_owner_${manifestHash4}`, owner.contractID) + + const auth = buildShelterAuthHeader(nonOwner.contractID, nonOwner.SAK) + const res = await fetch(`${baseURL}/deleteFile/${manifestHash4}`, { + method: 'POST', + headers: { authorization: auth } + }) + await res.body?.cancel() + if (res.status !== 401) throw new Error(`Expected 401 but got ${res.status}`) + }) + + await t.step('POST /deleteContract without auth returns 401', async () => { + const hash = createCID('some-contract', multicodes.SHELTER_CONTRACT_DATA) + const res = await fetch(`${baseURL}/deleteContract/${hash}`, { method: 'POST' }) + await res.body?.cancel() + if (res.status !== 401) throw new Error(`Expected 401 but got ${res.status}`) + }) + + await t.step('POST /deleteContract with _private prefix returns 404', async () => { + const auth = buildShelterAuthHeader(owner.contractID, owner.SAK) + const res = await fetch(`${baseURL}/deleteContract/_private_something`, { + method: 'POST', + headers: { authorization: auth } + }) + await res.body?.cancel() + if (res.status !== 404) throw new Error(`Expected 404 but got ${res.status}`) + }) + + await t.step('POST /deleteContract with nonexistent contract returns 404', async () => { + const auth = buildShelterAuthHeader(owner.contractID, owner.SAK) + const hash = createCID('nonexistent-contract', multicodes.SHELTER_CONTRACT_DATA) + const res = await fetch(`${baseURL}/deleteContract/${hash}`, { + method: 'POST', + headers: { authorization: auth } + }) + await res.body?.cancel() + if (res.status !== 404) throw new Error(`Expected 404 but got ${res.status}`) + }) + + await t.step('POST /deleteContract with bearer token and matching deletion token returns 202', async () => { + const contractData = 'deletable-contract-data' + const contractCID = createCID(contractData, multicodes.SHELTER_CONTRACT_DATA) + await sbp('chelonia.db/set', contractCID, contractData) + await sbp('chelonia.db/set', `head=${contractCID}`, JSON.stringify({ + HEAD: createCID('del-head', multicodes.SHELTER_CONTRACT_DATA), + previousKeyOp: null, + height: 0 + })) + await sbp('chelonia.db/set', `_private_owner_${contractCID}`, owner.contractID) + + const deletionToken = 'contract-deletion-token' + const tokenDigest = blake32Hash(deletionToken) + await sbp('chelonia.db/set', `_private_deletionTokenDgst_${contractCID}`, tokenDigest) + + const res = await fetch(`${baseURL}/deleteContract/${contractCID}`, { + method: 'POST', + headers: { authorization: `bearer ${deletionToken}` } + }) + const body = await res.json() + if (res.status !== 202) throw new Error(`Expected 202 but got ${res.status}`) + if (!body.id) throw new Error('Expected response to have an id field') + }) + + await t.step('POST /deleteContract with wrong bearer token returns 401', async () => { + const contractData = 'contract-wrong-bearer' + const contractCID = createCID(contractData, multicodes.SHELTER_CONTRACT_DATA) + await sbp('chelonia.db/set', contractCID, contractData) + await sbp('chelonia.db/set', `_private_owner_${contractCID}`, owner.contractID) + const tokenDigest = blake32Hash('the-right-token') + await sbp('chelonia.db/set', `_private_deletionTokenDgst_${contractCID}`, tokenDigest) + + const res = await fetch(`${baseURL}/deleteContract/${contractCID}`, { + method: 'POST', + headers: { authorization: 'bearer wrong-token' } + }) + await res.body?.cancel() + if (res.status !== 401) throw new Error(`Expected 401 but got ${res.status}`) + }) + + await t.step('POST /event with invalid payload returns 400', async () => { + const res = await fetch(`${baseURL}/event`, { + method: 'POST', + headers: { 'content-type': 'text/plain' }, + body: '' + }) + await res.body?.cancel() + if (res.status !== 400) throw new Error(`Expected 400 but got ${res.status}`) + }) + + await t.step('POST /event with non-JSON payload returns 500', async () => { + const res = await fetch(`${baseURL}/event`, { + method: 'POST', + headers: { 'content-type': 'text/plain' }, + body: 'not-json-at-all' + }) + await res.body?.cancel() + if (res.status !== 500) throw new Error(`Expected 500 but got ${res.status}`) + }) + + await t.step('POST /file without auth returns 401', async () => { + const form = new FormData() + form.append('manifest', new Blob(['{}'], { type: 'application/json' }), 'manifest.json') + const res = await fetch(`${baseURL}/file`, { + method: 'POST', + body: form + }) + await res.body?.cancel() + if (res.status !== 401) throw new Error(`Expected 401 but got ${res.status}`) + }) + + await t.step('POST /file with auth but missing manifest returns 400', async () => { + const auth = buildShelterAuthHeader(owner.contractID, owner.SAK) + const form = new FormData() + form.append('notmanifest', new Blob(['data'], { type: 'application/octet-stream' }), 'something.bin') + const res = await fetch(`${baseURL}/file`, { + method: 'POST', + headers: { authorization: auth }, + body: form + }) + await res.body?.cancel() + if (res.status !== 400) throw new Error(`Expected 400 but got ${res.status}`) + }) + + await t.step('POST /file with wrong manifest filename returns 400', async () => { + const auth = buildShelterAuthHeader(owner.contractID, owner.SAK) + const form = new FormData() + form.append('manifest', new Blob(['{}'], { type: 'application/vnd.shelter.filemanifest' }), 'wrong.json') + const res = await fetch(`${baseURL}/file`, { + method: 'POST', + headers: { authorization: auth }, + body: form + }) + await res.body?.cancel() + if (res.status !== 400) throw new Error(`Expected 400 but got ${res.status}`) + }) + + await t.step('POST /file with invalid manifest JSON returns 422', async () => { + const auth = buildShelterAuthHeader(owner.contractID, owner.SAK) + const form = new FormData() + form.append('manifest', new Blob(['not-json'], { type: 'application/vnd.shelter.filemanifest' }), 'manifest.json') + const res = await fetch(`${baseURL}/file`, { + method: 'POST', + headers: { authorization: auth }, + body: form + }) + await res.body?.cancel() + if (res.status !== 422) throw new Error(`Expected 422 but got ${res.status}`) + }) + + await t.step('POST /file with unsupported manifest version returns 422', async () => { + const auth = buildShelterAuthHeader(owner.contractID, owner.SAK) + const manifest = JSON.stringify({ version: '2.0.0', cipher: 'aes256gcm', chunks: [[1, 'z']] }) + const form = new FormData() + form.append('manifest', new Blob([manifest], { type: 'application/vnd.shelter.filemanifest' }), 'manifest.json') + const res = await fetch(`${baseURL}/file`, { + method: 'POST', + headers: { authorization: auth }, + body: form + }) + await res.body?.cancel() + if (res.status !== 422) throw new Error(`Expected 422 but got ${res.status}`) + }) + + await t.step('POST /file with valid manifest and chunk uploads file', async () => { + const auth = buildShelterAuthHeader(owner.contractID, owner.SAK) + const chunkContent = new Uint8Array([72, 101, 108, 108, 111]) + const chunkHash = createCID(chunkContent, multicodes.SHELTER_FILE_CHUNK) + const manifest = JSON.stringify({ + version: '1.0.0', + cipher: 'aes256gcm', + size: chunkContent.byteLength, + chunks: [[chunkContent.byteLength, chunkHash]] + }) + const form = new FormData() + form.append('manifest', new Blob([manifest], { type: 'application/vnd.shelter.filemanifest' }), 'manifest.json') + form.append('0', new Blob([chunkContent], { type: 'application/octet-stream' }), '0') + const res = await fetch(`${baseURL}/file`, { + method: 'POST', + headers: { authorization: auth }, + body: form + }) + const body = await res.text() + if (res.status !== 200) throw new Error(`Expected 200 but got ${res.status}: ${body}`) + const manifestHash = createCID(new TextEncoder().encode(manifest), multicodes.SHELTER_FILE_MANIFEST) + if (body !== manifestHash) { + throw new Error(`Expected manifest hash ${manifestHash} but got ${body}`) + } + + const getRes = await fetch(`${baseURL}/file/${manifestHash}`) + if (getRes.status !== 200) throw new Error(`GET file failed: ${getRes.status}`) + await getRes.body?.cancel() + }) + + await t.step('POST /file rejects duplicate upload', async () => { + const auth = buildShelterAuthHeader(owner.contractID, owner.SAK) + const chunkContent = new Uint8Array([72, 101, 108, 108, 111]) + const chunkHash = createCID(chunkContent, multicodes.SHELTER_FILE_CHUNK) + const manifest = JSON.stringify({ + version: '1.0.0', + cipher: 'aes256gcm', + size: chunkContent.byteLength, + chunks: [[chunkContent.byteLength, chunkHash]] + }) + const form = new FormData() + form.append('manifest', new Blob([manifest], { type: 'application/vnd.shelter.filemanifest' }), 'manifest.json') + form.append('0', new Blob([chunkContent], { type: 'application/octet-stream' }), '0') + const res = await fetch(`${baseURL}/file`, { + method: 'POST', + headers: { authorization: auth }, + body: form + }) + await res.body?.cancel() + if (res.status !== 500) throw new Error(`Expected 500 for duplicate but got ${res.status}`) + }) + } finally { + await stopTestServer() + } + } +}) diff --git a/src/serve/routes-zkpp.test.ts b/src/serve/routes-zkpp.test.ts new file mode 100644 index 0000000..2655dc8 --- /dev/null +++ b/src/serve/routes-zkpp.test.ts @@ -0,0 +1,275 @@ +import 'jsr:@db/sqlite' +import { Buffer } from 'node:buffer' +import { + createCID, + decryptRegistrationRedemptionToken, + multicodes, + nacl, + saltsAndEncryptedHashedPassword, + sbp, + startTestServer, + stopTestServer +} from './routes-test-helpers.ts' +import { CS } from 'npm:@chelonia/lib/zkppConstants' + +async function zkppFullFlow (baseURL: string, contentType: 'json' | 'form'): Promise { + const keyPair = nacl.box.keyPair() + const publicKey = Buffer.from(keyPair.publicKey).toString('base64url') + const publicKeyHash = Buffer.from(nacl.hash(Buffer.from(publicKey))).toString('base64url') + const hash = contentType === 'json' ? 'myhash' : 'myhash-form' + const name = contentType === 'json' ? 'zkppfullflow' : 'zkppformfull' + const suffix = contentType === 'json' ? '' : '-form' + + const contentTypeHeader = contentType === 'json' + ? 'application/json' + : 'application/x-www-form-urlencoded' + const postBody1 = contentType === 'json' + ? JSON.stringify({ b: publicKeyHash }) + : `b=${encodeURIComponent(publicKeyHash)}` + + const res1 = await fetch(`${baseURL}/zkpp/register/${name}`, { + method: 'POST', + headers: { 'content-type': contentTypeHeader }, + body: postBody1 + }) + if (res1.status !== 200) throw new Error(`Reg step 1 failed: ${res1.status}`) + const step1 = await res1.json() + + const [authSalt, , encryptedHashedPassword] = saltsAndEncryptedHashedPassword(step1.p, keyPair.secretKey, hash) + const step2Payload: Record = { + r: publicKey, s: step1.s, sig: step1.sig, Eh: encryptedHashedPassword + } + const postBody2 = contentType === 'json' + ? JSON.stringify(step2Payload) + : new URLSearchParams(step2Payload).toString() + const res2 = await fetch(`${baseURL}/zkpp/register/${name}`, { + method: 'POST', + headers: { 'content-type': contentTypeHeader }, + body: postBody2 + }) + if (res2.status !== 200) throw new Error(`Reg step 2 failed: ${res2.status}`) + const encryptedToken = await res2.text() + if (/[^\da-zA-Z_-]/.test(encryptedToken)) throw new Error('Invalid characters in encrypted token') + const token = decryptRegistrationRedemptionToken(step1.p, keyPair.secretKey, encryptedToken) + + const contractCID = createCID(`${name}-contract`, multicodes.SHELTER_CONTRACT_DATA) + await sbp('backend/db/registerName', name, contractCID) + + const { redeemSaltRegistrationToken } = await import('./zkppSalt.ts') + await redeemSaltRegistrationToken(name, contractCID, token) + + const r = `challenge-r${suffix}` + const b = Buffer.from(nacl.hash(Buffer.from(r))).toString('base64url') + const challengeRes = await fetch(`${baseURL}/zkpp/${contractCID}/auth_hash?b=${encodeURIComponent(b)}`) + if (challengeRes.status !== 200) throw new Error(`auth_hash failed: ${challengeRes.status}`) + const challenge = await challengeRes.json() + if (!challenge.authSalt) throw new Error('Expected authSalt') + if (!challenge.s) throw new Error('Expected s') + if (!challenge.sig) throw new Error('Expected sig') + if (challenge.authSalt !== authSalt) throw new Error(`authSalt mismatch: ${challenge.authSalt} !== ${authSalt}`) + + const ħ = nacl.hash(Buffer.concat([nacl.hash(Buffer.from(r)), nacl.hash(Buffer.from(challenge.s))])) + const c = nacl.hash(Buffer.concat([nacl.hash(Buffer.from(hash)), nacl.hash(ħ)])) + const hc = nacl.hash(c) + + const saltRes = await fetch( + `${baseURL}/zkpp/${contractCID}/contract_hash?` + + `r=${encodeURIComponent(r)}&s=${encodeURIComponent(challenge.s)}` + + `&sig=${encodeURIComponent(challenge.sig)}&hc=${encodeURIComponent(Buffer.from(hc).toString('base64url'))}` + ) + if (saltRes.status !== 200) throw new Error(`contract_hash failed: ${saltRes.status}`) + const encryptedSalt = await saltRes.text() + if (!encryptedSalt) throw new Error('Expected encrypted salt response') + if (/[^\da-zA-Z_-]/.test(encryptedSalt)) throw new Error('Invalid characters in encrypted salt') + + const saltBuf = Buffer.from(encryptedSalt, 'base64url') + const nonce = saltBuf.subarray(0, nacl.secretbox.nonceLength) + const encryptionKey = nacl.hash(Buffer.concat([Buffer.from(CS), c])).slice(0, nacl.secretbox.keyLength) + const decrypted = nacl.secretbox.open(saltBuf.subarray(nacl.secretbox.nonceLength), nonce, encryptionKey) + if (!decrypted) throw new Error('Failed to decrypt contract salt') + const [retrievedContractSalt] = JSON.parse(Buffer.from(decrypted).toString()) + if (!retrievedContractSalt) throw new Error('Expected non-empty contract salt') +} + +Deno.test({ + name: 'routes: ZKPP endpoints', + async fn (t: Deno.TestContext) { + const baseURL = await startTestServer() + + try { + await t.step('POST /zkpp/register step 1: returns {s, p, sig}', async () => { + const keyPair = nacl.box.keyPair() + const publicKeyHash = Buffer.from(nacl.hash(Buffer.from( + Buffer.from(keyPair.publicKey).toString('base64url') + ))).toString('base64url') + const res = await fetch(`${baseURL}/zkpp/register/zkppuser1`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ b: publicKeyHash }) + }) + if (res.status !== 200) throw new Error(`Expected 200 but got ${res.status}`) + const body = await res.json() + if (!body.s) throw new Error('Expected s in response') + if (!body.p) throw new Error('Expected p in response') + if (!body.sig) throw new Error('Expected sig in response') + }) + + await t.step('POST /zkpp/register step 2: completes registration', async () => { + const keyPair = nacl.box.keyPair() + const publicKey = Buffer.from(keyPair.publicKey).toString('base64url') + const publicKeyHash = Buffer.from(nacl.hash(Buffer.from(publicKey))).toString('base64url') + + const res1 = await fetch(`${baseURL}/zkpp/register/zkppuser2`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ b: publicKeyHash }) + }) + if (res1.status !== 200) throw new Error(`Step 1 failed: ${res1.status}`) + const step1 = await res1.json() + + const [, , encryptedHashedPassword] = saltsAndEncryptedHashedPassword(step1.p, keyPair.secretKey, 'testhash') + const res2 = await fetch(`${baseURL}/zkpp/register/zkppuser2`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + r: publicKey, + s: step1.s, + sig: step1.sig, + Eh: encryptedHashedPassword + }) + }) + if (res2.status !== 200) throw new Error(`Step 2 failed: ${res2.status}`) + const token = await res2.text() + if (!token) throw new Error('Expected registration token') + if (/[^\da-zA-Z_-]/.test(token)) throw new Error('Unexpected registration token characters') + }) + + await t.step('POST /zkpp/register with invalid name returns 400', async () => { + const res = await fetch(`${baseURL}/zkpp/register/INVALID_NAME!!`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ b: 'test' }) + }) + await res.body?.cancel() + if (res.status !== 400) throw new Error(`Expected 400 but got ${res.status}`) + }) + + await t.step('POST /zkpp/register with already registered name returns 409', async () => { + await sbp('backend/db/registerName', 'zkppregistered', createCID('zkpp-contract', multicodes.SHELTER_CONTRACT_DATA)) + const res = await fetch(`${baseURL}/zkpp/register/zkppregistered`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ b: 'test' }) + }) + await res.body?.cancel() + if (res.status !== 409) throw new Error(`Expected 409 but got ${res.status}`) + }) + + await t.step('GET /zkpp/{contractID}/auth_hash with unknown contract returns 404', async () => { + const unknownCID = createCID('zkpp-unknown', multicodes.SHELTER_CONTRACT_DATA) + const res = await fetch(`${baseURL}/zkpp/${unknownCID}/auth_hash?b=test`) + await res.body?.cancel() + if (res.status !== 404) throw new Error(`Expected 404 but got ${res.status}`) + }) + + await t.step('GET /zkpp/{contractID}/auth_hash with invalid CID returns 400', async () => { + const res = await fetch(`${baseURL}/zkpp/not-a-cid/auth_hash?b=test`) + await res.body?.cancel() + if (res.status !== 400) throw new Error(`Expected 400 but got ${res.status}`) + }) + + await t.step('GET /zkpp/{contractID}/auth_hash with missing b param returns 400', async () => { + const cid = createCID('zkpp-test', multicodes.SHELTER_CONTRACT_DATA) + const res = await fetch(`${baseURL}/zkpp/${cid}/auth_hash`) + await res.body?.cancel() + if (res.status !== 400) throw new Error(`Expected 400 but got ${res.status}`) + }) + + await t.step('GET /zkpp/{contractID}/contract_hash with missing params returns 400', async () => { + const cid = createCID('zkpp-test', multicodes.SHELTER_CONTRACT_DATA) + const res = await fetch(`${baseURL}/zkpp/${cid}/contract_hash?r=a&s=b`) + await res.body?.cancel() + if (res.status !== 400) throw new Error(`Expected 400 but got ${res.status}`) + }) + + await t.step('POST /zkpp/{contractID}/updatePasswordHash with missing params returns 400', async () => { + const cid = createCID('zkpp-test', multicodes.SHELTER_CONTRACT_DATA) + const res = await fetch(`${baseURL}/zkpp/${cid}/updatePasswordHash`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ r: 'a', s: 'b' }) + }) + await res.body?.cancel() + if (res.status !== 400) throw new Error(`Expected 400 but got ${res.status}`) + }) + + await t.step('ZKPP full flow: register, challenge, get contract salt', async () => { + await zkppFullFlow(baseURL, 'json') + }) + + await t.step('POST /zkpp/register step 1 with form-urlencoded body', async () => { + const keyPair = nacl.box.keyPair() + const publicKeyHash = Buffer.from(nacl.hash(Buffer.from( + Buffer.from(keyPair.publicKey).toString('base64url') + ))).toString('base64url') + const res = await fetch(`${baseURL}/zkpp/register/zkppformuser1`, { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: `b=${encodeURIComponent(publicKeyHash)}` + }) + if (res.status !== 200) throw new Error(`Expected 200 but got ${res.status}`) + const body = await res.json() + if (!body.s) throw new Error('Expected s in response') + if (!body.p) throw new Error('Expected p in response') + if (!body.sig) throw new Error('Expected sig in response') + }) + + await t.step('POST /zkpp/register step 2 with form-urlencoded body', async () => { + const keyPair = nacl.box.keyPair() + const publicKey = Buffer.from(keyPair.publicKey).toString('base64url') + const publicKeyHash = Buffer.from(nacl.hash(Buffer.from(publicKey))).toString('base64url') + + const res1 = await fetch(`${baseURL}/zkpp/register/zkppformuser2`, { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: `b=${encodeURIComponent(publicKeyHash)}` + }) + if (res1.status !== 200) throw new Error(`Step 1 failed: ${res1.status}`) + const step1 = await res1.json() + + const [, , encryptedHashedPassword] = saltsAndEncryptedHashedPassword(step1.p, keyPair.secretKey, 'testhash') + const res2 = await fetch(`${baseURL}/zkpp/register/zkppformuser2`, { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + r: publicKey, + s: step1.s, + sig: step1.sig, + Eh: encryptedHashedPassword + }).toString() + }) + if (res2.status !== 200) throw new Error(`Step 2 failed: ${res2.status}`) + const token = await res2.text() + if (!token) throw new Error('Expected registration token') + if (/[^\da-zA-Z_-]/.test(token)) throw new Error('Unexpected characters in registration token') + }) + + await t.step('POST /zkpp/{contractID}/updatePasswordHash with form-urlencoded body returns 400 for missing params', async () => { + const cid = createCID('zkpp-form-test', multicodes.SHELTER_CONTRACT_DATA) + const res = await fetch(`${baseURL}/zkpp/${cid}/updatePasswordHash`, { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ r: 'a', s: 'b' }).toString() + }) + await res.body?.cancel() + if (res.status !== 400) throw new Error(`Expected 400 but got ${res.status}`) + }) + + await t.step('ZKPP full flow with form-urlencoded registration', async () => { + await zkppFullFlow(baseURL, 'form') + }) + } finally { + await stopTestServer() + } + } +}) diff --git a/src/serve/routes.ts b/src/serve/routes.ts index 6579605..0311bbd 100644 --- a/src/serve/routes.ts +++ b/src/serve/routes.ts @@ -66,6 +66,21 @@ const limiterPerDay = new Bottleneck.Group({ reservoirRefreshAmount: SIGNUP_LIMIT_DAY }) +sbp('sbp/selectors/register', { + 'backend/server/stopRateLimiters': async function () { + await Promise.allSettled([ + limiterPerMinute.disconnect(), + limiterPerHour.disconnect(), + limiterPerDay.disconnect() + ]) + // The following needs to be done using a private API because `disconnect()` + // isn't enough. + clearInterval((limiterPerMinute as unknown as { interval: ReturnType }).interval) + clearInterval((limiterPerHour as unknown as { interval: ReturnType }).interval) + clearInterval((limiterPerDay as unknown as { interval: ReturnType }).interval) + } +}) + const cidLookupTable = { [multicodes.SHELTER_CONTRACT_MANIFEST]: 'application/vnd.shelter.contractmanifest+json', [multicodes.SHELTER_CONTRACT_TEXT]: 'application/vnd.shelter.contracttext', diff --git a/src/serve/server.ts b/src/serve/server.ts index b46e64d..98319b4 100644 --- a/src/serve/server.ts +++ b/src/serve/server.ts @@ -10,9 +10,9 @@ import * as Hapi from 'npm:@hapi/hapi' import Inert from 'npm:@hapi/inert' import sbp from 'npm:@sbp/sbp' import chalk from 'npm:chalk' +import type { ImportMeta } from '../types/build.d.ts' import createWorker from './createWorker.ts' // import type { SubMessage, UnsubMessage } from './pubsub.ts' // TODO: Use for type checking -import { join } from 'node:path' import process from 'node:process' import authPlugin from './auth.ts' import { CREDITS_WORKER_TASK_TIME_INTERVAL, OWNER_SIZE_TOTAL_WORKER_TASK_TIME_INTERVAL } from './constants.ts' @@ -35,6 +35,7 @@ import { addChannelToSubscription, deleteChannelFromSubscription, postEvent, pus import nconf from 'npm:nconf' const ARCHIVE_MODE = nconf.get('server:archiveMode') +let pushHeartbeatIntervalID: ReturnType if (CREDITS_WORKER_TASK_TIME_INTERVAL && OWNER_SIZE_TOTAL_WORKER_TASK_TIME_INTERVAL > CREDITS_WORKER_TASK_TIME_INTERVAL) { process.stderr.write('The size calculation worker must run more frequently than the credits worker for accurate billing') @@ -44,10 +45,10 @@ if (CREDITS_WORKER_TASK_TIME_INTERVAL && OWNER_SIZE_TOTAL_WORKER_TASK_TIME_INTER // Initialize workers for size calculation and credits processing const ownerSizeTotalWorker = ARCHIVE_MODE || !OWNER_SIZE_TOTAL_WORKER_TASK_TIME_INTERVAL ? undefined - : createWorker(join(import.meta.dirname || '.', import.meta.workerDir || '.', 'ownerSizeTotalWorker.js')) + : createWorker((import.meta as ImportMeta).ownerSizeTotalWorker || './ownerSizeTotalWorker.ts') const creditsWorker = ARCHIVE_MODE || !CREDITS_WORKER_TASK_TIME_INTERVAL ? undefined - : createWorker(join(import.meta.dirname || '.', import.meta.workerDir || '.', 'creditsWorker.js')) + : createWorker((import.meta as ImportMeta).creditsWorker || './creditsWorker.ts') const { CONTRACTS_VERSION, GI_VERSION } = process.env @@ -246,8 +247,16 @@ sbp('sbp/selectors/register', { const sizeKey = `_private_contractFilesTotalSize_${resourceID}` return updateSize(resourceID, sizeKey, size, true) }, - 'backend/server/stop': function () { - return hapi.stop() + 'backend/server/stop': async function () { + clearInterval(pushHeartbeatIntervalID) + if (sbp('sbp/selectors/fn', 'backend/server/stopRateLimiters')) { + await sbp('backend/server/stopRateLimiters') + } + await hapi.stop() + await Promise.all([ + ownerSizeTotalWorker?.terminate(), + creditsWorker?.terminate() + ]) }, async 'backend/deleteFile' (cid: string, ultimateOwnerID: string | null | undefined, skipIfDeleted: boolean | null | undefined): Promise { const owner = await sbp('chelonia.db/get', `_private_owner_${cid}`) @@ -584,7 +593,7 @@ sbp('okTurtles.data/set', PUBSUB_INSTANCE, createServer(hapi.listener, { ;(() => { const map = new WeakMap() - setInterval(() => { + pushHeartbeatIntervalID = setInterval(() => { const now = Date.now() const pubsub = sbp('okTurtles.data/get', PUBSUB_INSTANCE) as WSS | undefined // Notification text diff --git a/src/types/build.d.ts b/src/types/build.d.ts index e04f91e..a195654 100644 --- a/src/types/build.d.ts +++ b/src/types/build.d.ts @@ -1,6 +1,7 @@ interface ImportMeta { - VERSION: string - workerDir?: string + VERSION: string, + ownerSizeTotalWorker?: string, + creditsWorker?: string } declare const logger: {