diff --git a/README.md b/README.md index 15db85f..b0c8326 100644 --- a/README.md +++ b/README.md @@ -1,38 +1,81 @@ -# 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`. -- 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). +- `client.auth.*` — programmatic wallet authentication (EIP-712 → bearer session → API key). ## 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 +``` + +## 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 -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 +86,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 +106,53 @@ console.log(details.atlanticQuery.status, details.metadataUrls); ## CLI quickstart ```bash -ATLANTIC_API_KEY=... bunx 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 ``` -Every CLI command prints JSON so agents and shell scripts can chain outputs without scraping prose. +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 +``` -Common commands: +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 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 +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 +``` -### Client +## Atlantic SDK reference + +### 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 +160,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 +168,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 +213,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 +242,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/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/cli.ts b/src/auth/cli.ts new file mode 100644 index 0000000..9808d91 --- /dev/null +++ b/src/auth/cli.ts @@ -0,0 +1,155 @@ +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/auth/index.ts b/src/auth/index.ts new file mode 100644 index 0000000..409d1a8 --- /dev/null +++ b/src/auth/index.ts @@ -0,0 +1,270 @@ +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/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..b0eb26d 100644 --- a/src/cli/config.ts +++ b/src/cli/config.ts @@ -1,20 +1,18 @@ -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..a4b3b79 --- /dev/null +++ b/src/cli/dispatcher.ts @@ -0,0 +1,116 @@ +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[] = [authCli, 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; + } + + // 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} ${resolvedCommand}` }, + commands: Object.keys(service.commands).sort(), + }); + return 1; + } + + const { flags, positionals } = parseArgs(restAfterCommand); + 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..cdc452c --- /dev/null +++ b/src/hcloud-client.ts @@ -0,0 +1,67 @@ +import { resolveCoreConfig, type FetchLike, type HcloudCoreConfig } from './core/config'; +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. Wins over env vars and persisted credentials. */ + apiKey?: string; + /** Override fetch implementation. */ + fetch?: FetchLike; + /** x402 payment adapter forwarded to the Atlantic service. */ + paymentAdapter?: X402PaymentAdapter; + /** 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.*` 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 = 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 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 8f3d602..3aa8b1f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,12 +1,103 @@ -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'; + +// 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, + 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..188d92e 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,139 @@ 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..a2a85cc --- /dev/null +++ b/src/services/atlantic/index.ts @@ -0,0 +1,31 @@ +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/auth-cli.test.ts b/test/auth-cli.test.ts new file mode 100644 index 0000000..8a7cbe0 --- /dev/null +++ b/test/auth-cli.test.ts @@ -0,0 +1,164 @@ +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'); + }); +}); diff --git a/test/auth-service.test.ts b/test/auth-service.test.ts new file mode 100644 index 0000000..d602039 --- /dev/null +++ b/test/auth-service.test.ts @@ -0,0 +1,336 @@ +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' }); + }); +}); 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..b8be70d 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,31 @@ 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/multi-service.test.ts b/test/multi-service.test.ts new file mode 100644 index 0000000..366972c --- /dev/null +++ b/test/multi-service.test.ts @@ -0,0 +1,112 @@ +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'); + }); +}); 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); }); });