Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,52 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Added

- **§16 bounded read-only retry policy.** `checkAccess`/`can`/`batchCheck` now retry under
the contract's normative table: 3 attempts, 200 ms base, 5 s cap, **full jitter** over
`[0, backoff]`, `Retry-After` honored as a floor. Both non-deterministic inputs are
injectable, so the tests pin the jitter fraction to 0 and 1 to prove the range instead of
sleeping.
- **§18 `AxiamClient.close()`**, idempotent, with use-after-close rejecting rather than
silently reconnecting. It does **not** log out and never reaches the network: the
server-side session outlives the client object, and a `close()` that logged out would end
every user's session on each deploy.
- **§19 telemetry hooks** — `telemetryHook` on `AxiamClientOptions`, plus the `TelemetryEvent`
union and `examples/telemetry-hook.ts` with the OpenTelemetry mapping. A hook that throws
cannot fail the operation that fired it, and no event payload can carry a token. One
request pair per *attempt*, not per logical call, so callers can count real wire calls.
- **§17 decision memo — opt-in, off by default.** `decisionMemoTtlMs`, clamped to 5000 ms.
Allows and denies memoized identically, failures never memoized, cleared on any credential
change. **Reads-your-own-writes is not guaranteed.**
- `retryEnabled` (§16.6), default on. No knob for the attempt cap, base or delay cap: §16.1
forbids raising them.

### Fixed

- **`withRetry` was never called by any production path.** It was exported, unit-tested and
green, but `checkAccess` did not route through it — so this SDK performed **no read-only
retries at all** while appearing to, leaving §11.2 rule 5 silently unmet. A tested helper
nobody calls is worse than an absent one: the passing tests are what stop anyone looking.
The §16 conformance tests now assert through the public `checkAccess` surface.
- **`Retry-After` replaced the backoff instead of flooring it.** `retryAfterMs ?? backoff(n)`
meant a `Retry-After: 0` retried immediately, defeating the policy — exactly what §16.1's
"floor, never a ceiling" forbids. Now `Math.max(jittered, retryAfterMs)`.
- **Partial jitter replaced with full jitter.** The old `base + 0–20%` keeps every client's
retries clustered around the same instant, which causes the thundering herd retries are
meant to prevent.
- The §19 request pair now carries the real attempt number. An earlier draft emitted every
pair as attempt 1, which would have made a retried call indistinguishable from a single
slow one — caught by the conformance test asserting `[1, 2]`.

### Changed

- Re-vendored `CONTRACT.md` at **1.8.1**. `openapi.json` unchanged — docs-only contract revs.
- `RetryOptions.maxAttempts` removed: §16.1 fixes the cap at 3 and forbids raising it.
`withRetry`'s callback now receives the attempt number.

## [1.0.0-alpha24] - 2026-08-04

### Added
Expand Down
371 changes: 364 additions & 7 deletions CONTRACT.md

Large diffs are not rendered by default.

