axis-platform-sdk 0.2.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.
@@ -0,0 +1,101 @@
1
+ /**
2
+ * The Authorizer port + the free-tier SwitchAuthorizer.
3
+ *
4
+ * Owyhee (and any platform) always does IDENTITY verification first — is this a
5
+ * real, non-revoked agent with a trustworthy effective_scope (verifyAgent ->
6
+ * registry). That part is fixed and never pluggable.
7
+ *
8
+ * The authorization DECISION is the pluggable, monetizable layer. Every
9
+ * Authorizer implements the same shape:
10
+ *
11
+ * authorize(token, gateId, { registryBaseUrl, fetchImpl }) -> verdict
12
+ *
13
+ * where `verdict` is exactly what verifyAgent returns
14
+ * ({ accepted, reason?, code?, agent_id?, operator_id?, effective_scope?, tier?, ... }).
15
+ *
16
+ * Profiles (Josh's "simple on/off -> granular -> really complicated"):
17
+ * - SwitchAuthorizer (this file) — FREE tier. Config-driven on/off gates
18
+ * + optional tier / scope / operator rules.
19
+ * - EngineAuthorizer (not here) — PAID. Same port, delegates the decision
20
+ * to Permify / OpenFGA (sidecar) for
21
+ * relationship/attribute rules.
22
+ * - EnterpriseAuthorizer (not here)— full ReBAC/ABAC, same port.
23
+ *
24
+ * The SwitchAuthorizer's `policy` object is exactly what a "Door policy" screen
25
+ * edits and saves.
26
+ */
27
+ import { verifyAgent } from './verify.js';
28
+ import { extractToken } from './gate.js';
29
+
30
+ const deny = (reason, code, extra = {}) => ({ accepted: false, reason, code, ...extra });
31
+
32
+ /**
33
+ * Free-tier gate engine: a set of named gates, each on/off, with optional
34
+ * minimum tier, required scopes, and operator allow/block lists.
35
+ *
36
+ * policy = {
37
+ * audience: 'comments.mysite.com', // your platform id; applied to every gate
38
+ * defaultAllow: false, // posture for a gateId with no policy
39
+ * blockedOperators: [], // global blocklist, applied to all gates
40
+ * gates: {
41
+ * 'comments:write': {
42
+ * enabled: true,
43
+ * minTier: 'domain', // optional
44
+ * requireScopes: ['comments:write'], // optional
45
+ * blockedOperators: [], // optional, gate-specific
46
+ * approvedOperators: null // optional allowlist
47
+ * }
48
+ * }
49
+ * }
50
+ */
51
+ export class SwitchAuthorizer {
52
+ constructor(policy = {}) {
53
+ this.policy = policy || {};
54
+ }
55
+
56
+ /** The verifyAgent options this policy implies for a given gate. */
57
+ optsForGate(gateId) {
58
+ const p = this.policy;
59
+ const gate = (p.gates && p.gates[gateId]) || null;
60
+ return {
61
+ audience: p.audience,
62
+ requireScopes: (gate && gate.requireScopes) || [],
63
+ minTier: gate && gate.minTier,
64
+ blockedOperators: [...(p.blockedOperators || []), ...((gate && gate.blockedOperators) || [])],
65
+ approvedOperators: (gate && gate.approvedOperators) || null,
66
+ };
67
+ }
68
+
69
+ /**
70
+ * Decide whether `token` may act at `gateId`. Denies if the gate is turned
71
+ * off, or unknown when the posture is closed. Otherwise runs identity
72
+ * verification + this gate's policy.
73
+ */
74
+ async authorize(token, gateId, { registryBaseUrl, fetchImpl } = {}) {
75
+ const p = this.policy;
76
+ const gate = (p.gates && p.gates[gateId]) || null;
77
+
78
+ if (!gate) {
79
+ if (p.defaultAllow) {
80
+ return verifyAgent(token, {
81
+ audience: p.audience,
82
+ blockedOperators: p.blockedOperators || [],
83
+ registryBaseUrl,
84
+ fetchImpl,
85
+ });
86
+ }
87
+ return deny(`No policy for gate '${gateId}' and the default posture is closed`, 'gate_unknown');
88
+ }
89
+
90
+ if (gate.enabled === false) {
91
+ return deny(`Gate '${gateId}' is turned off`, 'gate_closed');
92
+ }
93
+
94
+ return verifyAgent(token, { ...this.optsForGate(gateId), registryBaseUrl, fetchImpl });
95
+ }
96
+
97
+ /** Bind this authorizer to a gateId as a request gate: (request) => verdict. */
98
+ gate(gateId, opts = {}) {
99
+ return async (request) => this.authorize(extractToken(request), gateId, opts);
100
+ }
101
+ }
@@ -0,0 +1,183 @@
1
+ /**
2
+ * Persistent block / allow list — the runtime, stateful counterpart to the
3
+ * static `blockedOperators` / `approvedOperators` arrays that verifyAgent and
4
+ * SwitchAuthorizer already take.
5
+ *
6
+ * The static lists are policy-at-config-time (what the door policy screen
7
+ * saves). This is policy-at-runtime: a platform operator clicks "boot this
8
+ * agent" and the decision sticks in the platform's OWN store, no redeploy. It
9
+ * adds two things the static arrays don't have:
10
+ *
11
+ * 1. Agent-level blocking (block one misbehaving agent without blocking its
12
+ * whole operator).
13
+ * 2. Mutation at runtime (block / unblock) backed by a pluggable store.
14
+ *
15
+ * Canonical adapter: Owyhee "The Door" (governor#27) ships the OPERATOR-level
16
+ * half of this port as its `operator_blocks` table + `blockedOperators()`,
17
+ * merged into the SwitchAuthorizer policy at authorize time. This module is the
18
+ * library form of that, plus the AGENT-level half The Door does not have yet
19
+ * (an additive `agent_blocks` table is the natural way for it to adopt it — a
20
+ * fast-follow, not a requirement). Block metadata is `{ reason, created_at }`
21
+ * (epoch ms), matching The Door's columns so the shapes are one.
22
+ *
23
+ * Same adapter shape as the ledger — a tiny CRUD port a platform implements
24
+ * against D1 / SQLite / Postgres. Default is in-memory.
25
+ *
26
+ * --- Adapter shape -------------------------------------------------------
27
+ * A store is any object implementing:
28
+ *
29
+ * async add(kind, id, meta) -> void // kind: 'operator'|'agent'
30
+ * async remove(kind, id) -> void
31
+ * async has(kind, id) -> boolean
32
+ * async list(kind) -> { id, meta }[] // all entries of a kind
33
+ *
34
+ * Integration: build the verifyAgent opts from a Blocklist and merge them with
35
+ * your static policy (`opts(staticBlocked)` -> { blockedOperators }), and call
36
+ * `checkVerdict(verdict)` AFTER verifyAgent to catch agent-level blocks (the
37
+ * registry verdict carries operator_id and agent_id, and operator blocking is
38
+ * already enforced by verifyAgent via blockedOperators, but agent-level
39
+ * blocking needs the resolved agent_id, which only exists post-verify).
40
+ */
41
+
42
+ /** Default in-memory block/allow store. */
43
+ export class MemoryBlocklistStore {
44
+ constructor() {
45
+ this._sets = { operator: new Map(), agent: new Map() };
46
+ }
47
+
48
+ async add(kind, id, meta = {}) {
49
+ const m = this._sets[kind];
50
+ if (!m) throw new Error(`MemoryBlocklistStore: unknown kind '${kind}'`);
51
+ m.set(id, { ...meta, created_at: meta.created_at || Date.now() });
52
+ }
53
+
54
+ async remove(kind, id) {
55
+ const m = this._sets[kind];
56
+ if (m) m.delete(id);
57
+ }
58
+
59
+ async has(kind, id) {
60
+ const m = this._sets[kind];
61
+ return !!(m && m.has(id));
62
+ }
63
+
64
+ async list(kind) {
65
+ const m = this._sets[kind];
66
+ if (!m) return [];
67
+ return [...m.entries()].map(([id, meta]) => ({ id, meta }));
68
+ }
69
+ }
70
+
71
+ /**
72
+ * Runtime block list. Holds two kinds of blocks — by operator_id and by
73
+ * agent_id — over a pluggable store.
74
+ */
75
+ export class Blocklist {
76
+ constructor({ store } = {}) {
77
+ this.store = store || new MemoryBlocklistStore();
78
+ }
79
+
80
+ /** Block an operator (every agent under it). `reason` is recorded as meta. */
81
+ async blockOperator(operatorId, reason) {
82
+ await this.store.add('operator', operatorId, { reason });
83
+ }
84
+
85
+ /** Block a single agent without blocking its whole operator. */
86
+ async blockAgent(agentId, reason) {
87
+ await this.store.add('agent', agentId, { reason });
88
+ }
89
+
90
+ async unblockOperator(operatorId) {
91
+ await this.store.remove('operator', operatorId);
92
+ }
93
+
94
+ async unblockAgent(agentId) {
95
+ await this.store.remove('agent', agentId);
96
+ }
97
+
98
+ async isOperatorBlocked(operatorId) {
99
+ if (!operatorId) return false;
100
+ return this.store.has('operator', operatorId);
101
+ }
102
+
103
+ async isAgentBlocked(agentId) {
104
+ if (!agentId) return false;
105
+ return this.store.has('agent', agentId);
106
+ }
107
+
108
+ /** All blocked operator ids (for merging into verifyAgent's blockedOperators). */
109
+ async blockedOperatorIds() {
110
+ return (await this.store.list('operator')).map((e) => e.id);
111
+ }
112
+
113
+ async listOperators() {
114
+ return this.store.list('operator');
115
+ }
116
+
117
+ async listAgents() {
118
+ return this.store.list('agent');
119
+ }
120
+
121
+ /**
122
+ * The verifyAgent option fragment this blocklist implies. Merge with your
123
+ * static policy so a runtime-blocked operator is denied BEFORE the scope/tier
124
+ * checks run:
125
+ *
126
+ * const dyn = await blocklist.verifyOpts();
127
+ * const verdict = await verifyAgent(token, {
128
+ * ...staticOpts,
129
+ * blockedOperators: [...(staticOpts.blockedOperators||[]), ...dyn.blockedOperators],
130
+ * });
131
+ * const final = await blocklist.checkVerdict(verdict); // agent-level catch
132
+ */
133
+ async verifyOpts() {
134
+ return { blockedOperators: await this.blockedOperatorIds() };
135
+ }
136
+
137
+ /**
138
+ * Post-verify agent-level enforcement. verifyAgent already denies blocked
139
+ * OPERATORS (when you fed it `blockedOperators`), but agent-level blocking
140
+ * needs the resolved agent_id, which only exists after the registry verify.
141
+ * Pass an accepted verdict through this; it flips it to denied if the agent
142
+ * (or, as a safety net, the operator) is blocked. Pass-through otherwise.
143
+ */
144
+ async checkVerdict(verdict) {
145
+ if (!verdict || !verdict.accepted) return verdict;
146
+ if (await this.isAgentBlocked(verdict.agent_id)) {
147
+ return {
148
+ accepted: false,
149
+ code: 'agent_blocked',
150
+ reason: 'Agent is blocked at this platform',
151
+ agent_id: verdict.agent_id,
152
+ operator_id: verdict.operator_id,
153
+ };
154
+ }
155
+ if (await this.isOperatorBlocked(verdict.operator_id)) {
156
+ return {
157
+ accepted: false,
158
+ code: 'operator_blocked',
159
+ reason: 'Operator is blocked at this platform',
160
+ agent_id: verdict.agent_id,
161
+ operator_id: verdict.operator_id,
162
+ };
163
+ }
164
+ return verdict;
165
+ }
166
+ }
167
+
168
+ /**
169
+ * Wrap a gate so a runtime-blocked agent/operator is denied even if it's
170
+ * globally valid and within policy. Sits OUTSIDE the verify call: it merges the
171
+ * dynamic operator blocks into the verdict path via checkVerdict (agent-level)
172
+ * and is the simplest way to bolt a Blocklist onto an existing gate.
173
+ *
174
+ * Note: operator-level dynamic blocks are most efficiently injected by feeding
175
+ * `await blocklist.blockedOperatorIds()` into the gate's policy, but checkVerdict
176
+ * also catches them here as a safety net, so this wrapper is correct on its own.
177
+ */
178
+ export function gatedWithBlocklist(gate, blocklist) {
179
+ return async function blocklistGate(request) {
180
+ const verdict = await gate(request);
181
+ return blocklist.checkVerdict(verdict);
182
+ };
183
+ }
package/src/client.js ADDED
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Thin registry-call helpers. The registry does the cryptographic heavy
3
+ * lifting (signature, revocation, delegation chain walk) at its public
4
+ * endpoints; these wrappers just call them.
5
+ */
6
+
7
+ export const DEFAULT_REGISTRY = 'https://registry.axisprime.ai';
8
+
9
+ /**
10
+ * GET a registry path. `fetchImpl` is injectable for testing. Returns
11
+ * { status, body } where body is the parsed JSON (or { raw } if not JSON).
12
+ */
13
+ export async function registryGet(base, path, { headers, fetchImpl = fetch } = {}) {
14
+ const res = await fetchImpl(`${base}${path}`, headers ? { headers } : undefined);
15
+ const text = await res.text();
16
+ let body;
17
+ try {
18
+ body = JSON.parse(text);
19
+ } catch {
20
+ body = { raw: text };
21
+ }
22
+ return { status: res.status, body };
23
+ }
24
+
25
+ /**
26
+ * Tier rank for minimum-tier gating. Higher = stronger verification.
27
+ * `kyb_business` is an accepted legacy alias for `kyb_organization`.
28
+ */
29
+ export const TIER_RANK = {
30
+ email: 1,
31
+ domain: 2,
32
+ verified: 3,
33
+ kyb_individual: 4,
34
+ kyb_organization: 5,
35
+ kyb_business: 5,
36
+ };
37
+
38
+ /**
39
+ * Resolve an agent's presentation layer (display_name, operator verification
40
+ * tier, etc.). Pass the agent's AIT to unlock the presentation layer; without
41
+ * it you get only the public layer.
42
+ *
43
+ * @returns {{ agent_id, did, display_name, tier, status, raw }}
44
+ */
45
+ export async function enrich(agentId, token, { registryBaseUrl = DEFAULT_REGISTRY, fetchImpl = fetch } = {}) {
46
+ const headers = token ? { Authorization: `Bearer ${token}` } : undefined;
47
+ const { body } = await registryGet(registryBaseUrl, `/agents/${encodeURIComponent(agentId)}`, { headers, fetchImpl });
48
+ return {
49
+ agent_id: body.agent_id || agentId,
50
+ did: body.did || null,
51
+ display_name: body.display_name || null,
52
+ tier: pickTier(body),
53
+ status: body.status || null,
54
+ raw: body,
55
+ };
56
+ }
57
+
58
+ /**
59
+ * Read an operator's verification tier from a resolved agent record.
60
+ * Defensive across the field shapes the registry uses for the tier.
61
+ */
62
+ export function pickTier(agentBody) {
63
+ if (!agentBody) return null;
64
+ return (
65
+ agentBody.operator_verification_tier ||
66
+ (agentBody.operator && agentBody.operator.verification_tier) ||
67
+ agentBody.verification_tier ||
68
+ null
69
+ );
70
+ }
71
+
72
+ /**
73
+ * Load a platform's published access policy (`/.well-known/axis-access`).
74
+ * Useful if you want your gate to read its own door policy rather than
75
+ * hard-coding it.
76
+ */
77
+ export async function loadAccessPolicy(platformBaseUrl, { fetchImpl = fetch } = {}) {
78
+ const base = String(platformBaseUrl || '').replace(/\/$/, '');
79
+ const res = await fetchImpl(`${base}/.well-known/axis-access`);
80
+ if (!res.ok) throw new Error(`axis-access fetch failed: ${res.status}`);
81
+ return res.json();
82
+ }
package/src/decode.js ADDED
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Decode an AIT (AXIS Identity Token, a JWT) payload WITHOUT verifying it.
3
+ *
4
+ * The cryptographic verification (signature, revocation, delegation chain)
5
+ * happens server-side at the registry's GET /verify endpoint. The only reason
6
+ * a platform decodes locally is to read the `aud` (audience) claim so it can
7
+ * confirm the agent actually meant to present to THIS platform. The registry
8
+ * enforces that `aud` is present and non-empty, but it does not know which
9
+ * platform is asking, so audience-matching is the platform's job.
10
+ *
11
+ * Returns the parsed payload object, or null if the token is malformed.
12
+ */
13
+ export function decodeAitPayload(token) {
14
+ const parts = String(token || '').split('.');
15
+ if (parts.length !== 3) return null;
16
+ try {
17
+ const b64 = parts[1].replace(/-/g, '+').replace(/_/g, '/');
18
+ return JSON.parse(atob(b64));
19
+ } catch {
20
+ return null;
21
+ }
22
+ }
package/src/gate.js ADDED
@@ -0,0 +1,70 @@
1
+ /**
2
+ * aitGate — a drop-in request gate for a consuming platform (the "bouncer").
3
+ *
4
+ * Wrap your handler: pull the AIT off the incoming request, verify it against
5
+ * the registry + your policy, and either let it through or bounce it.
6
+ *
7
+ * import { aitGate, denialResponse } from 'axis-platform-sdk';
8
+ *
9
+ * const gate = aitGate({ audience: 'comments.mysite.com', requireScopes: ['comments:write'] });
10
+ *
11
+ * export default {
12
+ * async fetch(request) {
13
+ * const verdict = await gate(request);
14
+ * if (!verdict.accepted) return denialResponse(verdict);
15
+ * // ...verdict.agent_id is verified; proceed.
16
+ * }
17
+ * };
18
+ */
19
+ import { verifyAgent } from './verify.js';
20
+
21
+ /**
22
+ * Pull the AIT off a request. Accepts a Fetch API Request, or any object with
23
+ * a `headers` (Headers or plain object) and optional `url`. Looks at, in order:
24
+ * Authorization: Bearer <ait>, X-AXIS-Token, ?ait= query param.
25
+ */
26
+ export function extractToken(request) {
27
+ const h = request && request.headers;
28
+ const get = (k) => {
29
+ if (!h) return null;
30
+ if (typeof h.get === 'function') return h.get(k);
31
+ return h[k] || h[k.toLowerCase()] || null;
32
+ };
33
+ const auth = get('authorization') || get('Authorization');
34
+ if (auth && /^Bearer\s+/i.test(auth)) return auth.replace(/^Bearer\s+/i, '').trim();
35
+ const xa = get('x-axis-token') || get('X-AXIS-Token');
36
+ if (xa) return xa;
37
+ try {
38
+ if (request && request.url) {
39
+ const q = new URL(request.url).searchParams.get('ait');
40
+ if (q) return q;
41
+ }
42
+ } catch {
43
+ /* not a URL; ignore */
44
+ }
45
+ return null;
46
+ }
47
+
48
+ /**
49
+ * Build a gate function bound to your platform's policy. Returns
50
+ * `(request) => Promise<verdict>`.
51
+ */
52
+ export function aitGate(opts = {}) {
53
+ return async function gate(request) {
54
+ const token = extractToken(request);
55
+ return verifyAgent(token, opts);
56
+ };
57
+ }
58
+
59
+ /**
60
+ * Turn a denied verdict into an HTTP Response (403 by default). 401 when no
61
+ * token was presented at all.
62
+ */
63
+ export function denialResponse(verdict, status) {
64
+ const code = verdict && verdict.code;
65
+ const httpStatus = status || (code === 'no_token' ? 401 : 403);
66
+ return new Response(
67
+ JSON.stringify({ error: code || 'denied', message: verdict ? verdict.reason : 'Denied' }),
68
+ { status: httpStatus, headers: { 'Content-Type': 'application/json' } }
69
+ );
70
+ }
package/src/index.js ADDED
@@ -0,0 +1,31 @@
1
+ /**
2
+ * axis-platform-sdk — the platform/verifier ("bouncer") side of AXIS.
3
+ *
4
+ * When an AXIS agent shows up at your platform and wants to act, verify its
5
+ * identity + delegation + scope and decide: accept, scope, or boot.
6
+ *
7
+ * The whole point: the registry already does the cryptography (signature,
8
+ * revocation, delegation chain walk) server-side. This SDK packages the
9
+ * verdict + policy layer so a platform integrates in a few lines instead of
10
+ * hand-rolling it.
11
+ */
12
+ export { verifyAgent } from './verify.js';
13
+ export { SwitchAuthorizer } from './authorizer.js';
14
+ export { aitGate, extractToken, denialResponse } from './gate.js';
15
+ export { scopeCovers, coversAll } from './scope.js';
16
+ export { enrich, loadAccessPolicy, registryGet, pickTier, TIER_RANK, DEFAULT_REGISTRY } from './client.js';
17
+ export { decodeAitPayload } from './decode.js';
18
+
19
+ // --- Stateful platform store (the "who showed up / who's blocked" half) ---
20
+ export { AccessLedger, MemoryLedgerStore, loggedGate, recordEntry } from './ledger.js';
21
+ export { Blocklist, MemoryBlocklistStore, gatedWithBlocklist } from './blocklist.js';
22
+ export {
23
+ reportFlag,
24
+ blockAndReport,
25
+ getPlatformKey,
26
+ buildAttestation,
27
+ signAttestation,
28
+ verifyAttestation,
29
+ MemoryKeyStore,
30
+ DEFAULT_REPUTATION_URL,
31
+ } from './reportback.js';
package/src/ledger.js ADDED
@@ -0,0 +1,171 @@
1
+ /**
2
+ * Access ledger — the platform's "who showed up at my door" record.
3
+ *
4
+ * Every time an agent presents and the gate reaches a verdict, the platform can
5
+ * log the arrival here. This is the STATEFUL half of the bouncer: verifyAgent /
6
+ * aitGate are stateless verdict machines; the ledger is the platform's own
7
+ * append-only record of what those verdicts were, kept in the platform's OWN
8
+ * store. Zero-infra by default (in-memory); a real platform plugs in D1 /
9
+ * SQLite / Postgres via the adapter shape below.
10
+ *
11
+ * Canonical adapter: Owyhee "The Door" (kipple-governor, governor#27) is the
12
+ * shipped, D1-backed product instance of this port — its `arrivals` table +
13
+ * `recordArrival()` ARE this ledger over D1. This module is the library form
14
+ * (the shape + an in-memory default); The Door is the production adapter. The
15
+ * entry shape below is deliberately byte-compatible with The Door's
16
+ * `ArrivalRecord` / `arrivals` columns so there is ONE arrival record across
17
+ * the SDK and the product, not two.
18
+ *
19
+ * Trust note: the only scope worth recording is `effective_scope` (the
20
+ * registry's chain-walked, trustworthy scope), which is exactly what a verdict
21
+ * carries. We never persist the AIT's self-declared scope.
22
+ *
23
+ * --- Adapter shape -------------------------------------------------------
24
+ * A store is any object implementing:
25
+ *
26
+ * async append(entry) -> void // persist one arrival
27
+ * async recent({ limit }) -> entry[] // newest first
28
+ * async byOperator(operatorId, { limit }) -> entry[] // newest first
29
+ *
30
+ * `entry` is the record shape produced by `recordEntry()` — the same fields The
31
+ * Door's `arrivals` table holds (minus the adapter's own PK/org columns):
32
+ * {
33
+ * agent_id, operator_id, created_at, // created_at = epoch ms (Date.now())
34
+ * tier, delegation_valid, effective_scope: string[],
35
+ * gate_id, requested_action, display_name,
36
+ * decision: 'auto_allow'|'denied'|'held'|'approved'|'booted',
37
+ * reason, audience
38
+ * }
39
+ *
40
+ * The same adapter shape is shared with blocklist.js (a tiny CRUD port). A
41
+ * platform implements both against whatever it already runs. The defaults here
42
+ * keep the demo and the free tier zero-infra.
43
+ */
44
+
45
+ /**
46
+ * Default in-memory ledger store. Newest-first iteration. Bounded so a
47
+ * long-running Worker isolate doesn't grow without limit; override `max` (0 =
48
+ * unbounded) when you want the full history and have a real store behind it.
49
+ */
50
+ export class MemoryLedgerStore {
51
+ constructor({ max = 10000 } = {}) {
52
+ this._entries = []; // chronological; newest pushed to the end
53
+ this.max = max;
54
+ }
55
+
56
+ async append(entry) {
57
+ this._entries.push(entry);
58
+ if (this.max && this._entries.length > this.max) {
59
+ this._entries.splice(0, this._entries.length - this.max);
60
+ }
61
+ }
62
+
63
+ async recent({ limit = 50 } = {}) {
64
+ const out = this._entries.slice(-limit);
65
+ out.reverse();
66
+ return out;
67
+ }
68
+
69
+ async byOperator(operatorId, { limit = 50 } = {}) {
70
+ const out = [];
71
+ for (let i = this._entries.length - 1; i >= 0 && out.length < limit; i--) {
72
+ if (this._entries[i].operator_id === operatorId) out.push(this._entries[i]);
73
+ }
74
+ return out;
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Normalize a verdict (from verifyAgent) into a ledger entry. The output is
80
+ * byte-compatible with The Door's `ArrivalRecord` (governor#27) so the SDK and
81
+ * the product share one arrival shape.
82
+ *
83
+ * `decision` defaults from the verdict (accepted -> 'auto_allow', else
84
+ * 'denied'); a caller may override it with a manual-review state a real bouncer
85
+ * needs ('held' | 'approved' | 'booted'). `created_at` is epoch ms (Date.now()),
86
+ * matching The Door's column; for an ISO string use
87
+ * `new Date(entry.created_at).toISOString()`.
88
+ *
89
+ * @param {object} verdict A verifyAgent verdict.
90
+ * @param {object} [fields]
91
+ * @param {string} [fields.audience] Platform audience (optional metadata; The Door doesn't persist it).
92
+ * @param {string} [fields.gate_id] Which gate was requested.
93
+ * @param {string} [fields.requested_action] Human-facing action label.
94
+ * @param {string} [fields.display_name] Enriched presentation name.
95
+ * @param {string} [fields.decision] Override decision ('held'|'approved'|'booted').
96
+ * @param {number} [fields.created_at] Override timestamp (epoch ms).
97
+ */
98
+ export function recordEntry(verdict, { audience, gate_id, requested_action, display_name, decision, created_at } = {}) {
99
+ const v = verdict || {};
100
+ return {
101
+ agent_id: v.agent_id || null,
102
+ operator_id: v.operator_id || null,
103
+ created_at: created_at || Date.now(),
104
+ tier: v.tier || null,
105
+ delegation_valid: v.delegation_valid === true,
106
+ effective_scope: Array.isArray(v.effective_scope) ? v.effective_scope : [],
107
+ gate_id: gate_id || null,
108
+ requested_action: requested_action || null,
109
+ display_name: display_name || null,
110
+ decision: decision || (v.accepted ? 'auto_allow' : 'denied'),
111
+ reason: v.accepted ? null : v.reason || v.code || 'denied',
112
+ audience: audience || null,
113
+ };
114
+ }
115
+
116
+ /**
117
+ * The platform's access ledger. Wraps a store and gives you the log helpers a
118
+ * "who's using my platform" view needs.
119
+ */
120
+ export class AccessLedger {
121
+ constructor({ store } = {}) {
122
+ this.store = store || new MemoryLedgerStore();
123
+ }
124
+
125
+ /**
126
+ * Log a verdict. Returns the persisted entry. `fields` carries the same
127
+ * optional arrival fields as `recordEntry` (audience, gate_id,
128
+ * requested_action, display_name, decision override).
129
+ */
130
+ async record(verdict, fields = {}) {
131
+ const entry = recordEntry(verdict, fields);
132
+ await this.store.append(entry);
133
+ return entry;
134
+ }
135
+
136
+ /** Most recent arrivals, newest first. */
137
+ async recent(opts = {}) {
138
+ return this.store.recent(opts);
139
+ }
140
+
141
+ /** Recent arrivals from a single operator, newest first. */
142
+ async byOperator(operatorId, opts = {}) {
143
+ return this.store.byOperator(operatorId, opts);
144
+ }
145
+ }
146
+
147
+ /**
148
+ * Wrap a gate (or any `(request) => Promise<verdict>`) so every verdict is
149
+ * logged to a ledger before it's returned. Drop-in around `aitGate(...)` or
150
+ * `authorizer.gate(gateId)`:
151
+ *
152
+ * const ledger = new AccessLedger();
153
+ * const gate = loggedGate(aitGate({ audience }), ledger, { audience, gate_id });
154
+ * const verdict = await gate(request); // logged as a side effect
155
+ *
156
+ * `fields` are the static arrival fields to stamp on every entry (audience,
157
+ * gate_id, requested_action, display_name). Logging failures never block the
158
+ * request — a store hiccup must not turn an accepted agent away. The verdict is
159
+ * always returned.
160
+ */
161
+ export function loggedGate(gate, ledger, fields = {}) {
162
+ return async function loggingGate(request) {
163
+ const verdict = await gate(request);
164
+ try {
165
+ await ledger.record(verdict, fields);
166
+ } catch {
167
+ /* never let a ledger write failure change the verdict */
168
+ }
169
+ return verdict;
170
+ };
171
+ }