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/CASE-STUDY-offworld.md +56 -0
- package/CHANGELOG.md +179 -97
- 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 -48
- package/src/authorizer.d.ts +1 -0
- package/src/authorizer.js +101 -101
- package/src/blocklist.d.ts +9 -0
- package/src/blocklist.js +183 -183
- package/src/express.d.ts +30 -0
- package/src/express.js +68 -0
- package/src/gate.d.ts +1 -0
- package/src/index.d.ts +288 -0
- package/src/ledger.d.ts +10 -0
- package/src/ledger.js +170 -171
- package/src/reportback.d.ts +15 -0
- package/src/scope.d.ts +1 -0
- 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
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
|
|
4
|
-
// (
|
|
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
|
+
}
|