94 changes: 93 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ Official TypeScript/JavaScript client SDK for [AXIAM](https://github.com/ilpanic

## Contract conformance

This SDK conforms to CONTRACT.md §1–§13 and §12.7, §14, §15 (including §6.1 mTLS client
This SDK conforms to CONTRACT.md §1–§13 and §12.7, §14, §15, §17, §19 (including §6.1 mTLS client
certificates, the §10.1 minimum local-verification set, the §12 OIDC/SSO relying-party
helpers, and the §13 `verifyWebhook` signature verifier).

Expand Down Expand Up @@ -661,6 +661,98 @@ like a stale one — and accepts a `tolerance` override plus a `now` injection s
failure always raises the typed `WebhookVerifyError` (never a generic exception whose message
could leak the expected signature).

## Client quality-of-life (CONTRACT.md §16–§19)

### Retry policy (§16)

Read-only authorization checks — `checkAccess`, `can`, `batchCheck` — retry transient
failures under the contract's normative table: **3 attempts** (1 initial + 2 retries),
200 ms base, 5 s cap, **full jitter** (uniform over `[0, backoff]`), and `Retry-After`
honored as a **floor**.

> **This changed in D5.** The previous policy used a 1000 ms base, an 8 s cap, partial
> jitter, and let `Retry-After` *replace* the backoff — so a `Retry-After: 0` retried
> immediately. Worse, `withRetry` was exported and unit-tested but **never called by
> `checkAccess`**, so this SDK performed no read-only retries at all. Both are fixed, and
> the conformance tests now assert through the public API rather than against the helper.

Only failures that could plausibly succeed on a second attempt are retried — transport
errors, `408`, `429`, `5xx`. A `401` or `403` is an answer, not a transport failure, and is
surfaced after exactly one attempt. Nothing that changes server state is ever retried.

```ts
// Turn it off if you own your own retry layer — you know your deadline, this SDK doesn't.
const client = new AxiamClient({ baseUrl, tenantSlug: 'acme', retryEnabled: false });
```

There is deliberately no knob for the attempt cap, base delay or delay cap: §16.1 forbids
raising them, and eleven SDKs agreeing on one table is the point.

### Deterministic shutdown (§18)

`client.close()` releases the client's local resources. It is idempotent, and any call
afterwards rejects with a `NetworkError` naming the cause rather than silently reconnecting.

**`close()` does not log out.** It never reaches the network. The server-side session
deliberately outlives the client object — that is what lets a process restart and resume —
so a `close()` that logged out would silently end every user's session on each deploy. Call
`logout()` first if ending the session is what you want.

### Telemetry hooks (§19)

Wire metrics without this package depending on any metrics library:

```ts
const client = new AxiamClient({
baseUrl,
tenantSlug: 'acme',
telemetryHook: (event) => {
if (event.type === 'requestEnd') {
histogram.record(event.durationMs, { op: event.operation, outcome: event.outcome });
} else if (event.type === 'retry') {
counter.add(1, { op: event.operation, attempt: event.attempt });
}
},
});
```

- **A hook that throws cannot fail the operation that fired it.** Telemetry is not permitted
to fail an authorization check.
- **No event payload can carry a token.** `TelemetryEvent` is a closed union with a fixed
field set — this surface exists to be shipped to a metrics backend.
- **Path templates, not URLs**, so a metric label cannot become a cardinality bomb.

One `requestStart`/`requestEnd` pair is emitted **per attempt**, so you can count real wire
calls. See [`examples/telemetry-hook.ts`](examples/telemetry-hook.ts), including the
OpenTelemetry mapping.

### Decision memo (§17) — opt-in, off by default

An optional TTL-bounded cache for `checkAccess` results. **Disabled by default**, because
§11.2 rule 6's ban on caching authorization decisions is still the default behaviour.

```ts
const client = new AxiamClient({
baseUrl,
tenantSlug: 'acme',
decisionMemoTtlMs: 5000, // 0 = off, which is the default
});
```

**What you are accepting.** The staleness bound is the TTL, in *both* directions: a grant
revoked on the server can still read as allowed for up to the TTL, and a grant just added
can still read as denied for up to the TTL.

> **Reads-your-own-writes is not guaranteed.** An admin UI that grants a role and
> immediately re-checks is the case that breaks, and it breaks silently. If that is your
> workload, leave this off.

The TTL is clamped to 5000 ms rather than rejected. Allows and denies are memoized
identically — asymmetric caching would leak which outcome occurred through latency.
Failures are never memoized: caching a transport error as a deny would turn a blip into a
TTL-long outage. The memo is cleared on `login`, `verifyMfa`, `refresh` and `logout`, since
entries are keyed by subject rather than by session.

## Error handling

Every persona throws the three CONTRACT.md §2 error types — `AuthError`, `AuthzError`,
Expand Down
137 changes: 137 additions & 0 deletions examples/telemetry-hook.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
// Telemetry hooks — CONTRACT.md §19.
//
// Wiring metrics to an AXIAM client **without this package depending on any
// metrics library**. The sink below aggregates in-process so the example runs
// with no extra dependencies; the block at the bottom shows the exact mapping
// onto OpenTelemetry, which is a drop-in replacement for the body.
//
// Run: npx tsx examples/telemetry-hook.ts

import { AxiamClient, type TelemetryEvent } from '../src/rest/index.js';

/** Accumulated call count and total latency for one (operation, outcome). */
interface Stat {
count: number;
totalMs: number;
}

const requests = new Map<string, Stat>();
const retries = new Map<string, number>();

function record(event: TelemetryEvent): void {
switch (event.type) {
// One pair per ATTEMPT, not per logical call (§19.2 rule 5), so counting
// these gives the real number of wire calls — including the ones a retry
// made on your behalf.
case 'requestEnd': {
const key = `${event.operation}/${event.outcome}`;
const stat = requests.get(key) ?? { count: 0, totalMs: 0 };
stat.count += 1;
stat.totalMs += event.durationMs;
requests.set(key, stat);
break;
}

// §16.5 — the reason this event exists. A retried-then-succeeded operation
// is otherwise invisible: the caller sees a slow success and no signal that
// the server is failing. Alert on this rate, not on the error rate, or a
// degrading server looks healthy right up until the retries stop being
// enough.
case 'retry':
retries.set(event.operation, (retries.get(event.operation) ?? 0) + 1);
break;

// `requestStart` and `refresh` are available too; a metrics sink usually
// only needs the ends.
default:
break;
}
}

function report(): void {
console.log('--- requests (per attempt) ---');
for (const [key, { count, totalMs }] of requests) {
console.log(` ${key.padEnd(24)} count=${count} mean=${Math.round(totalMs / count)}ms`);
}
console.log('--- retries ---');
if (retries.size === 0) console.log(' (none)');
for (const [op, count] of retries) console.log(` ${op.padEnd(24)} ${count}`);
}

async function main(): Promise<void> {
const client = new AxiamClient({
baseUrl: 'https://axiam.example.com',
tenantSlug: 'acme',
orgSlug: 'acme',
telemetryHook: record,
});

// This will fail — the host does not resolve — which is the point: a failing
// call still emits a `requestEnd` carrying the failure, and the §16 retries
// are visible as `retry` events. Against a real server the same sink reports
// the success path.
try {
const decision = await client.checkAccess({
action: 'read',
resourceId: '00000000-0000-0000-0000-000000000000',
});
console.log(`allowed=${decision.allowed} (${decision.reasonCode ?? 'no reason code'})`);
} catch (err) {
console.log(`check failed as expected in this example: ${(err as Error).message}`);
}

report();

// §18: release the client's local resources. Does not log out.
client.close();
}

void main();

// ---------------------------------------------------------------------------
// The same sink, against OpenTelemetry
// ---------------------------------------------------------------------------
//
// This package deliberately ships no `@opentelemetry/*` dependency — §19's
// whole point is that you choose your metrics stack. With the OTel API in YOUR
// package.json, `record` becomes:
//
// ```ts
// import { metrics } from '@opentelemetry/api';
//
// const meter = metrics.getMeter('axiam-sdk');
// const duration = meter.createHistogram('axiam.client.request.duration');
// const retryCounter = meter.createCounter('axiam.client.retries');
//
// function record(event: TelemetryEvent): void {
// if (event.type === 'requestEnd') {
// duration.record(event.durationMs / 1000, {
// 'axiam.operation': event.operation,
// // The path TEMPLATE, never a substituted URL: a metric label carrying
// // a UUID is a cardinality bomb.
// 'http.route': event.pathTemplate,
// 'http.response.status_code': event.status ?? 0,
// 'axiam.outcome': event.outcome,
// });
// } else if (event.type === 'retry') {
// retryCounter.add(1, {
// 'axiam.operation': event.operation,
// 'axiam.attempt': event.attempt,
// });
// }
// }
// ```
//
// Two rules to keep in mind when writing any adapter:
//
// * **Do not block.** Hooks run on the calling path (§19.2 rule 4). Every
// mature metrics library already buffers; if yours does not, buffer on your
// side rather than doing I/O here.
// * **Do not enrich events from elsewhere.** `TelemetryEvent` is a closed
// union precisely so this surface cannot leak a token into a metrics
// backend (§19.2 rule 3). Adding, say, the current `Authorization` header
// would defeat that on your side of the boundary.
//
// A hook that throws is caught and swallowed by the SDK (§19.2 rule 2) — an
// authorization check is never failed by telemetry — but that is a backstop,
// not a licence to let a sink throw.
48 changes: 48 additions & 0 deletions src/core/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
// customCa.

import { Sensitive } from './sensitive.js';
import type { TelemetryHook } from './telemetry.js';

/**
* Configuration for {@link AxiamClient}, passed as its constructor's first
Expand Down Expand Up @@ -51,6 +52,53 @@ export interface AxiamClientOptions {
orgId?: string;
/** PEM-encoded custom CA certificate, for self-signed/dev environments (§6). */
customCa?: string;
/**
* Enable the CONTRACT.md §16 bounded read-only retry policy. **Default:
* `true`.**
*
* Set `false` to make every operation exactly one attempt. That is the right
* choice for a caller who owns their own retry layer — they know their
* deadline and this SDK does not — but it is not a way to make failures
* quieter: a transient `NetworkError` simply surfaces immediately.
*
* §16.1 permits this switch but forbids raising the attempt cap, base delay
* or delay cap above the contract's values, so there is no knob for those:
* eleven SDKs agreeing on one table is the point.
*/
retryEnabled?: boolean;
/**
* Enable the CONTRACT.md §17 client-side decision memo, in milliseconds.
* **Default: `0`, which means disabled** — not "cache for zero milliseconds".
*
* ## What you are accepting
*
* The staleness bound is this TTL, **in both directions**. A grant revoked on
* the server can still read as allowed for up to the TTL, and a grant just
* added can still read as denied for up to the TTL.
*
* **Reads-your-own-writes is not guaranteed.** An admin UI that grants a role
* and immediately re-checks is the case that breaks, and it breaks silently.
* If that is your workload, leave this off.
*
* Clamped to 5000 ms rather than rejected, so asking for a minute gets you
* five seconds. Allows and denies are memoized identically (asymmetric
* caching leaks the outcome through latency), failures are never memoized,
* and the memo is cleared on any credential change.
*/
decisionMemoTtlMs?: number;
/**
* Install a CONTRACT.md §19 telemetry sink.
*
* Receives request start/end, §16 retry and §9 refresh events, so metrics can
* be wired without this package depending on any metrics library. See
* `examples/telemetry-hook.ts`.
*
* A hook that throws cannot fail the operation that fired it (§19.2 rule 2),
* and no event payload can carry a token — {@link TelemetryEvent} is a closed
* union with a fixed field set (§19.2 rule 3). Invoked on the calling path,
* so it must not block; buffer on your side if you need async delivery.
*/
telemetryHook?: TelemetryHook;
/**
* PEM-encoded client-certificate chain for mutual TLS (§6.1). Presented to
* the server to authenticate an IoT device / service account. MUST be
Expand Down
Loading