@cynodia/axiom-server 0.7.0-alpha.2 → 0.8.0-alpha.1

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.
package/README.md CHANGED
@@ -14,13 +14,24 @@ A client requests semantic actions; it never sends operations. Authorization, gu
14
14
  argument validation are evaluated here and nowhere else.
15
15
 
16
16
  Adapters keep the semantics free of infrastructure: `PersistenceAdapter` (in-memory and
17
- SQLite), `TransportAdapter` (in-process and HTTP), and `ServerHost` for time, identifiers
18
- and authentication. Nothing in an ApplicationGraph mentions HTTP, SQL or a route.
17
+ SQLite), `TransportAdapter` (in-process and HTTP), `ServerHost` for time, identifiers,
18
+ scheduling and authentication, and `IntegrationAdapter` for external systems. Nothing in
19
+ an ApplicationGraph mentions HTTP, SQL, a route or an SDK.
20
+
21
+ As of 0.8 this package also owns timed and event-driven execution: it schedules and
22
+ dispatches `TriggerDef`s, records `integration-effect` intent atomically with the state
23
+ write that requested it (the transactional outbox), dispatches an effect's committed
24
+ intents to its `IntegrationAdapter` post-commit with retry, and translates verified
25
+ webhook deliveries into semantic events. See
26
+ [`docs/INTEGRATIONS.md`](https://github.com/cynodia/axiom/blob/main/docs/INTEGRATIONS.md),
27
+ [`docs/EFFECTS.md`](https://github.com/cynodia/axiom/blob/main/docs/EFFECTS.md) and
28
+ [`docs/TRIGGERS.md`](https://github.com/cynodia/axiom/blob/main/docs/TRIGGERS.md).
19
29
 
20
30
  Main exports: `createAxiomServer`, `createMemoryPersistence`, `createSqlitePersistence`,
21
31
  `createServerHost`, `createDeterministicServerHost`, `createDirectTransport`,
22
32
  `createHttpTransport`, `createRemoteGateway`, `serveOverHttp`, `serveAxiomApplication`,
23
- `SERVER_DIAGNOSTIC_CODES`.
33
+ `createFakeIntegrationAdapter`, `createHttpIntegrationAdapter`, `createTriggerRuntime`,
34
+ `createEffectRunner`, `SERVER_DIAGNOSTIC_CODES`.
24
35
 
25
36
  `serveAxiomApplication` is the whole deployment story: it serves the generated page at `GET /`
26
37
  and the semantic endpoint at `POST /axiom`, from one process, for any Axiom application. No
@@ -34,7 +45,9 @@ implementation in another language needs to conform to it:
34
45
  ```
35
46
  @cynodia/axiom-server/conformance the fixture manifest
36
47
  @cynodia/axiom-server/conformance/<name>.json one fixture: IR, state, invocations, expectations
37
- @cynodia/axiom-server/schema/server-ir.v1.schema.json JSON Schema for the IR
48
+ @cynodia/axiom-server/schema/server-ir.v1.schema.json JSON Schema for the frozen v1 IR
49
+ @cynodia/axiom-server/schema/server-ir.v2.schema.json JSON Schema for v2 (+ group, expression-ref)
50
+ @cynodia/axiom-server/schema/server-ir.v3.schema.json JSON Schema for v3 (+ integrations, effects, triggers, events)
38
51
  @cynodia/axiom-server/schema/protocol.v1.schema.json JSON Schema for the protocol
39
52
  ```
40
53
 
@@ -2,7 +2,7 @@
2
2
  "conformance": "axiom.conformance.v1",
3
3
  "contract": "axiom.server.v1",
4
4
  "protocol": "axiom.protocol.v1",
5
- "release": "0.7.0-alpha.2",
5
+ "release": "0.8.0-alpha.1",
6
6
  "description": "Portable conformance fixtures for the Axiom Server IR. Each entry is a self-contained JSON document: the Server IR, the state to start from, the principals, the invocations to perform and the results required. Running them needs no part of this implementation.",
7
7
  "areas": [
8
8
  "action guards",
package/dist/deps.d.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  * runtime explicit: it is the semantic engine and nothing else. No transport, no database
6
6
  * driver and no host API appears here.
7
7
  */
8
- export type { ActionDef, AnyNode, ApplicationIR, Authority, ConstraintDef, EntityDef, Expression, FieldDef, FieldId, LiteralValue, NodeId, ServerIR, StateDef, TransitionConstraintDef, TypeRef, } from '@cynodia/axiom-core';
9
- export type { ActionOutcome, ActionResult, RuntimeDiagnostic, RuntimeDiagnosticCode, } from '@cynodia/axiom-runtime';
8
+ export type { ActionDef, AnyNode, ApplicationIR, Authority, ConstraintDef, EntityDef, EventDef, Expression, FieldDef, FieldId, IntegrationDef, IntegrationOperationDef, LiteralValue, NodeId, ServerIR, StateDef, TransitionConstraintDef, TriggerDef, TriggerSpec, TypeRef, } from '@cynodia/axiom-core';
9
+ export type { ActionOutcome, ActionResult, EffectIntentRecord, RuntimeDiagnostic, RuntimeDiagnosticCode, } from '@cynodia/axiom-runtime';
10
10
  export { PRINCIPAL, RUNTIME_DIAGNOSTIC_CODES, SERVER_IR_CONTRACT } from './runtime-deps.js';
11
11
  //# sourceMappingURL=deps.d.ts.map
@@ -0,0 +1,31 @@
1
+ import type { IntegrationOperationDef, NodeId } from './deps.js';
2
+ import type { ServerHost } from './host.js';
3
+ import type { IntegrationAdapterRegistry } from './integration.js';
4
+ import type { EffectRecord, PersistenceAdapter } from './persistence.js';
5
+ export interface EffectRunnerOptions {
6
+ adapters: IntegrationAdapterRegistry;
7
+ integrationOperations: Record<NodeId, IntegrationOperationDef>;
8
+ persistence: PersistenceAdapter;
9
+ host: ServerHost;
10
+ /** Called once an effect reaches `succeeded` or `failed`, to dispatch its declared event. */
11
+ onTerminal(record: EffectRecord): Promise<void>;
12
+ report?(event: {
13
+ kind: 'effect-attempted' | 'effect-succeeded' | 'effect-failed';
14
+ effectId: string;
15
+ operationId: NodeId;
16
+ attempt: number;
17
+ }): void;
18
+ }
19
+ /**
20
+ * Dispatches committed effect intents to their integration adapter, post-commit and never
21
+ * awaited by the invoking request (spec §17,123). Delivery is at-least-once: a retry
22
+ * policy governs how many attempts a failed effect gets, and every attempt — including the
23
+ * first — updates durable status through `persistence.recordEffectAttempt`, so a restart
24
+ * resumes from wherever dispatch left off rather than losing the intent (spec §19,20,96).
25
+ */
26
+ export interface EffectRunner {
27
+ /** Dispatches freshly committed effect intents. Fire-and-forget from the caller's view. */
28
+ dispatch(records: EffectRecord[]): void;
29
+ }
30
+ export declare function createEffectRunner(options: EffectRunnerOptions): EffectRunner;
31
+ //# sourceMappingURL=effects.d.ts.map
@@ -0,0 +1,83 @@
1
+ function delay(host, ms) {
2
+ return new Promise((resolve) => {
3
+ host.scheduleOnce(ms, resolve);
4
+ });
5
+ }
6
+ export function createEffectRunner(options) {
7
+ const { adapters, integrationOperations, persistence, host, onTerminal, report } = options;
8
+ async function run(record) {
9
+ const operation = integrationOperations[record.operationId];
10
+ if (!operation) {
11
+ const failed = {
12
+ ...record,
13
+ status: 'failed',
14
+ lastError: { code: 'UNKNOWN_INTEGRATION_OPERATION', message: `No integration operation ${record.operationId}` },
15
+ };
16
+ await persistence.recordEffectAttempt?.(record.id, failed);
17
+ await onTerminal(failed);
18
+ return;
19
+ }
20
+ const adapter = adapters[operation.integrationId];
21
+ if (!adapter) {
22
+ const failed = {
23
+ ...record,
24
+ status: 'failed',
25
+ lastError: { code: 'INTEGRATION_ADAPTER_MISSING', message: `No adapter registered for ${operation.integrationId}` },
26
+ };
27
+ await persistence.recordEffectAttempt?.(record.id, failed);
28
+ await onTerminal(failed);
29
+ return;
30
+ }
31
+ const policy = operation.retry ?? { policy: 'none' };
32
+ const maxAttempts = policy.policy === 'none' ? 1 : (policy.maxAttempts ?? 3);
33
+ // Deliberately local to this dispatch, not seeded from `record.attempts`: a record
34
+ // found `'running'` at startup means a previous process called the adapter and was
35
+ // never told the outcome (spec §96) — that attempt is unaccounted for, not spent, so
36
+ // resuming it gets a full fresh budget rather than silently going idle at zero
37
+ // remaining attempts. `record.attempts` is still carried forward into what gets
38
+ // persisted, so the historical count across a restart stays honest.
39
+ let attempt = 0;
40
+ while (attempt < maxAttempts) {
41
+ attempt += 1;
42
+ const persistedAttempts = record.attempts + attempt;
43
+ await persistence.recordEffectAttempt?.(record.id, { status: 'running', attempts: persistedAttempts });
44
+ report?.({ kind: 'effect-attempted', effectId: record.id, operationId: record.operationId, attempt: persistedAttempts });
45
+ const result = await adapter.effect(operation, record.arguments, record.idempotencyKey ? { idempotencyKey: record.idempotencyKey } : {});
46
+ if (result.ok) {
47
+ const succeeded = {
48
+ ...record,
49
+ status: 'succeeded',
50
+ attempts: persistedAttempts,
51
+ result: result.value,
52
+ };
53
+ await persistence.recordEffectAttempt?.(record.id, {
54
+ status: 'succeeded',
55
+ attempts: persistedAttempts,
56
+ result: result.value,
57
+ });
58
+ report?.({ kind: 'effect-succeeded', effectId: record.id, operationId: record.operationId, attempt: persistedAttempts });
59
+ await onTerminal(succeeded);
60
+ return;
61
+ }
62
+ const lastError = { code: result.code, message: result.message, retryable: result.retryable };
63
+ const exhausted = attempt >= maxAttempts || result.retryable === false;
64
+ if (exhausted) {
65
+ const failed = { ...record, status: 'failed', attempts: persistedAttempts, lastError };
66
+ await persistence.recordEffectAttempt?.(record.id, { status: 'failed', attempts: persistedAttempts, lastError });
67
+ report?.({ kind: 'effect-failed', effectId: record.id, operationId: record.operationId, attempt: persistedAttempts });
68
+ await onTerminal(failed);
69
+ return;
70
+ }
71
+ const base = policy.delayMs ?? 1000;
72
+ const waitMs = policy.policy === 'exponential' ? base * 2 ** (attempt - 1) : base;
73
+ await delay(host, waitMs);
74
+ }
75
+ }
76
+ return {
77
+ dispatch(records) {
78
+ for (const record of records) {
79
+ void run(record);
80
+ }
81
+ },
82
+ };
83
+ }
package/dist/host.d.ts CHANGED
@@ -11,10 +11,17 @@ export interface ExecutionContext {
11
11
  /** The credential the caller presented, for host-level logging. Never semantic. */
12
12
  credential?: Credential;
13
13
  requestId?: string;
14
+ /**
15
+ * `'system'` for a trigger- or event-originated invocation, which never authenticates a
16
+ * credential (spec §68) — `principal` is `null` exactly as an anonymous client request's
17
+ * is, so authorization still evaluates and cannot be silently bypassed (spec §69,104).
18
+ * Absent, or `'client'`, for an ordinary client request.
19
+ */
20
+ source?: 'client' | 'system';
14
21
  }
15
22
  /** What the authoritative runtime records about an execution, for observability. */
16
23
  export interface ServerEvent {
17
- kind: 'invoke' | 'snapshot' | 'reject' | 'conflict' | 'replay';
24
+ kind: 'invoke' | 'snapshot' | 'reject' | 'conflict' | 'replay' | 'trigger-fired' | 'trigger-skipped-overlap' | 'trigger-invocation-failed' | 'effect-requested' | 'effect-attempted' | 'effect-succeeded' | 'effect-failed' | 'event-received' | 'event-dispatched';
18
25
  actionId?: NodeId;
19
26
  /** The principal's identity field, when the graph declares one. Never the whole record. */
20
27
  principal?: LiteralValue;
@@ -26,6 +33,14 @@ export interface ServerEvent {
26
33
  diagnostics?: RuntimeDiagnostic[];
27
34
  /** States the transaction committed. */
28
35
  committed?: NodeId[];
36
+ /** Set for `trigger-*` and `effect-*` events. */
37
+ triggerId?: NodeId;
38
+ /** Set for `effect-*` events. */
39
+ effectId?: string;
40
+ operationId?: NodeId;
41
+ attempt?: number;
42
+ /** Set for `event-*` events. */
43
+ eventId?: NodeId;
29
44
  }
30
45
  /**
31
46
  * Everything the authoritative runtime needs from its environment.
@@ -33,6 +48,10 @@ export interface ServerEvent {
33
48
  * The semantic engine reads nothing from globals, exactly as the client runtime does not.
34
49
  * No transport, no database driver and no host API appears in the semantics.
35
50
  */
51
+ /** A cancellable scheduled callback, returned by `ServerHost.schedule`/`scheduleOnce`. */
52
+ export interface ScheduledTask {
53
+ cancel(): void;
54
+ }
36
55
  export interface ServerHost {
37
56
  now(): string;
38
57
  uuid(): string;
@@ -46,8 +65,16 @@ export interface ServerHost {
46
65
  authenticate?(credential: Credential): Promise<PrincipalRecord | null> | PrincipalRecord | null;
47
66
  /** Structured execution information. An application implements no logging of its own. */
48
67
  report?(event: ServerEvent): void;
68
+ /**
69
+ * Calls `callback` every `everyMs`, for an interval trigger. The runtime never sees a
70
+ * timer handle, a `Promise` or a Node API — only this call, so time stays a host
71
+ * capability rather than an accident of implementation (spec §84).
72
+ */
73
+ schedule(everyMs: number, callback: () => void): ScheduledTask;
74
+ /** Calls `callback` once, after `delayMs` — a one-shot delay trigger. */
75
+ scheduleOnce(delayMs: number, callback: () => void): ScheduledTask;
49
76
  }
50
- /** A host backed by real time and real identifiers, with no authentication. */
77
+ /** A host backed by real time, real identifiers and real timers, with no authentication. */
51
78
  export declare function createServerHost(overrides?: Partial<ServerHost>): ServerHost;
52
79
  /**
53
80
  * A deterministic host, for conformance runs and tests. `now` and `uuid` count rather than
@@ -70,5 +97,15 @@ export declare function createServerHost(overrides?: Partial<ServerHost>): Serve
70
97
  * order. The counter is per host instance, and a fixture that restarts the authority keeps
71
98
  * the same host — a restart does not rewind it.
72
99
  */
73
- export declare function createDeterministicServerHost(overrides?: Partial<ServerHost>): ServerHost;
100
+ /**
101
+ * A deterministic host that also owns a virtual clock: `advance(ms)` moves time forward
102
+ * and synchronously fires every timer that becomes due, re-scheduling intervals — the test
103
+ * clock spec §85/§141 require, so a trigger's interval/delay semantics are verifiable
104
+ * without an actual wait.
105
+ */
106
+ export interface DeterministicServerHost extends ServerHost {
107
+ /** Moves virtual time forward by `ms`, firing every timer that becomes due in order. */
108
+ advance(ms: number): void;
109
+ }
110
+ export declare function createDeterministicServerHost(overrides?: Partial<ServerHost>): DeterministicServerHost;
74
111
  //# sourceMappingURL=host.d.ts.map
package/dist/host.js CHANGED
@@ -1,36 +1,36 @@
1
- /** A host backed by real time and real identifiers, with no authentication. */
1
+ /** A host backed by real time, real identifiers and real timers, with no authentication. */
2
2
  export function createServerHost(overrides = {}) {
3
3
  return {
4
4
  now: () => new Date().toISOString(),
5
5
  uuid: () => typeof globalThis.crypto?.randomUUID === 'function'
6
6
  ? globalThis.crypto.randomUUID()
7
7
  : `id-${Date.now().toString(16)}-${Math.floor(Math.random() * 1e9).toString(16)}`,
8
+ schedule: (everyMs, callback) => {
9
+ const handle = setInterval(callback, everyMs);
10
+ return { cancel: () => clearInterval(handle) };
11
+ },
12
+ scheduleOnce: (delayMs, callback) => {
13
+ const handle = setTimeout(callback, delayMs);
14
+ return { cancel: () => clearTimeout(handle) };
15
+ },
8
16
  ...overrides,
9
17
  };
10
18
  }
11
- /**
12
- * A deterministic host, for conformance runs and tests. `now` and `uuid` count rather than
13
- * varying, so an expected result is stable.
14
- */
15
- /**
16
- * The host model the conformance suite runs against.
17
- *
18
- * `now()` and `uuid()` are the only two places semantics can depend on something outside the
19
- * graph, so a portable fixture needs both pinned. The model is one counter, shared, starting
20
- * at zero and incremented **before** each value is produced, so the nth host call in an
21
- * execution — whichever of the two it is — is always the same value:
22
- *
23
- * ```
24
- * uuid() → "id-<n>"
25
- * now() → "2026-01-01T00:00:<n, two digits>.000Z"
26
- * ```
27
- *
28
- * A runtime in another language reproduces this exactly by counting host calls in execution
29
- * order. The counter is per host instance, and a fixture that restarts the authority keeps
30
- * the same host — a restart does not rewind it.
31
- */
32
19
  export function createDeterministicServerHost(overrides = {}) {
33
20
  let counter = 0;
21
+ let virtualNow = 0;
22
+ let nextTimerId = 0;
23
+ const timers = new Map();
24
+ function schedule(everyMs, callback) {
25
+ const id = nextTimerId++;
26
+ timers.set(id, { dueAt: virtualNow + everyMs, everyMs, callback, cancelled: false });
27
+ return { cancel: () => timers.delete(id) };
28
+ }
29
+ function scheduleOnce(delayMs, callback) {
30
+ const id = nextTimerId++;
31
+ timers.set(id, { dueAt: virtualNow + delayMs, callback, cancelled: false });
32
+ return { cancel: () => timers.delete(id) };
33
+ }
34
34
  return {
35
35
  now: () => {
36
36
  counter += 1;
@@ -40,6 +40,36 @@ export function createDeterministicServerHost(overrides = {}) {
40
40
  counter += 1;
41
41
  return `id-${counter}`;
42
42
  },
43
+ schedule,
44
+ scheduleOnce,
45
+ advance(ms) {
46
+ const target = virtualNow + ms;
47
+ // Firing order is due-time order, so two timers that both become due in this advance
48
+ // still run in the order they would have on a real clock.
49
+ while (true) {
50
+ let next;
51
+ for (const [id, entry] of timers) {
52
+ if (entry.cancelled || entry.dueAt > target) {
53
+ continue;
54
+ }
55
+ if (!next || entry.dueAt < next.entry.dueAt) {
56
+ next = { id, entry };
57
+ }
58
+ }
59
+ if (!next) {
60
+ break;
61
+ }
62
+ virtualNow = next.entry.dueAt;
63
+ if (next.entry.everyMs !== undefined) {
64
+ next.entry.dueAt += next.entry.everyMs;
65
+ }
66
+ else {
67
+ timers.delete(next.id);
68
+ }
69
+ next.entry.callback();
70
+ }
71
+ virtualNow = target;
72
+ },
43
73
  ...overrides,
44
74
  };
45
75
  }
package/dist/index.d.ts CHANGED
@@ -12,6 +12,9 @@ export * from './protocol.js';
12
12
  export * from './persistence.js';
13
13
  export * from './sqlite-persistence.js';
14
14
  export * from './host.js';
15
+ export * from './integration.js';
16
+ export * from './effects.js';
17
+ export * from './triggers.js';
15
18
  export * from './server.js';
16
19
  export * from './transport.js';
17
20
  export * from './node-host.js';
package/dist/index.js CHANGED
@@ -3,6 +3,9 @@ export * from './protocol.js';
3
3
  export * from './persistence.js';
4
4
  export * from './sqlite-persistence.js';
5
5
  export * from './host.js';
6
+ export * from './integration.js';
7
+ export * from './effects.js';
8
+ export * from './triggers.js';
6
9
  export * from './server.js';
7
10
  export * from './transport.js';
8
11
  export * from './node-host.js';
@@ -0,0 +1,55 @@
1
+ import type { IntegrationOperationDef, NodeId } from './deps.js';
2
+ export interface IntegrationSuccess {
3
+ ok: true;
4
+ value: unknown;
5
+ }
6
+ export interface IntegrationFailure {
7
+ ok: false;
8
+ code: string;
9
+ message: string;
10
+ /** Whether a retry might succeed. Absent means the adapter could not determine it. */
11
+ retryable?: boolean;
12
+ }
13
+ export type IntegrationResult = IntegrationSuccess | IntegrationFailure;
14
+ /**
15
+ * Translates a semantic integration operation into provider-specific execution.
16
+ *
17
+ * The graph declares the operation's shape; this is where the SDK, the HTTP client, the
18
+ * credentials and the provider-specific error translation live (spec §28,29) — none of it
19
+ * ever reaches an `ApplicationGraph`.
20
+ */
21
+ export interface IntegrationAdapter {
22
+ query(operation: IntegrationOperationDef, args: Record<string, unknown>, context: {
23
+ timeoutMs?: number;
24
+ }): Promise<IntegrationResult>;
25
+ effect(operation: IntegrationOperationDef, args: Record<string, unknown>, context: {
26
+ idempotencyKey?: string;
27
+ }): Promise<IntegrationResult>;
28
+ }
29
+ export type IntegrationAdapterRegistry = Record<NodeId, IntegrationAdapter>;
30
+ /**
31
+ * Deterministic canned results, for tests and conformance fixtures — semantics must not
32
+ * depend on a real network call (spec §107).
33
+ */
34
+ export declare function createFakeIntegrationAdapter(options: {
35
+ query?(operation: IntegrationOperationDef, args: Record<string, unknown>): IntegrationResult | Promise<IntegrationResult>;
36
+ effect?(operation: IntegrationOperationDef, args: Record<string, unknown>): IntegrationResult | Promise<IntegrationResult>;
37
+ }): IntegrationAdapter;
38
+ export interface HttpIntegrationOperationConfig {
39
+ method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
40
+ /** A path template, `{paramName}` substituted from the operation's arguments. */
41
+ path: string;
42
+ /** Argument keys sent as the JSON body. Absent sends every argument not used in the path. */
43
+ bodyFields?: string[];
44
+ }
45
+ /**
46
+ * A generic HTTP adapter, sufficient to prove query/effect/timeout/typed-response/error
47
+ * mapping over an arbitrary REST service (spec §106) — a lower-level mechanism, not the
48
+ * canonical integration model (spec §30,31).
49
+ */
50
+ export declare function createHttpIntegrationAdapter(options: {
51
+ baseUrl: string;
52
+ headers?: Record<string, string>;
53
+ operations: Record<string, HttpIntegrationOperationConfig>;
54
+ }): IntegrationAdapter;
55
+ //# sourceMappingURL=integration.d.ts.map
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Deterministic canned results, for tests and conformance fixtures — semantics must not
3
+ * depend on a real network call (spec §107).
4
+ */
5
+ export function createFakeIntegrationAdapter(options) {
6
+ return {
7
+ async query(operation, args) {
8
+ if (!options.query) {
9
+ return { ok: false, code: 'NOT_IMPLEMENTED', message: `No fake query behaviour registered for ${operation.id}` };
10
+ }
11
+ return options.query(operation, args);
12
+ },
13
+ async effect(operation, args) {
14
+ if (!options.effect) {
15
+ return { ok: false, code: 'NOT_IMPLEMENTED', message: `No fake effect behaviour registered for ${operation.id}` };
16
+ }
17
+ return options.effect(operation, args);
18
+ },
19
+ };
20
+ }
21
+ /**
22
+ * A generic HTTP adapter, sufficient to prove query/effect/timeout/typed-response/error
23
+ * mapping over an arbitrary REST service (spec §106) — a lower-level mechanism, not the
24
+ * canonical integration model (spec §30,31).
25
+ */
26
+ export function createHttpIntegrationAdapter(options) {
27
+ async function call(operation, args, timeoutMs) {
28
+ const config = options.operations[String(operation.id)];
29
+ if (!config) {
30
+ return {
31
+ ok: false,
32
+ code: 'INTEGRATION_OPERATION_UNCONFIGURED',
33
+ message: `No HTTP mapping configured for ${operation.id}`,
34
+ };
35
+ }
36
+ const path = config.path.replace(/\{(\w+)\}/g, (_match, key) => encodeURIComponent(String(args[key] ?? '')));
37
+ const url = new URL(path.replace(/^\//, ''), options.baseUrl.endsWith('/') ? options.baseUrl : `${options.baseUrl}/`);
38
+ const pathKeys = new Set([...config.path.matchAll(/\{(\w+)\}/g)].map((match) => match[1]));
39
+ const bodyKeys = config.bodyFields ?? Object.keys(args).filter((key) => !pathKeys.has(key));
40
+ const body = {};
41
+ for (const key of bodyKeys) {
42
+ body[key] = args[key];
43
+ }
44
+ const controller = new AbortController();
45
+ const timer = timeoutMs !== undefined ? setTimeout(() => controller.abort(), timeoutMs) : undefined;
46
+ try {
47
+ const response = await fetch(url, {
48
+ method: config.method,
49
+ headers: { 'content-type': 'application/json', ...(options.headers ?? {}) },
50
+ ...(config.method === 'GET' ? {} : { body: JSON.stringify(body) }),
51
+ signal: controller.signal,
52
+ });
53
+ if (!response.ok) {
54
+ return {
55
+ ok: false,
56
+ code: `HTTP_${response.status}`,
57
+ message: `${config.method} ${url.pathname} returned ${response.status}`,
58
+ retryable: response.status >= 500,
59
+ };
60
+ }
61
+ const value = response.status === 204 ? null : await response.json();
62
+ return { ok: true, value };
63
+ }
64
+ catch (error) {
65
+ const timedOut = error instanceof Error && error.name === 'AbortError';
66
+ return {
67
+ ok: false,
68
+ code: timedOut ? 'INTEGRATION_TIMEOUT' : 'INTEGRATION_UNAVAILABLE',
69
+ message: error instanceof Error ? error.message : String(error),
70
+ retryable: true,
71
+ };
72
+ }
73
+ finally {
74
+ if (timer !== undefined) {
75
+ clearTimeout(timer);
76
+ }
77
+ }
78
+ }
79
+ return {
80
+ query: (operation, args, context) => call(operation, args, context.timeoutMs),
81
+ effect: (operation, args) => call(operation, args, undefined),
82
+ };
83
+ }
@@ -1,7 +1,39 @@
1
1
  import type { AxiomServer } from './server.js';
2
2
  import type { PrincipalRecord } from './host.js';
3
3
  import type { PersistenceAdapter } from './persistence.js';
4
- import type { ServerIR } from './deps.js';
4
+ import type { NodeId, ServerIR } from './deps.js';
5
+ /**
6
+ * What a webhook handler gets to verify and decode a delivery: the raw, unparsed request —
7
+ * signature verification runs over the exact bytes a provider signed, not a re-serialized
8
+ * JSON parse of them.
9
+ */
10
+ export interface WebhookRequestInfo {
11
+ headers: Record<string, string | string[] | undefined>;
12
+ rawBody: Buffer;
13
+ }
14
+ /**
15
+ * A registered webhook route: provider-specific verification and decoding, kept entirely
16
+ * out of application semantics (spec §52-55). `verify` runs first — an unverified request
17
+ * never reaches `decode`, and no `EventRequest` is ever constructed for it (spec §53).
18
+ */
19
+ export interface WebhookConfig {
20
+ verify(request: WebhookRequestInfo): boolean | Promise<boolean>;
21
+ /**
22
+ * Translates a verified provider payload into a typed Axiom event. `deliveryId`, when the
23
+ * provider supplies one, is what a bounded recent-deliveries window dedupes on (spec
24
+ * §56,99) — a duplicate delivery within that window is acknowledged without dispatching
25
+ * the event again. There is no claim of durable, unbounded deduplication.
26
+ */
27
+ decode(request: WebhookRequestInfo): {
28
+ eventId: NodeId;
29
+ payload: unknown;
30
+ deliveryId?: string;
31
+ } | Promise<{
32
+ eventId: NodeId;
33
+ payload: unknown;
34
+ deliveryId?: string;
35
+ }>;
36
+ }
5
37
  /**
6
38
  * The reference Node host.
7
39
  *
@@ -19,6 +51,13 @@ export interface NodeHostOptions {
19
51
  * the client and answers it. Omit to run a bare authority.
20
52
  */
21
53
  page?: string;
54
+ /**
55
+ * Webhook routes, keyed by the URL path a provider posts to (e.g. `/webhooks/stripe`).
56
+ * An application author never declares an HTTP route (spec §54); this is the one place
57
+ * a deployment registers one, and only to translate provider deliveries into semantic
58
+ * events — the graph never mentions it.
59
+ */
60
+ webhooks?: Record<string, WebhookConfig>;
22
61
  }
23
62
  export interface RunningNodeHost {
24
63
  /** The port actually bound, which matters when `port: 0` was requested. */