axis-platform-sdk 0.2.1 → 0.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.
package/src/ledger.js CHANGED
@@ -1,171 +1,170 @@
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
- }
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: the cloud-hosted version is the D1-backed product instance
12
+ * of this port — its `arrivals` table + `recordArrival()` ARE this ledger over
13
+ * D1. This module is the library form (the shape + an in-memory default); the
14
+ * cloud-hosted version is the production adapter. The entry shape below is
15
+ * deliberately byte-compatible with its `ArrivalRecord` / `arrivals` columns so
16
+ * there is ONE arrival record across the SDK and the cloud product, not two.
17
+ *
18
+ * Trust note: the only scope worth recording is `effective_scope` (the
19
+ * registry's chain-walked, trustworthy scope), which is exactly what a verdict
20
+ * carries. We never persist the AIT's self-declared scope.
21
+ *
22
+ * --- Adapter shape -------------------------------------------------------
23
+ * A store is any object implementing:
24
+ *
25
+ * async append(entry) -> void // persist one arrival
26
+ * async recent({ limit }) -> entry[] // newest first
27
+ * async byOperator(operatorId, { limit }) -> entry[] // newest first
28
+ *
29
+ * `entry` is the record shape produced by `recordEntry()` — the same fields The
30
+ * Door's `arrivals` table holds (minus the adapter's own PK/org columns):
31
+ * {
32
+ * agent_id, operator_id, created_at, // created_at = epoch ms (Date.now())
33
+ * tier, delegation_valid, effective_scope: string[],
34
+ * gate_id, requested_action, display_name,
35
+ * decision: 'auto_allow'|'denied'|'held'|'approved'|'booted',
36
+ * reason, audience
37
+ * }
38
+ *
39
+ * The same adapter shape is shared with blocklist.js (a tiny CRUD port). A
40
+ * platform implements both against whatever it already runs. The defaults here
41
+ * keep the demo and the free tier zero-infra.
42
+ */
43
+
44
+ /**
45
+ * Default in-memory ledger store. Newest-first iteration. Bounded so a
46
+ * long-running Worker isolate doesn't grow without limit; override `max` (0 =
47
+ * unbounded) when you want the full history and have a real store behind it.
48
+ */
49
+ export class MemoryLedgerStore {
50
+ constructor({ max = 10000 } = {}) {
51
+ this._entries = []; // chronological; newest pushed to the end
52
+ this.max = max;
53
+ }
54
+
55
+ async append(entry) {
56
+ this._entries.push(entry);
57
+ if (this.max && this._entries.length > this.max) {
58
+ this._entries.splice(0, this._entries.length - this.max);
59
+ }
60
+ }
61
+
62
+ async recent({ limit = 50 } = {}) {
63
+ const out = this._entries.slice(-limit);
64
+ out.reverse();
65
+ return out;
66
+ }
67
+
68
+ async byOperator(operatorId, { limit = 50 } = {}) {
69
+ const out = [];
70
+ for (let i = this._entries.length - 1; i >= 0 && out.length < limit; i--) {
71
+ if (this._entries[i].operator_id === operatorId) out.push(this._entries[i]);
72
+ }
73
+ return out;
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Normalize a verdict (from verifyAgent) into a ledger entry. The output is
79
+ * byte-compatible with the cloud-hosted version's `ArrivalRecord` so the SDK and
80
+ * the cloud product share one arrival shape.
81
+ *
82
+ * `decision` defaults from the verdict (accepted -> 'auto_allow', else
83
+ * 'denied'); a caller may override it with a manual-review state a real bouncer
84
+ * needs ('held' | 'approved' | 'booted'). `created_at` is epoch ms (Date.now()),
85
+ * matching the cloud-hosted version's column; for an ISO string use
86
+ * `new Date(entry.created_at).toISOString()`.
87
+ *
88
+ * @param {object} verdict A verifyAgent verdict.
89
+ * @param {object} [fields]
90
+ * @param {string} [fields.audience] Platform audience (optional metadata; the cloud-hosted version doesn't persist it).
91
+ * @param {string} [fields.gate_id] Which gate was requested.
92
+ * @param {string} [fields.requested_action] Human-facing action label.
93
+ * @param {string} [fields.display_name] Enriched presentation name.
94
+ * @param {string} [fields.decision] Override decision ('held'|'approved'|'booted').
95
+ * @param {number} [fields.created_at] Override timestamp (epoch ms).
96
+ */
97
+ export function recordEntry(verdict, { audience, gate_id, requested_action, display_name, decision, created_at } = {}) {
98
+ const v = verdict || {};
99
+ return {
100
+ agent_id: v.agent_id || null,
101
+ operator_id: v.operator_id || null,
102
+ created_at: created_at || Date.now(),
103
+ tier: v.tier || null,
104
+ delegation_valid: v.delegation_valid === true,
105
+ effective_scope: Array.isArray(v.effective_scope) ? v.effective_scope : [],
106
+ gate_id: gate_id || null,
107
+ requested_action: requested_action || null,
108
+ display_name: display_name || null,
109
+ decision: decision || (v.accepted ? 'auto_allow' : 'denied'),
110
+ reason: v.accepted ? null : v.reason || v.code || 'denied',
111
+ audience: audience || null,
112
+ };
113
+ }
114
+
115
+ /**
116
+ * The platform's access ledger. Wraps a store and gives you the log helpers a
117
+ * "who's using my platform" view needs.
118
+ */
119
+ export class AccessLedger {
120
+ constructor({ store } = {}) {
121
+ this.store = store || new MemoryLedgerStore();
122
+ }
123
+
124
+ /**
125
+ * Log a verdict. Returns the persisted entry. `fields` carries the same
126
+ * optional arrival fields as `recordEntry` (audience, gate_id,
127
+ * requested_action, display_name, decision override).
128
+ */
129
+ async record(verdict, fields = {}) {
130
+ const entry = recordEntry(verdict, fields);
131
+ await this.store.append(entry);
132
+ return entry;
133
+ }
134
+
135
+ /** Most recent arrivals, newest first. */
136
+ async recent(opts = {}) {
137
+ return this.store.recent(opts);
138
+ }
139
+
140
+ /** Recent arrivals from a single operator, newest first. */
141
+ async byOperator(operatorId, opts = {}) {
142
+ return this.store.byOperator(operatorId, opts);
143
+ }
144
+ }
145
+
146
+ /**
147
+ * Wrap a gate (or any `(request) => Promise<verdict>`) so every verdict is
148
+ * logged to a ledger before it's returned. Drop-in around `aitGate(...)` or
149
+ * `authorizer.gate(gateId)`:
150
+ *
151
+ * const ledger = new AccessLedger();
152
+ * const gate = loggedGate(aitGate({ audience }), ledger, { audience, gate_id });
153
+ * const verdict = await gate(request); // logged as a side effect
154
+ *
155
+ * `fields` are the static arrival fields to stamp on every entry (audience,
156
+ * gate_id, requested_action, display_name). Logging failures never block the
157
+ * request a store hiccup must not turn an accepted agent away. The verdict is
158
+ * always returned.
159
+ */
160
+ export function loggedGate(gate, ledger, fields = {}) {
161
+ return async function loggingGate(request) {
162
+ const verdict = await gate(request);
163
+ try {
164
+ await ledger.record(verdict, fields);
165
+ } catch {
166
+ /* never let a ledger write failure change the verdict */
167
+ }
168
+ return verdict;
169
+ };
170
+ }
package/src/scope.js CHANGED
@@ -1,46 +1,46 @@
1
- // Scope matching for the AXIS scope grammar (spec v0.2 §4.4).
2
- //
3
- // Ported verbatim from kipple-governor/src/scope-match.ts so the operator side
4
- // (Governor proxy chain-walk) and the platform side (this SDK) agree on scope
5
- // semantics exactly. Colon-separated segments; `*` is a wildcard for ONE
6
- // segment, not multi; no recursion. If the protocol grammar changes, change it
7
- // in both places.
8
-
9
- /**
10
- * Does `granted` cover `required`? I.e. can a caller granted `granted` claim
11
- * the permission `required`?
12
- *
13
- * scopeCovers('admin:*', 'admin:users') === true
14
- * scopeCovers('admin:users', 'admin:users') === true
15
- * scopeCovers('admin:users', 'admin:roles') === false
16
- * scopeCovers('admin:*', 'admin:users:delete') === false // * = 1 segment
17
- * scopeCovers('anthropic:complete', 'anthropic:complete') === true
18
- */
19
- export function scopeCovers(granted, required) {
20
- if (!granted || !required) return false;
21
- if (granted === required) return true;
22
- const g = granted.split(':');
23
- const r = required.split(':');
24
- if (g.length !== r.length) return false;
25
- for (let i = 0; i < g.length; i++) {
26
- if (g[i] === '*') continue;
27
- if (g[i] !== r[i]) return false;
28
- }
29
- return true;
30
- }
31
-
32
- /**
33
- * Do the granted scopes cover EVERY required scope? Empty `required` is
34
- * trivially satisfied. Returns the missing required scopes on failure so a
35
- * caller can put them in a denial reason.
36
- *
37
- * @returns {{ ok: boolean, missing: string[] }}
38
- */
39
- export function coversAll(granted, required) {
40
- const grantedList = Array.isArray(granted) ? granted : [];
41
- const missing = [];
42
- for (const req of required) {
43
- if (!grantedList.some((g) => scopeCovers(g, req))) missing.push(req);
44
- }
45
- return { ok: missing.length === 0, missing };
46
- }
1
+ // Scope matching for the AXIS scope grammar (spec v0.2 §4.4).
2
+ //
3
+ // Ported verbatim from the operator-side gateway's scope matcher so the operator
4
+ // side (the outbound proxy chain-walk) and the platform side (this SDK) agree on
5
+ // scope semantics exactly. Colon-separated segments; `*` is a wildcard for ONE
6
+ // segment, not multi; no recursion. If the protocol grammar changes, change it
7
+ // in both places.
8
+
9
+ /**
10
+ * Does `granted` cover `required`? I.e. can a caller granted `granted` claim
11
+ * the permission `required`?
12
+ *
13
+ * scopeCovers('admin:*', 'admin:users') === true
14
+ * scopeCovers('admin:users', 'admin:users') === true
15
+ * scopeCovers('admin:users', 'admin:roles') === false
16
+ * scopeCovers('admin:*', 'admin:users:delete') === false // * = 1 segment
17
+ * scopeCovers('anthropic:complete', 'anthropic:complete') === true
18
+ */
19
+ export function scopeCovers(granted, required) {
20
+ if (!granted || !required) return false;
21
+ if (granted === required) return true;
22
+ const g = granted.split(':');
23
+ const r = required.split(':');
24
+ if (g.length !== r.length) return false;
25
+ for (let i = 0; i < g.length; i++) {
26
+ if (g[i] === '*') continue;
27
+ if (g[i] !== r[i]) return false;
28
+ }
29
+ return true;
30
+ }
31
+
32
+ /**
33
+ * Do the granted scopes cover EVERY required scope? Empty `required` is
34
+ * trivially satisfied. Returns the missing required scopes on failure so a
35
+ * caller can put them in a denial reason.
36
+ *
37
+ * @returns {{ ok: boolean, missing: string[] }}
38
+ */
39
+ export function coversAll(granted, required) {
40
+ const grantedList = Array.isArray(granted) ? granted : [];
41
+ const missing = [];
42
+ for (const req of required) {
43
+ if (!grantedList.some((g) => scopeCovers(g, req))) missing.push(req);
44
+ }
45
+ return { ok: missing.length === 0, missing };
46
+ }
@@ -0,0 +1,79 @@
1
+ # AXIS gate — Cloudflare Worker starter
2
+
3
+ Gate a Cloudflare Worker on **AXIS verified-agent identity**. When an AI agent
4
+ shows up and wants to act, this verifies *who it is* and *what it's allowed to
5
+ do* before your handler runs.
6
+
7
+ Use this template if your platform already runs on Workers. **You do not need to
8
+ adopt Cloudflare just to use AXIS** — if you run a Node backend, use the
9
+ `node-express` starter instead. Both wrap the same zero-dependency engine.
10
+
11
+ ## Run it
12
+
13
+ ```bash
14
+ npm install
15
+ npx wrangler dev # http://localhost:8787
16
+ ```
17
+
18
+ ```bash
19
+ # Your published door policy:
20
+ curl -s localhost:8787/.well-known/axis-access
21
+
22
+ # No AIT -> 401:
23
+ curl -i -X POST localhost:8787/comments -H 'content-type: application/json' -d '{"text":"hi"}'
24
+
25
+ # Arrivals + boot console:
26
+ open http://localhost:8787/admin
27
+ ```
28
+
29
+ ## Deploy it
30
+
31
+ ```bash
32
+ npx wrangler deploy
33
+ ```
34
+
35
+ No bindings are required for the free standalone path — the Worker's only
36
+ outbound call is one HTTPS GET to the public AXIS registry.
37
+
38
+ ## The drop-in
39
+
40
+ The minimal version is a few lines:
41
+
42
+ ```js
43
+ import { aitGate, denialResponse } from 'axis-platform-sdk';
44
+
45
+ const gate = aitGate({ audience: 'comments.mysite.com', requireScopes: ['content:comment'] });
46
+
47
+ export default {
48
+ async fetch(request) {
49
+ const verdict = await gate(request);
50
+ if (!verdict.accepted) return denialResponse(verdict); // 401/403 + reason
51
+ // verdict.agent_id is verified. Proceed.
52
+ return Response.json({ ok: true, by: verdict.agent_id });
53
+ },
54
+ };
55
+ ```
56
+
57
+ `src/worker.js` is the fuller version: a `SwitchAuthorizer` door policy (named
58
+ on/off gates you can edit like config), an access ledger ("who showed up"), a
59
+ runtime blocklist ("boot this one"), and a tiny `/admin` console.
60
+
61
+ ## Options
62
+
63
+ `aitGate(opts)` / `verifyAgent(token, opts)` take:
64
+
65
+ | option | meaning |
66
+ | --- | --- |
67
+ | `audience` | Your platform's stable id. The AIT's `aud` must equal it. |
68
+ | `requireScopes` | Scopes checked against the trustworthy `effective_scope`. |
69
+ | `minTier` | `email` < `domain` < `verified` < `kyb_individual` < `kyb_organization`. |
70
+ | `blockedOperators` / `approvedOperators` | Deny / allow by operator id. |
71
+ | `registryBaseUrl` | Defaults to `https://registry.axisprime.ai`. |
72
+
73
+ ## Persisting state in production
74
+
75
+ The in-memory ledger/blocklist reset when the isolate recycles. For durable
76
+ arrivals + blocks, add a D1 binding in `wrangler.toml` and implement the SDK's
77
+ documented store adapter shape against it (see `ledger.js` / `blocklist.js` in
78
+ the SDK). The arrival record shape is byte-compatible with the cloud-hosted version
79
+ (in alpha, Q3 2026), so moving to it later is a lift, not a rewrite.
@@ -0,0 +1,17 @@
1
+ {
2
+ "name": "axis-gate-worker-starter",
3
+ "version": "1.0.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "description": "Drop-in starter: gate a Cloudflare Worker platform on AXIS verified-agent identity.",
7
+ "scripts": {
8
+ "dev": "wrangler dev",
9
+ "deploy": "wrangler deploy"
10
+ },
11
+ "dependencies": {
12
+ "axis-platform-sdk": "^0.2.1"
13
+ },
14
+ "devDependencies": {
15
+ "wrangler": "^3.90.0"
16
+ }
17
+ }