Skip to content
Merged
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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# Changelog

## 0.2.0 (unreleased)

- `init` prints a dashboard link that opens the test event (`<dashboard>/payments?event=…`), derived from the endpoint or set with `--dashboard`. It printed a fixed `https://app.halvorai.com` before.
- `init --endpoint URL` saves `HALVOR_ENDPOINT` to `.env.local`, and `HalvorClient` reads `HALVOR_ENDPOINT` when no `endpoint` is passed.
- `npx @halvorai/ingest --help` exits 0.
- `verify-pack` reports a missing chain anchor once, as "No chain anchor covers the included events".

## 0.1.0 (developer preview)

- `HalvorClient`: batched, signed (EdDSA) event delivery with retries and an optional disk spool; `emit` never throws.
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ Signed, attributed payment events for AI agents. HalvorAI records each payment a
npm install @halvorai/ingest
```

The offline example below also uses `jose` to make a key: `npm install @halvorai/ingest jose`.

## Try it offline

No account needed. Events are signed with your own Ed25519 key and handed to your `sink` instead of the API.
Expand Down Expand Up @@ -65,7 +67,7 @@ With a sandbox key (`hk_test_…`):
npx @halvorai/ingest init --api-key hk_test_…
```

`init` creates a source key pair, registers the public half, writes `HALVOR_API_KEY` and `HALVOR_SOURCE_KEY` to `.env.local`, and sends one test event. Then `new HalvorClient()` reads both from the environment.
`init` creates a source key pair, registers the public half, writes `HALVOR_API_KEY` and `HALVOR_SOURCE_KEY` (and `HALVOR_ENDPOINT` when you pass `--endpoint`) to `.env.local`, sends one test event and prints a dashboard link that opens it. Then `new HalvorClient()` reads them from the environment.

## Rails

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.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@halvorai/ingest",
"version": "0.1.0",
"version": "0.2.0",
"description": "Signed, attributed payment events for AI agents. Never in the payment path.",
"type": "module",
"author": "HalvorAI <hello@halvorai.com>",
Expand Down
3 changes: 2 additions & 1 deletion scripts/smoke.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ import { join, resolve } from 'node:path';
const root = resolve(import.meta.dirname, '..');
const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm';
const run = (cmd, args, cwd) =>
execFileSync(cmd, args, { cwd, stdio: ['ignore', 'pipe', 'inherit'], shell: process.platform === 'win32' }).toString().trim();
// npm.cmd needs a shell on Windows; node (often under "Program Files") must not go through one.
execFileSync(cmd, args, { cwd, stdio: ['ignore', 'pipe', 'inherit'], shell: process.platform === 'win32' && cmd === npm }).toString().trim();

