Repository navigation
@halvorai/ingest 0.4.0: device helpers for Flutter apps - #6
hedge-mustafa wants to merge 2 commits into
Conversation
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 <noreply@anthropic.com>
hedge-urk
left a comment
There was a problem hiding this comment.
Forwarding interoperability bug — preserve bodyless HTTP responses.
In src/device/handlers.ts, deviceRoutes.forward always does new Response(result.body, {status: result.status, ...}). forwardEnvelopes can legitimately return a 204 from HalvorAI with body: '' (and likewise 205). In the Fetch API, new Response('', { status: 204 }) throws TypeError: Invalid response status code 204; these statuses require a null body, not an empty string. That turns a successful upstream delivery into an exception/500 seen by the app, potentially causing retries and duplicate observations rather than preserving the original response as promised.
Please use null for statuses that prohibit a body (204, 205, 304), preserve the response status, and add a deviceRoutes.forward regression test for a 204 response. I verified the 204 case with Node's native Response constructor.
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 <noreply@anthropic.com>
|
Fixed in 476f5fc. |
Why
Mobile apps built with
halvorai_ingest_fluttersign events on the phone, with a key each install makes for itself. The app never holds an API key. The customer's backend has two jobs:This PR adds that backend half: items SDK-1 to SDK-3 in the Flutter backend requirements. The API-side items are separate. The same change is going into the other server SDK (halvorAI/ingest-python) so both behave the same.
What's added:
@halvorai/ingest/device(0.4.0)registerDeviceSource:d.kty,crvandxtoPOST /v1/sources, and returns thesrc_id.forwardEnvelopes:owner.idis the signed-in user, and that itsenvironmentmatches the API key.POST /v1/eventsbyte for byte. It's parsed only for the checks, because re-serializing could change bytes the device signed.resultsfrom them.deviceRoutes: both as Fetch API handlers (Request→Response) for Next.js route handlers, Hono, Bun and Workers. The README also shows an Express version, usingexpress.raw()so the body is never re-serialized.Refusals use HalvorAI's error format (
{"error":{"code","message"}}), so the Flutter SDK reports the code asserverCode. The status tells the app what to do:invalid_requestpayload_too_largeowner_mismatch,environment_mismatchupstream_error(HalvorAI unreachable)initnow registers its source through the same code (src/sources.ts). Its messages are unchanged, except that a network failure now readsPOST /v1/sources failed: <reason>.Tested
npm run typecheck,npm test(251 tests, 22 new),npm run buildandnpm run smoke(imports@halvorai/ingest/devicefrom the packed tarball).End to end with the Dart SDK. A local backend built from these helpers sat in front of a fake HalvorAI, which verifies every forwarded signature against the key registered for its source. The client was
halvorai_ingest(Dart), doing exactly what the Flutter package does: registering per the provisioning contract and delivering throughForwardingTransport. Results:src_id; a private key is refused with 400.owner_mismatch.The same run passes against the other SDK's helpers.
Not in this PR
kind: "device"and paging for many sourcesDELETE /v1/sources/{id}revokeDeviceSource(SDK-5): waits for thatDELETEendpoint.unconfirmedrules. They aren't needed for Flutter to work, and are a separate change.Please don't merge until it has been reviewed.
🤖 Generated with Claude Code