From 39d68ba31f4fbeca6af23495cdbb68953b04117d Mon Sep 17 00:00:00 2001 From: skusnierz Date: Thu, 7 May 2026 09:19:04 +0200 Subject: [PATCH 1/9] feat: rename to hcloud, restructure for multi-service SDK --- README.md | 101 +++++---- examples/buckets.ts | 6 +- examples/submit-and-wait.ts | 6 +- examples/submit-query.ts | 6 +- examples/x402-submit.ts | 6 +- package.json | 16 +- src/cli/commands.ts | 202 ----------------- src/cli/config.ts | 19 +- src/cli/dispatcher.ts | 103 +++++++++ src/cli/index.ts | 2 +- src/cli/output.ts | 4 +- src/cli/registry.ts | 26 +++ src/client/config.ts | 30 --- src/client/http.ts | 103 --------- src/core/config.ts | 41 ++++ src/{ => core}/errors.ts | 24 +- src/core/http.ts | 156 +++++++++++++ src/hcloud-client.ts | 48 ++++ src/index.ts | 94 +++++++- src/services/atlantic/cli.ts | 206 ++++++++++++++++++ .../atlantic/client.ts} | 95 +++++--- src/services/atlantic/index.ts | 35 +++ .../atlantic}/multipart.ts | 2 +- src/{ => services/atlantic}/types.ts | 0 .../atlantic}/workflows/buckets.ts | 6 +- .../atlantic}/workflows/queries.ts | 20 +- src/{ => services/atlantic}/x402/adapter.ts | 0 src/{ => services/atlantic}/x402/headers.ts | 4 +- src/{ => services/atlantic}/x402/payments.ts | 39 ++-- .../atlantic}/x402/private-key-adapter.ts | 6 +- test/cli.test.ts | 6 +- test/client.test.ts | 54 +++-- test/docs.test.ts | 4 +- test/errors.test.ts | 6 +- test/http.test.ts | 61 +++++- test/multipart.test.ts | 2 +- test/private-key-adapter.test.ts | 4 +- test/scaffold.test.ts | 9 + test/workflows.test.ts | 12 +- test/x402.test.ts | 20 +- 40 files changed, 1029 insertions(+), 555 deletions(-) delete mode 100644 src/cli/commands.ts create mode 100644 src/cli/dispatcher.ts create mode 100644 src/cli/registry.ts delete mode 100644 src/client/config.ts delete mode 100644 src/client/http.ts create mode 100644 src/core/config.ts rename src/{ => core}/errors.ts (73%) create mode 100644 src/core/http.ts create mode 100644 src/hcloud-client.ts create mode 100644 src/services/atlantic/cli.ts rename src/{client/atlantic-client.ts => services/atlantic/client.ts} (60%) create mode 100644 src/services/atlantic/index.ts rename src/{client => services/atlantic}/multipart.ts (98%) rename src/{ => services/atlantic}/types.ts (100%) rename src/{ => services/atlantic}/workflows/buckets.ts (87%) rename src/{ => services/atlantic}/workflows/queries.ts (85%) rename src/{ => services/atlantic}/x402/adapter.ts (100%) rename src/{ => services/atlantic}/x402/headers.ts (94%) rename src/{ => services/atlantic}/x402/payments.ts (76%) rename src/{ => services/atlantic}/x402/private-key-adapter.ts (97%) diff --git a/README.md b/README.md index 15db85f..4be0be3 100644 --- a/README.md +++ b/README.md @@ -1,38 +1,46 @@ -# Atlantic SDK +# hcloud -TypeScript SDK and CLI for the Herodotus Atlantic API. +TypeScript SDK and CLI for [Herodotus Cloud](https://herodotus.cloud). -This package provides two surfaces: +This package gives you a single entrypoint to every Herodotus Cloud service. Each +service is reached through its own namespace on `HcloudClient`, so methods that +share a name across services (e.g. `submitQuery`) stay unambiguous. -- A typed SDK for direct Atlantic API calls and higher-level workflow helpers. -- A JSON-first CLI that delegates to the SDK so terminal usage and library usage share behavior. +Today the package ships: + +- `client.atlantic.*` — Atlantic proving service (queries, buckets, x402 payments). + +Coming next: + +- `client.auth.*` — programmatic wallet authentication (EIP-712 → bearer session → API key). +- Additional services (storage proof, data processor, …) under their own namespaces. ## Installation ```bash -bun add @herodotus_dev/atlantic-sdk +bun add @herodotus_dev/hcloud ``` ```bash -npm install @herodotus_dev/atlantic-sdk +npm install @herodotus_dev/hcloud ``` Install the CLI globally: ```bash -npm install -g @herodotus_dev/atlantic-sdk +npm install -g @herodotus_dev/hcloud ``` ## SDK quickstart ```ts -import { AtlanticClient, submitAndWait } from '@herodotus_dev/atlantic-sdk'; +import { HcloudClient, submitAndWait } from '@herodotus_dev/hcloud'; -const client = new AtlanticClient({ - apiKey: process.env.ATLANTIC_API_KEY, +const client = new HcloudClient({ + apiKey: process.env.HCLOUD_API_KEY, }); -const result = await submitAndWait(client, { +const result = await submitAndWait(client.atlantic, { declaredJobSize: 'S', pieFile: './pie.zip', }); @@ -43,19 +51,19 @@ console.log(result.atlanticQuery.id, result.atlanticQuery.status); ## Direct API usage ```ts -import { AtlanticClient } from '@herodotus_dev/atlantic-sdk'; +import { HcloudClient } from '@herodotus_dev/hcloud'; -const client = new AtlanticClient({ - apiKey: process.env.ATLANTIC_API_KEY, +const client = new HcloudClient({ + apiKey: process.env.HCLOUD_API_KEY, }); -const submitted = await client.submitQuery({ +const submitted = await client.atlantic.submitQuery({ declaredJobSize: 'S', pieFile: './pie.zip', layout: 'dynamic', }); -const details = await client.getQuery(submitted.atlanticQueryId); +const details = await client.atlantic.getQuery(submitted.atlanticQueryId); console.log(details.atlanticQuery.status, details.metadataUrls); ``` @@ -63,29 +71,32 @@ console.log(details.atlanticQuery.status, details.metadataUrls); ## CLI quickstart ```bash -ATLANTIC_API_KEY=... bunx atlantic submit-query \ +HCLOUD_API_KEY=... bunx hcloud atlantic submit-query \ --pie-file ./pie.zip \ --declared-job-size S ``` -Every CLI command prints JSON so agents and shell scripts can chain outputs without scraping prose. +CLI structure is `hcloud `. List service groups with `hcloud --help`, +list commands with `hcloud --help`. Every CLI command prints JSON so agents and +shell scripts can chain outputs without scraping prose. -Common commands: +Common Atlantic commands: ```bash -atlantic get-query-details -atlantic get-my-queries --limit 20 --offset 0 -atlantic retry-if-retriable -atlantic list-buckets -atlantic create-bucket --aggregator-version STONE -atlantic close-bucket +hcloud atlantic get-query-details +hcloud atlantic get-my-queries --limit 20 --offset 0 +hcloud atlantic retry-if-retriable +hcloud atlantic list-buckets +hcloud atlantic create-bucket --aggregator-version STONE +hcloud atlantic close-bucket ``` -## SDK reference +## Atlantic SDK reference -### Client +### Service surface + +Reached as `client.atlantic` on `HcloudClient`. -- `new AtlanticClient(options)`: Creates a typed Atlantic API client. Configure `baseUrl`, `apiKey`, optional `fetch`, and optional x402 `paymentAdapter`. - `healthCheck()`: Checks whether Atlantic is reachable. Returns `{ alive }`. - `submitQuery(input, options)`: Submits a proving query. Accepts program/input/PIE/proof files, Cairo options, result target, declared job size, dedup/external IDs, and bucket fields. Returns the durable `atlanticQueryId` and optional x402 settlement data. - `getQuery(atlanticQueryId)`: Fetches query details plus metadata URLs. @@ -93,7 +104,7 @@ atlantic close-bucket - `listQueries({ limit, offset })`: Lists submitted queries with pagination. - `getQueryStats()`: Fetches query statistics for the configured API key. - `getQueryJobs(atlanticQueryId)`: Fetches job lifecycle details for a query. -- `retryQuery(atlanticQueryId)`: Requests an API retry for a failed query. Branch on structured errors for wrong state, max retries, not found, and forbidden outcomes. +- `retryQuery(atlanticQueryId)`: Requests an API retry for a failed query. - `listBuckets({ limit, offset })`: Lists Applicative Recursion buckets. - `createBucket(input)`: Creates an Applicative Recursion bucket. - `getBucket(bucketId)`: Fetches a bucket and associated queries. @@ -101,35 +112,37 @@ atlantic close-bucket ### Workflow helpers -- `submitAndReturnId(client, input)`: Submit and return the query ID immediately. -- `waitForQuery(client, atlanticQueryId, options)`: Poll until terminal status or timeout. Returns observed statuses and final query details. -- `submitAndWait(client, input, options)`: Submit and wait in one helper while preserving the original submit result. -- `retryIfRetriable(client, atlanticQueryId)`: Fetches current query details and retries only when the query is failed and retriable. -- `getQueryWithJobs(client, atlanticQueryId)`: Fetches query details and query jobs together. -- `submitToBucket(client, input)`: Submits a query into an existing bucket with explicit job index. -- `createBucketAndSubmit(client, input)`: Creates a bucket and submits indexed queries into it. +Free functions that take `client.atlantic` and compose lifecycle behavior. + +- `submitAndReturnId(service, input)`: Submit and return the query ID immediately. +- `waitForQuery(service, atlanticQueryId, options)`: Poll until terminal status or timeout. Returns observed statuses and final query details. +- `submitAndWait(service, input, options)`: Submit and wait in one helper while preserving the original submit result. +- `retryIfRetriable(service, atlanticQueryId)`: Fetches current query details and retries only when the query is failed and retriable. +- `getQueryWithJobs(service, atlanticQueryId)`: Fetches query details and query jobs together. +- `submitToBucket(service, input)`: Submits a query into an existing bucket with explicit job index. +- `createBucketAndSubmit(service, input)`: Creates a bucket and submits indexed queries into it. ## x402 -The SDK supports Atlantic's x402 flow through a caller-provided payment adapter. It waits for a real `402` challenge, signs the server-provided payment requirement, retries the original submit request with `PAYMENT-SIGNATURE`, and parses `PAYMENT-RESPONSE`. +The Atlantic service supports Atlantic's x402 flow through a caller-provided payment adapter. It waits for a real `402` challenge, signs the server-provided payment requirement, retries the original submit request with `PAYMENT-SIGNATURE`, and parses `PAYMENT-RESPONSE`. ### Private-key adapter For server-side scripts, CI, or agents with a dedicated payment key, use the built-in private-key adapter: ```ts -import { AtlanticClient, createPrivateKeyPaymentAdapter } from '@herodotus_dev/atlantic-sdk'; +import { HcloudClient, createPrivateKeyPaymentAdapter } from '@herodotus_dev/hcloud'; const paymentAdapter = createPrivateKeyPaymentAdapter({ privateKey: process.env.WALLET_PRIVATE_KEY as `0x${string}`, }); -const client = new AtlanticClient({ - apiKey: process.env.ATLANTIC_API_KEY, +const client = new HcloudClient({ + apiKey: process.env.HCLOUD_API_KEY, paymentAdapter, }); -const result = await client.submitQuery({ +const result = await client.atlantic.submitQuery({ declaredJobSize: 'S', pieFile: './pie.zip', }); @@ -144,7 +157,7 @@ The adapter reads the x402 requirement returned by Atlantic, verifies that `acce Use a custom adapter when signing should happen through another wallet, custody service, browser wallet, or agent wallet. ```ts -import { AtlanticClient, type X402PaymentAdapter, type X402PaymentPayload } from '@herodotus_dev/atlantic-sdk'; +import { HcloudClient, type X402PaymentAdapter, type X402PaymentPayload } from '@herodotus_dev/hcloud'; const paymentAdapter: X402PaymentAdapter = { async createPayment({ requirement }): Promise { @@ -173,7 +186,7 @@ const paymentAdapter: X402PaymentAdapter = { }, }; -const client = new AtlanticClient({ paymentAdapter }); +const client = new HcloudClient({ paymentAdapter }); ``` Preserve the `requirement` object from `accepts[]` verbatim. Atlantic currently accepts USD Coin on Base mainnet; do not hardcode `payTo`, `asset`, `network`, or `amount`, because Atlantic may rotate the receiver or change the required payment amount. diff --git a/examples/buckets.ts b/examples/buckets.ts index d66d8b2..47da480 100644 --- a/examples/buckets.ts +++ b/examples/buckets.ts @@ -1,8 +1,8 @@ -import { AtlanticClient, createBucketAndSubmit } from '../src'; +import { HcloudClient, createBucketAndSubmit } from '../src'; -const client = new AtlanticClient(process.env.ATLANTIC_API_KEY ? { apiKey: process.env.ATLANTIC_API_KEY } : {}); +const client = new HcloudClient(process.env.HCLOUD_API_KEY ? { apiKey: process.env.HCLOUD_API_KEY } : {}); -const result = await createBucketAndSubmit(client, { +const result = await createBucketAndSubmit(client.atlantic, { bucket: { aggregatorVersion: 'STONE', }, diff --git a/examples/submit-and-wait.ts b/examples/submit-and-wait.ts index a72cc4e..5bcede7 100644 --- a/examples/submit-and-wait.ts +++ b/examples/submit-and-wait.ts @@ -1,9 +1,9 @@ -import { AtlanticClient, submitAndWait } from '../src'; +import { HcloudClient, submitAndWait } from '../src'; -const client = new AtlanticClient(process.env.ATLANTIC_API_KEY ? { apiKey: process.env.ATLANTIC_API_KEY } : {}); +const client = new HcloudClient(process.env.HCLOUD_API_KEY ? { apiKey: process.env.HCLOUD_API_KEY } : {}); const result = await submitAndWait( - client, + client.atlantic, { declaredJobSize: 'S', pieFile: './pie.zip', diff --git a/examples/submit-query.ts b/examples/submit-query.ts index bc87d24..d80d867 100644 --- a/examples/submit-query.ts +++ b/examples/submit-query.ts @@ -1,8 +1,8 @@ -import { AtlanticClient } from '../src'; +import { HcloudClient } from '../src'; -const client = new AtlanticClient(process.env.ATLANTIC_API_KEY ? { apiKey: process.env.ATLANTIC_API_KEY } : {}); +const client = new HcloudClient(process.env.HCLOUD_API_KEY ? { apiKey: process.env.HCLOUD_API_KEY } : {}); -const result = await client.submitQuery({ +const result = await client.atlantic.submitQuery({ declaredJobSize: 'S', pieFile: './pie.zip', layout: 'dynamic', diff --git a/examples/x402-submit.ts b/examples/x402-submit.ts index 7ad616c..aaeee07 100644 --- a/examples/x402-submit.ts +++ b/examples/x402-submit.ts @@ -1,4 +1,4 @@ -import { AtlanticClient, type X402PaymentAdapter } from '../src'; +import { HcloudClient, type X402PaymentAdapter } from '../src'; const paymentAdapter: X402PaymentAdapter = { async createPayment({ requirement }) { @@ -8,9 +8,9 @@ const paymentAdapter: X402PaymentAdapter = { }, }; -const client = new AtlanticClient({ paymentAdapter }); +const client = new HcloudClient({ paymentAdapter }); -const result = await client.submitQuery( +const result = await client.atlantic.submitQuery( { declaredJobSize: 'S', pieFile: './pie.zip', diff --git a/package.json b/package.json index 334da7b..5e3e91a 100644 --- a/package.json +++ b/package.json @@ -1,13 +1,13 @@ { - "name": "@herodotus_dev/atlantic-sdk", - "version": "1.0.0", - "description": "TypeScript SDK and CLI for submitting, tracking, retrying, and paying for Herodotus Atlantic proofs.", + "name": "@herodotus_dev/hcloud", + "version": "0.1.0", + "description": "TypeScript SDK and CLI for Herodotus Cloud — wallet auth, Atlantic proving, and future services.", "type": "module", "main": "./dist/index.js", "module": "./dist/index.js", "types": "./dist/index.d.ts", "bin": { - "atlantic": "./dist/cli/index.js" + "hcloud": "./dist/cli/index.js" }, "exports": { ".": { @@ -32,14 +32,18 @@ "examples" ], "keywords": [ - "atlantic", "herodotus", + "hcloud", + "atlantic", + "storage-proof", + "data-processor", "sharp", "stwo", "stone", "proof generation", "proof verification", - "trace generation" + "wallet auth", + "eip-712" ], "author": "Herodotus Dev Ltd", "license": "MIT", diff --git a/src/cli/commands.ts b/src/cli/commands.ts deleted file mode 100644 index 1a86e77..0000000 --- a/src/cli/commands.ts +++ /dev/null @@ -1,202 +0,0 @@ -import { AtlanticClient } from '../client/atlantic-client'; -import type { AtlanticJobSize, SubmitQueryInput } from '../types'; -import { createBucketAndSubmit, submitToBucket } from '../workflows/buckets'; -import { getQueryWithJobs, retryIfRetriable, submitAndWait } from '../workflows/queries'; -import { readCliConfig } from './config'; -import { formatCliError, printJson } from './output'; - -type CommandHandler = (context: CliContext) => Promise; - -interface CliContext { - client: AtlanticClient; - flags: Record; - positionals: string[]; -} - -const commandHandlers: Record = { - health: ({ client }) => client.healthCheck(), - 'submit-query': ({ client, flags }) => client.submitQuery(readSubmitInput(flags)), - 'submit-and-wait': ({ client, flags }) => submitAndWait(client, readSubmitInput(flags), readWaitOptions(flags)), - 'retry-query': ({ client, flags, positionals }) => client.retryQuery(requiredId(flags, positionals, 'query-id')), - 'retry-if-retriable': ({ client, flags, positionals }) => - retryIfRetriable(client, requiredId(flags, positionals, 'query-id')), - 'get-query-details': ({ client, flags, positionals }) => client.getQuery(requiredId(flags, positionals, 'query-id')), - 'get-query-by-dedup-id': ({ client, flags, positionals }) => - client.getQueryByDedupId(requiredId(flags, positionals, 'dedup-id')), - 'get-my-queries': ({ client, flags }) => client.listQueries(readPagination(flags)), - 'get-query-jobs': ({ client, flags, positionals }) => client.getQueryJobs(requiredId(flags, positionals, 'query-id')), - 'get-query-with-jobs': ({ client, flags, positionals }) => - getQueryWithJobs(client, requiredId(flags, positionals, 'query-id')), - 'get-query-stats': ({ client }) => client.getQueryStats(), - 'list-buckets': ({ client, flags }) => client.listBuckets(readPagination(flags)), - 'create-bucket': ({ client, flags }) => - client.createBucket( - compact({ - aggregatorVersion: requiredString(flags, 'aggregator-version'), - externalId: optionalString(flags, 'external-id') ?? null, - nodeWidth: optionalNumber(flags, 'node-width') ?? null, - mockProof: optionalBoolean(flags, 'mock-proof') ?? null, - }), - ), - 'get-bucket': ({ client, flags, positionals }) => client.getBucket(requiredId(flags, positionals, 'bucket-id')), - 'close-bucket': ({ client, flags, positionals }) => client.closeBucket(requiredId(flags, positionals, 'bucket-id')), - 'submit-to-bucket': ({ client, flags }) => - submitToBucket(client, { - bucketId: requiredString(flags, 'bucket-id'), - bucketJobIndex: requiredNumber(flags, 'bucket-job-index'), - query: readSubmitInput(flags), - }), - 'create-bucket-and-submit': ({ client, flags }) => - createBucketAndSubmit(client, { - bucket: { - aggregatorVersion: requiredString(flags, 'aggregator-version'), - }, - queries: [readSubmitInput(flags)], - }), -}; - -export async function runCli(argv: string[]): Promise { - const [command, ...rest] = argv; - if (!command || command === 'help' || command === '--help' || command === '-h') { - printJson({ ok: true, commands: Object.keys(commandHandlers).sort() }); - return 0; - } - - const handler = commandHandlers[command]; - if (!handler) { - printJson({ - ok: false, - error: { message: `Unknown command: ${command}` }, - commands: Object.keys(commandHandlers).sort(), - }); - return 1; - } - - const { flags, positionals } = parseArgs(rest); - const config = readCliConfig(flags); - const client = new AtlanticClient(config.client); - - try { - const data = await handler({ client, flags, positionals }); - printJson({ ok: true, data }); - return 0; - } catch (error) { - printJson(formatCliError(error), { log: console.error }); - return 1; - } -} - -export function parseArgs(argv: string[]): { - flags: Record; - positionals: string[]; -} { - const flags: Record = {}; - const positionals: string[] = []; - - for (let index = 0; index < argv.length; index += 1) { - const token = argv[index]; - if (!token) continue; - if (!token.startsWith('--')) { - positionals.push(token); - continue; - } - - const name = token.slice(2); - const next = argv[index + 1]; - if (!next || next.startsWith('--')) { - flags[name] = true; - continue; - } - - flags[name] = next; - index += 1; - } - - return { flags, positionals }; -} - -function readSubmitInput(flags: Record): SubmitQueryInput { - const input: Partial = { - declaredJobSize: (optionalString(flags, 'declared-job-size') ?? 'S') as AtlanticJobSize, - }; - setIfPresent(input, 'externalId', optionalString(flags, 'external-id')); - setIfPresent(input, 'dedupId', optionalString(flags, 'dedup-id')); - setIfPresent(input, 'layout', optionalString(flags, 'layout') as SubmitQueryInput['layout']); - setIfPresent(input, 'cairoVm', optionalString(flags, 'cairo-vm') as SubmitQueryInput['cairoVm']); - setIfPresent(input, 'cairoVersion', optionalString(flags, 'cairo-version') as SubmitQueryInput['cairoVersion']); - setIfPresent(input, 'result', optionalString(flags, 'result') as SubmitQueryInput['result']); - setIfPresent(input, 'network', optionalString(flags, 'network') as SubmitQueryInput['network']); - setIfPresent(input, 'sharpProver', optionalString(flags, 'sharp-prover') as SubmitQueryInput['sharpProver']); - setIfPresent(input, 'programHash', optionalString(flags, 'program-hash')); - setIfPresent(input, 'programFile', optionalString(flags, 'program-file')); - setIfPresent(input, 'inputFile', optionalString(flags, 'input-file')); - setIfPresent(input, 'pieFile', optionalString(flags, 'pie-file')); - setIfPresent(input, 'proofFile', optionalString(flags, 'proof-file')); - setIfPresent(input, 'bucketId', optionalString(flags, 'bucket-id')); - setIfPresent(input, 'bucketJobIndex', optionalNumber(flags, 'bucket-job-index')); - return input as SubmitQueryInput; -} - -function readWaitOptions(flags: Record) { - const options: { intervalMs?: number; timeoutMs?: number } = {}; - setIfPresent(options, 'intervalMs', optionalNumber(flags, 'interval-ms')); - setIfPresent(options, 'timeoutMs', optionalNumber(flags, 'timeout-ms')); - return options; -} - -function readPagination(flags: Record) { - const pagination: { limit?: number; offset?: number } = {}; - setIfPresent(pagination, 'limit', optionalNumber(flags, 'limit')); - setIfPresent(pagination, 'offset', optionalNumber(flags, 'offset')); - return pagination; -} - -function requiredId( - flags: Record, - positionals: string[], - flagName: string, -): string { - return optionalString(flags, flagName) ?? positionals[0] ?? fail(`Missing required ${flagName}`); -} - -function requiredString(flags: Record, name: string): string { - return optionalString(flags, name) ?? fail(`Missing required --${name}`); -} - -function requiredNumber(flags: Record, name: string): number { - return optionalNumber(flags, name) ?? fail(`Missing required --${name}`); -} - -function optionalString(flags: Record, name: string): string | undefined { - const value = flags[name]; - return typeof value === 'string' && value.length > 0 ? value : undefined; -} - -function optionalNumber(flags: Record, name: string): number | undefined { - const value = optionalString(flags, name); - if (value === undefined) return undefined; - const number = Number(value); - if (!Number.isFinite(number)) fail(`--${name} must be a number`); - return number; -} - -function optionalBoolean(flags: Record, name: string): boolean | undefined { - const value = flags[name]; - if (value === undefined) return undefined; - if (typeof value === 'boolean') return value; - if (value === 'true') return true; - if (value === 'false') return false; - fail(`--${name} must be true or false`); -} - -function fail(message: string): never { - throw new Error(message); -} - -function compact>(value: T): T { - return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined)) as T; -} - -function setIfPresent(target: T, key: K, value: T[K] | undefined): void { - if (value !== undefined) target[key] = value; -} diff --git a/src/cli/config.ts b/src/cli/config.ts index 335d6c6..ef37dd8 100644 --- a/src/cli/config.ts +++ b/src/cli/config.ts @@ -1,20 +1,19 @@ -import type { AtlanticClientOptions } from '../client/atlantic-client'; +import type { HcloudClientOptions } from '../hcloud-client'; export interface CliConfig { - client: AtlanticClientOptions; + client: HcloudClientOptions; } export function readCliConfig(flags: Record): CliConfig { const env = process.env; - const baseUrl = stringFlag(flags, 'base-url') ?? env.ATLANTIC_BASE_URL; - const apiKey = stringFlag(flags, 'api-key') ?? env.ATLANTIC_API_KEY; + const baseUrl = + stringFlag(flags, 'base-url') ?? env.HCLOUD_ATLANTIC_BASE_URL ?? env.ATLANTIC_BASE_URL; + const apiKey = stringFlag(flags, 'api-key') ?? env.HCLOUD_API_KEY ?? env.ATLANTIC_API_KEY; - return { - client: { - ...(baseUrl ? { baseUrl } : {}), - ...(apiKey ? { apiKey } : {}), - }, - }; + const client: HcloudClientOptions = {}; + if (apiKey) client.apiKey = apiKey; + if (baseUrl) client.baseUrls = { atlantic: baseUrl }; + return { client }; } function stringFlag(flags: Record, name: string): string | undefined { diff --git a/src/cli/dispatcher.ts b/src/cli/dispatcher.ts new file mode 100644 index 0000000..6e99561 --- /dev/null +++ b/src/cli/dispatcher.ts @@ -0,0 +1,103 @@ +import { HcloudClient } from '../hcloud-client'; +import { atlanticCli } from '../services/atlantic/cli'; +import { readCliConfig } from './config'; +import { formatCliError, printJson } from './output'; +import type { CliFlags, ServiceCli } from './registry'; + +const services: ServiceCli[] = [atlanticCli]; + +export async function runCli(argv: string[]): Promise { + const [serviceName, command, ...rest] = argv; + + if (!serviceName || isHelp(serviceName)) { + printJson({ + ok: true, + services: services.map(({ name, description, commands }) => ({ + name, + description, + commands: Object.keys(commands).sort(), + })), + }); + return 0; + } + + const service = services.find((entry) => entry.name === serviceName); + if (!service) { + printJson({ + ok: false, + error: { message: `Unknown service: ${serviceName}` }, + services: services.map((entry) => entry.name).sort(), + }); + return 1; + } + + if (!command || isHelp(command)) { + printJson({ + ok: true, + service: service.name, + description: service.description, + commands: Object.entries(service.commands) + .map(([name, spec]) => ({ name, description: spec.description ?? '' })) + .sort((a, b) => a.name.localeCompare(b.name)), + }); + return 0; + } + + const spec = service.commands[command]; + if (!spec) { + printJson({ + ok: false, + error: { message: `Unknown command: ${service.name} ${command}` }, + commands: Object.keys(service.commands).sort(), + }); + return 1; + } + + const { flags, positionals } = parseArgs(rest); + const config = readCliConfig(flags); + const client = new HcloudClient(config.client); + + try { + const data = await spec.run({ client, flags, positionals }); + printJson({ ok: true, data }); + return 0; + } catch (error) { + printJson(formatCliError(error), { log: console.error }); + return 1; + } +} + +export function parseArgs(argv: string[]): { + flags: Record; + positionals: string[]; +} { + const flags: Record = {}; + const positionals: string[] = []; + + for (let index = 0; index < argv.length; index += 1) { + const token = argv[index]; + if (!token) continue; + if (!token.startsWith('--')) { + positionals.push(token); + continue; + } + + const name = token.slice(2); + const next = argv[index + 1]; + if (!next || next.startsWith('--')) { + flags[name] = true; + continue; + } + + flags[name] = next; + index += 1; + } + + return { flags, positionals }; +} + +function isHelp(token: string): boolean { + return token === 'help' || token === '--help' || token === '-h'; +} + +export type { CliFlags }; diff --git a/src/cli/index.ts b/src/cli/index.ts index 22d4bdf..30637b3 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -1,6 +1,6 @@ #!/usr/bin/env bun -import { runCli } from './commands'; +import { runCli } from './dispatcher'; if (import.meta.main) { const exitCode = await runCli(process.argv.slice(2)); diff --git a/src/cli/output.ts b/src/cli/output.ts index ff8422e..98a3a6e 100644 --- a/src/cli/output.ts +++ b/src/cli/output.ts @@ -1,4 +1,4 @@ -import { isAtlanticSdkError } from '../errors'; +import { isHcloudError } from '../core/errors'; export interface JsonStream { log?: (message?: unknown, ...optionalParams: unknown[]) => void; @@ -11,7 +11,7 @@ export function printJson(value: unknown, stream: JsonStream = console): void { } export function formatCliError(error: unknown) { - if (isAtlanticSdkError(error)) { + if (isHcloudError(error)) { return { ok: false, error: error.toJSON() }; } if (error instanceof Error) { diff --git a/src/cli/registry.ts b/src/cli/registry.ts new file mode 100644 index 0000000..21fae41 --- /dev/null +++ b/src/cli/registry.ts @@ -0,0 +1,26 @@ +import type { HcloudClient } from '../hcloud-client'; + +export type CliFlags = Record; + +export interface CliCommandContext { + client: HcloudClient; + flags: CliFlags; + positionals: string[]; +} + +export type CliCommandHandler = (context: CliCommandContext) => Promise; + +export interface CliCommandSpec { + run: CliCommandHandler; + /** Short human description shown by ` --help`. */ + description?: string; +} + +export interface ServiceCli { + /** Service identifier used in argv: `hcloud `. */ + name: string; + /** Short human description shown by `hcloud --help`. */ + description: string; + /** Map of command name → handler. */ + commands: Record; +} diff --git a/src/client/config.ts b/src/client/config.ts deleted file mode 100644 index 59265ca..0000000 --- a/src/client/config.ts +++ /dev/null @@ -1,30 +0,0 @@ -export const DEFAULT_ATLANTIC_BASE_URL = 'https://atlantic.api.herodotus.cloud'; - -export interface AtlanticClientConfig { - /** Atlantic API base URL. Defaults to the public hosted API. */ - baseUrl?: string; - /** API key sent as the `api-key` header for authenticated requests. */ - apiKey?: string; - /** Optional fetch implementation for tests or custom runtimes. */ - fetch?: FetchLike; -} - -export interface ResolvedAtlanticClientConfig { - baseUrl: string; - apiKey?: string; - fetch: FetchLike; -} - -export type FetchLike = (input: string | URL | Request, init?: RequestInit) => Promise; - -export function resolveAtlanticConfig(config: AtlanticClientConfig = {}): ResolvedAtlanticClientConfig { - const env = globalThis.process?.env; - const baseUrl = config.baseUrl ?? env?.ATLANTIC_BASE_URL ?? DEFAULT_ATLANTIC_BASE_URL; - const apiKey = config.apiKey ?? env?.ATLANTIC_API_KEY; - - return { - baseUrl: baseUrl.replace(/\/+$/, ''), - ...(apiKey ? { apiKey } : {}), - fetch: config.fetch ?? fetch, - }; -} diff --git a/src/client/http.ts b/src/client/http.ts deleted file mode 100644 index dae4dec..0000000 --- a/src/client/http.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { AtlanticSdkError, errorMessageFromBody, normalizeErrorCode } from '../errors'; -import type { ResolvedAtlanticClientConfig } from './config'; - -export interface AtlanticRequestOptions { - method: 'GET' | 'POST'; - path: string; - query?: Record; - body?: BodyInit; - headers?: HeadersInit; - expectStatus?: number | number[]; -} - -export interface AtlanticResponse { - data: T; - headers: Headers; - status: number; -} - -export async function requestAtlantic( - config: ResolvedAtlanticClientConfig, - options: AtlanticRequestOptions, -): Promise> { - const response = await sendAtlanticRequest(config, options); - return parseAtlanticResponse(response, options.expectStatus); -} - -export async function sendAtlanticRequest( - config: ResolvedAtlanticClientConfig, - options: AtlanticRequestOptions, -): Promise { - const url = buildUrl(config.baseUrl, options.path, options.query); - const headers = new Headers(options.headers); - - if (config.apiKey && !headers.has('api-key')) { - headers.set('api-key', config.apiKey); - } - - try { - const init: RequestInit = { - method: options.method, - headers, - }; - if (options.body !== undefined) init.body = options.body; - return await config.fetch(url, init); - } catch (cause) { - throw new AtlanticSdkError({ - kind: 'transport', - message: 'Atlantic request failed before receiving a response', - cause, - }); - } -} - -export async function parseAtlanticResponse( - response: Response, - expectStatus: number | number[] = [200, 201], -): Promise> { - const expected = Array.isArray(expectStatus) ? expectStatus : [expectStatus]; - const rawBody = await response.text(); - const data = parseMaybeJson(rawBody); - - if (!expected.includes(response.status)) { - const code = normalizeErrorCode(data); - throw new AtlanticSdkError({ - kind: response.status === 402 ? 'payment_challenge' : 'api', - status: response.status, - message: errorMessageFromBody(data, `Atlantic API returned ${response.status}`), - details: data, - rawBody, - ...(code ? { code } : {}), - }); - } - - return { - data: data as T, - headers: response.headers, - status: response.status, - }; -} - -export function buildUrl( - baseUrl: string, - path: string, - query?: Record, -): string { - const url = new URL(path, `${baseUrl}/`); - - for (const [key, value] of Object.entries(query ?? {})) { - if (value === undefined || value === null || value === '') continue; - url.searchParams.set(key, String(value)); - } - - return url.toString(); -} - -function parseMaybeJson(rawBody: string): unknown { - if (!rawBody) return undefined; - try { - return JSON.parse(rawBody); - } catch { - return rawBody; - } -} diff --git a/src/core/config.ts b/src/core/config.ts new file mode 100644 index 0000000..cd0f71b --- /dev/null +++ b/src/core/config.ts @@ -0,0 +1,41 @@ +export type HcloudSurface = 'atlantic' | 'auth-billing'; + +export const DEFAULT_BASE_URLS: Record = { + atlantic: 'https://atlantic.api.herodotus.cloud', + 'auth-billing': 'https://auth-billing.api.herodotus.cloud', +}; + +export type FetchLike = (input: string | URL | Request, init?: RequestInit) => Promise; + +export interface HcloudCoreConfig { + /** Per-surface base URL overrides. */ + baseUrls?: Partial>; + /** Optional fetch implementation for tests or custom runtimes. */ + fetch?: FetchLike; +} + +export interface ResolvedHcloudCoreConfig { + baseUrls: Record; + fetch: FetchLike; +} + +export function resolveCoreConfig(config: HcloudCoreConfig = {}): ResolvedHcloudCoreConfig { + const env = globalThis.process?.env; + + const atlanticBase = + config.baseUrls?.atlantic ?? env?.HCLOUD_ATLANTIC_BASE_URL ?? env?.ATLANTIC_BASE_URL ?? DEFAULT_BASE_URLS.atlantic; + const authBillingBase = + config.baseUrls?.['auth-billing'] ?? env?.HCLOUD_AUTH_BILLING_BASE_URL ?? DEFAULT_BASE_URLS['auth-billing']; + + return { + baseUrls: { + atlantic: stripTrailingSlash(atlanticBase), + 'auth-billing': stripTrailingSlash(authBillingBase), + }, + fetch: config.fetch ?? fetch, + }; +} + +function stripTrailingSlash(url: string): string { + return url.replace(/\/+$/, ''); +} diff --git a/src/errors.ts b/src/core/errors.ts similarity index 73% rename from src/errors.ts rename to src/core/errors.ts index 3a0efc5..9d45ab9 100644 --- a/src/errors.ts +++ b/src/core/errors.ts @@ -1,14 +1,18 @@ -export type AtlanticErrorKind = +export type HcloudErrorKind = | 'transport' | 'api' | 'validation' | 'timeout' | 'payment' | 'payment_challenge' - | 'payment_settlement'; + | 'payment_settlement' + | 'not_authenticated' + | 'session_expired' + | 'signing_failed' + | 'channel_binding'; -export interface AtlanticErrorOptions { - kind: AtlanticErrorKind; +export interface HcloudErrorOptions { + kind: HcloudErrorKind; message: string; status?: number; code?: string; @@ -17,16 +21,16 @@ export interface AtlanticErrorOptions { cause?: unknown; } -export class AtlanticSdkError extends Error { - readonly kind: AtlanticErrorKind; +export class HcloudError extends Error { + readonly kind: HcloudErrorKind; readonly status: number | undefined; readonly code: string | undefined; readonly details: unknown; readonly rawBody: string | undefined; - constructor(options: AtlanticErrorOptions) { + constructor(options: HcloudErrorOptions) { super(options.message); - this.name = 'AtlanticSdkError'; + this.name = 'HcloudError'; this.kind = options.kind; this.status = options.status; this.code = options.code; @@ -51,8 +55,8 @@ export class AtlanticSdkError extends Error { } } -export function isAtlanticSdkError(error: unknown): error is AtlanticSdkError { - return error instanceof AtlanticSdkError; +export function isHcloudError(error: unknown): error is HcloudError { + return error instanceof HcloudError; } export function normalizeErrorCode(body: unknown): string | undefined { diff --git a/src/core/http.ts b/src/core/http.ts new file mode 100644 index 0000000..318a91e --- /dev/null +++ b/src/core/http.ts @@ -0,0 +1,156 @@ +import { HcloudError, errorMessageFromBody, normalizeErrorCode } from './errors'; +import type { HcloudSurface, ResolvedHcloudCoreConfig } from './config'; + +export type HcloudAuthMode = 'api-key' | 'bearer' | 'none'; + +export interface HcloudRequest { + method: 'GET' | 'POST'; + surface: HcloudSurface; + path: string; + query?: Record; + body?: BodyInit; + headers?: HeadersInit; + expectStatus?: number | number[]; + authMode: HcloudAuthMode; +} + +export interface HcloudResponse { + data: T; + headers: Headers; + status: number; +} + +/** + * Provides authentication headers for outgoing requests. Implemented by the auth + * subsystem and injected into HttpClient. Phase 1 ships StaticAuthHeaderProvider; + * the auth subsystem replaces it with a session-aware implementation. + */ +export interface AuthHeaderProvider { + /** Returns the API key value to send as `api-key` header, or undefined if none configured. */ + apiKeyHeader(): Promise; + /** Returns the bearer token to send as `Authorization: Bearer `, or undefined if none. */ + bearerHeader(): Promise; +} + +/** Auth provider for callers passing an explicit API key (or none). Used until the auth subsystem ships. */ +export class StaticAuthHeaderProvider implements AuthHeaderProvider { + constructor(private readonly apiKey: string | undefined) {} + + async apiKeyHeader(): Promise { + return this.apiKey; + } + + async bearerHeader(): Promise { + return undefined; + } +} + +export class HttpClient { + constructor( + private readonly deps: { + config: ResolvedHcloudCoreConfig; + auth: AuthHeaderProvider; + }, + ) {} + + /** Issue a request and parse the response, throwing on unexpected status. */ + async request(req: HcloudRequest): Promise> { + const response = await this.send(req); + return parseResponse(response, req.surface, req.expectStatus); + } + + /** Issue a request and return the raw Response; caller is responsible for status handling. */ + async send(req: HcloudRequest): Promise { + const url = buildUrl(this.deps.config.baseUrls[req.surface], req.path, req.query); + const headers = new Headers(req.headers); + + enforceChannelBinding(headers, req.authMode); + + if (req.authMode === 'api-key') { + const apiKey = await this.deps.auth.apiKeyHeader(); + if (apiKey && !headers.has('api-key')) headers.set('api-key', apiKey); + } else if (req.authMode === 'bearer') { + const bearer = await this.deps.auth.bearerHeader(); + if (bearer && !headers.has('authorization')) headers.set('authorization', `Bearer ${bearer}`); + } + + try { + const init: RequestInit = { method: req.method, headers }; + if (req.body !== undefined) init.body = req.body; + return await this.deps.config.fetch(url, init); + } catch (cause) { + throw new HcloudError({ + kind: 'transport', + message: `Hcloud ${req.surface} request failed before receiving a response`, + cause, + }); + } + } +} + +export async function parseResponse( + response: Response, + surface: HcloudSurface, + expectStatus: number | number[] = [200, 201], +): Promise> { + const expected = Array.isArray(expectStatus) ? expectStatus : [expectStatus]; + const rawBody = await response.text(); + const data = parseMaybeJson(rawBody); + + if (!expected.includes(response.status)) { + const code = normalizeErrorCode(data); + throw new HcloudError({ + kind: response.status === 402 ? 'payment_challenge' : 'api', + status: response.status, + message: errorMessageFromBody(data, `Hcloud ${surface} returned ${response.status}`), + details: data, + rawBody, + ...(code ? { code } : {}), + }); + } + + return { + data: data as T, + headers: response.headers, + status: response.status, + }; +} + +export function buildUrl( + baseUrl: string, + path: string, + query?: Record, +): string { + const url = new URL(path, `${baseUrl}/`); + + for (const [key, value] of Object.entries(query ?? {})) { + if (value === undefined || value === null || value === '') continue; + url.searchParams.set(key, String(value)); + } + + return url.toString(); +} + +function enforceChannelBinding(headers: Headers, authMode: HcloudAuthMode): void { + if (authMode === 'bearer' && headers.has('cookie')) { + throw new HcloudError({ + kind: 'channel_binding', + message: 'bearer-mode request must not carry a Cookie header', + }); + } + if (authMode === 'api-key' && headers.has('authorization')) { + throw new HcloudError({ + kind: 'channel_binding', + message: 'api-key-mode request must not carry an Authorization header', + }); + } +} + +function parseMaybeJson(rawBody: string): unknown { + if (!rawBody) return undefined; + try { + return JSON.parse(rawBody); + } catch { + return rawBody; + } +} diff --git a/src/hcloud-client.ts b/src/hcloud-client.ts new file mode 100644 index 0000000..405a295 --- /dev/null +++ b/src/hcloud-client.ts @@ -0,0 +1,48 @@ +import { resolveCoreConfig, type FetchLike, type HcloudCoreConfig } from './core/config'; +import { HttpClient, StaticAuthHeaderProvider, type AuthHeaderProvider } from './core/http'; +import { AtlanticService } from './services/atlantic/client'; +import type { X402PaymentAdapter } from './services/atlantic/x402/adapter'; + +export interface HcloudClientOptions extends HcloudCoreConfig { + /** Explicit Atlantic API key. When set, takes precedence over env vars and persisted credentials. */ + apiKey?: string; + /** Override fetch implementation. */ + fetch?: FetchLike; + /** x402 payment adapter forwarded to the Atlantic service. */ + paymentAdapter?: X402PaymentAdapter; + /** + * Custom auth header provider. Phase 2 ships a session-aware provider; advanced + * callers can inject their own to wire bespoke credential storage. + */ + authProvider?: AuthHeaderProvider; +} + +/** + * Top-level entry point for Herodotus Cloud services. + * + * Service surfaces are reached via namespaces: `client.atlantic.*` for Atlantic + * proving, `client.auth.*` (Phase 2) for wallet authentication and API keys. + */ +export class HcloudClient { + readonly atlantic: AtlanticService; + + private readonly explicitApiKey: string | undefined; + + constructor(options: HcloudClientOptions = {}) { + const env = globalThis.process?.env; + this.explicitApiKey = options.apiKey ?? env?.HCLOUD_API_KEY ?? env?.ATLANTIC_API_KEY; + + const coreConfig = resolveCoreConfig({ + ...(options.baseUrls ? { baseUrls: options.baseUrls } : {}), + ...(options.fetch ? { fetch: options.fetch } : {}), + }); + + const auth = options.authProvider ?? new StaticAuthHeaderProvider(this.explicitApiKey); + const http = new HttpClient({ config: coreConfig, auth }); + + this.atlantic = new AtlanticService(http, { + ...(options.paymentAdapter ? { paymentAdapter: options.paymentAdapter } : {}), + hasApiKey: () => Boolean(this.explicitApiKey), + }); + } +} diff --git a/src/index.ts b/src/index.ts index 8f3d602..8abeb7f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,12 +1,82 @@ -export * from './client/atlantic-client'; -export * from './client/config'; -export * from './client/http'; -export * from './client/multipart'; -export * from './errors'; -export * from './types'; -export * from './workflows/buckets'; -export * from './workflows/queries'; -export * from './x402/adapter'; -export * from './x402/headers'; -export * from './x402/payments'; -export * from './x402/private-key-adapter'; +// Top-level client +export { HcloudClient } from './hcloud-client'; +export type { HcloudClientOptions } from './hcloud-client'; + +// Core +export { HcloudError, isHcloudError, normalizeErrorCode } from './core/errors'; +export type { HcloudErrorKind, HcloudErrorOptions } from './core/errors'; +export type { HcloudSurface, HcloudCoreConfig, ResolvedHcloudCoreConfig, FetchLike } from './core/config'; +export type { AuthHeaderProvider, HcloudAuthMode, HcloudRequest, HcloudResponse } from './core/http'; + +// Atlantic service +export { + AtlanticService, + submitAndReturnId, + waitForQuery, + submitAndWait, + retryIfRetriable, + getQueryWithJobs, + submitToBucket, + createBucketAndSubmit, + createPrivateKeyPaymentAdapter, + isPaymentError, + PAYMENT_REQUIRED_HEADER, + PAYMENT_SIGNATURE_HEADER, + PAYMENT_RESPONSE_HEADER, + encodePaymentPayload, + parsePaymentRequiredHeader, + parsePaymentResponseHeader, +} from './services/atlantic'; +export type { + AtlanticServiceOptions, + AtlanticSubmitQueryOptions, + WaitForQueryOptions, + WaitForQueryResult, + SubmitAndWaitOptions, + QueryWithJobsResult, + SubmitToBucketInput, + CreateBucketAndSubmitInput, + CreateBucketAndSubmitResult, + X402PaymentAdapter, + X402PaymentRequest, + PrivateKeyPaymentAdapterOptions, +} from './services/atlantic'; +export type { + AtlanticBucket, + AtlanticCairoVersion, + AtlanticCairoVm, + AtlanticChain, + AtlanticClientInfo, + AtlanticFileReference, + AtlanticFileValue, + AtlanticHints, + AtlanticJobSize, + AtlanticLayout, + AtlanticNetwork, + AtlanticQuery, + AtlanticQueryJob, + AtlanticQueryStatus, + AtlanticResult, + AtlanticRetryBlockedReason, + AtlanticSharpProver, + AtlanticTerminalStatus, + AtlanticX402Network, + CreateBucketInput, + CreateBucketResult, + GetBucketResult, + GetQueryJobsResult, + GetQueryResult, + PaginatedResult, + PaginationInput, + RetryQueryResult, + SubmitQueryInput, + SubmitQueryResult, + X402Authorization, + X402Challenge, + X402PaymentPayload, + X402PaymentRequiredResponse, + X402PaymentRequirement, + X402PaymentRequirementExtra, + X402SettlementResponse, +} from './services/atlantic/types'; +export { isTerminalStatus } from './services/atlantic/types'; diff --git a/src/services/atlantic/cli.ts b/src/services/atlantic/cli.ts new file mode 100644 index 0000000..f945894 --- /dev/null +++ b/src/services/atlantic/cli.ts @@ -0,0 +1,206 @@ +import type { CliCommandContext, CliFlags, ServiceCli } from '../../cli/registry'; +import type { AtlanticService } from './client'; +import type { AtlanticJobSize, SubmitQueryInput } from './types'; +import { createBucketAndSubmit, submitToBucket } from './workflows/buckets'; +import { getQueryWithJobs, retryIfRetriable, submitAndWait } from './workflows/queries'; + +interface AtlanticCommandContext extends CliCommandContext { + service: AtlanticService; +} + +function withService(handler: (context: AtlanticCommandContext) => Promise) { + return async (context: CliCommandContext) => handler({ ...context, service: context.client.atlantic }); +} + +export const atlanticCli: ServiceCli = { + name: 'atlantic', + description: 'Atlantic proving service', + commands: { + health: { + description: 'Check the Atlantic API is reachable', + run: withService(({ service }) => service.healthCheck()), + }, + 'submit-query': { + description: 'Submit a Cairo proving query', + run: withService(({ service, flags }) => service.submitQuery(readSubmitInput(flags))), + }, + 'submit-and-wait': { + description: 'Submit a query and wait for it to reach a terminal status', + run: withService(({ service, flags }) => submitAndWait(service, readSubmitInput(flags), readWaitOptions(flags))), + }, + 'retry-query': { + description: 'Retry a failed query', + run: withService(({ service, flags, positionals }) => + service.retryQuery(requiredId(flags, positionals, 'query-id')), + ), + }, + 'retry-if-retriable': { + description: 'Retry only when the query is retriable', + run: withService(({ service, flags, positionals }) => + retryIfRetriable(service, requiredId(flags, positionals, 'query-id')), + ), + }, + 'get-query-details': { + description: 'Fetch query details and metadata URLs', + run: withService(({ service, flags, positionals }) => + service.getQuery(requiredId(flags, positionals, 'query-id')), + ), + }, + 'get-query-by-dedup-id': { + description: 'Fetch a query by deduplication ID', + run: withService(({ service, flags, positionals }) => + service.getQueryByDedupId(requiredId(flags, positionals, 'dedup-id')), + ), + }, + 'get-my-queries': { + description: 'List queries for the configured API key', + run: withService(({ service, flags }) => service.listQueries(readPagination(flags))), + }, + 'get-query-jobs': { + description: 'Fetch processing jobs for a query', + run: withService(({ service, flags, positionals }) => + service.getQueryJobs(requiredId(flags, positionals, 'query-id')), + ), + }, + 'get-query-with-jobs': { + description: 'Fetch query details and jobs together', + run: withService(({ service, flags, positionals }) => + getQueryWithJobs(service, requiredId(flags, positionals, 'query-id')), + ), + }, + 'get-query-stats': { + description: 'Aggregate query statistics for the configured API key', + run: withService(({ service }) => service.getQueryStats()), + }, + 'list-buckets': { + description: 'List Applicative Recursion buckets', + run: withService(({ service, flags }) => service.listBuckets(readPagination(flags))), + }, + 'create-bucket': { + description: 'Create an Applicative Recursion bucket', + run: withService(({ service, flags }) => + service.createBucket( + compact({ + aggregatorVersion: requiredString(flags, 'aggregator-version'), + externalId: optionalString(flags, 'external-id') ?? null, + nodeWidth: optionalNumber(flags, 'node-width') ?? null, + mockProof: optionalBoolean(flags, 'mock-proof') ?? null, + }), + ), + ), + }, + 'get-bucket': { + description: 'Fetch bucket details and associated queries', + run: withService(({ service, flags, positionals }) => + service.getBucket(requiredId(flags, positionals, 'bucket-id')), + ), + }, + 'close-bucket': { + description: 'Close an Applicative Recursion bucket', + run: withService(({ service, flags, positionals }) => + service.closeBucket(requiredId(flags, positionals, 'bucket-id')), + ), + }, + 'submit-to-bucket': { + description: 'Submit a query into an existing bucket', + run: withService(({ service, flags }) => + submitToBucket(service, { + bucketId: requiredString(flags, 'bucket-id'), + bucketJobIndex: requiredNumber(flags, 'bucket-job-index'), + query: readSubmitInput(flags), + }), + ), + }, + 'create-bucket-and-submit': { + description: 'Create a bucket and submit indexed queries', + run: withService(({ service, flags }) => + createBucketAndSubmit(service, { + bucket: { aggregatorVersion: requiredString(flags, 'aggregator-version') }, + queries: [readSubmitInput(flags)], + }), + ), + }, + }, +}; + +function readSubmitInput(flags: CliFlags): SubmitQueryInput { + const input: Partial = { + declaredJobSize: (optionalString(flags, 'declared-job-size') ?? 'S') as AtlanticJobSize, + }; + setIfPresent(input, 'externalId', optionalString(flags, 'external-id')); + setIfPresent(input, 'dedupId', optionalString(flags, 'dedup-id')); + setIfPresent(input, 'layout', optionalString(flags, 'layout') as SubmitQueryInput['layout']); + setIfPresent(input, 'cairoVm', optionalString(flags, 'cairo-vm') as SubmitQueryInput['cairoVm']); + setIfPresent(input, 'cairoVersion', optionalString(flags, 'cairo-version') as SubmitQueryInput['cairoVersion']); + setIfPresent(input, 'result', optionalString(flags, 'result') as SubmitQueryInput['result']); + setIfPresent(input, 'network', optionalString(flags, 'network') as SubmitQueryInput['network']); + setIfPresent(input, 'sharpProver', optionalString(flags, 'sharp-prover') as SubmitQueryInput['sharpProver']); + setIfPresent(input, 'programHash', optionalString(flags, 'program-hash')); + setIfPresent(input, 'programFile', optionalString(flags, 'program-file')); + setIfPresent(input, 'inputFile', optionalString(flags, 'input-file')); + setIfPresent(input, 'pieFile', optionalString(flags, 'pie-file')); + setIfPresent(input, 'proofFile', optionalString(flags, 'proof-file')); + setIfPresent(input, 'bucketId', optionalString(flags, 'bucket-id')); + setIfPresent(input, 'bucketJobIndex', optionalNumber(flags, 'bucket-job-index')); + return input as SubmitQueryInput; +} + +function readWaitOptions(flags: CliFlags) { + const options: { intervalMs?: number; timeoutMs?: number } = {}; + setIfPresent(options, 'intervalMs', optionalNumber(flags, 'interval-ms')); + setIfPresent(options, 'timeoutMs', optionalNumber(flags, 'timeout-ms')); + return options; +} + +function readPagination(flags: CliFlags) { + const pagination: { limit?: number; offset?: number } = {}; + setIfPresent(pagination, 'limit', optionalNumber(flags, 'limit')); + setIfPresent(pagination, 'offset', optionalNumber(flags, 'offset')); + return pagination; +} + +function requiredId(flags: CliFlags, positionals: string[], flagName: string): string { + return optionalString(flags, flagName) ?? positionals[0] ?? fail(`Missing required ${flagName}`); +} + +function requiredString(flags: CliFlags, name: string): string { + return optionalString(flags, name) ?? fail(`Missing required --${name}`); +} + +function requiredNumber(flags: CliFlags, name: string): number { + return optionalNumber(flags, name) ?? fail(`Missing required --${name}`); +} + +function optionalString(flags: CliFlags, name: string): string | undefined { + const value = flags[name]; + return typeof value === 'string' && value.length > 0 ? value : undefined; +} + +function optionalNumber(flags: CliFlags, name: string): number | undefined { + const value = optionalString(flags, name); + if (value === undefined) return undefined; + const number = Number(value); + if (!Number.isFinite(number)) fail(`--${name} must be a number`); + return number; +} + +function optionalBoolean(flags: CliFlags, name: string): boolean | undefined { + const value = flags[name]; + if (value === undefined) return undefined; + if (typeof value === 'boolean') return value; + if (value === 'true') return true; + if (value === 'false') return false; + fail(`--${name} must be true or false`); +} + +function fail(message: string): never { + throw new Error(message); +} + +function compact>(value: T): T { + return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined)) as T; +} + +function setIfPresent(target: T, key: K, value: T[K] | undefined): void { + if (value !== undefined) target[key] = value; +} diff --git a/src/client/atlantic-client.ts b/src/services/atlantic/client.ts similarity index 60% rename from src/client/atlantic-client.ts rename to src/services/atlantic/client.ts index 0fac530..f16b853 100644 --- a/src/client/atlantic-client.ts +++ b/src/services/atlantic/client.ts @@ -1,6 +1,5 @@ +import type { HttpClient } from '../../core/http'; import { buildSubmitQueryForm } from './multipart'; -import { requestAtlantic, sendAtlanticRequest, parseAtlanticResponse } from './http'; -import { resolveAtlanticConfig, type AtlanticClientConfig, type ResolvedAtlanticClientConfig } from './config'; import type { AtlanticBucket, AtlanticQuery, @@ -15,34 +14,48 @@ import type { SubmitQueryInput, SubmitQueryResult, X402SettlementResponse, -} from '../types'; -import type { X402PaymentAdapter } from '../x402/adapter'; -import { submitWithX402 } from '../x402/payments'; -import { parsePaymentResponseHeader } from '../x402/headers'; +} from './types'; +import type { X402PaymentAdapter } from './x402/adapter'; +import { submitWithX402 } from './x402/payments'; +import { parsePaymentResponseHeader } from './x402/headers'; +import { parseResponse } from '../../core/http'; -export interface SubmitQueryOptions { +export interface AtlanticSubmitQueryOptions { paymentAdapter?: X402PaymentAdapter; anonymousPayment?: boolean; } -export interface AtlanticClientOptions extends AtlanticClientConfig { +export interface AtlanticServiceOptions { paymentAdapter?: X402PaymentAdapter; + /** Whether the active credential resolution yields an API key. Used to choose the default x402 anonymous mode. */ + hasApiKey: () => boolean; } -export class AtlanticClient { - readonly config: ResolvedAtlanticClientConfig; +/** + * Atlantic proving service surface, accessed via `client.atlantic` on `HcloudClient`. + * + * Wraps the documented Atlantic API endpoints behind typed methods and integrates + * with the optional x402 payment flow for anonymous and API-key submissions. + */ +export class AtlanticService { readonly paymentAdapter: X402PaymentAdapter | undefined; + private readonly hasApiKey: () => boolean; - constructor(options: AtlanticClientOptions = {}) { - this.config = resolveAtlanticConfig(options); + constructor( + private readonly http: HttpClient, + options: AtlanticServiceOptions, + ) { this.paymentAdapter = options.paymentAdapter; + this.hasApiKey = options.hasApiKey; } /** Check whether the Atlantic API is reachable. */ async healthCheck(): Promise<{ alive: boolean }> { - const response = await requestAtlantic<{ alive: boolean }>(this.config, { + const response = await this.http.request<{ alive: boolean }>({ method: 'GET', + surface: 'atlantic', path: '/is-alive', + authMode: 'api-key', }); return response.data; } @@ -53,118 +66,142 @@ export class AtlanticClient { * Use this low-level method when you want direct control over query options and lifecycle handling. * Provide `paymentAdapter` when you want the SDK to complete Atlantic's x402 payment challenge. */ - async submitQuery(input: SubmitQueryInput, options: SubmitQueryOptions = {}): Promise { + async submitQuery( + input: SubmitQueryInput, + options: AtlanticSubmitQueryOptions = {}, + ): Promise { const adapter = options.paymentAdapter ?? this.paymentAdapter; if (adapter) { - return submitWithX402(this.config, input, adapter, { - anonymousPayment: options.anonymousPayment ?? !this.config.apiKey, + return submitWithX402(this.http, input, adapter, { + anonymousPayment: options.anonymousPayment ?? !this.hasApiKey(), }); } const form = await buildSubmitQueryForm(input); - const response = await sendAtlanticRequest(this.config, { + const response = await this.http.send({ method: 'POST', + surface: 'atlantic', path: '/atlantic-query', body: form, - expectStatus: 201, + authMode: 'api-key', }); - const parsed = await parseAtlanticResponse<{ atlanticQueryId: string }>(response, 201); + const parsed = await parseResponse<{ atlanticQueryId: string }>(response, 'atlantic', 201); const payment = parsePaymentResponseHeader(response.headers); return withPayment(parsed.data, payment); } /** Fetch query details and metadata URLs by Atlantic query ID. */ async getQuery(atlanticQueryId: string): Promise { - const response = await requestAtlantic(this.config, { + const response = await this.http.request({ method: 'GET', + surface: 'atlantic', path: `/atlantic-query/${encodeURIComponent(atlanticQueryId)}`, + authMode: 'api-key', }); return response.data; } /** Fetch a query by deduplication ID. Requires API-key flow; anonymous x402 queries do not support dedup IDs. */ async getQueryByDedupId(dedupId: string): Promise<{ atlanticQuery: AtlanticQuery }> { - const response = await requestAtlantic<{ atlanticQuery: AtlanticQuery }>(this.config, { + const response = await this.http.request<{ atlanticQuery: AtlanticQuery }>({ method: 'GET', + surface: 'atlantic', path: '/atlantic-query-by-dedup-id', query: { dedupId }, + authMode: 'api-key', }); return response.data; } /** List queries submitted with the configured API key. */ async listQueries(pagination: PaginationInput = {}): Promise> { - const response = await requestAtlantic>(this.config, { + const response = await this.http.request>({ method: 'GET', + surface: 'atlantic', path: '/atlantic-queries', query: { ...pagination }, + authMode: 'api-key', }); return response.data; } /** Fetch aggregate query statistics for the configured API key. */ async getQueryStats(): Promise> { - const response = await requestAtlantic>(this.config, { + const response = await this.http.request>({ method: 'GET', + surface: 'atlantic', path: '/atlantic-queries/stats', + authMode: 'api-key', }); return response.data; } /** Fetch Atlantic processing jobs and step names for a query. */ async getQueryJobs(atlanticQueryId: string): Promise { - const response = await requestAtlantic(this.config, { + const response = await this.http.request({ method: 'GET', + surface: 'atlantic', path: `/atlantic-query-jobs/${encodeURIComponent(atlanticQueryId)}`, + authMode: 'api-key', }); return response.data; } /** Retry a failed Atlantic query when the API marks it as retriable. */ async retryQuery(atlanticQueryId: string): Promise { - const response = await requestAtlantic(this.config, { + const response = await this.http.request({ method: 'POST', + surface: 'atlantic', path: `/atlantic-queries/${encodeURIComponent(atlanticQueryId)}/retry`, + authMode: 'api-key', }); return response.data; } /** List Applicative Recursion buckets submitted with the configured API key. */ async listBuckets(pagination: PaginationInput = {}): Promise> { - const response = await requestAtlantic>(this.config, { + const response = await this.http.request>({ method: 'GET', + surface: 'atlantic', path: '/buckets', query: { ...pagination }, + authMode: 'api-key', }); return response.data; } /** Create an Applicative Recursion bucket for grouping query jobs. */ async createBucket(input: CreateBucketInput): Promise { - const response = await requestAtlantic(this.config, { + const response = await this.http.request({ method: 'POST', + surface: 'atlantic', path: '/buckets', body: JSON.stringify(input), headers: { 'content-type': 'application/json' }, + authMode: 'api-key', }); return response.data; } /** Fetch bucket details and the queries currently associated with the bucket. */ async getBucket(bucketId: string): Promise { - const response = await requestAtlantic(this.config, { + const response = await this.http.request({ method: 'GET', + surface: 'atlantic', path: `/buckets/${encodeURIComponent(bucketId)}`, + authMode: 'api-key', }); return response.data; } /** Close an Applicative Recursion bucket. */ async closeBucket(bucketId: string): Promise { - const response = await requestAtlantic(this.config, { + const response = await this.http.request({ method: 'POST', + surface: 'atlantic', path: '/buckets/close', query: { bucketId }, + authMode: 'api-key', }); return response.data; } diff --git a/src/services/atlantic/index.ts b/src/services/atlantic/index.ts new file mode 100644 index 0000000..a82e7ec --- /dev/null +++ b/src/services/atlantic/index.ts @@ -0,0 +1,35 @@ +export { AtlanticService } from './client'; +export type { AtlanticServiceOptions, AtlanticSubmitQueryOptions } from './client'; +export * from './types'; +export { + submitAndReturnId, + waitForQuery, + submitAndWait, + retryIfRetriable, + getQueryWithJobs, +} from './workflows/queries'; +export type { + WaitForQueryOptions, + WaitForQueryResult, + SubmitAndWaitOptions, + QueryWithJobsResult, +} from './workflows/queries'; +export { submitToBucket, createBucketAndSubmit } from './workflows/buckets'; +export type { + SubmitToBucketInput, + CreateBucketAndSubmitInput, + CreateBucketAndSubmitResult, +} from './workflows/buckets'; +export type { X402PaymentAdapter, X402PaymentRequest } from './x402/adapter'; +export { + PAYMENT_REQUIRED_HEADER, + PAYMENT_SIGNATURE_HEADER, + PAYMENT_RESPONSE_HEADER, + encodePaymentPayload, + parsePaymentRequiredHeader, + parsePaymentResponseHeader, +} from './x402/headers'; +export { isPaymentError } from './x402/payments'; +export { createPrivateKeyPaymentAdapter } from './x402/private-key-adapter'; +export type { PrivateKeyPaymentAdapterOptions } from './x402/private-key-adapter'; +export { atlanticCli } from './cli'; diff --git a/src/client/multipart.ts b/src/services/atlantic/multipart.ts similarity index 98% rename from src/client/multipart.ts rename to src/services/atlantic/multipart.ts index 2c4de8a..aa0a8b5 100644 --- a/src/client/multipart.ts +++ b/src/services/atlantic/multipart.ts @@ -1,4 +1,4 @@ -import type { AtlanticFileReference, AtlanticFileValue, SubmitQueryInput } from '../types'; +import type { AtlanticFileReference, AtlanticFileValue, SubmitQueryInput } from './types'; type MultipartValue = string | Blob; diff --git a/src/types.ts b/src/services/atlantic/types.ts similarity index 100% rename from src/types.ts rename to src/services/atlantic/types.ts diff --git a/src/workflows/buckets.ts b/src/services/atlantic/workflows/buckets.ts similarity index 87% rename from src/workflows/buckets.ts rename to src/services/atlantic/workflows/buckets.ts index 2a216b8..273380e 100644 --- a/src/workflows/buckets.ts +++ b/src/services/atlantic/workflows/buckets.ts @@ -1,4 +1,4 @@ -import type { AtlanticClient } from '../client/atlantic-client'; +import type { AtlanticService } from '../client'; import type { CreateBucketInput, CreateBucketResult, SubmitQueryInput, SubmitQueryResult } from '../types'; export interface SubmitToBucketInput { @@ -18,7 +18,7 @@ export interface CreateBucketAndSubmitResult { } /** Submit a query into an existing Applicative Recursion bucket. */ -export async function submitToBucket(client: AtlanticClient, input: SubmitToBucketInput): Promise { +export async function submitToBucket(client: AtlanticService, input: SubmitToBucketInput): Promise { return client.submitQuery({ ...input.query, bucketId: input.bucketId, @@ -33,7 +33,7 @@ export async function submitToBucket(client: AtlanticClient, input: SubmitToBuck * persist every durable Atlantic identifier. */ export async function createBucketAndSubmit( - client: AtlanticClient, + client: AtlanticService, input: CreateBucketAndSubmitInput, ): Promise { const bucket = await client.createBucket(input.bucket); diff --git a/src/workflows/queries.ts b/src/services/atlantic/workflows/queries.ts similarity index 85% rename from src/workflows/queries.ts rename to src/services/atlantic/workflows/queries.ts index 9616a13..626b85b 100644 --- a/src/workflows/queries.ts +++ b/src/services/atlantic/workflows/queries.ts @@ -1,5 +1,5 @@ -import { AtlanticSdkError } from '../errors'; -import type { AtlanticClient } from '../client/atlantic-client'; +import { HcloudError } from '../../../core/errors'; +import type { AtlanticService } from '../client'; import type { AtlanticQuery, AtlanticQueryStatus, @@ -35,7 +35,7 @@ const DEFAULT_TIMEOUT_MS = 10 * 60_000; * * Use this helper when the caller persists query IDs and handles polling separately. */ -export async function submitAndReturnId(client: AtlanticClient, input: SubmitQueryInput): Promise { +export async function submitAndReturnId(client: AtlanticService, input: SubmitQueryInput): Promise { return client.submitQuery(input); } @@ -46,7 +46,7 @@ export async function submitAndReturnId(client: AtlanticClient, input: SubmitQue * losing the durable Atlantic query ID. */ export async function waitForQuery( - client: AtlanticClient, + client: AtlanticService, atlanticQueryId: string, options: WaitForQueryOptions = {}, ): Promise { @@ -68,7 +68,7 @@ export async function waitForQuery( await sleep(intervalMs); } - throw new AtlanticSdkError({ + throw new HcloudError({ kind: 'timeout', message: `Timed out waiting for Atlantic query ${atlanticQueryId}`, details: { atlanticQueryId, observedStatuses, lastResult }, @@ -77,7 +77,7 @@ export async function waitForQuery( /** Submit a query and wait for it to reach a terminal status. */ export async function submitAndWait( - client: AtlanticClient, + client: AtlanticService, input: SubmitQueryInput, options: SubmitAndWaitOptions = {}, ): Promise { @@ -92,21 +92,21 @@ export async function submitAndWait( * This helper avoids retrying queries that are not failed, exceeded retry budget, or were * rejected by Atlantic as non-retriable. */ -export async function retryIfRetriable(client: AtlanticClient, atlanticQueryId: string): Promise { +export async function retryIfRetriable(client: AtlanticService, atlanticQueryId: string): Promise { const current = await client.getQuery(atlanticQueryId); assertRetriable(current.atlanticQuery); return client.retryQuery(atlanticQueryId); } /** Fetch query details and query jobs together for lifecycle inspection. */ -export async function getQueryWithJobs(client: AtlanticClient, atlanticQueryId: string): Promise { +export async function getQueryWithJobs(client: AtlanticService, atlanticQueryId: string): Promise { const [query, jobs] = await Promise.all([client.getQuery(atlanticQueryId), client.getQueryJobs(atlanticQueryId)]); return { ...query, jobs }; } function assertRetriable(query: AtlanticQuery): void { if (query.status !== 'FAILED') { - throw new AtlanticSdkError({ + throw new HcloudError({ kind: 'validation', code: 'QUERY_NOT_IN_FAILED_STATUS', message: 'Atlantic query is not in FAILED status', @@ -114,7 +114,7 @@ function assertRetriable(query: AtlanticQuery): void { }); } if (query.isRetriable === false) { - throw new AtlanticSdkError({ + throw new HcloudError({ kind: 'validation', code: query.retryBlockedReason ?? 'QUERY_NOT_RETRIABLE', message: query.retryBlockedReason ?? 'QUERY_NOT_RETRIABLE', diff --git a/src/x402/adapter.ts b/src/services/atlantic/x402/adapter.ts similarity index 100% rename from src/x402/adapter.ts rename to src/services/atlantic/x402/adapter.ts diff --git a/src/x402/headers.ts b/src/services/atlantic/x402/headers.ts similarity index 94% rename from src/x402/headers.ts rename to src/services/atlantic/x402/headers.ts index d8187b8..96fd6ca 100644 --- a/src/x402/headers.ts +++ b/src/services/atlantic/x402/headers.ts @@ -1,4 +1,4 @@ -import { AtlanticSdkError } from '../errors'; +import { HcloudError } from '../../../core/errors'; import type { X402Challenge, X402PaymentPayload, X402SettlementResponse } from '../types'; export const PAYMENT_REQUIRED_HEADER = 'PAYMENT-REQUIRED'; @@ -27,7 +27,7 @@ export function decodeBase64Json(value: string, kind: 'payment_challenge' | ' try { return JSON.parse(Buffer.from(value, 'base64').toString('utf8')) as T; } catch (cause) { - throw new AtlanticSdkError({ + throw new HcloudError({ kind, message: `Invalid x402 ${kind === 'payment_challenge' ? 'challenge' : 'settlement'} header`, cause, diff --git a/src/x402/payments.ts b/src/services/atlantic/x402/payments.ts similarity index 76% rename from src/x402/payments.ts rename to src/services/atlantic/x402/payments.ts index bf464f3..749f2d7 100644 --- a/src/x402/payments.ts +++ b/src/services/atlantic/x402/payments.ts @@ -1,7 +1,7 @@ -import { buildSubmitQueryForm } from '../client/multipart'; -import { parseAtlanticResponse, sendAtlanticRequest } from '../client/http'; -import type { ResolvedAtlanticClientConfig } from '../client/config'; -import { AtlanticSdkError, isAtlanticSdkError } from '../errors'; +import type { HttpClient } from '../../../core/http'; +import { parseResponse } from '../../../core/http'; +import { HcloudError, isHcloudError } from '../../../core/errors'; +import { buildSubmitQueryForm } from '../multipart'; import type { SubmitQueryInput, SubmitQueryResult, X402Challenge, X402PaymentRequiredResponse } from '../types'; import type { X402PaymentAdapter } from './adapter'; import { @@ -16,23 +16,25 @@ export interface X402SubmitOptions { } export async function submitWithX402( - config: ResolvedAtlanticClientConfig, + http: HttpClient, input: SubmitQueryInput, adapter: X402PaymentAdapter, options: X402SubmitOptions = {}, ): Promise { - assertAnonymousFlowSupported(input, options.anonymousPayment ?? !config.apiKey); + const anonymous = options.anonymousPayment ?? false; + assertAnonymousFlowSupported(input, anonymous); const firstForm = await buildSubmitQueryForm(input); - const firstResponse = await sendAtlanticRequest(config, { + const firstResponse = await http.send({ method: 'POST', + surface: 'atlantic', path: '/atlantic-query', body: firstForm, - expectStatus: 201, + authMode: anonymous ? 'none' : 'api-key', }); if (firstResponse.status !== 402) { - const parsed = await parseAtlanticResponse<{ atlanticQueryId: string }>(firstResponse, 201); + const parsed = await parseResponse<{ atlanticQueryId: string }>(firstResponse, 'atlantic', 201); const payment = parsePaymentResponseHeader(firstResponse.headers); return payment ? { ...parsed.data, payment } : parsed.data; } @@ -40,7 +42,7 @@ export async function submitWithX402( const challenge = await readChallenge(firstResponse); const requirement = challenge.accepts[0]; if (!requirement) { - throw new AtlanticSdkError({ + throw new HcloudError({ kind: 'payment_challenge', status: 402, code: challenge.error, @@ -51,16 +53,17 @@ export async function submitWithX402( const payment = await adapter.createPayment({ requirement }); const retryForm = await buildSubmitQueryForm(input); - const retryResponse = await sendAtlanticRequest(config, { + const retryResponse = await http.send({ method: 'POST', + surface: 'atlantic', path: '/atlantic-query', body: retryForm, headers: { [PAYMENT_SIGNATURE_HEADER]: encodePaymentPayload(payment), }, - expectStatus: 201, + authMode: anonymous ? 'none' : 'api-key', }); - const parsed = await parseAtlanticResponse<{ atlanticQueryId: string }>(retryResponse, 201); + const parsed = await parseResponse<{ atlanticQueryId: string }>(retryResponse, 'atlantic', 201); const settlement = parsePaymentResponseHeader(retryResponse.headers); return settlement ? { ...parsed.data, payment: settlement } : parsed.data; @@ -76,7 +79,7 @@ async function readChallenge(response: Response): Promise { body = JSON.parse(rawBody) as X402PaymentRequiredResponse; } catch (cause) { if (!headerChallenge) { - throw new AtlanticSdkError({ + throw new HcloudError({ kind: 'payment_challenge', status: 402, message: 'Atlantic returned 402 without a valid x402 challenge body', @@ -89,7 +92,7 @@ async function readChallenge(response: Response): Promise { const challenge = headerChallenge ?? body?.paymentRequired; if (!challenge) { - throw new AtlanticSdkError({ + throw new HcloudError({ kind: 'payment_challenge', status: 402, message: 'Atlantic returned 402 without PAYMENT-REQUIRED challenge data', @@ -103,14 +106,14 @@ async function readChallenge(response: Response): Promise { function assertAnonymousFlowSupported(input: SubmitQueryInput, anonymous: boolean): void { if (!anonymous) return; if (input.dedupId) { - throw new AtlanticSdkError({ + throw new HcloudError({ kind: 'payment', code: 'WALLET_FLOW_DEDUP_ID_NOT_SUPPORTED', message: 'Anonymous x402 flow does not support dedupId', }); } if (input.bucketId) { - throw new AtlanticSdkError({ + throw new HcloudError({ kind: 'payment', code: 'WALLET_FLOW_BUCKET_NOT_SUPPORTED', message: 'Anonymous x402 flow does not support bucketId', @@ -120,7 +123,7 @@ function assertAnonymousFlowSupported(input: SubmitQueryInput, anonymous: boolea export function isPaymentError(error: unknown): boolean { return ( - isAtlanticSdkError(error) && + isHcloudError(error) && (error.kind === 'payment' || error.kind === 'payment_challenge' || error.kind === 'payment_settlement') ); } diff --git a/src/x402/private-key-adapter.ts b/src/services/atlantic/x402/private-key-adapter.ts similarity index 97% rename from src/x402/private-key-adapter.ts rename to src/services/atlantic/x402/private-key-adapter.ts index 0e9f7d6..d9ca2a2 100644 --- a/src/x402/private-key-adapter.ts +++ b/src/services/atlantic/x402/private-key-adapter.ts @@ -1,6 +1,6 @@ import { privateKeyToAccount } from 'viem/accounts'; import type { Hex } from 'viem'; -import { AtlanticSdkError } from '../errors'; +import { HcloudError } from '../../../core/errors'; import type { X402PaymentAdapter } from './adapter'; import type { X402Authorization, X402PaymentPayload, X402PaymentRequirement } from '../types'; @@ -89,7 +89,7 @@ export function createPrivateKeyPaymentAdapter(options: PrivateKeyPaymentAdapter function assertAtlanticPaymentRequirement(requirement: X402PaymentRequirement): void { if (requirement.network !== ATLANTIC_X402_NETWORK) { - throw new AtlanticSdkError({ + throw new HcloudError({ kind: 'payment', code: 'X402_UNSUPPORTED_NETWORK', message: `Atlantic x402 payments are supported only on Base mainnet (${ATLANTIC_X402_NETWORK}); received ${requirement.network}`, @@ -98,7 +98,7 @@ function assertAtlanticPaymentRequirement(requirement: X402PaymentRequirement): } if (requirement.extra.name !== USD_COIN_DOMAIN_NAME) { - throw new AtlanticSdkError({ + throw new HcloudError({ kind: 'payment', code: 'X402_UNSUPPORTED_ASSET', message: `Atlantic x402 payments accept USD Coin only; received ${requirement.extra.name}`, diff --git a/test/cli.test.ts b/test/cli.test.ts index 1ab98ac..275d98e 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from 'bun:test'; import { formatCliError } from '../src/cli/output'; -import { parseArgs } from '../src/cli/commands'; -import { AtlanticSdkError } from '../src/errors'; +import { parseArgs } from '../src/cli/dispatcher'; +import { HcloudError } from '../src/core/errors'; describe('CLI utilities', () => { test('parses flags and positional arguments', () => { @@ -12,7 +12,7 @@ describe('CLI utilities', () => { }); test('formats structured SDK errors as JSON-safe objects', () => { - const error = new AtlanticSdkError({ + const error = new HcloudError({ kind: 'api', message: 'INVALID_API_KEY', status: 401, diff --git a/test/client.test.ts b/test/client.test.ts index ecca801..c9625de 100644 --- a/test/client.test.ts +++ b/test/client.test.ts @@ -1,11 +1,11 @@ import { describe, expect, test } from 'bun:test'; -import { AtlanticClient } from '../src/client/atlantic-client'; +import { HcloudClient } from '../src/hcloud-client'; -describe('AtlanticClient', () => { +describe('AtlanticService via HcloudClient', () => { test('maps direct API methods to documented endpoints', async () => { const calls: Request[] = []; - const client = new AtlanticClient({ - baseUrl: 'https://example.com', + const client = new HcloudClient({ + baseUrls: { atlantic: 'https://example.com' }, apiKey: 'key', fetch: (input, init) => { const request = new Request(input, init); @@ -14,17 +14,17 @@ describe('AtlanticClient', () => { }, }); - await client.healthCheck(); - await client.getQuery('query-1'); - await client.getQueryByDedupId('dedup-1'); - await client.listQueries({ limit: 10, offset: 5 }); - await client.getQueryStats(); - await client.getQueryJobs('query-1'); - await client.retryQuery('query-1'); - await client.listBuckets({ limit: 2 }); - await client.createBucket({ aggregatorVersion: 'STONE' }); - await client.getBucket('bucket-1'); - await client.closeBucket('bucket-1'); + await client.atlantic.healthCheck(); + await client.atlantic.getQuery('query-1'); + await client.atlantic.getQueryByDedupId('dedup-1'); + await client.atlantic.listQueries({ limit: 10, offset: 5 }); + await client.atlantic.getQueryStats(); + await client.atlantic.getQueryJobs('query-1'); + await client.atlantic.retryQuery('query-1'); + await client.atlantic.listBuckets({ limit: 2 }); + await client.atlantic.createBucket({ aggregatorVersion: 'STONE' }); + await client.atlantic.getBucket('bucket-1'); + await client.atlantic.closeBucket('bucket-1'); expect(calls.map((call) => `${call.method} ${new URL(call.url).pathname}`)).toEqual([ 'GET /is-alive', @@ -42,15 +42,33 @@ describe('AtlanticClient', () => { }); test('submits multipart query and returns query id', async () => { - const client = new AtlanticClient({ - baseUrl: 'https://example.com', + const client = new HcloudClient({ + baseUrls: { atlantic: 'https://example.com' }, fetch: () => Promise.resolve(Response.json({ atlanticQueryId: 'query-1' }, { status: 201 })), }); - await expect(client.submitQuery({ declaredJobSize: 'S', pieFile: new Blob(['zip']) })).resolves.toEqual({ + await expect( + client.atlantic.submitQuery({ declaredJobSize: 'S', pieFile: new Blob(['zip']) }), + ).resolves.toEqual({ atlanticQueryId: 'query-1', }); }); + + test('attaches api-key header to atlantic requests', async () => { + const calls: Request[] = []; + const client = new HcloudClient({ + baseUrls: { atlantic: 'https://example.com' }, + apiKey: 'secret', + fetch: (input, init) => { + calls.push(new Request(input, init)); + return Promise.resolve(Response.json({ alive: true })); + }, + }); + + await client.atlantic.healthCheck(); + + expect(calls[0]?.headers.get('api-key')).toBe('secret'); + }); }); function responseFor(request: Request): unknown { diff --git a/test/docs.test.ts b/test/docs.test.ts index f333f0b..8dd7d54 100644 --- a/test/docs.test.ts +++ b/test/docs.test.ts @@ -1,10 +1,10 @@ import { describe, expect, test } from 'bun:test'; describe('documentation surface', () => { - test('README documents x402 challenge handling and global CLI install', async () => { + test('README documents the hcloud package and CLI install', async () => { const reference = await Bun.file('README.md').text(); - expect(reference).toContain('npm install -g @herodotus_dev/atlantic-sdk'); + expect(reference).toContain('npm install -g @herodotus_dev/hcloud'); expect(reference).toContain('waits for a real `402` challenge'); }); diff --git a/test/errors.test.ts b/test/errors.test.ts index f9ef349..ae2154b 100644 --- a/test/errors.test.ts +++ b/test/errors.test.ts @@ -1,9 +1,9 @@ import { describe, expect, test } from 'bun:test'; -import { AtlanticSdkError, normalizeErrorCode } from '../src/errors'; +import { HcloudError, normalizeErrorCode } from '../src/core/errors'; -describe('AtlanticSdkError', () => { +describe('HcloudError', () => { test('serializes structured error fields', () => { - const error = new AtlanticSdkError({ + const error = new HcloudError({ kind: 'api', message: 'INVALID_API_KEY', status: 401, diff --git a/test/http.test.ts b/test/http.test.ts index 3ce158c..b48b4ca 100644 --- a/test/http.test.ts +++ b/test/http.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from 'bun:test'; -import { resolveAtlanticConfig } from '../src/client/config'; -import { buildUrl, requestAtlantic } from '../src/client/http'; -import { AtlanticSdkError } from '../src/errors'; +import { resolveCoreConfig } from '../src/core/config'; +import { HttpClient, StaticAuthHeaderProvider, buildUrl } from '../src/core/http'; +import { HcloudError } from '../src/core/errors'; describe('HTTP transport', () => { test('builds URLs with query parameters', () => { @@ -15,28 +15,65 @@ describe('HTTP transport', () => { test('adds api-key header when configured', async () => { const calls: Request[] = []; - const config = resolveAtlanticConfig({ - baseUrl: 'https://example.com', - apiKey: 'key', + const config = resolveCoreConfig({ + baseUrls: { atlantic: 'https://example.com' }, fetch: (input, init) => { calls.push(new Request(input, init)); return Promise.resolve(Response.json({ alive: true })); }, }); + const http = new HttpClient({ config, auth: new StaticAuthHeaderProvider('key') }); - await requestAtlantic(config, { method: 'GET', path: '/is-alive' }); + await http.request({ method: 'GET', surface: 'atlantic', path: '/is-alive', authMode: 'api-key' }); expect(calls[0]?.headers.get('api-key')).toBe('key'); }); test('throws structured API errors', async () => { - const config = resolveAtlanticConfig({ - baseUrl: 'https://example.com', + const config = resolveCoreConfig({ + baseUrls: { atlantic: 'https://example.com' }, fetch: () => Promise.resolve(Response.json({ message: 'INVALID_API_KEY' }, { status: 401 })), }); + const http = new HttpClient({ config, auth: new StaticAuthHeaderProvider(undefined) }); - await expect(requestAtlantic(config, { method: 'GET', path: '/is-alive' })).rejects.toBeInstanceOf( - AtlanticSdkError, - ); + await expect( + http.request({ method: 'GET', surface: 'atlantic', path: '/is-alive', authMode: 'api-key' }), + ).rejects.toBeInstanceOf(HcloudError); + }); + + test('rejects bearer requests carrying a Cookie header (channel binding)', async () => { + const config = resolveCoreConfig({ + baseUrls: { 'auth-billing': 'https://auth.example.com' }, + fetch: () => Promise.resolve(Response.json({})), + }); + const http = new HttpClient({ config, auth: new StaticAuthHeaderProvider(undefined) }); + + await expect( + http.send({ + method: 'GET', + surface: 'auth-billing', + path: '/api-keys', + authMode: 'bearer', + headers: { cookie: 'session=abc' }, + }), + ).rejects.toMatchObject({ kind: 'channel_binding' }); + }); + + test('rejects api-key requests carrying an Authorization header (channel binding)', async () => { + const config = resolveCoreConfig({ + baseUrls: { atlantic: 'https://example.com' }, + fetch: () => Promise.resolve(Response.json({})), + }); + const http = new HttpClient({ config, auth: new StaticAuthHeaderProvider('key') }); + + await expect( + http.send({ + method: 'GET', + surface: 'atlantic', + path: '/is-alive', + authMode: 'api-key', + headers: { authorization: 'Bearer x' }, + }), + ).rejects.toMatchObject({ kind: 'channel_binding' }); }); }); diff --git a/test/multipart.test.ts b/test/multipart.test.ts index 88ac124..08eee80 100644 --- a/test/multipart.test.ts +++ b/test/multipart.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from 'bun:test'; -import { buildSubmitQueryForm } from '../src/client/multipart'; +import { buildSubmitQueryForm } from '../src/services/atlantic/multipart'; describe('multipart submit query form', () => { test('serializes scalar fields and files', async () => { diff --git a/test/private-key-adapter.test.ts b/test/private-key-adapter.test.ts index 12432de..4a5f204 100644 --- a/test/private-key-adapter.test.ts +++ b/test/private-key-adapter.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from 'bun:test'; -import { createPrivateKeyPaymentAdapter } from '../src/x402/private-key-adapter'; -import type { X402PaymentRequirement } from '../src/types'; +import { createPrivateKeyPaymentAdapter } from '../src/services/atlantic/x402/private-key-adapter'; +import type { X402PaymentRequirement } from '../src/services/atlantic/types'; describe('private key x402 payment adapter', () => { test('signs a payment requirement and preserves it verbatim', async () => { diff --git a/test/scaffold.test.ts b/test/scaffold.test.ts index 81a2ace..db1059d 100644 --- a/test/scaffold.test.ts +++ b/test/scaffold.test.ts @@ -5,6 +5,15 @@ describe('package scaffold', () => { const sdk = await import('../src/index'); expect(sdk).toBeDefined(); + expect(sdk.HcloudClient).toBeTypeOf('function'); + }); + + test('exposes the atlantic service on the client', async () => { + const { HcloudClient } = await import('../src/index'); + const client = new HcloudClient(); + + expect(client.atlantic).toBeDefined(); + expect(client.atlantic.healthCheck).toBeTypeOf('function'); }); test('imports the CLI entrypoint without executing a command', async () => { diff --git a/test/workflows.test.ts b/test/workflows.test.ts index 0523d4b..506e0c3 100644 --- a/test/workflows.test.ts +++ b/test/workflows.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from 'bun:test'; -import { AtlanticClient } from '../src/client/atlantic-client'; -import { getQueryWithJobs, retryIfRetriable, waitForQuery } from '../src/workflows/queries'; -import { createBucketAndSubmit } from '../src/workflows/buckets'; +import type { AtlanticService } from '../src/services/atlantic/client'; +import { getQueryWithJobs, retryIfRetriable, waitForQuery } from '../src/services/atlantic/workflows/queries'; +import { createBucketAndSubmit } from '../src/services/atlantic/workflows/buckets'; describe('workflow helpers', () => { test('waits until a query reaches DONE', async () => { @@ -65,8 +65,8 @@ describe('workflow helpers', () => { }); }); -function fakeClient(overrides: Partial): AtlanticClient { - return overrides as AtlanticClient; +function fakeClient(overrides: Partial): AtlanticService { + return overrides as AtlanticService; } function queryResult(status: 'DONE' | 'FAILED' | 'IN_PROGRESS') { @@ -108,5 +108,5 @@ function queryResult(status: 'DONE' | 'FAILED' | 'IN_PROGRESS') { createdAt: '2026-05-06T00:00:00.000Z', }, metadataUrls: [], - } satisfies Awaited>; + } satisfies Awaited>; } diff --git a/test/x402.test.ts b/test/x402.test.ts index ec6467b..f8e9aa8 100644 --- a/test/x402.test.ts +++ b/test/x402.test.ts @@ -1,8 +1,8 @@ import { describe, expect, test } from 'bun:test'; -import { AtlanticClient } from '../src/client/atlantic-client'; -import { AtlanticSdkError } from '../src/errors'; -import { encodeBase64Json, parsePaymentResponseHeader } from '../src/x402/headers'; -import type { X402PaymentRequirement } from '../src/types'; +import { HcloudClient } from '../src/hcloud-client'; +import { HcloudError } from '../src/core/errors'; +import { encodeBase64Json, parsePaymentResponseHeader } from '../src/services/atlantic/x402/headers'; +import type { X402PaymentRequirement } from '../src/services/atlantic/types'; describe('x402 payments', () => { test('parses payment response header', () => { @@ -31,8 +31,8 @@ describe('x402 payments', () => { extra: { name: 'USD Coin', version: '2', challengeId: 'challenge-1' }, }; let calls = 0; - const client = new AtlanticClient({ - baseUrl: 'https://example.com', + const client = new HcloudClient({ + baseUrls: { atlantic: 'https://example.com' }, fetch: (_input, init) => { calls += 1; if (calls === 1) { @@ -78,7 +78,7 @@ describe('x402 payments', () => { }, }); - const result = await client.submitQuery( + const result = await client.atlantic.submitQuery( { declaredJobSize: 'S', pieFile: new Blob(['zip']) }, { anonymousPayment: true, @@ -108,10 +108,10 @@ describe('x402 payments', () => { }); test('rejects anonymous dedup flow before paying', async () => { - const client = new AtlanticClient({ baseUrl: 'https://example.com' }); + const client = new HcloudClient({ baseUrls: { atlantic: 'https://example.com' } }); await expect( - client.submitQuery( + client.atlantic.submitQuery( { declaredJobSize: 'S', dedupId: 'dedup-1', @@ -128,6 +128,6 @@ describe('x402 payments', () => { ), ).rejects.toMatchObject({ code: 'WALLET_FLOW_DEDUP_ID_NOT_SUPPORTED', - } satisfies Partial); + } satisfies Partial); }); }); From 1f89857620b0cac8aecb3a89dba4a0652467101b Mon Sep 17 00:00:00 2001 From: skusnierz Date: Thu, 7 May 2026 09:26:37 +0200 Subject: [PATCH 2/9] feat(auth): add wallet auth subsystem with persistent credential store --- src/auth/api-keys.ts | 103 +++++++++ src/auth/index.ts | 275 ++++++++++++++++++++++++ src/auth/refresh.ts | 36 ++++ src/auth/signer.ts | 50 +++++ src/auth/store/credential-store.ts | 47 ++++ src/auth/store/file-store.ts | 115 ++++++++++ src/auth/store/memory-store.ts | 70 ++++++ src/auth/web3.ts | 46 ++++ src/hcloud-client.ts | 43 ++-- src/index.ts | 21 ++ test/auth-service.test.ts | 333 +++++++++++++++++++++++++++++ test/auth-store.test.ts | 90 ++++++++ 12 files changed, 1217 insertions(+), 12 deletions(-) create mode 100644 src/auth/api-keys.ts create mode 100644 src/auth/index.ts create mode 100644 src/auth/refresh.ts create mode 100644 src/auth/signer.ts create mode 100644 src/auth/store/credential-store.ts create mode 100644 src/auth/store/file-store.ts create mode 100644 src/auth/store/memory-store.ts create mode 100644 src/auth/web3.ts create mode 100644 test/auth-service.test.ts create mode 100644 test/auth-store.test.ts diff --git a/src/auth/api-keys.ts b/src/auth/api-keys.ts new file mode 100644 index 0000000..1daaa86 --- /dev/null +++ b/src/auth/api-keys.ts @@ -0,0 +1,103 @@ +import type { HttpClient } from '../core/http'; +import { HcloudError } from '../core/errors'; + +export interface HcloudApiKey { + id: string; + apiKey: string; + type?: { name: string; color: string }; + status?: 'active' | 'inactive'; + projectId?: string; + clientId?: string; + createdAt?: string; +} + +export interface ApiKeysListResponse { + data: HcloudApiKey[]; + total?: number; +} + +export interface ListApiKeysOptions { + projectId?: string; + limit?: number; + offset?: number; +} + +export interface CreateApiKeyInput { + projectId?: string; + type: { name: string; color: string }; +} + +/** + * Manage API keys via the auth-billing service. All methods require an active bearer session; + * obtain one with `client.auth.login(...)`. + */ +export class ApiKeysClient { + constructor( + private readonly getHttp: () => HttpClient, + private readonly resolveProjectId: () => Promise, + ) {} + + /** List API keys for a project. Defaults to the active wallet's selected project. */ + async list(options: ListApiKeysOptions = {}): Promise { + const projectId = options.projectId ?? (await this.resolveProjectId()); + if (!projectId) throw missingProject(); + + const response = await this.getHttp().request({ + method: 'GET', + surface: 'auth-billing', + path: '/api-keys', + query: { + projectId, + limit: options.limit ?? 10, + offset: options.offset ?? 0, + }, + authMode: 'bearer', + }); + return response.data; + } + + /** Mint an additional API key for a project. */ + async create(input: CreateApiKeyInput): Promise { + const projectId = input.projectId ?? (await this.resolveProjectId()); + if (!projectId) throw missingProject(); + + const response = await this.getHttp().request<{ apiKey: HcloudApiKey }>({ + method: 'POST', + surface: 'auth-billing', + path: '/api-keys', + body: JSON.stringify({ projectId, type: input.type }), + headers: { 'content-type': 'application/json' }, + authMode: 'bearer', + }); + return response.data.apiKey; + } + + /** Re-activate a previously deactivated API key. */ + async activate(apiKeyId: string): Promise<{ message: string }> { + const response = await this.getHttp().request<{ message: string }>({ + method: 'POST', + surface: 'auth-billing', + path: `/api-keys/activate/${encodeURIComponent(apiKeyId)}`, + authMode: 'bearer', + }); + return response.data; + } + + /** Deactivate an API key. The project must retain at least one active key. */ + async deactivate(apiKeyId: string): Promise<{ message: string }> { + const response = await this.getHttp().request<{ message: string }>({ + method: 'POST', + surface: 'auth-billing', + path: `/api-keys/deactivate/${encodeURIComponent(apiKeyId)}`, + authMode: 'bearer', + }); + return response.data; + } +} + +function missingProject(): HcloudError { + return new HcloudError({ + kind: 'not_authenticated', + message: 'No active project. Run hcloud auth login or pass projectId explicitly.', + }); +} diff --git a/src/auth/index.ts b/src/auth/index.ts new file mode 100644 index 0000000..0c76d49 --- /dev/null +++ b/src/auth/index.ts @@ -0,0 +1,275 @@ +import type { AuthHeaderProvider, HttpClient } from '../core/http'; +import { HcloudError } from '../core/errors'; +import { ApiKeysClient, type HcloudApiKey } from './api-keys'; +import { refreshSession } from './refresh'; +import type { HcloudSigner } from './signer'; +import { fetchChallenge, submitSession, type SessionResponse } from './web3'; +import { + toWalletEntry, + type CredentialStore, + type SessionEntry, + type WalletEntry, +} from './store/credential-store'; + +export interface AuthServiceOptions { + store: CredentialStore; + /** Default signer used when `login()` is called with no signer argument. */ + signer?: HcloudSigner; + /** Explicit API key passed via HcloudClient options. Wins over env vars and stored wallets. */ + explicitApiKey?: string; + /** Read env vars at every header resolution. Defaults to `process.env`. Pass {} to disable env overlay. */ + env?: NodeJS.ProcessEnv; +} + +export interface LoginInput { + /** Override the default signer for this login call. */ + signer?: HcloudSigner; +} + +export interface LoginResult { + wallet: `0x${string}`; + projectId: string; + apiKey: string; +} + +export interface WhoAmIResult { + wallet: `0x${string}`; + projectId: string; + apiKey: string; + sessionExpiresAt?: string; +} + +const REFRESH_LEEWAY_MS = 60_000; + +/** + * Wallet-auth subsystem reached as `client.auth` on `HcloudClient`. + * + * Implements `AuthHeaderProvider` so the same instance powers attached headers on every + * outgoing request. API keys are durable: once `login` succeeds, services keep working + * indefinitely without re-login. Bearer access/refresh tokens are only consulted when + * the caller invokes auth/api-key operations. + */ +export class AuthService implements AuthHeaderProvider { + readonly store: CredentialStore; + readonly apiKeys: ApiKeysClient; + + private readonly env: NodeJS.ProcessEnv; + private readonly explicitApiKey: string | undefined; + private signer: HcloudSigner | undefined; + private http?: HttpClient; + private refreshing: Promise | undefined; + + constructor(options: AuthServiceOptions) { + this.store = options.store; + this.signer = options.signer; + this.explicitApiKey = options.explicitApiKey; + this.env = options.env ?? globalThis.process?.env ?? {}; + this.apiKeys = new ApiKeysClient( + () => this.requireHttp(), + async () => { + const active = await this.store.activeWallet(); + return active?.projectId; + }, + ); + } + + /** @internal Wired by HcloudClient after construction to break the AuthService↔HttpClient cycle. */ + attachHttp(http: HttpClient): void { + this.http = http; + } + + // ---- AuthHeaderProvider -------------------------------------------------- + + async apiKeyHeader(): Promise { + if (this.explicitApiKey) return this.explicitApiKey; + const envKey = this.env.HCLOUD_API_KEY ?? this.env.ATLANTIC_API_KEY; + if (envKey) return envKey; + const active = await this.store.activeWallet(); + return active?.apiKey; + } + + async bearerHeader(): Promise { + const active = await this.store.activeWallet(); + if (!active?.session) return undefined; + + if (isExpiringSoon(active.session.expiresAt)) { + const refreshed = await this.refreshActive(active); + return refreshed.accessToken; + } + return active.session.accessToken; + } + + // ---- Public API ---------------------------------------------------------- + + /** + * Run the full EIP-712 → bearer session → API key flow and persist credentials. + * Sets the resulting wallet as active (last-login-wins); switch later with `use()`. + */ + async login(input: LoginInput = {}): Promise { + const signer = input.signer ?? this.signer; + if (!signer) { + throw new HcloudError({ + kind: 'not_authenticated', + message: 'No signer configured. Pass { signer } to auth.login or to HcloudClient.', + }); + } + const http = this.requireHttp(); + const wallet = await signer.getAddress(); + + const challenge = await fetchChallenge(http, wallet); + const signature = await signer.signTypedData(challenge.eip712); + const session = await submitSession(http, { + wallet, + challengeToken: challenge.challengeToken, + signature, + }); + + const apiKey = await this.fetchOrMintApiKey(session); + + const entry: WalletEntry = { + wallet, + apiKey, + projectId: session.selectedProject, + session: extractSession(session), + lastLoginAt: new Date().toISOString(), + }; + await this.store.upsertWallet(entry); + await this.store.setActive(wallet); + + return { wallet, projectId: session.selectedProject, apiKey }; + } + + /** Explicitly rotate the active wallet's bearer pair. Throws `session_expired` if the refresh is rejected. */ + async refresh(): Promise { + const active = await this.store.activeWallet(); + if (!active?.session?.refreshToken) { + throw new HcloudError({ + kind: 'not_authenticated', + message: 'No active session to refresh. Run hcloud auth login.', + }); + } + await this.refreshActive(active); + } + + /** Return identity details for the active wallet, or null when no wallet is active. */ + async whoami(): Promise { + const active = await this.store.activeWallet(); + if (!active) return null; + const result: WhoAmIResult = { + wallet: active.wallet, + projectId: active.projectId, + apiKey: active.apiKey, + }; + if (active.session?.expiresAt) result.sessionExpiresAt = active.session.expiresAt; + return result; + } + + /** List all stored wallets along with the active flag. */ + async list(): Promise> { + const file = await this.store.load(); + return Object.entries(file.wallets).map(([wallet, stored]) => ({ + ...toWalletEntry(wallet as `0x${string}`, stored), + active: file.active === wallet, + })); + } + + /** Switch which stored wallet is "active" without re-signing. */ + async use(wallet: `0x${string}`): Promise { + await this.store.setActive(wallet); + } + + /** + * Remove credentials. With no argument, clears the active wallet; with a wallet, + * removes that wallet specifically; with the literal `'all'`, wipes the file. + */ + async logout(target?: `0x${string}` | 'all'): Promise { + if (target === 'all') { + await this.store.clear(); + return; + } + if (target) { + await this.store.removeWallet(target); + return; + } + const active = await this.store.activeWallet(); + if (active) await this.store.removeWallet(active.wallet); + } + + // ---- Internals ----------------------------------------------------------- + + private requireHttp(): HttpClient { + if (!this.http) { + throw new HcloudError({ + kind: 'validation', + message: 'AuthService is not attached to an HttpClient. Construct via HcloudClient.', + }); + } + return this.http; + } + + /** Serialize concurrent refreshes through a single in-flight Promise. */ + private async refreshActive(active: WalletEntry): Promise { + if (!active.session?.refreshToken) { + throw new HcloudError({ + kind: 'session_expired', + message: 'No refresh token available. Run hcloud auth login.', + }); + } + if (!this.refreshing) { + this.refreshing = (async () => { + try { + const refreshed = await refreshSession(this.requireHttp(), active.session!.refreshToken); + await this.store.upsertWallet({ + ...active, + session: extractSession(refreshed), + }); + return refreshed; + } finally { + this.refreshing = undefined; + } + })(); + } + return this.refreshing; + } + + private async fetchOrMintApiKey(session: SessionResponse): Promise { + // Use the brand-new accessToken directly; the wallet hasn't been persisted yet, + // so bearerHeader() can't resolve it from the store. + const list = await this.requireHttp().request<{ data: HcloudApiKey[] }>({ + method: 'GET', + surface: 'auth-billing', + path: '/api-keys', + query: { projectId: session.selectedProject, limit: 10, offset: 0 }, + headers: { authorization: `Bearer ${session.accessToken}` }, + authMode: 'none', + }); + const first = list.data.data[0]; + if (first?.apiKey) return first.apiKey; + throw new HcloudError({ + kind: 'not_authenticated', + message: 'No API key returned for the wallet. Mint one with hcloud auth api-keys create.', + }); + } +} + +function extractSession(session: SessionResponse): SessionEntry { + return { + accessToken: session.accessToken, + refreshToken: session.refreshToken, + expiresAt: session.expiresAt, + }; +} + +function isExpiringSoon(expiresAt: string): boolean { + const expires = Date.parse(expiresAt); + if (Number.isNaN(expires)) return false; + return expires - Date.now() < REFRESH_LEEWAY_MS; +} + +export type { CredentialStore, WalletEntry, SessionEntry } from './store/credential-store'; +export { FileCredentialStore, DEFAULT_CREDENTIAL_PATH } from './store/file-store'; +export { MemoryCredentialStore } from './store/memory-store'; +export { privateKeySigner } from './signer'; +export type { HcloudSigner, HcloudTypedDataPayload } from './signer'; +export { ApiKeysClient } from './api-keys'; +export type { HcloudApiKey, ApiKeysListResponse, ListApiKeysOptions, CreateApiKeyInput } from './api-keys'; diff --git a/src/auth/refresh.ts b/src/auth/refresh.ts new file mode 100644 index 0000000..733512c --- /dev/null +++ b/src/auth/refresh.ts @@ -0,0 +1,36 @@ +import type { HttpClient } from '../core/http'; +import { HcloudError } from '../core/errors'; +import type { SessionResponse } from './web3'; + +/** + * Rotate the bearer session by exchanging a refresh token for a new pair. + * + * Calls `POST /auth/refresh-token` with `Authorization: Bearer `. + * The old refresh token is invalidated server-side; persist the returned pair. + */ +export async function refreshSession(http: HttpClient, refreshToken: string): Promise { + try { + const response = await http.request({ + method: 'POST', + surface: 'auth-billing', + path: '/auth/refresh-token', + body: '{}', + headers: { + authorization: `Bearer ${refreshToken}`, + 'content-type': 'application/json', + }, + authMode: 'none', + }); + return response.data; + } catch (cause) { + if (cause instanceof HcloudError && cause.kind === 'api') { + throw new HcloudError({ + kind: 'session_expired', + message: 'Refresh token rejected; the session has expired. Run hcloud auth login.', + ...(cause.status !== undefined ? { status: cause.status } : {}), + cause, + }); + } + throw cause; + } +} diff --git a/src/auth/signer.ts b/src/auth/signer.ts new file mode 100644 index 0000000..330bb00 --- /dev/null +++ b/src/auth/signer.ts @@ -0,0 +1,50 @@ +import { privateKeyToAccount } from 'viem/accounts'; +import type { Hex } from 'viem'; +import { HcloudError } from '../core/errors'; + +export interface HcloudTypedDataPayload { + domain: Record; + types: Record>; + primaryType: string; + message: Record; +} + +/** + * Pluggable EIP-712 signer used by `client.auth.login`. + * + * Implementations exist for in-memory private keys (`privateKeySigner`), and adapters + * for ethers v6, KMS, and browser wallets are documented in `docs/guides/signers.md`. + */ +export interface HcloudSigner { + /** EVM address that will be sent to `/auth/web3/challenge` and verified by the server. */ + getAddress(): Promise<`0x${string}`>; + /** Sign the EIP-712 typed data verbatim. Do not reconstruct the payload. */ + signTypedData(payload: HcloudTypedDataPayload): Promise<`0x${string}`>; +} + +/** In-memory private-key signer using viem's `privateKeyToAccount`. */ +export function privateKeySigner(privateKey: Hex): HcloudSigner { + if (!/^0x[0-9a-fA-F]{64}$/.test(privateKey)) { + throw new HcloudError({ + kind: 'signing_failed', + message: 'privateKeySigner expected a 0x-prefixed 32-byte hex private key', + }); + } + const account = privateKeyToAccount(privateKey); + return { + async getAddress() { + return account.address; + }, + async signTypedData(payload) { + try { + return await account.signTypedData(payload as Parameters[0]); + } catch (cause) { + throw new HcloudError({ + kind: 'signing_failed', + message: `EIP-712 signing failed: ${(cause as Error).message}`, + cause, + }); + } + }, + }; +} diff --git a/src/auth/store/credential-store.ts b/src/auth/store/credential-store.ts new file mode 100644 index 0000000..638e49a --- /dev/null +++ b/src/auth/store/credential-store.ts @@ -0,0 +1,47 @@ +export const CREDENTIAL_FILE_VERSION = 1; + +export interface SessionEntry { + accessToken: string; + refreshToken: string; + expiresAt: string; +} + +export interface WalletEntry { + wallet: `0x${string}`; + apiKey: string; + projectId: string; + session?: SessionEntry; + lastLoginAt?: string; +} + +export interface CredentialFile { + version: typeof CREDENTIAL_FILE_VERSION; + active?: `0x${string}`; + wallets: Record<`0x${string}`, Omit>; +} + +export function emptyCredentialFile(): CredentialFile { + return { version: CREDENTIAL_FILE_VERSION, wallets: {} }; +} + +export interface CredentialStore { + /** Load the full credential file. Should always succeed; returns an empty file if none exists. */ + load(): Promise; + /** Persist the entire credential file atomically. */ + save(file: CredentialFile): Promise; + /** Read the active wallet's entry, or undefined if no wallets are stored. */ + activeWallet(): Promise; + /** Switch the active wallet pointer. Throws if the wallet is not stored. */ + setActive(wallet: `0x${string}`): Promise; + /** Insert or replace a wallet entry. Sets it active if no wallet was active. */ + upsertWallet(entry: WalletEntry): Promise; + /** Delete a wallet entry. If it was active, clears the active pointer. */ + removeWallet(wallet: `0x${string}`): Promise; + /** Delete all wallets. */ + clear(): Promise; +} + +/** Helper used by store implementations to project a stored entry into a WalletEntry. */ +export function toWalletEntry(wallet: `0x${string}`, stored: Omit): WalletEntry { + return { wallet, ...stored }; +} diff --git a/src/auth/store/file-store.ts b/src/auth/store/file-store.ts new file mode 100644 index 0000000..3ce5635 --- /dev/null +++ b/src/auth/store/file-store.ts @@ -0,0 +1,115 @@ +import { promises as fs } from 'node:fs'; +import { homedir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { + CREDENTIAL_FILE_VERSION, + emptyCredentialFile, + toWalletEntry, + type CredentialFile, + type CredentialStore, + type WalletEntry, +} from './credential-store'; +import { HcloudError } from '../../core/errors'; + +export const DEFAULT_CREDENTIAL_PATH = join(homedir(), '.hcloud', 'credentials.json'); + +export interface FileCredentialStoreOptions { + /** Override the on-disk path. Defaults to `~/.hcloud/credentials.json`. */ + path?: string; +} + +/** + * File-backed credential store. + * + * Writes are atomic (write to `.tmp`, fsync, rename) and the credential file + * is created with mode 0600. Multi-wallet entries are supported; the `active` + * pointer determines which wallet's API key services use by default. + */ +export class FileCredentialStore implements CredentialStore { + readonly path: string; + + constructor(options: FileCredentialStoreOptions = {}) { + this.path = options.path ?? DEFAULT_CREDENTIAL_PATH; + } + + async load(): Promise { + try { + const raw = await fs.readFile(this.path, 'utf8'); + const parsed = JSON.parse(raw) as Partial; + if (parsed?.version !== CREDENTIAL_FILE_VERSION) { + throw new HcloudError({ + kind: 'validation', + message: `Unsupported credentials file version: ${parsed?.version}. Expected ${CREDENTIAL_FILE_VERSION}.`, + }); + } + return { + version: CREDENTIAL_FILE_VERSION, + ...(parsed.active ? { active: parsed.active } : {}), + wallets: parsed.wallets ?? {}, + }; + } catch (cause) { + if ((cause as NodeJS.ErrnoException).code === 'ENOENT') { + return emptyCredentialFile(); + } + if (cause instanceof HcloudError) throw cause; + throw new HcloudError({ + kind: 'validation', + message: `Failed to read credentials file at ${this.path}`, + cause, + }); + } + } + + async save(file: CredentialFile): Promise { + await fs.mkdir(dirname(this.path), { recursive: true, mode: 0o700 }); + const tmp = `${this.path}.${process.pid}.tmp`; + const payload = JSON.stringify(file, null, 2); + await fs.writeFile(tmp, payload, { encoding: 'utf8', mode: 0o600 }); + await fs.rename(tmp, this.path); + // Best-effort: tighten perms in case the file pre-existed with looser perms. + try { + await fs.chmod(this.path, 0o600); + } catch { + // ignore + } + } + + async activeWallet(): Promise { + const file = await this.load(); + if (!file.active) return undefined; + const stored = file.wallets[file.active]; + if (!stored) return undefined; + return toWalletEntry(file.active, stored); + } + + async setActive(wallet: `0x${string}`): Promise { + const file = await this.load(); + if (!file.wallets[wallet]) { + throw new HcloudError({ + kind: 'not_authenticated', + message: `Wallet ${wallet} is not stored. Run hcloud auth login first.`, + }); + } + file.active = wallet; + await this.save(file); + } + + async upsertWallet(entry: WalletEntry): Promise { + const file = await this.load(); + const { wallet, ...stored } = entry; + file.wallets[wallet] = stored; + if (!file.active) file.active = wallet; + await this.save(file); + } + + async removeWallet(wallet: `0x${string}`): Promise { + const file = await this.load(); + delete file.wallets[wallet]; + if (file.active === wallet) delete file.active; + await this.save(file); + } + + async clear(): Promise { + await this.save(emptyCredentialFile()); + } +} diff --git a/src/auth/store/memory-store.ts b/src/auth/store/memory-store.ts new file mode 100644 index 0000000..53cbfe8 --- /dev/null +++ b/src/auth/store/memory-store.ts @@ -0,0 +1,70 @@ +import { + CREDENTIAL_FILE_VERSION, + emptyCredentialFile, + toWalletEntry, + type CredentialFile, + type CredentialStore, + type WalletEntry, +} from './credential-store'; +import { HcloudError } from '../../core/errors'; + +/** + * In-memory credential store. Useful for tests, servers that manage credentials + * themselves, and programmatic flows that should not write to disk. + */ +export class MemoryCredentialStore implements CredentialStore { + private file: CredentialFile; + + constructor(initial?: CredentialFile) { + this.file = initial ?? emptyCredentialFile(); + } + + async load(): Promise { + return cloneFile(this.file); + } + + async save(file: CredentialFile): Promise { + this.file = cloneFile(file); + } + + async activeWallet(): Promise { + const active = this.file.active; + if (!active) return undefined; + const stored = this.file.wallets[active]; + if (!stored) return undefined; + return toWalletEntry(active, stored); + } + + async setActive(wallet: `0x${string}`): Promise { + if (!this.file.wallets[wallet]) { + throw new HcloudError({ + kind: 'not_authenticated', + message: `Wallet ${wallet} is not stored. Run hcloud auth login first.`, + }); + } + this.file.active = wallet; + } + + async upsertWallet(entry: WalletEntry): Promise { + const { wallet, ...stored } = entry; + this.file.wallets[wallet] = stored; + if (!this.file.active) this.file.active = wallet; + } + + async removeWallet(wallet: `0x${string}`): Promise { + delete this.file.wallets[wallet]; + if (this.file.active === wallet) delete this.file.active; + } + + async clear(): Promise { + this.file = emptyCredentialFile(); + } +} + +function cloneFile(file: CredentialFile): CredentialFile { + return { + version: CREDENTIAL_FILE_VERSION, + ...(file.active ? { active: file.active } : {}), + wallets: { ...file.wallets }, + }; +} diff --git a/src/auth/web3.ts b/src/auth/web3.ts new file mode 100644 index 0000000..78c12d1 --- /dev/null +++ b/src/auth/web3.ts @@ -0,0 +1,46 @@ +import type { HttpClient } from '../core/http'; +import type { HcloudTypedDataPayload } from './signer'; + +export interface ChallengeResponse { + challengeToken: string; + nonce: string; + issuedAt: string; + expiresAt: string; + statement: string; + eip712: HcloudTypedDataPayload; +} + +export interface SessionResponse { + accessToken: string; + refreshToken: string; + expiresAt: string; + selectedProject: string; +} + +/** Fetch the EIP-712 challenge for `wallet`. The eip712 payload must be signed verbatim. */ +export async function fetchChallenge(http: HttpClient, wallet: `0x${string}`): Promise { + const response = await http.request({ + method: 'GET', + surface: 'auth-billing', + path: '/auth/web3/challenge', + query: { wallet }, + authMode: 'none', + }); + return response.data; +} + +/** Exchange a signed challenge for a bearer-channel session. */ +export async function submitSession( + http: HttpClient, + body: { wallet: `0x${string}`; challengeToken: string; signature: `0x${string}` }, +): Promise { + const response = await http.request({ + method: 'POST', + surface: 'auth-billing', + path: '/auth/web3/session', + body: JSON.stringify({ ...body, channel: 'bearer' }), + headers: { 'content-type': 'application/json' }, + authMode: 'none', + }); + return response.data; +} diff --git a/src/hcloud-client.ts b/src/hcloud-client.ts index 405a295..cdc452c 100644 --- a/src/hcloud-client.ts +++ b/src/hcloud-client.ts @@ -1,45 +1,64 @@ import { resolveCoreConfig, type FetchLike, type HcloudCoreConfig } from './core/config'; -import { HttpClient, StaticAuthHeaderProvider, type AuthHeaderProvider } from './core/http'; +import { HttpClient } from './core/http'; +import { AuthService } from './auth'; +import { FileCredentialStore } from './auth/store/file-store'; +import type { CredentialStore } from './auth/store/credential-store'; +import type { HcloudSigner } from './auth/signer'; import { AtlanticService } from './services/atlantic/client'; import type { X402PaymentAdapter } from './services/atlantic/x402/adapter'; export interface HcloudClientOptions extends HcloudCoreConfig { - /** Explicit Atlantic API key. When set, takes precedence over env vars and persisted credentials. */ + /** Explicit Atlantic API key. Wins over env vars and persisted credentials. */ apiKey?: string; /** Override fetch implementation. */ fetch?: FetchLike; /** x402 payment adapter forwarded to the Atlantic service. */ paymentAdapter?: X402PaymentAdapter; - /** - * Custom auth header provider. Phase 2 ships a session-aware provider; advanced - * callers can inject their own to wire bespoke credential storage. - */ - authProvider?: AuthHeaderProvider; + /** Credential store backing `client.auth`. Defaults to a `FileCredentialStore` at `~/.hcloud/credentials.json`. */ + credentialStore?: CredentialStore; + /** Default EIP-712 signer. Required only when calling `client.auth.login()` with no signer argument. */ + signer?: HcloudSigner; + /** Override env vars consulted for `HCLOUD_API_KEY` / `ATLANTIC_API_KEY`. Pass `{}` to disable env overlay. */ + env?: NodeJS.ProcessEnv; } /** * Top-level entry point for Herodotus Cloud services. * - * Service surfaces are reached via namespaces: `client.atlantic.*` for Atlantic - * proving, `client.auth.*` (Phase 2) for wallet authentication and API keys. + * Service surfaces are reached via namespaces: + * - `client.atlantic.*` for Atlantic proving + * - `client.auth.*` for wallet authentication, sessions, and API keys + * + * Resolution order for outgoing API-key headers: explicit `apiKey` → + * `HCLOUD_API_KEY` / `ATLANTIC_API_KEY` env → active wallet from credential store. */ export class HcloudClient { readonly atlantic: AtlanticService; + readonly auth: AuthService; private readonly explicitApiKey: string | undefined; constructor(options: HcloudClientOptions = {}) { - const env = globalThis.process?.env; - this.explicitApiKey = options.apiKey ?? env?.HCLOUD_API_KEY ?? env?.ATLANTIC_API_KEY; + const env = options.env ?? globalThis.process?.env ?? {}; + this.explicitApiKey = options.apiKey ?? env.HCLOUD_API_KEY ?? env.ATLANTIC_API_KEY; const coreConfig = resolveCoreConfig({ ...(options.baseUrls ? { baseUrls: options.baseUrls } : {}), ...(options.fetch ? { fetch: options.fetch } : {}), }); - const auth = options.authProvider ?? new StaticAuthHeaderProvider(this.explicitApiKey); + const store = options.credentialStore ?? new FileCredentialStore(); + const auth = new AuthService({ + store, + ...(options.signer ? { signer: options.signer } : {}), + ...(options.apiKey ? { explicitApiKey: options.apiKey } : {}), + env, + }); + const http = new HttpClient({ config: coreConfig, auth }); + auth.attachHttp(http); + this.auth = auth; this.atlantic = new AtlanticService(http, { ...(options.paymentAdapter ? { paymentAdapter: options.paymentAdapter } : {}), hasApiKey: () => Boolean(this.explicitApiKey), diff --git a/src/index.ts b/src/index.ts index 8abeb7f..3aa8b1f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,6 +8,27 @@ export type { HcloudErrorKind, HcloudErrorOptions } from './core/errors'; export type { HcloudSurface, HcloudCoreConfig, ResolvedHcloudCoreConfig, FetchLike } from './core/config'; export type { AuthHeaderProvider, HcloudAuthMode, HcloudRequest, HcloudResponse } from './core/http'; +// Auth subsystem +export { + AuthService, + ApiKeysClient, + FileCredentialStore, + MemoryCredentialStore, + DEFAULT_CREDENTIAL_PATH, + privateKeySigner, +} from './auth'; +export type { + HcloudSigner, + HcloudTypedDataPayload, + CredentialStore, + WalletEntry, + SessionEntry, + HcloudApiKey, + ApiKeysListResponse, + ListApiKeysOptions, + CreateApiKeyInput, +} from './auth'; + // Atlantic service export { AtlanticService, diff --git a/test/auth-service.test.ts b/test/auth-service.test.ts new file mode 100644 index 0000000..c35baa6 --- /dev/null +++ b/test/auth-service.test.ts @@ -0,0 +1,333 @@ +import { describe, expect, test } from 'bun:test'; +import { HcloudClient } from '../src/hcloud-client'; +import { MemoryCredentialStore } from '../src/auth/store/memory-store'; +import type { HcloudSigner } from '../src/auth/signer'; + +const WALLET = '0x1234567890123456789012345678901234567890' as const; + +const fakeSigner = (wallet: `0x${string}` = WALLET): HcloudSigner => ({ + async getAddress() { + return wallet; + }, + async signTypedData() { + return '0xdeadbeef'; + }, +}); + +const challengeBody = (wallet: `0x${string}`) => ({ + challengeToken: 'challenge-1', + nonce: 'nonce', + issuedAt: '2026-05-07T00:00:00.000Z', + expiresAt: '2026-05-07T00:05:00.000Z', + statement: 'Sign in to Herodotus Cloud', + eip712: { + domain: { name: 'Herodotus', chainId: 1, version: '1' }, + types: { Login: [{ name: 'wallet', type: 'address' }] }, + primaryType: 'Login', + message: { wallet }, + }, +}); + +const sessionBody = (wallet: `0x${string}`, suffix = '1') => ({ + accessToken: `access-${suffix}`, + refreshToken: `refresh-${suffix}`, + expiresAt: '2099-01-01T00:00:00.000Z', + selectedProject: `proj-${wallet}`, +}); + +const apiKeysBody = (apiKey: string) => ({ + data: [{ id: 'key-1', apiKey }], + total: 1, +}); + +interface FetchScript { + match: (req: Request) => boolean; + respond: (req: Request) => Response; +} + +function scriptedFetch(script: FetchScript[]) { + const calls: Request[] = []; + return { + calls, + fetch: async (input: string | URL | Request, init?: RequestInit) => { + const request = new Request(input, init); + calls.push(request); + const entry = script.find((s) => s.match(request)); + if (!entry) throw new Error(`Unscripted request: ${request.method} ${request.url}`); + return entry.respond(request); + }, + }; +} + +describe('AuthService.login', () => { + test('runs the full challenge → session → api-key flow and persists the wallet', async () => { + const apiKey = 'apikey-discovered'; + const { fetch, calls } = scriptedFetch([ + { + match: (r) => r.url.includes('/auth/web3/challenge'), + respond: () => Response.json(challengeBody(WALLET)), + }, + { + match: (r) => r.url.endsWith('/auth/web3/session') && r.method === 'POST', + respond: () => Response.json(sessionBody(WALLET)), + }, + { + match: (r) => r.url.includes('/api-keys') && r.method === 'GET', + respond: () => Response.json(apiKeysBody(apiKey)), + }, + ]); + + const store = new MemoryCredentialStore(); + const client = new HcloudClient({ + baseUrls: { 'auth-billing': 'https://auth.example.com', atlantic: 'https://atl.example.com' }, + fetch, + credentialStore: store, + signer: fakeSigner(), + env: {}, + }); + + const result = await client.auth.login(); + + expect(result).toEqual({ wallet: WALLET, projectId: `proj-${WALLET}`, apiKey }); + expect((await store.activeWallet())?.apiKey).toBe(apiKey); + + // Atlantic call now uses the persisted apiKey automatically. + const atlanticFetch = scriptedFetch([ + { + match: (r) => r.url.endsWith('/is-alive'), + respond: () => Response.json({ alive: true }), + }, + ]); + const client2 = new HcloudClient({ + baseUrls: { atlantic: 'https://atl.example.com', 'auth-billing': 'https://auth.example.com' }, + fetch: atlanticFetch.fetch, + credentialStore: store, + env: {}, + }); + await client2.atlantic.healthCheck(); + expect(atlanticFetch.calls[0]?.headers.get('api-key')).toBe(apiKey); + + void calls; + }); + + test('honours last-login-wins across two logins', async () => { + const w1 = '0xaaaa000000000000000000000000000000000001' as const; + const w2 = '0xbbbb000000000000000000000000000000000002' as const; + const store = new MemoryCredentialStore(); + + for (const [wallet, apiKey, suffix] of [ + [w1, 'key-A', 'A'], + [w2, 'key-B', 'B'], + ] as const) { + const { fetch } = scriptedFetch([ + { match: (r) => r.url.includes('/auth/web3/challenge'), respond: () => Response.json(challengeBody(wallet)) }, + { match: (r) => r.url.endsWith('/auth/web3/session'), respond: () => Response.json(sessionBody(wallet, suffix)) }, + { match: (r) => r.url.includes('/api-keys'), respond: () => Response.json(apiKeysBody(apiKey)) }, + ]); + const client = new HcloudClient({ + baseUrls: { 'auth-billing': 'https://auth.example.com', atlantic: 'https://atl.example.com' }, + fetch, + credentialStore: store, + signer: fakeSigner(wallet), + env: {}, + }); + await client.auth.login(); + } + + const active = await store.activeWallet(); + expect(active?.wallet).toBe(w2); + expect(active?.apiKey).toBe('key-B'); + + // Switch back via auth.use without re-signing. + const client = new HcloudClient({ + baseUrls: { 'auth-billing': 'https://auth.example.com', atlantic: 'https://atl.example.com' }, + fetch: () => Promise.resolve(new Response()), + credentialStore: store, + env: {}, + }); + await client.auth.use(w1); + expect((await store.activeWallet())?.apiKey).toBe('key-A'); + }); + + test('persisted apiKey alone keeps Atlantic working without auth-billing calls', async () => { + const store = new MemoryCredentialStore(); + await store.upsertWallet({ + wallet: WALLET, + apiKey: 'persisted', + projectId: 'proj-1', + // intentionally no session — proves Atlantic does not need bearer + }); + + const calls: Request[] = []; + const client = new HcloudClient({ + baseUrls: { atlantic: 'https://atl.example.com', 'auth-billing': 'https://auth.example.com' }, + fetch: (input, init) => { + const r = new Request(input, init); + calls.push(r); + return Promise.resolve(Response.json({ alive: true })); + }, + credentialStore: store, + env: {}, + }); + + await client.atlantic.healthCheck(); + expect(calls.every((c) => !c.url.includes('auth-billing'))).toBe(true); + expect(calls[0]?.headers.get('api-key')).toBe('persisted'); + }); + + test('explicit apiKey overrides persisted credentials', async () => { + const store = new MemoryCredentialStore(); + await store.upsertWallet({ wallet: WALLET, apiKey: 'persisted', projectId: 'p' }); + + const calls: Request[] = []; + const client = new HcloudClient({ + apiKey: 'explicit-wins', + baseUrls: { atlantic: 'https://atl.example.com', 'auth-billing': 'https://auth.example.com' }, + fetch: (input, init) => { + calls.push(new Request(input, init)); + return Promise.resolve(Response.json({ alive: true })); + }, + credentialStore: store, + env: {}, + }); + + await client.atlantic.healthCheck(); + expect(calls[0]?.headers.get('api-key')).toBe('explicit-wins'); + }); + + test('HCLOUD_API_KEY env var overrides persisted credentials', async () => { + const store = new MemoryCredentialStore(); + await store.upsertWallet({ wallet: WALLET, apiKey: 'persisted', projectId: 'p' }); + + const calls: Request[] = []; + const client = new HcloudClient({ + baseUrls: { atlantic: 'https://atl.example.com', 'auth-billing': 'https://auth.example.com' }, + fetch: (input, init) => { + calls.push(new Request(input, init)); + return Promise.resolve(Response.json({ alive: true })); + }, + credentialStore: store, + env: { HCLOUD_API_KEY: 'env-wins' }, + }); + + await client.atlantic.healthCheck(); + expect(calls[0]?.headers.get('api-key')).toBe('env-wins'); + }); + + test('login throws not_authenticated when no signer is configured', async () => { + const store = new MemoryCredentialStore(); + const client = new HcloudClient({ + baseUrls: { 'auth-billing': 'https://auth.example.com', atlantic: 'https://atl.example.com' }, + fetch: () => Promise.resolve(new Response()), + credentialStore: store, + env: {}, + }); + await expect(client.auth.login()).rejects.toMatchObject({ kind: 'not_authenticated' }); + }); +}); + +describe('AuthService.refresh', () => { + test('rotates the bearer pair and persists the new session', async () => { + const store = new MemoryCredentialStore(); + await store.upsertWallet({ + wallet: WALLET, + apiKey: 'k', + projectId: 'p', + session: { accessToken: 'old', refreshToken: 'old-refresh', expiresAt: '2099-01-01T00:00:00.000Z' }, + }); + + const { fetch, calls } = scriptedFetch([ + { + match: (r) => r.url.endsWith('/auth/refresh-token') && r.method === 'POST', + respond: () => + Response.json({ + accessToken: 'new', + refreshToken: 'new-refresh', + expiresAt: '2099-12-31T00:00:00.000Z', + selectedProject: 'p', + }), + }, + ]); + + const client = new HcloudClient({ + baseUrls: { 'auth-billing': 'https://auth.example.com', atlantic: 'https://atl.example.com' }, + fetch, + credentialStore: store, + env: {}, + }); + + await client.auth.refresh(); + expect(calls[0]?.headers.get('authorization')).toBe('Bearer old-refresh'); + expect((await store.activeWallet())?.session?.accessToken).toBe('new'); + }); + + test('throws session_expired when refresh is rejected', async () => { + const store = new MemoryCredentialStore(); + await store.upsertWallet({ + wallet: WALLET, + apiKey: 'k', + projectId: 'p', + session: { accessToken: 'old', refreshToken: 'bad', expiresAt: '2099-01-01T00:00:00.000Z' }, + }); + + const client = new HcloudClient({ + baseUrls: { 'auth-billing': 'https://auth.example.com', atlantic: 'https://atl.example.com' }, + fetch: () => Promise.resolve(Response.json({ message: 'INVALID_REFRESH_TOKEN' }, { status: 401 })), + credentialStore: store, + env: {}, + }); + + await expect(client.auth.refresh()).rejects.toMatchObject({ kind: 'session_expired' }); + }); +}); + +describe('AuthService.apiKeys', () => { + test('list/create/activate/deactivate use bearer auth', async () => { + const store = new MemoryCredentialStore(); + await store.upsertWallet({ + wallet: WALLET, + apiKey: 'k', + projectId: 'proj-x', + session: { accessToken: 'access', refreshToken: 'refresh', expiresAt: '2099-01-01T00:00:00.000Z' }, + }); + + const requests: Request[] = []; + const client = new HcloudClient({ + baseUrls: { 'auth-billing': 'https://auth.example.com', atlantic: 'https://atl.example.com' }, + fetch: (input, init) => { + const r = new Request(input, init); + requests.push(r); + const url = new URL(r.url); + if (url.pathname === '/api-keys' && r.method === 'GET') { + return Promise.resolve(Response.json({ data: [{ id: 'k1', apiKey: 'value' }], total: 1 })); + } + if (url.pathname === '/api-keys' && r.method === 'POST') { + return Promise.resolve(Response.json({ apiKey: { id: 'k2', apiKey: 'minted' } })); + } + if (url.pathname.startsWith('/api-keys/activate/')) { + return Promise.resolve(Response.json({ message: 'API_KEY_ACTIVATED' })); + } + if (url.pathname.startsWith('/api-keys/deactivate/')) { + return Promise.resolve(Response.json({ message: 'API_KEY_DEACTIVATED' })); + } + throw new Error(`unexpected ${r.method} ${r.url}`); + }, + credentialStore: store, + env: {}, + }); + + const list = await client.auth.apiKeys.list(); + expect(list.data[0]?.apiKey).toBe('value'); + + const minted = await client.auth.apiKeys.create({ type: { name: 'agent', color: '#0aa' } }); + expect(minted.apiKey).toBe('minted'); + + const activated = await client.auth.apiKeys.activate('k1'); + expect(activated.message).toBe('API_KEY_ACTIVATED'); + + const deactivated = await client.auth.apiKeys.deactivate('k1'); + expect(deactivated.message).toBe('API_KEY_DEACTIVATED'); + + expect(requests.every((r) => r.headers.get('authorization') === 'Bearer access')).toBe(true); + }); +}); diff --git a/test/auth-store.test.ts b/test/auth-store.test.ts new file mode 100644 index 0000000..e9db7ef --- /dev/null +++ b/test/auth-store.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, test, beforeEach, afterEach } from 'bun:test'; +import { promises as fs } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { FileCredentialStore } from '../src/auth/store/file-store'; +import { MemoryCredentialStore } from '../src/auth/store/memory-store'; +import type { WalletEntry } from '../src/auth/store/credential-store'; + +const sampleWallet = (wallet: `0x${string}`, apiKey: string): WalletEntry => ({ + wallet, + apiKey, + projectId: `proj_${wallet}`, + session: { + accessToken: `access-${wallet}`, + refreshToken: `refresh-${wallet}`, + expiresAt: '2099-01-01T00:00:00.000Z', + }, +}); + +describe('MemoryCredentialStore', () => { + test('upsert + activate + load', async () => { + const store = new MemoryCredentialStore(); + await store.upsertWallet(sampleWallet('0xaaaa000000000000000000000000000000000001', 'k1')); + await store.upsertWallet(sampleWallet('0xbbbb000000000000000000000000000000000002', 'k2')); + + const active = await store.activeWallet(); + expect(active?.wallet).toBe('0xaaaa000000000000000000000000000000000001'); + + await store.setActive('0xbbbb000000000000000000000000000000000002'); + expect((await store.activeWallet())?.apiKey).toBe('k2'); + }); + + test('removeWallet clears active when removing the active one', async () => { + const store = new MemoryCredentialStore(); + const w = '0xcccc000000000000000000000000000000000003' as const; + await store.upsertWallet(sampleWallet(w, 'k3')); + await store.removeWallet(w); + expect(await store.activeWallet()).toBeUndefined(); + }); + + test('setActive throws when wallet missing', async () => { + const store = new MemoryCredentialStore(); + await expect(store.setActive('0xdead000000000000000000000000000000000000')).rejects.toMatchObject({ + kind: 'not_authenticated', + }); + }); +}); + +describe('FileCredentialStore', () => { + let path: string; + + beforeEach(() => { + path = join(tmpdir(), `hcloud-creds-${process.pid}-${Math.random().toString(36).slice(2)}.json`); + }); + + afterEach(async () => { + await fs.rm(path, { force: true }); + }); + + test('returns empty file when none exists', async () => { + const store = new FileCredentialStore({ path }); + const file = await store.load(); + expect(file).toEqual({ version: 1, wallets: {} }); + }); + + test('persists with mode 0600', async () => { + const store = new FileCredentialStore({ path }); + await store.upsertWallet(sampleWallet('0xaaaa000000000000000000000000000000000004', 'k4')); + + const stat = await fs.stat(path); + expect(stat.mode & 0o777).toBe(0o600); + }); + + test('load round-trips a saved file', async () => { + const store = new FileCredentialStore({ path }); + const w = '0xeeee000000000000000000000000000000000005' as const; + await store.upsertWallet(sampleWallet(w, 'k5')); + + const reloaded = await new FileCredentialStore({ path }).activeWallet(); + expect(reloaded?.apiKey).toBe('k5'); + expect(reloaded?.wallet).toBe(w); + }); + + test('rejects unsupported file versions', async () => { + await fs.mkdir(join(path, '..'), { recursive: true }); + await fs.writeFile(path, JSON.stringify({ version: 999, wallets: {} })); + const store = new FileCredentialStore({ path }); + await expect(store.load()).rejects.toMatchObject({ kind: 'validation' }); + }); +}); From acf9bc98efa67084a038592f57eff7bfb6b09ac0 Mon Sep 17 00:00:00 2001 From: skusnierz Date: Thu, 7 May 2026 09:28:55 +0200 Subject: [PATCH 3/9] feat(auth): add hcloud auth CLI command group --- src/auth/cli.ts | 159 ++++++++++++++++++++++++++++++++++++++++ src/cli/dispatcher.ts | 21 ++++-- test/auth-cli.test.ts | 163 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 339 insertions(+), 4 deletions(-) create mode 100644 src/auth/cli.ts create mode 100644 test/auth-cli.test.ts diff --git a/src/auth/cli.ts b/src/auth/cli.ts new file mode 100644 index 0000000..a0d91f2 --- /dev/null +++ b/src/auth/cli.ts @@ -0,0 +1,159 @@ +import type { CliFlags, ServiceCli } from '../cli/registry'; +import { HcloudError } from '../core/errors'; +import { privateKeySigner } from './signer'; +import type { WalletEntry } from './store/credential-store'; + +const SHOW_SECRETS_FLAG = 'show-secrets'; + +export const authCli: ServiceCli = { + name: 'auth', + description: 'Wallet authentication, sessions, and API keys', + commands: { + login: { + description: 'Sign an EIP-712 challenge and obtain a persistent API key', + run: async ({ client, flags }) => { + const privateKey = + stringFlag(flags, 'private-key') ?? + process.env.HCLOUD_WALLET_PRIVATE_KEY ?? + process.env.WALLET_PRIVATE_KEY; + if (!privateKey) { + throw new HcloudError({ + kind: 'not_authenticated', + message: + 'Provide --private-key or set HCLOUD_WALLET_PRIVATE_KEY (alias: WALLET_PRIVATE_KEY).', + }); + } + const signer = privateKeySigner(privateKey as `0x${string}`); + const result = await client.auth.login({ signer }); + return maskApiKey(result, flags); + }, + }, + refresh: { + description: 'Rotate the active wallet bearer pair', + run: async ({ client }) => { + await client.auth.refresh(); + return { refreshed: true }; + }, + }, + whoami: { + description: 'Print the active wallet identity', + run: async ({ client, flags }) => { + const result = await client.auth.whoami(); + if (!result) return null; + return maskApiKey(result, flags); + }, + }, + list: { + description: 'List stored wallets', + run: async ({ client, flags }) => { + const wallets = await client.auth.list(); + return wallets.map((entry) => maskApiKey(stripSession(entry), flags)); + }, + }, + use: { + description: 'Switch the active wallet without re-signing', + run: async ({ client, flags, positionals }) => { + const wallet = stringFlag(flags, 'wallet') ?? positionals[0]; + if (!wallet) throw new HcloudError({ kind: 'validation', message: 'Missing wallet address' }); + await client.auth.use(wallet as `0x${string}`); + return { active: wallet }; + }, + }, + logout: { + description: 'Remove credentials for the active wallet, a specific wallet, or all wallets', + run: async ({ client, flags, positionals }) => { + const all = booleanFlag(flags, 'all'); + if (all) { + await client.auth.logout('all'); + return { cleared: 'all' }; + } + const target = stringFlag(flags, 'wallet') ?? positionals[0]; + await client.auth.logout(target as `0x${string}` | undefined); + return { cleared: target ?? 'active' }; + }, + }, + 'api-keys list': { + description: 'List API keys for a project', + run: async ({ client, flags }) => { + const options: { projectId?: string; limit?: number; offset?: number } = {}; + const projectId = stringFlag(flags, 'project-id'); + if (projectId) options.projectId = projectId; + const limit = numberFlag(flags, 'limit'); + if (limit !== undefined) options.limit = limit; + const offset = numberFlag(flags, 'offset'); + if (offset !== undefined) options.offset = offset; + return client.auth.apiKeys.list(options); + }, + }, + 'api-keys create': { + description: 'Mint a new API key', + run: async ({ client, flags }) => { + const name = stringFlag(flags, 'type-name'); + const color = stringFlag(flags, 'type-color'); + if (!name || !color) { + throw new HcloudError({ + kind: 'validation', + message: 'Both --type-name and --type-color are required', + }); + } + const projectId = stringFlag(flags, 'project-id'); + return client.auth.apiKeys.create({ + ...(projectId ? { projectId } : {}), + type: { name, color }, + }); + }, + }, + 'api-keys activate': { + description: 'Reactivate an API key by ID', + run: async ({ client, flags, positionals }) => { + const id = stringFlag(flags, 'id') ?? positionals[0]; + if (!id) throw new HcloudError({ kind: 'validation', message: 'Missing api-key id' }); + return client.auth.apiKeys.activate(id); + }, + }, + 'api-keys deactivate': { + description: 'Deactivate an API key by ID', + run: async ({ client, flags, positionals }) => { + const id = stringFlag(flags, 'id') ?? positionals[0]; + if (!id) throw new HcloudError({ kind: 'validation', message: 'Missing api-key id' }); + return client.auth.apiKeys.deactivate(id); + }, + }, + }, +}; + +function stringFlag(flags: CliFlags, name: string): string | undefined { + const value = flags[name]; + return typeof value === 'string' && value.length > 0 ? value : undefined; +} + +function numberFlag(flags: CliFlags, name: string): number | undefined { + const value = stringFlag(flags, name); + if (value === undefined) return undefined; + const n = Number(value); + return Number.isFinite(n) ? n : undefined; +} + +function booleanFlag(flags: CliFlags, name: string): boolean { + const value = flags[name]; + if (value === true) return true; + if (value === 'true') return true; + return false; +} + +function maskApiKey(value: T, flags: CliFlags): T { + if (booleanFlag(flags, SHOW_SECRETS_FLAG)) return value; + if (!value || typeof value.apiKey !== 'string' || value.apiKey.length === 0) return value; + return { ...value, apiKey: maskSecret(value.apiKey) }; +} + +function maskSecret(secret: string): string { + if (secret.length <= 4) return '***'; + return `***${secret.slice(-4)}`; +} + +function stripSession(entry: T): Omit { + const { session: _session, ...rest } = entry; + return rest; +} + diff --git a/src/cli/dispatcher.ts b/src/cli/dispatcher.ts index 6e99561..a4b3b79 100644 --- a/src/cli/dispatcher.ts +++ b/src/cli/dispatcher.ts @@ -1,10 +1,11 @@ import { HcloudClient } from '../hcloud-client'; import { atlanticCli } from '../services/atlantic/cli'; +import { authCli } from '../auth/cli'; import { readCliConfig } from './config'; import { formatCliError, printJson } from './output'; import type { CliFlags, ServiceCli } from './registry'; -const services: ServiceCli[] = [atlanticCli]; +const services: ServiceCli[] = [authCli, atlanticCli]; export async function runCli(argv: string[]): Promise { const [serviceName, command, ...rest] = argv; @@ -43,17 +44,29 @@ export async function runCli(argv: string[]): Promise { return 0; } - const spec = service.commands[command]; + // Allow 2-token command names ("api-keys list") to enable nested groups like `hcloud auth api-keys list`. + let resolvedCommand = command; + let restAfterCommand = rest; + const next = rest[0]; + if (next && !next.startsWith('--')) { + const twoTokenName = `${command} ${next}`; + if (service.commands[twoTokenName]) { + resolvedCommand = twoTokenName; + restAfterCommand = rest.slice(1); + } + } + + const spec = service.commands[resolvedCommand]; if (!spec) { printJson({ ok: false, - error: { message: `Unknown command: ${service.name} ${command}` }, + error: { message: `Unknown command: ${service.name} ${resolvedCommand}` }, commands: Object.keys(service.commands).sort(), }); return 1; } - const { flags, positionals } = parseArgs(rest); + const { flags, positionals } = parseArgs(restAfterCommand); const config = readCliConfig(flags); const client = new HcloudClient(config.client); diff --git a/test/auth-cli.test.ts b/test/auth-cli.test.ts new file mode 100644 index 0000000..f796f7c --- /dev/null +++ b/test/auth-cli.test.ts @@ -0,0 +1,163 @@ +import { describe, expect, test } from 'bun:test'; +import { authCli } from '../src/auth/cli'; +import { atlanticCli } from '../src/services/atlantic/cli'; +import { HcloudClient } from '../src/hcloud-client'; +import { MemoryCredentialStore } from '../src/auth/store/memory-store'; + +const WALLET = '0x1234567890123456789012345678901234567890' as const; +// Test-only private key (fixed to keep the corresponding wallet deterministic in mocks). +const PRIVATE_KEY = '0x4c0883a69102937d6231471b5dbb6204fe5129617082792ae468d01a3f362318'; + +const challengeBody = (wallet: `0x${string}`) => ({ + challengeToken: 'challenge-1', + nonce: 'n', + issuedAt: '2026-05-07T00:00:00.000Z', + expiresAt: '2026-05-07T00:05:00.000Z', + statement: 'sign in', + eip712: { + domain: { name: 'Herodotus', chainId: 1, version: '1' }, + types: { Login: [{ name: 'wallet', type: 'address' }] }, + primaryType: 'Login', + message: { wallet }, + }, +}); + +const sessionBody = { + accessToken: 'access', + refreshToken: 'refresh', + expiresAt: '2099-01-01T00:00:00.000Z', + selectedProject: 'proj-x', +}; + +function buildClient(store: MemoryCredentialStore, fetchImpl: typeof fetch) { + return new HcloudClient({ + baseUrls: { 'auth-billing': 'https://auth.example.com', atlantic: 'https://atl.example.com' }, + fetch: fetchImpl, + credentialStore: store, + env: {}, + }); +} + +describe('auth CLI', () => { + test('login persists creds and masks the API key by default', async () => { + const store = new MemoryCredentialStore(); + const fetchImpl = (input: string | URL | Request) => { + const url = new URL(typeof input === 'string' || input instanceof URL ? input : input.url); + if (url.pathname === '/auth/web3/challenge') { + return Promise.resolve(Response.json(challengeBody(WALLET))); + } + if (url.pathname === '/auth/web3/session') { + return Promise.resolve(Response.json(sessionBody)); + } + if (url.pathname === '/api-keys') { + return Promise.resolve(Response.json({ data: [{ id: 'k1', apiKey: 'long-secret-1234' }] })); + } + throw new Error(`unexpected ${url.pathname}`); + }; + + const client = buildClient(store, fetchImpl as never); + const result = (await authCli.commands.login!.run({ + client, + flags: { 'private-key': PRIVATE_KEY }, + positionals: [], + })) as { wallet: string; projectId: string; apiKey: string }; + + expect(result.apiKey.startsWith('***')).toBe(true); + expect(result.apiKey.endsWith('1234')).toBe(true); + expect((await store.activeWallet())?.apiKey).toBe('long-secret-1234'); + }); + + test('login --show-secrets returns the full api key', async () => { + const store = new MemoryCredentialStore(); + const fetchImpl = (input: string | URL | Request) => { + const url = new URL(typeof input === 'string' || input instanceof URL ? input : input.url); + if (url.pathname === '/auth/web3/challenge') return Promise.resolve(Response.json(challengeBody(WALLET))); + if (url.pathname === '/auth/web3/session') return Promise.resolve(Response.json(sessionBody)); + if (url.pathname === '/api-keys') return Promise.resolve(Response.json({ data: [{ id: 'k1', apiKey: 'plain' }] })); + throw new Error(`unexpected ${url.pathname}`); + }; + + const client = buildClient(store, fetchImpl as never); + const result = (await authCli.commands.login!.run({ + client, + flags: { 'private-key': PRIVATE_KEY, 'show-secrets': true }, + positionals: [], + })) as { apiKey: string }; + + expect(result.apiKey).toBe('plain'); + }); + + test('login throws when no private key supplied', async () => { + const store = new MemoryCredentialStore(); + const client = buildClient(store, (() => Promise.resolve(new Response())) as never); + + await expect( + authCli.commands.login!.run({ client, flags: {}, positionals: [] }), + ).rejects.toMatchObject({ kind: 'not_authenticated' }); + }); + + test('atlantic command after login uses the persisted api key', async () => { + const store = new MemoryCredentialStore(); + await store.upsertWallet({ wallet: WALLET, apiKey: 'persisted', projectId: 'p' }); + + const calls: Request[] = []; + const client = buildClient(store, ((input: string | URL | Request, init?: RequestInit) => { + calls.push(new Request(input, init)); + return Promise.resolve(Response.json({ alive: true })); + }) as never); + + await atlanticCli.commands.health!.run({ client, flags: {}, positionals: [] }); + + expect(calls[0]?.headers.get('api-key')).toBe('persisted'); + }); + + test('use switches the active wallet without re-signing', async () => { + const store = new MemoryCredentialStore(); + const w1 = '0xaaaa000000000000000000000000000000000001' as const; + const w2 = '0xbbbb000000000000000000000000000000000002' as const; + await store.upsertWallet({ wallet: w1, apiKey: 'k1', projectId: 'p' }); + await store.upsertWallet({ wallet: w2, apiKey: 'k2', projectId: 'p' }); + + const client = buildClient(store, (() => Promise.resolve(new Response())) as never); + + await authCli.commands.use!.run({ client, flags: {}, positionals: [w1] }); + expect((await store.activeWallet())?.wallet).toBe(w1); + }); + + test('logout --all clears the store', async () => { + const store = new MemoryCredentialStore(); + await store.upsertWallet({ wallet: WALLET, apiKey: 'persisted', projectId: 'p' }); + + const client = buildClient(store, (() => Promise.resolve(new Response())) as never); + await authCli.commands.logout!.run({ client, flags: { all: true }, positionals: [] }); + + expect(await store.activeWallet()).toBeUndefined(); + }); + + test('api-keys list flows through the bearer-authenticated request', async () => { + const store = new MemoryCredentialStore(); + await store.upsertWallet({ + wallet: WALLET, + apiKey: 'persisted', + projectId: 'proj-from-store', + session: { accessToken: 'access', refreshToken: 'refresh', expiresAt: '2099-01-01T00:00:00.000Z' }, + }); + + const calls: Request[] = []; + const client = buildClient(store, ((input: string | URL | Request, init?: RequestInit) => { + const r = new Request(input, init); + calls.push(r); + return Promise.resolve(Response.json({ data: [{ id: 'k1', apiKey: 'value' }], total: 1 })); + }) as never); + + const result = (await authCli.commands['api-keys list']!.run({ + client, + flags: {}, + positionals: [], + })) as { data: Array<{ apiKey: string }> }; + + expect(result.data[0]?.apiKey).toBe('value'); + expect(calls[0]?.headers.get('authorization')).toBe('Bearer access'); + expect(new URL(calls[0]!.url).searchParams.get('projectId')).toBe('proj-from-store'); + }); +}); From 0b0e624b52ddc59104b47c70261e04f223185f04 Mon Sep 17 00:00:00 2001 From: skusnierz Date: Thu, 7 May 2026 09:31:09 +0200 Subject: [PATCH 4/9] docs: README auth section + reference guides --- README.md | 73 ++++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 70 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 4be0be3..034f2d8 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,46 @@ Install the CLI globally: npm install -g @herodotus_dev/hcloud ``` +## Two ways to authenticate + +You can either pass an API key directly, or have the SDK obtain one through the +programmatic wallet auth flow. Either path produces the same `client.atlantic.*` +behavior; pick whichever fits your environment. + +### Wallet auth (no manual API key) + +```ts +import { HcloudClient, privateKeySigner } from '@herodotus_dev/hcloud'; + +const client = new HcloudClient(); + +await client.auth.login({ + signer: privateKeySigner(process.env.WALLET_PRIVATE_KEY as `0x${string}`), +}); + +// API key is now persisted at ~/.hcloud/credentials.json (mode 0600). +// Atlantic and future services will use it automatically. +const health = await client.atlantic.healthCheck(); +``` + +`client.auth.login` runs the full EIP-712 challenge → bearer session → API key +flow against `auth-billing` and writes the result to disk. Subsequent runs from +any process on the same machine pick the key up automatically — no re-login +required to keep using Atlantic. + +### Explicit API key + +```ts +import { HcloudClient } from '@herodotus_dev/hcloud'; + +const client = new HcloudClient({ + apiKey: process.env.HCLOUD_API_KEY, +}); +``` + +Explicit keys win over env vars and persisted credentials. `HCLOUD_API_KEY` +(alias: `ATLANTIC_API_KEY`) is read automatically when no explicit key is passed. + ## SDK quickstart ```ts @@ -71,9 +111,17 @@ console.log(details.atlanticQuery.status, details.metadataUrls); ## CLI quickstart ```bash -HCLOUD_API_KEY=... bunx hcloud atlantic submit-query \ - --pie-file ./pie.zip \ - --declared-job-size S +# One-time wallet auth (writes ~/.hcloud/credentials.json) +WALLET_PRIVATE_KEY=0x... hcloud auth login + +# After that, Atlantic commands just work: +hcloud atlantic submit-query --pie-file ./pie.zip --declared-job-size S +``` + +You can skip the wallet auth and pass an API key directly: + +```bash +HCLOUD_API_KEY=... hcloud atlantic submit-query --pie-file ./pie.zip --declared-job-size S ``` CLI structure is `hcloud `. List service groups with `hcloud --help`, @@ -91,6 +139,25 @@ hcloud atlantic create-bucket --aggregator-version STONE hcloud atlantic close-bucket ``` +Common auth commands: + +```bash +hcloud auth login --private-key 0x... # or set WALLET_PRIVATE_KEY +hcloud auth whoami # masks api key by default +hcloud auth list # all stored wallets +hcloud auth use 0xabc... # switch active wallet +hcloud auth api-keys list +hcloud auth api-keys create --type-name agent --type-color "#0a0" +hcloud auth refresh # rotate bearer pair +hcloud auth logout --all # wipe credentials +``` + +For deeper docs see: + +- [`docs/guides/credentials.md`](docs/guides/credentials.md) — credential storage, env vars, custom stores. +- [`docs/guides/signers.md`](docs/guides/signers.md) — signer adapters (viem, ethers, KMS, browser). +- [`docs/guides/adding-a-service.md`](docs/guides/adding-a-service.md) — how new services plug into the SDK. + ## Atlantic SDK reference ### Service surface From 9f60ebe482c8feb5f4cf0e64ebfe371cc7b6222d Mon Sep 17 00:00:00 2001 From: skusnierz Date: Thu, 7 May 2026 09:31:53 +0200 Subject: [PATCH 5/9] docs: track guides, brainstorm, and plan in repo --- .gitignore | 4 +- ...7-hcloud-multi-service-sdk-requirements.md | 193 +++++ docs/guides/adding-a-service.md | 130 ++++ docs/guides/credentials.md | 108 +++ docs/guides/signers.md | 111 +++ ...rename-to-hcloud-multi-service-sdk-plan.md | 729 ++++++++++++++++++ 6 files changed, 1272 insertions(+), 3 deletions(-) create mode 100644 docs/brainstorms/2026-05-07-hcloud-multi-service-sdk-requirements.md create mode 100644 docs/guides/adding-a-service.md create mode 100644 docs/guides/credentials.md create mode 100644 docs/guides/signers.md create mode 100644 docs/plans/2026-05-07-001-feat-rename-to-hcloud-multi-service-sdk-plan.md diff --git a/.gitignore b/.gitignore index 8e164de..3632834 100644 --- a/.gitignore +++ b/.gitignore @@ -110,6 +110,4 @@ dist .dynamodb/ # TernJS port file -.tern-port - -/docs \ No newline at end of file +.tern-port \ No newline at end of file diff --git a/docs/brainstorms/2026-05-07-hcloud-multi-service-sdk-requirements.md b/docs/brainstorms/2026-05-07-hcloud-multi-service-sdk-requirements.md new file mode 100644 index 0000000..cecb4c2 --- /dev/null +++ b/docs/brainstorms/2026-05-07-hcloud-multi-service-sdk-requirements.md @@ -0,0 +1,193 @@ +--- +date: 2026-05-07 +topic: hcloud-multi-service-sdk +--- + +# hcloud: Multi-Service SDK + CLI with Wallet Auth + +## Problem Frame + +Today this repo is a single-service SDK/CLI for Atlantic. It assumes the caller already +has an API key (passed via config) and ships only Atlantic's surface. Two pressures +push it past that frame: + +1. **More Herodotus services are coming through the same SDK** (storage-proof first, + then data-processor, data-structure-indexer, satellite). They will share auth, + HTTP, retries, and CLI conventions, but each has its own domain operations — and + some operations will collide by name across services (e.g. `submitQuery`). +2. **Users should not have to obtain an API key out-of-band.** `auth-billing` already + exposes a programmatic web3 path (EIP-712 challenge → bearer session → API key), + and `ai-skills/herodotus-skills/herodotus-auth/scripts/*.ts` is a working reference + implementation. That flow belongs in the SDK so any agent, CLI user, or server + can go from "I have a wallet" to "I have an API key" without touching a browser. + +The package and repo will be renamed to **hcloud** (Herodotus Cloud) to reflect the +broader scope. + +## Requirements + +- **R1. Single SDK entrypoint with service namespaces.** `new HcloudClient({...})` + exposes services as namespaces: `client.auth.*`, `client.atlantic.*`, + `client.storageProof.*` (future). Same operation name on different services + (e.g. `submitQuery`) is unambiguous because it is reached through its service + namespace; never via a top-level overloaded function. +- **R2. Source layout mirrors the surface.** `src/auth/`, `src/services/atlantic/`, + `src/services//` are siblings; shared HTTP/config/errors live in + `src/core/` (or equivalent). Adding a new service is a copy-paste-shaped task + with no edits to existing services. +- **R3. CLI mirrors the SDK as `hcloud `.** + Examples: `hcloud auth login`, `hcloud auth whoami`, `hcloud auth api-keys list`, + `hcloud atlantic submit-query`, `hcloud atlantic get-query`, + `hcloud storage-proof submit-query` (future). `hcloud --help` lists service groups; + `hcloud --help` lists that service's commands. Existing Atlantic command + set is preserved 1:1 under the new path. +- **R4. Wallet auth produces an API key without a browser.** + `client.auth.login({ wallet })` (and `hcloud auth login`) executes the full + programmatic flow: `GET /auth/web3/challenge` → sign EIP-712 → `POST /auth/web3/session` + with `channel: "bearer"` → persist `accessToken/refreshToken/expiresAt/selectedProject` → + `GET /api-keys?projectId=…` → return a usable API key. New wallets are + auto-provisioned by the backend; the SDK does not need a separate "signup" path. +- **R5. Token refresh is handled for the user.** Before the access token expires + the SDK calls `POST /auth/refresh-token` with the bearer refresh token and + rotates the persisted pair. CLI users can also run `hcloud auth refresh` + explicitly. Channel binding is honored: bearer-issued tokens are never sent + as cookies. +- **R6. API-key management is a first-class command.** + `client.auth.apiKeys.list({ projectId? })`, `.create({ projectId, type })`, + `.activate(id)`, `.deactivate(id)` — and CLI equivalents under `hcloud auth api-keys`. + When `projectId` is omitted, the SDK uses `selectedProject` from the active session. +- **R7. Service calls authenticate transparently with the persisted API key.** + Once `auth.login` has produced an API key, the SDK persists it and all + `client.atlantic.*` and future-service calls use it automatically — *forever*, + no re-login required. Bearer access/refresh tokens are only consulted when the + user invokes auth/api-key operations (refreshing the session, listing/creating + keys). A user who only consumes Atlantic never needs to refresh the session + or log in again as long as the stored API key remains active server-side. + Callers can still pass an `apiKey` directly to `HcloudClient` and skip the + auth flow entirely. +- **R8. Pluggable signer.** The SDK accepts a signer abstraction (private key in + env is the default convenience wrapper) so KMS, hardware wallets, and browser + wallets can plug in without forking the auth code. +- **R9. Atlantic functionality is preserved.** All current operations + (queries, buckets, jobs, x402 USDC payments) continue to work; only their import + paths and CLI invocation change. x402 lives under `src/services/atlantic/` since + it is Atlantic-specific today. +- **R10. Rename hits package, repo, and binary.** npm package becomes + `@herodotus_dev/hcloud` (exact scope/name TBD — see questions), CLI binary becomes + `hcloud`, GitHub repo renamed to `hcloud`. README, examples, and docs updated. + +## Success Criteria + +- A user with only a wallet private key runs `hcloud auth login`, then + `hcloud atlantic submit-query …`, and a query is accepted — no manual API key + copy/paste, no browser. +- An SDK consumer writes `new HcloudClient({ wallet }).atlantic.submitQuery(...)` + end-to-end without ever importing from `auth-billing` or copying scripts out of + `ai-skills`. +- A new service (e.g. storage-proof) can be added by creating + `src/services/storage-proof/`, exporting it on the client, and registering its + CLI commands — with zero changes to `auth/` or `services/atlantic/`. +- Existing Atlantic users following the README can migrate by changing imports + and one CLI binary name; no semantics change for Atlantic operations. + +## Scope Boundaries + +- **Out of scope: GitHub OAuth / cookie-based auth.** Bearer/wallet only — matches + the herodotus-auth skill's stance. +- **Out of scope: secure-enclave key storage.** Default credential store is a flat + file under `~/.hcloud/` with restrictive permissions; KMS/hardware support is + enabled via the signer interface but the SDK does not ship integrations. +- **Out of scope: implementing storage-proof / data-processor / DSI / satellite + surfaces.** R1–R3 only require the *shape* that lets them be added later; this + brainstorm does not define their operations. +- **Out of scope: billing UI, invoices, payments, project management beyond + reading `selectedProject` and minting/listing API keys.** Anything else under + `auth-billing` (credit packages, transactions, admin) stays server-side. +- **Out of scope: backwards-compatibility shims for the old `@herodotus_dev/atlantic-sdk` + package name.** Old package is deprecated; users migrate by changing imports. + (Confirm — see questions.) +- **Out of scope: rewriting x402 payment logic.** Moves under `services/atlantic/` + unchanged. + +## Key Decisions + +- **Package name: `@herodotus_dev/hcloud`.** CLI binary `hcloud`, repo renamed + to `hcloud`. +- **Hard-deprecate `@herodotus_dev/atlantic-sdk`.** No transition release; final + version of `atlantic-sdk` carries a deprecation notice pointing at `hcloud`. + Migration is a one-line import change for SDK users and a binary rename for + CLI users. +- **Default credential storage: `~/.hcloud/credentials.json` (mode 600), keyed + by wallet.** Confirmed. +- **API key is the durable credential; bearer session is ephemeral.** Once + `auth login` succeeds, the API key persists and Atlantic (and future services) + continue to work indefinitely without re-login. Bearer access/refresh tokens + are stored alongside the API key but only consulted when the user invokes + auth/api-key operations. +- **Multi-wallet CLI: last-login-wins, with explicit switching available.** + `hcloud auth login` sets the new wallet as active. `hcloud auth list` shows + stored wallets; `hcloud auth use ` switches the active one without + re-signing. +- **Single client, namespaced services (R1).** Rationale: shared auth state, shared + HTTP/retry/error handling, single config object, and the disambiguation the user + asked about falls out for free — `client.atlantic.submitQuery` vs + `client.storageProof.submitQuery` are different methods on different objects. + Subpath imports were considered and rejected because they force callers to thread + session state manually and don't match the CLI structure. +- **Reuse the herodotus-auth scripts as the reference, not the dependency.** + Port `auth.ts` / `get-api-key.ts` / `refresh.ts` into `src/auth/` as proper + modules. Rationale: the scripts are intentionally minimal viem-based examples; + copying them in lets us share the SDK's HTTP layer, error types, and signer + abstraction instead of carrying a second style of code. +- **Default credential storage: `~/.hcloud/credentials.json`, mode 600.** + Keyed by wallet address so multiple identities can coexist. Env vars + (`HCLOUD_API_KEY`, `HCLOUD_ACCESS_TOKEN`, `HCLOUD_WALLET_PRIVATE_KEY`) override + the file when set. SDK consumers can inject a custom `CredentialStore` to opt + out of disk entirely (servers, tests). +- **Auth is its own service namespace, not hidden plumbing.** `client.auth.login`, + `client.auth.refresh`, `client.auth.apiKeys.*` are user-callable; the SDK also + calls them implicitly when service requests need a fresh token. +- **CLI is generated from a service registry.** Each service module exports its + command table; the CLI dispatcher composes them. Adding a service does not + require editing the CLI dispatcher. + +## Dependencies / Assumptions + +- `auth-billing` endpoints used here (`/auth/web3/challenge`, `/auth/web3/session`, + `/auth/refresh-token`, `/api-keys`) are stable and match the contract documented + in `herodotus-auth/SKILL.md`. +- `viem` is acceptable as the default EIP-712 signer dependency (already a + dependency of this SDK). +- New wallets are auto-provisioned with a Personal project + one API key by the + backend, so no signup endpoint is needed in the SDK. +- The npm scope `@herodotus_dev` (or whatever final scope is chosen) is available + for `hcloud`. + +## Outstanding Questions + +### Deferred to Planning + +- [Affects R1, R2][Technical] Exact module boundary between `src/core/` + (HTTP/config/errors/retries) and `src/auth/`. Likely shape: `core` exposes a + request function that auth and services both consume; auth attaches bearer/api-key + headers via a request interceptor. +- [Affects R3][Technical] CLI command-registry interface (how each service module + declares its commands, flags, and help text without coupling to the dispatcher). +- [Affects R7][Technical] Auth strategy resolution order when both an explicit + `apiKey` and a stored session are present. Proposed: explicit `apiKey` wins; + otherwise fall back to stored session and refresh on demand. +- [Affects R5][Technical] Refresh trigger policy: refresh on 401, refresh on + expiry-1m, or both. Proposed: both — proactive refresh in long-running clients, + reactive 401 retry as a safety net. +- [Affects R8][Needs research] Signer interface shape that cleanly covers viem + account, ethers v6 signer, and a KMS-style async signer without leaking + library types into the public API. +- [Affects R9][Technical] Where x402 lives long-term — Atlantic-specific today, + but if other services adopt x402 it will need to move under `core/`. +- [Affects R2][Technical] Whether to keep a thin `src/services/atlantic/` re-export + at the old import paths during one release for migration ergonomics, or do a + hard cut. + +## Next Steps + +→ `/ce:plan` for structured implementation planning. diff --git a/docs/guides/adding-a-service.md b/docs/guides/adding-a-service.md new file mode 100644 index 0000000..e4d1b54 --- /dev/null +++ b/docs/guides/adding-a-service.md @@ -0,0 +1,130 @@ +# Adding a new service + +`hcloud` is structured so each Herodotus Cloud service lives under its own +folder and can be added without touching shared code. This guide walks through +creating `client..*` end to end. + +The contract is intentionally small. After this guide: + +- The new service appears as a namespace on `HcloudClient`. +- The CLI dispatcher exposes `hcloud ` automatically. +- No edits to `src/core/`, `src/auth/`, or `src/services/atlantic/` are required. + +## 1. Pick a surface name + +Surfaces are how `core/http.ts` knows which base URL to use. Add yours to +`HcloudSurface` in `src/core/config.ts`: + +```ts +export type HcloudSurface = 'atlantic' | 'auth-billing' | 'storage-proof'; + +export const DEFAULT_BASE_URLS: Record = { + atlantic: 'https://atlantic.api.herodotus.cloud', + 'auth-billing': 'https://auth-billing.api.herodotus.cloud', + 'storage-proof': 'https://storage-proof.api.herodotus.cloud', +}; +``` + +Add an env-var override path if you want one (`HCLOUD_STORAGE_PROOF_BASE_URL`). +The shape mirrors what `atlantic` already does. + +## 2. Create the service folder + +``` +src/services/storage-proof/ + index.ts # public exports + client.ts # StorageProofService class + types.ts # request/response types + cli.ts # CLI command table (ServiceCli) +``` + +`client.ts` takes an injected `HttpClient` and consumes it through the same +`request()` / `send()` API the Atlantic service uses. Auth handling is automatic +— pick the right `authMode`: + +```ts +import type { HttpClient } from '../../core/http'; + +export class StorageProofService { + constructor(private readonly http: HttpClient) {} + + async submitQuery(input: SubmitStorageProofQueryInput) { + const response = await this.http.request({ + method: 'POST', + surface: 'storage-proof', + path: '/queries', + body: JSON.stringify(input), + headers: { 'content-type': 'application/json' }, + authMode: 'api-key', + }); + return response.data; + } +} +``` + +## 3. Register on `HcloudClient` + +Add the namespace in `src/hcloud-client.ts`: + +```ts +this.storageProof = new StorageProofService(http); +``` + +Two services on the same `HcloudClient` can have method names that collide +(`submitQuery` for both Atlantic and storage-proof) — they are unambiguous because +they live on different namespaces. + +## 4. Register the CLI + +Each service exports a `ServiceCli` (see `src/cli/registry.ts`): + +```ts +// src/services/storage-proof/cli.ts +import type { ServiceCli } from '../../cli/registry'; + +export const storageProofCli: ServiceCli = { + name: 'storage-proof', + description: 'Storage proof service', + commands: { + 'submit-query': { + description: 'Submit a storage proof query', + run: async ({ client, flags }) => client.storageProof.submitQuery(readInput(flags)), + }, + }, +}; +``` + +Then add it to the registry list in `src/cli/dispatcher.ts`: + +```ts +import { storageProofCli } from '../services/storage-proof/cli'; + +const services: ServiceCli[] = [authCli, atlanticCli, storageProofCli]; +``` + +`hcloud --help` and `hcloud storage-proof --help` will pick up the new entry +automatically. The dispatcher already supports two-token command names if you +want subgroups (e.g. `hcloud storage-proof some-group action`). + +## 5. Re-export from `src/index.ts` + +Add the public types and class to the package's main export so consumers can +import them without reaching into internal paths: + +```ts +export { StorageProofService } from './services/storage-proof'; +export type { SubmitStorageProofQueryInput } from './services/storage-proof/types'; +``` + +## Done + +That's it. Adding a service does not require any edit to: + +- `src/core/http.ts` +- `src/auth/` +- existing services + +Concretely: the smallest allowed PR for a new service touches **one new folder +under `src/services/`**, plus three small registration sites +(`HcloudSurface` enum, `HcloudClient` constructor, CLI dispatcher list) and the +public re-exports in `src/index.ts`. diff --git a/docs/guides/credentials.md b/docs/guides/credentials.md new file mode 100644 index 0000000..cb0fea7 --- /dev/null +++ b/docs/guides/credentials.md @@ -0,0 +1,108 @@ +# Credentials + +`hcloud` resolves the API key sent on every Atlantic (and future-service) request +through a single deterministic chain. Once an entry is found, lookup stops. + +``` +1. HcloudClient({ apiKey: '...' }) # explicit option +2. process.env.HCLOUD_API_KEY # env var +3. process.env.ATLANTIC_API_KEY # legacy env alias +4. CredentialStore active wallet's apiKey # written by `hcloud auth login` +5. (none — request proceeds without an api-key header) +``` + +If your environment passes an explicit key (CI, server, `--api-key` flag), the +credential store is ignored entirely. + +## Default storage + +`FileCredentialStore` is the default. It writes to `~/.hcloud/credentials.json` +with mode `0600` and uses an atomic write (`.tmp` + `rename`) so a crash +mid-write cannot corrupt the file. + +The file is keyed by wallet address and looks like this: + +```json +{ + "version": 1, + "active": "0xabc...", + "wallets": { + "0xabc...": { + "apiKey": "...", + "projectId": "proj_...", + "session": { + "accessToken": "...", + "refreshToken": "...", + "expiresAt": "2099-01-01T00:00:00.000Z" + }, + "lastLoginAt": "2026-05-07T14:00:00Z" + } + } +} +``` + +The `apiKey` is durable. The `session` is only consulted by `client.auth.*` +operations (mostly `apiKeys.*` and `refresh`). Atlantic never reads the bearer +session — it goes straight to the api-key header. + +## Switching wallets + +`hcloud auth login` writes the new wallet and sets it active (last-login-wins). +If you log in with multiple wallets, you can switch between them without +re-signing: + +```bash +hcloud auth list +hcloud auth use 0xabc... +``` + +## Override the storage path + +```ts +import { HcloudClient, FileCredentialStore } from '@herodotus_dev/hcloud'; + +const client = new HcloudClient({ + credentialStore: new FileCredentialStore({ path: '/etc/hcloud/credentials.json' }), +}); +``` + +## Run without disk persistence (servers, tests, agents) + +`MemoryCredentialStore` keeps everything in memory and never touches disk: + +```ts +import { HcloudClient, MemoryCredentialStore } from '@herodotus_dev/hcloud'; + +const store = new MemoryCredentialStore(); +const client = new HcloudClient({ credentialStore: store }); +``` + +## Custom stores + +Implement the `CredentialStore` interface to plug in OS keychains, encrypted +secrets backends, or remote credential services. The SDK uses the interface +through `AuthService`, which handles refresh serialization and active-wallet +bookkeeping; your store only needs CRUD on `WalletEntry`s. + +```ts +import type { CredentialStore } from '@herodotus_dev/hcloud'; + +export class MyKeychainStore implements CredentialStore { + load() { /* ... */ } + save(file) { /* ... */ } + activeWallet() { /* ... */ } + setActive(wallet) { /* ... */ } + upsertWallet(entry) { /* ... */ } + removeWallet(wallet) { /* ... */ } + clear() { /* ... */ } +} +``` + +## Security notes + +- The credentials file contains long-lived bearer refresh tokens and API keys. + Mode `0600` keeps it readable only by your user; do not loosen that. +- `hcloud` masks `apiKey` in CLI output by default. Pass `--show-secrets` to + reveal values when you actually need to copy them. +- Bearer tokens are channel-bound: they will never be sent as cookies, and + cookie-issued tokens cannot be lifted into `Authorization: Bearer`. diff --git a/docs/guides/signers.md b/docs/guides/signers.md new file mode 100644 index 0000000..bfc2df9 --- /dev/null +++ b/docs/guides/signers.md @@ -0,0 +1,111 @@ +# Signers + +`client.auth.login` needs a signer to produce an EIP-712 signature over the +challenge returned by `auth-billing`. The `HcloudSigner` interface is small on +purpose so it is easy to adapt to whatever wallet stack you already have. + +```ts +export interface HcloudSigner { + getAddress(): Promise<`0x${string}`>; + signTypedData(payload: { + domain: Record; + types: Record>; + primaryType: string; + message: Record; + }): Promise<`0x${string}`>; +} +``` + +> **Sign the challenge verbatim.** Always pass the `eip712` payload from the +> challenge response straight into `signTypedData`. Do not reconstruct the +> domain, types, or message client-side — the server can rotate them. + +## Built-in: private key (viem) + +```ts +import { HcloudClient, privateKeySigner } from '@herodotus_dev/hcloud'; + +const client = new HcloudClient(); +await client.auth.login({ + signer: privateKeySigner(process.env.WALLET_PRIVATE_KEY as `0x${string}`), +}); +``` + +This is the only signer shipped with the SDK. It uses `viem`'s `privateKeyToAccount`. + +## ethers v6 + +```ts +import { Wallet } from 'ethers'; +import type { HcloudSigner } from '@herodotus_dev/hcloud'; + +export function ethersSigner(wallet: Wallet): HcloudSigner { + return { + async getAddress() { + return (await wallet.getAddress()) as `0x${string}`; + }, + async signTypedData(payload) { + // ethers expects EIP712Domain to be omitted from `types` and provided via `domain`. + const { EIP712Domain: _omit, ...types } = payload.types as Record; + return (await wallet.signTypedData(payload.domain, types as never, payload.message)) as `0x${string}`; + }, + }; +} +``` + +## KMS / hardware wallets + +Wrap the underlying signer in the same shape. The signer is the only async +boundary, so any KMS that returns a signature for a typed-data hash plugs in: + +```ts +import type { HcloudSigner } from '@herodotus_dev/hcloud'; + +export function kmsSigner(deps: { + address: `0x${string}`; + signTypedDataDigest(digestHex: `0x${string}`): Promise<`0x${string}`>; +}): HcloudSigner { + return { + async getAddress() { + return deps.address; + }, + async signTypedData(payload) { + const digest = computeTypedDataDigest(payload); // your library of choice + return deps.signTypedDataDigest(digest); + }, + }; +} +``` + +Make sure the digest computation matches EIP-712 exactly (`0x1901 || domainSeparator || hashStruct(message)`). The `viem`, `ethers`, or `eth-sig-util` libraries all expose helpers for this. + +## Browser wallets + +Browser-side, take an EIP-1193 provider and adapt it. The example below assumes +`window.ethereum`: + +```ts +import type { HcloudSigner } from '@herodotus_dev/hcloud'; + +export function browserSigner(provider: { request: (args: unknown) => Promise }): HcloudSigner { + return { + async getAddress() { + const accounts = (await provider.request({ method: 'eth_requestAccounts' })) as `0x${string}`[]; + if (!accounts[0]) throw new Error('No wallet account exposed'); + return accounts[0]; + }, + async signTypedData(payload) { + const accounts = (await provider.request({ method: 'eth_requestAccounts' })) as `0x${string}`[]; + const signature = (await provider.request({ + method: 'eth_signTypedData_v4', + params: [accounts[0], JSON.stringify(payload)], + })) as `0x${string}`; + return signature; + }, + }; +} +``` + +When running in the browser remember that `FileCredentialStore` is not +available — pass `MemoryCredentialStore` (or your own store backed by +`localStorage`/`IndexedDB`) explicitly. diff --git a/docs/plans/2026-05-07-001-feat-rename-to-hcloud-multi-service-sdk-plan.md b/docs/plans/2026-05-07-001-feat-rename-to-hcloud-multi-service-sdk-plan.md new file mode 100644 index 0000000..1a2bf2c --- /dev/null +++ b/docs/plans/2026-05-07-001-feat-rename-to-hcloud-multi-service-sdk-plan.md @@ -0,0 +1,729 @@ +--- +title: "feat: rename to hcloud, add wallet auth and multi-service SDK shape" +type: feat +status: active +date: 2026-05-07 +origin: docs/brainstorms/2026-05-07-hcloud-multi-service-sdk-requirements.md +--- + +# feat: rename to hcloud, add wallet auth and multi-service SDK shape + +## Overview + +Restructure the current single-service Atlantic SDK into a multi-service Herodotus +Cloud SDK, rename it to **`@herodotus_dev/hcloud`** (binary `hcloud`, repo `hcloud`), +and add a built-in wallet authentication subsystem so callers go from "I have a +private key" to "I have a working API key" without a browser, without copying +scripts out of `ai-skills`, and without a manual visit to the dashboard. + +The transformation is structural, not behavioral: every existing Atlantic +operation continues to work bit-for-bit. What changes is where it lives in the +source tree (`src/services/atlantic/`), how it is reached through the public API +(`new HcloudClient(...).atlantic.*`), how it is invoked from the CLI +(`hcloud atlantic `), and the fact that an API key can now be acquired +through `client.auth.login({ signer })` / `hcloud auth login`. + +The design is explicitly forward-leaning on a single dimension only — the +**shape** that makes adding `storage-proof`, `data-processor`, etc. a copy-paste +of an existing service folder rather than a refactor of shared code. + +--- + +## Problem Statement + +Today the package is `@herodotus_dev/atlantic-sdk` with `src/{client,workflows,x402,cli,...}` flat +under `src/`. Three structural problems collide with where Herodotus Cloud is going: + +1. **Single-service framing.** Storage-proof, data-processor, and other services + will share auth, HTTP, retries, and CLI conventions but each has its own domain + surface. The current layout has nothing pulling shared concerns out of + Atlantic-specific code. +2. **No auth flow.** API keys are passed in via config and assumed-available. + `auth-billing` already exposes a programmatic web3 path + (EIP-712 challenge → bearer session → API key) and `ai-skills/.../herodotus-auth/scripts` + has a working reference. That flow belongs in the SDK so any agent or CLI user + can self-provision. +3. **Name collision risk in the future SDK.** Multiple services will have + operations called `submitQuery` (or analogues). They must be unambiguously + reached and disambiguated by *namespace*, not by overloaded top-level functions. + +The brainstorm settled the WHAT (see origin: `docs/brainstorms/2026-05-07-hcloud-multi-service-sdk-requirements.md`). +This plan defines the HOW. + +--- + +## Proposed Solution + +A single `HcloudClient` exposing services as namespaces, a CLI dispatcher that +generates `hcloud ` from a per-service command registry, and a +ported, hardened version of the herodotus-auth scripts living in `src/auth/` with +on-disk credential persistence at `~/.hcloud/credentials.json`. + +### Headline shape + +```ts +// SDK +const client = new HcloudClient({ /* nothing — uses stored creds */ }); +await client.auth.login({ signer: privateKeySigner(env.WALLET_PRIVATE_KEY) }); +await client.atlantic.submitQuery({ ... }); +await client.auth.apiKeys.list(); +// future: +// await client.storageProof.submitQuery({ ... }); + +// CLI +hcloud auth login --private-key 0x... +hcloud atlantic submit-query --program-file ./prog.json ... +hcloud auth api-keys list +``` + +### Source layout (target) + +``` +src/ + index.ts # exports HcloudClient + per-service public types + cli/ + index.ts # bun shebang + runCli + dispatcher.ts # parses argv → routes to service registry + registry.ts # ServiceCli interface, registerServiceCli(...) + output.ts # printJson, formatCliError (unchanged) + core/ + config.ts # base URLs per surface (atlantic, auth-billing, …) + http.ts # HttpClient w/ interceptors (auth, retry) + interceptors.ts # apiKeyHeader, bearerHeader (mutually exclusive) + errors.ts # HcloudError + kind enum (extends current AtlanticErrorKind) + multipart.ts # moved from client/multipart.ts (still Atlantic-shaped today) + hcloud-client.ts # HcloudClient: holds CredentialStore, exposes services + auth/ + index.ts # AuthService class (client.auth.*) + web3.ts # GET challenge, POST session + refresh.ts # POST /auth/refresh-token + api-keys.ts # ApiKeysClient (client.auth.apiKeys.*) + signer.ts # HcloudSigner interface + privateKeySigner default + store/ + credential-store.ts # interface + file-store.ts # ~/.hcloud/credentials.json (mode 600), atomic write + env-store.ts # HCLOUD_* env overlay + memory-store.ts # for tests / serverless + cli.ts # ServiceCli registration: hcloud auth + services/ + atlantic/ + index.ts # AtlanticService class (client.atlantic.*) + client.ts # current AtlanticClient logic, refactored to take HttpClient + types.ts # was src/types.ts (Atlantic-specific only) + workflows/ + queries.ts # was src/workflows/queries.ts + buckets.ts # was src/workflows/buckets.ts + x402/ # whole folder moved unchanged (atlantic-specific today) + cli.ts # ServiceCli: hcloud atlantic +docs/ + guides/ + adding-a-service.md # new — how to create src/services// + migration-from-atlantic-sdk.md # new — import and binary changes +``` + +### Why this layout + +- **Service folders are siblings.** Adding `storage-proof` is `cp -r services/atlantic services/storage-proof`, + edit, register. No shared code touched. +- **`core/` owns shared plumbing.** Auth and services both import `core/http`; + neither imports the other. Auth attaches `Authorization: Bearer` for + auth-billing endpoints; services attach `api-key` headers. +- **`auth/` is its own service**, not hidden plumbing. Same registry pattern, + same CLI invocation shape, same exposure on `HcloudClient` — making it the + canonical example for future services. +- **`x402/` stays inside `services/atlantic/`.** It is Atlantic-specific today. + If a second service adopts x402 the migration to `core/` is a straight move; + YAGNI for now. + +--- + +## Technical Approach + +### Architecture + +#### `HcloudClient` + +```ts +export interface HcloudClientOptions { + baseUrls?: Partial>; // per-service override + apiKey?: string; // explicit override + signer?: HcloudSigner; // optional, attached so client.auth.login() can be called without args + credentialStore?: CredentialStore; // default: file-store + env-overlay + fetch?: FetchLike; + paymentAdapter?: X402PaymentAdapter; // forwarded to AtlanticService +} + +export class HcloudClient { + readonly auth: AuthService; + readonly atlantic: AtlanticService; + // future: readonly storageProof: StorageProofService; + + constructor(options: HcloudClientOptions = {}) { ... } +} +``` + +#### `core/http.ts` + +```ts +export interface HttpRequest { + method: 'GET' | 'POST'; + surface: HcloudSurface; // 'atlantic' | 'auth-billing' | … + path: string; + query?: Record; + body?: BodyInit; + headers?: HeadersInit; + expectStatus?: number | number[]; + authMode: 'api-key' | 'bearer' | 'none'; +} + +export class HttpClient { + constructor(private deps: { config: ResolvedCoreConfig; auth: AuthHeaderProvider; fetch: FetchLike }) {} + request(req: HttpRequest): Promise>; +} +``` + +`AuthHeaderProvider` is implemented inside `AuthService` and injected at +construction. This avoids a circular `core ↔ auth` dependency: `core` defines +the interface, `auth` implements it, `HcloudClient` wires them together. + +#### `core/errors.ts` + +Rename `AtlanticSdkError` → `HcloudError`. Extend `AtlanticErrorKind` with auth-specific kinds: + +```ts +type HcloudErrorKind = + | 'transport' | 'api' | 'validation' | 'timeout' + | 'payment' | 'payment_challenge' | 'payment_settlement' // x402 + | 'not_authenticated' | 'session_expired' | 'signing_failed' | 'channel_binding'; // auth +``` + +Re-export `AtlanticSdkError` as a deprecated alias of `HcloudError` from the +Atlantic service module so internal Atlantic code can be moved with minimal churn, +then remove the alias before publish (we're hard-deprecating, no public alias). + +#### Auth resolution order (resolves brainstorm deferred Q) + +For service requests (`atlantic`, future services): +1. `HcloudClient({ apiKey })` explicit → use it, never refresh, ignore store. +2. `HCLOUD_API_KEY` env → use it, ignore store. +3. Active wallet in `CredentialStore` → use its persisted `apiKey`. +4. Otherwise → throw `HcloudError({ kind: 'not_authenticated', ... })` with message: + `"No API key. Run \`hcloud auth login\` or set HCLOUD_API_KEY."`. + +For auth-billing requests (`client.auth.apiKeys.*`, `client.auth.refresh`): +1. Active wallet's bearer `accessToken`. Refresh proactively if `expiresAt - 60s < now` + AND a `refreshToken` is present. +2. On 401, try one refresh + retry. +3. If refresh fails → throw `HcloudError({ kind: 'session_expired' })` directing user + to re-login. **Do not** auto-trigger interactive login. + +`client.auth.login()` is the only path that drives the EIP-712 dance. Service +calls never silently sign anything. + +#### Channel binding (resolves brainstorm deferred Q) + +`HttpClient` enforces: +- `authMode: 'bearer'` requests **never** carry a `Cookie` header. +- `authMode: 'api-key'` requests carry the `api-key` header and **never** carry + `Authorization`. +- Throws `HcloudError({ kind: 'channel_binding' })` if a caller violates this in + custom headers. (Defensive — matches the herodotus-auth skill's anti-hallucination + rule.) + +#### Refresh policy (resolves brainstorm deferred Q) + +Both proactive and reactive: +- Proactive: when `AuthService` is asked for a bearer header and `expiresAt - 60s < now`, + refresh first. +- Reactive: 401 from auth-billing → single refresh + replay. Two consecutive 401s → + `session_expired`. + +Service calls (Atlantic, etc.) never trigger refresh. They use the persisted +API key directly (per origin R7: API key is the durable credential). + +#### Signer interface (resolves brainstorm deferred Q) + +```ts +export interface HcloudSigner { + getAddress(): Promise<`0x${string}`>; + signTypedData(args: { + domain: Record; + types: Record>; + primaryType: string; + message: Record; + }): Promise<`0x${string}`>; +} + +export function privateKeySigner(privateKey: `0x${string}`): HcloudSigner; +``` + +Default impl uses viem's `privateKeyToAccount` (already a dependency). Adapters +for ethers/KMS/browser wallets are documented in `docs/guides/signers.md` but +not shipped as code — keeping the public surface narrow. + +#### Credential file format + +`~/.hcloud/credentials.json`, mode 600, atomic write (`write tmp + rename`): + +```json +{ + "version": 1, + "active": "0xabc...", + "wallets": { + "0xabc...": { + "apiKey": "...", + "projectId": "proj_...", + "session": { + "accessToken": "...", + "refreshToken": "...", + "expiresAt": "2026-05-07T15:00:00Z" + }, + "lastLoginAt": "2026-05-07T14:00:00Z" + } + } +} +``` + +Per origin: API key persists indefinitely; `session` may be absent or expired +without affecting Atlantic calls. `auth login` writes both; `auth use ` +only updates `active`. + +#### CLI dispatcher and service registry + +Each service module exports a `cli` object: + +```ts +// e.g. src/services/atlantic/cli.ts +export const atlanticCli: ServiceCli = { + name: 'atlantic', + description: 'Atlantic proving service', + commands: { + 'submit-query': { run: ..., flags: {...}, help: '...' }, + 'submit-and-wait': { ... }, + // ... all current commands + }, +}; +``` + +`src/cli/dispatcher.ts` imports each service's `cli` and registers it: + +```ts +const services = [authCli, atlanticCli /*, storageProofCli */]; +``` + +`hcloud --help` lists service groups by reading `services[*].name + .description`. +`hcloud --help` lists commands. `hcloud --help` prints +per-command help. Adding a service requires zero edits to `dispatcher.ts`. + +### Implementation Phases + +#### Phase 1 — Rename and core extraction + +Goal: rename package + binary + repo, introduce `core/` and `services/atlantic/`, +preserve all Atlantic behavior 1:1. No new functionality. + +Tasks: +- `package.json`: name → `@herodotus_dev/hcloud`, bin → `{ "hcloud": "./dist/cli/index.js" }`, + description, keywords. +- Move files: + - `src/client/atlantic-client.ts` → `src/services/atlantic/client.ts` + - `src/client/http.ts` → `src/core/http.ts` (refactor to `HttpClient` class with surfaces) + - `src/client/config.ts` → split: `src/core/config.ts` (base URLs) + caller-side options stay with services + - `src/client/multipart.ts` → `src/core/multipart.ts` (only consumer is Atlantic today; keep it; revisit later) + - `src/errors.ts` → `src/core/errors.ts` (rename class) + - `src/types.ts` → `src/services/atlantic/types.ts` + - `src/workflows/*` → `src/services/atlantic/workflows/*` + - `src/x402/*` → `src/services/atlantic/x402/*` +- Introduce `HcloudClient` skeleton with only `client.atlantic`. +- `AtlanticService` is a thin wrapper around the existing `AtlanticClient` logic + but takes an injected `HttpClient` instead of building its own request loop. +- `src/index.ts`: export `HcloudClient`, `AtlanticService` types, `HcloudError`, + `privateKeySigner` (Phase 2 stub for now). **Stop** wildcard-re-exporting + internal modules; explicit exports only. +- CLI: `runCli` now dispatches by service. Atlantic command set preserved at + `hcloud atlantic `. +- Tests: rename `AtlanticSdkError` → `HcloudError` in tests; otherwise keep + behavior coverage. Add `scaffold.test.ts` assertions that + `new HcloudClient()` exposes `.atlantic`. +- Update existing CI checks (`bun test`, `tsc -p tsconfig.check.json`). +- README: full rewrite under hcloud framing; preserve all Atlantic examples + with new import paths. + +Success criteria: +- `bun test` green. +- `bun run check` green. +- Manual smoke: `hcloud atlantic health`, `hcloud atlantic submit-query …` + behave identically to the old `atlantic` binary. +- No file at `src/client/`, `src/workflows/`, `src/x402/`, `src/types.ts`, + `src/errors.ts`. + +Estimated effort: 1–2 days. + +#### Phase 2 — Auth subsystem + +Goal: implement `src/auth/` — port the herodotus-auth scripts into proper +modules backed by `core/http.ts` and `CredentialStore`. + +Tasks: +- `src/auth/web3.ts`: `fetchChallenge(wallet)`, `submitSession({ wallet, challengeToken, signature })`. +- `src/auth/refresh.ts`: `refreshSession(refreshToken)` (atomic; throws `session_expired`). +- `src/auth/api-keys.ts`: `ApiKeysClient` with `list({ projectId? })`, `create({ projectId, type })`, + `activate(id)`, `deactivate(id)`. Uses bearer auth-mode. +- `src/auth/signer.ts`: `HcloudSigner` interface + `privateKeySigner(hex)` viem impl. +- `src/auth/store/`: + - `credential-store.ts`: interface + ```ts + interface CredentialStore { + load(): Promise; + save(file: CredentialFile): Promise; + activeWallet(): Promise; + setActive(wallet: `0x${string}`): Promise; + upsertWallet(entry: WalletEntry): Promise; + removeWallet(wallet: `0x${string}`): Promise; + } + ``` + - `file-store.ts`: default impl. `~/.hcloud/credentials.json`, `chmod 0600`, + atomic write (write to `credentials.json.tmp`, fsync, rename). + - `env-store.ts`: read-only overlay. If `HCLOUD_API_KEY` set, surfaces a + synthetic active-wallet entry with that key (no session). + - `memory-store.ts`: for tests + servers. +- `src/auth/index.ts`: `AuthService` exposing: + - `login({ signer? }): Promise<{ wallet, apiKey, projectId }>` + - `refresh(): Promise` + - `whoami(): Promise<{ wallet, projectId, apiKey, sessionExpiresAt? } | null>` + - `list(): Promise` + - `use(wallet): Promise` + - `logout(wallet?): Promise` (`undefined` = active; `'all'` = wipe file) + - `apiKeys: ApiKeysClient` +- Wire `AuthService` as the `AuthHeaderProvider` for `HttpClient`. +- `HcloudClient` resolves API key via the order documented above. + +Success criteria: +- Unit tests against a mocked `auth-billing` for: challenge fetch, signature + shape, session exchange, channel-binding rejection, list/create/activate/deactivate + api-keys, refresh happy path, refresh expired path. +- Integration test: full `login()` against a recorded fixture matches the + reference output of `ai-skills/.../scripts/auth.ts`. +- File-store concurrency test: two parallel `upsertWallet` calls don't truncate + the file (atomic rename verified). + +Estimated effort: 2–3 days. + +#### Phase 3 — Auth CLI commands + +Goal: `hcloud auth …` mirrors `client.auth.*`. + +Commands: +- `hcloud auth login [--private-key | (env WALLET_PRIVATE_KEY/HCLOUD_WALLET_PRIVATE_KEY)]` +- `hcloud auth refresh` +- `hcloud auth whoami` — JSON: `{ wallet, projectId, apiKey: "***" + last4, sessionExpiresAt }`. Add `--show-secrets` to print the key in full. +- `hcloud auth list` — JSON list of stored wallets with masked keys + active flag. +- `hcloud auth use ` +- `hcloud auth logout [ | --all]` +- `hcloud auth api-keys list [--project-id ]` +- `hcloud auth api-keys create --type-name --type-color ` +- `hcloud auth api-keys activate ` +- `hcloud auth api-keys deactivate ` + +All commands follow the existing `output.ts` JSON-first pattern. Errors +serialize via `HcloudError.toJSON()`. + +Success criteria: +- CLI test: `auth login` against mocked auth-billing stores creds and reports + active wallet. +- CLI test: subsequent `atlantic submit-query` against mocked Atlantic uses the + stored key automatically. +- CLI test: multi-wallet — login A, login B, `auth list` shows both with B + active. `auth use 0xA…` flips active without re-signing. + +Estimated effort: 1–2 days. + +#### Phase 4 — Atlantic integration, docs, migration guide + +Goal: cement the contract that "one login = forever Atlantic access" and ship docs. + +Tasks: +- Verify origin R7 end-to-end: empty session + valid `apiKey` in store → + Atlantic calls succeed; `auth.refresh` not called; no 401. +- README rewrite under hcloud framing. +- `docs/guides/migration-from-atlantic-sdk.md`: + - `npm i @herodotus_dev/hcloud` (replaces `@herodotus_dev/atlantic-sdk`). + - Imports: `import { AtlanticClient } from '@herodotus_dev/atlantic-sdk'` + → `import { HcloudClient } from '@herodotus_dev/hcloud'; const c = new HcloudClient(...).atlantic;`. + - Binary: `atlantic ` → `hcloud atlantic `. + - Env: `ATLANTIC_API_KEY` → `HCLOUD_API_KEY` (still read for one release with deprecation warning). +- `docs/guides/adding-a-service.md`: walkthrough of creating + `src/services//{client,types,cli,index}.ts` and registering it. +- Final deprecated release of `@herodotus_dev/atlantic-sdk` (optional, manual) + with `package.json#deprecated` notice pointing to `hcloud`. +- Repo rename on GitHub: rename `atlantic-sdk` → `hcloud`. Update all + internal links in README and docs. + +Success criteria: +- A user following migration guide takes < 5 minutes to migrate a known + consumer of `atlantic-sdk`. +- README shows both auth-driven and explicit-key paths. + +Estimated effort: 1 day. + +#### Phase 5 — Service-extension contract (docs only) + +Goal: lock in the multi-service contract without shipping placeholders. + +Tasks: +- `docs/guides/adding-a-service.md` covers: new folder layout, `XService` class, + `XCli` registration, `core/config.ts` base URL entry, `HcloudSurface` enum + extension, exporting from `src/index.ts`. +- Add a deliberately-failing test under `test/multi-service.test.ts` that + asserts the service registry is the only edit point (smoke against an + in-test fake `EchoService`). + +Success criteria: +- `EchoService` test demonstrates registration in < 30 LOC and zero edits to + `core/`, `auth/`, or other services. + +Estimated effort: 0.5 day. + +--- + +## Alternative Approaches Considered + +- **Separate per-service clients (`new AtlanticClient`, `new StorageProofClient`).** + Rejected in brainstorm. Would force callers to hand-thread session state, and + the CLI structure would not mirror it. (origin: Key Decisions §1) +- **Subpath imports per service (`@herodotus_dev/hcloud/atlantic`).** Rejected + in brainstorm. Same reason — auth becomes parameter plumbing rather than + shared state. (origin: Key Decisions §1) +- **Keep `atlantic-sdk` as a thin shim that re-exports from `hcloud`.** Rejected: + brainstorm chose hard deprecation. Migration is one import change; carrying + cost of a shim is not justified. +- **Use OS keychain (libsecret / Keychain Access) for credential storage.** + Considered. Rejected for v1: cross-platform consistency cost > value, and + flat-file with mode 600 matches what `gcloud`, `aws`, `gh` do. Re-revisit if + enterprise users ask. +- **Auto-trigger interactive login on 401.** Rejected. SDK is used in headless + agents and servers; surprise-signing is the wrong default. Throw + `not_authenticated`/`session_expired` and let the caller choose. + +--- + +## System-Wide Impact + +### Interaction Graph + +`client.atlantic.submitQuery(input)` → +`AtlanticService.submitQuery` → `HttpClient.request({ surface: 'atlantic', authMode: 'api-key' })` → +`AuthHeaderProvider.apiKeyHeader()` → reads `CredentialStore.activeWallet()` → +attaches `api-key` header → `fetch` → response → optional x402 challenge handling +(unchanged from today). + +`client.auth.apiKeys.create(...)` → +`ApiKeysClient.create` → `HttpClient.request({ surface: 'auth-billing', authMode: 'bearer' })` → +`AuthHeaderProvider.bearerHeader()` → checks `expiresAt - 60s < now` → if yes, +`AuthService.refresh()` → `HttpClient.request(...)` → new pair → `CredentialStore.upsertWallet` → +returns header → outer request proceeds. + +`client.auth.login({ signer })` → +`fetchChallenge(wallet)` → `signer.signTypedData(challenge.eip712)` → +`submitSession({ ..., channel: 'bearer' })` → `apiKeys.list({ projectId })` → +`CredentialStore.upsertWallet(...)` → `setActive(wallet)`. + +### Error & Failure Propagation + +All HTTP errors funnel through `HttpClient` → `HcloudError`. New error kinds +with explicit handling: +- `not_authenticated` (no creds at all) — actionable message includes + `hcloud auth login` and `HCLOUD_API_KEY`. +- `session_expired` — refresh failed; user must re-login. Do **not** retry. +- `signing_failed` — wraps signer exceptions with the offending wallet address. +- `channel_binding` — defensive; should never escape unit tests. + +x402 errors (`payment`, `payment_challenge`, `payment_settlement`) are +unchanged. + +### State Lifecycle Risks + +- **Concurrent CLI invocations writing to credentials.json.** Mitigation: + atomic write via `fs.rename`; the worst case is a lost write, not corruption. + No multi-line locking. +- **Refresh race.** Two parallel auth-billing requests both decide a refresh is + needed → second refresh invalidates first's `refreshToken`. Mitigation: + `AuthService` serializes refreshes through a single `Promise` field + (in-process). Cross-process races (CLI A and CLI B refreshing simultaneously) + fall back to the reactive 401 path. +- **Stale persisted API key.** If a key is deactivated server-side, Atlantic + returns 401. Mitigation: error message guides user to re-login or + `hcloud auth api-keys list` to inspect. +- **Old `atlantic-sdk` consumers.** They break on upgrade because we hard-deprecate. + Mitigation: clear migration guide; final atlantic-sdk publish carries deprecation + notice. + +### API Surface Parity + +- SDK and CLI must remain symmetrical. Every `client..` has a + `hcloud ` counterpart and vice versa. Enforce by + inspection during PR review; consider a generated parity table later. +- `auth login` is the *only* CLI command without a 1:1 SDK match (the SDK + uses `client.auth.login({ signer })` rather than reading env). Documented + intentional difference. + +### Integration Test Scenarios + +1. **Cold start → login → atlantic.** Empty `~/.hcloud/`. `client.atlantic.health()` + throws `not_authenticated`. After `client.auth.login({ signer })`, same call + succeeds. Credential file exists at mode 600 with valid JSON. +2. **Persisted key only.** Pre-seed credentials with apiKey but no session. + `client.atlantic.submitQuery(...)` succeeds without contacting auth-billing. + `client.auth.apiKeys.list()` triggers refresh attempt; on absent refresh + token, throws `session_expired`. Atlantic call after that still works. +3. **Multi-wallet.** Login wallet A, login wallet B → `active = B`. + `client.atlantic.health()` uses B's apiKey (assert via header inspection). + `client.auth.use(A)` → next call uses A's apiKey. No re-signing occurred. +4. **Env override.** `HCLOUD_API_KEY=abc` set; both A and B in store. + Atlantic call uses `abc`, ignoring active wallet. `client.auth.use(A)` is + a no-op for outgoing service requests but still updates the file. +5. **Channel-binding violation.** Inject a custom header `Cookie: session=…` + into a bearer request → `HcloudError({ kind: 'channel_binding' })` thrown + pre-flight. +6. **Refresh storm.** 10 parallel `auth.apiKeys.list()` calls with a + near-expiry token result in exactly one refresh request (in-process + serialization). + +--- + +## Acceptance Criteria + +### Functional + +- [ ] **R1** `new HcloudClient()` exposes `.auth` and `.atlantic`. Same operation name on different services is unambiguous via namespace. +- [ ] **R2** Source layout matches the target tree above. No file at `src/client/`, `src/workflows/`, `src/x402/`, `src/types.ts`, `src/errors.ts`. +- [ ] **R3** CLI is `hcloud `. Atlantic command set preserved 1:1 under `hcloud atlantic`. Service groups visible in `hcloud --help`; commands in `hcloud --help`. +- [ ] **R4** `client.auth.login({ signer })` and `hcloud auth login` complete the EIP-712 → bearer → API-key flow against `auth-billing` and persist credentials. +- [ ] **R5** `client.auth.refresh` rotates bearer pair. Bearer tokens are never sent as cookies and vice versa (channel-binding test passes). +- [ ] **R6** `client.auth.apiKeys.{list,create,activate,deactivate}` and `hcloud auth api-keys ` work end-to-end. `projectId` defaults to `selectedProject` from active wallet. +- [ ] **R7** Once an API key is persisted, *all* `client.atlantic.*` calls work indefinitely with no re-login. No `auth-billing` requests are made on Atlantic-only workflows. +- [ ] **R8** `HcloudSigner` interface is documented; `privateKeySigner(hex)` is the default; KMS / ethers / browser signers can implement the interface. +- [ ] **R9** Existing Atlantic tests (`client.test.ts`, `workflows.test.ts`, `x402.test.ts`, `multipart.test.ts`) pass with import-path updates only. +- [ ] **R10** `package.json#name` = `@herodotus_dev/hcloud`; `package.json#bin.hcloud` set; repo renamed to `hcloud` on GitHub; README/migration guide updated. + +### Non-Functional + +- [ ] No new runtime dependencies beyond what's already in the SDK (viem is reused for the default signer). +- [ ] Credential file is mode 600 on disk (verified by test). +- [ ] Atomic write: simulated crash mid-write does not corrupt `credentials.json` (verified by test that hard-kills mid-write). +- [ ] Token never logged. Masked in CLI output by default. + +### Quality Gates + +- [ ] `bun test` green (existing + new auth + new multi-service tests). +- [ ] `bun run check` green (typecheck). +- [ ] `bun run format:check` clean. +- [ ] Migration guide tested by hand against a sample `atlantic-sdk` consumer. + +--- + +## Success Metrics + +- A new user goes from `git clone` of an empty project to a successful + `client.atlantic.submitQuery` in **one terminal session** with no browser. +- A new service can be added in **< 1 day** with zero edits to `auth/` or + `services/atlantic/` (proven by Phase 5 `EchoService` test). +- Existing `atlantic-sdk` consumers migrate in a single PR with import-path + changes only. + +--- + +## Dependencies & Prerequisites + +- `auth-billing` endpoints (`/auth/web3/challenge`, `/auth/web3/session`, + `/auth/refresh-token`, `/api-keys`) stable and matching the contract in + `ai-skills/.../herodotus-auth/SKILL.md`. +- npm scope `@herodotus_dev` available for `hcloud` (assumed; confirm before publish). +- GitHub permission to rename the repository. +- viem already a dependency; no new packages. + +--- + +## Risk Analysis & Mitigation + +- **Risk:** `auth-billing` schema drift between brainstorm date and shipping. + *Mitigation:* contract tests against a fixture recorded from the running + service; fail fast if shape changes. +- **Risk:** Hard deprecation breaks downstream users immediately. + *Mitigation:* migration guide, deprecation notice on final `atlantic-sdk` + release, announcement in repo README at rename time. +- **Risk:** Credential file leakage. + *Mitigation:* mode 600, mask keys in default CLI output, never log secrets, + document storage location prominently in README. +- **Risk:** Refresh races between CLI and SDK on the same machine. + *Mitigation:* documented; reactive 401 retry catches the rare loss; advisory + lock deferred until needed. +- **Risk:** Channel-binding bug accidentally sending bearer as cookie. + *Mitigation:* defensive check in `HttpClient`; integration test covers it. +- **Risk:** Rename breaks open PRs / forks. + *Mitigation:* GitHub repo redirect handles old URLs; one-time PR flush + before rename. + +--- + +## Future Considerations + +- **Storage-proof + data-processor + data-structure-indexer + satellite** + services. Each follows the `services//` template. +- **OS keychain credential store** as an opt-in alternative to file-store. +- **Browser bundle** (currently Bun-first; enabling browser would require + splitting node-only modules like `fs`). +- **x402 generalization** if a second service adopts the protocol — promote + `services/atlantic/x402/` to `core/x402/`. +- **Org-shared credentials** (CI runners, agents) — separate concern; needs a + remote credential store implementing `CredentialStore`. + +--- + +## Documentation Plan + +- `README.md` — full rewrite under hcloud framing. +- `docs/guides/migration-from-atlantic-sdk.md` — new. +- `docs/guides/adding-a-service.md` — new (locks in the contract). +- `docs/guides/signers.md` — new (ethers/KMS/browser signer recipes). +- `docs/guides/credentials.md` — new (storage location, env vars, + custom `CredentialStore`). +- Existing examples under `examples/` updated to new import paths and to show + `auth login` as the first step. + +--- + +## Sources & References + +### Origin + +- **Origin document:** [docs/brainstorms/2026-05-07-hcloud-multi-service-sdk-requirements.md](../brainstorms/2026-05-07-hcloud-multi-service-sdk-requirements.md). Key decisions carried forward: + - Single `HcloudClient` with namespaced services, mirrored CLI `hcloud `. + - Hard-deprecate `@herodotus_dev/atlantic-sdk`; final name `@herodotus_dev/hcloud`. + - Credential storage: `~/.hcloud/credentials.json`, mode 600, keyed by wallet, env overlay. + - API key is the durable credential; bearer session is ephemeral. One login = forever Atlantic. + - Multi-wallet: last-login-wins with `auth list` / `auth use ` for explicit switching. + +### Internal References + +- Current Atlantic client (to be moved): `src/client/atlantic-client.ts:32`. +- Current HTTP layer (to be promoted to `core/`): `src/client/http.ts:19`. +- Current error types (to be renamed): `src/errors.ts:20`. +- Current CLI dispatcher (to be replaced by per-service registry): `src/cli/commands.ts:1`. +- Existing scaffold tests anchoring the public surface: `test/scaffold.test.ts:1`. +- Prior origin plan for context: `docs/plans/2026-05-06-001-feat-atlantic-bun-sdk-plan.md`. + +### Reference Implementation + +- `auth.ts` (137 LOC, full EIP-712 → bearer → api-key flow): `../ai-skills/plugins/herodotus-skills/skills/herodotus-auth/scripts/auth.ts:55`. +- `get-api-key.ts` (api-key list/read): `../ai-skills/plugins/herodotus-skills/skills/herodotus-auth/scripts/get-api-key.ts:25`. +- `refresh.ts` (bearer rotation): `../ai-skills/plugins/herodotus-skills/skills/herodotus-auth/scripts/refresh.ts:31`. +- Skill documentation: `../ai-skills/plugins/herodotus-skills/skills/herodotus-auth/SKILL.md`. + +### Backend Contract + +- Web3 challenge route: `../auth-billing/src/routes/auth/web3/challenge.ts`. +- Session issuer (channel binding): `../auth-billing/src/routes/auth/web3/session-issuer.ts`. +- EIP-712 typed-data builder: `../auth-billing/src/routes/auth/web3/eip712.ts`. +- Refresh: `../auth-billing/src/routes/auth/refresh.ts`. +- API keys: `../auth-billing/src/routes/api-keys/{create,get,activate,deactivate}.ts`. From e368f86afc6654b1d1730d3771fde69234832ecd Mon Sep 17 00:00:00 2001 From: skusnierz Date: Thu, 7 May 2026 09:33:03 +0200 Subject: [PATCH 6/9] test: lock in multi-service contract via EchoService test --- .../atlantic-bun-sdk-requirements.md | 161 +++++ ...26-05-06-001-feat-atlantic-bun-sdk-plan.md | 572 ++++++++++++++++++ test/multi-service.test.ts | 111 ++++ 3 files changed, 844 insertions(+) create mode 100644 docs/brainstorms/atlantic-bun-sdk-requirements.md create mode 100644 docs/plans/2026-05-06-001-feat-atlantic-bun-sdk-plan.md create mode 100644 test/multi-service.test.ts diff --git a/docs/brainstorms/atlantic-bun-sdk-requirements.md b/docs/brainstorms/atlantic-bun-sdk-requirements.md new file mode 100644 index 0000000..6c27aea --- /dev/null +++ b/docs/brainstorms/atlantic-bun-sdk-requirements.md @@ -0,0 +1,161 @@ +--- +date: 2026-05-06 +topic: atlantic-bun-sdk +--- + +# Atlantic Bun SDK + +## Summary + +Build a Bun-first TypeScript SDK and CLI for Atlantic that covers the full public API while also providing agent-friendly workflow helpers for query submission, lifecycle tracking, retries, buckets, artifacts, and x402 payments. + +--- + +## Problem Frame + +Atlantic already exposes proving, query lifecycle, bucket, and payment capabilities through public API documentation and the `atlantic-api` AI skill. A developer or coding agent can call those endpoints directly, but doing so forces every integration to rediscover request composition, multipart file handling, polling, retry semantics, x402 payment flow, artifact lookup, and error interpretation. + +The SDK should make Atlantic easier to use from Bun and TypeScript projects without hiding the underlying API model. It should also give agents a predictable surface with clear method descriptions, safer defaults, and workflow-level helpers so agent-built integrations are less likely to hallucinate endpoints, mishandle payments, or lose query state. + +--- + +## Actors + +- A1. SDK user: A developer integrating Atlantic into a Bun or TypeScript application. +- A2. CLI user: A developer or operator running Atlantic workflows from a terminal. +- A3. AI agent: A coding or operations agent using the SDK/CLI with limited context and needing strong descriptions and guardrails. +- A4. Atlantic API: The existing Herodotus Atlantic service that receives queries, tracks jobs, manages buckets, and handles x402 challenges. + +--- + +## Key Flows + +- F1. Submit and track a query + - **Trigger:** A user wants to submit a Cairo program, PIE, or proof-related input to Atlantic. + - **Actors:** A1, A2, A3, A4 + - **Steps:** The caller prepares input files and query options, submits the request, receives an Atlantic query ID, and can poll or inspect details until the query reaches a terminal state. + - **Outcome:** The caller has a durable query ID, current status, and a clear path to metadata or artifacts. + - **Covered by:** R1, R2, R5, R8, R9 + +- F2. Recover or retry a query + - **Trigger:** A query fails, a submit operation is ambiguous, or a caller needs to locate a query by deduplication key. + - **Actors:** A1, A2, A3, A4 + - **Steps:** The caller retrieves query details or looks up by deduplication key, determines whether retry is allowed, retries through the API when valid, and surfaces non-retriable states clearly. + - **Outcome:** Retriable failures are restarted without guessing; non-retriable failures are explained. + - **Covered by:** R3, R4, R6, R9, R10 + +- F3. Work with Applicative Recursion buckets + - **Trigger:** A caller wants to group multiple Atlantic queries into a bucket for Applicative Recursion. + - **Actors:** A1, A2, A3, A4 + - **Steps:** The caller creates or lists buckets, submits bucket-linked queries when supported, inspects bucket details and associated queries, and closes a bucket when ready. + - **Outcome:** Bucket workflows are available through both SDK and CLI without manual endpoint composition. + - **Covered by:** R7, R8, R9, R11 + +- F4. Pay with x402 when required + - **Trigger:** A submit request receives a payment challenge because project credits are insufficient or the caller uses an anonymous wallet flow. + - **Actors:** A1, A3, A4 + - **Steps:** The SDK exposes the challenge, signs or delegates signing through a wallet adapter, retries the original submit with a payment signature, and returns the settlement response alongside the query result. + - **Outcome:** Payment is handled according to the public x402 flow, without preemptive signatures or invented payment endpoints. + - **Covered by:** R12, R13, R14, R15 + +--- + +## Requirements + +**API coverage** +- R1. The SDK must expose typed methods for every public Atlantic API capability documented in the current Atlantic OpenAPI contract: health check, submit query, get query by ID, get query by dedup ID, list queries, query stats, query jobs, retry query, list buckets, create bucket, get bucket details, and close bucket. +- R2. Query submission must support the documented Atlantic inputs and options, including program, input, PIE, and proof file inputs; Cairo version and VM options; result selection; layouts; prover selection; declared job size; network selection; deduplication identifiers; external identifiers; and bucket-related fields. +- R3. Query lookup must support direct query ID lookup and deduplication-key lookup as separate explicit capabilities. +- R4. Retry support must call the public retry capability and clearly distinguish retriable, non-retriable, max-retry, missing-query, forbidden, and wrong-state outcomes. +- R5. Query lifecycle support must make status, step, job list, metadata URLs, and terminal-state inspection easy to consume without requiring callers to manually understand every raw API response. +- R6. List operations must support pagination where the Atlantic API supports it. +- R7. Bucket support must cover listing buckets, creating buckets, fetching bucket details with associated queries, closing buckets, and submitting bucket-linked queries where the API permits them. + +**Agent-first workflow helpers** +- R8. The SDK must include higher-level helpers for common workflows: submit-and-return-ID, submit-and-wait, wait-for-query, retry-if-retriable, get-query-with-jobs, and bucket-oriented submission. +- R9. Workflow helpers must preserve caller control over polling intervals, timeout budgets, retry budgets, and terminal-state behavior. +- R10. The SDK must keep Atlantic query IDs, dedup IDs, and bucket IDs visible as first-class outputs rather than hiding them inside opaque workflow results. +- R11. Agent-facing helper descriptions must include what the helper does, when to use it, required inputs, important constraints, expected result, and common failure modes. + +**x402 payments** +- R12. The SDK must support Atlantic's documented x402 v2 flow for `submit query`, including challenge parsing, payment-signature retry, and settlement-response parsing. +- R13. The SDK must support both API-key fallback payments and anonymous wallet payments, while making their behavioral differences explicit to the caller. +- R14. The SDK must prevent or clearly warn against unsafe x402 usage: preemptive payment signatures, reused successful payment signatures, hardcoded payment requirements, anonymous `dedupId` usage, anonymous bucket usage, and double-payment after ambiguous settlement. +- R15. x402 signing must be wallet-adapter friendly so applications can provide their own signing implementation rather than coupling the SDK to a single wallet source. + +**CLI** +- R16. The CLI must expose commands for the same main capabilities as the SDK: submit query, retry query, get query details, get query by dedup ID, list queries, get query jobs, query stats, list buckets, create bucket, get bucket, close bucket, and health check. +- R17. CLI commands must support file-path inputs for query submission and must print machine-readable JSON by default or through an explicit mode. +- R18. CLI commands must support environment-based configuration for the Atlantic base URL, API key, and payment-related wallet configuration. +- R19. CLI output must preserve important identifiers, status, payment receipt data, and error details so agents can chain commands reliably. + +**Developer experience and quality** +- R20. The package must be Bun-first while remaining idiomatic TypeScript for library consumers. +- R21. Public SDK methods must have clear names, strong TypeScript types, and documentation comments that explain behavior rather than merely restating parameter names. +- R22. The SDK must separate low-level API methods from higher-level workflow helpers so advanced users can keep exact control while agents and common integrations get safer defaults. +- R23. Errors must be structured enough for callers to branch on category, HTTP status where available, Atlantic message/code where available, and payment-specific failure class where available. +- R24. The codebase must be organized for maintainability, with focused modules, minimal duplication, and tests that cover request construction, response parsing, x402 behavior, workflow helpers, and CLI command behavior. + +--- + +## Acceptance Examples + +- AE1. **Covers R1, R16.** Given a user wants to inspect their Atlantic usage from code or terminal, when they list queries, fetch query details, fetch query jobs, or query stats, then both SDK and CLI expose those capabilities without requiring manual URL construction. +- AE2. **Covers R2, R8, R10.** Given a user submits a query using a local PIE file, when submission succeeds, then the result includes the Atlantic query ID and any relevant metadata needed for later polling or artifact inspection. +- AE3. **Covers R4, R23.** Given a failed query is not retriable or has exceeded retry limits, when the caller retries it, then the SDK/CLI returns a structured failure that lets the caller distinguish the reason without parsing prose. +- AE4. **Covers R7, R16.** Given a user is building an Applicative Recursion flow, when they create a bucket, submit bucket-linked queries, inspect the bucket, and close it, then each step is available through documented SDK methods and CLI commands. +- AE5. **Covers R12, R14, R15.** Given a submit request receives an x402 challenge, when the caller provides a wallet signing adapter, then the SDK signs the documented payment authorization, retries the original submit, returns the query result and settlement response, and does not reuse a successful payment signature for a later query. +- AE6. **Covers R11, R21.** Given an AI agent sees only the SDK method descriptions, when it chooses between direct API methods and workflow helpers, then the descriptions are concrete enough to avoid inventing unsupported payment endpoints or unsupported anonymous-flow bucket/dedup behavior. +- AE7. **Covers R17, R19.** Given an agent runs the CLI in a shell workflow, when a command succeeds or fails, then the output includes stable JSON fields for IDs, status, result data, payment receipt data, and structured errors. + +--- + +## Success Criteria + +- A developer can complete a normal Atlantic proof workflow from Bun using the SDK without reading the raw OpenAPI spec for every call. +- An AI agent can use the SDK/CLI safely for submit, poll, retry, bucket, and x402 workflows with low hallucination risk. +- Full public API coverage exists without sacrificing clear higher-level workflow helpers. +- Payment behavior follows Atlantic's documented x402 flow and avoids double-payment or unsupported anonymous-flow behavior. +- A downstream planning or implementation agent can proceed from this document without inventing SDK product behavior or CLI scope. + +--- + +## Scope Boundaries + +- Do not change the Atlantic backend or public API as part of this SDK effort. +- Do not invent payment endpoints or payment flows outside the documented x402 behavior on query submission. +- Do not build a GUI, dashboard, or hosted service. +- Do not hide all Atlantic concepts behind a single opaque workflow; query IDs, dedup IDs, statuses, jobs, buckets, and payment receipts remain visible. +- Do not implement persistent state storage as a required SDK feature in the first version; applications may persist IDs and results themselves. +- Do not make on-chain verification adapters a blocker unless they are thin helpers around documented Atlantic results and public integration points. + +--- + +## Key Decisions + +- Full API plus agent-first helpers: The SDK should not choose between complete endpoint coverage and ergonomic workflows; it needs both because direct developers and AI agents have different needs. +- SDK as the source of logic, CLI as operational surface: Shared SDK behavior prevents divergent request handling, x402 semantics, and error interpretation between code and terminal usage. +- x402 scoped to documented submit flow: This keeps payment support useful while avoiding hallucinated endpoints and unsafe payment abstractions. +- Explicit IDs and structured errors: Atlantic workflows are asynchronous and operational; callers need durable identifiers and branchable errors more than opaque convenience wrappers. +- Documentation comments are part of the product: Method descriptions should guide agents and developers toward correct usage, not merely satisfy generated API documentation. + +--- + +## Dependencies / Assumptions + +- The current source of truth is the Atlantic OpenAPI contract, the Atlantic API docs, the `atlantic-api` AI skill, and the existing Atlantic route schemas. +- Bun is the primary runtime and package manager target. +- TypeScript users are a primary audience, so types and documentation comments carry product value. +- Wallet signing should be adapter-based because caller environments vary across agents, CLIs, server apps, and local scripts. +- API-key authentication remains the normal flow; x402 is used when required by Atlantic or when anonymous wallet flow is intentionally used. + +--- + +## Outstanding Questions + +### Deferred to Planning + +- [Affects R12, R15][Technical] Which wallet/signing adapter shape best supports Bun CLI, server-side SDK use, and external wallet clients without overcoupling? +- [Affects R17, R18][Technical] What exact CLI command naming and configuration precedence should be used for environment variables, flags, and config files? +- [Affects R24][Needs research] Should SDK types be generated from OpenAPI, hand-authored from source schemas, or combined with a generated base plus curated workflow types? +- [Affects R8, R9][Technical] What default polling interval, timeout, and retry budgets should helpers use? diff --git a/docs/plans/2026-05-06-001-feat-atlantic-bun-sdk-plan.md b/docs/plans/2026-05-06-001-feat-atlantic-bun-sdk-plan.md new file mode 100644 index 0000000..1cf5e57 --- /dev/null +++ b/docs/plans/2026-05-06-001-feat-atlantic-bun-sdk-plan.md @@ -0,0 +1,572 @@ +--- +title: "feat: Build Atlantic Bun SDK and CLI" +type: feat +status: active +date: 2026-05-06 +origin: docs/brainstorms/atlantic-bun-sdk-requirements.md +--- + +# feat: Build Atlantic Bun SDK and CLI + +## Summary + +Create a Bun-first TypeScript package that exposes a complete Atlantic API client, agent-oriented workflow helpers, x402 payment support, and a JSON-first CLI. The implementation should keep the SDK as the shared behavior layer, with CLI commands delegating to the SDK rather than duplicating request, retry, payment, and error handling. + +--- + +## Problem Frame + +The origin requirements define a greenfield SDK/CLI because direct Atlantic API usage forces each integration to rediscover multipart query submission, lifecycle polling, retry semantics, Applicative Recursion buckets, artifact metadata, and x402 payment rules. This plan turns that product scope into an implementation sequence for the currently minimal `atlantic-sdk` repo. + +--- + +## Requirements + +- R1. Expose typed SDK methods for every public Atlantic API capability in the current contract. +- R2. Support documented query submission inputs, options, files, dedup IDs, external IDs, and bucket fields. +- R3. Support query lookup by query ID and by dedup ID. +- R4. Support retry with structured retriable/non-retriable/error-state handling. +- R5. Make query lifecycle, status, steps, jobs, metadata URLs, and terminal state easy to consume. +- R6. Support pagination for list operations where available. +- R7. Cover listing, creating, fetching, closing, and using Applicative Recursion buckets. +- R8. Provide workflow helpers for submit, wait, retry, query-with-jobs, and bucket-oriented flows. +- R9. Let callers control polling intervals, timeout budgets, retry budgets, and terminal-state behavior. +- R10. Keep Atlantic query IDs, dedup IDs, bucket IDs, and payment receipts visible. +- R11. Write agent-useful SDK descriptions covering purpose, inputs, constraints, results, and common failures. +- R12. Support Atlantic's documented x402 v2 submit-query payment flow. +- R13. Support API-key fallback payments and anonymous wallet payments with explicit behavioral differences. +- R14. Guard against unsafe x402 usage and unsupported anonymous-flow dedup/bucket behavior. +- R15. Keep x402 signing wallet-adapter friendly. +- R16. Expose CLI commands for the main SDK capabilities. +- R17. Support file-path query inputs and machine-readable CLI output. +- R18. Support environment-based CLI/SDK configuration for base URL, API key, and payment options. +- R19. Preserve IDs, statuses, payment receipts, and structured errors in CLI output. +- R20. Be Bun-first while remaining idiomatic TypeScript for library consumers. +- R21. Use clear public names, strong types, and documentation comments. +- R22. Separate low-level API methods from higher-level workflow helpers. +- R23. Use structured, branchable SDK and CLI errors. +- R24. Organize code and tests for maintainability across request construction, response parsing, x402, helpers, and CLI behavior. + +**Origin actors:** A1 SDK user, A2 CLI user, A3 AI agent, A4 Atlantic API. + +**Origin flows:** F1 submit and track a query, F2 recover or retry a query, F3 work with Applicative Recursion buckets, F4 pay with x402 when required. + +**Origin acceptance examples:** AE1 API/CLI parity, AE2 submit with durable query ID, AE3 structured retry failure, AE4 bucket workflow, AE5 x402 challenge handling, AE6 agent-readable descriptions, AE7 JSON CLI chaining. + +--- + +## Scope Boundaries + +- Do not change the Atlantic backend or public API. +- Do not invent payment endpoints or payment flows outside documented x402 behavior on query submission. +- Do not build a GUI, dashboard, hosted service, or marketing documentation site. +- Do not hide Atlantic IDs, statuses, jobs, buckets, or payment receipts behind opaque workflow results. +- Do not require persistent SDK-managed state storage in the first version. +- Do not make full on-chain verifier adapters a blocker for this SDK unless they are thin helpers around documented Atlantic results. + +### Deferred to Follow-Up Work + +- Hosted docs site: publish generated API docs or a full documentation website after the SDK surface stabilizes. +- Additional payment schemes beyond Atlantic's current x402 submit-query flow: revisit only when the upstream API exposes them. +- Persistent local state cache for the CLI: consider later if repeated operator workflows need it. + +--- + +## Context & Research + +### Relevant Code and Patterns + +- `README.md`: current repo is effectively empty, so this is a greenfield package rather than an extension of existing SDK patterns. +- `docs/brainstorms/atlantic-bun-sdk-requirements.md`: origin document for product scope, actors, flows, acceptance examples, and scope boundaries. +- `../cloud-services-docs/atlantic-api/openapi-atlantic.json`: current public API contract for endpoints, request bodies, response shapes, and x402 response schema. +- `../cloud-services-docs/atlantic-api/x402-payments.mdx`: Atlantic-specific x402 behavior, including API-key fallback and anonymous wallet differences. +- `../ai-skills/plugins/herodotus-skills/skills/atlantic-api/SKILL.md`: agent guardrails for Atlantic workflows, query lifecycle, artifact handling, and x402. +- `../atlantic/src/routes/atlantic/*/schemas.ts`: implementation-side schemas that clarify documented fields, status values, retry outcomes, and bucket/query response shapes. + +### Institutional Learnings + +- No repo-local `docs/solutions/` learnings exist yet. + +### External References + +- Coinbase x402 migration guide: confirms x402 v2 header names, package split, and current v2 client/scheme pattern. +- Coinbase x402 flow docs: confirms request, 402 challenge, payment creation, retry, and response flow. +- x402 docs: confirms x402 as an HTTP-native payment protocol suitable for APIs and autonomous agents. + +--- + +## Key Technical Decisions + +| Decision | Rationale | +| --- | --- | +| SDK-first architecture with CLI delegation | Keeps request construction, errors, workflow helpers, and x402 behavior consistent across library and terminal usage. | +| Curated public types backed by source contracts | Pure generated types would cover breadth but can produce poor DX; hand-authored-only types risk drift. Use the OpenAPI and route schemas as sources while designing clean exported SDK types. | +| Two SDK layers: API client and workflows | Low-level methods preserve full control; workflow helpers satisfy agent and common integration needs without hiding identifiers. | +| Adapter-based x402 signing | Supports Bun CLI, server apps, agent wallets, and external wallet clients without coupling the SDK to one private-key or wallet implementation. | +| JSON-first CLI output | Makes terminal workflows chainable and agent-readable, while still allowing later human-friendly formatting if needed. | +| Structured error model | Callers need to branch on Atlantic/API/payment outcomes without parsing prose or raw response bodies. | + +--- + +## Open Questions + +### Resolved During Planning + +- Wallet/signing adapter shape: use an SDK-defined adapter interface for x402 signing so the core payment flow can remain independent of any one wallet library; provide a Bun/private-key adapter as an optional convenience in the CLI-facing layer. +- CLI naming and configuration precedence: plan for explicit flags to override environment variables, with environment variables as the baseline configuration source. Config files are deferred until a concrete need appears. +- SDK types source strategy: use curated public types informed by OpenAPI and route schemas rather than exposing raw generated types as the primary public API. +- Default workflow budgets: define conservative defaults in code, but require all wait/retry helpers to accept caller overrides. + +### Deferred to Implementation + +- Exact public method and command names: settle during implementation while keeping the capability map and documentation requirements intact. +- Exact generated-doc tooling: choose after package tooling is scaffolded and public exports are visible. +- Exact x402 package use versus manual header handling: decide during implementation based on Bun compatibility and how well current x402 packages fit Atlantic's server-specific challenge shape. + +--- + +## Output Structure + +This tree shows the expected package shape. It is a scope declaration, not a rigid implementation constraint. + +```text +. +├── package.json +├── bun.lock +├── tsconfig.json +├── src +│ ├── index.ts +│ ├── client +│ │ ├── atlantic-client.ts +│ │ ├── http.ts +│ │ └── multipart.ts +│ ├── cli +│ │ ├── index.ts +│ │ ├── commands.ts +│ │ ├── config.ts +│ │ └── output.ts +│ ├── errors.ts +│ ├── types.ts +│ ├── workflows +│ │ ├── queries.ts +│ │ └── buckets.ts +│ └── x402 +│ ├── adapter.ts +│ ├── headers.ts +│ └── payments.ts +├── test +│ ├── client.test.ts +│ ├── cli.test.ts +│ ├── errors.test.ts +│ ├── workflows.test.ts +│ └── x402.test.ts +└── docs + ├── brainstorms + └── plans +``` + +--- + +## High-Level Technical Design + +> *This illustrates the intended approach and is directional guidance for review, not implementation specification. The implementing agent should treat it as context, not code to reproduce.* + +```mermaid +flowchart TB + Config["SDK/CLI configuration"] + Http["HTTP + multipart transport"] + Errors["Structured errors"] + Api["Atlantic API client"] + Workflows["Agent-first workflows"] + X402["x402 payment flow"] + Cli["CLI commands"] + + Config --> Http + Http --> Errors + Http --> Api + X402 --> Api + Api --> Workflows + Api --> Cli + Workflows --> Cli + Errors --> Cli +``` + +--- + +## Implementation Units + +```mermaid +flowchart TB + U1["U1 Scaffold package"] + U2["U2 Core transport and errors"] + U3["U3 Public types"] + U4["U4 Full API client"] + U5["U5 Workflow helpers"] + U6["U6 x402 payments"] + U7["U7 CLI"] + U8["U8 Documentation and examples"] + + U1 --> U2 + U2 --> U3 + U3 --> U4 + U4 --> U5 + U4 --> U6 + U5 --> U7 + U6 --> U7 + U7 --> U8 +``` + +### U1. Scaffold Bun TypeScript package + +**Goal:** Establish the Bun package, TypeScript compiler configuration, test harness, lint/format baseline, public entrypoint, and binary entrypoint needed for SDK and CLI work. + +**Requirements:** R20, R24 + +**Dependencies:** None + +**Files:** +- Create: `package.json` +- Create: `bun.lock` +- Create: `tsconfig.json` +- Create: `src/index.ts` +- Create: `src/cli/index.ts` +- Create: `test/scaffold.test.ts` +- Modify: `README.md` + +**Approach:** +- Define the package as a TypeScript library with Bun as the primary runtime and test runner. +- Configure exports for SDK use and a CLI binary entrypoint. +- Keep initial runtime dependencies minimal; add domain dependencies only when required by later units. +- Add a smoke test that validates the package entrypoint and CLI entrypoint can be imported. + +**Execution note:** Start test-first with the entrypoint smoke test so package wiring is validated before feature units build on it. + +**Patterns to follow:** +- Current repo root conventions in `README.md`. +- Bun package conventions for scripts, tests, and bin entrypoints. + +**Test scenarios:** +- Happy path: importing the SDK entrypoint exposes the planned public namespace without throwing. +- Happy path: importing the CLI entrypoint does not execute a command as a side effect. +- Error path: package test setup fails clearly if TypeScript compilation cannot resolve source paths. + +**Verification:** +- The package has working Bun scripts for test and typecheck. +- SDK and CLI entrypoints exist but contain only safe initialization scaffolding. + +### U2. Build core transport, configuration, multipart, and error model + +**Goal:** Implement shared configuration, HTTP request handling, multipart request construction, response parsing, and structured errors used by all SDK and CLI capabilities. + +**Requirements:** R2, R18, R19, R23, R24 + +**Dependencies:** U1 + +**Files:** +- Create: `src/client/http.ts` +- Create: `src/client/multipart.ts` +- Create: `src/client/config.ts` +- Create: `src/errors.ts` +- Create: `test/errors.test.ts` +- Create: `test/http.test.ts` +- Create: `test/multipart.test.ts` + +**Approach:** +- Centralize base URL, API key, headers, query parameters, and fetch injection. +- Support Bun-native file inputs and browser/server-compatible binary inputs where practical without compromising Bun-first behavior. +- Normalize Atlantic error responses into structured SDK errors that preserve HTTP status, raw response data, Atlantic message/error fields, and payment-specific classification hooks. +- Keep multipart construction reusable for SDK calls and CLI file-path conversion. + +**Patterns to follow:** +- Atlantic route schemas in `../atlantic/src/routes/atlantic/submit-query/schemas.ts` for multipart field expectations. +- OpenAPI request body shape in `../cloud-services-docs/atlantic-api/openapi-atlantic.json`. + +**Test scenarios:** +- Happy path: configuration merges explicit options with environment defaults without mutating caller input. +- Happy path: JSON requests include base URL, path, query parameters, and API key headers when configured. +- Happy path: multipart query submission serializes files and scalar fields in the expected form-data categories. +- Edge case: optional API key is omitted for anonymous x402 flows without sending an empty header. +- Edge case: nullish query fields are omitted or serialized according to the documented Atlantic behavior. +- Error path: a response with `message` becomes a structured Atlantic error with status and message preserved. +- Error path: a response with `error` becomes a structured Atlantic error with status and error preserved. +- Error path: invalid JSON error bodies still produce a structured transport error with raw text preserved. + +**Verification:** +- All later units can call one transport layer rather than constructing fetch requests directly. +- Errors are branchable without parsing prose. + +### U3. Define public types and capability map + +**Goal:** Create curated TypeScript types, enums, request/response models, lifecycle helpers, payment models, and capability documentation anchors for the public SDK surface. + +**Requirements:** R1, R2, R3, R4, R5, R6, R7, R10, R11, R21, R22 + +**Dependencies:** U1, U2 + +**Files:** +- Create: `src/types.ts` +- Create: `src/x402/adapter.ts` +- Create: `test/types.test.ts` + +**Approach:** +- Model Atlantic statuses, retry blocked reasons, Cairo options, result options, job sizes, networks, query responses, bucket responses, list responses, and metadata URL responses. +- Model submit query input as a developer-friendly type that can still represent every documented field. +- Model x402 challenge, payment requirement, payment payload, settlement response, and signing adapter contracts. +- Use documentation comments on exported types and methods as a design requirement, especially where agent misuse is likely. + +**Patterns to follow:** +- Response schemas in `../atlantic/src/routes/atlantic/get-atlantic-query/schemas.ts`. +- Retry schema in `../atlantic/src/routes/atlantic/retry-atlantic-query/schemas.ts`. +- Bucket schemas in `../atlantic/src/routes/atlantic/create-bucket/schemas.ts`, `../atlantic/src/routes/atlantic/get-buckets/schemas.ts`, and `../atlantic/src/routes/atlantic/get-bucket-details/schemas.ts`. +- x402 data shape from `../cloud-services-docs/atlantic-api/x402-payments.mdx`. + +**Test scenarios:** +- Happy path: representative query, job, bucket, list, and x402 objects satisfy exported type guards or compile-time fixtures. +- Edge case: terminal-state helper recognizes `DONE` and `FAILED` distinctly from in-progress states. +- Edge case: retry blocked reason values are modeled distinctly from generic error messages. +- Error path: unsupported anonymous-flow dedup/bucket combinations can be represented as validation outcomes in later units. + +**Verification:** +- Public types are explicit enough for SDK and CLI units to avoid ad hoc `any` at boundaries. +- Type docs explain behavior, constraints, and common failure cases for agent-facing surfaces. + +### U4. Implement full low-level Atlantic API client + +**Goal:** Implement the complete direct Atlantic API client surface using the shared transport and public types. + +**Requirements:** R1, R2, R3, R4, R5, R6, R7, R10, R21, R22, R23, AE1, AE2, AE3, AE4 + +**Dependencies:** U2, U3 + +**Files:** +- Create: `src/client/atlantic-client.ts` +- Modify: `src/index.ts` +- Create: `test/client.test.ts` + +**Approach:** +- Add direct methods for health check, submit query, get query, get query by dedup ID, list queries, query stats, get query jobs, retry query, list buckets, create bucket, get bucket, and close bucket. +- Keep low-level methods close to API semantics while returning typed, normalized results. +- Ensure submit returns the query ID and any payment response metadata supplied by the transport/x402 layer. +- Keep list pagination explicit. +- Do not add workflow waiting, retry budgets, or polling into the low-level client; that belongs to U5. + +**Patterns to follow:** +- Public endpoint list from `../cloud-services-docs/atlantic-api/openapi-atlantic.json`. +- Endpoint docs under `../cloud-services-docs/atlantic-api/endpoints/`. + +**Test scenarios:** +- Covers AE1. Happy path: each documented endpoint maps to the expected HTTP method and path. +- Covers AE2. Happy path: submit query with a PIE file returns an Atlantic query ID and preserves metadata needed for later lookup. +- Covers AE3. Error path: retry returns a structured wrong-state, max-retry, or not-retriable error instead of generic failure. +- Covers AE4. Happy path: bucket create/list/get/close methods call the correct API capabilities and return typed bucket data. +- Edge case: list queries and list buckets include pagination parameters only when provided. +- Edge case: get by dedup ID requires a dedup ID and keeps API key behavior explicit. +- Error path: not-found query and bucket responses preserve Atlantic's missing-resource information. + +**Verification:** +- SDK has full public API coverage from the origin requirement. +- No CLI or workflow code bypasses this client for Atlantic API calls. + +### U5. Implement agent-first query and bucket workflows + +**Goal:** Add higher-level helpers for common asynchronous Atlantic workflows while keeping IDs, statuses, and caller-controlled budgets visible. + +**Requirements:** R5, R8, R9, R10, R11, R22, R23, AE2, AE3, AE4, AE6 + +**Dependencies:** U4 + +**Files:** +- Create: `src/workflows/queries.ts` +- Create: `src/workflows/buckets.ts` +- Modify: `src/index.ts` +- Create: `test/workflows.test.ts` + +**Approach:** +- Build workflow helpers on top of the low-level client: submit-and-return-ID, submit-and-wait, wait-for-query, retry-if-retriable, get-query-with-jobs, and bucket-oriented submission. +- Make polling interval, timeout, retry budget, terminal statuses, and backoff behavior configurable per call. +- Return rich workflow results that include query IDs, status transitions observed by the helper, final query, jobs when requested, bucket IDs when relevant, and structured errors when workflows stop early. +- Keep helper descriptions explicit enough for agents to choose them correctly. + +**Patterns to follow:** +- Agent workflow guidance in `../ai-skills/plugins/herodotus-skills/skills/atlantic-api/SKILL.md`. +- Query lifecycle and status docs in `../cloud-services-docs/atlantic-api/status.mdx`. + +**Test scenarios:** +- Covers AE2. Happy path: submit-and-wait submits a query, polls until `DONE`, and returns the final query plus the original query ID. +- Covers AE3. Error path: retry-if-retriable does not retry when the query is not failed, not retriable, or retry budget is exhausted. +- Covers AE4. Happy path: bucket-oriented helper creates a bucket, submits bucket-linked jobs, and exposes the bucket ID for later close. +- Covers AE6. Happy path: helper exports include documentation comments that state when to use direct client methods versus workflows. +- Edge case: wait-for-query times out with a structured timeout error that includes the last observed query. +- Edge case: caller-supplied polling interval and terminal statuses override defaults. +- Error path: polling preserves downstream API errors with the query ID context when available. + +**Verification:** +- Workflow helpers are useful without hiding the lower-level API concepts. +- Agent-facing descriptions are concrete and guard against unsupported behavior. + +### U6. Implement x402 payment support + +**Goal:** Support Atlantic's documented x402 v2 flow for submit-query payments through challenge parsing, wallet-adapter signing, payment retry, settlement parsing, and safety guardrails. + +**Requirements:** R12, R13, R14, R15, R23, AE5, AE6 + +**Dependencies:** U2, U3, U4 + +**Files:** +- Create: `src/x402/headers.ts` +- Create: `src/x402/payments.ts` +- Modify: `src/x402/adapter.ts` +- Modify: `src/client/atlantic-client.ts` +- Create: `test/x402.test.ts` + +**Approach:** +- Parse x402 challenges from `PAYMENT-REQUIRED` header and response body where Atlantic provides both. +- Represent accepted payment requirements verbatim so caller code does not hardcode receiver, asset, network, amount, or challenge metadata. +- Define a signing adapter contract that accepts the chosen payment requirement and returns the authorization/signature payload needed for `PAYMENT-SIGNATURE`. +- Retry the original submit request with the payment signature only after a 402 challenge. +- Parse `PAYMENT-RESPONSE` on success and attach settlement data to submit results. +- Enforce or surface guardrails for anonymous flow: no dedup ID and no bucket ID. +- Treat ambiguous settlement failures as payment-specific errors that preserve enough detail for callers to inspect query state before paying again. + +**Patterns to follow:** +- Atlantic x402 docs in `../cloud-services-docs/atlantic-api/x402-payments.mdx`. +- Agent guardrails in `../ai-skills/plugins/herodotus-skills/skills/atlantic-api/SKILL.md`. +- x402 v2 header and package guidance from Coinbase/x402 docs. + +**Test scenarios:** +- Covers AE5. Happy path: a 402 challenge is parsed, signed through the adapter, retried with `PAYMENT-SIGNATURE`, and returns query ID plus settlement response. +- Covers AE5. Edge case: `alreadyProcessed` settlement response is treated as success and does not trigger another payment. +- Covers AE6. Error path: anonymous submit with dedup ID or bucket ID fails before payment retry or returns a clearly classified unsupported-flow error. +- Error path: missing `PAYMENT-REQUIRED` header with 402 response becomes a malformed-payment-challenge error. +- Error path: rejected settlement response preserves payment error class and raw challenge/response details. +- Edge case: API-key flow does not send a payment signature before a 402 challenge. +- Edge case: payment requirements are passed verbatim to the signer rather than rebuilt from hardcoded network or amount constants. + +**Verification:** +- Payment support follows Atlantic's documented x402 flow and avoids double-payment hazards. +- The SDK remains wallet-source agnostic. + +### U7. Implement JSON-first CLI + +**Goal:** Provide a Bun CLI that exposes the main SDK capabilities with file-path inputs, environment configuration, JSON output, and structured errors suitable for agent chaining. + +**Requirements:** R16, R17, R18, R19, R23, AE1, AE4, AE7 + +**Dependencies:** U4, U5, U6 + +**Files:** +- Create: `src/cli/commands.ts` +- Create: `src/cli/config.ts` +- Create: `src/cli/output.ts` +- Modify: `src/cli/index.ts` +- Modify: `package.json` +- Create: `test/cli.test.ts` + +**Approach:** +- Implement commands for health, submit query, get query, get query by dedup ID, list queries, query stats, get query jobs, retry query, list buckets, create bucket, get bucket, and close bucket. +- Convert file-path flags into SDK file inputs through shared multipart utilities. +- Use flags over environment variables over defaults for configuration precedence. +- Print JSON for successful responses and structured JSON errors for failures. +- Keep payment support available through SDK x402 configuration rather than embedding payment logic directly in command handlers. + +**Patterns to follow:** +- Capability names from the origin requirements and OpenAPI endpoint tags. +- JSON output requirements from AE7. + +**Test scenarios:** +- Covers AE1. Happy path: list/get/stats/job CLI commands call the corresponding SDK capability and print JSON. +- Covers AE4. Happy path: bucket CLI commands support create/list/get/close and preserve bucket IDs in output. +- Covers AE7. Happy path: successful commands print stable JSON objects with IDs, statuses, and result payloads. +- Covers AE7. Error path: failed commands print structured JSON errors and exit non-zero. +- Edge case: explicit CLI flags override environment configuration. +- Edge case: file-path submit input is converted into an SDK file input without reading unrelated files. +- Error path: missing required file or required ID produces a command validation error before calling the API. + +**Verification:** +- CLI behavior is a thin orchestration layer over SDK methods and workflows. +- Commands are chainable by agents through stable JSON output. + +### U8. Add public docs, examples, and quality gates + +**Goal:** Document the SDK and CLI surfaces, add examples for core workflows, and ensure tests/typechecks cover the public contract. + +**Requirements:** R11, R20, R21, R24, AE6 + +**Dependencies:** U1, U4, U5, U6, U7 + +**Files:** +- Modify: `README.md` +- Create: `examples/submit-query.ts` +- Create: `examples/submit-and-wait.ts` +- Create: `examples/x402-submit.ts` +- Create: `examples/buckets.ts` +- Create: `docs/sdk-function-reference.md` +- Create: `test/docs.test.ts` + +**Approach:** +- Add README quickstarts for SDK configuration, submit query, wait workflow, retry, buckets, CLI usage, and x402. +- Add examples that compile against public exports and avoid private modules. +- Add a concise SDK function reference focused on purpose, inputs, constraints, results, and common failures. +- Add quality checks that examples import public APIs and documentation references exported capabilities. + +**Patterns to follow:** +- Usage examples in `../cloud-services-docs/atlantic-api/sending-query.mdx`. +- x402 recipe in `../cloud-services-docs/atlantic-api/x402-payments.mdx`. +- Agent-facing descriptions from the origin requirements. + +**Test scenarios:** +- Covers AE6. Happy path: each public SDK method or helper has a documentation entry or documentation comment with purpose and constraints. +- Happy path: examples compile or typecheck against public SDK exports. +- Edge case: x402 docs mention anonymous-flow dedup/bucket restrictions and no preemptive signatures. +- Error path: docs test fails when a public CLI command lacks a corresponding documentation entry. + +**Verification:** +- A developer or agent can discover how to use SDK, CLI, workflow helpers, and x402 without reading raw OpenAPI first. +- Public examples stay synchronized with exported APIs. + +--- + +## System-Wide Impact + +- **Interaction graph:** SDK configuration feeds the shared transport; low-level client and workflow helpers share transport/errors; CLI delegates to SDK/workflows; x402 hooks into submit-query only. +- **Error propagation:** Transport and payment errors should normalize into SDK errors first, then CLI output renders those errors as stable JSON. +- **State lifecycle risks:** Query IDs, dedup IDs, bucket IDs, and payment challenge IDs must not be dropped during submit, retry, wait, or payment flows. +- **API surface parity:** Every low-level SDK capability should have an intentional CLI decision: exposed directly, exposed through workflow command, or explicitly deferred. +- **Integration coverage:** End-to-end mocked tests should prove CLI-to-SDK delegation, multipart submit construction, x402 retry flow, and polling behavior. +- **Unchanged invariants:** The SDK does not change Atlantic semantics; it preserves public API behavior while making it typed, documented, and easier to orchestrate. + +--- + +## Risks & Dependencies + +| Risk | Mitigation | +| --- | --- | +| OpenAPI and backend route schemas drift | Keep route/docs references in tests and isolate public types so updates are localized. | +| x402 package compatibility with Bun or Atlantic-specific headers is imperfect | Keep x402 handling behind local adapter/header modules and allow manual protocol handling when package fit is poor. | +| CLI and SDK behavior diverge | Require CLI to call SDK/workflow functions and test command-to-SDK delegation. | +| Multipart file handling differs across Bun and other TypeScript runtimes | Treat Bun as primary, document supported input forms, and test Bun file-path use explicitly. | +| Payment retry can create double-payment risk if ambiguous states are hidden | Preserve payment challenge/settlement details and classify ambiguous failures so callers inspect query state before retrying payment. | +| Full API coverage expands initial implementation size | Sequence low-level client before workflows and CLI so each layer can be tested independently. | + +--- + +## Documentation / Operational Notes + +- `README.md` should remain the primary quickstart for installation, configuration, SDK usage, CLI usage, and x402 warnings. +- `docs/sdk-function-reference.md` should document every public client method, workflow helper, and CLI command at a practical level for developers and AI agents. +- Examples should avoid real credentials and should use environment variable placeholders for API key and wallet-related configuration. +- Release notes are not required in this plan, but the package should be structured so they can be added before publishing. + +--- + +## Sources & References + +- **Origin document:** [docs/brainstorms/atlantic-bun-sdk-requirements.md](../brainstorms/atlantic-bun-sdk-requirements.md) +- Atlantic API OpenAPI: `../cloud-services-docs/atlantic-api/openapi-atlantic.json` +- Atlantic x402 docs: `../cloud-services-docs/atlantic-api/x402-payments.mdx` +- Atlantic sending query docs: `../cloud-services-docs/atlantic-api/sending-query.mdx` +- Atlantic API skill: `../ai-skills/plugins/herodotus-skills/skills/atlantic-api/SKILL.md` +- Atlantic route schemas: `../atlantic/src/routes/atlantic/` +- Coinbase x402 migration guide: https://docs.cdp.coinbase.com/x402/migration-guide +- Coinbase x402 flow docs: https://docs.cdp.coinbase.com/x402/core-concepts/how-it-works +- x402 docs: https://docs.x402.org/introduction diff --git a/test/multi-service.test.ts b/test/multi-service.test.ts new file mode 100644 index 0000000..4a910e2 --- /dev/null +++ b/test/multi-service.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, test } from 'bun:test'; +import { HttpClient, StaticAuthHeaderProvider } from '../src/core/http'; +import { resolveCoreConfig } from '../src/core/config'; +import type { HcloudSurface } from '../src/core/config'; + +/** + * Multi-service contract test. + * + * Proves that a brand-new service can be implemented using only public + * primitives from `core/` — without touching `auth/`, `services/atlantic/`, + * or each other. The full registration walkthrough lives in + * `docs/guides/adding-a-service.md`. + * + * For the purposes of this test we only build the *implementation* half of a + * new service (the class that consumes `HttpClient`); the registration + * sites (HcloudClient namespace, CLI dispatcher entry, HcloudSurface enum) + * are exercised by the live Atlantic and auth services. + */ + +interface EchoMessage { + message: string; +} + +class EchoService { + constructor(private readonly http: HttpClient) {} + + async send(message: string): Promise { + const response = await this.http.request({ + method: 'POST', + // Cast simulates extending HcloudSurface in core/config.ts. This is the + // exact one-line edit a real new service would make. + surface: 'echo' as unknown as HcloudSurface, + path: '/echo', + body: JSON.stringify({ message }), + headers: { 'content-type': 'application/json' }, + authMode: 'api-key', + }); + return response.data; + } +} + +describe('multi-service contract', () => { + test('a new service can be built from public core primitives only', async () => { + const calls: Request[] = []; + const config = resolveCoreConfig({ + // Likewise, a new surface adds a base URL entry. We pass it inline here. + baseUrls: { atlantic: 'https://atl.example.com', 'auth-billing': 'https://auth.example.com' }, + fetch: (input, init) => { + const r = new Request(input, init); + calls.push(r); + return Promise.resolve(Response.json({ message: 'pong' })); + }, + }); + // Override the resolved config to include the test surface. + (config.baseUrls as Record)['echo'] = 'https://echo.example.com'; + + const auth = new StaticAuthHeaderProvider('test-api-key'); + const http = new HttpClient({ config, auth }); + + const echo = new EchoService(http); + const result = await echo.send('ping'); + + expect(result.message).toBe('pong'); + expect(calls[0]?.headers.get('api-key')).toBe('test-api-key'); + expect(new URL(calls[0]!.url).hostname).toBe('echo.example.com'); + }); + + test('different services can share a method name without ambiguity', async () => { + // Build two minimal services sharing the same method name. Callers reach each + // through its own instance — there is no top-level overloaded function. + class StorageProofService { + constructor(private readonly http: HttpClient) {} + async submitQuery() { + const response = await this.http.request<{ from: string }>({ + method: 'POST', + surface: 'atlantic' as HcloudSurface, + path: '/storage-proof-stand-in', + authMode: 'api-key', + }); + return response.data; + } + } + class AtlanticAlikeService { + constructor(private readonly http: HttpClient) {} + async submitQuery() { + const response = await this.http.request<{ from: string }>({ + method: 'POST', + surface: 'atlantic' as HcloudSurface, + path: '/atlantic-stand-in', + authMode: 'api-key', + }); + return response.data; + } + } + + const config = resolveCoreConfig({ + baseUrls: { atlantic: 'https://example.com', 'auth-billing': 'https://auth.example.com' }, + fetch: (input) => { + const path = new URL(typeof input === 'string' ? input : input instanceof URL ? input.href : input.url).pathname; + return Promise.resolve(Response.json({ from: path })); + }, + }); + const http = new HttpClient({ config, auth: new StaticAuthHeaderProvider('k') }); + + const storageProof = new StorageProofService(http); + const atlantic = new AtlanticAlikeService(http); + + expect((await storageProof.submitQuery()).from).toBe('/storage-proof-stand-in'); + expect((await atlantic.submitQuery()).from).toBe('/atlantic-stand-in'); + }); +}); From 9321c55d81b1709b27223398bea0935a17dd7736 Mon Sep 17 00:00:00 2001 From: skusnierz Date: Thu, 7 May 2026 09:33:35 +0200 Subject: [PATCH 7/9] style: apply prettier across new files --- ...7-hcloud-multi-service-sdk-requirements.md | 4 +- .../atlantic-bun-sdk-requirements.md | 5 ++ docs/guides/credentials.md | 28 +++++-- ...26-05-06-001-feat-atlantic-bun-sdk-plan.md | 74 ++++++++++++++----- ...rename-to-hcloud-multi-service-sdk-plan.md | 49 ++++++++---- src/auth/cli.ts | 8 +- src/auth/index.ts | 7 +- src/cli/config.ts | 3 +- src/services/atlantic/client.ts | 5 +- src/services/atlantic/index.ts | 6 +- test/auth-cli.test.ts | 9 ++- test/auth-service.test.ts | 5 +- test/client.test.ts | 4 +- test/multi-service.test.ts | 3 +- 14 files changed, 139 insertions(+), 71 deletions(-) diff --git a/docs/brainstorms/2026-05-07-hcloud-multi-service-sdk-requirements.md b/docs/brainstorms/2026-05-07-hcloud-multi-service-sdk-requirements.md index cecb4c2..8376e93 100644 --- a/docs/brainstorms/2026-05-07-hcloud-multi-service-sdk-requirements.md +++ b/docs/brainstorms/2026-05-07-hcloud-multi-service-sdk-requirements.md @@ -58,7 +58,7 @@ broader scope. When `projectId` is omitted, the SDK uses `selectedProject` from the active session. - **R7. Service calls authenticate transparently with the persisted API key.** Once `auth.login` has produced an API key, the SDK persists it and all - `client.atlantic.*` and future-service calls use it automatically — *forever*, + `client.atlantic.*` and future-service calls use it automatically — _forever_, no re-login required. Bearer access/refresh tokens are only consulted when the user invokes auth/api-key operations (refreshing the session, listing/creating keys). A user who only consumes Atlantic never needs to refresh the session @@ -98,7 +98,7 @@ broader scope. file under `~/.hcloud/` with restrictive permissions; KMS/hardware support is enabled via the signer interface but the SDK does not ship integrations. - **Out of scope: implementing storage-proof / data-processor / DSI / satellite - surfaces.** R1–R3 only require the *shape* that lets them be added later; this + surfaces.** R1–R3 only require the _shape_ that lets them be added later; this brainstorm does not define their operations. - **Out of scope: billing UI, invoices, payments, project management beyond reading `selectedProject` and minting/listing API keys.** Anything else under diff --git a/docs/brainstorms/atlantic-bun-sdk-requirements.md b/docs/brainstorms/atlantic-bun-sdk-requirements.md index 6c27aea..8da65f7 100644 --- a/docs/brainstorms/atlantic-bun-sdk-requirements.md +++ b/docs/brainstorms/atlantic-bun-sdk-requirements.md @@ -63,6 +63,7 @@ The SDK should make Atlantic easier to use from Bun and TypeScript projects with ## Requirements **API coverage** + - R1. The SDK must expose typed methods for every public Atlantic API capability documented in the current Atlantic OpenAPI contract: health check, submit query, get query by ID, get query by dedup ID, list queries, query stats, query jobs, retry query, list buckets, create bucket, get bucket details, and close bucket. - R2. Query submission must support the documented Atlantic inputs and options, including program, input, PIE, and proof file inputs; Cairo version and VM options; result selection; layouts; prover selection; declared job size; network selection; deduplication identifiers; external identifiers; and bucket-related fields. - R3. Query lookup must support direct query ID lookup and deduplication-key lookup as separate explicit capabilities. @@ -72,24 +73,28 @@ The SDK should make Atlantic easier to use from Bun and TypeScript projects with - R7. Bucket support must cover listing buckets, creating buckets, fetching bucket details with associated queries, closing buckets, and submitting bucket-linked queries where the API permits them. **Agent-first workflow helpers** + - R8. The SDK must include higher-level helpers for common workflows: submit-and-return-ID, submit-and-wait, wait-for-query, retry-if-retriable, get-query-with-jobs, and bucket-oriented submission. - R9. Workflow helpers must preserve caller control over polling intervals, timeout budgets, retry budgets, and terminal-state behavior. - R10. The SDK must keep Atlantic query IDs, dedup IDs, and bucket IDs visible as first-class outputs rather than hiding them inside opaque workflow results. - R11. Agent-facing helper descriptions must include what the helper does, when to use it, required inputs, important constraints, expected result, and common failure modes. **x402 payments** + - R12. The SDK must support Atlantic's documented x402 v2 flow for `submit query`, including challenge parsing, payment-signature retry, and settlement-response parsing. - R13. The SDK must support both API-key fallback payments and anonymous wallet payments, while making their behavioral differences explicit to the caller. - R14. The SDK must prevent or clearly warn against unsafe x402 usage: preemptive payment signatures, reused successful payment signatures, hardcoded payment requirements, anonymous `dedupId` usage, anonymous bucket usage, and double-payment after ambiguous settlement. - R15. x402 signing must be wallet-adapter friendly so applications can provide their own signing implementation rather than coupling the SDK to a single wallet source. **CLI** + - R16. The CLI must expose commands for the same main capabilities as the SDK: submit query, retry query, get query details, get query by dedup ID, list queries, get query jobs, query stats, list buckets, create bucket, get bucket, close bucket, and health check. - R17. CLI commands must support file-path inputs for query submission and must print machine-readable JSON by default or through an explicit mode. - R18. CLI commands must support environment-based configuration for the Atlantic base URL, API key, and payment-related wallet configuration. - R19. CLI output must preserve important identifiers, status, payment receipt data, and error details so agents can chain commands reliably. **Developer experience and quality** + - R20. The package must be Bun-first while remaining idiomatic TypeScript for library consumers. - R21. Public SDK methods must have clear names, strong TypeScript types, and documentation comments that explain behavior rather than merely restating parameter names. - R22. The SDK must separate low-level API methods from higher-level workflow helpers so advanced users can keep exact control while agents and common integrations get safer defaults. diff --git a/docs/guides/credentials.md b/docs/guides/credentials.md index cb0fea7..8c19fd9 100644 --- a/docs/guides/credentials.md +++ b/docs/guides/credentials.md @@ -88,13 +88,27 @@ bookkeeping; your store only needs CRUD on `WalletEntry`s. import type { CredentialStore } from '@herodotus_dev/hcloud'; export class MyKeychainStore implements CredentialStore { - load() { /* ... */ } - save(file) { /* ... */ } - activeWallet() { /* ... */ } - setActive(wallet) { /* ... */ } - upsertWallet(entry) { /* ... */ } - removeWallet(wallet) { /* ... */ } - clear() { /* ... */ } + load() { + /* ... */ + } + save(file) { + /* ... */ + } + activeWallet() { + /* ... */ + } + setActive(wallet) { + /* ... */ + } + upsertWallet(entry) { + /* ... */ + } + removeWallet(wallet) { + /* ... */ + } + clear() { + /* ... */ + } } ``` diff --git a/docs/plans/2026-05-06-001-feat-atlantic-bun-sdk-plan.md b/docs/plans/2026-05-06-001-feat-atlantic-bun-sdk-plan.md index 1cf5e57..465afa9 100644 --- a/docs/plans/2026-05-06-001-feat-atlantic-bun-sdk-plan.md +++ b/docs/plans/2026-05-06-001-feat-atlantic-bun-sdk-plan.md @@ -1,5 +1,5 @@ --- -title: "feat: Build Atlantic Bun SDK and CLI" +title: 'feat: Build Atlantic Bun SDK and CLI' type: feat status: active date: 2026-05-06 @@ -97,14 +97,14 @@ The origin requirements define a greenfield SDK/CLI because direct Atlantic API ## Key Technical Decisions -| Decision | Rationale | -| --- | --- | -| SDK-first architecture with CLI delegation | Keeps request construction, errors, workflow helpers, and x402 behavior consistent across library and terminal usage. | +| Decision | Rationale | +| ----------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| SDK-first architecture with CLI delegation | Keeps request construction, errors, workflow helpers, and x402 behavior consistent across library and terminal usage. | | Curated public types backed by source contracts | Pure generated types would cover breadth but can produce poor DX; hand-authored-only types risk drift. Use the OpenAPI and route schemas as sources while designing clean exported SDK types. | -| Two SDK layers: API client and workflows | Low-level methods preserve full control; workflow helpers satisfy agent and common integration needs without hiding identifiers. | -| Adapter-based x402 signing | Supports Bun CLI, server apps, agent wallets, and external wallet clients without coupling the SDK to one private-key or wallet implementation. | -| JSON-first CLI output | Makes terminal workflows chainable and agent-readable, while still allowing later human-friendly formatting if needed. | -| Structured error model | Callers need to branch on Atlantic/API/payment outcomes without parsing prose or raw response bodies. | +| Two SDK layers: API client and workflows | Low-level methods preserve full control; workflow helpers satisfy agent and common integration needs without hiding identifiers. | +| Adapter-based x402 signing | Supports Bun CLI, server apps, agent wallets, and external wallet clients without coupling the SDK to one private-key or wallet implementation. | +| JSON-first CLI output | Makes terminal workflows chainable and agent-readable, while still allowing later human-friendly formatting if needed. | +| Structured error model | Callers need to branch on Atlantic/API/payment outcomes without parsing prose or raw response bodies. | --- @@ -169,7 +169,7 @@ This tree shows the expected package shape. It is a scope declaration, not a rig ## High-Level Technical Design -> *This illustrates the intended approach and is directional guidance for review, not implementation specification. The implementing agent should treat it as context, not code to reproduce.* +> _This illustrates the intended approach and is directional guidance for review, not implementation specification. The implementing agent should treat it as context, not code to reproduce._ ```mermaid flowchart TB @@ -225,6 +225,7 @@ flowchart TB **Dependencies:** None **Files:** + - Create: `package.json` - Create: `bun.lock` - Create: `tsconfig.json` @@ -234,6 +235,7 @@ flowchart TB - Modify: `README.md` **Approach:** + - Define the package as a TypeScript library with Bun as the primary runtime and test runner. - Configure exports for SDK use and a CLI binary entrypoint. - Keep initial runtime dependencies minimal; add domain dependencies only when required by later units. @@ -242,15 +244,18 @@ flowchart TB **Execution note:** Start test-first with the entrypoint smoke test so package wiring is validated before feature units build on it. **Patterns to follow:** + - Current repo root conventions in `README.md`. - Bun package conventions for scripts, tests, and bin entrypoints. **Test scenarios:** + - Happy path: importing the SDK entrypoint exposes the planned public namespace without throwing. - Happy path: importing the CLI entrypoint does not execute a command as a side effect. - Error path: package test setup fails clearly if TypeScript compilation cannot resolve source paths. **Verification:** + - The package has working Bun scripts for test and typecheck. - SDK and CLI entrypoints exist but contain only safe initialization scaffolding. @@ -263,6 +268,7 @@ flowchart TB **Dependencies:** U1 **Files:** + - Create: `src/client/http.ts` - Create: `src/client/multipart.ts` - Create: `src/client/config.ts` @@ -272,16 +278,19 @@ flowchart TB - Create: `test/multipart.test.ts` **Approach:** + - Centralize base URL, API key, headers, query parameters, and fetch injection. - Support Bun-native file inputs and browser/server-compatible binary inputs where practical without compromising Bun-first behavior. - Normalize Atlantic error responses into structured SDK errors that preserve HTTP status, raw response data, Atlantic message/error fields, and payment-specific classification hooks. - Keep multipart construction reusable for SDK calls and CLI file-path conversion. **Patterns to follow:** + - Atlantic route schemas in `../atlantic/src/routes/atlantic/submit-query/schemas.ts` for multipart field expectations. - OpenAPI request body shape in `../cloud-services-docs/atlantic-api/openapi-atlantic.json`. **Test scenarios:** + - Happy path: configuration merges explicit options with environment defaults without mutating caller input. - Happy path: JSON requests include base URL, path, query parameters, and API key headers when configured. - Happy path: multipart query submission serializes files and scalar fields in the expected form-data categories. @@ -292,6 +301,7 @@ flowchart TB - Error path: invalid JSON error bodies still produce a structured transport error with raw text preserved. **Verification:** + - All later units can call one transport layer rather than constructing fetch requests directly. - Errors are branchable without parsing prose. @@ -304,29 +314,34 @@ flowchart TB **Dependencies:** U1, U2 **Files:** + - Create: `src/types.ts` - Create: `src/x402/adapter.ts` - Create: `test/types.test.ts` **Approach:** + - Model Atlantic statuses, retry blocked reasons, Cairo options, result options, job sizes, networks, query responses, bucket responses, list responses, and metadata URL responses. - Model submit query input as a developer-friendly type that can still represent every documented field. - Model x402 challenge, payment requirement, payment payload, settlement response, and signing adapter contracts. - Use documentation comments on exported types and methods as a design requirement, especially where agent misuse is likely. **Patterns to follow:** + - Response schemas in `../atlantic/src/routes/atlantic/get-atlantic-query/schemas.ts`. - Retry schema in `../atlantic/src/routes/atlantic/retry-atlantic-query/schemas.ts`. - Bucket schemas in `../atlantic/src/routes/atlantic/create-bucket/schemas.ts`, `../atlantic/src/routes/atlantic/get-buckets/schemas.ts`, and `../atlantic/src/routes/atlantic/get-bucket-details/schemas.ts`. - x402 data shape from `../cloud-services-docs/atlantic-api/x402-payments.mdx`. **Test scenarios:** + - Happy path: representative query, job, bucket, list, and x402 objects satisfy exported type guards or compile-time fixtures. - Edge case: terminal-state helper recognizes `DONE` and `FAILED` distinctly from in-progress states. - Edge case: retry blocked reason values are modeled distinctly from generic error messages. - Error path: unsupported anonymous-flow dedup/bucket combinations can be represented as validation outcomes in later units. **Verification:** + - Public types are explicit enough for SDK and CLI units to avoid ad hoc `any` at boundaries. - Type docs explain behavior, constraints, and common failure cases for agent-facing surfaces. @@ -339,11 +354,13 @@ flowchart TB **Dependencies:** U2, U3 **Files:** + - Create: `src/client/atlantic-client.ts` - Modify: `src/index.ts` - Create: `test/client.test.ts` **Approach:** + - Add direct methods for health check, submit query, get query, get query by dedup ID, list queries, query stats, get query jobs, retry query, list buckets, create bucket, get bucket, and close bucket. - Keep low-level methods close to API semantics while returning typed, normalized results. - Ensure submit returns the query ID and any payment response metadata supplied by the transport/x402 layer. @@ -351,10 +368,12 @@ flowchart TB - Do not add workflow waiting, retry budgets, or polling into the low-level client; that belongs to U5. **Patterns to follow:** + - Public endpoint list from `../cloud-services-docs/atlantic-api/openapi-atlantic.json`. - Endpoint docs under `../cloud-services-docs/atlantic-api/endpoints/`. **Test scenarios:** + - Covers AE1. Happy path: each documented endpoint maps to the expected HTTP method and path. - Covers AE2. Happy path: submit query with a PIE file returns an Atlantic query ID and preserves metadata needed for later lookup. - Covers AE3. Error path: retry returns a structured wrong-state, max-retry, or not-retriable error instead of generic failure. @@ -364,6 +383,7 @@ flowchart TB - Error path: not-found query and bucket responses preserve Atlantic's missing-resource information. **Verification:** + - SDK has full public API coverage from the origin requirement. - No CLI or workflow code bypasses this client for Atlantic API calls. @@ -376,22 +396,26 @@ flowchart TB **Dependencies:** U4 **Files:** + - Create: `src/workflows/queries.ts` - Create: `src/workflows/buckets.ts` - Modify: `src/index.ts` - Create: `test/workflows.test.ts` **Approach:** + - Build workflow helpers on top of the low-level client: submit-and-return-ID, submit-and-wait, wait-for-query, retry-if-retriable, get-query-with-jobs, and bucket-oriented submission. - Make polling interval, timeout, retry budget, terminal statuses, and backoff behavior configurable per call. - Return rich workflow results that include query IDs, status transitions observed by the helper, final query, jobs when requested, bucket IDs when relevant, and structured errors when workflows stop early. - Keep helper descriptions explicit enough for agents to choose them correctly. **Patterns to follow:** + - Agent workflow guidance in `../ai-skills/plugins/herodotus-skills/skills/atlantic-api/SKILL.md`. - Query lifecycle and status docs in `../cloud-services-docs/atlantic-api/status.mdx`. **Test scenarios:** + - Covers AE2. Happy path: submit-and-wait submits a query, polls until `DONE`, and returns the final query plus the original query ID. - Covers AE3. Error path: retry-if-retriable does not retry when the query is not failed, not retriable, or retry budget is exhausted. - Covers AE4. Happy path: bucket-oriented helper creates a bucket, submits bucket-linked jobs, and exposes the bucket ID for later close. @@ -401,6 +425,7 @@ flowchart TB - Error path: polling preserves downstream API errors with the query ID context when available. **Verification:** + - Workflow helpers are useful without hiding the lower-level API concepts. - Agent-facing descriptions are concrete and guard against unsupported behavior. @@ -413,6 +438,7 @@ flowchart TB **Dependencies:** U2, U3, U4 **Files:** + - Create: `src/x402/headers.ts` - Create: `src/x402/payments.ts` - Modify: `src/x402/adapter.ts` @@ -420,6 +446,7 @@ flowchart TB - Create: `test/x402.test.ts` **Approach:** + - Parse x402 challenges from `PAYMENT-REQUIRED` header and response body where Atlantic provides both. - Represent accepted payment requirements verbatim so caller code does not hardcode receiver, asset, network, amount, or challenge metadata. - Define a signing adapter contract that accepts the chosen payment requirement and returns the authorization/signature payload needed for `PAYMENT-SIGNATURE`. @@ -429,11 +456,13 @@ flowchart TB - Treat ambiguous settlement failures as payment-specific errors that preserve enough detail for callers to inspect query state before paying again. **Patterns to follow:** + - Atlantic x402 docs in `../cloud-services-docs/atlantic-api/x402-payments.mdx`. - Agent guardrails in `../ai-skills/plugins/herodotus-skills/skills/atlantic-api/SKILL.md`. - x402 v2 header and package guidance from Coinbase/x402 docs. **Test scenarios:** + - Covers AE5. Happy path: a 402 challenge is parsed, signed through the adapter, retried with `PAYMENT-SIGNATURE`, and returns query ID plus settlement response. - Covers AE5. Edge case: `alreadyProcessed` settlement response is treated as success and does not trigger another payment. - Covers AE6. Error path: anonymous submit with dedup ID or bucket ID fails before payment retry or returns a clearly classified unsupported-flow error. @@ -443,6 +472,7 @@ flowchart TB - Edge case: payment requirements are passed verbatim to the signer rather than rebuilt from hardcoded network or amount constants. **Verification:** + - Payment support follows Atlantic's documented x402 flow and avoids double-payment hazards. - The SDK remains wallet-source agnostic. @@ -455,6 +485,7 @@ flowchart TB **Dependencies:** U4, U5, U6 **Files:** + - Create: `src/cli/commands.ts` - Create: `src/cli/config.ts` - Create: `src/cli/output.ts` @@ -463,6 +494,7 @@ flowchart TB - Create: `test/cli.test.ts` **Approach:** + - Implement commands for health, submit query, get query, get query by dedup ID, list queries, query stats, get query jobs, retry query, list buckets, create bucket, get bucket, and close bucket. - Convert file-path flags into SDK file inputs through shared multipart utilities. - Use flags over environment variables over defaults for configuration precedence. @@ -470,10 +502,12 @@ flowchart TB - Keep payment support available through SDK x402 configuration rather than embedding payment logic directly in command handlers. **Patterns to follow:** + - Capability names from the origin requirements and OpenAPI endpoint tags. - JSON output requirements from AE7. **Test scenarios:** + - Covers AE1. Happy path: list/get/stats/job CLI commands call the corresponding SDK capability and print JSON. - Covers AE4. Happy path: bucket CLI commands support create/list/get/close and preserve bucket IDs in output. - Covers AE7. Happy path: successful commands print stable JSON objects with IDs, statuses, and result payloads. @@ -483,6 +517,7 @@ flowchart TB - Error path: missing required file or required ID produces a command validation error before calling the API. **Verification:** + - CLI behavior is a thin orchestration layer over SDK methods and workflows. - Commands are chainable by agents through stable JSON output. @@ -495,6 +530,7 @@ flowchart TB **Dependencies:** U1, U4, U5, U6, U7 **Files:** + - Modify: `README.md` - Create: `examples/submit-query.ts` - Create: `examples/submit-and-wait.ts` @@ -504,23 +540,27 @@ flowchart TB - Create: `test/docs.test.ts` **Approach:** + - Add README quickstarts for SDK configuration, submit query, wait workflow, retry, buckets, CLI usage, and x402. - Add examples that compile against public exports and avoid private modules. - Add a concise SDK function reference focused on purpose, inputs, constraints, results, and common failures. - Add quality checks that examples import public APIs and documentation references exported capabilities. **Patterns to follow:** + - Usage examples in `../cloud-services-docs/atlantic-api/sending-query.mdx`. - x402 recipe in `../cloud-services-docs/atlantic-api/x402-payments.mdx`. - Agent-facing descriptions from the origin requirements. **Test scenarios:** + - Covers AE6. Happy path: each public SDK method or helper has a documentation entry or documentation comment with purpose and constraints. - Happy path: examples compile or typecheck against public SDK exports. - Edge case: x402 docs mention anonymous-flow dedup/bucket restrictions and no preemptive signatures. - Error path: docs test fails when a public CLI command lacks a corresponding documentation entry. **Verification:** + - A developer or agent can discover how to use SDK, CLI, workflow helpers, and x402 without reading raw OpenAPI first. - Public examples stay synchronized with exported APIs. @@ -539,14 +579,14 @@ flowchart TB ## Risks & Dependencies -| Risk | Mitigation | -| --- | --- | -| OpenAPI and backend route schemas drift | Keep route/docs references in tests and isolate public types so updates are localized. | -| x402 package compatibility with Bun or Atlantic-specific headers is imperfect | Keep x402 handling behind local adapter/header modules and allow manual protocol handling when package fit is poor. | -| CLI and SDK behavior diverge | Require CLI to call SDK/workflow functions and test command-to-SDK delegation. | -| Multipart file handling differs across Bun and other TypeScript runtimes | Treat Bun as primary, document supported input forms, and test Bun file-path use explicitly. | -| Payment retry can create double-payment risk if ambiguous states are hidden | Preserve payment challenge/settlement details and classify ambiguous failures so callers inspect query state before retrying payment. | -| Full API coverage expands initial implementation size | Sequence low-level client before workflows and CLI so each layer can be tested independently. | +| Risk | Mitigation | +| ----------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | +| OpenAPI and backend route schemas drift | Keep route/docs references in tests and isolate public types so updates are localized. | +| x402 package compatibility with Bun or Atlantic-specific headers is imperfect | Keep x402 handling behind local adapter/header modules and allow manual protocol handling when package fit is poor. | +| CLI and SDK behavior diverge | Require CLI to call SDK/workflow functions and test command-to-SDK delegation. | +| Multipart file handling differs across Bun and other TypeScript runtimes | Treat Bun as primary, document supported input forms, and test Bun file-path use explicitly. | +| Payment retry can create double-payment risk if ambiguous states are hidden | Preserve payment challenge/settlement details and classify ambiguous failures so callers inspect query state before retrying payment. | +| Full API coverage expands initial implementation size | Sequence low-level client before workflows and CLI so each layer can be tested independently. | --- diff --git a/docs/plans/2026-05-07-001-feat-rename-to-hcloud-multi-service-sdk-plan.md b/docs/plans/2026-05-07-001-feat-rename-to-hcloud-multi-service-sdk-plan.md index 1a2bf2c..461e738 100644 --- a/docs/plans/2026-05-07-001-feat-rename-to-hcloud-multi-service-sdk-plan.md +++ b/docs/plans/2026-05-07-001-feat-rename-to-hcloud-multi-service-sdk-plan.md @@ -1,5 +1,5 @@ --- -title: "feat: rename to hcloud, add wallet auth and multi-service SDK shape" +title: 'feat: rename to hcloud, add wallet auth and multi-service SDK shape' type: feat status: active date: 2026-05-07 @@ -45,7 +45,7 @@ under `src/`. Three structural problems collide with where Herodotus Cloud is go can self-provision. 3. **Name collision risk in the future SDK.** Multiple services will have operations called `submitQuery` (or analogues). They must be unambiguously - reached and disambiguated by *namespace*, not by overloaded top-level functions. + reached and disambiguated by _namespace_, not by overloaded top-level functions. The brainstorm settled the WHAT (see origin: `docs/brainstorms/2026-05-07-hcloud-multi-service-sdk-requirements.md`). This plan defines the HOW. @@ -192,9 +192,17 @@ Rename `AtlanticSdkError` → `HcloudError`. Extend `AtlanticErrorKind` with aut ```ts type HcloudErrorKind = - | 'transport' | 'api' | 'validation' | 'timeout' - | 'payment' | 'payment_challenge' | 'payment_settlement' // x402 - | 'not_authenticated' | 'session_expired' | 'signing_failed' | 'channel_binding'; // auth + | 'transport' + | 'api' + | 'validation' + | 'timeout' + | 'payment' + | 'payment_challenge' + | 'payment_settlement' // x402 + | 'not_authenticated' + | 'session_expired' + | 'signing_failed' + | 'channel_binding'; // auth ``` Re-export `AtlanticSdkError` as a deprecated alias of `HcloudError` from the @@ -204,6 +212,7 @@ then remove the alias before publish (we're hard-deprecating, no public alias). #### Auth resolution order (resolves brainstorm deferred Q) For service requests (`atlantic`, future services): + 1. `HcloudClient({ apiKey })` explicit → use it, never refresh, ignore store. 2. `HCLOUD_API_KEY` env → use it, ignore store. 3. Active wallet in `CredentialStore` → use its persisted `apiKey`. @@ -211,6 +220,7 @@ For service requests (`atlantic`, future services): `"No API key. Run \`hcloud auth login\` or set HCLOUD_API_KEY."`. For auth-billing requests (`client.auth.apiKeys.*`, `client.auth.refresh`): + 1. Active wallet's bearer `accessToken`. Refresh proactively if `expiresAt - 60s < now` AND a `refreshToken` is present. 2. On 401, try one refresh + retry. @@ -223,6 +233,7 @@ calls never silently sign anything. #### Channel binding (resolves brainstorm deferred Q) `HttpClient` enforces: + - `authMode: 'bearer'` requests **never** carry a `Cookie` header. - `authMode: 'api-key'` requests carry the `api-key` header and **never** carry `Authorization`. @@ -233,6 +244,7 @@ calls never silently sign anything. #### Refresh policy (resolves brainstorm deferred Q) Both proactive and reactive: + - Proactive: when `AuthService` is asked for a bearer header and `expiresAt - 60s < now`, refresh first. - Reactive: 401 from auth-billing → single refresh + replay. Two consecutive 401s → @@ -323,6 +335,7 @@ Goal: rename package + binary + repo, introduce `core/` and `services/atlantic/` preserve all Atlantic behavior 1:1. No new functionality. Tasks: + - `package.json`: name → `@herodotus_dev/hcloud`, bin → `{ "hcloud": "./dist/cli/index.js" }`, description, keywords. - Move files: @@ -350,6 +363,7 @@ Tasks: with new import paths. Success criteria: + - `bun test` green. - `bun run check` green. - Manual smoke: `hcloud atlantic health`, `hcloud atlantic submit-query …` @@ -365,6 +379,7 @@ Goal: implement `src/auth/` — port the herodotus-auth scripts into proper modules backed by `core/http.ts` and `CredentialStore`. Tasks: + - `src/auth/web3.ts`: `fetchChallenge(wallet)`, `submitSession({ wallet, challengeToken, signature })`. - `src/auth/refresh.ts`: `refreshSession(refreshToken)` (atomic; throws `session_expired`). - `src/auth/api-keys.ts`: `ApiKeysClient` with `list({ projectId? })`, `create({ projectId, type })`, @@ -399,6 +414,7 @@ Tasks: - `HcloudClient` resolves API key via the order documented above. Success criteria: + - Unit tests against a mocked `auth-billing` for: challenge fetch, signature shape, session exchange, channel-binding rejection, list/create/activate/deactivate api-keys, refresh happy path, refresh expired path. @@ -414,6 +430,7 @@ Estimated effort: 2–3 days. Goal: `hcloud auth …` mirrors `client.auth.*`. Commands: + - `hcloud auth login [--private-key | (env WALLET_PRIVATE_KEY/HCLOUD_WALLET_PRIVATE_KEY)]` - `hcloud auth refresh` - `hcloud auth whoami` — JSON: `{ wallet, projectId, apiKey: "***" + last4, sessionExpiresAt }`. Add `--show-secrets` to print the key in full. @@ -429,6 +446,7 @@ All commands follow the existing `output.ts` JSON-first pattern. Errors serialize via `HcloudError.toJSON()`. Success criteria: + - CLI test: `auth login` against mocked auth-billing stores creds and reports active wallet. - CLI test: subsequent `atlantic submit-query` against mocked Atlantic uses the @@ -443,6 +461,7 @@ Estimated effort: 1–2 days. Goal: cement the contract that "one login = forever Atlantic access" and ship docs. Tasks: + - Verify origin R7 end-to-end: empty session + valid `apiKey` in store → Atlantic calls succeed; `auth.refresh` not called; no 401. - README rewrite under hcloud framing. @@ -460,6 +479,7 @@ Tasks: internal links in README and docs. Success criteria: + - A user following migration guide takes < 5 minutes to migrate a known consumer of `atlantic-sdk`. - README shows both auth-driven and explicit-key paths. @@ -471,6 +491,7 @@ Estimated effort: 1 day. Goal: lock in the multi-service contract without shipping placeholders. Tasks: + - `docs/guides/adding-a-service.md` covers: new folder layout, `XService` class, `XCli` registration, `core/config.ts` base URL entry, `HcloudSurface` enum extension, exporting from `src/index.ts`. @@ -479,6 +500,7 @@ Tasks: in-test fake `EchoService`). Success criteria: + - `EchoService` test demonstrates registration in < 30 LOC and zero edits to `core/`, `auth/`, or other services. @@ -532,6 +554,7 @@ returns header → outer request proceeds. All HTTP errors funnel through `HttpClient` → `HcloudError`. New error kinds with explicit handling: + - `not_authenticated` (no creds at all) — actionable message includes `hcloud auth login` and `HCLOUD_API_KEY`. - `session_expired` — refresh failed; user must re-login. Do **not** retry. @@ -563,7 +586,7 @@ unchanged. - SDK and CLI must remain symmetrical. Every `client..` has a `hcloud ` counterpart and vice versa. Enforce by inspection during PR review; consider a generated parity table later. -- `auth login` is the *only* CLI command without a 1:1 SDK match (the SDK +- `auth login` is the _only_ CLI command without a 1:1 SDK match (the SDK uses `client.auth.login({ signer })` rather than reading env). Documented intentional difference. @@ -601,7 +624,7 @@ unchanged. - [ ] **R4** `client.auth.login({ signer })` and `hcloud auth login` complete the EIP-712 → bearer → API-key flow against `auth-billing` and persist credentials. - [ ] **R5** `client.auth.refresh` rotates bearer pair. Bearer tokens are never sent as cookies and vice versa (channel-binding test passes). - [ ] **R6** `client.auth.apiKeys.{list,create,activate,deactivate}` and `hcloud auth api-keys ` work end-to-end. `projectId` defaults to `selectedProject` from active wallet. -- [ ] **R7** Once an API key is persisted, *all* `client.atlantic.*` calls work indefinitely with no re-login. No `auth-billing` requests are made on Atlantic-only workflows. +- [ ] **R7** Once an API key is persisted, _all_ `client.atlantic.*` calls work indefinitely with no re-login. No `auth-billing` requests are made on Atlantic-only workflows. - [ ] **R8** `HcloudSigner` interface is documented; `privateKeySigner(hex)` is the default; KMS / ethers / browser signers can implement the interface. - [ ] **R9** Existing Atlantic tests (`client.test.ts`, `workflows.test.ts`, `x402.test.ts`, `multipart.test.ts`) pass with import-path updates only. - [ ] **R10** `package.json#name` = `@herodotus_dev/hcloud`; `package.json#bin.hcloud` set; repo renamed to `hcloud` on GitHub; README/migration guide updated. @@ -647,21 +670,21 @@ unchanged. ## Risk Analysis & Mitigation - **Risk:** `auth-billing` schema drift between brainstorm date and shipping. - *Mitigation:* contract tests against a fixture recorded from the running + _Mitigation:_ contract tests against a fixture recorded from the running service; fail fast if shape changes. - **Risk:** Hard deprecation breaks downstream users immediately. - *Mitigation:* migration guide, deprecation notice on final `atlantic-sdk` + _Mitigation:_ migration guide, deprecation notice on final `atlantic-sdk` release, announcement in repo README at rename time. - **Risk:** Credential file leakage. - *Mitigation:* mode 600, mask keys in default CLI output, never log secrets, + _Mitigation:_ mode 600, mask keys in default CLI output, never log secrets, document storage location prominently in README. - **Risk:** Refresh races between CLI and SDK on the same machine. - *Mitigation:* documented; reactive 401 retry catches the rare loss; advisory + _Mitigation:_ documented; reactive 401 retry catches the rare loss; advisory lock deferred until needed. - **Risk:** Channel-binding bug accidentally sending bearer as cookie. - *Mitigation:* defensive check in `HttpClient`; integration test covers it. + _Mitigation:_ defensive check in `HttpClient`; integration test covers it. - **Risk:** Rename breaks open PRs / forks. - *Mitigation:* GitHub repo redirect handles old URLs; one-time PR flush + _Mitigation:_ GitHub repo redirect handles old URLs; one-time PR flush before rename. --- diff --git a/src/auth/cli.ts b/src/auth/cli.ts index a0d91f2..9808d91 100644 --- a/src/auth/cli.ts +++ b/src/auth/cli.ts @@ -13,14 +13,11 @@ export const authCli: ServiceCli = { description: 'Sign an EIP-712 challenge and obtain a persistent API key', run: async ({ client, flags }) => { const privateKey = - stringFlag(flags, 'private-key') ?? - process.env.HCLOUD_WALLET_PRIVATE_KEY ?? - process.env.WALLET_PRIVATE_KEY; + stringFlag(flags, 'private-key') ?? process.env.HCLOUD_WALLET_PRIVATE_KEY ?? process.env.WALLET_PRIVATE_KEY; if (!privateKey) { throw new HcloudError({ kind: 'not_authenticated', - message: - 'Provide --private-key or set HCLOUD_WALLET_PRIVATE_KEY (alias: WALLET_PRIVATE_KEY).', + message: 'Provide --private-key or set HCLOUD_WALLET_PRIVATE_KEY (alias: WALLET_PRIVATE_KEY).', }); } const signer = privateKeySigner(privateKey as `0x${string}`); @@ -156,4 +153,3 @@ function stripSession(entry: T): Om const { session: _session, ...rest } = entry; return rest; } - diff --git a/src/auth/index.ts b/src/auth/index.ts index 0c76d49..409d1a8 100644 --- a/src/auth/index.ts +++ b/src/auth/index.ts @@ -4,12 +4,7 @@ import { ApiKeysClient, type HcloudApiKey } from './api-keys'; import { refreshSession } from './refresh'; import type { HcloudSigner } from './signer'; import { fetchChallenge, submitSession, type SessionResponse } from './web3'; -import { - toWalletEntry, - type CredentialStore, - type SessionEntry, - type WalletEntry, -} from './store/credential-store'; +import { toWalletEntry, type CredentialStore, type SessionEntry, type WalletEntry } from './store/credential-store'; export interface AuthServiceOptions { store: CredentialStore; diff --git a/src/cli/config.ts b/src/cli/config.ts index ef37dd8..b0eb26d 100644 --- a/src/cli/config.ts +++ b/src/cli/config.ts @@ -6,8 +6,7 @@ export interface CliConfig { export function readCliConfig(flags: Record): CliConfig { const env = process.env; - const baseUrl = - stringFlag(flags, 'base-url') ?? env.HCLOUD_ATLANTIC_BASE_URL ?? env.ATLANTIC_BASE_URL; + const baseUrl = stringFlag(flags, 'base-url') ?? env.HCLOUD_ATLANTIC_BASE_URL ?? env.ATLANTIC_BASE_URL; const apiKey = stringFlag(flags, 'api-key') ?? env.HCLOUD_API_KEY ?? env.ATLANTIC_API_KEY; const client: HcloudClientOptions = {}; diff --git a/src/services/atlantic/client.ts b/src/services/atlantic/client.ts index f16b853..188d92e 100644 --- a/src/services/atlantic/client.ts +++ b/src/services/atlantic/client.ts @@ -66,10 +66,7 @@ export class AtlanticService { * Use this low-level method when you want direct control over query options and lifecycle handling. * Provide `paymentAdapter` when you want the SDK to complete Atlantic's x402 payment challenge. */ - async submitQuery( - input: SubmitQueryInput, - options: AtlanticSubmitQueryOptions = {}, - ): Promise { + async submitQuery(input: SubmitQueryInput, options: AtlanticSubmitQueryOptions = {}): Promise { const adapter = options.paymentAdapter ?? this.paymentAdapter; if (adapter) { return submitWithX402(this.http, input, adapter, { diff --git a/src/services/atlantic/index.ts b/src/services/atlantic/index.ts index a82e7ec..a2a85cc 100644 --- a/src/services/atlantic/index.ts +++ b/src/services/atlantic/index.ts @@ -15,11 +15,7 @@ export type { QueryWithJobsResult, } from './workflows/queries'; export { submitToBucket, createBucketAndSubmit } from './workflows/buckets'; -export type { - SubmitToBucketInput, - CreateBucketAndSubmitInput, - CreateBucketAndSubmitResult, -} from './workflows/buckets'; +export type { SubmitToBucketInput, CreateBucketAndSubmitInput, CreateBucketAndSubmitResult } from './workflows/buckets'; export type { X402PaymentAdapter, X402PaymentRequest } from './x402/adapter'; export { PAYMENT_REQUIRED_HEADER, diff --git a/test/auth-cli.test.ts b/test/auth-cli.test.ts index f796f7c..8a7cbe0 100644 --- a/test/auth-cli.test.ts +++ b/test/auth-cli.test.ts @@ -73,7 +73,8 @@ describe('auth CLI', () => { const url = new URL(typeof input === 'string' || input instanceof URL ? input : input.url); if (url.pathname === '/auth/web3/challenge') return Promise.resolve(Response.json(challengeBody(WALLET))); if (url.pathname === '/auth/web3/session') return Promise.resolve(Response.json(sessionBody)); - if (url.pathname === '/api-keys') return Promise.resolve(Response.json({ data: [{ id: 'k1', apiKey: 'plain' }] })); + if (url.pathname === '/api-keys') + return Promise.resolve(Response.json({ data: [{ id: 'k1', apiKey: 'plain' }] })); throw new Error(`unexpected ${url.pathname}`); }; @@ -91,9 +92,9 @@ describe('auth CLI', () => { const store = new MemoryCredentialStore(); const client = buildClient(store, (() => Promise.resolve(new Response())) as never); - await expect( - authCli.commands.login!.run({ client, flags: {}, positionals: [] }), - ).rejects.toMatchObject({ kind: 'not_authenticated' }); + await expect(authCli.commands.login!.run({ client, flags: {}, positionals: [] })).rejects.toMatchObject({ + kind: 'not_authenticated', + }); }); test('atlantic command after login uses the persisted api key', async () => { diff --git a/test/auth-service.test.ts b/test/auth-service.test.ts index c35baa6..d602039 100644 --- a/test/auth-service.test.ts +++ b/test/auth-service.test.ts @@ -121,7 +121,10 @@ describe('AuthService.login', () => { ] as const) { const { fetch } = scriptedFetch([ { match: (r) => r.url.includes('/auth/web3/challenge'), respond: () => Response.json(challengeBody(wallet)) }, - { match: (r) => r.url.endsWith('/auth/web3/session'), respond: () => Response.json(sessionBody(wallet, suffix)) }, + { + match: (r) => r.url.endsWith('/auth/web3/session'), + respond: () => Response.json(sessionBody(wallet, suffix)), + }, { match: (r) => r.url.includes('/api-keys'), respond: () => Response.json(apiKeysBody(apiKey)) }, ]); const client = new HcloudClient({ diff --git a/test/client.test.ts b/test/client.test.ts index c9625de..b8be70d 100644 --- a/test/client.test.ts +++ b/test/client.test.ts @@ -47,9 +47,7 @@ describe('AtlanticService via HcloudClient', () => { fetch: () => Promise.resolve(Response.json({ atlanticQueryId: 'query-1' }, { status: 201 })), }); - await expect( - client.atlantic.submitQuery({ declaredJobSize: 'S', pieFile: new Blob(['zip']) }), - ).resolves.toEqual({ + await expect(client.atlantic.submitQuery({ declaredJobSize: 'S', pieFile: new Blob(['zip']) })).resolves.toEqual({ atlanticQueryId: 'query-1', }); }); diff --git a/test/multi-service.test.ts b/test/multi-service.test.ts index 4a910e2..366972c 100644 --- a/test/multi-service.test.ts +++ b/test/multi-service.test.ts @@ -96,7 +96,8 @@ describe('multi-service contract', () => { const config = resolveCoreConfig({ baseUrls: { atlantic: 'https://example.com', 'auth-billing': 'https://auth.example.com' }, fetch: (input) => { - const path = new URL(typeof input === 'string' ? input : input instanceof URL ? input.href : input.url).pathname; + const path = new URL(typeof input === 'string' ? input : input instanceof URL ? input.href : input.url) + .pathname; return Promise.resolve(Response.json({ from: path })); }, }); From 8563b2f14971392606f90587448ff36a0b3dfec5 Mon Sep 17 00:00:00 2001 From: skusnierz Date: Thu, 7 May 2026 09:38:09 +0200 Subject: [PATCH 8/9] chore: mark hcloud plan as completed --- ...26-05-07-001-feat-rename-to-hcloud-multi-service-sdk-plan.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/plans/2026-05-07-001-feat-rename-to-hcloud-multi-service-sdk-plan.md b/docs/plans/2026-05-07-001-feat-rename-to-hcloud-multi-service-sdk-plan.md index 461e738..a7c6d94 100644 --- a/docs/plans/2026-05-07-001-feat-rename-to-hcloud-multi-service-sdk-plan.md +++ b/docs/plans/2026-05-07-001-feat-rename-to-hcloud-multi-service-sdk-plan.md @@ -1,7 +1,7 @@ --- title: 'feat: rename to hcloud, add wallet auth and multi-service SDK shape' type: feat -status: active +status: completed date: 2026-05-07 origin: docs/brainstorms/2026-05-07-hcloud-multi-service-sdk-requirements.md --- From b639f87d262e93dc770295035deffed05ca90c9a Mon Sep 17 00:00:00 2001 From: skusnierz Date: Thu, 7 May 2026 09:46:49 +0200 Subject: [PATCH 9/9] chore: remove docs folder from repo --- .gitignore | 4 +- README.md | 13 +- ...7-hcloud-multi-service-sdk-requirements.md | 193 ----- .../atlantic-bun-sdk-requirements.md | 166 ---- docs/guides/adding-a-service.md | 130 --- docs/guides/credentials.md | 122 --- docs/guides/signers.md | 111 --- ...26-05-06-001-feat-atlantic-bun-sdk-plan.md | 612 -------------- ...rename-to-hcloud-multi-service-sdk-plan.md | 752 ------------------ 9 files changed, 4 insertions(+), 2099 deletions(-) delete mode 100644 docs/brainstorms/2026-05-07-hcloud-multi-service-sdk-requirements.md delete mode 100644 docs/brainstorms/atlantic-bun-sdk-requirements.md delete mode 100644 docs/guides/adding-a-service.md delete mode 100644 docs/guides/credentials.md delete mode 100644 docs/guides/signers.md delete mode 100644 docs/plans/2026-05-06-001-feat-atlantic-bun-sdk-plan.md delete mode 100644 docs/plans/2026-05-07-001-feat-rename-to-hcloud-multi-service-sdk-plan.md diff --git a/.gitignore b/.gitignore index 3632834..8e164de 100644 --- a/.gitignore +++ b/.gitignore @@ -110,4 +110,6 @@ dist .dynamodb/ # TernJS port file -.tern-port \ No newline at end of file +.tern-port + +/docs \ No newline at end of file diff --git a/README.md b/README.md index 034f2d8..b0c8326 100644 --- a/README.md +++ b/README.md @@ -3,17 +3,12 @@ TypeScript SDK and CLI for [Herodotus Cloud](https://herodotus.cloud). This package gives you a single entrypoint to every Herodotus Cloud service. Each -service is reached through its own namespace on `HcloudClient`, so methods that -share a name across services (e.g. `submitQuery`) stay unambiguous. +service is reached through its own namespace on `HcloudClient`. Today the package ships: - `client.atlantic.*` — Atlantic proving service (queries, buckets, x402 payments). - -Coming next: - - `client.auth.*` — programmatic wallet authentication (EIP-712 → bearer session → API key). -- Additional services (storage proof, data processor, …) under their own namespaces. ## Installation @@ -152,12 +147,6 @@ hcloud auth refresh # rotate bearer pair hcloud auth logout --all # wipe credentials ``` -For deeper docs see: - -- [`docs/guides/credentials.md`](docs/guides/credentials.md) — credential storage, env vars, custom stores. -- [`docs/guides/signers.md`](docs/guides/signers.md) — signer adapters (viem, ethers, KMS, browser). -- [`docs/guides/adding-a-service.md`](docs/guides/adding-a-service.md) — how new services plug into the SDK. - ## Atlantic SDK reference ### Service surface diff --git a/docs/brainstorms/2026-05-07-hcloud-multi-service-sdk-requirements.md b/docs/brainstorms/2026-05-07-hcloud-multi-service-sdk-requirements.md deleted file mode 100644 index 8376e93..0000000 --- a/docs/brainstorms/2026-05-07-hcloud-multi-service-sdk-requirements.md +++ /dev/null @@ -1,193 +0,0 @@ ---- -date: 2026-05-07 -topic: hcloud-multi-service-sdk ---- - -# hcloud: Multi-Service SDK + CLI with Wallet Auth - -## Problem Frame - -Today this repo is a single-service SDK/CLI for Atlantic. It assumes the caller already -has an API key (passed via config) and ships only Atlantic's surface. Two pressures -push it past that frame: - -1. **More Herodotus services are coming through the same SDK** (storage-proof first, - then data-processor, data-structure-indexer, satellite). They will share auth, - HTTP, retries, and CLI conventions, but each has its own domain operations — and - some operations will collide by name across services (e.g. `submitQuery`). -2. **Users should not have to obtain an API key out-of-band.** `auth-billing` already - exposes a programmatic web3 path (EIP-712 challenge → bearer session → API key), - and `ai-skills/herodotus-skills/herodotus-auth/scripts/*.ts` is a working reference - implementation. That flow belongs in the SDK so any agent, CLI user, or server - can go from "I have a wallet" to "I have an API key" without touching a browser. - -The package and repo will be renamed to **hcloud** (Herodotus Cloud) to reflect the -broader scope. - -## Requirements - -- **R1. Single SDK entrypoint with service namespaces.** `new HcloudClient({...})` - exposes services as namespaces: `client.auth.*`, `client.atlantic.*`, - `client.storageProof.*` (future). Same operation name on different services - (e.g. `submitQuery`) is unambiguous because it is reached through its service - namespace; never via a top-level overloaded function. -- **R2. Source layout mirrors the surface.** `src/auth/`, `src/services/atlantic/`, - `src/services//` are siblings; shared HTTP/config/errors live in - `src/core/` (or equivalent). Adding a new service is a copy-paste-shaped task - with no edits to existing services. -- **R3. CLI mirrors the SDK as `hcloud `.** - Examples: `hcloud auth login`, `hcloud auth whoami`, `hcloud auth api-keys list`, - `hcloud atlantic submit-query`, `hcloud atlantic get-query`, - `hcloud storage-proof submit-query` (future). `hcloud --help` lists service groups; - `hcloud --help` lists that service's commands. Existing Atlantic command - set is preserved 1:1 under the new path. -- **R4. Wallet auth produces an API key without a browser.** - `client.auth.login({ wallet })` (and `hcloud auth login`) executes the full - programmatic flow: `GET /auth/web3/challenge` → sign EIP-712 → `POST /auth/web3/session` - with `channel: "bearer"` → persist `accessToken/refreshToken/expiresAt/selectedProject` → - `GET /api-keys?projectId=…` → return a usable API key. New wallets are - auto-provisioned by the backend; the SDK does not need a separate "signup" path. -- **R5. Token refresh is handled for the user.** Before the access token expires - the SDK calls `POST /auth/refresh-token` with the bearer refresh token and - rotates the persisted pair. CLI users can also run `hcloud auth refresh` - explicitly. Channel binding is honored: bearer-issued tokens are never sent - as cookies. -- **R6. API-key management is a first-class command.** - `client.auth.apiKeys.list({ projectId? })`, `.create({ projectId, type })`, - `.activate(id)`, `.deactivate(id)` — and CLI equivalents under `hcloud auth api-keys`. - When `projectId` is omitted, the SDK uses `selectedProject` from the active session. -- **R7. Service calls authenticate transparently with the persisted API key.** - Once `auth.login` has produced an API key, the SDK persists it and all - `client.atlantic.*` and future-service calls use it automatically — _forever_, - no re-login required. Bearer access/refresh tokens are only consulted when the - user invokes auth/api-key operations (refreshing the session, listing/creating - keys). A user who only consumes Atlantic never needs to refresh the session - or log in again as long as the stored API key remains active server-side. - Callers can still pass an `apiKey` directly to `HcloudClient` and skip the - auth flow entirely. -- **R8. Pluggable signer.** The SDK accepts a signer abstraction (private key in - env is the default convenience wrapper) so KMS, hardware wallets, and browser - wallets can plug in without forking the auth code. -- **R9. Atlantic functionality is preserved.** All current operations - (queries, buckets, jobs, x402 USDC payments) continue to work; only their import - paths and CLI invocation change. x402 lives under `src/services/atlantic/` since - it is Atlantic-specific today. -- **R10. Rename hits package, repo, and binary.** npm package becomes - `@herodotus_dev/hcloud` (exact scope/name TBD — see questions), CLI binary becomes - `hcloud`, GitHub repo renamed to `hcloud`. README, examples, and docs updated. - -## Success Criteria - -- A user with only a wallet private key runs `hcloud auth login`, then - `hcloud atlantic submit-query …`, and a query is accepted — no manual API key - copy/paste, no browser. -- An SDK consumer writes `new HcloudClient({ wallet }).atlantic.submitQuery(...)` - end-to-end without ever importing from `auth-billing` or copying scripts out of - `ai-skills`. -- A new service (e.g. storage-proof) can be added by creating - `src/services/storage-proof/`, exporting it on the client, and registering its - CLI commands — with zero changes to `auth/` or `services/atlantic/`. -- Existing Atlantic users following the README can migrate by changing imports - and one CLI binary name; no semantics change for Atlantic operations. - -## Scope Boundaries - -- **Out of scope: GitHub OAuth / cookie-based auth.** Bearer/wallet only — matches - the herodotus-auth skill's stance. -- **Out of scope: secure-enclave key storage.** Default credential store is a flat - file under `~/.hcloud/` with restrictive permissions; KMS/hardware support is - enabled via the signer interface but the SDK does not ship integrations. -- **Out of scope: implementing storage-proof / data-processor / DSI / satellite - surfaces.** R1–R3 only require the _shape_ that lets them be added later; this - brainstorm does not define their operations. -- **Out of scope: billing UI, invoices, payments, project management beyond - reading `selectedProject` and minting/listing API keys.** Anything else under - `auth-billing` (credit packages, transactions, admin) stays server-side. -- **Out of scope: backwards-compatibility shims for the old `@herodotus_dev/atlantic-sdk` - package name.** Old package is deprecated; users migrate by changing imports. - (Confirm — see questions.) -- **Out of scope: rewriting x402 payment logic.** Moves under `services/atlantic/` - unchanged. - -## Key Decisions - -- **Package name: `@herodotus_dev/hcloud`.** CLI binary `hcloud`, repo renamed - to `hcloud`. -- **Hard-deprecate `@herodotus_dev/atlantic-sdk`.** No transition release; final - version of `atlantic-sdk` carries a deprecation notice pointing at `hcloud`. - Migration is a one-line import change for SDK users and a binary rename for - CLI users. -- **Default credential storage: `~/.hcloud/credentials.json` (mode 600), keyed - by wallet.** Confirmed. -- **API key is the durable credential; bearer session is ephemeral.** Once - `auth login` succeeds, the API key persists and Atlantic (and future services) - continue to work indefinitely without re-login. Bearer access/refresh tokens - are stored alongside the API key but only consulted when the user invokes - auth/api-key operations. -- **Multi-wallet CLI: last-login-wins, with explicit switching available.** - `hcloud auth login` sets the new wallet as active. `hcloud auth list` shows - stored wallets; `hcloud auth use ` switches the active one without - re-signing. -- **Single client, namespaced services (R1).** Rationale: shared auth state, shared - HTTP/retry/error handling, single config object, and the disambiguation the user - asked about falls out for free — `client.atlantic.submitQuery` vs - `client.storageProof.submitQuery` are different methods on different objects. - Subpath imports were considered and rejected because they force callers to thread - session state manually and don't match the CLI structure. -- **Reuse the herodotus-auth scripts as the reference, not the dependency.** - Port `auth.ts` / `get-api-key.ts` / `refresh.ts` into `src/auth/` as proper - modules. Rationale: the scripts are intentionally minimal viem-based examples; - copying them in lets us share the SDK's HTTP layer, error types, and signer - abstraction instead of carrying a second style of code. -- **Default credential storage: `~/.hcloud/credentials.json`, mode 600.** - Keyed by wallet address so multiple identities can coexist. Env vars - (`HCLOUD_API_KEY`, `HCLOUD_ACCESS_TOKEN`, `HCLOUD_WALLET_PRIVATE_KEY`) override - the file when set. SDK consumers can inject a custom `CredentialStore` to opt - out of disk entirely (servers, tests). -- **Auth is its own service namespace, not hidden plumbing.** `client.auth.login`, - `client.auth.refresh`, `client.auth.apiKeys.*` are user-callable; the SDK also - calls them implicitly when service requests need a fresh token. -- **CLI is generated from a service registry.** Each service module exports its - command table; the CLI dispatcher composes them. Adding a service does not - require editing the CLI dispatcher. - -## Dependencies / Assumptions - -- `auth-billing` endpoints used here (`/auth/web3/challenge`, `/auth/web3/session`, - `/auth/refresh-token`, `/api-keys`) are stable and match the contract documented - in `herodotus-auth/SKILL.md`. -- `viem` is acceptable as the default EIP-712 signer dependency (already a - dependency of this SDK). -- New wallets are auto-provisioned with a Personal project + one API key by the - backend, so no signup endpoint is needed in the SDK. -- The npm scope `@herodotus_dev` (or whatever final scope is chosen) is available - for `hcloud`. - -## Outstanding Questions - -### Deferred to Planning - -- [Affects R1, R2][Technical] Exact module boundary between `src/core/` - (HTTP/config/errors/retries) and `src/auth/`. Likely shape: `core` exposes a - request function that auth and services both consume; auth attaches bearer/api-key - headers via a request interceptor. -- [Affects R3][Technical] CLI command-registry interface (how each service module - declares its commands, flags, and help text without coupling to the dispatcher). -- [Affects R7][Technical] Auth strategy resolution order when both an explicit - `apiKey` and a stored session are present. Proposed: explicit `apiKey` wins; - otherwise fall back to stored session and refresh on demand. -- [Affects R5][Technical] Refresh trigger policy: refresh on 401, refresh on - expiry-1m, or both. Proposed: both — proactive refresh in long-running clients, - reactive 401 retry as a safety net. -- [Affects R8][Needs research] Signer interface shape that cleanly covers viem - account, ethers v6 signer, and a KMS-style async signer without leaking - library types into the public API. -- [Affects R9][Technical] Where x402 lives long-term — Atlantic-specific today, - but if other services adopt x402 it will need to move under `core/`. -- [Affects R2][Technical] Whether to keep a thin `src/services/atlantic/` re-export - at the old import paths during one release for migration ergonomics, or do a - hard cut. - -## Next Steps - -→ `/ce:plan` for structured implementation planning. diff --git a/docs/brainstorms/atlantic-bun-sdk-requirements.md b/docs/brainstorms/atlantic-bun-sdk-requirements.md deleted file mode 100644 index 8da65f7..0000000 --- a/docs/brainstorms/atlantic-bun-sdk-requirements.md +++ /dev/null @@ -1,166 +0,0 @@ ---- -date: 2026-05-06 -topic: atlantic-bun-sdk ---- - -# Atlantic Bun SDK - -## Summary - -Build a Bun-first TypeScript SDK and CLI for Atlantic that covers the full public API while also providing agent-friendly workflow helpers for query submission, lifecycle tracking, retries, buckets, artifacts, and x402 payments. - ---- - -## Problem Frame - -Atlantic already exposes proving, query lifecycle, bucket, and payment capabilities through public API documentation and the `atlantic-api` AI skill. A developer or coding agent can call those endpoints directly, but doing so forces every integration to rediscover request composition, multipart file handling, polling, retry semantics, x402 payment flow, artifact lookup, and error interpretation. - -The SDK should make Atlantic easier to use from Bun and TypeScript projects without hiding the underlying API model. It should also give agents a predictable surface with clear method descriptions, safer defaults, and workflow-level helpers so agent-built integrations are less likely to hallucinate endpoints, mishandle payments, or lose query state. - ---- - -## Actors - -- A1. SDK user: A developer integrating Atlantic into a Bun or TypeScript application. -- A2. CLI user: A developer or operator running Atlantic workflows from a terminal. -- A3. AI agent: A coding or operations agent using the SDK/CLI with limited context and needing strong descriptions and guardrails. -- A4. Atlantic API: The existing Herodotus Atlantic service that receives queries, tracks jobs, manages buckets, and handles x402 challenges. - ---- - -## Key Flows - -- F1. Submit and track a query - - **Trigger:** A user wants to submit a Cairo program, PIE, or proof-related input to Atlantic. - - **Actors:** A1, A2, A3, A4 - - **Steps:** The caller prepares input files and query options, submits the request, receives an Atlantic query ID, and can poll or inspect details until the query reaches a terminal state. - - **Outcome:** The caller has a durable query ID, current status, and a clear path to metadata or artifacts. - - **Covered by:** R1, R2, R5, R8, R9 - -- F2. Recover or retry a query - - **Trigger:** A query fails, a submit operation is ambiguous, or a caller needs to locate a query by deduplication key. - - **Actors:** A1, A2, A3, A4 - - **Steps:** The caller retrieves query details or looks up by deduplication key, determines whether retry is allowed, retries through the API when valid, and surfaces non-retriable states clearly. - - **Outcome:** Retriable failures are restarted without guessing; non-retriable failures are explained. - - **Covered by:** R3, R4, R6, R9, R10 - -- F3. Work with Applicative Recursion buckets - - **Trigger:** A caller wants to group multiple Atlantic queries into a bucket for Applicative Recursion. - - **Actors:** A1, A2, A3, A4 - - **Steps:** The caller creates or lists buckets, submits bucket-linked queries when supported, inspects bucket details and associated queries, and closes a bucket when ready. - - **Outcome:** Bucket workflows are available through both SDK and CLI without manual endpoint composition. - - **Covered by:** R7, R8, R9, R11 - -- F4. Pay with x402 when required - - **Trigger:** A submit request receives a payment challenge because project credits are insufficient or the caller uses an anonymous wallet flow. - - **Actors:** A1, A3, A4 - - **Steps:** The SDK exposes the challenge, signs or delegates signing through a wallet adapter, retries the original submit with a payment signature, and returns the settlement response alongside the query result. - - **Outcome:** Payment is handled according to the public x402 flow, without preemptive signatures or invented payment endpoints. - - **Covered by:** R12, R13, R14, R15 - ---- - -## Requirements - -**API coverage** - -- R1. The SDK must expose typed methods for every public Atlantic API capability documented in the current Atlantic OpenAPI contract: health check, submit query, get query by ID, get query by dedup ID, list queries, query stats, query jobs, retry query, list buckets, create bucket, get bucket details, and close bucket. -- R2. Query submission must support the documented Atlantic inputs and options, including program, input, PIE, and proof file inputs; Cairo version and VM options; result selection; layouts; prover selection; declared job size; network selection; deduplication identifiers; external identifiers; and bucket-related fields. -- R3. Query lookup must support direct query ID lookup and deduplication-key lookup as separate explicit capabilities. -- R4. Retry support must call the public retry capability and clearly distinguish retriable, non-retriable, max-retry, missing-query, forbidden, and wrong-state outcomes. -- R5. Query lifecycle support must make status, step, job list, metadata URLs, and terminal-state inspection easy to consume without requiring callers to manually understand every raw API response. -- R6. List operations must support pagination where the Atlantic API supports it. -- R7. Bucket support must cover listing buckets, creating buckets, fetching bucket details with associated queries, closing buckets, and submitting bucket-linked queries where the API permits them. - -**Agent-first workflow helpers** - -- R8. The SDK must include higher-level helpers for common workflows: submit-and-return-ID, submit-and-wait, wait-for-query, retry-if-retriable, get-query-with-jobs, and bucket-oriented submission. -- R9. Workflow helpers must preserve caller control over polling intervals, timeout budgets, retry budgets, and terminal-state behavior. -- R10. The SDK must keep Atlantic query IDs, dedup IDs, and bucket IDs visible as first-class outputs rather than hiding them inside opaque workflow results. -- R11. Agent-facing helper descriptions must include what the helper does, when to use it, required inputs, important constraints, expected result, and common failure modes. - -**x402 payments** - -- R12. The SDK must support Atlantic's documented x402 v2 flow for `submit query`, including challenge parsing, payment-signature retry, and settlement-response parsing. -- R13. The SDK must support both API-key fallback payments and anonymous wallet payments, while making their behavioral differences explicit to the caller. -- R14. The SDK must prevent or clearly warn against unsafe x402 usage: preemptive payment signatures, reused successful payment signatures, hardcoded payment requirements, anonymous `dedupId` usage, anonymous bucket usage, and double-payment after ambiguous settlement. -- R15. x402 signing must be wallet-adapter friendly so applications can provide their own signing implementation rather than coupling the SDK to a single wallet source. - -**CLI** - -- R16. The CLI must expose commands for the same main capabilities as the SDK: submit query, retry query, get query details, get query by dedup ID, list queries, get query jobs, query stats, list buckets, create bucket, get bucket, close bucket, and health check. -- R17. CLI commands must support file-path inputs for query submission and must print machine-readable JSON by default or through an explicit mode. -- R18. CLI commands must support environment-based configuration for the Atlantic base URL, API key, and payment-related wallet configuration. -- R19. CLI output must preserve important identifiers, status, payment receipt data, and error details so agents can chain commands reliably. - -**Developer experience and quality** - -- R20. The package must be Bun-first while remaining idiomatic TypeScript for library consumers. -- R21. Public SDK methods must have clear names, strong TypeScript types, and documentation comments that explain behavior rather than merely restating parameter names. -- R22. The SDK must separate low-level API methods from higher-level workflow helpers so advanced users can keep exact control while agents and common integrations get safer defaults. -- R23. Errors must be structured enough for callers to branch on category, HTTP status where available, Atlantic message/code where available, and payment-specific failure class where available. -- R24. The codebase must be organized for maintainability, with focused modules, minimal duplication, and tests that cover request construction, response parsing, x402 behavior, workflow helpers, and CLI command behavior. - ---- - -## Acceptance Examples - -- AE1. **Covers R1, R16.** Given a user wants to inspect their Atlantic usage from code or terminal, when they list queries, fetch query details, fetch query jobs, or query stats, then both SDK and CLI expose those capabilities without requiring manual URL construction. -- AE2. **Covers R2, R8, R10.** Given a user submits a query using a local PIE file, when submission succeeds, then the result includes the Atlantic query ID and any relevant metadata needed for later polling or artifact inspection. -- AE3. **Covers R4, R23.** Given a failed query is not retriable or has exceeded retry limits, when the caller retries it, then the SDK/CLI returns a structured failure that lets the caller distinguish the reason without parsing prose. -- AE4. **Covers R7, R16.** Given a user is building an Applicative Recursion flow, when they create a bucket, submit bucket-linked queries, inspect the bucket, and close it, then each step is available through documented SDK methods and CLI commands. -- AE5. **Covers R12, R14, R15.** Given a submit request receives an x402 challenge, when the caller provides a wallet signing adapter, then the SDK signs the documented payment authorization, retries the original submit, returns the query result and settlement response, and does not reuse a successful payment signature for a later query. -- AE6. **Covers R11, R21.** Given an AI agent sees only the SDK method descriptions, when it chooses between direct API methods and workflow helpers, then the descriptions are concrete enough to avoid inventing unsupported payment endpoints or unsupported anonymous-flow bucket/dedup behavior. -- AE7. **Covers R17, R19.** Given an agent runs the CLI in a shell workflow, when a command succeeds or fails, then the output includes stable JSON fields for IDs, status, result data, payment receipt data, and structured errors. - ---- - -## Success Criteria - -- A developer can complete a normal Atlantic proof workflow from Bun using the SDK without reading the raw OpenAPI spec for every call. -- An AI agent can use the SDK/CLI safely for submit, poll, retry, bucket, and x402 workflows with low hallucination risk. -- Full public API coverage exists without sacrificing clear higher-level workflow helpers. -- Payment behavior follows Atlantic's documented x402 flow and avoids double-payment or unsupported anonymous-flow behavior. -- A downstream planning or implementation agent can proceed from this document without inventing SDK product behavior or CLI scope. - ---- - -## Scope Boundaries - -- Do not change the Atlantic backend or public API as part of this SDK effort. -- Do not invent payment endpoints or payment flows outside the documented x402 behavior on query submission. -- Do not build a GUI, dashboard, or hosted service. -- Do not hide all Atlantic concepts behind a single opaque workflow; query IDs, dedup IDs, statuses, jobs, buckets, and payment receipts remain visible. -- Do not implement persistent state storage as a required SDK feature in the first version; applications may persist IDs and results themselves. -- Do not make on-chain verification adapters a blocker unless they are thin helpers around documented Atlantic results and public integration points. - ---- - -## Key Decisions - -- Full API plus agent-first helpers: The SDK should not choose between complete endpoint coverage and ergonomic workflows; it needs both because direct developers and AI agents have different needs. -- SDK as the source of logic, CLI as operational surface: Shared SDK behavior prevents divergent request handling, x402 semantics, and error interpretation between code and terminal usage. -- x402 scoped to documented submit flow: This keeps payment support useful while avoiding hallucinated endpoints and unsafe payment abstractions. -- Explicit IDs and structured errors: Atlantic workflows are asynchronous and operational; callers need durable identifiers and branchable errors more than opaque convenience wrappers. -- Documentation comments are part of the product: Method descriptions should guide agents and developers toward correct usage, not merely satisfy generated API documentation. - ---- - -## Dependencies / Assumptions - -- The current source of truth is the Atlantic OpenAPI contract, the Atlantic API docs, the `atlantic-api` AI skill, and the existing Atlantic route schemas. -- Bun is the primary runtime and package manager target. -- TypeScript users are a primary audience, so types and documentation comments carry product value. -- Wallet signing should be adapter-based because caller environments vary across agents, CLIs, server apps, and local scripts. -- API-key authentication remains the normal flow; x402 is used when required by Atlantic or when anonymous wallet flow is intentionally used. - ---- - -## Outstanding Questions - -### Deferred to Planning - -- [Affects R12, R15][Technical] Which wallet/signing adapter shape best supports Bun CLI, server-side SDK use, and external wallet clients without overcoupling? -- [Affects R17, R18][Technical] What exact CLI command naming and configuration precedence should be used for environment variables, flags, and config files? -- [Affects R24][Needs research] Should SDK types be generated from OpenAPI, hand-authored from source schemas, or combined with a generated base plus curated workflow types? -- [Affects R8, R9][Technical] What default polling interval, timeout, and retry budgets should helpers use? diff --git a/docs/guides/adding-a-service.md b/docs/guides/adding-a-service.md deleted file mode 100644 index e4d1b54..0000000 --- a/docs/guides/adding-a-service.md +++ /dev/null @@ -1,130 +0,0 @@ -# Adding a new service - -`hcloud` is structured so each Herodotus Cloud service lives under its own -folder and can be added without touching shared code. This guide walks through -creating `client..*` end to end. - -The contract is intentionally small. After this guide: - -- The new service appears as a namespace on `HcloudClient`. -- The CLI dispatcher exposes `hcloud ` automatically. -- No edits to `src/core/`, `src/auth/`, or `src/services/atlantic/` are required. - -## 1. Pick a surface name - -Surfaces are how `core/http.ts` knows which base URL to use. Add yours to -`HcloudSurface` in `src/core/config.ts`: - -```ts -export type HcloudSurface = 'atlantic' | 'auth-billing' | 'storage-proof'; - -export const DEFAULT_BASE_URLS: Record = { - atlantic: 'https://atlantic.api.herodotus.cloud', - 'auth-billing': 'https://auth-billing.api.herodotus.cloud', - 'storage-proof': 'https://storage-proof.api.herodotus.cloud', -}; -``` - -Add an env-var override path if you want one (`HCLOUD_STORAGE_PROOF_BASE_URL`). -The shape mirrors what `atlantic` already does. - -## 2. Create the service folder - -``` -src/services/storage-proof/ - index.ts # public exports - client.ts # StorageProofService class - types.ts # request/response types - cli.ts # CLI command table (ServiceCli) -``` - -`client.ts` takes an injected `HttpClient` and consumes it through the same -`request()` / `send()` API the Atlantic service uses. Auth handling is automatic -— pick the right `authMode`: - -```ts -import type { HttpClient } from '../../core/http'; - -export class StorageProofService { - constructor(private readonly http: HttpClient) {} - - async submitQuery(input: SubmitStorageProofQueryInput) { - const response = await this.http.request({ - method: 'POST', - surface: 'storage-proof', - path: '/queries', - body: JSON.stringify(input), - headers: { 'content-type': 'application/json' }, - authMode: 'api-key', - }); - return response.data; - } -} -``` - -## 3. Register on `HcloudClient` - -Add the namespace in `src/hcloud-client.ts`: - -```ts -this.storageProof = new StorageProofService(http); -``` - -Two services on the same `HcloudClient` can have method names that collide -(`submitQuery` for both Atlantic and storage-proof) — they are unambiguous because -they live on different namespaces. - -## 4. Register the CLI - -Each service exports a `ServiceCli` (see `src/cli/registry.ts`): - -```ts -// src/services/storage-proof/cli.ts -import type { ServiceCli } from '../../cli/registry'; - -export const storageProofCli: ServiceCli = { - name: 'storage-proof', - description: 'Storage proof service', - commands: { - 'submit-query': { - description: 'Submit a storage proof query', - run: async ({ client, flags }) => client.storageProof.submitQuery(readInput(flags)), - }, - }, -}; -``` - -Then add it to the registry list in `src/cli/dispatcher.ts`: - -```ts -import { storageProofCli } from '../services/storage-proof/cli'; - -const services: ServiceCli[] = [authCli, atlanticCli, storageProofCli]; -``` - -`hcloud --help` and `hcloud storage-proof --help` will pick up the new entry -automatically. The dispatcher already supports two-token command names if you -want subgroups (e.g. `hcloud storage-proof some-group action`). - -## 5. Re-export from `src/index.ts` - -Add the public types and class to the package's main export so consumers can -import them without reaching into internal paths: - -```ts -export { StorageProofService } from './services/storage-proof'; -export type { SubmitStorageProofQueryInput } from './services/storage-proof/types'; -``` - -## Done - -That's it. Adding a service does not require any edit to: - -- `src/core/http.ts` -- `src/auth/` -- existing services - -Concretely: the smallest allowed PR for a new service touches **one new folder -under `src/services/`**, plus three small registration sites -(`HcloudSurface` enum, `HcloudClient` constructor, CLI dispatcher list) and the -public re-exports in `src/index.ts`. diff --git a/docs/guides/credentials.md b/docs/guides/credentials.md deleted file mode 100644 index 8c19fd9..0000000 --- a/docs/guides/credentials.md +++ /dev/null @@ -1,122 +0,0 @@ -# Credentials - -`hcloud` resolves the API key sent on every Atlantic (and future-service) request -through a single deterministic chain. Once an entry is found, lookup stops. - -``` -1. HcloudClient({ apiKey: '...' }) # explicit option -2. process.env.HCLOUD_API_KEY # env var -3. process.env.ATLANTIC_API_KEY # legacy env alias -4. CredentialStore active wallet's apiKey # written by `hcloud auth login` -5. (none — request proceeds without an api-key header) -``` - -If your environment passes an explicit key (CI, server, `--api-key` flag), the -credential store is ignored entirely. - -## Default storage - -`FileCredentialStore` is the default. It writes to `~/.hcloud/credentials.json` -with mode `0600` and uses an atomic write (`.tmp` + `rename`) so a crash -mid-write cannot corrupt the file. - -The file is keyed by wallet address and looks like this: - -```json -{ - "version": 1, - "active": "0xabc...", - "wallets": { - "0xabc...": { - "apiKey": "...", - "projectId": "proj_...", - "session": { - "accessToken": "...", - "refreshToken": "...", - "expiresAt": "2099-01-01T00:00:00.000Z" - }, - "lastLoginAt": "2026-05-07T14:00:00Z" - } - } -} -``` - -The `apiKey` is durable. The `session` is only consulted by `client.auth.*` -operations (mostly `apiKeys.*` and `refresh`). Atlantic never reads the bearer -session — it goes straight to the api-key header. - -## Switching wallets - -`hcloud auth login` writes the new wallet and sets it active (last-login-wins). -If you log in with multiple wallets, you can switch between them without -re-signing: - -```bash -hcloud auth list -hcloud auth use 0xabc... -``` - -## Override the storage path - -```ts -import { HcloudClient, FileCredentialStore } from '@herodotus_dev/hcloud'; - -const client = new HcloudClient({ - credentialStore: new FileCredentialStore({ path: '/etc/hcloud/credentials.json' }), -}); -``` - -## Run without disk persistence (servers, tests, agents) - -`MemoryCredentialStore` keeps everything in memory and never touches disk: - -```ts -import { HcloudClient, MemoryCredentialStore } from '@herodotus_dev/hcloud'; - -const store = new MemoryCredentialStore(); -const client = new HcloudClient({ credentialStore: store }); -``` - -## Custom stores - -Implement the `CredentialStore` interface to plug in OS keychains, encrypted -secrets backends, or remote credential services. The SDK uses the interface -through `AuthService`, which handles refresh serialization and active-wallet -bookkeeping; your store only needs CRUD on `WalletEntry`s. - -```ts -import type { CredentialStore } from '@herodotus_dev/hcloud'; - -export class MyKeychainStore implements CredentialStore { - load() { - /* ... */ - } - save(file) { - /* ... */ - } - activeWallet() { - /* ... */ - } - setActive(wallet) { - /* ... */ - } - upsertWallet(entry) { - /* ... */ - } - removeWallet(wallet) { - /* ... */ - } - clear() { - /* ... */ - } -} -``` - -## Security notes - -- The credentials file contains long-lived bearer refresh tokens and API keys. - Mode `0600` keeps it readable only by your user; do not loosen that. -- `hcloud` masks `apiKey` in CLI output by default. Pass `--show-secrets` to - reveal values when you actually need to copy them. -- Bearer tokens are channel-bound: they will never be sent as cookies, and - cookie-issued tokens cannot be lifted into `Authorization: Bearer`. diff --git a/docs/guides/signers.md b/docs/guides/signers.md deleted file mode 100644 index bfc2df9..0000000 --- a/docs/guides/signers.md +++ /dev/null @@ -1,111 +0,0 @@ -# Signers - -`client.auth.login` needs a signer to produce an EIP-712 signature over the -challenge returned by `auth-billing`. The `HcloudSigner` interface is small on -purpose so it is easy to adapt to whatever wallet stack you already have. - -```ts -export interface HcloudSigner { - getAddress(): Promise<`0x${string}`>; - signTypedData(payload: { - domain: Record; - types: Record>; - primaryType: string; - message: Record; - }): Promise<`0x${string}`>; -} -``` - -> **Sign the challenge verbatim.** Always pass the `eip712` payload from the -> challenge response straight into `signTypedData`. Do not reconstruct the -> domain, types, or message client-side — the server can rotate them. - -## Built-in: private key (viem) - -```ts -import { HcloudClient, privateKeySigner } from '@herodotus_dev/hcloud'; - -const client = new HcloudClient(); -await client.auth.login({ - signer: privateKeySigner(process.env.WALLET_PRIVATE_KEY as `0x${string}`), -}); -``` - -This is the only signer shipped with the SDK. It uses `viem`'s `privateKeyToAccount`. - -## ethers v6 - -```ts -import { Wallet } from 'ethers'; -import type { HcloudSigner } from '@herodotus_dev/hcloud'; - -export function ethersSigner(wallet: Wallet): HcloudSigner { - return { - async getAddress() { - return (await wallet.getAddress()) as `0x${string}`; - }, - async signTypedData(payload) { - // ethers expects EIP712Domain to be omitted from `types` and provided via `domain`. - const { EIP712Domain: _omit, ...types } = payload.types as Record; - return (await wallet.signTypedData(payload.domain, types as never, payload.message)) as `0x${string}`; - }, - }; -} -``` - -## KMS / hardware wallets - -Wrap the underlying signer in the same shape. The signer is the only async -boundary, so any KMS that returns a signature for a typed-data hash plugs in: - -```ts -import type { HcloudSigner } from '@herodotus_dev/hcloud'; - -export function kmsSigner(deps: { - address: `0x${string}`; - signTypedDataDigest(digestHex: `0x${string}`): Promise<`0x${string}`>; -}): HcloudSigner { - return { - async getAddress() { - return deps.address; - }, - async signTypedData(payload) { - const digest = computeTypedDataDigest(payload); // your library of choice - return deps.signTypedDataDigest(digest); - }, - }; -} -``` - -Make sure the digest computation matches EIP-712 exactly (`0x1901 || domainSeparator || hashStruct(message)`). The `viem`, `ethers`, or `eth-sig-util` libraries all expose helpers for this. - -## Browser wallets - -Browser-side, take an EIP-1193 provider and adapt it. The example below assumes -`window.ethereum`: - -```ts -import type { HcloudSigner } from '@herodotus_dev/hcloud'; - -export function browserSigner(provider: { request: (args: unknown) => Promise }): HcloudSigner { - return { - async getAddress() { - const accounts = (await provider.request({ method: 'eth_requestAccounts' })) as `0x${string}`[]; - if (!accounts[0]) throw new Error('No wallet account exposed'); - return accounts[0]; - }, - async signTypedData(payload) { - const accounts = (await provider.request({ method: 'eth_requestAccounts' })) as `0x${string}`[]; - const signature = (await provider.request({ - method: 'eth_signTypedData_v4', - params: [accounts[0], JSON.stringify(payload)], - })) as `0x${string}`; - return signature; - }, - }; -} -``` - -When running in the browser remember that `FileCredentialStore` is not -available — pass `MemoryCredentialStore` (or your own store backed by -`localStorage`/`IndexedDB`) explicitly. diff --git a/docs/plans/2026-05-06-001-feat-atlantic-bun-sdk-plan.md b/docs/plans/2026-05-06-001-feat-atlantic-bun-sdk-plan.md deleted file mode 100644 index 465afa9..0000000 --- a/docs/plans/2026-05-06-001-feat-atlantic-bun-sdk-plan.md +++ /dev/null @@ -1,612 +0,0 @@ ---- -title: 'feat: Build Atlantic Bun SDK and CLI' -type: feat -status: active -date: 2026-05-06 -origin: docs/brainstorms/atlantic-bun-sdk-requirements.md ---- - -# feat: Build Atlantic Bun SDK and CLI - -## Summary - -Create a Bun-first TypeScript package that exposes a complete Atlantic API client, agent-oriented workflow helpers, x402 payment support, and a JSON-first CLI. The implementation should keep the SDK as the shared behavior layer, with CLI commands delegating to the SDK rather than duplicating request, retry, payment, and error handling. - ---- - -## Problem Frame - -The origin requirements define a greenfield SDK/CLI because direct Atlantic API usage forces each integration to rediscover multipart query submission, lifecycle polling, retry semantics, Applicative Recursion buckets, artifact metadata, and x402 payment rules. This plan turns that product scope into an implementation sequence for the currently minimal `atlantic-sdk` repo. - ---- - -## Requirements - -- R1. Expose typed SDK methods for every public Atlantic API capability in the current contract. -- R2. Support documented query submission inputs, options, files, dedup IDs, external IDs, and bucket fields. -- R3. Support query lookup by query ID and by dedup ID. -- R4. Support retry with structured retriable/non-retriable/error-state handling. -- R5. Make query lifecycle, status, steps, jobs, metadata URLs, and terminal state easy to consume. -- R6. Support pagination for list operations where available. -- R7. Cover listing, creating, fetching, closing, and using Applicative Recursion buckets. -- R8. Provide workflow helpers for submit, wait, retry, query-with-jobs, and bucket-oriented flows. -- R9. Let callers control polling intervals, timeout budgets, retry budgets, and terminal-state behavior. -- R10. Keep Atlantic query IDs, dedup IDs, bucket IDs, and payment receipts visible. -- R11. Write agent-useful SDK descriptions covering purpose, inputs, constraints, results, and common failures. -- R12. Support Atlantic's documented x402 v2 submit-query payment flow. -- R13. Support API-key fallback payments and anonymous wallet payments with explicit behavioral differences. -- R14. Guard against unsafe x402 usage and unsupported anonymous-flow dedup/bucket behavior. -- R15. Keep x402 signing wallet-adapter friendly. -- R16. Expose CLI commands for the main SDK capabilities. -- R17. Support file-path query inputs and machine-readable CLI output. -- R18. Support environment-based CLI/SDK configuration for base URL, API key, and payment options. -- R19. Preserve IDs, statuses, payment receipts, and structured errors in CLI output. -- R20. Be Bun-first while remaining idiomatic TypeScript for library consumers. -- R21. Use clear public names, strong types, and documentation comments. -- R22. Separate low-level API methods from higher-level workflow helpers. -- R23. Use structured, branchable SDK and CLI errors. -- R24. Organize code and tests for maintainability across request construction, response parsing, x402, helpers, and CLI behavior. - -**Origin actors:** A1 SDK user, A2 CLI user, A3 AI agent, A4 Atlantic API. - -**Origin flows:** F1 submit and track a query, F2 recover or retry a query, F3 work with Applicative Recursion buckets, F4 pay with x402 when required. - -**Origin acceptance examples:** AE1 API/CLI parity, AE2 submit with durable query ID, AE3 structured retry failure, AE4 bucket workflow, AE5 x402 challenge handling, AE6 agent-readable descriptions, AE7 JSON CLI chaining. - ---- - -## Scope Boundaries - -- Do not change the Atlantic backend or public API. -- Do not invent payment endpoints or payment flows outside documented x402 behavior on query submission. -- Do not build a GUI, dashboard, hosted service, or marketing documentation site. -- Do not hide Atlantic IDs, statuses, jobs, buckets, or payment receipts behind opaque workflow results. -- Do not require persistent SDK-managed state storage in the first version. -- Do not make full on-chain verifier adapters a blocker for this SDK unless they are thin helpers around documented Atlantic results. - -### Deferred to Follow-Up Work - -- Hosted docs site: publish generated API docs or a full documentation website after the SDK surface stabilizes. -- Additional payment schemes beyond Atlantic's current x402 submit-query flow: revisit only when the upstream API exposes them. -- Persistent local state cache for the CLI: consider later if repeated operator workflows need it. - ---- - -## Context & Research - -### Relevant Code and Patterns - -- `README.md`: current repo is effectively empty, so this is a greenfield package rather than an extension of existing SDK patterns. -- `docs/brainstorms/atlantic-bun-sdk-requirements.md`: origin document for product scope, actors, flows, acceptance examples, and scope boundaries. -- `../cloud-services-docs/atlantic-api/openapi-atlantic.json`: current public API contract for endpoints, request bodies, response shapes, and x402 response schema. -- `../cloud-services-docs/atlantic-api/x402-payments.mdx`: Atlantic-specific x402 behavior, including API-key fallback and anonymous wallet differences. -- `../ai-skills/plugins/herodotus-skills/skills/atlantic-api/SKILL.md`: agent guardrails for Atlantic workflows, query lifecycle, artifact handling, and x402. -- `../atlantic/src/routes/atlantic/*/schemas.ts`: implementation-side schemas that clarify documented fields, status values, retry outcomes, and bucket/query response shapes. - -### Institutional Learnings - -- No repo-local `docs/solutions/` learnings exist yet. - -### External References - -- Coinbase x402 migration guide: confirms x402 v2 header names, package split, and current v2 client/scheme pattern. -- Coinbase x402 flow docs: confirms request, 402 challenge, payment creation, retry, and response flow. -- x402 docs: confirms x402 as an HTTP-native payment protocol suitable for APIs and autonomous agents. - ---- - -## Key Technical Decisions - -| Decision | Rationale | -| ----------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| SDK-first architecture with CLI delegation | Keeps request construction, errors, workflow helpers, and x402 behavior consistent across library and terminal usage. | -| Curated public types backed by source contracts | Pure generated types would cover breadth but can produce poor DX; hand-authored-only types risk drift. Use the OpenAPI and route schemas as sources while designing clean exported SDK types. | -| Two SDK layers: API client and workflows | Low-level methods preserve full control; workflow helpers satisfy agent and common integration needs without hiding identifiers. | -| Adapter-based x402 signing | Supports Bun CLI, server apps, agent wallets, and external wallet clients without coupling the SDK to one private-key or wallet implementation. | -| JSON-first CLI output | Makes terminal workflows chainable and agent-readable, while still allowing later human-friendly formatting if needed. | -| Structured error model | Callers need to branch on Atlantic/API/payment outcomes without parsing prose or raw response bodies. | - ---- - -## Open Questions - -### Resolved During Planning - -- Wallet/signing adapter shape: use an SDK-defined adapter interface for x402 signing so the core payment flow can remain independent of any one wallet library; provide a Bun/private-key adapter as an optional convenience in the CLI-facing layer. -- CLI naming and configuration precedence: plan for explicit flags to override environment variables, with environment variables as the baseline configuration source. Config files are deferred until a concrete need appears. -- SDK types source strategy: use curated public types informed by OpenAPI and route schemas rather than exposing raw generated types as the primary public API. -- Default workflow budgets: define conservative defaults in code, but require all wait/retry helpers to accept caller overrides. - -### Deferred to Implementation - -- Exact public method and command names: settle during implementation while keeping the capability map and documentation requirements intact. -- Exact generated-doc tooling: choose after package tooling is scaffolded and public exports are visible. -- Exact x402 package use versus manual header handling: decide during implementation based on Bun compatibility and how well current x402 packages fit Atlantic's server-specific challenge shape. - ---- - -## Output Structure - -This tree shows the expected package shape. It is a scope declaration, not a rigid implementation constraint. - -```text -. -├── package.json -├── bun.lock -├── tsconfig.json -├── src -│ ├── index.ts -│ ├── client -│ │ ├── atlantic-client.ts -│ │ ├── http.ts -│ │ └── multipart.ts -│ ├── cli -│ │ ├── index.ts -│ │ ├── commands.ts -│ │ ├── config.ts -│ │ └── output.ts -│ ├── errors.ts -│ ├── types.ts -│ ├── workflows -│ │ ├── queries.ts -│ │ └── buckets.ts -│ └── x402 -│ ├── adapter.ts -│ ├── headers.ts -│ └── payments.ts -├── test -│ ├── client.test.ts -│ ├── cli.test.ts -│ ├── errors.test.ts -│ ├── workflows.test.ts -│ └── x402.test.ts -└── docs - ├── brainstorms - └── plans -``` - ---- - -## High-Level Technical Design - -> _This illustrates the intended approach and is directional guidance for review, not implementation specification. The implementing agent should treat it as context, not code to reproduce._ - -```mermaid -flowchart TB - Config["SDK/CLI configuration"] - Http["HTTP + multipart transport"] - Errors["Structured errors"] - Api["Atlantic API client"] - Workflows["Agent-first workflows"] - X402["x402 payment flow"] - Cli["CLI commands"] - - Config --> Http - Http --> Errors - Http --> Api - X402 --> Api - Api --> Workflows - Api --> Cli - Workflows --> Cli - Errors --> Cli -``` - ---- - -## Implementation Units - -```mermaid -flowchart TB - U1["U1 Scaffold package"] - U2["U2 Core transport and errors"] - U3["U3 Public types"] - U4["U4 Full API client"] - U5["U5 Workflow helpers"] - U6["U6 x402 payments"] - U7["U7 CLI"] - U8["U8 Documentation and examples"] - - U1 --> U2 - U2 --> U3 - U3 --> U4 - U4 --> U5 - U4 --> U6 - U5 --> U7 - U6 --> U7 - U7 --> U8 -``` - -### U1. Scaffold Bun TypeScript package - -**Goal:** Establish the Bun package, TypeScript compiler configuration, test harness, lint/format baseline, public entrypoint, and binary entrypoint needed for SDK and CLI work. - -**Requirements:** R20, R24 - -**Dependencies:** None - -**Files:** - -- Create: `package.json` -- Create: `bun.lock` -- Create: `tsconfig.json` -- Create: `src/index.ts` -- Create: `src/cli/index.ts` -- Create: `test/scaffold.test.ts` -- Modify: `README.md` - -**Approach:** - -- Define the package as a TypeScript library with Bun as the primary runtime and test runner. -- Configure exports for SDK use and a CLI binary entrypoint. -- Keep initial runtime dependencies minimal; add domain dependencies only when required by later units. -- Add a smoke test that validates the package entrypoint and CLI entrypoint can be imported. - -**Execution note:** Start test-first with the entrypoint smoke test so package wiring is validated before feature units build on it. - -**Patterns to follow:** - -- Current repo root conventions in `README.md`. -- Bun package conventions for scripts, tests, and bin entrypoints. - -**Test scenarios:** - -- Happy path: importing the SDK entrypoint exposes the planned public namespace without throwing. -- Happy path: importing the CLI entrypoint does not execute a command as a side effect. -- Error path: package test setup fails clearly if TypeScript compilation cannot resolve source paths. - -**Verification:** - -- The package has working Bun scripts for test and typecheck. -- SDK and CLI entrypoints exist but contain only safe initialization scaffolding. - -### U2. Build core transport, configuration, multipart, and error model - -**Goal:** Implement shared configuration, HTTP request handling, multipart request construction, response parsing, and structured errors used by all SDK and CLI capabilities. - -**Requirements:** R2, R18, R19, R23, R24 - -**Dependencies:** U1 - -**Files:** - -- Create: `src/client/http.ts` -- Create: `src/client/multipart.ts` -- Create: `src/client/config.ts` -- Create: `src/errors.ts` -- Create: `test/errors.test.ts` -- Create: `test/http.test.ts` -- Create: `test/multipart.test.ts` - -**Approach:** - -- Centralize base URL, API key, headers, query parameters, and fetch injection. -- Support Bun-native file inputs and browser/server-compatible binary inputs where practical without compromising Bun-first behavior. -- Normalize Atlantic error responses into structured SDK errors that preserve HTTP status, raw response data, Atlantic message/error fields, and payment-specific classification hooks. -- Keep multipart construction reusable for SDK calls and CLI file-path conversion. - -**Patterns to follow:** - -- Atlantic route schemas in `../atlantic/src/routes/atlantic/submit-query/schemas.ts` for multipart field expectations. -- OpenAPI request body shape in `../cloud-services-docs/atlantic-api/openapi-atlantic.json`. - -**Test scenarios:** - -- Happy path: configuration merges explicit options with environment defaults without mutating caller input. -- Happy path: JSON requests include base URL, path, query parameters, and API key headers when configured. -- Happy path: multipart query submission serializes files and scalar fields in the expected form-data categories. -- Edge case: optional API key is omitted for anonymous x402 flows without sending an empty header. -- Edge case: nullish query fields are omitted or serialized according to the documented Atlantic behavior. -- Error path: a response with `message` becomes a structured Atlantic error with status and message preserved. -- Error path: a response with `error` becomes a structured Atlantic error with status and error preserved. -- Error path: invalid JSON error bodies still produce a structured transport error with raw text preserved. - -**Verification:** - -- All later units can call one transport layer rather than constructing fetch requests directly. -- Errors are branchable without parsing prose. - -### U3. Define public types and capability map - -**Goal:** Create curated TypeScript types, enums, request/response models, lifecycle helpers, payment models, and capability documentation anchors for the public SDK surface. - -**Requirements:** R1, R2, R3, R4, R5, R6, R7, R10, R11, R21, R22 - -**Dependencies:** U1, U2 - -**Files:** - -- Create: `src/types.ts` -- Create: `src/x402/adapter.ts` -- Create: `test/types.test.ts` - -**Approach:** - -- Model Atlantic statuses, retry blocked reasons, Cairo options, result options, job sizes, networks, query responses, bucket responses, list responses, and metadata URL responses. -- Model submit query input as a developer-friendly type that can still represent every documented field. -- Model x402 challenge, payment requirement, payment payload, settlement response, and signing adapter contracts. -- Use documentation comments on exported types and methods as a design requirement, especially where agent misuse is likely. - -**Patterns to follow:** - -- Response schemas in `../atlantic/src/routes/atlantic/get-atlantic-query/schemas.ts`. -- Retry schema in `../atlantic/src/routes/atlantic/retry-atlantic-query/schemas.ts`. -- Bucket schemas in `../atlantic/src/routes/atlantic/create-bucket/schemas.ts`, `../atlantic/src/routes/atlantic/get-buckets/schemas.ts`, and `../atlantic/src/routes/atlantic/get-bucket-details/schemas.ts`. -- x402 data shape from `../cloud-services-docs/atlantic-api/x402-payments.mdx`. - -**Test scenarios:** - -- Happy path: representative query, job, bucket, list, and x402 objects satisfy exported type guards or compile-time fixtures. -- Edge case: terminal-state helper recognizes `DONE` and `FAILED` distinctly from in-progress states. -- Edge case: retry blocked reason values are modeled distinctly from generic error messages. -- Error path: unsupported anonymous-flow dedup/bucket combinations can be represented as validation outcomes in later units. - -**Verification:** - -- Public types are explicit enough for SDK and CLI units to avoid ad hoc `any` at boundaries. -- Type docs explain behavior, constraints, and common failure cases for agent-facing surfaces. - -### U4. Implement full low-level Atlantic API client - -**Goal:** Implement the complete direct Atlantic API client surface using the shared transport and public types. - -**Requirements:** R1, R2, R3, R4, R5, R6, R7, R10, R21, R22, R23, AE1, AE2, AE3, AE4 - -**Dependencies:** U2, U3 - -**Files:** - -- Create: `src/client/atlantic-client.ts` -- Modify: `src/index.ts` -- Create: `test/client.test.ts` - -**Approach:** - -- Add direct methods for health check, submit query, get query, get query by dedup ID, list queries, query stats, get query jobs, retry query, list buckets, create bucket, get bucket, and close bucket. -- Keep low-level methods close to API semantics while returning typed, normalized results. -- Ensure submit returns the query ID and any payment response metadata supplied by the transport/x402 layer. -- Keep list pagination explicit. -- Do not add workflow waiting, retry budgets, or polling into the low-level client; that belongs to U5. - -**Patterns to follow:** - -- Public endpoint list from `../cloud-services-docs/atlantic-api/openapi-atlantic.json`. -- Endpoint docs under `../cloud-services-docs/atlantic-api/endpoints/`. - -**Test scenarios:** - -- Covers AE1. Happy path: each documented endpoint maps to the expected HTTP method and path. -- Covers AE2. Happy path: submit query with a PIE file returns an Atlantic query ID and preserves metadata needed for later lookup. -- Covers AE3. Error path: retry returns a structured wrong-state, max-retry, or not-retriable error instead of generic failure. -- Covers AE4. Happy path: bucket create/list/get/close methods call the correct API capabilities and return typed bucket data. -- Edge case: list queries and list buckets include pagination parameters only when provided. -- Edge case: get by dedup ID requires a dedup ID and keeps API key behavior explicit. -- Error path: not-found query and bucket responses preserve Atlantic's missing-resource information. - -**Verification:** - -- SDK has full public API coverage from the origin requirement. -- No CLI or workflow code bypasses this client for Atlantic API calls. - -### U5. Implement agent-first query and bucket workflows - -**Goal:** Add higher-level helpers for common asynchronous Atlantic workflows while keeping IDs, statuses, and caller-controlled budgets visible. - -**Requirements:** R5, R8, R9, R10, R11, R22, R23, AE2, AE3, AE4, AE6 - -**Dependencies:** U4 - -**Files:** - -- Create: `src/workflows/queries.ts` -- Create: `src/workflows/buckets.ts` -- Modify: `src/index.ts` -- Create: `test/workflows.test.ts` - -**Approach:** - -- Build workflow helpers on top of the low-level client: submit-and-return-ID, submit-and-wait, wait-for-query, retry-if-retriable, get-query-with-jobs, and bucket-oriented submission. -- Make polling interval, timeout, retry budget, terminal statuses, and backoff behavior configurable per call. -- Return rich workflow results that include query IDs, status transitions observed by the helper, final query, jobs when requested, bucket IDs when relevant, and structured errors when workflows stop early. -- Keep helper descriptions explicit enough for agents to choose them correctly. - -**Patterns to follow:** - -- Agent workflow guidance in `../ai-skills/plugins/herodotus-skills/skills/atlantic-api/SKILL.md`. -- Query lifecycle and status docs in `../cloud-services-docs/atlantic-api/status.mdx`. - -**Test scenarios:** - -- Covers AE2. Happy path: submit-and-wait submits a query, polls until `DONE`, and returns the final query plus the original query ID. -- Covers AE3. Error path: retry-if-retriable does not retry when the query is not failed, not retriable, or retry budget is exhausted. -- Covers AE4. Happy path: bucket-oriented helper creates a bucket, submits bucket-linked jobs, and exposes the bucket ID for later close. -- Covers AE6. Happy path: helper exports include documentation comments that state when to use direct client methods versus workflows. -- Edge case: wait-for-query times out with a structured timeout error that includes the last observed query. -- Edge case: caller-supplied polling interval and terminal statuses override defaults. -- Error path: polling preserves downstream API errors with the query ID context when available. - -**Verification:** - -- Workflow helpers are useful without hiding the lower-level API concepts. -- Agent-facing descriptions are concrete and guard against unsupported behavior. - -### U6. Implement x402 payment support - -**Goal:** Support Atlantic's documented x402 v2 flow for submit-query payments through challenge parsing, wallet-adapter signing, payment retry, settlement parsing, and safety guardrails. - -**Requirements:** R12, R13, R14, R15, R23, AE5, AE6 - -**Dependencies:** U2, U3, U4 - -**Files:** - -- Create: `src/x402/headers.ts` -- Create: `src/x402/payments.ts` -- Modify: `src/x402/adapter.ts` -- Modify: `src/client/atlantic-client.ts` -- Create: `test/x402.test.ts` - -**Approach:** - -- Parse x402 challenges from `PAYMENT-REQUIRED` header and response body where Atlantic provides both. -- Represent accepted payment requirements verbatim so caller code does not hardcode receiver, asset, network, amount, or challenge metadata. -- Define a signing adapter contract that accepts the chosen payment requirement and returns the authorization/signature payload needed for `PAYMENT-SIGNATURE`. -- Retry the original submit request with the payment signature only after a 402 challenge. -- Parse `PAYMENT-RESPONSE` on success and attach settlement data to submit results. -- Enforce or surface guardrails for anonymous flow: no dedup ID and no bucket ID. -- Treat ambiguous settlement failures as payment-specific errors that preserve enough detail for callers to inspect query state before paying again. - -**Patterns to follow:** - -- Atlantic x402 docs in `../cloud-services-docs/atlantic-api/x402-payments.mdx`. -- Agent guardrails in `../ai-skills/plugins/herodotus-skills/skills/atlantic-api/SKILL.md`. -- x402 v2 header and package guidance from Coinbase/x402 docs. - -**Test scenarios:** - -- Covers AE5. Happy path: a 402 challenge is parsed, signed through the adapter, retried with `PAYMENT-SIGNATURE`, and returns query ID plus settlement response. -- Covers AE5. Edge case: `alreadyProcessed` settlement response is treated as success and does not trigger another payment. -- Covers AE6. Error path: anonymous submit with dedup ID or bucket ID fails before payment retry or returns a clearly classified unsupported-flow error. -- Error path: missing `PAYMENT-REQUIRED` header with 402 response becomes a malformed-payment-challenge error. -- Error path: rejected settlement response preserves payment error class and raw challenge/response details. -- Edge case: API-key flow does not send a payment signature before a 402 challenge. -- Edge case: payment requirements are passed verbatim to the signer rather than rebuilt from hardcoded network or amount constants. - -**Verification:** - -- Payment support follows Atlantic's documented x402 flow and avoids double-payment hazards. -- The SDK remains wallet-source agnostic. - -### U7. Implement JSON-first CLI - -**Goal:** Provide a Bun CLI that exposes the main SDK capabilities with file-path inputs, environment configuration, JSON output, and structured errors suitable for agent chaining. - -**Requirements:** R16, R17, R18, R19, R23, AE1, AE4, AE7 - -**Dependencies:** U4, U5, U6 - -**Files:** - -- Create: `src/cli/commands.ts` -- Create: `src/cli/config.ts` -- Create: `src/cli/output.ts` -- Modify: `src/cli/index.ts` -- Modify: `package.json` -- Create: `test/cli.test.ts` - -**Approach:** - -- Implement commands for health, submit query, get query, get query by dedup ID, list queries, query stats, get query jobs, retry query, list buckets, create bucket, get bucket, and close bucket. -- Convert file-path flags into SDK file inputs through shared multipart utilities. -- Use flags over environment variables over defaults for configuration precedence. -- Print JSON for successful responses and structured JSON errors for failures. -- Keep payment support available through SDK x402 configuration rather than embedding payment logic directly in command handlers. - -**Patterns to follow:** - -- Capability names from the origin requirements and OpenAPI endpoint tags. -- JSON output requirements from AE7. - -**Test scenarios:** - -- Covers AE1. Happy path: list/get/stats/job CLI commands call the corresponding SDK capability and print JSON. -- Covers AE4. Happy path: bucket CLI commands support create/list/get/close and preserve bucket IDs in output. -- Covers AE7. Happy path: successful commands print stable JSON objects with IDs, statuses, and result payloads. -- Covers AE7. Error path: failed commands print structured JSON errors and exit non-zero. -- Edge case: explicit CLI flags override environment configuration. -- Edge case: file-path submit input is converted into an SDK file input without reading unrelated files. -- Error path: missing required file or required ID produces a command validation error before calling the API. - -**Verification:** - -- CLI behavior is a thin orchestration layer over SDK methods and workflows. -- Commands are chainable by agents through stable JSON output. - -### U8. Add public docs, examples, and quality gates - -**Goal:** Document the SDK and CLI surfaces, add examples for core workflows, and ensure tests/typechecks cover the public contract. - -**Requirements:** R11, R20, R21, R24, AE6 - -**Dependencies:** U1, U4, U5, U6, U7 - -**Files:** - -- Modify: `README.md` -- Create: `examples/submit-query.ts` -- Create: `examples/submit-and-wait.ts` -- Create: `examples/x402-submit.ts` -- Create: `examples/buckets.ts` -- Create: `docs/sdk-function-reference.md` -- Create: `test/docs.test.ts` - -**Approach:** - -- Add README quickstarts for SDK configuration, submit query, wait workflow, retry, buckets, CLI usage, and x402. -- Add examples that compile against public exports and avoid private modules. -- Add a concise SDK function reference focused on purpose, inputs, constraints, results, and common failures. -- Add quality checks that examples import public APIs and documentation references exported capabilities. - -**Patterns to follow:** - -- Usage examples in `../cloud-services-docs/atlantic-api/sending-query.mdx`. -- x402 recipe in `../cloud-services-docs/atlantic-api/x402-payments.mdx`. -- Agent-facing descriptions from the origin requirements. - -**Test scenarios:** - -- Covers AE6. Happy path: each public SDK method or helper has a documentation entry or documentation comment with purpose and constraints. -- Happy path: examples compile or typecheck against public SDK exports. -- Edge case: x402 docs mention anonymous-flow dedup/bucket restrictions and no preemptive signatures. -- Error path: docs test fails when a public CLI command lacks a corresponding documentation entry. - -**Verification:** - -- A developer or agent can discover how to use SDK, CLI, workflow helpers, and x402 without reading raw OpenAPI first. -- Public examples stay synchronized with exported APIs. - ---- - -## System-Wide Impact - -- **Interaction graph:** SDK configuration feeds the shared transport; low-level client and workflow helpers share transport/errors; CLI delegates to SDK/workflows; x402 hooks into submit-query only. -- **Error propagation:** Transport and payment errors should normalize into SDK errors first, then CLI output renders those errors as stable JSON. -- **State lifecycle risks:** Query IDs, dedup IDs, bucket IDs, and payment challenge IDs must not be dropped during submit, retry, wait, or payment flows. -- **API surface parity:** Every low-level SDK capability should have an intentional CLI decision: exposed directly, exposed through workflow command, or explicitly deferred. -- **Integration coverage:** End-to-end mocked tests should prove CLI-to-SDK delegation, multipart submit construction, x402 retry flow, and polling behavior. -- **Unchanged invariants:** The SDK does not change Atlantic semantics; it preserves public API behavior while making it typed, documented, and easier to orchestrate. - ---- - -## Risks & Dependencies - -| Risk | Mitigation | -| ----------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | -| OpenAPI and backend route schemas drift | Keep route/docs references in tests and isolate public types so updates are localized. | -| x402 package compatibility with Bun or Atlantic-specific headers is imperfect | Keep x402 handling behind local adapter/header modules and allow manual protocol handling when package fit is poor. | -| CLI and SDK behavior diverge | Require CLI to call SDK/workflow functions and test command-to-SDK delegation. | -| Multipart file handling differs across Bun and other TypeScript runtimes | Treat Bun as primary, document supported input forms, and test Bun file-path use explicitly. | -| Payment retry can create double-payment risk if ambiguous states are hidden | Preserve payment challenge/settlement details and classify ambiguous failures so callers inspect query state before retrying payment. | -| Full API coverage expands initial implementation size | Sequence low-level client before workflows and CLI so each layer can be tested independently. | - ---- - -## Documentation / Operational Notes - -- `README.md` should remain the primary quickstart for installation, configuration, SDK usage, CLI usage, and x402 warnings. -- `docs/sdk-function-reference.md` should document every public client method, workflow helper, and CLI command at a practical level for developers and AI agents. -- Examples should avoid real credentials and should use environment variable placeholders for API key and wallet-related configuration. -- Release notes are not required in this plan, but the package should be structured so they can be added before publishing. - ---- - -## Sources & References - -- **Origin document:** [docs/brainstorms/atlantic-bun-sdk-requirements.md](../brainstorms/atlantic-bun-sdk-requirements.md) -- Atlantic API OpenAPI: `../cloud-services-docs/atlantic-api/openapi-atlantic.json` -- Atlantic x402 docs: `../cloud-services-docs/atlantic-api/x402-payments.mdx` -- Atlantic sending query docs: `../cloud-services-docs/atlantic-api/sending-query.mdx` -- Atlantic API skill: `../ai-skills/plugins/herodotus-skills/skills/atlantic-api/SKILL.md` -- Atlantic route schemas: `../atlantic/src/routes/atlantic/` -- Coinbase x402 migration guide: https://docs.cdp.coinbase.com/x402/migration-guide -- Coinbase x402 flow docs: https://docs.cdp.coinbase.com/x402/core-concepts/how-it-works -- x402 docs: https://docs.x402.org/introduction diff --git a/docs/plans/2026-05-07-001-feat-rename-to-hcloud-multi-service-sdk-plan.md b/docs/plans/2026-05-07-001-feat-rename-to-hcloud-multi-service-sdk-plan.md deleted file mode 100644 index a7c6d94..0000000 --- a/docs/plans/2026-05-07-001-feat-rename-to-hcloud-multi-service-sdk-plan.md +++ /dev/null @@ -1,752 +0,0 @@ ---- -title: 'feat: rename to hcloud, add wallet auth and multi-service SDK shape' -type: feat -status: completed -date: 2026-05-07 -origin: docs/brainstorms/2026-05-07-hcloud-multi-service-sdk-requirements.md ---- - -# feat: rename to hcloud, add wallet auth and multi-service SDK shape - -## Overview - -Restructure the current single-service Atlantic SDK into a multi-service Herodotus -Cloud SDK, rename it to **`@herodotus_dev/hcloud`** (binary `hcloud`, repo `hcloud`), -and add a built-in wallet authentication subsystem so callers go from "I have a -private key" to "I have a working API key" without a browser, without copying -scripts out of `ai-skills`, and without a manual visit to the dashboard. - -The transformation is structural, not behavioral: every existing Atlantic -operation continues to work bit-for-bit. What changes is where it lives in the -source tree (`src/services/atlantic/`), how it is reached through the public API -(`new HcloudClient(...).atlantic.*`), how it is invoked from the CLI -(`hcloud atlantic `), and the fact that an API key can now be acquired -through `client.auth.login({ signer })` / `hcloud auth login`. - -The design is explicitly forward-leaning on a single dimension only — the -**shape** that makes adding `storage-proof`, `data-processor`, etc. a copy-paste -of an existing service folder rather than a refactor of shared code. - ---- - -## Problem Statement - -Today the package is `@herodotus_dev/atlantic-sdk` with `src/{client,workflows,x402,cli,...}` flat -under `src/`. Three structural problems collide with where Herodotus Cloud is going: - -1. **Single-service framing.** Storage-proof, data-processor, and other services - will share auth, HTTP, retries, and CLI conventions but each has its own domain - surface. The current layout has nothing pulling shared concerns out of - Atlantic-specific code. -2. **No auth flow.** API keys are passed in via config and assumed-available. - `auth-billing` already exposes a programmatic web3 path - (EIP-712 challenge → bearer session → API key) and `ai-skills/.../herodotus-auth/scripts` - has a working reference. That flow belongs in the SDK so any agent or CLI user - can self-provision. -3. **Name collision risk in the future SDK.** Multiple services will have - operations called `submitQuery` (or analogues). They must be unambiguously - reached and disambiguated by _namespace_, not by overloaded top-level functions. - -The brainstorm settled the WHAT (see origin: `docs/brainstorms/2026-05-07-hcloud-multi-service-sdk-requirements.md`). -This plan defines the HOW. - ---- - -## Proposed Solution - -A single `HcloudClient` exposing services as namespaces, a CLI dispatcher that -generates `hcloud ` from a per-service command registry, and a -ported, hardened version of the herodotus-auth scripts living in `src/auth/` with -on-disk credential persistence at `~/.hcloud/credentials.json`. - -### Headline shape - -```ts -// SDK -const client = new HcloudClient({ /* nothing — uses stored creds */ }); -await client.auth.login({ signer: privateKeySigner(env.WALLET_PRIVATE_KEY) }); -await client.atlantic.submitQuery({ ... }); -await client.auth.apiKeys.list(); -// future: -// await client.storageProof.submitQuery({ ... }); - -// CLI -hcloud auth login --private-key 0x... -hcloud atlantic submit-query --program-file ./prog.json ... -hcloud auth api-keys list -``` - -### Source layout (target) - -``` -src/ - index.ts # exports HcloudClient + per-service public types - cli/ - index.ts # bun shebang + runCli - dispatcher.ts # parses argv → routes to service registry - registry.ts # ServiceCli interface, registerServiceCli(...) - output.ts # printJson, formatCliError (unchanged) - core/ - config.ts # base URLs per surface (atlantic, auth-billing, …) - http.ts # HttpClient w/ interceptors (auth, retry) - interceptors.ts # apiKeyHeader, bearerHeader (mutually exclusive) - errors.ts # HcloudError + kind enum (extends current AtlanticErrorKind) - multipart.ts # moved from client/multipart.ts (still Atlantic-shaped today) - hcloud-client.ts # HcloudClient: holds CredentialStore, exposes services - auth/ - index.ts # AuthService class (client.auth.*) - web3.ts # GET challenge, POST session - refresh.ts # POST /auth/refresh-token - api-keys.ts # ApiKeysClient (client.auth.apiKeys.*) - signer.ts # HcloudSigner interface + privateKeySigner default - store/ - credential-store.ts # interface - file-store.ts # ~/.hcloud/credentials.json (mode 600), atomic write - env-store.ts # HCLOUD_* env overlay - memory-store.ts # for tests / serverless - cli.ts # ServiceCli registration: hcloud auth - services/ - atlantic/ - index.ts # AtlanticService class (client.atlantic.*) - client.ts # current AtlanticClient logic, refactored to take HttpClient - types.ts # was src/types.ts (Atlantic-specific only) - workflows/ - queries.ts # was src/workflows/queries.ts - buckets.ts # was src/workflows/buckets.ts - x402/ # whole folder moved unchanged (atlantic-specific today) - cli.ts # ServiceCli: hcloud atlantic -docs/ - guides/ - adding-a-service.md # new — how to create src/services// - migration-from-atlantic-sdk.md # new — import and binary changes -``` - -### Why this layout - -- **Service folders are siblings.** Adding `storage-proof` is `cp -r services/atlantic services/storage-proof`, - edit, register. No shared code touched. -- **`core/` owns shared plumbing.** Auth and services both import `core/http`; - neither imports the other. Auth attaches `Authorization: Bearer` for - auth-billing endpoints; services attach `api-key` headers. -- **`auth/` is its own service**, not hidden plumbing. Same registry pattern, - same CLI invocation shape, same exposure on `HcloudClient` — making it the - canonical example for future services. -- **`x402/` stays inside `services/atlantic/`.** It is Atlantic-specific today. - If a second service adopts x402 the migration to `core/` is a straight move; - YAGNI for now. - ---- - -## Technical Approach - -### Architecture - -#### `HcloudClient` - -```ts -export interface HcloudClientOptions { - baseUrls?: Partial>; // per-service override - apiKey?: string; // explicit override - signer?: HcloudSigner; // optional, attached so client.auth.login() can be called without args - credentialStore?: CredentialStore; // default: file-store + env-overlay - fetch?: FetchLike; - paymentAdapter?: X402PaymentAdapter; // forwarded to AtlanticService -} - -export class HcloudClient { - readonly auth: AuthService; - readonly atlantic: AtlanticService; - // future: readonly storageProof: StorageProofService; - - constructor(options: HcloudClientOptions = {}) { ... } -} -``` - -#### `core/http.ts` - -```ts -export interface HttpRequest { - method: 'GET' | 'POST'; - surface: HcloudSurface; // 'atlantic' | 'auth-billing' | … - path: string; - query?: Record; - body?: BodyInit; - headers?: HeadersInit; - expectStatus?: number | number[]; - authMode: 'api-key' | 'bearer' | 'none'; -} - -export class HttpClient { - constructor(private deps: { config: ResolvedCoreConfig; auth: AuthHeaderProvider; fetch: FetchLike }) {} - request(req: HttpRequest): Promise>; -} -``` - -`AuthHeaderProvider` is implemented inside `AuthService` and injected at -construction. This avoids a circular `core ↔ auth` dependency: `core` defines -the interface, `auth` implements it, `HcloudClient` wires them together. - -#### `core/errors.ts` - -Rename `AtlanticSdkError` → `HcloudError`. Extend `AtlanticErrorKind` with auth-specific kinds: - -```ts -type HcloudErrorKind = - | 'transport' - | 'api' - | 'validation' - | 'timeout' - | 'payment' - | 'payment_challenge' - | 'payment_settlement' // x402 - | 'not_authenticated' - | 'session_expired' - | 'signing_failed' - | 'channel_binding'; // auth -``` - -Re-export `AtlanticSdkError` as a deprecated alias of `HcloudError` from the -Atlantic service module so internal Atlantic code can be moved with minimal churn, -then remove the alias before publish (we're hard-deprecating, no public alias). - -#### Auth resolution order (resolves brainstorm deferred Q) - -For service requests (`atlantic`, future services): - -1. `HcloudClient({ apiKey })` explicit → use it, never refresh, ignore store. -2. `HCLOUD_API_KEY` env → use it, ignore store. -3. Active wallet in `CredentialStore` → use its persisted `apiKey`. -4. Otherwise → throw `HcloudError({ kind: 'not_authenticated', ... })` with message: - `"No API key. Run \`hcloud auth login\` or set HCLOUD_API_KEY."`. - -For auth-billing requests (`client.auth.apiKeys.*`, `client.auth.refresh`): - -1. Active wallet's bearer `accessToken`. Refresh proactively if `expiresAt - 60s < now` - AND a `refreshToken` is present. -2. On 401, try one refresh + retry. -3. If refresh fails → throw `HcloudError({ kind: 'session_expired' })` directing user - to re-login. **Do not** auto-trigger interactive login. - -`client.auth.login()` is the only path that drives the EIP-712 dance. Service -calls never silently sign anything. - -#### Channel binding (resolves brainstorm deferred Q) - -`HttpClient` enforces: - -- `authMode: 'bearer'` requests **never** carry a `Cookie` header. -- `authMode: 'api-key'` requests carry the `api-key` header and **never** carry - `Authorization`. -- Throws `HcloudError({ kind: 'channel_binding' })` if a caller violates this in - custom headers. (Defensive — matches the herodotus-auth skill's anti-hallucination - rule.) - -#### Refresh policy (resolves brainstorm deferred Q) - -Both proactive and reactive: - -- Proactive: when `AuthService` is asked for a bearer header and `expiresAt - 60s < now`, - refresh first. -- Reactive: 401 from auth-billing → single refresh + replay. Two consecutive 401s → - `session_expired`. - -Service calls (Atlantic, etc.) never trigger refresh. They use the persisted -API key directly (per origin R7: API key is the durable credential). - -#### Signer interface (resolves brainstorm deferred Q) - -```ts -export interface HcloudSigner { - getAddress(): Promise<`0x${string}`>; - signTypedData(args: { - domain: Record; - types: Record>; - primaryType: string; - message: Record; - }): Promise<`0x${string}`>; -} - -export function privateKeySigner(privateKey: `0x${string}`): HcloudSigner; -``` - -Default impl uses viem's `privateKeyToAccount` (already a dependency). Adapters -for ethers/KMS/browser wallets are documented in `docs/guides/signers.md` but -not shipped as code — keeping the public surface narrow. - -#### Credential file format - -`~/.hcloud/credentials.json`, mode 600, atomic write (`write tmp + rename`): - -```json -{ - "version": 1, - "active": "0xabc...", - "wallets": { - "0xabc...": { - "apiKey": "...", - "projectId": "proj_...", - "session": { - "accessToken": "...", - "refreshToken": "...", - "expiresAt": "2026-05-07T15:00:00Z" - }, - "lastLoginAt": "2026-05-07T14:00:00Z" - } - } -} -``` - -Per origin: API key persists indefinitely; `session` may be absent or expired -without affecting Atlantic calls. `auth login` writes both; `auth use ` -only updates `active`. - -#### CLI dispatcher and service registry - -Each service module exports a `cli` object: - -```ts -// e.g. src/services/atlantic/cli.ts -export const atlanticCli: ServiceCli = { - name: 'atlantic', - description: 'Atlantic proving service', - commands: { - 'submit-query': { run: ..., flags: {...}, help: '...' }, - 'submit-and-wait': { ... }, - // ... all current commands - }, -}; -``` - -`src/cli/dispatcher.ts` imports each service's `cli` and registers it: - -```ts -const services = [authCli, atlanticCli /*, storageProofCli */]; -``` - -`hcloud --help` lists service groups by reading `services[*].name + .description`. -`hcloud --help` lists commands. `hcloud --help` prints -per-command help. Adding a service requires zero edits to `dispatcher.ts`. - -### Implementation Phases - -#### Phase 1 — Rename and core extraction - -Goal: rename package + binary + repo, introduce `core/` and `services/atlantic/`, -preserve all Atlantic behavior 1:1. No new functionality. - -Tasks: - -- `package.json`: name → `@herodotus_dev/hcloud`, bin → `{ "hcloud": "./dist/cli/index.js" }`, - description, keywords. -- Move files: - - `src/client/atlantic-client.ts` → `src/services/atlantic/client.ts` - - `src/client/http.ts` → `src/core/http.ts` (refactor to `HttpClient` class with surfaces) - - `src/client/config.ts` → split: `src/core/config.ts` (base URLs) + caller-side options stay with services - - `src/client/multipart.ts` → `src/core/multipart.ts` (only consumer is Atlantic today; keep it; revisit later) - - `src/errors.ts` → `src/core/errors.ts` (rename class) - - `src/types.ts` → `src/services/atlantic/types.ts` - - `src/workflows/*` → `src/services/atlantic/workflows/*` - - `src/x402/*` → `src/services/atlantic/x402/*` -- Introduce `HcloudClient` skeleton with only `client.atlantic`. -- `AtlanticService` is a thin wrapper around the existing `AtlanticClient` logic - but takes an injected `HttpClient` instead of building its own request loop. -- `src/index.ts`: export `HcloudClient`, `AtlanticService` types, `HcloudError`, - `privateKeySigner` (Phase 2 stub for now). **Stop** wildcard-re-exporting - internal modules; explicit exports only. -- CLI: `runCli` now dispatches by service. Atlantic command set preserved at - `hcloud atlantic `. -- Tests: rename `AtlanticSdkError` → `HcloudError` in tests; otherwise keep - behavior coverage. Add `scaffold.test.ts` assertions that - `new HcloudClient()` exposes `.atlantic`. -- Update existing CI checks (`bun test`, `tsc -p tsconfig.check.json`). -- README: full rewrite under hcloud framing; preserve all Atlantic examples - with new import paths. - -Success criteria: - -- `bun test` green. -- `bun run check` green. -- Manual smoke: `hcloud atlantic health`, `hcloud atlantic submit-query …` - behave identically to the old `atlantic` binary. -- No file at `src/client/`, `src/workflows/`, `src/x402/`, `src/types.ts`, - `src/errors.ts`. - -Estimated effort: 1–2 days. - -#### Phase 2 — Auth subsystem - -Goal: implement `src/auth/` — port the herodotus-auth scripts into proper -modules backed by `core/http.ts` and `CredentialStore`. - -Tasks: - -- `src/auth/web3.ts`: `fetchChallenge(wallet)`, `submitSession({ wallet, challengeToken, signature })`. -- `src/auth/refresh.ts`: `refreshSession(refreshToken)` (atomic; throws `session_expired`). -- `src/auth/api-keys.ts`: `ApiKeysClient` with `list({ projectId? })`, `create({ projectId, type })`, - `activate(id)`, `deactivate(id)`. Uses bearer auth-mode. -- `src/auth/signer.ts`: `HcloudSigner` interface + `privateKeySigner(hex)` viem impl. -- `src/auth/store/`: - - `credential-store.ts`: interface - ```ts - interface CredentialStore { - load(): Promise; - save(file: CredentialFile): Promise; - activeWallet(): Promise; - setActive(wallet: `0x${string}`): Promise; - upsertWallet(entry: WalletEntry): Promise; - removeWallet(wallet: `0x${string}`): Promise; - } - ``` - - `file-store.ts`: default impl. `~/.hcloud/credentials.json`, `chmod 0600`, - atomic write (write to `credentials.json.tmp`, fsync, rename). - - `env-store.ts`: read-only overlay. If `HCLOUD_API_KEY` set, surfaces a - synthetic active-wallet entry with that key (no session). - - `memory-store.ts`: for tests + servers. -- `src/auth/index.ts`: `AuthService` exposing: - - `login({ signer? }): Promise<{ wallet, apiKey, projectId }>` - - `refresh(): Promise` - - `whoami(): Promise<{ wallet, projectId, apiKey, sessionExpiresAt? } | null>` - - `list(): Promise` - - `use(wallet): Promise` - - `logout(wallet?): Promise` (`undefined` = active; `'all'` = wipe file) - - `apiKeys: ApiKeysClient` -- Wire `AuthService` as the `AuthHeaderProvider` for `HttpClient`. -- `HcloudClient` resolves API key via the order documented above. - -Success criteria: - -- Unit tests against a mocked `auth-billing` for: challenge fetch, signature - shape, session exchange, channel-binding rejection, list/create/activate/deactivate - api-keys, refresh happy path, refresh expired path. -- Integration test: full `login()` against a recorded fixture matches the - reference output of `ai-skills/.../scripts/auth.ts`. -- File-store concurrency test: two parallel `upsertWallet` calls don't truncate - the file (atomic rename verified). - -Estimated effort: 2–3 days. - -#### Phase 3 — Auth CLI commands - -Goal: `hcloud auth …` mirrors `client.auth.*`. - -Commands: - -- `hcloud auth login [--private-key | (env WALLET_PRIVATE_KEY/HCLOUD_WALLET_PRIVATE_KEY)]` -- `hcloud auth refresh` -- `hcloud auth whoami` — JSON: `{ wallet, projectId, apiKey: "***" + last4, sessionExpiresAt }`. Add `--show-secrets` to print the key in full. -- `hcloud auth list` — JSON list of stored wallets with masked keys + active flag. -- `hcloud auth use ` -- `hcloud auth logout [ | --all]` -- `hcloud auth api-keys list [--project-id ]` -- `hcloud auth api-keys create --type-name --type-color ` -- `hcloud auth api-keys activate ` -- `hcloud auth api-keys deactivate ` - -All commands follow the existing `output.ts` JSON-first pattern. Errors -serialize via `HcloudError.toJSON()`. - -Success criteria: - -- CLI test: `auth login` against mocked auth-billing stores creds and reports - active wallet. -- CLI test: subsequent `atlantic submit-query` against mocked Atlantic uses the - stored key automatically. -- CLI test: multi-wallet — login A, login B, `auth list` shows both with B - active. `auth use 0xA…` flips active without re-signing. - -Estimated effort: 1–2 days. - -#### Phase 4 — Atlantic integration, docs, migration guide - -Goal: cement the contract that "one login = forever Atlantic access" and ship docs. - -Tasks: - -- Verify origin R7 end-to-end: empty session + valid `apiKey` in store → - Atlantic calls succeed; `auth.refresh` not called; no 401. -- README rewrite under hcloud framing. -- `docs/guides/migration-from-atlantic-sdk.md`: - - `npm i @herodotus_dev/hcloud` (replaces `@herodotus_dev/atlantic-sdk`). - - Imports: `import { AtlanticClient } from '@herodotus_dev/atlantic-sdk'` - → `import { HcloudClient } from '@herodotus_dev/hcloud'; const c = new HcloudClient(...).atlantic;`. - - Binary: `atlantic ` → `hcloud atlantic `. - - Env: `ATLANTIC_API_KEY` → `HCLOUD_API_KEY` (still read for one release with deprecation warning). -- `docs/guides/adding-a-service.md`: walkthrough of creating - `src/services//{client,types,cli,index}.ts` and registering it. -- Final deprecated release of `@herodotus_dev/atlantic-sdk` (optional, manual) - with `package.json#deprecated` notice pointing to `hcloud`. -- Repo rename on GitHub: rename `atlantic-sdk` → `hcloud`. Update all - internal links in README and docs. - -Success criteria: - -- A user following migration guide takes < 5 minutes to migrate a known - consumer of `atlantic-sdk`. -- README shows both auth-driven and explicit-key paths. - -Estimated effort: 1 day. - -#### Phase 5 — Service-extension contract (docs only) - -Goal: lock in the multi-service contract without shipping placeholders. - -Tasks: - -- `docs/guides/adding-a-service.md` covers: new folder layout, `XService` class, - `XCli` registration, `core/config.ts` base URL entry, `HcloudSurface` enum - extension, exporting from `src/index.ts`. -- Add a deliberately-failing test under `test/multi-service.test.ts` that - asserts the service registry is the only edit point (smoke against an - in-test fake `EchoService`). - -Success criteria: - -- `EchoService` test demonstrates registration in < 30 LOC and zero edits to - `core/`, `auth/`, or other services. - -Estimated effort: 0.5 day. - ---- - -## Alternative Approaches Considered - -- **Separate per-service clients (`new AtlanticClient`, `new StorageProofClient`).** - Rejected in brainstorm. Would force callers to hand-thread session state, and - the CLI structure would not mirror it. (origin: Key Decisions §1) -- **Subpath imports per service (`@herodotus_dev/hcloud/atlantic`).** Rejected - in brainstorm. Same reason — auth becomes parameter plumbing rather than - shared state. (origin: Key Decisions §1) -- **Keep `atlantic-sdk` as a thin shim that re-exports from `hcloud`.** Rejected: - brainstorm chose hard deprecation. Migration is one import change; carrying - cost of a shim is not justified. -- **Use OS keychain (libsecret / Keychain Access) for credential storage.** - Considered. Rejected for v1: cross-platform consistency cost > value, and - flat-file with mode 600 matches what `gcloud`, `aws`, `gh` do. Re-revisit if - enterprise users ask. -- **Auto-trigger interactive login on 401.** Rejected. SDK is used in headless - agents and servers; surprise-signing is the wrong default. Throw - `not_authenticated`/`session_expired` and let the caller choose. - ---- - -## System-Wide Impact - -### Interaction Graph - -`client.atlantic.submitQuery(input)` → -`AtlanticService.submitQuery` → `HttpClient.request({ surface: 'atlantic', authMode: 'api-key' })` → -`AuthHeaderProvider.apiKeyHeader()` → reads `CredentialStore.activeWallet()` → -attaches `api-key` header → `fetch` → response → optional x402 challenge handling -(unchanged from today). - -`client.auth.apiKeys.create(...)` → -`ApiKeysClient.create` → `HttpClient.request({ surface: 'auth-billing', authMode: 'bearer' })` → -`AuthHeaderProvider.bearerHeader()` → checks `expiresAt - 60s < now` → if yes, -`AuthService.refresh()` → `HttpClient.request(...)` → new pair → `CredentialStore.upsertWallet` → -returns header → outer request proceeds. - -`client.auth.login({ signer })` → -`fetchChallenge(wallet)` → `signer.signTypedData(challenge.eip712)` → -`submitSession({ ..., channel: 'bearer' })` → `apiKeys.list({ projectId })` → -`CredentialStore.upsertWallet(...)` → `setActive(wallet)`. - -### Error & Failure Propagation - -All HTTP errors funnel through `HttpClient` → `HcloudError`. New error kinds -with explicit handling: - -- `not_authenticated` (no creds at all) — actionable message includes - `hcloud auth login` and `HCLOUD_API_KEY`. -- `session_expired` — refresh failed; user must re-login. Do **not** retry. -- `signing_failed` — wraps signer exceptions with the offending wallet address. -- `channel_binding` — defensive; should never escape unit tests. - -x402 errors (`payment`, `payment_challenge`, `payment_settlement`) are -unchanged. - -### State Lifecycle Risks - -- **Concurrent CLI invocations writing to credentials.json.** Mitigation: - atomic write via `fs.rename`; the worst case is a lost write, not corruption. - No multi-line locking. -- **Refresh race.** Two parallel auth-billing requests both decide a refresh is - needed → second refresh invalidates first's `refreshToken`. Mitigation: - `AuthService` serializes refreshes through a single `Promise` field - (in-process). Cross-process races (CLI A and CLI B refreshing simultaneously) - fall back to the reactive 401 path. -- **Stale persisted API key.** If a key is deactivated server-side, Atlantic - returns 401. Mitigation: error message guides user to re-login or - `hcloud auth api-keys list` to inspect. -- **Old `atlantic-sdk` consumers.** They break on upgrade because we hard-deprecate. - Mitigation: clear migration guide; final atlantic-sdk publish carries deprecation - notice. - -### API Surface Parity - -- SDK and CLI must remain symmetrical. Every `client..` has a - `hcloud ` counterpart and vice versa. Enforce by - inspection during PR review; consider a generated parity table later. -- `auth login` is the _only_ CLI command without a 1:1 SDK match (the SDK - uses `client.auth.login({ signer })` rather than reading env). Documented - intentional difference. - -### Integration Test Scenarios - -1. **Cold start → login → atlantic.** Empty `~/.hcloud/`. `client.atlantic.health()` - throws `not_authenticated`. After `client.auth.login({ signer })`, same call - succeeds. Credential file exists at mode 600 with valid JSON. -2. **Persisted key only.** Pre-seed credentials with apiKey but no session. - `client.atlantic.submitQuery(...)` succeeds without contacting auth-billing. - `client.auth.apiKeys.list()` triggers refresh attempt; on absent refresh - token, throws `session_expired`. Atlantic call after that still works. -3. **Multi-wallet.** Login wallet A, login wallet B → `active = B`. - `client.atlantic.health()` uses B's apiKey (assert via header inspection). - `client.auth.use(A)` → next call uses A's apiKey. No re-signing occurred. -4. **Env override.** `HCLOUD_API_KEY=abc` set; both A and B in store. - Atlantic call uses `abc`, ignoring active wallet. `client.auth.use(A)` is - a no-op for outgoing service requests but still updates the file. -5. **Channel-binding violation.** Inject a custom header `Cookie: session=…` - into a bearer request → `HcloudError({ kind: 'channel_binding' })` thrown - pre-flight. -6. **Refresh storm.** 10 parallel `auth.apiKeys.list()` calls with a - near-expiry token result in exactly one refresh request (in-process - serialization). - ---- - -## Acceptance Criteria - -### Functional - -- [ ] **R1** `new HcloudClient()` exposes `.auth` and `.atlantic`. Same operation name on different services is unambiguous via namespace. -- [ ] **R2** Source layout matches the target tree above. No file at `src/client/`, `src/workflows/`, `src/x402/`, `src/types.ts`, `src/errors.ts`. -- [ ] **R3** CLI is `hcloud `. Atlantic command set preserved 1:1 under `hcloud atlantic`. Service groups visible in `hcloud --help`; commands in `hcloud --help`. -- [ ] **R4** `client.auth.login({ signer })` and `hcloud auth login` complete the EIP-712 → bearer → API-key flow against `auth-billing` and persist credentials. -- [ ] **R5** `client.auth.refresh` rotates bearer pair. Bearer tokens are never sent as cookies and vice versa (channel-binding test passes). -- [ ] **R6** `client.auth.apiKeys.{list,create,activate,deactivate}` and `hcloud auth api-keys ` work end-to-end. `projectId` defaults to `selectedProject` from active wallet. -- [ ] **R7** Once an API key is persisted, _all_ `client.atlantic.*` calls work indefinitely with no re-login. No `auth-billing` requests are made on Atlantic-only workflows. -- [ ] **R8** `HcloudSigner` interface is documented; `privateKeySigner(hex)` is the default; KMS / ethers / browser signers can implement the interface. -- [ ] **R9** Existing Atlantic tests (`client.test.ts`, `workflows.test.ts`, `x402.test.ts`, `multipart.test.ts`) pass with import-path updates only. -- [ ] **R10** `package.json#name` = `@herodotus_dev/hcloud`; `package.json#bin.hcloud` set; repo renamed to `hcloud` on GitHub; README/migration guide updated. - -### Non-Functional - -- [ ] No new runtime dependencies beyond what's already in the SDK (viem is reused for the default signer). -- [ ] Credential file is mode 600 on disk (verified by test). -- [ ] Atomic write: simulated crash mid-write does not corrupt `credentials.json` (verified by test that hard-kills mid-write). -- [ ] Token never logged. Masked in CLI output by default. - -### Quality Gates - -- [ ] `bun test` green (existing + new auth + new multi-service tests). -- [ ] `bun run check` green (typecheck). -- [ ] `bun run format:check` clean. -- [ ] Migration guide tested by hand against a sample `atlantic-sdk` consumer. - ---- - -## Success Metrics - -- A new user goes from `git clone` of an empty project to a successful - `client.atlantic.submitQuery` in **one terminal session** with no browser. -- A new service can be added in **< 1 day** with zero edits to `auth/` or - `services/atlantic/` (proven by Phase 5 `EchoService` test). -- Existing `atlantic-sdk` consumers migrate in a single PR with import-path - changes only. - ---- - -## Dependencies & Prerequisites - -- `auth-billing` endpoints (`/auth/web3/challenge`, `/auth/web3/session`, - `/auth/refresh-token`, `/api-keys`) stable and matching the contract in - `ai-skills/.../herodotus-auth/SKILL.md`. -- npm scope `@herodotus_dev` available for `hcloud` (assumed; confirm before publish). -- GitHub permission to rename the repository. -- viem already a dependency; no new packages. - ---- - -## Risk Analysis & Mitigation - -- **Risk:** `auth-billing` schema drift between brainstorm date and shipping. - _Mitigation:_ contract tests against a fixture recorded from the running - service; fail fast if shape changes. -- **Risk:** Hard deprecation breaks downstream users immediately. - _Mitigation:_ migration guide, deprecation notice on final `atlantic-sdk` - release, announcement in repo README at rename time. -- **Risk:** Credential file leakage. - _Mitigation:_ mode 600, mask keys in default CLI output, never log secrets, - document storage location prominently in README. -- **Risk:** Refresh races between CLI and SDK on the same machine. - _Mitigation:_ documented; reactive 401 retry catches the rare loss; advisory - lock deferred until needed. -- **Risk:** Channel-binding bug accidentally sending bearer as cookie. - _Mitigation:_ defensive check in `HttpClient`; integration test covers it. -- **Risk:** Rename breaks open PRs / forks. - _Mitigation:_ GitHub repo redirect handles old URLs; one-time PR flush - before rename. - ---- - -## Future Considerations - -- **Storage-proof + data-processor + data-structure-indexer + satellite** - services. Each follows the `services//` template. -- **OS keychain credential store** as an opt-in alternative to file-store. -- **Browser bundle** (currently Bun-first; enabling browser would require - splitting node-only modules like `fs`). -- **x402 generalization** if a second service adopts the protocol — promote - `services/atlantic/x402/` to `core/x402/`. -- **Org-shared credentials** (CI runners, agents) — separate concern; needs a - remote credential store implementing `CredentialStore`. - ---- - -## Documentation Plan - -- `README.md` — full rewrite under hcloud framing. -- `docs/guides/migration-from-atlantic-sdk.md` — new. -- `docs/guides/adding-a-service.md` — new (locks in the contract). -- `docs/guides/signers.md` — new (ethers/KMS/browser signer recipes). -- `docs/guides/credentials.md` — new (storage location, env vars, - custom `CredentialStore`). -- Existing examples under `examples/` updated to new import paths and to show - `auth login` as the first step. - ---- - -## Sources & References - -### Origin - -- **Origin document:** [docs/brainstorms/2026-05-07-hcloud-multi-service-sdk-requirements.md](../brainstorms/2026-05-07-hcloud-multi-service-sdk-requirements.md). Key decisions carried forward: - - Single `HcloudClient` with namespaced services, mirrored CLI `hcloud `. - - Hard-deprecate `@herodotus_dev/atlantic-sdk`; final name `@herodotus_dev/hcloud`. - - Credential storage: `~/.hcloud/credentials.json`, mode 600, keyed by wallet, env overlay. - - API key is the durable credential; bearer session is ephemeral. One login = forever Atlantic. - - Multi-wallet: last-login-wins with `auth list` / `auth use ` for explicit switching. - -### Internal References - -- Current Atlantic client (to be moved): `src/client/atlantic-client.ts:32`. -- Current HTTP layer (to be promoted to `core/`): `src/client/http.ts:19`. -- Current error types (to be renamed): `src/errors.ts:20`. -- Current CLI dispatcher (to be replaced by per-service registry): `src/cli/commands.ts:1`. -- Existing scaffold tests anchoring the public surface: `test/scaffold.test.ts:1`. -- Prior origin plan for context: `docs/plans/2026-05-06-001-feat-atlantic-bun-sdk-plan.md`. - -### Reference Implementation - -- `auth.ts` (137 LOC, full EIP-712 → bearer → api-key flow): `../ai-skills/plugins/herodotus-skills/skills/herodotus-auth/scripts/auth.ts:55`. -- `get-api-key.ts` (api-key list/read): `../ai-skills/plugins/herodotus-skills/skills/herodotus-auth/scripts/get-api-key.ts:25`. -- `refresh.ts` (bearer rotation): `../ai-skills/plugins/herodotus-skills/skills/herodotus-auth/scripts/refresh.ts:31`. -- Skill documentation: `../ai-skills/plugins/herodotus-skills/skills/herodotus-auth/SKILL.md`. - -### Backend Contract - -- Web3 challenge route: `../auth-billing/src/routes/auth/web3/challenge.ts`. -- Session issuer (channel binding): `../auth-billing/src/routes/auth/web3/session-issuer.ts`. -- EIP-712 typed-data builder: `../auth-billing/src/routes/auth/web3/eip712.ts`. -- Refresh: `../auth-billing/src/routes/auth/refresh.ts`. -- API keys: `../auth-billing/src/routes/api-keys/{create,get,activate,deactivate}.ts`.