axis-platform-sdk 0.2.0 → 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/scope.d.ts ADDED
@@ -0,0 +1 @@
1
+ export { scopeCovers, coversAll } from './index.js';
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
+ }
@@ -0,0 +1,162 @@
1
+ /**
2
+ * worker.js — a complete, deployable AXIS-gated platform on Cloudflare Workers.
3
+ *
4
+ * A pretend comments service that only accepts AXIS-verified agents holding
5
+ * `content:comment`, with the stateful half a real platform needs: an access
6
+ * ledger ("who showed up") and a runtime blocklist ("boot this one"), plus a
7
+ * tiny admin console over both.
8
+ *
9
+ * npm install
10
+ * npx wrangler dev # local: http://localhost:8787
11
+ * npx wrangler deploy # ship it
12
+ *
13
+ * What's the "real" integration vs. the demo scaffolding?
14
+ * - the SwitchAuthorizer door policy -> COPY; it's your editable gate config.
15
+ * - the gate + ledger + blocklist wiring on POST /comments -> COPY.
16
+ * - the in-memory stores -> SWAP for D1 / KV in production (the
17
+ * stores reset when the isolate recycles).
18
+ * - the /admin HTML -> demo only; build your own console.
19
+ */
20
+ import {
21
+ SwitchAuthorizer,
22
+ verifyAgent,
23
+ extractToken,
24
+ denialResponse,
25
+ AccessLedger,
26
+ Blocklist,
27
+ enrich,
28
+ blockAndReport,
29
+ } from 'axis-platform-sdk';
30
+
31
+ const AUDIENCE = 'comments.demo-platform.example'; // your platform's stable audience id
32
+ const PLATFORM_ID = 'axis:demo-platform:door'; // this platform's AXIS id (attestor on boot-reports)
33
+ // const REPUTATION_URL = 'https://axis-reputation.example/attestations'; // OFF by default
34
+
35
+ // The editable "Door policy": named gates, each on/off, with optional tier/scope.
36
+ const door = new SwitchAuthorizer({
37
+ audience: AUDIENCE,
38
+ defaultAllow: false,
39
+ gates: {
40
+ 'content:comment': {
41
+ enabled: true,
42
+ requireScopes: ['content:comment'],
43
+ // minTier: 'domain',
44
+ // blockedOperators: ['axis:spammer:operator'],
45
+ },
46
+ },
47
+ });
48
+
49
+ // Stateful stores. In-memory reference (resets on isolate recycle); swap in
50
+ // D1 / KV via the documented adapter shape for production.
51
+ const ledger = new AccessLedger();
52
+ const blocklist = new Blocklist();
53
+
54
+ export default {
55
+ async fetch(request) {
56
+ const url = new URL(request.url);
57
+
58
+ // --- the gated action ---------------------------------------------------
59
+ if (url.pathname === '/comments' && request.method === 'POST') {
60
+ const base = door.optsForGate('content:comment');
61
+ const dynBlocked = await blocklist.blockedOperatorIds();
62
+ let verdict = await verifyAgent(extractToken(request), {
63
+ ...base,
64
+ blockedOperators: [...(base.blockedOperators || []), ...dynBlocked],
65
+ });
66
+ verdict = await blocklist.checkVerdict(verdict);
67
+
68
+ await ledger.record(verdict, { audience: AUDIENCE, gate_id: 'content:comment', requested_action: 'post a comment' }).catch(() => {});
69
+
70
+ if (!verdict.accepted) return denialResponse(verdict);
71
+ const body = await request.json().catch(() => ({}));
72
+ return Response.json({ ok: true, posted_by: verdict.agent_id, operator: verdict.operator_id, scope: verdict.effective_scope, comment: body.text || '' });
73
+ }
74
+
75
+ // --- publish your door policy ------------------------------------------
76
+ if (url.pathname === '/.well-known/axis-access') {
77
+ return Response.json({
78
+ axis_version: '0.3',
79
+ platform_id: AUDIENCE,
80
+ audience: AUDIENCE,
81
+ access_policy: { minimum_verification_level: 'email', required_scopes: ['content:comment'], allow_unverified: false },
82
+ });
83
+ }
84
+
85
+ // --- admin: recent arrivals (enriched for display) ----------------------
86
+ if (url.pathname === '/admin/arrivals' && request.method === 'GET') {
87
+ const limit = Number(url.searchParams.get('limit') || 25);
88
+ const rows = await ledger.recent({ limit });
89
+ const enriched = await Promise.all(rows.map(async (r) => {
90
+ if (!r.agent_id) return r;
91
+ try {
92
+ const info = await enrich(r.agent_id, null);
93
+ return { ...r, display_name: info.display_name, tier: info.tier };
94
+ } catch {
95
+ return r;
96
+ }
97
+ }));
98
+ return Response.json({ arrivals: enriched });
99
+ }
100
+
101
+ // --- admin: boot an agent or operator (block + best-effort report) ------
102
+ if (url.pathname === '/admin/boot' && request.method === 'POST') {
103
+ const body = await request.json().catch(() => ({}));
104
+ const reason = body.reason || 'booted by platform admin';
105
+ if (body.agent_id) {
106
+ const out = await blockAndReport(
107
+ blocklist,
108
+ { platformId: PLATFORM_ID, agentId: body.agent_id, operatorId: body.operator_id, category: body.category || 'abuse', reason },
109
+ { /* reputationUrl: REPUTATION_URL */ }
110
+ );
111
+ return Response.json({ ok: true, ...out });
112
+ }
113
+ if (body.operator_id) {
114
+ await blocklist.blockOperator(body.operator_id, reason);
115
+ return Response.json({ ok: true, blocked_operator: body.operator_id });
116
+ }
117
+ return Response.json({ ok: false, error: 'provide agent_id or operator_id' }, { status: 400 });
118
+ }
119
+
120
+ // --- admin: tiny HTML console ------------------------------------------
121
+ if (url.pathname === '/admin') {
122
+ return new Response(ADMIN_HTML, { headers: { 'Content-Type': 'text/html; charset=utf-8' } });
123
+ }
124
+
125
+ return new Response('AXIS-gated comments demo. POST /comments with an AIT to get in. See /admin.\n', { status: 200 });
126
+ },
127
+ };
128
+
129
+ const ADMIN_HTML = `<!doctype html><meta charset=utf-8>
130
+ <title>AXIS bouncer — admin</title>
131
+ <style>
132
+ body{font:14px/1.5 system-ui,sans-serif;margin:2rem;max-width:60rem}
133
+ h1{font-size:1.2rem} table{border-collapse:collapse;width:100%;margin:1rem 0}
134
+ th,td{border:1px solid #ccc;padding:.35rem .5rem;text-align:left;font-size:13px}
135
+ .denied,.booted{color:#b00} .auto_allow,.approved{color:#070} .held{color:#a60}
136
+ button{cursor:pointer} code{background:#f3f3f3;padding:0 .25rem}
137
+ </style>
138
+ <h1>AXIS bouncer — arrivals</h1>
139
+ <p>Who showed up at <code>${AUDIENCE}</code>. Click <b>boot</b> to block + report an agent.</p>
140
+ <table id=t><thead><tr><th>when</th><th>agent</th><th>operator</th><th>scope</th><th>decision</th><th></th></tr></thead><tbody></tbody></table>
141
+ <script>
142
+ async function load(){
143
+ const r=await fetch('/admin/arrivals?limit=50');const {arrivals}=await r.json();
144
+ const tb=document.querySelector('#t tbody');tb.innerHTML='';
145
+ for(const a of arrivals){
146
+ const tr=document.createElement('tr');
147
+ const name=a.display_name?a.display_name+' ':'';
148
+ const when=a.created_at?new Date(a.created_at).toISOString().replace('T',' ').slice(0,19):'';
149
+ tr.innerHTML='<td>'+when+'</td><td>'+name+'<code>'+(a.agent_id||'?')+'</code></td>'+
150
+ '<td><code>'+(a.operator_id||'?')+'</code></td><td>'+(a.effective_scope||[]).join(', ')+'</td>'+
151
+ '<td class="'+a.decision+'">'+a.decision+'</td>'+
152
+ '<td>'+(a.agent_id?'<button data-a="'+a.agent_id+'">boot</button>':'')+'</td>';
153
+ tb.appendChild(tr);
154
+ }
155
+ tb.querySelectorAll('button').forEach(b=>b.onclick=async()=>{
156
+ await fetch('/admin/boot',{method:'POST',headers:{'Content-Type':'application/json'},
157
+ body:JSON.stringify({agent_id:b.dataset.a,reason:'booted from console',category:'abuse'})});
158
+ load();
159
+ });
160
+ }
161
+ load();
162
+ </script>`;
@@ -0,0 +1,20 @@
1
+ # Minimal Wrangler config for the AXIS gate Worker starter.
2
+ # npx wrangler dev # run locally at http://localhost:8787
3
+ # npx wrangler deploy # deploy to your account
4
+ #
5
+ # Rename `name` to whatever you want the Worker to be called. No bindings are
6
+ # required for the free standalone path — the Worker only makes one outbound
7
+ # HTTPS call to the public AXIS registry.
8
+
9
+ name = "axis-gate-worker"
10
+ main = "src/worker.js"
11
+ compatibility_date = "2025-01-01"
12
+
13
+ # The in-memory ledger/blocklist reset when the isolate recycles. For durable
14
+ # arrivals + blocks, add a D1 binding (or KV) here and implement the SDK's
15
+ # documented store adapter shape against it. See the SDK's ledger.js / blocklist.js.
16
+ #
17
+ # [[d1_databases]]
18
+ # binding = "DB"
19
+ # database_name = "axis-gate"
20
+ # database_id = "<your-d1-id>"
@@ -0,0 +1,113 @@
1
+ # AXIS gate — Node / Express starter
2
+
3
+ Gate any Express platform on **AXIS verified-agent identity** in a few lines. When
4
+ an AI agent shows up and wants to act (post a comment, call your API, place an
5
+ order), this verifies *who it is* and *what it's allowed to do* before your
6
+ handler runs.
7
+
8
+ No Cloudflare. No account with us. No infra to stand up. The only network call is
9
+ one HTTPS GET to the public AXIS registry, which does the cryptography
10
+ (signature + revocation + delegation-chain walk) server-side and hands back a
11
+ trustworthy verdict.
12
+
13
+ ## Run it
14
+
15
+ ```bash
16
+ npm install
17
+ npm start # http://localhost:8787
18
+ ```
19
+
20
+ Then, in another terminal:
21
+
22
+ ```bash
23
+ # Your published door policy (what agents must satisfy to get in):
24
+ curl -s localhost:8787/.well-known/axis-access
25
+
26
+ # No AIT -> 401. The bouncer turns away anyone who doesn't present an identity:
27
+ curl -i -X POST localhost:8787/comments -H 'content-type: application/json' -d '{"text":"hi"}'
28
+
29
+ # Open the admin console to watch arrivals and boot a bad agent:
30
+ open http://localhost:8787/admin
31
+ ```
32
+
33
+ To prove the gate works without a real agent:
34
+
35
+ ```bash
36
+ npm run smoke # boots the real middleware, asserts the deny paths
37
+ ```
38
+
39
+ ## The whole integration is one import + one line
40
+
41
+ `axisGate` is a real export of the package — `axis-platform-sdk/express`. You
42
+ don't copy a file; you import it:
43
+
44
+ ```js
45
+ import express from 'express';
46
+ import { axisGate } from 'axis-platform-sdk/express';
47
+
48
+ const app = express();
49
+ app.use(express.json());
50
+
51
+ app.post('/comments',
52
+ axisGate({ audience: 'comments.mysite.com', requireScopes: ['content:comment'] }),
53
+ (req, res) => {
54
+ // req.axis.agent_id is a VERIFIED agent. Proceed with your real logic.
55
+ res.json({ ok: true, by: req.axis.agent_id });
56
+ });
57
+
58
+ app.listen(8787);
59
+ ```
60
+
61
+ `axisGate(opts)` pulls the AIT off the request (`Authorization: Bearer …`,
62
+ `X-AXIS-Token`, or `?ait=`), verifies it, and either calls `next()` with
63
+ `req.axis` set to the verdict, or responds `401` (no token) / `403` (policy) /
64
+ `503` (unexpected verify error) with `{ error, message }`. It imports nothing
65
+ from Express, so it works with Connect and bare `http` servers too.
66
+
67
+ ### Options (passed straight through to `verifyAgent`)
68
+
69
+ | option | meaning |
70
+ | --- | --- |
71
+ | `audience` | **Required-ish.** Your platform's stable id. The AIT's `aud` must equal it, so an agent's token for *another* site can't be replayed at yours. |
72
+ | `requireScopes` | Scopes the agent must hold, checked against the **trustworthy `effective_scope`** (the registry's chain-walked result — never the AIT's self-declared scope). |
73
+ | `minTier` | Minimum operator verification tier: `email` < `domain` < `verified` < `kyb_individual` < `kyb_organization`. |
74
+ | `blockedOperators` / `approvedOperators` | Deny-list / allow-list by operator id. |
75
+ | `registryBaseUrl` | Defaults to `https://registry.axisprime.ai`. Point at your own registry if you run one. |
76
+
77
+ ## What `server.js` adds (the stateful bouncer)
78
+
79
+ A real platform wants more than a yes/no. `server.js` shows the rest, all
80
+ zero-infra by default:
81
+
82
+ - **Door policy as config** — a `SwitchAuthorizer` with named, on/off gates.
83
+ Flip `enabled: false` and a gate closes with no code change. This object is
84
+ exactly what a "door policy" admin screen would edit.
85
+ - **Access ledger** — every arrival (accepted *or* denied) is logged so you can
86
+ answer "who's been using my platform."
87
+ - **Runtime blocklist** — boot one agent, or a whole operator, without a deploy.
88
+ - **`/admin` console** — a tiny HTML page listing arrivals with a **boot** button.
89
+
90
+ ## Persisting state in production
91
+
92
+ The ledger and blocklist default to in-memory stores — fine for the demo, gone
93
+ when the process restarts. For production, implement the documented adapter
94
+ shape (a small CRUD interface) against whatever database you already run
95
+ (Postgres, SQLite, Redis, …) and pass it in:
96
+
97
+ ```js
98
+ new AccessLedger({ store: myPostgresLedgerStore });
99
+ new Blocklist({ store: myPostgresBlocklistStore });
100
+ ```
101
+
102
+ The adapter shape is documented in the SDK's `ledger.js` / `blocklist.js`. The
103
+ entry shape is byte-compatible with the cloud-hosted version (in alpha, Q3 2026),
104
+ so if you move to it later, your arrival records already line up.
105
+
106
+ ## Letting a real agent in
107
+
108
+ The accept path needs an agent that has actually been delegated `content:comment`
109
+ by its operator and presents an AIT addressed to your `audience`. That agent gets
110
+ its identity from the **[AXIS Prime MCP](https://github.com/MachinesOfDesire/axis-mcp)**
111
+ (the agent side). Point a test agent at your `audience`, have its operator grant
112
+ `content:comment`, and POST its AIT to `/comments` — you'll see a `200` and the
113
+ arrival turns green in `/admin`.
@@ -0,0 +1,19 @@
1
+ {
2
+ "name": "axis-gate-express-starter",
3
+ "version": "1.0.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "description": "Drop-in starter: gate an Express platform on AXIS verified-agent identity.",
7
+ "main": "server.js",
8
+ "scripts": {
9
+ "start": "node server.js",
10
+ "smoke": "node smoke.js"
11
+ },
12
+ "engines": {
13
+ "node": ">=20.0.0"
14
+ },
15
+ "dependencies": {
16
+ "axis-platform-sdk": "^0.3.0",
17
+ "express": "^4.22.2"
18
+ }
19
+ }