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.
@@ -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
+ }
@@ -0,0 +1,182 @@
1
+ /**
2
+ * server.js — a complete, runnable AXIS-gated platform on Express.
3
+ *
4
+ * This is the worked example: a pretend comments service that only accepts
5
+ * AXIS-verified agents holding `content:comment`, plus the stateful half a real
6
+ * platform needs — an access ledger ("who showed up") and a runtime blocklist
7
+ * ("boot this one") with a tiny admin console over both.
8
+ *
9
+ * npm install
10
+ * npm start # http://localhost:8787
11
+ *
12
+ * Then:
13
+ * curl -s localhost:8787/.well-known/axis-access | jq # your published door policy
14
+ * curl -s -XPOST localhost:8787/comments -d '{"text":"hi"}' # 401: no AIT
15
+ * open http://localhost:8787/admin # arrivals + boot console
16
+ *
17
+ * The simplest integration is one import + one line:
18
+ * import { axisGate } from 'axis-platform-sdk/express';
19
+ * app.post('/comments', axisGate({ audience, requireScopes: ['content:comment'] }), handler);
20
+ * This file shows the fuller, stateful version (door policy + ledger + blocklist
21
+ * + admin) — it inlines verify + the runtime blocklist so it can log arrivals and
22
+ * boot agents, which a bare `axisGate` doesn't do.
23
+ *
24
+ * What's the "real" integration vs. the demo scaffolding?
25
+ * - axisGate (from axis-platform-sdk/express) -> the one-liner above, for simple routes.
26
+ * - the SwitchAuthorizer door policy below -> COPY; it's your editable gate config.
27
+ * - the AccessLedger + Blocklist wiring -> COPY if you want arrivals/boot.
28
+ * - the in-memory stores -> SWAP for your DB in production
29
+ * (see "Persisting state" in the README).
30
+ * - the /admin HTML -> demo only; build your own console.
31
+ */
32
+ import express from 'express';
33
+ import {
34
+ SwitchAuthorizer,
35
+ verifyAgent,
36
+ AccessLedger,
37
+ Blocklist,
38
+ enrich,
39
+ blockAndReport,
40
+ } from 'axis-platform-sdk';
41
+ import { extractToken } from 'axis-platform-sdk/express';
42
+
43
+ // --- your platform's identity + policy --------------------------------------
44
+ const AUDIENCE = process.env.AXIS_AUDIENCE || 'comments.demo-platform.example';
45
+ const PLATFORM_ID = process.env.AXIS_PLATFORM_ID || 'axis:demo-platform:door'; // attestor id on boot-reports
46
+ const PORT = Number(process.env.PORT || 8787);
47
+ // const REPUTATION_URL = process.env.AXIS_REPUTATION_URL; // OFF by default
48
+
49
+ // The editable "Door policy": named gates, each on/off, with optional tier/scope.
50
+ // Flip `enabled: false` and the gate closes with no code change.
51
+ const door = new SwitchAuthorizer({
52
+ audience: AUDIENCE,
53
+ defaultAllow: false,
54
+ gates: {
55
+ 'content:comment': {
56
+ enabled: true,
57
+ requireScopes: ['content:comment'],
58
+ // minTier: 'domain', // require domain-verified+ to comment
59
+ // blockedOperators: ['axis:spammer:operator'],
60
+ },
61
+ },
62
+ });
63
+
64
+ // Stateful stores. In-memory here (zero-infra); swap in your DB in production.
65
+ const ledger = new AccessLedger();
66
+ const blocklist = new Blocklist();
67
+
68
+ const app = express();
69
+ app.use(express.json());
70
+
71
+ // --- the gated action -------------------------------------------------------
72
+ // Identity verify + door policy + runtime blocklist + arrival logging, all in one.
73
+ app.post('/comments', async (req, res) => {
74
+ // Inject runtime operator-blocks into the policy, then verify.
75
+ const base = door.optsForGate('content:comment');
76
+ const dynBlocked = await blocklist.blockedOperatorIds();
77
+ let verdict = await verifyAgent(extractToken(req), {
78
+ ...base,
79
+ blockedOperators: [...(base.blockedOperators || []), ...dynBlocked],
80
+ });
81
+ // Agent-level runtime block (needs the resolved agent_id from the verdict).
82
+ verdict = await blocklist.checkVerdict(verdict);
83
+
84
+ // Log the arrival regardless of decision; a ledger hiccup never changes it.
85
+ await ledger.record(verdict, { audience: AUDIENCE, gate_id: 'content:comment', requested_action: 'post a comment' }).catch(() => {});
86
+
87
+ if (!verdict.accepted) {
88
+ const status = verdict.code === 'no_token' ? 401 : 403;
89
+ return res.status(status).json({ error: verdict.code || 'denied', message: verdict.reason });
90
+ }
91
+ return res.json({ ok: true, posted_by: verdict.agent_id, operator: verdict.operator_id, scope: verdict.effective_scope, comment: req.body?.text || '' });
92
+ });
93
+
94
+ // --- publish your door policy so AIT issuers know your audience + requirements
95
+ app.get('/.well-known/axis-access', (_req, res) => {
96
+ res.json({
97
+ axis_version: '0.3',
98
+ platform_id: AUDIENCE,
99
+ audience: AUDIENCE,
100
+ access_policy: { minimum_verification_level: 'email', required_scopes: ['content:comment'], allow_unverified: false },
101
+ });
102
+ });
103
+
104
+ // --- admin: recent arrivals (enriched for display) --------------------------
105
+ app.get('/admin/arrivals', async (req, res) => {
106
+ const limit = Number(req.query.limit || 25);
107
+ const rows = await ledger.recent({ limit });
108
+ const enriched = await Promise.all(rows.map(async (r) => {
109
+ if (!r.agent_id) return r;
110
+ try {
111
+ const info = await enrich(r.agent_id, null); // public layer only (no AIT here)
112
+ return { ...r, display_name: info.display_name, tier: info.tier };
113
+ } catch {
114
+ return r; // registry unreachable; show the raw id
115
+ }
116
+ }));
117
+ res.json({ arrivals: enriched });
118
+ });
119
+
120
+ // --- admin: boot an agent or operator (block + best-effort report) ----------
121
+ app.post('/admin/boot', async (req, res) => {
122
+ const reason = req.body?.reason || 'booted by platform admin';
123
+ if (req.body?.agent_id) {
124
+ const out = await blockAndReport(
125
+ blocklist,
126
+ { platformId: PLATFORM_ID, agentId: req.body.agent_id, operatorId: req.body.operator_id, category: req.body.category || 'abuse', reason },
127
+ { /* reputationUrl: REPUTATION_URL */ }
128
+ );
129
+ return res.json({ ok: true, ...out });
130
+ }
131
+ if (req.body?.operator_id) {
132
+ await blocklist.blockOperator(req.body.operator_id, reason);
133
+ return res.json({ ok: true, blocked_operator: req.body.operator_id });
134
+ }
135
+ return res.status(400).json({ ok: false, error: 'provide agent_id or operator_id' });
136
+ });
137
+
138
+ // --- admin: tiny HTML console ----------------------------------------------
139
+ app.get('/admin', (_req, res) => res.type('html').send(ADMIN_HTML));
140
+
141
+ app.get('/', (_req, res) => res.type('text').send('AXIS-gated comments demo. POST /comments with an AIT to get in. See /admin.\n'));
142
+
143
+ app.listen(PORT, () => {
144
+ console.log(`AXIS-gated platform on http://localhost:${PORT} (audience: ${AUDIENCE})`);
145
+ console.log(` door policy: http://localhost:${PORT}/.well-known/axis-access`);
146
+ console.log(` admin: http://localhost:${PORT}/admin`);
147
+ });
148
+
149
+ const ADMIN_HTML = `<!doctype html><meta charset=utf-8>
150
+ <title>AXIS bouncer — admin</title>
151
+ <style>
152
+ body{font:14px/1.5 system-ui,sans-serif;margin:2rem;max-width:60rem}
153
+ h1{font-size:1.2rem} table{border-collapse:collapse;width:100%;margin:1rem 0}
154
+ th,td{border:1px solid #ccc;padding:.35rem .5rem;text-align:left;font-size:13px}
155
+ .denied,.booted{color:#b00} .auto_allow,.approved{color:#070} .held{color:#a60}
156
+ button{cursor:pointer} code{background:#f3f3f3;padding:0 .25rem}
157
+ </style>
158
+ <h1>AXIS bouncer — arrivals</h1>
159
+ <p>Who showed up at <code>${AUDIENCE}</code>. Click <b>boot</b> to block + report an agent.</p>
160
+ <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>
161
+ <script>
162
+ async function load(){
163
+ const r=await fetch('/admin/arrivals?limit=50');const {arrivals}=await r.json();
164
+ const tb=document.querySelector('#t tbody');tb.innerHTML='';
165
+ for(const a of arrivals){
166
+ const tr=document.createElement('tr');
167
+ const name=a.display_name?a.display_name+' ':'';
168
+ const when=a.created_at?new Date(a.created_at).toISOString().replace('T',' ').slice(0,19):'';
169
+ tr.innerHTML='<td>'+when+'</td><td>'+name+'<code>'+(a.agent_id||'?')+'</code></td>'+
170
+ '<td><code>'+(a.operator_id||'?')+'</code></td><td>'+(a.effective_scope||[]).join(', ')+'</td>'+
171
+ '<td class="'+a.decision+'">'+a.decision+'</td>'+
172
+ '<td>'+(a.agent_id?'<button data-a="'+a.agent_id+'">boot</button>':'')+'</td>';
173
+ tb.appendChild(tr);
174
+ }
175
+ tb.querySelectorAll('button').forEach(b=>b.onclick=async()=>{
176
+ await fetch('/admin/boot',{method:'POST',headers:{'Content-Type':'application/json'},
177
+ body:JSON.stringify({agent_id:b.dataset.a,reason:'booted from console',category:'abuse'})});
178
+ load();
179
+ });
180
+ }
181
+ load();
182
+ </script>`;
@@ -0,0 +1,63 @@
1
+ /**
2
+ * smoke.js — proves the drop-in actually gates, no real agent required.
3
+ *
4
+ * Boots a throwaway Express app using the real `axisGate` middleware from
5
+ * `axis-platform-sdk/express`, then checks the two deny paths against the live
6
+ * registry:
7
+ * - no AIT -> 401 no_token
8
+ * - an invalid AIT -> 403 (bounced)
9
+ *
10
+ * The accept path needs a real delegated AIT from an onboarded agent (see the
11
+ * README), so it isn't asserted here — but every line of the gate it would run
12
+ * through is exercised by these two.
13
+ *
14
+ * npm install && npm run smoke
15
+ */
16
+ import express from 'express';
17
+ import { axisGate } from 'axis-platform-sdk/express';
18
+
19
+ const AUDIENCE = 'comments.demo-platform.example';
20
+
21
+ const app = express();
22
+ app.use(express.json());
23
+ app.post(
24
+ '/comments',
25
+ axisGate({ audience: AUDIENCE, requireScopes: ['content:comment'] }),
26
+ (req, res) => res.json({ ok: true, by: req.axis.agent_id })
27
+ );
28
+
29
+ const server = app.listen(0);
30
+ await new Promise((r) => server.once('listening', r));
31
+ const base = `http://localhost:${server.address().port}`;
32
+
33
+ let failures = 0;
34
+ const check = (name, cond, detail = '') => {
35
+ console.log(`${cond ? 'PASS' : 'FAIL'} — ${name}${detail ? ` ${detail}` : ''}`);
36
+ if (!cond) failures++;
37
+ };
38
+
39
+ // `connection: close` so neither side keeps a socket alive — lets the process
40
+ // exit on its own without a forced process.exit() (which trips a libuv handle
41
+ // assertion on Windows when the fetch keep-alive pool is still up).
42
+ const noKeepAlive = { 'content-type': 'application/json', connection: 'close' };
43
+
44
+ // 1. No AIT presented -> 401 no_token.
45
+ let r = await fetch(`${base}/comments`, { method: 'POST', headers: noKeepAlive, body: '{}' });
46
+ let b = await r.json().catch(() => ({}));
47
+ check('no AIT -> 401 no_token', r.status === 401 && b.error === 'no_token', `(got ${r.status} ${b.error})`);
48
+
49
+ // 2. An invalid AIT -> 403 (bounced; reason comes from the registry/decode).
50
+ r = await fetch(`${base}/comments`, {
51
+ method: 'POST',
52
+ headers: { ...noKeepAlive, authorization: 'Bearer not.a.jwt' },
53
+ body: '{}',
54
+ });
55
+ b = await r.json().catch(() => ({}));
56
+ check('invalid AIT -> 403 denied', r.status === 403, `(got ${r.status} ${b.error} — "${b.message}")`);
57
+
58
+ console.log(failures ? `\n${failures} check(s) failed.` : '\nAll checks passed.');
59
+ // Stop listening and drop any lingering sockets; the event loop then drains and
60
+ // the process exits with this code on its own. No process.exit() needed.
61
+ process.exitCode = failures ? 1 : 0;
62
+ server.closeAllConnections?.();
63
+ server.close();