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/src/blocklist.js CHANGED
@@ -1,183 +1,183 @@
1
- /**
2
- * Persistent block / allow list — the runtime, stateful counterpart to the
3
- * static `blockedOperators` / `approvedOperators` arrays that verifyAgent and
4
- * SwitchAuthorizer already take.
5
- *
6
- * The static lists are policy-at-config-time (what the door policy screen
7
- * saves). This is policy-at-runtime: a platform operator clicks "boot this
8
- * agent" and the decision sticks in the platform's OWN store, no redeploy. It
9
- * adds two things the static arrays don't have:
10
- *
11
- * 1. Agent-level blocking (block one misbehaving agent without blocking its
12
- * whole operator).
13
- * 2. Mutation at runtime (block / unblock) backed by a pluggable store.
14
- *
15
- * Canonical adapter: Owyhee "The Door" (governor#27) ships the OPERATOR-level
16
- * half of this port as its `operator_blocks` table + `blockedOperators()`,
17
- * merged into the SwitchAuthorizer policy at authorize time. This module is the
18
- * library form of that, plus the AGENT-level half The Door does not have yet
19
- * (an additive `agent_blocks` table is the natural way for it to adopt it — a
20
- * fast-follow, not a requirement). Block metadata is `{ reason, created_at }`
21
- * (epoch ms), matching The Door's columns so the shapes are one.
22
- *
23
- * Same adapter shape as the ledger — a tiny CRUD port a platform implements
24
- * against D1 / SQLite / Postgres. Default is in-memory.
25
- *
26
- * --- Adapter shape -------------------------------------------------------
27
- * A store is any object implementing:
28
- *
29
- * async add(kind, id, meta) -> void // kind: 'operator'|'agent'
30
- * async remove(kind, id) -> void
31
- * async has(kind, id) -> boolean
32
- * async list(kind) -> { id, meta }[] // all entries of a kind
33
- *
34
- * Integration: build the verifyAgent opts from a Blocklist and merge them with
35
- * your static policy (`opts(staticBlocked)` -> { blockedOperators }), and call
36
- * `checkVerdict(verdict)` AFTER verifyAgent to catch agent-level blocks (the
37
- * registry verdict carries operator_id and agent_id, and operator blocking is
38
- * already enforced by verifyAgent via blockedOperators, but agent-level
39
- * blocking needs the resolved agent_id, which only exists post-verify).
40
- */
41
-
42
- /** Default in-memory block/allow store. */
43
- export class MemoryBlocklistStore {
44
- constructor() {
45
- this._sets = { operator: new Map(), agent: new Map() };
46
- }
47
-
48
- async add(kind, id, meta = {}) {
49
- const m = this._sets[kind];
50
- if (!m) throw new Error(`MemoryBlocklistStore: unknown kind '${kind}'`);
51
- m.set(id, { ...meta, created_at: meta.created_at || Date.now() });
52
- }
53
-
54
- async remove(kind, id) {
55
- const m = this._sets[kind];
56
- if (m) m.delete(id);
57
- }
58
-
59
- async has(kind, id) {
60
- const m = this._sets[kind];
61
- return !!(m && m.has(id));
62
- }
63
-
64
- async list(kind) {
65
- const m = this._sets[kind];
66
- if (!m) return [];
67
- return [...m.entries()].map(([id, meta]) => ({ id, meta }));
68
- }
69
- }
70
-
71
- /**
72
- * Runtime block list. Holds two kinds of blocks — by operator_id and by
73
- * agent_id — over a pluggable store.
74
- */
75
- export class Blocklist {
76
- constructor({ store } = {}) {
77
- this.store = store || new MemoryBlocklistStore();
78
- }
79
-
80
- /** Block an operator (every agent under it). `reason` is recorded as meta. */
81
- async blockOperator(operatorId, reason) {
82
- await this.store.add('operator', operatorId, { reason });
83
- }
84
-
85
- /** Block a single agent without blocking its whole operator. */
86
- async blockAgent(agentId, reason) {
87
- await this.store.add('agent', agentId, { reason });
88
- }
89
-
90
- async unblockOperator(operatorId) {
91
- await this.store.remove('operator', operatorId);
92
- }
93
-
94
- async unblockAgent(agentId) {
95
- await this.store.remove('agent', agentId);
96
- }
97
-
98
- async isOperatorBlocked(operatorId) {
99
- if (!operatorId) return false;
100
- return this.store.has('operator', operatorId);
101
- }
102
-
103
- async isAgentBlocked(agentId) {
104
- if (!agentId) return false;
105
- return this.store.has('agent', agentId);
106
- }
107
-
108
- /** All blocked operator ids (for merging into verifyAgent's blockedOperators). */
109
- async blockedOperatorIds() {
110
- return (await this.store.list('operator')).map((e) => e.id);
111
- }
112
-
113
- async listOperators() {
114
- return this.store.list('operator');
115
- }
116
-
117
- async listAgents() {
118
- return this.store.list('agent');
119
- }
120
-
121
- /**
122
- * The verifyAgent option fragment this blocklist implies. Merge with your
123
- * static policy so a runtime-blocked operator is denied BEFORE the scope/tier
124
- * checks run:
125
- *
126
- * const dyn = await blocklist.verifyOpts();
127
- * const verdict = await verifyAgent(token, {
128
- * ...staticOpts,
129
- * blockedOperators: [...(staticOpts.blockedOperators||[]), ...dyn.blockedOperators],
130
- * });
131
- * const final = await blocklist.checkVerdict(verdict); // agent-level catch
132
- */
133
- async verifyOpts() {
134
- return { blockedOperators: await this.blockedOperatorIds() };
135
- }
136
-
137
- /**
138
- * Post-verify agent-level enforcement. verifyAgent already denies blocked
139
- * OPERATORS (when you fed it `blockedOperators`), but agent-level blocking
140
- * needs the resolved agent_id, which only exists after the registry verify.
141
- * Pass an accepted verdict through this; it flips it to denied if the agent
142
- * (or, as a safety net, the operator) is blocked. Pass-through otherwise.
143
- */
144
- async checkVerdict(verdict) {
145
- if (!verdict || !verdict.accepted) return verdict;
146
- if (await this.isAgentBlocked(verdict.agent_id)) {
147
- return {
148
- accepted: false,
149
- code: 'agent_blocked',
150
- reason: 'Agent is blocked at this platform',
151
- agent_id: verdict.agent_id,
152
- operator_id: verdict.operator_id,
153
- };
154
- }
155
- if (await this.isOperatorBlocked(verdict.operator_id)) {
156
- return {
157
- accepted: false,
158
- code: 'operator_blocked',
159
- reason: 'Operator is blocked at this platform',
160
- agent_id: verdict.agent_id,
161
- operator_id: verdict.operator_id,
162
- };
163
- }
164
- return verdict;
165
- }
166
- }
167
-
168
- /**
169
- * Wrap a gate so a runtime-blocked agent/operator is denied even if it's
170
- * globally valid and within policy. Sits OUTSIDE the verify call: it merges the
171
- * dynamic operator blocks into the verdict path via checkVerdict (agent-level)
172
- * and is the simplest way to bolt a Blocklist onto an existing gate.
173
- *
174
- * Note: operator-level dynamic blocks are most efficiently injected by feeding
175
- * `await blocklist.blockedOperatorIds()` into the gate's policy, but checkVerdict
176
- * also catches them here as a safety net, so this wrapper is correct on its own.
177
- */
178
- export function gatedWithBlocklist(gate, blocklist) {
179
- return async function blocklistGate(request) {
180
- const verdict = await gate(request);
181
- return blocklist.checkVerdict(verdict);
182
- };
183
- }
1
+ /**
2
+ * Persistent block / allow list — the runtime, stateful counterpart to the
3
+ * static `blockedOperators` / `approvedOperators` arrays that verifyAgent and
4
+ * SwitchAuthorizer already take.
5
+ *
6
+ * The static lists are policy-at-config-time (what the door policy screen
7
+ * saves). This is policy-at-runtime: a platform operator clicks "boot this
8
+ * agent" and the decision sticks in the platform's OWN store, no redeploy. It
9
+ * adds two things the static arrays don't have:
10
+ *
11
+ * 1. Agent-level blocking (block one misbehaving agent without blocking its
12
+ * whole operator).
13
+ * 2. Mutation at runtime (block / unblock) backed by a pluggable store.
14
+ *
15
+ * Canonical adapter: the cloud-hosted version ships the OPERATOR-level
16
+ * half of this port as its `operator_blocks` table + `blockedOperators()`,
17
+ * merged into the SwitchAuthorizer policy at authorize time. This module is the
18
+ * library form of that, plus the AGENT-level half the cloud-hosted version does
19
+ * not have yet (an additive `agent_blocks` table is the natural way for it to
20
+ * adopt it — a fast-follow, not a requirement). Block metadata is `{ reason,
21
+ * created_at }` (epoch ms), matching its columns so the shapes are one.
22
+ *
23
+ * Same adapter shape as the ledger — a tiny CRUD port a platform implements
24
+ * against D1 / SQLite / Postgres. Default is in-memory.
25
+ *
26
+ * --- Adapter shape -------------------------------------------------------
27
+ * A store is any object implementing:
28
+ *
29
+ * async add(kind, id, meta) -> void // kind: 'operator'|'agent'
30
+ * async remove(kind, id) -> void
31
+ * async has(kind, id) -> boolean
32
+ * async list(kind) -> { id, meta }[] // all entries of a kind
33
+ *
34
+ * Integration: build the verifyAgent opts from a Blocklist and merge them with
35
+ * your static policy (`opts(staticBlocked)` -> { blockedOperators }), and call
36
+ * `checkVerdict(verdict)` AFTER verifyAgent to catch agent-level blocks (the
37
+ * registry verdict carries operator_id and agent_id, and operator blocking is
38
+ * already enforced by verifyAgent via blockedOperators, but agent-level
39
+ * blocking needs the resolved agent_id, which only exists post-verify).
40
+ */
41
+
42
+ /** Default in-memory block/allow store. */
43
+ export class MemoryBlocklistStore {
44
+ constructor() {
45
+ this._sets = { operator: new Map(), agent: new Map() };
46
+ }
47
+
48
+ async add(kind, id, meta = {}) {
49
+ const m = this._sets[kind];
50
+ if (!m) throw new Error(`MemoryBlocklistStore: unknown kind '${kind}'`);
51
+ m.set(id, { ...meta, created_at: meta.created_at || Date.now() });
52
+ }
53
+
54
+ async remove(kind, id) {
55
+ const m = this._sets[kind];
56
+ if (m) m.delete(id);
57
+ }
58
+
59
+ async has(kind, id) {
60
+ const m = this._sets[kind];
61
+ return !!(m && m.has(id));
62
+ }
63
+
64
+ async list(kind) {
65
+ const m = this._sets[kind];
66
+ if (!m) return [];
67
+ return [...m.entries()].map(([id, meta]) => ({ id, meta }));
68
+ }
69
+ }
70
+
71
+ /**
72
+ * Runtime block list. Holds two kinds of blocks — by operator_id and by
73
+ * agent_id — over a pluggable store.
74
+ */
75
+ export class Blocklist {
76
+ constructor({ store } = {}) {
77
+ this.store = store || new MemoryBlocklistStore();
78
+ }
79
+
80
+ /** Block an operator (every agent under it). `reason` is recorded as meta. */
81
+ async blockOperator(operatorId, reason) {
82
+ await this.store.add('operator', operatorId, { reason });
83
+ }
84
+
85
+ /** Block a single agent without blocking its whole operator. */
86
+ async blockAgent(agentId, reason) {
87
+ await this.store.add('agent', agentId, { reason });
88
+ }
89
+
90
+ async unblockOperator(operatorId) {
91
+ await this.store.remove('operator', operatorId);
92
+ }
93
+
94
+ async unblockAgent(agentId) {
95
+ await this.store.remove('agent', agentId);
96
+ }
97
+
98
+ async isOperatorBlocked(operatorId) {
99
+ if (!operatorId) return false;
100
+ return this.store.has('operator', operatorId);
101
+ }
102
+
103
+ async isAgentBlocked(agentId) {
104
+ if (!agentId) return false;
105
+ return this.store.has('agent', agentId);
106
+ }
107
+
108
+ /** All blocked operator ids (for merging into verifyAgent's blockedOperators). */
109
+ async blockedOperatorIds() {
110
+ return (await this.store.list('operator')).map((e) => e.id);
111
+ }
112
+
113
+ async listOperators() {
114
+ return this.store.list('operator');
115
+ }
116
+
117
+ async listAgents() {
118
+ return this.store.list('agent');
119
+ }
120
+
121
+ /**
122
+ * The verifyAgent option fragment this blocklist implies. Merge with your
123
+ * static policy so a runtime-blocked operator is denied BEFORE the scope/tier
124
+ * checks run:
125
+ *
126
+ * const dyn = await blocklist.verifyOpts();
127
+ * const verdict = await verifyAgent(token, {
128
+ * ...staticOpts,
129
+ * blockedOperators: [...(staticOpts.blockedOperators||[]), ...dyn.blockedOperators],
130
+ * });
131
+ * const final = await blocklist.checkVerdict(verdict); // agent-level catch
132
+ */
133
+ async verifyOpts() {
134
+ return { blockedOperators: await this.blockedOperatorIds() };
135
+ }
136
+
137
+ /**
138
+ * Post-verify agent-level enforcement. verifyAgent already denies blocked
139
+ * OPERATORS (when you fed it `blockedOperators`), but agent-level blocking
140
+ * needs the resolved agent_id, which only exists after the registry verify.
141
+ * Pass an accepted verdict through this; it flips it to denied if the agent
142
+ * (or, as a safety net, the operator) is blocked. Pass-through otherwise.
143
+ */
144
+ async checkVerdict(verdict) {
145
+ if (!verdict || !verdict.accepted) return verdict;
146
+ if (await this.isAgentBlocked(verdict.agent_id)) {
147
+ return {
148
+ accepted: false,
149
+ code: 'agent_blocked',
150
+ reason: 'Agent is blocked at this platform',
151
+ agent_id: verdict.agent_id,
152
+ operator_id: verdict.operator_id,
153
+ };
154
+ }
155
+ if (await this.isOperatorBlocked(verdict.operator_id)) {
156
+ return {
157
+ accepted: false,
158
+ code: 'operator_blocked',
159
+ reason: 'Operator is blocked at this platform',
160
+ agent_id: verdict.agent_id,
161
+ operator_id: verdict.operator_id,
162
+ };
163
+ }
164
+ return verdict;
165
+ }
166
+ }
167
+
168
+ /**
169
+ * Wrap a gate so a runtime-blocked agent/operator is denied even if it's
170
+ * globally valid and within policy. Sits OUTSIDE the verify call: it merges the
171
+ * dynamic operator blocks into the verdict path via checkVerdict (agent-level)
172
+ * and is the simplest way to bolt a Blocklist onto an existing gate.
173
+ *
174
+ * Note: operator-level dynamic blocks are most efficiently injected by feeding
175
+ * `await blocklist.blockedOperatorIds()` into the gate's policy, but checkVerdict
176
+ * also catches them here as a safety net, so this wrapper is correct on its own.
177
+ */
178
+ export function gatedWithBlocklist(gate, blocklist) {
179
+ return async function blocklistGate(request) {
180
+ const verdict = await gate(request);
181
+ return blocklist.checkVerdict(verdict);
182
+ };
183
+ }
@@ -0,0 +1,30 @@
1
+ import { VerifyOptions, Verdict } from './index.js';
2
+
3
+ /** Minimal request shape the middleware reads (Express/Connect compatible). */
4
+ export interface AxisRequest {
5
+ headers: Record<string, string | string[] | undefined>;
6
+ query?: Record<string, unknown>;
7
+ /** Set by `axisGate` to the verdict before `next()` / the denial response. */
8
+ axis?: Verdict;
9
+ [key: string]: unknown;
10
+ }
11
+
12
+ /** Minimal response shape the middleware writes to (Express helpers or bare Node). */
13
+ export interface AxisResponse {
14
+ status?: (code: number) => AxisResponse;
15
+ json?: (body: unknown) => unknown;
16
+ statusCode?: number;
17
+ setHeader?: (name: string, value: string) => void;
18
+ end?: (chunk?: string) => unknown;
19
+ [key: string]: unknown;
20
+ }
21
+
22
+ export type AxisNext = (err?: unknown) => void;
23
+
24
+ /** Pull the AIT off a Node request: Bearer header, X-AXIS-Token, then ?ait=. */
25
+ export function extractToken(req: AxisRequest): string | null;
26
+
27
+ /** Build an Express/Connect middleware bound to your platform's verify policy. */
28
+ export function axisGate(
29
+ opts?: VerifyOptions
30
+ ): (req: AxisRequest, res: AxisResponse, next: AxisNext) => Promise<void>;
package/src/express.js ADDED
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Express / Connect adapter — `axisGate` as first-class middleware.
3
+ *
4
+ * This is the platform-side drop-in for Node web apps. Wrap a route and the
5
+ * presenting agent is verified against the registry + your policy before your
6
+ * handler runs:
7
+ *
8
+ * import { axisGate } from 'axis-platform-sdk/express';
9
+ *
10
+ * app.post('/comments',
11
+ * axisGate({ audience: 'comments.mysite.com', requireScopes: ['content:comment'] }),
12
+ * (req, res) => res.json({ ok: true, by: req.axis.agent_id })); // verified agent
13
+ *
14
+ * Zero-dependency: this module does NOT import express. The middleware is plain
15
+ * `(req, res, next)`, so it also works with Connect, restify, and any framework
16
+ * that uses the same signature. On accept it sets `req.axis` (the verdict) and
17
+ * calls `next()`; on deny it responds 401 (no token) / 403 (policy) / 503
18
+ * (unexpected verify error) with `{ error, message }`.
19
+ */
20
+ import { verifyAgent } from './verify.js';
21
+
22
+ /**
23
+ * Pull the AIT off a Node request. Order: `Authorization: Bearer <ait>`, then
24
+ * `X-AXIS-Token`, then `?ait=` (from `req.query`, as Express populates it).
25
+ */
26
+ export function extractToken(req) {
27
+ const h = (req && req.headers) || {};
28
+ const auth = h['authorization'] || h['Authorization'];
29
+ if (auth && /^Bearer\s+/i.test(auth)) return auth.replace(/^Bearer\s+/i, '').trim();
30
+ const xa = h['x-axis-token'] || h['X-AXIS-Token'];
31
+ if (xa) return Array.isArray(xa) ? xa[0] : xa;
32
+ if (req && req.query && req.query.ait) return String(req.query.ait);
33
+ return null;
34
+ }
35
+
36
+ /**
37
+ * Build an Express/Connect middleware bound to your platform's policy. `opts`
38
+ * are passed straight to `verifyAgent` (audience, requireScopes, minTier,
39
+ * blockedOperators/approvedOperators, registryBaseUrl, fetchImpl).
40
+ *
41
+ * @param {object} opts
42
+ * @returns {(req, res, next) => Promise<void>}
43
+ */
44
+ export function axisGate(opts = {}) {
45
+ return async function axisGateMiddleware(req, res, next) {
46
+ let verdict;
47
+ try {
48
+ verdict = await verifyAgent(extractToken(req), opts);
49
+ } catch (err) {
50
+ // verifyAgent already maps registry-unreachable to a deny verdict; this
51
+ // only catches a truly unexpected throw. Fail closed.
52
+ verdict = { accepted: false, code: 'verify_error', reason: String((err && err.message) || err) };
53
+ }
54
+ req.axis = verdict;
55
+ if (verdict.accepted) return next();
56
+
57
+ const status = verdict.code === 'no_token' ? 401 : verdict.code === 'verify_error' ? 503 : 403;
58
+ const payload = { error: verdict.code || 'denied', message: verdict.reason };
59
+ // Prefer Express helpers; fall back to the bare Node response API so this
60
+ // also works on Connect / http.Server without Express's res.json.
61
+ if (res && typeof res.status === 'function' && typeof res.json === 'function') {
62
+ return void res.status(status).json(payload);
63
+ }
64
+ res.statusCode = status;
65
+ if (typeof res.setHeader === 'function') res.setHeader('Content-Type', 'application/json');
66
+ return void res.end(JSON.stringify(payload));
67
+ };
68
+ }