Skip to content
Merged
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
110 changes: 72 additions & 38 deletions build/main.js

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion scripts/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"',
Comment thread
corrideat marked this conversation as resolved.
Comment thread
corrideat marked this conversation as resolved.
},
format: 'esm',
platform: 'node',
Expand Down
17 changes: 15 additions & 2 deletions src/serve/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ sbp('okTurtles.events/once', SERVER_EXITING, () => {
return new Promise<void>((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) {
Expand Down Expand Up @@ -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 <https://tldp.org/LDP/abs/html/exitcodes.html>
exit(128 + code)
})
}
signalHandlers.push([signal, handler])
process.on(signal, handler)
}

// Codes from <signal.h>
Expand All @@ -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
Comment thread
taoeffect marked this conversation as resolved.
}
3 changes: 2 additions & 1 deletion src/serve/pubsub.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,7 @@ export interface WSS extends Omit<WebSocketServer, 'clients' | 'options'> {
customServerEventHandlers: Partial<ServerHandlers>;
customSocketEventHandlers: Partial<SocketHandlers>;
customMessageHandlers: Partial<MessageHandlers>;
pingIntervalID?: ReturnType<typeof setTimeout>
pingIntervalID?: ReturnType<typeof setInterval>
subscribersByChannelID: Record<string, Set<WS | PushSubscriptionInfo>>
pushSubscriptions: Record<string, PushSubscriptionInfo>;
options: ServerOptions
Expand Down Expand Up @@ -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')
},
/**
Expand Down
225 changes: 225 additions & 0 deletions src/serve/routes-kv.test.ts
Original file line number Diff line number Diff line change
@@ -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()
}
}
})
Loading
Loading