@cortexkit/anthropic-auth-core 2.2.7 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -49,6 +49,7 @@ export interface NativeAccountRuntime {
49
49
  readonly onLockEvent?: (event: NativeLockEvent) => void;
50
50
  readonly vault: NativeVaultRuntime;
51
51
  readonly quotaObservations: NativeQuotaObservations;
52
+ /** Waits for pending quota observations, quota submissions, and runtime metadata writes. An accepted pool quota update may not yet be on disk; close() drains the pool's pending quota writes. */
52
53
  drainQuotaPublications(): Promise<void>;
53
54
  drainRoutingPublications(): Promise<void>;
54
55
  /** Fresh private relay authorization for transport only; never include it in a status snapshot. */
@@ -1,5 +1,7 @@
1
1
  import { enrollmentAuthority, readClaustrumEnrollmentStatus } from "./internal-types/claustrum/index.js";
2
2
  import { NativeCustodyError } from './native-custody.ts';
3
+ /** Test-only reset for the once-per-process enrollment mode log. */
4
+ export declare function __resetEnrollmentModeLogForTests(): void;
3
5
  /**
4
6
  * Shown instead of `Native custody refused: invalid-token` when the
5
7
  * enrollment files could not be read because the private-files backend did
@@ -9,11 +9,11 @@ export declare function nativeRuntimeStorageKey(options: {
9
9
  export declare function nativeRuntimeConnectionFile(host: 'opencode' | 'pi', env: NodeJS.ProcessEnv): string;
10
10
  /** A release applies to the runtime entry acquired earlier, not to a later entry created with the same key. */
11
11
  export declare class NativeRuntimeRegistry<T extends {
12
- close(): void;
12
+ close(): void | Promise<void>;
13
13
  }> {
14
14
  private readonly entries;
15
15
  acquire(key: string, create: () => T): {
16
16
  use: () => T;
17
- close: () => void;
17
+ close: () => void | Promise<void>;
18
18
  };
19
19
  }
@@ -116,7 +116,7 @@ export interface NativeVaultRuntime {
116
116
  resetBackoff(receipt: NativeCustodyReceipt, kind?: 'refresh' | 'quota' | 'all'): Promise<boolean>;
117
117
  getOrCreateAuthLineage(receipt: NativeCustodyReceipt): Promise<string | undefined>;
118
118
  incrementPrimeUsage(receipt: NativeCustodyReceipt, delta?: PrimeUsageDelta): Promise<boolean>;
119
- close(): void;
119
+ close(): void | Promise<void>;
120
120
  }
121
121
  /** Publish the initial roster from validated account inventory. A caller importing captured state must fence that state through assertOwned, which runs again at the roster's actual publication. */
122
122
  export declare function publishNativeVaultRosterSeed(paths: Pick<NativeVaultPaths, 'roster'>, input: NativeRosterSeedInput, options?: {
@@ -136,4 +136,4 @@ export declare function nativeVaultRuntimeKey(options: NativeVaultRuntimeOptions
136
136
  /** Each handle owns one reference; disposing it cannot close another host session. */
137
137
  export declare function acquireNativeVaultRuntime(options: NativeVaultRuntimeOptions): NativeVaultRuntime;
138
138
  /** Each account lease wraps the shared vault runtime and tracks its own subscriptions, so releasing the lease removes those subscriptions. */
139
- export declare function leaseNativeVaultRuntime(use: () => NativeVaultRuntime, close: () => void): NativeVaultRuntime;
139
+ export declare function leaseNativeVaultRuntime(use: () => NativeVaultRuntime, close: () => void | Promise<void>): NativeVaultRuntime;
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Where a Claude OAuth access token may be sent.
3
+ *
4
+ * A Claude sign-in (OAuth) access token is sent only to Anthropic's own HTTPS
5
+ * API server, unless a caller explicitly allows one test-only loopback origin
6
+ * (see `extraOrigin`). Hosts take the request URL from provider configuration,
7
+ * and that configuration can come from a project file in a repository the
8
+ * user opened, so the URL alone is not trusted: every OAuth send checks its
9
+ * final URL here before the token leaves the process. API-key routes are not
10
+ * checked here; they keep whatever base URL the user configured.
11
+ */
12
+ export declare const ANTHROPIC_OAUTH_ORIGIN = "https://api.anthropic.com";
13
+ /** Fixed refusal text. It never includes the refused URL or any credential. */
14
+ export declare const ANTHROPIC_OAUTH_ORIGIN_REFUSAL = "Claude OAuth requests can only go to https://api.anthropic.com. A provider baseURL that points elsewhere is not used for Claude sign-in accounts.";
15
+ /**
16
+ * A Claude OAuth request was refused locally because its destination is not
17
+ * Anthropic's API server. Nothing was sent, and another account cannot help,
18
+ * so callers treat it as a terminal request error, not an account failure.
19
+ */
20
+ export declare class AnthropicOAuthOriginError extends Error {
21
+ readonly check = "oauth_destination";
22
+ constructor();
23
+ }
24
+ export interface AnthropicOAuthOriginOptions {
25
+ /**
26
+ * One more exact origin (scheme, host and port) the caller accepts besides
27
+ * Anthropic's. Hosts use it only for a test-only loopback server; it is
28
+ * never derived from provider configuration.
29
+ */
30
+ readonly extraOrigin?: string;
31
+ /**
32
+ * Builds the error thrown for a refused URL. `unparseable` means the value
33
+ * is not a URL at all. Defaults to AnthropicOAuthOriginError for both.
34
+ */
35
+ readonly refusal?: (reason: 'unparseable' | 'destination') => Error;
36
+ }
37
+ /**
38
+ * Returns the parsed URL when an OAuth token may be sent to it: HTTPS on
39
+ * api.anthropic.com's default port (or the caller's extra origin), with no
40
+ * user name or password in the URL. Throws otherwise.
41
+ */
42
+ export declare function requireAnthropicOAuthOrigin(value: string, options?: AnthropicOAuthOriginOptions): URL;
43
+ /**
44
+ * A fetch for OAuth sends. It checks each URL with
45
+ * requireAnthropicOAuthOrigin before dispatch and always sets
46
+ * `redirect: 'error'`, so an approved destination cannot forward the token
47
+ * elsewhere with a redirect. It calls the global fetch at call time, so test
48
+ * stubs installed later still apply. Options are read on every call when
49
+ * given as a function.
50
+ */
51
+ export declare function createAnthropicOAuthFetch(options?: AnthropicOAuthOriginOptions | (() => AnthropicOAuthOriginOptions)): typeof fetch;
package/dist/relay.d.ts CHANGED
@@ -76,5 +76,11 @@ export declare function sendViaRelay(options: {
76
76
  onDumpCreated?: (handle: import('./dump.ts').DumpHandle) => void;
77
77
  setTimeoutImpl?: typeof globalThis.setTimeout;
78
78
  clearTimeoutImpl?: typeof globalThis.clearTimeout;
79
+ /**
80
+ * Checks the URL the relay will forward to (the envelope's upstream.url)
81
+ * and throws to refuse it. Runs before anything is sent, relay or direct;
82
+ * its error reaches the caller unchanged and never triggers a fallback.
83
+ */
84
+ requireUpstreamUrl?: (url: string) => unknown;
79
85
  }): Promise<Response>;
80
86
  export declare const WORKER_SCRIPT = "\nasync function hashBody(body) {\n const bytes = new TextEncoder().encode(body)\n const digest = await crypto.subtle.digest('SHA-256', bytes)\n const hex = [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('')\n return 'sha256:' + hex\n}\n\nfunction applyPatch(base, patch) {\n return base.slice(0, patch.start) + patch.insert + base.slice(patch.start + patch.deleteCount)\n}\n\nfunction applyPatchSet(base, patch) {\n if (!Array.isArray(patch)) return applyPatch(base, patch)\n if (patch.length === 0) return base\n\n let result = ''\n let cursor = 0\n for (const item of patch) {\n if (item.start < cursor) return { error: 'overlapping patch', status: 400 }\n result += base.slice(cursor, item.start)\n result += item.insert\n cursor = item.start + item.deleteCount\n }\n return result + base.slice(cursor)\n}\n\nasync function readState(env, affinity) {\n const raw = await env.RELAY_STATE.get('session:' + affinity)\n return raw ? JSON.parse(raw) : null\n}\n\nasync function writeState(env, affinity, state) {\n await env.RELAY_STATE.put('session:' + affinity, JSON.stringify(state), { expirationTtl: 86400 })\n}\n\nasync function resolveBody(env, payload) {\n if (payload.mode === 'full_sync') return payload.body\n if (payload.mode === 'patch') {\n const state = await readState(env, payload.affinity)\n if (!state || state.hash !== payload.base_hash) {\n return { error: 'state mismatch', status: 409 }\n }\n return applyPatchSet(state.body, payload.patch)\n }\n return { error: 'unknown mode', status: 400 }\n}\n\nasync function prepareUpstream(env, payload) {\n if ((payload.protocol !== 1 && payload.protocol !== 2) || payload.type !== 'request' || !payload.affinity || !payload.upstream?.url || !payload.next_hash) {\n return { error: 'invalid payload', status: 400 }\n }\n\n const body = await resolveBody(env, payload)\n if (body && typeof body === 'object' && 'error' in body) return body\n\n if (typeof body !== 'string' || (await hashBody(body)) !== payload.next_hash) {\n return { error: 'hash mismatch', status: 409 }\n }\n\n const stateWrite = writeState(env, payload.affinity, { body, hash: payload.next_hash, revision: payload.revision }).catch(() => {})\n console.log(JSON.stringify({\n relay: 'opencode-anthropic-auth',\n transport: 'relay',\n mode: payload.mode,\n revision: payload.revision,\n affinity: String(payload.affinity).slice(0, 12),\n bodyBytes: body.length,\n }))\n\n return { body, stateWrite }\n}\n\nfunction resolveBodyFromState(state, payload) {\n if (payload.mode === 'full_sync') return payload.body\n if (payload.mode === 'patch') {\n if (!state || state.hash !== payload.base_hash) {\n return { error: 'state mismatch', status: 409 }\n }\n return applyPatchSet(state.body, payload.patch)\n }\n return { error: 'unknown mode', status: 400 }\n}\n\nfunction deferWorkerTask(task) {\n return new Promise((resolve) => setTimeout(resolve, 0)).then(task)\n}\n\nconst WEBSOCKET_STREAM_FLUSH_BYTES = 1024\nconst WEBSOCKET_STREAM_FLUSH_MS = 20\n\nfunction createWebSocketStreamCoalescer(socket, options = {}) {\n const flushBytes = options.flushBytes || WEBSOCKET_STREAM_FLUSH_BYTES\n const flushMs = options.flushMs ?? WEBSOCKET_STREAM_FLUSH_MS\n let chunks = []\n let byteLength = 0\n let flushTimer = null\n const stats = {\n upstreamChunks: 0,\n upstreamBytes: 0,\n frames: 0,\n frameBytes: 0,\n }\n\n const clearFlushTimer = () => {\n if (flushTimer === null) return\n clearTimeout(flushTimer)\n flushTimer = null\n }\n\n const mergeChunks = () => {\n if (chunks.length === 1) return chunks[0]\n const frame = new Uint8Array(byteLength)\n let offset = 0\n for (const chunk of chunks) {\n frame.set(chunk, offset)\n offset += chunk.byteLength\n }\n return frame\n }\n\n const flush = () => {\n clearFlushTimer()\n if (byteLength === 0) return\n const frame = mergeChunks()\n chunks = []\n byteLength = 0\n socket.send(frame)\n stats.frames += 1\n stats.frameBytes += frame.byteLength\n }\n\n const scheduleFlush = () => {\n if (flushTimer !== null) return\n flushTimer = setTimeout(flush, flushMs)\n }\n\n return {\n push(value) {\n const chunk = value instanceof Uint8Array ? value : new Uint8Array(value)\n if (chunk.byteLength === 0) return\n stats.upstreamChunks += 1\n stats.upstreamBytes += chunk.byteLength\n chunks.push(chunk)\n byteLength += chunk.byteLength\n if (byteLength >= flushBytes) flush()\n else scheduleFlush()\n },\n flush,\n stats() {\n return { ...stats, pendingBytes: byteLength }\n },\n }\n}\n\nfunction checkpointWebSocketState(env, payload, body, nextState) {\n return deferWorkerTask(async () => {\n try {\n if ((await hashBody(body)) !== payload.next_hash) {\n throw new Error('hash mismatch')\n }\n await writeState(env, payload.affinity, nextState)\n } catch {\n console.log(JSON.stringify({\n relay: 'opencode-anthropic-auth',\n transport: 'websocket',\n event: 'checkpoint_write_failed',\n affinity: String(payload.affinity).slice(0, 12),\n code: 'checkpoint_write_error',\n }))\n }\n })\n}\n\nasync function prepareWebSocketUpstream(env, state, payload) {\n if (payload.protocol !== 2 || payload.type !== 'request' || !payload.id || !payload.affinity || !payload.upstream?.url || !payload.next_hash) {\n return { error: 'invalid payload', status: 400 }\n }\n\n const body = resolveBodyFromState(state, payload)\n if (body && typeof body === 'object' && 'error' in body) return body\n if (typeof body !== 'string') return { error: 'invalid body', status: 400 }\n if ((await hashBody(body)) !== payload.next_hash) {\n return { error: 'hash mismatch', status: 409 }\n }\n\n const nextState = { body, hash: payload.next_hash, revision: payload.revision }\n const checkpoint = checkpointWebSocketState(env, payload, body, nextState)\n const logAccepted = () => console.log(JSON.stringify({\n relay: 'opencode-anthropic-auth',\n transport: 'websocket',\n mode: payload.mode,\n revision: payload.revision,\n affinity: String(payload.affinity).slice(0, 12),\n bodyBytes: body.length,\n }))\n\n return { body, state: nextState, checkpoint, logAccepted }\n}\n\nfunction headersToObject(headers) {\n const result = {}\n for (const [key, value] of headers.entries()) result[key] = value\n return result\n}\n\nasync function handleRelayPayload(env, payload) {\n const prepared = await prepareUpstream(env, payload)\n if (prepared.error) return prepared\n const upstream = await fetch(payload.upstream.url, {\n method: payload.upstream.method || 'POST',\n headers: payload.upstream.headers,\n body: prepared.body,\n })\n return { upstream, stateWrite: prepared.stateWrite }\n}\n\nasync function handleWebSocket(socket, env, ctx, payload, getState, setState) {\n const heartbeat = setInterval(() => {\n try {\n socket.send(JSON.stringify({ protocol: 2, type: 'keepalive' }))\n } catch {\n clearInterval(heartbeat)\n }\n }, 15000)\n let streamCoalescer = null\n try {\n const result = await prepareWebSocketUpstream(env, getState(), payload)\n if (result.error) {\n socket.send(JSON.stringify({ protocol: 2, type: 'error', id: payload.id, status: result.status, message: result.error }))\n return\n }\n\n setState(result.state)\n socket.send(JSON.stringify({ protocol: 2, type: 'accepted', id: payload.id, hash: result.state.hash, revision: result.state.revision }))\n ctx?.waitUntil?.(deferWorkerTask(result.logAccepted))\n\n const upstreamPromise = fetch(payload.upstream.url, {\n method: payload.upstream.method || 'POST',\n headers: payload.upstream.headers,\n body: result.body,\n })\n ctx?.waitUntil?.(result.checkpoint)\n const upstream = await upstreamPromise\n socket.send(JSON.stringify({\n protocol: 2,\n type: 'response_start',\n id: payload.id,\n status: upstream.status,\n statusText: upstream.statusText,\n headers: headersToObject(upstream.headers),\n }))\n\n const reader = upstream.body?.getReader()\n if (reader) {\n streamCoalescer = createWebSocketStreamCoalescer(socket)\n while (true) {\n const { done, value } = await reader.read()\n if (done) break\n streamCoalescer.push(value)\n }\n streamCoalescer.flush()\n }\n const stats = streamCoalescer?.stats?.() || {}\n socket.send(JSON.stringify({ protocol: 2, type: 'done', id: payload.id, ...stats }))\n } catch (error) {\n try {\n streamCoalescer?.flush?.()\n } catch {}\n socket.send(JSON.stringify({ protocol: 2, type: 'error', id: payload.id, status: 500, message: error instanceof Error ? error.message : String(error) }))\n } finally {\n clearInterval(heartbeat)\n }\n}\n\nexport default {\n async fetch(request, env, ctx) {\n if (request.headers.get('Upgrade') === 'websocket') {\n const url = new URL(request.url)\n const token = url.searchParams.get('token')\n const affinity = url.searchParams.get('affinity')\n if (token !== env.RELAY_TOKEN) return new Response('unauthorized', { status: 401 })\n if (!affinity) return new Response('missing affinity', { status: 400 })\n const pair = new WebSocketPair()\n const client = pair[0]\n const server = pair[1]\n server.binaryType = 'arraybuffer'\n let state = null\n let ready = false\n server.accept()\n const loadState = readState(env, affinity).then((loadedState) => {\n state = loadedState\n ready = true\n server.send(JSON.stringify({\n protocol: 2,\n type: 'ready',\n state: state ? { hash: state.hash, revision: state.revision } : null,\n }))\n }).catch((error) => {\n server.send(JSON.stringify({ protocol: 2, type: 'error', status: 500, message: error instanceof Error ? error.message : String(error) }))\n server.close(1011, 'state load failed')\n })\n ctx?.waitUntil?.(loadState)\n if (!ctx?.waitUntil) void loadState\n let busy = false\n server.addEventListener('message', (event) => {\n if (!ready) {\n server.send(JSON.stringify({ protocol: 2, type: 'error', status: 425, message: 'relay state is not ready' }))\n return\n }\n if (busy) {\n server.send(JSON.stringify({ protocol: 2, type: 'error', status: 429, message: 'request already in flight' }))\n return\n }\n let payload\n try {\n payload = JSON.parse(event.data)\n } catch {\n server.send(JSON.stringify({ protocol: 2, type: 'error', status: 400, message: 'invalid JSON payload' }))\n return\n }\n payload.affinity = affinity\n busy = true\n const run = handleWebSocket(server, env, ctx, payload, () => state, (nextState) => { state = nextState }).finally(() => { busy = false })\n ctx?.waitUntil?.(run)\n if (!ctx?.waitUntil) void run\n })\n return new Response(null, { status: 101, webSocket: client })\n }\n\n if (request.method === 'GET') {\n return Response.json({ status: 'ok', transports: ['http', 'websocket'] })\n }\n if (request.method !== 'POST') return new Response('method not allowed', { status: 405 })\n if (request.headers.get('x-relay-token') !== env.RELAY_TOKEN) {\n return new Response('unauthorized', { status: 401 })\n }\n\n try {\n const payload = await request.json()\n const result = await handleRelayPayload(env, payload)\n if (result.error) return Response.json({ error: result.error }, { status: result.status })\n\n if (result.stateWrite) ctx.waitUntil(result.stateWrite)\n\n const upstream = result.upstream\n return new Response(upstream.body, {\n status: upstream.status,\n statusText: upstream.statusText,\n headers: upstream.headers,\n })\n } catch (error) {\n return Response.json(\n { error: error instanceof Error ? error.message : 'internal relay error' },\n { status: 502 },\n )\n }\n },\n}\n";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cortexkit/anthropic-auth-core",
3
- "version": "2.2.7",
3
+ "version": "2.3.0",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
@@ -30,7 +30,7 @@
30
30
  "prepublishOnly": "bun run build"
31
31
  },
32
32
  "devDependencies": {
33
- "@cortexkit/common-auth": "0.25.0"
33
+ "@cortexkit/common-auth": "0.27.1"
34
34
  },
35
35
  "dependencies": {
36
36
  "@cortexkit/claustrum-client": "0.12.0",