From 962b519eff2382c7bce96e7c564016ac83f95b87 Mon Sep 17 00:00:00 2001 From: Mustafa Zaidi <201567061+hedge-mustafa@users.noreply.github.com> Date: Tue, 3 Nov 2026 11:37:03 +0500 Subject: [PATCH 1/2] @halvorai/ingest 0.4.0: device helpers for Flutter apps Apps built with halvorai_ingest_flutter sign events on the phone and never hold an API key; the customer's backend registers each install's key and forwards its batches. @halvorai/ingest/device provides that half: - registerDeviceSource: registers an install's public Ed25519 key with POST /v1/sources; a key with d is refused. - forwardEnvelopes: checks each event's owner against the signed-in user and its environment against the key, then passes the body to POST /v1/events byte for byte and returns HalvorAI's answer unchanged. Refusals use HalvorAI's error format; 400/413/422 are permanent for the app, 502 means retry. - deviceRoutes: both as Fetch API handlers (Next.js, Hono); README has Next.js, Hono and Express examples. init now registers its source through the same code (src/sources.ts). Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 13 +++ README.md | 60 ++++++++++ package-lock.json | 4 +- package.json | 6 +- scripts/smoke.mjs | 2 +- src/cli/init.ts | 35 +----- src/device/config.ts | 65 +++++++++++ src/device/device.test.ts | 226 ++++++++++++++++++++++++++++++++++++++ src/device/forward.ts | 132 ++++++++++++++++++++++ src/device/handlers.ts | 69 ++++++++++++ src/device/index.ts | 10 ++ src/device/register.ts | 68 ++++++++++++ src/sources.ts | 60 ++++++++++ tsup.config.ts | 3 +- 14 files changed, 715 insertions(+), 38 deletions(-) create mode 100644 src/device/config.ts create mode 100644 src/device/device.test.ts create mode 100644 src/device/forward.ts create mode 100644 src/device/handlers.ts create mode 100644 src/device/index.ts create mode 100644 src/device/register.ts create mode 100644 src/sources.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index a47a51e..9394419 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## 0.4.0 + +### Added + +- `@halvorai/ingest/device`, the backend half of mobile apps built with `halvorai_ingest_flutter`, whose installs sign their own events: + - `registerDeviceSource` registers an install's public Ed25519 key (a key with `d` is refused). + - `forwardEnvelopes` checks each event's owner and environment, then passes the batch to `POST /v1/events` byte for byte and returns HalvorAI's answer unchanged. + - `deviceRoutes` provides both as Fetch API handlers for Next.js, Hono and similar. + +### Changed + +- `init` registers its source through the same code, and a network failure there now says `POST /v1/sources failed: `. + ## 0.3.4 ### Added diff --git a/README.md b/README.md index 9e5b2c3..3eed09c 100644 --- a/README.md +++ b/README.md @@ -132,6 +132,66 @@ await stripe.issuing.cards.create( Guides for each: [docs.halvorai.com](https://docs.halvorai.com). +## Mobile apps (Flutter) + +Apps built with [`halvorai_ingest_flutter`](https://github.com/halvorAI/ingest-dart) sign events on the phone, with a key that each install makes for itself. The app never holds your API key: your backend registers each install's public key and forwards its signed batches. `@halvorai/ingest/device` provides both routes: + +```ts +import { deviceRoutes } from '@halvorai/ingest/device'; + +const halvor = deviceRoutes({ + apiKey: process.env.HALVOR_API_KEY, + // Your own session check: the signed-in user's owner id, or null (answers 401). + ownerId: async (request) => (await getSession(request))?.userId ?? null, +}); +``` + +The app's `provisionUrl` goes to `halvor.register` and its `forwardUrl` to `halvor.forward`. Both take a Fetch `Request` and return a `Response`: + +```ts +// Next.js (app router), app/halvor/sources/route.ts +export const POST = (request: Request) => halvor.register(request); +// Next.js (app router), app/halvor/events/route.ts +export const POST = (request: Request) => halvor.forward(request); + +// Hono +app.post('/halvor/sources', (c) => halvor.register(c.req.raw)); +app.post('/halvor/events', (c) => halvor.forward(c.req.raw)); + +// Express: pass the raw body through, never a parsed and re-serialized one. +import { forwardEnvelopes, registerDeviceSource, DeviceRequestError } from '@halvorai/ingest/device'; + +app.post('/halvor/events', express.raw({ type: 'application/json', limit: '1mb' }), async (req, res) => { + if (!req.user) return res.status(401).json({ error: { code: 'unauthorized', message: 'sign in first' } }); + const out = await forwardEnvelopes(req.body, { expectedOwnerId: req.user.id }); + res.status(out.status).type(out.contentType).send(out.body); +}); +app.post('/halvor/sources', express.json(), async (req, res) => { + if (!req.user) return res.status(401).json({ error: { code: 'unauthorized', message: 'sign in first' } }); + try { + const { sourceId } = await registerDeviceSource({ publicJwk: req.body.public_jwk, name: req.body.name }); + res.json({ source_id: sourceId }); + } catch (err) { + if (err instanceof DeviceRequestError) return res.status(err.status).json(err); + throw err; + } +}); +``` + +What the routes do: + +- **`register`** accepts only the public half of an Ed25519 key, refusing a key with `d`. It registers the key with `POST /v1/sources` and answers `{"source_id": "src_…"}`. +- **`forward`**: + - Checks that every event's `owner.id` is the signed-in user and its `environment` is the key's. + - Sends the body to `POST /v1/events` **byte for byte**, so the device's signatures stay valid. + - Answers with HalvorAI's status and body unchanged, which the app reads its per-event results from. +- **Status codes tell the app what to do:** + - 401 or 403 means "try later": the app keeps the batch on disk and retries. + - 400, 413 and 422 are permanent: the app drops the batch, or splits it on 413. + - 502 means HalvorAI couldn't be reached, and the app retries. + +Set the signed-in user as `owner` in the app's agent context; events without it are refused. + ## Verify an evidence pack Banks can check a HalvorAI evidence pack without trusting us: diff --git a/package-lock.json b/package-lock.json index 0cff733..7e15e34 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@halvorai/ingest", - "version": "0.3.4", + "version": "0.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@halvorai/ingest", - "version": "0.3.4", + "version": "0.4.0", "license": "Apache-2.0", "dependencies": { "fflate": "^0.8.2", diff --git a/package.json b/package.json index 29c30dd..e25973e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@halvorai/ingest", - "version": "0.3.4", + "version": "0.4.0", "description": "Signed, attributed payment events for AI agents. Never in the payment path.", "type": "module", "author": "HalvorAI ", @@ -54,6 +54,10 @@ "types": "./dist/mcp.d.ts", "default": "./dist/mcp.js" }, + "./device": { + "types": "./dist/device.d.ts", + "default": "./dist/device.js" + }, "./evidence": { "types": "./dist/evidence.d.ts", "default": "./dist/evidence.js" diff --git a/scripts/smoke.mjs b/scripts/smoke.mjs index 804b0ff..9188a23 100644 --- a/scripts/smoke.mjs +++ b/scripts/smoke.mjs @@ -22,7 +22,7 @@ try { import assert from 'node:assert/strict'; import { generateKeyPair, exportJWK } from 'jose'; import * as core from '@halvorai/ingest'; -for (const p of ['cli', 'stripe', 'x402', 'mpp', 'mcp', 'evidence']) await import('@halvorai/ingest/' + p); +for (const p of ['cli', 'stripe', 'x402', 'mpp', 'mcp', 'evidence', 'device']) await import('@halvorai/ingest/' + p); assert.equal(core.name, '@halvorai/ingest'); const { privateKey } = await generateKeyPair('EdDSA', { crv: 'Ed25519', extractable: true }); diff --git a/src/cli/init.ts b/src/cli/init.ts index 3d3f71a..628f2a7 100644 --- a/src/cli/init.ts +++ b/src/cli/init.ts @@ -3,6 +3,7 @@ import { hostname } from 'node:os'; import { join, resolve } from 'node:path'; import { generateSourceKeyPair, parseApiKey, type JWK } from '@halvorai/schema'; import { HalvorClient } from '../client.js'; +import { postSource } from '../sources.js'; const DASHBOARD_URL = 'https://app.halvorai.com'; const SANDBOX_DASHBOARD_URL = 'https://app.sandbox.halvorai.com'; @@ -82,38 +83,6 @@ export function upsertEnvFile(filePath: string, vars: Record): v writeFileSync(filePath, content, 'utf8'); } -async function registerSource( - fetchImpl: typeof fetch, - endpoint: string, - apiKey: string, - name: string, - publicJwk: JWK, -): Promise { - const url = `${endpoint.replace(/\/+$/, '')}/v1/sources`; - const res = await fetchImpl(url, { - method: 'POST', - headers: { - authorization: `Bearer ${apiKey}`, - 'content-type': 'application/json', - accept: 'application/json', - }, - body: JSON.stringify({ - name, - adapter: 'custom', - public_jwk: publicJwk, - }), - }); - if (res.status !== 201) { - const text = await res.text().catch(() => ''); - throw new Error(`POST /v1/sources failed (${res.status}): ${text || res.statusText}`); - } - const body = (await res.json()) as { id?: string }; - if (typeof body.id !== 'string' || !body.id.startsWith('src_')) { - throw new Error('POST /v1/sources returned no source id'); - } - return body.id; -} - /** * Non-interactive init used by the CLI and CI. Generates a source key, registers it, * writes `.env.local`, and sends one test event. @@ -135,7 +104,7 @@ export async function runInit(options: InitOptions): Promise { const pair = await generateSourceKeyPair(); log(`Registering source at ${endpoint}…`); - const sourceId = await registerSource(fetchImpl, endpoint, options.apiKey, name, pair.publicJwk); + const sourceId = await postSource(fetchImpl, endpoint, options.apiKey, name, pair.publicJwk); const privateJwk: JWK = { ...pair.privateJwk, kid: sourceId }; const envPath = join(cwd, '.env.local'); diff --git a/src/device/config.ts b/src/device/config.ts new file mode 100644 index 0000000..220b9d9 --- /dev/null +++ b/src/device/config.ts @@ -0,0 +1,65 @@ +import { parseApiKey, type KeyEnv } from '@halvorai/schema'; + +/** Where the helpers send what an app install hands your backend. */ +export interface DeviceConfig { + /** Your HalvorAI API key. Default: `HALVOR_API_KEY`. Never send it to the app. */ + apiKey?: string; + /** API base URL. Default: `HALVOR_ENDPOINT`, else sandbox for test keys, production for live. */ + endpoint?: string; + /** Injected fetch, for tests. */ + fetch?: typeof fetch; +} + +export interface ResolvedDeviceConfig { + apiKey: string; + environment: KeyEnv; + endpoint: string; + fetch: typeof fetch; +} + +/** + * A request the app sent that your backend must answer with [status]. The body follows HalvorAI's + * error format, so the Flutter SDK reports [code] as `serverCode`. + * + * The status tells the app what to do: 400, 413 and 422 are permanent (the app drops the batch, + * or splits it on 413), 502 means try again later. + */ +export class DeviceRequestError extends Error { + override readonly name = 'DeviceRequestError'; + + constructor( + readonly status: number, + readonly code: string, + message: string, + ) { + super(message); + } + + /** `{"error":{"code","message"}}`, as HalvorAI answers errors. */ + toJSON(): { error: { code: string; message: string } } { + return { error: { code: this.code, message: this.message } }; + } +} + +function env(name: string): string | undefined { + return typeof process !== 'undefined' ? process.env?.[name] : undefined; +} + +/** A misconfigured backend is a programming error, so this throws a plain Error. */ +export function resolveDeviceConfig(config: DeviceConfig): ResolvedDeviceConfig { + const apiKey = config.apiKey ?? env('HALVOR_API_KEY'); + const parsed = typeof apiKey === 'string' ? parseApiKey(apiKey) : null; + if (!apiKey || !parsed) { + throw new Error('HalvorAI api key is missing or malformed (expected hk_test_… or hk_live_…)'); + } + const endpoint = + config.endpoint ?? + env('HALVOR_ENDPOINT') ?? + (parsed.env === 'live' ? 'https://api.halvorai.com' : 'https://api.sandbox.halvorai.com'); + return { + apiKey, + environment: parsed.env, + endpoint: endpoint.replace(/\/+$/, '').replace(/\/v1\/events$/, ''), + fetch: config.fetch ?? globalThis.fetch.bind(globalThis), + }; +} diff --git a/src/device/device.test.ts b/src/device/device.test.ts new file mode 100644 index 0000000..72837c1 --- /dev/null +++ b/src/device/device.test.ts @@ -0,0 +1,226 @@ +import { generateApiKey, generateSourceKeyPair, type JWK } from '@halvorai/schema'; +import { beforeAll, describe, expect, it } from 'vitest'; +import { deviceRoutes, DeviceRequestError, forwardEnvelopes, registerDeviceSource } from './index.js'; + +const apiKey = generateApiKey('live'); +const endpoint = 'https://api.example'; +let publicJwk: JWK; + +beforeAll(async () => { + const pair = await generateSourceKeyPair(); + publicJwk = pair.publicJwk; +}); + +interface Call { + url: string; + authorization: string | null; + body: Uint8Array; +} + +/** A fake HalvorAI that records requests and answers with [respond]. */ +function fakeApi(respond: (call: Call) => Response) { + const calls: Call[] = []; + const fetchImpl: typeof fetch = async (input, init) => { + const call: Call = { + url: String(input), + authorization: new Headers(init?.headers).get('authorization'), + body: new Uint8Array(await new Response(init?.body).arrayBuffer()), + }; + calls.push(call); + return respond(call); + }; + return { calls, fetch: fetchImpl }; +} + +const accepted = (call: Call): Response => { + const { events } = JSON.parse(new TextDecoder().decode(call.body)) as { + events: { event: { id: string } }[]; + }; + return Response.json({ results: events.map((e) => ({ id: e.event.id, status: 'accepted' })) }); +}; + +function envelope(over: Record = {}) { + return { + event: { id: 'evt_01JA8Q4K3T9V2C7M5N6P8R0S1W', environment: 'live', owner: { id: 'own_u_1', type: 'person' }, ...over }, + sig: 'eyJhbGciOiJFZERTQSJ9..c2ln', + }; +} + +describe('registerDeviceSource', () => { + it('registers only the public half and returns the source id', async () => { + const api = fakeApi(() => Response.json({ id: 'src_device_1' }, { status: 201 })); + const { sourceId } = await registerDeviceSource({ + apiKey, + endpoint, + fetch: api.fetch, + publicJwk: { ...publicJwk, kid: 'src_pending', alg: 'EdDSA' }, + name: 'iOS app', + }); + + expect(sourceId).toBe('src_device_1'); + expect(api.calls[0]?.url).toBe('https://api.example/v1/sources'); + expect(api.calls[0]?.authorization).toBe(`Bearer ${apiKey}`); + const body = JSON.parse(new TextDecoder().decode(api.calls[0]?.body)) as Record; + expect(body).toEqual({ + name: 'iOS app', + adapter: 'custom', + public_jwk: { kty: 'OKP', crv: 'Ed25519', x: publicJwk.x }, + }); + }); + + it.each([ + ['a private key', () => ({ ...publicJwk, d: 'secret' }), 'private key'], + ['another curve', () => ({ ...publicJwk, crv: 'P-256' }), 'Ed25519'], + ['a short x', () => ({ ...publicJwk, x: 'abc' }), '32 bytes'], + ['not an object', () => 'key', 'JSON object'], + ])('refuses %s with 400 and never calls HalvorAI', async (_label, jwk, message) => { + const api = fakeApi(() => Response.json({ id: 'src_x' }, { status: 201 })); + const err = await registerDeviceSource({ apiKey, endpoint, fetch: api.fetch, publicJwk: jwk() }).catch( + (e: unknown) => e, + ); + expect(err).toBeInstanceOf(DeviceRequestError); + expect((err as DeviceRequestError).status).toBe(400); + expect((err as DeviceRequestError).message).toContain(message); + expect(api.calls).toHaveLength(0); + }); + + it('answers 502 when HalvorAI does not register the key, so the app retries', async () => { + const api = fakeApi(() => new Response('down', { status: 503 })); + const err = await registerDeviceSource({ apiKey, endpoint, fetch: api.fetch, publicJwk }).catch( + (e: unknown) => e, + ); + expect((err as DeviceRequestError).status).toBe(502); + expect((err as DeviceRequestError).code).toBe('upstream_error'); + }); + + it('refuses to start without a well-formed API key', async () => { + await expect(registerDeviceSource({ apiKey: 'hk_nope', publicJwk })).rejects.toThrow('api key'); + }); +}); + +describe('forwardEnvelopes', () => { + it('passes the body to HalvorAI byte for byte and returns its answer unchanged', async () => { + const api = fakeApi(accepted); + // Unusual spacing and key order: re-serializing would change these bytes. + const raw = `{ "events" : [ ${JSON.stringify(envelope())} ] , "extra": 1 }`; + const result = await forwardEnvelopes(raw, { + apiKey, + endpoint, + fetch: api.fetch, + expectedOwnerId: 'own_u_1', + }); + + expect(api.calls[0]?.url).toBe('https://api.example/v1/events'); + expect(api.calls[0]?.authorization).toBe(`Bearer ${apiKey}`); + expect(new TextDecoder().decode(api.calls[0]?.body)).toBe(raw); + expect(result.status).toBe(200); + expect(JSON.parse(result.body)).toEqual({ + results: [{ id: 'evt_01JA8Q4K3T9V2C7M5N6P8R0S1W', status: 'accepted' }], + }); + }); + + it('returns HalvorAI errors unchanged, such as a per-event rejection or a 503', async () => { + const api = fakeApi(() => new Response('{"error":{"code":"unavailable"}}', { status: 503 })); + const result = await forwardEnvelopes(JSON.stringify({ events: [envelope()] }), { + apiKey, + endpoint, + fetch: api.fetch, + expectedOwnerId: 'own_u_1', + }); + expect(result.status).toBe(503); + expect(result.body).toBe('{"error":{"code":"unavailable"}}'); + }); + + it.each([ + ['another user as owner', { events: [envelope({ owner: { id: 'own_someone_else', type: 'person' } })] }, 422, 'owner_mismatch'], + ['no owner', { events: [envelope({ owner: undefined })] }, 422, 'owner_mismatch'], + ['the other environment', { events: [envelope({ environment: 'test' })] }, 422, 'environment_mismatch'], + ['no events', { events: [] }, 400, 'invalid_request'], + ['an envelope without sig', { events: [{ event: envelope().event }] }, 400, 'invalid_request'], + ['too many envelopes', { events: Array.from({ length: 101 }, () => envelope()) }, 413, 'payload_too_large'], + ])('refuses %s without calling HalvorAI', async (_label, body, status, code) => { + const api = fakeApi(accepted); + const result = await forwardEnvelopes(JSON.stringify(body), { + apiKey, + endpoint, + fetch: api.fetch, + expectedOwnerId: 'own_u_1', + }); + expect(result.status).toBe(status); + expect((JSON.parse(result.body) as { error: { code: string } }).error.code).toBe(code); + expect(api.calls).toHaveLength(0); + }); + + it('refuses a body that is not JSON, or too large', async () => { + const api = fakeApi(accepted); + const options = { apiKey, endpoint, fetch: api.fetch, expectedOwnerId: 'own_u_1' }; + expect((await forwardEnvelopes('not json', options)).status).toBe(400); + expect((await forwardEnvelopes('x'.repeat(1_000_001), options)).status).toBe(413); + expect(api.calls).toHaveLength(0); + }); + + it('answers 502 when HalvorAI cannot be reached, so the app retries', async () => { + const result = await forwardEnvelopes(JSON.stringify({ events: [envelope()] }), { + apiKey, + endpoint, + fetch: () => Promise.reject(new Error('ECONNRESET')), + expectedOwnerId: 'own_u_1', + }); + expect(result.status).toBe(502); + expect(result.body).toContain('upstream_error'); + }); +}); + +describe('deviceRoutes', () => { + const routes = (fetchImpl: typeof fetch, ownerId: string | null = 'own_u_1') => + deviceRoutes({ apiKey, endpoint, fetch: fetchImpl, ownerId: () => ownerId }); + + it('register answers {source_id} for a signed-in user', async () => { + const api = fakeApi(() => Response.json({ id: 'src_device_7' }, { status: 201 })); + const res = await routes(api.fetch).register( + new Request('https://app.example/halvor/sources', { + method: 'POST', + body: JSON.stringify({ public_jwk: publicJwk, name: 'Android app' }), + }), + ); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ source_id: 'src_device_7' }); + }); + + it('register refuses a private key with 400 in HalvorAI error format', async () => { + const api = fakeApi(() => Response.json({ id: 'src_x' }, { status: 201 })); + const res = await routes(api.fetch).register( + new Request('https://app.example/halvor/sources', { + method: 'POST', + body: JSON.stringify({ public_jwk: { ...publicJwk, d: 'secret' } }), + }), + ); + expect(res.status).toBe(400); + expect(((await res.json()) as { error: { code: string } }).error.code).toBe('invalid_request'); + }); + + it('forward checks the owner against the session and relays HalvorAI', async () => { + const api = fakeApi(accepted); + const res = await routes(api.fetch).forward( + new Request('https://app.example/halvor/events', { + method: 'POST', + body: JSON.stringify({ events: [envelope()] }), + }), + ); + expect(res.status).toBe(200); + expect(api.calls).toHaveLength(1); + }); + + it('both routes answer 401 without a signed-in user, which the app retries later', async () => { + const api = fakeApi(accepted); + const r = routes(api.fetch, null); + const post = (path: string) => new Request(`https://app.example${path}`, { method: 'POST', body: '{}' }); + expect((await r.register(post('/halvor/sources'))).status).toBe(401); + expect((await r.forward(post('/halvor/events'))).status).toBe(401); + expect(api.calls).toHaveLength(0); + }); + + it('fails at setup, not on the first request, without an API key', () => { + expect(() => deviceRoutes({ apiKey: '', ownerId: () => 'x' })).toThrow('api key'); + }); +}); diff --git a/src/device/forward.ts b/src/device/forward.ts new file mode 100644 index 0000000..976e095 --- /dev/null +++ b/src/device/forward.ts @@ -0,0 +1,132 @@ +import { DeviceRequestError, resolveDeviceConfig, type DeviceConfig } from './config.js'; + +export interface ForwardEnvelopesOptions extends DeviceConfig { + /** + * The signed-in user's owner id. Every event must name it as `owner.id`, so one user cannot + * send events that claim to be another. Set it in the app's agent context. + */ + expectedOwnerId: string; + /** Most envelopes per request (default 100, as every SDK batches). More gets 413. */ + maxEnvelopes?: number; + /** Most bytes per request (default 1 MB, HalvorAI's own limit). More gets 413. */ + maxBytes?: number; +} + +/** What to answer the app: HalvorAI's response unchanged, or the reason it was not sent. */ +export interface ForwardResult { + status: number; + body: string; + contentType: string; +} + +const DEFAULT_MAX_ENVELOPES = 100; +const DEFAULT_MAX_BYTES = 1_000_000; + +/** + * Checks a batch an app install sent and passes it to HalvorAI's `POST /v1/events` **byte for + * byte**, then returns HalvorAI's status and body unchanged. The app reads its per-event results + * from them. The body is parsed only to check it; re-serializing it could change the bytes the + * device signed. + * + * Refused without calling HalvorAI, in HalvorAI's error format: + * - 400 `invalid_request`: not `{"events":[{"event":{…},"sig":"…"}, …]}`. + * - 413 `payload_too_large`: more than [maxEnvelopes] or [maxBytes]; the app splits the batch. + * - 422 `owner_mismatch`: an event's `owner.id` is not [expectedOwnerId]. + * - 422 `environment_mismatch`: an event's `environment` is not the API key's. + * + * These are permanent, so the app drops the batch. If HalvorAI cannot be reached the answer is + * 502, which the app retries. Answer 401 or 403 yourself when the user's session is not valid: + * the app keeps the batch and tries again later. + */ +export async function forwardEnvelopes( + body: string | Uint8Array | ArrayBuffer, + options: ForwardEnvelopesOptions, +): Promise { + const config = resolveDeviceConfig(options); + const bytes = + typeof body === 'string' + ? new TextEncoder().encode(body) + : body instanceof Uint8Array + ? body + : new Uint8Array(body); + try { + checkBatch(bytes, options, config.environment); + } catch (err) { + if (err instanceof DeviceRequestError) return errorResult(err); + throw err; + } + + let res: Response; + try { + res = await config.fetch(`${config.endpoint}/v1/events`, { + method: 'POST', + headers: { + authorization: `Bearer ${config.apiKey}`, + 'content-type': 'application/json', + accept: 'application/json', + }, + body: bytes, + }); + } catch (err) { + return errorResult( + new DeviceRequestError( + 502, + 'upstream_error', + `HalvorAI could not be reached: ${err instanceof Error ? err.message : 'network error'}`, + ), + ); + } + return { + status: res.status, + body: await res.text(), + contentType: res.headers.get('content-type') ?? 'application/json', + }; +} + +function checkBatch(bytes: Uint8Array, options: ForwardEnvelopesOptions, environment: string): void { + const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES; + if (bytes.byteLength > maxBytes) { + throw new DeviceRequestError(413, 'payload_too_large', `body is over ${maxBytes} bytes`); + } + + let parsed: unknown; + try { + parsed = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes)); + } catch { + throw new DeviceRequestError(400, 'invalid_request', 'body is not UTF-8 JSON'); + } + const events = (parsed as { events?: unknown } | null)?.events; + if (!Array.isArray(events) || events.length === 0) { + throw new DeviceRequestError(400, 'invalid_request', 'body must be {"events":[…]} with at least one envelope'); + } + const maxEnvelopes = options.maxEnvelopes ?? DEFAULT_MAX_ENVELOPES; + if (events.length > maxEnvelopes) { + throw new DeviceRequestError(413, 'payload_too_large', `more than ${maxEnvelopes} envelopes`); + } + + events.forEach((envelope: unknown, i) => { + const e = envelope as { event?: unknown; sig?: unknown } | null; + const event = e?.event as { owner?: { id?: unknown }; environment?: unknown } | undefined; + if (event === null || typeof event !== 'object' || Array.isArray(event) || typeof e?.sig !== 'string') { + throw new DeviceRequestError(400, 'invalid_request', `events[${i}] must be {"event":{…},"sig":"…"}`); + } + if (event.owner?.id !== options.expectedOwnerId) { + throw new DeviceRequestError( + 422, + 'owner_mismatch', + `events[${i}].event.owner.id is not the signed-in user`, + ); + } + if (event.environment !== environment) { + throw new DeviceRequestError( + 422, + 'environment_mismatch', + `events[${i}].event.environment must be ${environment}, the API key's environment`, + ); + } + }); +} + +function errorResult(err: DeviceRequestError): ForwardResult { + return { status: err.status, body: JSON.stringify(err), contentType: 'application/json' }; +} diff --git a/src/device/handlers.ts b/src/device/handlers.ts new file mode 100644 index 0000000..8c3e42a --- /dev/null +++ b/src/device/handlers.ts @@ -0,0 +1,69 @@ +import { DeviceRequestError, resolveDeviceConfig, type DeviceConfig } from './config.js'; +import { forwardEnvelopes } from './forward.js'; +import { registerDeviceSource } from './register.js'; + +export interface DeviceRoutesOptions extends DeviceConfig { + /** + * The signed-in user's owner id for this request, from your own session or token, or null when + * nobody is signed in. Null answers 401, and the app tries again later. + */ + ownerId(request: Request): string | null | undefined | Promise; +} + +export interface DeviceRoutes { + /** The app's `provisionUrl`: `{"public_jwk", "name"}` in, `{"source_id"}` out. */ + register(request: Request): Promise; + /** The app's `forwardUrl`: a signed batch in, HalvorAI's response out. */ + forward(request: Request): Promise; +} + +/** + * The two routes an app using `halvorai_ingest_flutter` calls, as Fetch API handlers (Next.js + * route handlers, Hono, Bun, Deno, Cloudflare Workers; Express through a small adapter). Your API + * key stays on the server; the app holds only its own signing key. + */ +export function deviceRoutes(options: DeviceRoutesOptions): DeviceRoutes { + // Checked now, so a missing key fails at startup rather than on the first request. + resolveDeviceConfig(options); + + const json = (status: number, body: unknown): Response => + new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } }); + const unauthorized = (): Response => + json(401, { error: { code: 'unauthorized', message: 'sign in first' } }); + + return { + async register(request) { + if (!(await options.ownerId(request))) return unauthorized(); + let body: { public_jwk?: unknown; name?: unknown }; + try { + body = (await request.json()) as typeof body; + } catch { + return json(400, { error: { code: 'invalid_request', message: 'body is not JSON' } }); + } + try { + const { sourceId } = await registerDeviceSource({ + ...options, + publicJwk: body?.public_jwk, + ...(typeof body?.name === 'string' ? { name: body.name } : {}), + }); + return json(200, { source_id: sourceId }); + } catch (err) { + if (err instanceof DeviceRequestError) return json(err.status, err); + throw err; + } + }, + + async forward(request) { + const ownerId = await options.ownerId(request); + if (!ownerId) return unauthorized(); + const result = await forwardEnvelopes(new Uint8Array(await request.arrayBuffer()), { + ...options, + expectedOwnerId: ownerId, + }); + return new Response(result.body, { + status: result.status, + headers: { 'content-type': result.contentType }, + }); + }, + }; +} diff --git a/src/device/index.ts b/src/device/index.ts new file mode 100644 index 0000000..fe468eb --- /dev/null +++ b/src/device/index.ts @@ -0,0 +1,10 @@ +// Server-side helpers for apps whose installs sign their own events (halvorai_ingest_flutter): +// your backend registers each install's public key and forwards its signed batches unchanged. +export { DeviceRequestError, type DeviceConfig } from './config.js'; +export { forwardEnvelopes, type ForwardEnvelopesOptions, type ForwardResult } from './forward.js'; +export { deviceRoutes, type DeviceRoutes, type DeviceRoutesOptions } from './handlers.js'; +export { + publicDeviceJwk, + registerDeviceSource, + type RegisterDeviceSourceOptions, +} from './register.js'; diff --git a/src/device/register.ts b/src/device/register.ts new file mode 100644 index 0000000..5fcb486 --- /dev/null +++ b/src/device/register.ts @@ -0,0 +1,68 @@ +import type { JWK } from '@halvorai/schema'; +import { postSource, SourceRegistrationError } from '../sources.js'; +import { DeviceRequestError, resolveDeviceConfig, type DeviceConfig } from './config.js'; + +export interface RegisterDeviceSourceOptions extends DeviceConfig { + /** The public half of the app install's key, as the app sent it. */ + publicJwk: unknown; + /** A label for the source in the dashboard, such as the app and platform (cut to 100). */ + name?: string; +} + +const BASE64URL_32_BYTES = /^[A-Za-z0-9_-]{43}$/; + +/** + * Checks that [jwk] is the public half of an Ed25519 key and returns only `kty`, `crv` and `x`. + * A key that has `d` is refused: the app sent its private key, which must never leave the device. + */ +export function publicDeviceJwk(jwk: unknown): JWK { + if (jwk === null || typeof jwk !== 'object' || Array.isArray(jwk)) { + throw new DeviceRequestError(400, 'invalid_request', 'public_jwk must be a JSON object'); + } + const key = jwk as Record; + if ('d' in key) { + throw new DeviceRequestError( + 400, + 'invalid_request', + 'public_jwk contains a private key (d); only the public half may be sent', + ); + } + if (key['kty'] !== 'OKP' || key['crv'] !== 'Ed25519') { + throw new DeviceRequestError(400, 'invalid_request', 'public_jwk must be an Ed25519 key'); + } + const x = key['x']; + if (typeof x !== 'string' || !BASE64URL_32_BYTES.test(x)) { + throw new DeviceRequestError(400, 'invalid_request', 'public_jwk.x must be 32 bytes, base64url'); + } + return { kty: 'OKP', crv: 'Ed25519', x }; +} + +/** + * Registers an app install's public key as a HalvorAI source and returns the `src_` id the app + * signs with. Call it from the route your app's provisioning request reaches, after checking the + * user is signed in. + * + * Throws [DeviceRequestError] for a key the app should not have sent (400), and when HalvorAI + * does not register it (502, so the app retries later). + */ +export async function registerDeviceSource( + options: RegisterDeviceSourceOptions, +): Promise<{ sourceId: string }> { + const config = resolveDeviceConfig(options); + const publicJwk = publicDeviceJwk(options.publicJwk); + const name = (typeof options.name === 'string' ? options.name.trim() : '') || 'app install'; + try { + const sourceId = await postSource( + config.fetch, + config.endpoint, + config.apiKey, + name.slice(0, 100), + publicJwk, + ); + return { sourceId }; + } catch (err) { + if (!(err instanceof SourceRegistrationError)) throw err; + throw new DeviceRequestError(502, 'upstream_error', err.message); + } +} + diff --git a/src/sources.ts b/src/sources.ts new file mode 100644 index 0000000..40735b6 --- /dev/null +++ b/src/sources.ts @@ -0,0 +1,60 @@ +import type { JWK } from '@halvorai/schema'; + +/** Thrown when `POST /v1/sources` does not register the key. */ +export class SourceRegistrationError extends Error { + override readonly name = 'SourceRegistrationError'; + + constructor( + message: string, + /** HTTP status from the API, or undefined when the request never got an answer. */ + readonly status?: number, + ) { + super(message); + } +} + +/** + * Registers a public Ed25519 key as a source with `POST /v1/sources` and returns its `src_` id. + * Used by `init` for a server's own key and by `registerDeviceSource` for an app install's key. + */ +export async function postSource( + fetchImpl: typeof fetch, + endpoint: string, + apiKey: string, + name: string, + publicJwk: JWK, +): Promise { + const url = `${endpoint.replace(/\/+$/, '').replace(/\/v1\/events$/, '')}/v1/sources`; + let res: Response; + try { + res = await fetchImpl(url, { + method: 'POST', + headers: { + authorization: `Bearer ${apiKey}`, + 'content-type': 'application/json', + accept: 'application/json', + }, + body: JSON.stringify({ + name, + adapter: 'custom', + public_jwk: publicJwk, + }), + }); + } catch (err) { + throw new SourceRegistrationError( + `POST /v1/sources failed: ${err instanceof Error ? err.message : 'network error'}`, + ); + } + if (res.status !== 201) { + const text = await res.text().catch(() => ''); + throw new SourceRegistrationError( + `POST /v1/sources failed (${res.status}): ${text || res.statusText}`, + res.status, + ); + } + const body = (await res.json().catch(() => ({}))) as { id?: unknown }; + if (typeof body.id !== 'string' || !body.id.startsWith('src_')) { + throw new SourceRegistrationError('POST /v1/sources returned no source id', res.status); + } + return body.id; +} diff --git a/tsup.config.ts b/tsup.config.ts index e2fd3bf..aeff890 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -13,12 +13,13 @@ export default defineConfig({ mpp: 'src/mpp/index.ts', mcp: 'src/mcp/index.ts', evidence: 'src/evidence/index.ts', + device: 'src/device/index.ts', }, format: ['esm'], define: { __SDK_VERSION__: JSON.stringify(version) }, target: 'node22', platform: 'node', - dts: { entry: { index: 'src/index.ts', cli: 'src/cli/init.ts', stripe: 'src/stripe/index.ts', x402: 'src/x402/index.ts', mpp: 'src/mpp/index.ts', mcp: 'src/mcp/index.ts', evidence: 'src/evidence/index.ts' }, resolve: [/^@halvorai\//] }, + dts: { entry: { index: 'src/index.ts', cli: 'src/cli/init.ts', stripe: 'src/stripe/index.ts', x402: 'src/x402/index.ts', mpp: 'src/mpp/index.ts', mcp: 'src/mcp/index.ts', evidence: 'src/evidence/index.ts', device: 'src/device/index.ts' }, resolve: [/^@halvorai\//] }, splitting: true, sourcemap: true, clean: true, From 476f5fc1f41bb3cd9408c6223ca2736cdfd3937b Mon Sep 17 00:00:00 2001 From: Mustafa Zaidi <201567061+hedge-mustafa@users.noreply.github.com> Date: Wed, 4 Nov 2026 16:33:58 +0500 Subject: [PATCH 2/2] Relay bodyless responses from deviceRoutes.forward new Response('', {status: 204}) throws, so a 204 or 205 from HalvorAI became an exception and the app saw a failed delivery. Use a null body for the statuses that cannot have one. Co-Authored-By: Claude Opus 5.5 --- src/device/device.test.ts | 13 +++++++++++++ src/device/handlers.ts | 5 ++++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/device/device.test.ts b/src/device/device.test.ts index 72837c1..dcf9f14 100644 --- a/src/device/device.test.ts +++ b/src/device/device.test.ts @@ -211,6 +211,19 @@ describe('deviceRoutes', () => { expect(api.calls).toHaveLength(1); }); + it.each([204, 205])('forward relays a bodyless %i from HalvorAI', async (status) => { + const api = fakeApi(() => new Response(null, { status })); + const res = await routes(api.fetch).forward( + new Request('https://app.example/halvor/events', { + method: 'POST', + body: JSON.stringify({ events: [envelope()] }), + }), + ); + expect(res.status).toBe(status); + expect(res.body).toBeNull(); + expect(api.calls).toHaveLength(1); + }); + it('both routes answer 401 without a signed-in user, which the app retries later', async () => { const api = fakeApi(accepted); const r = routes(api.fetch, null); diff --git a/src/device/handlers.ts b/src/device/handlers.ts index 8c3e42a..53578ba 100644 --- a/src/device/handlers.ts +++ b/src/device/handlers.ts @@ -17,6 +17,9 @@ export interface DeviceRoutes { forward(request: Request): Promise; } +/** The Fetch API throws on any body with these statuses, even an empty string. */ +const NULL_BODY_STATUSES = new Set([204, 205, 304]); + /** * The two routes an app using `halvorai_ingest_flutter` calls, as Fetch API handlers (Next.js * route handlers, Hono, Bun, Deno, Cloudflare Workers; Express through a small adapter). Your API @@ -60,7 +63,7 @@ export function deviceRoutes(options: DeviceRoutesOptions): DeviceRoutes { ...options, expectedOwnerId: ownerId, }); - return new Response(result.body, { + return new Response(NULL_BODY_STATUSES.has(result.status) ? null : result.body, { status: result.status, headers: { 'content-type': result.contentType }, });