const work = mkdtempSync(join(tmpdir(), 'halvorai-smoke-'));
try {
Expand Down
18 changes: 11 additions & 7 deletions src/cli/bin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,17 @@ import { stdin as input, stdout as output } from 'node:process';
import { runInit } from './init.js';
import { runVerifyPack } from './verify-pack.js';

function usage(): never {
function usage(code = 2): never {
console.log(`Usage:
npx @halvorai/ingest init [--api-key hk_test_…] [--endpoint URL] [--name NAME] [--cwd DIR]
npx @halvorai/ingest init [--api-key hk_test_…] [--endpoint URL] [--dashboard URL] [--name NAME] [--cwd DIR]
npx @halvorai/ingest verify-pack <pack.zip> [--jwks FILE_OR_URL]

init registers a source key, writes .env.local, and sends one test event.
init registers a source key, writes .env.local (with HALVOR_ENDPOINT when --endpoint is given),
and sends one test event.
verify-pack verifies an evidence pack locally. If --jwks is omitted, it fetches the
JWKS URL named by the signed manifest; pass a pinned JWK or JWKS file for offline use.
`);
process.exit(2);
process.exit(code);
}

function argValue(argv: string[], name: string): string | undefined {
Expand All @@ -39,8 +40,9 @@ async function promptApiKey(): Promise<string> {
}

async function main(argv: string[]): Promise<void> {
const command = argv[0] ?? 'help';
if (command === 'help' || command === '--help' || command === '-h') usage();
const command = argv[0];
if (command === undefined) usage();
if (command === 'help' || command === '--help' || command === '-h') usage(0);
if (command === 'verify-pack') {
const rest = argv.slice(1);
const zipPath = rest[0];
Expand All @@ -62,13 +64,15 @@ async function main(argv: string[]): Promise<void> {
const apiKey =
argValue(rest, '--api-key') ?? process.env['HALVOR_API_KEY'] ?? (await promptApiKey());

const endpoint = argValue(rest, '--endpoint');
const endpoint = argValue(rest, '--endpoint') ?? process.env['HALVOR_ENDPOINT'];
const dashboard = argValue(rest, '--dashboard');
const name = argValue(rest, '--name');
const cwd = argValue(rest, '--cwd');
await runInit({
apiKey,
...(endpoint === undefined ? {} : { endpoint }),
...(name === undefined ? {} : { name }),
...(dashboard === undefined ? {} : { dashboard }),
...(cwd === undefined ? {} : { cwd }),
});
}
Expand Down
33 changes: 31 additions & 2 deletions src/cli/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,30 @@ import { HalvorClient } from '../client.js';

const DASHBOARD_URL = 'https://app.halvorai.com';

/**
* The dashboard that belongs to an API endpoint: the hosted API maps to app.halvorai.com, a local
* API on :8787 to the local dashboard on localhost:3000, and `api.<host>` to `app.<host>`.
*/
export function dashboardFor(endpoint: string): string {
let url: URL;
try {
url = new URL(endpoint);
} catch {
return DASHBOARD_URL;
}
if (url.hostname === 'api.halvorai.com' || url.hostname === 'api.sandbox.halvorai.com') {
return DASHBOARD_URL;
}
// The local dashboard is served (and signed in to) at localhost:3000, whatever loopback name the
// API was given; a session cookie for localhost is not sent to 127.0.0.1.
if (url.port === '8787') {
const loopback = ['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname);
return `${url.protocol}//${loopback ? 'localhost' : url.hostname}:3000`;
}
if (url.hostname.startsWith('api.')) return `${url.protocol}//app.${url.hostname.slice(4)}`;
return url.origin;
}

export interface InitOptions {
/** API key (`hk_test_…` or `hk_live_…`). */
apiKey: string;
Expand All @@ -15,6 +39,8 @@ export interface InitOptions {
endpoint?: string;
/** Source display name (default: hostname). */
name?: string;
/** Dashboard origin for the printed link (default: derived from the endpoint). */
dashboard?: string;
/** Injected fetch for tests. */
fetch?: typeof fetch;
/** Optional log sink (default: console.log). */
Expand Down Expand Up @@ -115,6 +141,8 @@ export async function runInit(options: InitOptions): Promise<InitResult> {
upsertEnvFile(envPath, {
HALVOR_API_KEY: options.apiKey,
HALVOR_SOURCE_KEY: JSON.stringify(privateJwk),
// Saved only when chosen, so a later `new HalvorClient()` talks to the same API.
...(options.endpoint === undefined ? {} : { HALVOR_ENDPOINT: endpoint }),
});
log(`Wrote ${envPath}`);

Expand Down Expand Up @@ -157,10 +185,11 @@ export async function runInit(options: InitOptions): Promise<InitResult> {
throw new Error(`test event was not accepted: ${errors.join('; ') || 'flush timed out'}`);
}

const dashboardUrl = DASHBOARD_URL;
const dashboard = (options.dashboard ?? dashboardFor(endpoint)).replace(/\/+$/, '');
const dashboardUrl = `${dashboard}/payments?event=${encodeURIComponent(eventId)}`;
log('');
log(`Source ${sourceId} is ready.`);
log(`Open the dashboard: ${dashboardUrl}`);
log(`See it in the dashboard: ${dashboardUrl}`);
log(`First event id: ${eventId}`);

return { sourceId, eventId, envPath, dashboardUrl, endpoint, environment };
Expand Down
3 changes: 2 additions & 1 deletion src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { redactDeep, redactText, type RedactOptions } from './redact.js';
import { createSpool, type SpoolEnvelope, type SpoolStore } from './spool.js';
import { postEvents, type SignedEnvelope } from './transport.js';

const SDK_NAME = '@halvorai/ingest@0.1.0';
const SDK_NAME = '@halvorai/ingest@0.2.0';
const DEFAULT_BATCH = 100;
const DEFAULT_FLUSH_MS = 1000;
const DEFAULT_RETRY_DELAYS = [1000, 2000, 4000, 8000, 16000, 32000];
Expand Down Expand Up @@ -211,6 +211,7 @@ export class HalvorClient {
this.apiKey = apiKey;
this.endpoint =
options.endpoint ??
process.env['HALVOR_ENDPOINT'] ??
(keyEnv.env === 'live' ? 'https://api.halvorai.com' : 'https://api.sandbox.halvorai.com');
this.environment = options.environment ?? keyEnv.env;
}
Expand Down
5 changes: 1 addition & 4 deletions vendor/packs/verify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -489,9 +489,6 @@ export async function verifyPack(
if (environments.size > 1) report.failures.push('Included events span multiple environments');
const environment = [...environments][0];
const jwks = jwksFrom(keys);
if (sortedChain.length > 0 && chain.anchors.length === 0) {
report.failures.push('No daily anchor covers the included events');
}
const lastIncluded = sortedChain.at(-1);
const hasCoveringAnchor =
lastIncluded === undefined ||
Expand All @@ -500,7 +497,7 @@ export async function verifyPack(
/^(0|[1-9][0-9]*)$/.test(anchor.last_chain_seq) &&
BigInt(anchor.last_chain_seq) >= BigInt(lastIncluded.chain_seq),
);
if (!hasCoveringAnchor) report.failures.push('No daily anchor covers the included events');
if (!hasCoveringAnchor) report.failures.push('No chain anchor covers the included events');
for (const anchor of chain.anchors) {
if (environment === undefined) {
report.failures.push(`Anchor ${anchor.date} cannot be tied to an event environment`);
Expand Down
Loading