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
|
@@ -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();
|