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/CASE-STUDY-offworld.md +56 -0
- package/CHANGELOG.md +179 -112
- package/QUICKSTART.md +168 -0
- package/README.md +390 -218
- package/badges/README.md +41 -0
- package/badges/verified-by-axis-compact.svg +7 -0
- package/badges/verified-by-axis-dark.svg +7 -0
- package/badges/verified-by-axis.svg +7 -0
- package/examples/toy-platform-worker.js +60 -60
- package/package.json +54 -49
- package/src/authorizer.js +101 -101
- package/src/blocklist.js +183 -183
- package/src/express.d.ts +30 -0
- package/src/express.js +68 -0
- package/src/index.d.ts +288 -288
- package/src/ledger.js +170 -171
- package/src/scope.js +46 -46
- package/templates/cloudflare-worker/README.md +79 -0
- package/templates/cloudflare-worker/package.json +17 -0
- package/templates/cloudflare-worker/src/worker.js +162 -0
- package/templates/cloudflare-worker/wrangler.toml +20 -0
- package/templates/node-express/README.md +113 -0
- package/templates/node-express/package.json +19 -0
- package/templates/node-express/server.js +182 -0
- package/templates/node-express/smoke.js +63 -0
|
@@ -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();
|