axis-platform-sdk 0.2.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/CHANGELOG.md +97 -0
- package/LICENSE +16 -0
- package/NOTICE +11 -0
- package/README.md +218 -0
- package/examples/bouncer-worker.js +158 -0
- package/examples/toy-platform-worker.js +60 -0
- package/package.json +48 -0
- package/src/authorizer.js +101 -0
- package/src/blocklist.js +183 -0
- package/src/client.js +82 -0
- package/src/decode.js +22 -0
- package/src/gate.js +70 -0
- package/src/index.js +31 -0
- package/src/ledger.js +171 -0
- package/src/reportback.js +268 -0
- package/src/scope.js +46 -0
- package/src/verify.js +125 -0
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Authorizer port + the free-tier SwitchAuthorizer.
|
|
3
|
+
*
|
|
4
|
+
* Owyhee (and any platform) always does IDENTITY verification first — is this a
|
|
5
|
+
* real, non-revoked agent with a trustworthy effective_scope (verifyAgent ->
|
|
6
|
+
* registry). That part is fixed and never pluggable.
|
|
7
|
+
*
|
|
8
|
+
* The authorization DECISION is the pluggable, monetizable layer. Every
|
|
9
|
+
* Authorizer implements the same shape:
|
|
10
|
+
*
|
|
11
|
+
* authorize(token, gateId, { registryBaseUrl, fetchImpl }) -> verdict
|
|
12
|
+
*
|
|
13
|
+
* where `verdict` is exactly what verifyAgent returns
|
|
14
|
+
* ({ accepted, reason?, code?, agent_id?, operator_id?, effective_scope?, tier?, ... }).
|
|
15
|
+
*
|
|
16
|
+
* Profiles (Josh's "simple on/off -> granular -> really complicated"):
|
|
17
|
+
* - SwitchAuthorizer (this file) — FREE tier. Config-driven on/off gates
|
|
18
|
+
* + optional tier / scope / operator rules.
|
|
19
|
+
* - EngineAuthorizer (not here) — PAID. Same port, delegates the decision
|
|
20
|
+
* to Permify / OpenFGA (sidecar) for
|
|
21
|
+
* relationship/attribute rules.
|
|
22
|
+
* - EnterpriseAuthorizer (not here)— full ReBAC/ABAC, same port.
|
|
23
|
+
*
|
|
24
|
+
* The SwitchAuthorizer's `policy` object is exactly what a "Door policy" screen
|
|
25
|
+
* edits and saves.
|
|
26
|
+
*/
|
|
27
|
+
import { verifyAgent } from './verify.js';
|
|
28
|
+
import { extractToken } from './gate.js';
|
|
29
|
+
|
|
30
|
+
const deny = (reason, code, extra = {}) => ({ accepted: false, reason, code, ...extra });
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Free-tier gate engine: a set of named gates, each on/off, with optional
|
|
34
|
+
* minimum tier, required scopes, and operator allow/block lists.
|
|
35
|
+
*
|
|
36
|
+
* policy = {
|
|
37
|
+
* audience: 'comments.mysite.com', // your platform id; applied to every gate
|
|
38
|
+
* defaultAllow: false, // posture for a gateId with no policy
|
|
39
|
+
* blockedOperators: [], // global blocklist, applied to all gates
|
|
40
|
+
* gates: {
|
|
41
|
+
* 'comments:write': {
|
|
42
|
+
* enabled: true,
|
|
43
|
+
* minTier: 'domain', // optional
|
|
44
|
+
* requireScopes: ['comments:write'], // optional
|
|
45
|
+
* blockedOperators: [], // optional, gate-specific
|
|
46
|
+
* approvedOperators: null // optional allowlist
|
|
47
|
+
* }
|
|
48
|
+
* }
|
|
49
|
+
* }
|
|
50
|
+
*/
|
|
51
|
+
export class SwitchAuthorizer {
|
|
52
|
+
constructor(policy = {}) {
|
|
53
|
+
this.policy = policy || {};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The verifyAgent options this policy implies for a given gate. */
|
|
57
|
+
optsForGate(gateId) {
|
|
58
|
+
const p = this.policy;
|
|
59
|
+
const gate = (p.gates && p.gates[gateId]) || null;
|
|
60
|
+
return {
|
|
61
|
+
audience: p.audience,
|
|
62
|
+
requireScopes: (gate && gate.requireScopes) || [],
|
|
63
|
+
minTier: gate && gate.minTier,
|
|
64
|
+
blockedOperators: [...(p.blockedOperators || []), ...((gate && gate.blockedOperators) || [])],
|
|
65
|
+
approvedOperators: (gate && gate.approvedOperators) || null,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Decide whether `token` may act at `gateId`. Denies if the gate is turned
|
|
71
|
+
* off, or unknown when the posture is closed. Otherwise runs identity
|
|
72
|
+
* verification + this gate's policy.
|
|
73
|
+
*/
|
|
74
|
+
async authorize(token, gateId, { registryBaseUrl, fetchImpl } = {}) {
|
|
75
|
+
const p = this.policy;
|
|
76
|
+
const gate = (p.gates && p.gates[gateId]) || null;
|
|
77
|
+
|
|
78
|
+
if (!gate) {
|
|
79
|
+
if (p.defaultAllow) {
|
|
80
|
+
return verifyAgent(token, {
|
|
81
|
+
audience: p.audience,
|
|
82
|
+
blockedOperators: p.blockedOperators || [],
|
|
83
|
+
registryBaseUrl,
|
|
84
|
+
fetchImpl,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
return deny(`No policy for gate '${gateId}' and the default posture is closed`, 'gate_unknown');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (gate.enabled === false) {
|
|
91
|
+
return deny(`Gate '${gateId}' is turned off`, 'gate_closed');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return verifyAgent(token, { ...this.optsForGate(gateId), registryBaseUrl, fetchImpl });
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Bind this authorizer to a gateId as a request gate: (request) => verdict. */
|
|
98
|
+
gate(gateId, opts = {}) {
|
|
99
|
+
return async (request) => this.authorize(extractToken(request), gateId, opts);
|
|
100
|
+
}
|
|
101
|
+
}
|
package/src/blocklist.js
ADDED
|
@@ -0,0 +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
|
+
}
|
package/src/client.js
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thin registry-call helpers. The registry does the cryptographic heavy
|
|
3
|
+
* lifting (signature, revocation, delegation chain walk) at its public
|
|
4
|
+
* endpoints; these wrappers just call them.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export const DEFAULT_REGISTRY = 'https://registry.axisprime.ai';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* GET a registry path. `fetchImpl` is injectable for testing. Returns
|
|
11
|
+
* { status, body } where body is the parsed JSON (or { raw } if not JSON).
|
|
12
|
+
*/
|
|
13
|
+
export async function registryGet(base, path, { headers, fetchImpl = fetch } = {}) {
|
|
14
|
+
const res = await fetchImpl(`${base}${path}`, headers ? { headers } : undefined);
|
|
15
|
+
const text = await res.text();
|
|
16
|
+
let body;
|
|
17
|
+
try {
|
|
18
|
+
body = JSON.parse(text);
|
|
19
|
+
} catch {
|
|
20
|
+
body = { raw: text };
|
|
21
|
+
}
|
|
22
|
+
return { status: res.status, body };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Tier rank for minimum-tier gating. Higher = stronger verification.
|
|
27
|
+
* `kyb_business` is an accepted legacy alias for `kyb_organization`.
|
|
28
|
+
*/
|
|
29
|
+
export const TIER_RANK = {
|
|
30
|
+
email: 1,
|
|
31
|
+
domain: 2,
|
|
32
|
+
verified: 3,
|
|
33
|
+
kyb_individual: 4,
|
|
34
|
+
kyb_organization: 5,
|
|
35
|
+
kyb_business: 5,
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Resolve an agent's presentation layer (display_name, operator verification
|
|
40
|
+
* tier, etc.). Pass the agent's AIT to unlock the presentation layer; without
|
|
41
|
+
* it you get only the public layer.
|
|
42
|
+
*
|
|
43
|
+
* @returns {{ agent_id, did, display_name, tier, status, raw }}
|
|
44
|
+
*/
|
|
45
|
+
export async function enrich(agentId, token, { registryBaseUrl = DEFAULT_REGISTRY, fetchImpl = fetch } = {}) {
|
|
46
|
+
const headers = token ? { Authorization: `Bearer ${token}` } : undefined;
|
|
47
|
+
const { body } = await registryGet(registryBaseUrl, `/agents/${encodeURIComponent(agentId)}`, { headers, fetchImpl });
|
|
48
|
+
return {
|
|
49
|
+
agent_id: body.agent_id || agentId,
|
|
50
|
+
did: body.did || null,
|
|
51
|
+
display_name: body.display_name || null,
|
|
52
|
+
tier: pickTier(body),
|
|
53
|
+
status: body.status || null,
|
|
54
|
+
raw: body,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Read an operator's verification tier from a resolved agent record.
|
|
60
|
+
* Defensive across the field shapes the registry uses for the tier.
|
|
61
|
+
*/
|
|
62
|
+
export function pickTier(agentBody) {
|
|
63
|
+
if (!agentBody) return null;
|
|
64
|
+
return (
|
|
65
|
+
agentBody.operator_verification_tier ||
|
|
66
|
+
(agentBody.operator && agentBody.operator.verification_tier) ||
|
|
67
|
+
agentBody.verification_tier ||
|
|
68
|
+
null
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Load a platform's published access policy (`/.well-known/axis-access`).
|
|
74
|
+
* Useful if you want your gate to read its own door policy rather than
|
|
75
|
+
* hard-coding it.
|
|
76
|
+
*/
|
|
77
|
+
export async function loadAccessPolicy(platformBaseUrl, { fetchImpl = fetch } = {}) {
|
|
78
|
+
const base = String(platformBaseUrl || '').replace(/\/$/, '');
|
|
79
|
+
const res = await fetchImpl(`${base}/.well-known/axis-access`);
|
|
80
|
+
if (!res.ok) throw new Error(`axis-access fetch failed: ${res.status}`);
|
|
81
|
+
return res.json();
|
|
82
|
+
}
|
package/src/decode.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Decode an AIT (AXIS Identity Token, a JWT) payload WITHOUT verifying it.
|
|
3
|
+
*
|
|
4
|
+
* The cryptographic verification (signature, revocation, delegation chain)
|
|
5
|
+
* happens server-side at the registry's GET /verify endpoint. The only reason
|
|
6
|
+
* a platform decodes locally is to read the `aud` (audience) claim so it can
|
|
7
|
+
* confirm the agent actually meant to present to THIS platform. The registry
|
|
8
|
+
* enforces that `aud` is present and non-empty, but it does not know which
|
|
9
|
+
* platform is asking, so audience-matching is the platform's job.
|
|
10
|
+
*
|
|
11
|
+
* Returns the parsed payload object, or null if the token is malformed.
|
|
12
|
+
*/
|
|
13
|
+
export function decodeAitPayload(token) {
|
|
14
|
+
const parts = String(token || '').split('.');
|
|
15
|
+
if (parts.length !== 3) return null;
|
|
16
|
+
try {
|
|
17
|
+
const b64 = parts[1].replace(/-/g, '+').replace(/_/g, '/');
|
|
18
|
+
return JSON.parse(atob(b64));
|
|
19
|
+
} catch {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
}
|
package/src/gate.js
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* aitGate — a drop-in request gate for a consuming platform (the "bouncer").
|
|
3
|
+
*
|
|
4
|
+
* Wrap your handler: pull the AIT off the incoming request, verify it against
|
|
5
|
+
* the registry + your policy, and either let it through or bounce it.
|
|
6
|
+
*
|
|
7
|
+
* import { aitGate, denialResponse } from 'axis-platform-sdk';
|
|
8
|
+
*
|
|
9
|
+
* const gate = aitGate({ audience: 'comments.mysite.com', requireScopes: ['comments:write'] });
|
|
10
|
+
*
|
|
11
|
+
* export default {
|
|
12
|
+
* async fetch(request) {
|
|
13
|
+
* const verdict = await gate(request);
|
|
14
|
+
* if (!verdict.accepted) return denialResponse(verdict);
|
|
15
|
+
* // ...verdict.agent_id is verified; proceed.
|
|
16
|
+
* }
|
|
17
|
+
* };
|
|
18
|
+
*/
|
|
19
|
+
import { verifyAgent } from './verify.js';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Pull the AIT off a request. Accepts a Fetch API Request, or any object with
|
|
23
|
+
* a `headers` (Headers or plain object) and optional `url`. Looks at, in order:
|
|
24
|
+
* Authorization: Bearer <ait>, X-AXIS-Token, ?ait= query param.
|
|
25
|
+
*/
|
|
26
|
+
export function extractToken(request) {
|
|
27
|
+
const h = request && request.headers;
|
|
28
|
+
const get = (k) => {
|
|
29
|
+
if (!h) return null;
|
|
30
|
+
if (typeof h.get === 'function') return h.get(k);
|
|
31
|
+
return h[k] || h[k.toLowerCase()] || null;
|
|
32
|
+
};
|
|
33
|
+
const auth = get('authorization') || get('Authorization');
|
|
34
|
+
if (auth && /^Bearer\s+/i.test(auth)) return auth.replace(/^Bearer\s+/i, '').trim();
|
|
35
|
+
const xa = get('x-axis-token') || get('X-AXIS-Token');
|
|
36
|
+
if (xa) return xa;
|
|
37
|
+
try {
|
|
38
|
+
if (request && request.url) {
|
|
39
|
+
const q = new URL(request.url).searchParams.get('ait');
|
|
40
|
+
if (q) return q;
|
|
41
|
+
}
|
|
42
|
+
} catch {
|
|
43
|
+
/* not a URL; ignore */
|
|
44
|
+
}
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Build a gate function bound to your platform's policy. Returns
|
|
50
|
+
* `(request) => Promise<verdict>`.
|
|
51
|
+
*/
|
|
52
|
+
export function aitGate(opts = {}) {
|
|
53
|
+
return async function gate(request) {
|
|
54
|
+
const token = extractToken(request);
|
|
55
|
+
return verifyAgent(token, opts);
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Turn a denied verdict into an HTTP Response (403 by default). 401 when no
|
|
61
|
+
* token was presented at all.
|
|
62
|
+
*/
|
|
63
|
+
export function denialResponse(verdict, status) {
|
|
64
|
+
const code = verdict && verdict.code;
|
|
65
|
+
const httpStatus = status || (code === 'no_token' ? 401 : 403);
|
|
66
|
+
return new Response(
|
|
67
|
+
JSON.stringify({ error: code || 'denied', message: verdict ? verdict.reason : 'Denied' }),
|
|
68
|
+
{ status: httpStatus, headers: { 'Content-Type': 'application/json' } }
|
|
69
|
+
);
|
|
70
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* axis-platform-sdk — the platform/verifier ("bouncer") side of AXIS.
|
|
3
|
+
*
|
|
4
|
+
* When an AXIS agent shows up at your platform and wants to act, verify its
|
|
5
|
+
* identity + delegation + scope and decide: accept, scope, or boot.
|
|
6
|
+
*
|
|
7
|
+
* The whole point: the registry already does the cryptography (signature,
|
|
8
|
+
* revocation, delegation chain walk) server-side. This SDK packages the
|
|
9
|
+
* verdict + policy layer so a platform integrates in a few lines instead of
|
|
10
|
+
* hand-rolling it.
|
|
11
|
+
*/
|
|
12
|
+
export { verifyAgent } from './verify.js';
|
|
13
|
+
export { SwitchAuthorizer } from './authorizer.js';
|
|
14
|
+
export { aitGate, extractToken, denialResponse } from './gate.js';
|
|
15
|
+
export { scopeCovers, coversAll } from './scope.js';
|
|
16
|
+
export { enrich, loadAccessPolicy, registryGet, pickTier, TIER_RANK, DEFAULT_REGISTRY } from './client.js';
|
|
17
|
+
export { decodeAitPayload } from './decode.js';
|
|
18
|
+
|
|
19
|
+
// --- Stateful platform store (the "who showed up / who's blocked" half) ---
|
|
20
|
+
export { AccessLedger, MemoryLedgerStore, loggedGate, recordEntry } from './ledger.js';
|
|
21
|
+
export { Blocklist, MemoryBlocklistStore, gatedWithBlocklist } from './blocklist.js';
|
|
22
|
+
export {
|
|
23
|
+
reportFlag,
|
|
24
|
+
blockAndReport,
|
|
25
|
+
getPlatformKey,
|
|
26
|
+
buildAttestation,
|
|
27
|
+
signAttestation,
|
|
28
|
+
verifyAttestation,
|
|
29
|
+
MemoryKeyStore,
|
|
30
|
+
DEFAULT_REPUTATION_URL,
|
|
31
|
+
} from './reportback.js';
|
package/src/ledger.js
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Access ledger — the platform's "who showed up at my door" record.
|
|
3
|
+
*
|
|
4
|
+
* Every time an agent presents and the gate reaches a verdict, the platform can
|
|
5
|
+
* log the arrival here. This is the STATEFUL half of the bouncer: verifyAgent /
|
|
6
|
+
* aitGate are stateless verdict machines; the ledger is the platform's own
|
|
7
|
+
* append-only record of what those verdicts were, kept in the platform's OWN
|
|
8
|
+
* store. Zero-infra by default (in-memory); a real platform plugs in D1 /
|
|
9
|
+
* SQLite / Postgres via the adapter shape below.
|
|
10
|
+
*
|
|
11
|
+
* Canonical adapter: Owyhee "The Door" (kipple-governor, governor#27) is the
|
|
12
|
+
* shipped, D1-backed product instance of this port — its `arrivals` table +
|
|
13
|
+
* `recordArrival()` ARE this ledger over D1. This module is the library form
|
|
14
|
+
* (the shape + an in-memory default); The Door is the production adapter. The
|
|
15
|
+
* entry shape below is deliberately byte-compatible with The Door's
|
|
16
|
+
* `ArrivalRecord` / `arrivals` columns so there is ONE arrival record across
|
|
17
|
+
* the SDK and the product, not two.
|
|
18
|
+
*
|
|
19
|
+
* Trust note: the only scope worth recording is `effective_scope` (the
|
|
20
|
+
* registry's chain-walked, trustworthy scope), which is exactly what a verdict
|
|
21
|
+
* carries. We never persist the AIT's self-declared scope.
|
|
22
|
+
*
|
|
23
|
+
* --- Adapter shape -------------------------------------------------------
|
|
24
|
+
* A store is any object implementing:
|
|
25
|
+
*
|
|
26
|
+
* async append(entry) -> void // persist one arrival
|
|
27
|
+
* async recent({ limit }) -> entry[] // newest first
|
|
28
|
+
* async byOperator(operatorId, { limit }) -> entry[] // newest first
|
|
29
|
+
*
|
|
30
|
+
* `entry` is the record shape produced by `recordEntry()` — the same fields The
|
|
31
|
+
* Door's `arrivals` table holds (minus the adapter's own PK/org columns):
|
|
32
|
+
* {
|
|
33
|
+
* agent_id, operator_id, created_at, // created_at = epoch ms (Date.now())
|
|
34
|
+
* tier, delegation_valid, effective_scope: string[],
|
|
35
|
+
* gate_id, requested_action, display_name,
|
|
36
|
+
* decision: 'auto_allow'|'denied'|'held'|'approved'|'booted',
|
|
37
|
+
* reason, audience
|
|
38
|
+
* }
|
|
39
|
+
*
|
|
40
|
+
* The same adapter shape is shared with blocklist.js (a tiny CRUD port). A
|
|
41
|
+
* platform implements both against whatever it already runs. The defaults here
|
|
42
|
+
* keep the demo and the free tier zero-infra.
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Default in-memory ledger store. Newest-first iteration. Bounded so a
|
|
47
|
+
* long-running Worker isolate doesn't grow without limit; override `max` (0 =
|
|
48
|
+
* unbounded) when you want the full history and have a real store behind it.
|
|
49
|
+
*/
|
|
50
|
+
export class MemoryLedgerStore {
|
|
51
|
+
constructor({ max = 10000 } = {}) {
|
|
52
|
+
this._entries = []; // chronological; newest pushed to the end
|
|
53
|
+
this.max = max;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async append(entry) {
|
|
57
|
+
this._entries.push(entry);
|
|
58
|
+
if (this.max && this._entries.length > this.max) {
|
|
59
|
+
this._entries.splice(0, this._entries.length - this.max);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async recent({ limit = 50 } = {}) {
|
|
64
|
+
const out = this._entries.slice(-limit);
|
|
65
|
+
out.reverse();
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async byOperator(operatorId, { limit = 50 } = {}) {
|
|
70
|
+
const out = [];
|
|
71
|
+
for (let i = this._entries.length - 1; i >= 0 && out.length < limit; i--) {
|
|
72
|
+
if (this._entries[i].operator_id === operatorId) out.push(this._entries[i]);
|
|
73
|
+
}
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Normalize a verdict (from verifyAgent) into a ledger entry. The output is
|
|
80
|
+
* byte-compatible with The Door's `ArrivalRecord` (governor#27) so the SDK and
|
|
81
|
+
* the product share one arrival shape.
|
|
82
|
+
*
|
|
83
|
+
* `decision` defaults from the verdict (accepted -> 'auto_allow', else
|
|
84
|
+
* 'denied'); a caller may override it with a manual-review state a real bouncer
|
|
85
|
+
* needs ('held' | 'approved' | 'booted'). `created_at` is epoch ms (Date.now()),
|
|
86
|
+
* matching The Door's column; for an ISO string use
|
|
87
|
+
* `new Date(entry.created_at).toISOString()`.
|
|
88
|
+
*
|
|
89
|
+
* @param {object} verdict A verifyAgent verdict.
|
|
90
|
+
* @param {object} [fields]
|
|
91
|
+
* @param {string} [fields.audience] Platform audience (optional metadata; The Door doesn't persist it).
|
|
92
|
+
* @param {string} [fields.gate_id] Which gate was requested.
|
|
93
|
+
* @param {string} [fields.requested_action] Human-facing action label.
|
|
94
|
+
* @param {string} [fields.display_name] Enriched presentation name.
|
|
95
|
+
* @param {string} [fields.decision] Override decision ('held'|'approved'|'booted').
|
|
96
|
+
* @param {number} [fields.created_at] Override timestamp (epoch ms).
|
|
97
|
+
*/
|
|
98
|
+
export function recordEntry(verdict, { audience, gate_id, requested_action, display_name, decision, created_at } = {}) {
|
|
99
|
+
const v = verdict || {};
|
|
100
|
+
return {
|
|
101
|
+
agent_id: v.agent_id || null,
|
|
102
|
+
operator_id: v.operator_id || null,
|
|
103
|
+
created_at: created_at || Date.now(),
|
|
104
|
+
tier: v.tier || null,
|
|
105
|
+
delegation_valid: v.delegation_valid === true,
|
|
106
|
+
effective_scope: Array.isArray(v.effective_scope) ? v.effective_scope : [],
|
|
107
|
+
gate_id: gate_id || null,
|
|
108
|
+
requested_action: requested_action || null,
|
|
109
|
+
display_name: display_name || null,
|
|
110
|
+
decision: decision || (v.accepted ? 'auto_allow' : 'denied'),
|
|
111
|
+
reason: v.accepted ? null : v.reason || v.code || 'denied',
|
|
112
|
+
audience: audience || null,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The platform's access ledger. Wraps a store and gives you the log helpers a
|
|
118
|
+
* "who's using my platform" view needs.
|
|
119
|
+
*/
|
|
120
|
+
export class AccessLedger {
|
|
121
|
+
constructor({ store } = {}) {
|
|
122
|
+
this.store = store || new MemoryLedgerStore();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Log a verdict. Returns the persisted entry. `fields` carries the same
|
|
127
|
+
* optional arrival fields as `recordEntry` (audience, gate_id,
|
|
128
|
+
* requested_action, display_name, decision override).
|
|
129
|
+
*/
|
|
130
|
+
async record(verdict, fields = {}) {
|
|
131
|
+
const entry = recordEntry(verdict, fields);
|
|
132
|
+
await this.store.append(entry);
|
|
133
|
+
return entry;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Most recent arrivals, newest first. */
|
|
137
|
+
async recent(opts = {}) {
|
|
138
|
+
return this.store.recent(opts);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Recent arrivals from a single operator, newest first. */
|
|
142
|
+
async byOperator(operatorId, opts = {}) {
|
|
143
|
+
return this.store.byOperator(operatorId, opts);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Wrap a gate (or any `(request) => Promise<verdict>`) so every verdict is
|
|
149
|
+
* logged to a ledger before it's returned. Drop-in around `aitGate(...)` or
|
|
150
|
+
* `authorizer.gate(gateId)`:
|
|
151
|
+
*
|
|
152
|
+
* const ledger = new AccessLedger();
|
|
153
|
+
* const gate = loggedGate(aitGate({ audience }), ledger, { audience, gate_id });
|
|
154
|
+
* const verdict = await gate(request); // logged as a side effect
|
|
155
|
+
*
|
|
156
|
+
* `fields` are the static arrival fields to stamp on every entry (audience,
|
|
157
|
+
* gate_id, requested_action, display_name). Logging failures never block the
|
|
158
|
+
* request — a store hiccup must not turn an accepted agent away. The verdict is
|
|
159
|
+
* always returned.
|
|
160
|
+
*/
|
|
161
|
+
export function loggedGate(gate, ledger, fields = {}) {
|
|
162
|
+
return async function loggingGate(request) {
|
|
163
|
+
const verdict = await gate(request);
|
|
164
|
+
try {
|
|
165
|
+
await ledger.record(verdict, fields);
|
|
166
|
+
} catch {
|
|
167
|
+
/* never let a ledger write failure change the verdict */
|
|
168
|
+
}
|
|
169
|
+
return verdict;
|
|
170
|
+
};
|
|
171
|
+
}
|