Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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: <reason>`.

## 0.3.4

### Added
Expand Down
60 changes: 60 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 5 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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 <hello@halvorai.com>",
Expand Down Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion scripts/smoke.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
35 changes: 2 additions & 33 deletions src/cli/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -82,38 +83,6 @@ export function upsertEnvFile(filePath: string, vars: Record<string, string>): v
writeFileSync(filePath, content, 'utf8');
}

async function registerSource(
fetchImpl: typeof fetch,
endpoint: string,
apiKey: string,
name: string,
publicJwk: JWK,
): Promise<string> {
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.
Expand All @@ -135,7 +104,7 @@ export async function runInit(options: InitOptions): Promise<InitResult> {
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');
Expand Down
65 changes: 65 additions & 0 deletions src/device/config.ts
Original file line number Diff line number Diff line change
@@ -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),
};
}
Loading
Loading