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,268 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reputation report-back — the platform's outbound "this agent misbehaved"
|
|
3
|
+
* signal.
|
|
4
|
+
*
|
|
5
|
+
* A platform that boots an agent has knowledge the rest of the network doesn't:
|
|
6
|
+
* THIS agent spammed / abused / violated policy here. AXIS Layer 3 (Reputation)
|
|
7
|
+
* is exactly the channel for that signal, as a signed **Trust Attestation** (a
|
|
8
|
+
* negative one). This module builds a protocol-shaped TA (axis-protocol
|
|
9
|
+
* SPEC §4.5 / schemas/trust-attestation.json), signs it with the PLATFORM'S OWN
|
|
10
|
+
* Ed25519 key, and POSTs it to a configurable reputation index.
|
|
11
|
+
*
|
|
12
|
+
* Crucial scoping note (per the locked decision):
|
|
13
|
+
* - The canonical registry is IDENTITY-ONLY (Layer 1 + Layer 2). Trust
|
|
14
|
+
* Attestations MUST NOT be stored there.
|
|
15
|
+
* - The reputation index is a SEPARATE, future, commercial service. It does
|
|
16
|
+
* not exist yet. This module ships the SENDING mechanism; `axis-reputation`
|
|
17
|
+
* ships a stub receiver that accepts-and-discards until the real index is
|
|
18
|
+
* built. Default REPUTATION_URL is OFF — unconfigured = graceful no-op.
|
|
19
|
+
*
|
|
20
|
+
* Zero-dep: signing uses WebCrypto (`crypto.subtle`) Ed25519, the same
|
|
21
|
+
* primitive axis-protocol-sdk uses. The platform keypair is generated once and
|
|
22
|
+
* persisted via a tiny pluggable key store (default in-memory; a real platform
|
|
23
|
+
* persists the JWK to its secret store / KV / D1).
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
// --- base64url (no Buffer dependency; Workers/browser/Node 20+) -------------
|
|
27
|
+
|
|
28
|
+
function bytesToB64url(bytes) {
|
|
29
|
+
let s = '';
|
|
30
|
+
for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]);
|
|
31
|
+
return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function b64urlToBytes(s) {
|
|
35
|
+
const padded = s.replace(/-/g, '+').replace(/_/g, '/') + '='.repeat((4 - (s.length % 4)) % 4);
|
|
36
|
+
const bin = atob(padded);
|
|
37
|
+
const out = new Uint8Array(bin.length);
|
|
38
|
+
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// --- RFC 8785 JCS (kept minimal + identical in spirit to axis-protocol jcs) --
|
|
43
|
+
|
|
44
|
+
function jcsCanonicalize(value) {
|
|
45
|
+
if (value === null) return 'null';
|
|
46
|
+
const t = typeof value;
|
|
47
|
+
if (t === 'number') {
|
|
48
|
+
if (!Number.isFinite(value)) throw new TypeError('non-finite number not representable in JCS');
|
|
49
|
+
return JSON.stringify(value);
|
|
50
|
+
}
|
|
51
|
+
if (t === 'string' || t === 'boolean') return JSON.stringify(value);
|
|
52
|
+
if (Array.isArray(value)) {
|
|
53
|
+
return '[' + value.map((el) => (el === undefined ? 'null' : jcsCanonicalize(el))).join(',') + ']';
|
|
54
|
+
}
|
|
55
|
+
if (t === 'object') {
|
|
56
|
+
const keys = Object.keys(value).sort();
|
|
57
|
+
const parts = [];
|
|
58
|
+
for (const k of keys) {
|
|
59
|
+
const v = value[k];
|
|
60
|
+
if (v === undefined || typeof v === 'function' || typeof v === 'symbol') continue;
|
|
61
|
+
parts.push(JSON.stringify(k) + ':' + jcsCanonicalize(v));
|
|
62
|
+
}
|
|
63
|
+
return '{' + parts.join(',') + '}';
|
|
64
|
+
}
|
|
65
|
+
throw new TypeError(`value of type ${t} not representable in JCS`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// --- platform key store -----------------------------------------------------
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Default in-memory platform key store. Generate once, hold for the isolate's
|
|
72
|
+
* lifetime. A real platform implements the same `load()` / `save(jwk)` shape
|
|
73
|
+
* over its secret store / KV / D1 so the key is stable across restarts.
|
|
74
|
+
*/
|
|
75
|
+
export class MemoryKeyStore {
|
|
76
|
+
constructor() {
|
|
77
|
+
this._jwk = null;
|
|
78
|
+
}
|
|
79
|
+
async load() {
|
|
80
|
+
return this._jwk;
|
|
81
|
+
}
|
|
82
|
+
async save(jwk) {
|
|
83
|
+
this._jwk = jwk;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
let _defaultKeyStore = null;
|
|
88
|
+
function defaultKeyStore() {
|
|
89
|
+
if (!_defaultKeyStore) _defaultKeyStore = new MemoryKeyStore();
|
|
90
|
+
return _defaultKeyStore;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Get (generating + persisting on first use) this platform's Ed25519 signing
|
|
95
|
+
* key. Returns { privateKey: CryptoKey, publicKeyB64: string }.
|
|
96
|
+
*
|
|
97
|
+
* @param {object} [opts]
|
|
98
|
+
* @param {object} [opts.keyStore] Pluggable store with async load()/save(jwk).
|
|
99
|
+
*/
|
|
100
|
+
export async function getPlatformKey({ keyStore } = {}) {
|
|
101
|
+
const store = keyStore || defaultKeyStore();
|
|
102
|
+
let jwk = await store.load();
|
|
103
|
+
if (!jwk) {
|
|
104
|
+
const pair = await crypto.subtle.generateKey({ name: 'Ed25519' }, true, ['sign', 'verify']);
|
|
105
|
+
jwk = await crypto.subtle.exportKey('jwk', pair.privateKey);
|
|
106
|
+
await store.save(jwk);
|
|
107
|
+
}
|
|
108
|
+
const privateKey = await crypto.subtle.importKey('jwk', jwk, { name: 'Ed25519' }, false, ['sign']);
|
|
109
|
+
// Derive the raw public key from the private JWK's `x` (Ed25519 public bytes).
|
|
110
|
+
const publicKeyB64 = jwk.x; // already base64url in a JWK
|
|
111
|
+
return { privateKey, publicKeyB64, jwk };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// --- Trust Attestation build + sign -----------------------------------------
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Build a (negative) Trust Attestation document MINUS its signature, in the
|
|
118
|
+
* protocol shape (axis-protocol SPEC §4.5). `category` becomes the TA `scope`
|
|
119
|
+
* (the domain of the attestation); `reason` becomes the `statement`.
|
|
120
|
+
*
|
|
121
|
+
* @returns the unsigned TA object (no `signature` field yet).
|
|
122
|
+
*/
|
|
123
|
+
export function buildAttestation({ platformId, agentId, category, reason, issuedAt }) {
|
|
124
|
+
if (!platformId) throw new Error('buildAttestation: platformId is required (this platform\'s AXIS id)');
|
|
125
|
+
if (!agentId) throw new Error('buildAttestation: agentId (subject) is required');
|
|
126
|
+
const ts = issuedAt || new Date().toISOString();
|
|
127
|
+
// id convention: ta:{operator-of-platform-or-platform-slug}:{descriptor}
|
|
128
|
+
const slug = String(platformId).replace(/^axis:/, '').replace(/[^a-z0-9:-]/gi, '-').toLowerCase();
|
|
129
|
+
const cat = String(category || 'abuse').replace(/[^a-z0-9:-]/gi, '-').toLowerCase();
|
|
130
|
+
const idDescriptor = `${cat}-${ts.replace(/[^0-9]/g, '').slice(0, 14)}`;
|
|
131
|
+
return {
|
|
132
|
+
axis_version: '0.1',
|
|
133
|
+
type: 'TrustAttestation',
|
|
134
|
+
id: `ta:${slug}:${idDescriptor}`,
|
|
135
|
+
issued_by: platformId,
|
|
136
|
+
subject: agentId,
|
|
137
|
+
issued_at: ts,
|
|
138
|
+
scope: cat,
|
|
139
|
+
statement: reason ? String(reason).slice(0, 1000) : `Flagged at ${platformId}`,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Sign an unsigned TA with an Ed25519 private key. The signed bytes are the
|
|
145
|
+
* RFC 8785 JCS canonicalization of the TA WITHOUT its `signature` field — the
|
|
146
|
+
* same minus-the-proof convention every other AXIS signed body uses. Returns
|
|
147
|
+
* the TA with `signature` attached (base64url, no padding).
|
|
148
|
+
*/
|
|
149
|
+
export async function signAttestation(attestation, privateKey) {
|
|
150
|
+
const { signature, ...unsigned } = attestation;
|
|
151
|
+
const bytes = new TextEncoder().encode(jcsCanonicalize(unsigned));
|
|
152
|
+
const sig = new Uint8Array(await crypto.subtle.sign('Ed25519', privateKey, bytes));
|
|
153
|
+
return { ...unsigned, signature: bytesToB64url(sig) };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Verify a TA's self-consistency: does `signature` verify against the supplied
|
|
158
|
+
* Ed25519 public key (base64url raw 32 bytes) over the JCS of the TA minus its
|
|
159
|
+
* signature? This is what the stub receiver runs. It proves the report wasn't
|
|
160
|
+
* tampered with in transit and that the holder of `platform_public_key` signed
|
|
161
|
+
* it — NOT that the public key belongs to a trusted platform (that's the
|
|
162
|
+
* index's policy job later).
|
|
163
|
+
*/
|
|
164
|
+
export async function verifyAttestation(attestation, publicKeyB64) {
|
|
165
|
+
if (!attestation || typeof attestation !== 'object' || !attestation.signature) return false;
|
|
166
|
+
const { signature, ...unsigned } = attestation;
|
|
167
|
+
let key;
|
|
168
|
+
try {
|
|
169
|
+
key = await crypto.subtle.importKey('raw', b64urlToBytes(publicKeyB64), { name: 'Ed25519' }, false, ['verify']);
|
|
170
|
+
} catch {
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
const bytes = new TextEncoder().encode(jcsCanonicalize(unsigned));
|
|
174
|
+
let sig;
|
|
175
|
+
try {
|
|
176
|
+
sig = b64urlToBytes(signature);
|
|
177
|
+
} catch {
|
|
178
|
+
return false;
|
|
179
|
+
}
|
|
180
|
+
return crypto.subtle.verify('Ed25519', key, sig, bytes);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// --- report-back ------------------------------------------------------------
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Default reputation index URL. OFF by default — reputation is a separate,
|
|
187
|
+
* future commercial service. When the real `axis-reputation` index ships, set
|
|
188
|
+
* this (or pass `reputationUrl`) to its `/attestations` endpoint.
|
|
189
|
+
*/
|
|
190
|
+
export const DEFAULT_REPUTATION_URL = null;
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Report a negative reputation flag about an agent.
|
|
194
|
+
*
|
|
195
|
+
* Builds a signed Trust Attestation and POSTs
|
|
196
|
+
* { attestation, platform_public_key, signature }
|
|
197
|
+
* to the reputation index. If no index is configured (the default), this is a
|
|
198
|
+
* graceful no-op: it returns { sent: false, reason: 'reputation_disabled' } and
|
|
199
|
+
* NEVER throws — a platform's boot flow must not break because reputation isn't
|
|
200
|
+
* wired up yet.
|
|
201
|
+
*
|
|
202
|
+
* @param {object} args
|
|
203
|
+
* @param {string} args.platformId This platform's AXIS id (the attestor).
|
|
204
|
+
* @param {string} args.agentId Subject agent's AXIS id.
|
|
205
|
+
* @param {string} [args.operatorId] Subject's operator id (recorded, not signed into TA core).
|
|
206
|
+
* @param {string} args.category Flag category -> TA scope (e.g. 'abuse:spam').
|
|
207
|
+
* @param {string} args.reason Human-readable reason -> TA statement.
|
|
208
|
+
* @param {object} [opts]
|
|
209
|
+
* @param {string|null} [opts.reputationUrl=DEFAULT_REPUTATION_URL]
|
|
210
|
+
* @param {object} [opts.keyStore] Platform key store (default in-memory).
|
|
211
|
+
* @param {function} [opts.fetchImpl=fetch]
|
|
212
|
+
* @returns {Promise<{ sent: boolean, status?: number, attestation?: object, reason?: string }>}
|
|
213
|
+
*/
|
|
214
|
+
export async function reportFlag(
|
|
215
|
+
{ platformId, agentId, operatorId, category, reason },
|
|
216
|
+
{ reputationUrl = DEFAULT_REPUTATION_URL, keyStore, fetchImpl = fetch } = {}
|
|
217
|
+
) {
|
|
218
|
+
let attestation;
|
|
219
|
+
let publicKeyB64;
|
|
220
|
+
try {
|
|
221
|
+
const { privateKey, publicKeyB64: pk } = await getPlatformKey({ keyStore });
|
|
222
|
+
publicKeyB64 = pk;
|
|
223
|
+
const unsigned = buildAttestation({ platformId, agentId, category, reason });
|
|
224
|
+
attestation = await signAttestation(unsigned, privateKey);
|
|
225
|
+
} catch (e) {
|
|
226
|
+
// Building/signing failed (e.g. missing platformId). Don't throw out of a
|
|
227
|
+
// boot flow; surface the reason.
|
|
228
|
+
return { sent: false, reason: `report_build_failed: ${e.message}` };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (!reputationUrl) {
|
|
232
|
+
// Reputation index not configured. No-op, but hand back the signed TA so a
|
|
233
|
+
// caller can hold it as a portfolio item or send it later.
|
|
234
|
+
return { sent: false, reason: 'reputation_disabled', attestation };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
try {
|
|
238
|
+
const res = await fetchImpl(reputationUrl, {
|
|
239
|
+
method: 'POST',
|
|
240
|
+
headers: { 'Content-Type': 'application/json' },
|
|
241
|
+
body: JSON.stringify({
|
|
242
|
+
attestation,
|
|
243
|
+
platform_public_key: publicKeyB64,
|
|
244
|
+
signature: attestation.signature,
|
|
245
|
+
operator_id: operatorId || null,
|
|
246
|
+
}),
|
|
247
|
+
});
|
|
248
|
+
return { sent: res.status >= 200 && res.status < 300, status: res.status, attestation };
|
|
249
|
+
} catch (e) {
|
|
250
|
+
// Network failure to the index must not break the platform's boot flow.
|
|
251
|
+
return { sent: false, reason: `reputation_unreachable: ${e.message}`, attestation };
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Convenience: block the agent locally AND report it. The local block is the
|
|
257
|
+
* authoritative action (it sticks regardless of the report's fate); the report
|
|
258
|
+
* is best-effort. Returns both outcomes.
|
|
259
|
+
*
|
|
260
|
+
* @param {import('./blocklist.js').Blocklist} blocklist
|
|
261
|
+
* @param {object} args Same as reportFlag, plus the agentId is what gets blocked.
|
|
262
|
+
* @param {object} [opts] reportFlag opts.
|
|
263
|
+
*/
|
|
264
|
+
export async function blockAndReport(blocklist, args, opts = {}) {
|
|
265
|
+
await blocklist.blockAgent(args.agentId, args.reason);
|
|
266
|
+
const report = await reportFlag(args, opts);
|
|
267
|
+
return { blocked: true, agent_id: args.agentId, report };
|
|
268
|
+
}
|
package/src/scope.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// Scope matching for the AXIS scope grammar (spec v0.2 §4.4).
|
|
2
|
+
//
|
|
3
|
+
// Ported verbatim from kipple-governor/src/scope-match.ts so the operator side
|
|
4
|
+
// (Governor proxy chain-walk) and the platform side (this SDK) agree on scope
|
|
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
|
+
}
|
package/src/verify.js
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* verifyAgent — the one function that matters.
|
|
3
|
+
*
|
|
4
|
+
* An AXIS agent shows up at your platform and presents an AIT. This calls the
|
|
5
|
+
* registry to verify it (signature + revocation + delegation chain, all done
|
|
6
|
+
* server-side), then applies YOUR platform's gate: audience match, required
|
|
7
|
+
* scopes, blocked/approved operators, minimum verification tier. Returns a
|
|
8
|
+
* single structured verdict you can act on.
|
|
9
|
+
*
|
|
10
|
+
* Trust model notes:
|
|
11
|
+
* - The registry's `effective_scope` (returned when a valid delegation is
|
|
12
|
+
* presented) is the trustworthy, chain-walked scope. The AIT's self-declared
|
|
13
|
+
* `scope` is NOT trusted. An agent acting on its own identity with no
|
|
14
|
+
* delegation has NO proven scope, so any non-empty `requireScopes` denies it.
|
|
15
|
+
* - Audience is matched locally: the registry only checks that `aud` exists,
|
|
16
|
+
* not that it equals your platform. That check is yours.
|
|
17
|
+
*/
|
|
18
|
+
import { decodeAitPayload } from './decode.js';
|
|
19
|
+
import { coversAll } from './scope.js';
|
|
20
|
+
import { registryGet, enrich, TIER_RANK, DEFAULT_REGISTRY } from './client.js';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @param {string} token The AIT the agent presented.
|
|
24
|
+
* @param {object} opts
|
|
25
|
+
* @param {string} [opts.audience] Your platform's audience id. If set, the AIT's `aud` must equal it.
|
|
26
|
+
* @param {string[]} [opts.requireScopes] Scopes the agent must hold (checked against trustworthy effective_scope).
|
|
27
|
+
* @param {string} [opts.minTier] Minimum operator verification tier (email|domain|verified|kyb_individual|kyb_organization).
|
|
28
|
+
* @param {string[]} [opts.blockedOperators] Operator ids to reject outright.
|
|
29
|
+
* @param {string[]|null} [opts.approvedOperators] If set, only these operator ids are accepted.
|
|
30
|
+
* @param {string} [opts.registryBaseUrl] Defaults to https://registry.axisprime.ai
|
|
31
|
+
* @param {function} [opts.fetchImpl] Injectable fetch (testing).
|
|
32
|
+
* @returns {Promise<object>} verdict: { accepted, reason?, code?, agent_id?, operator_id?, effective_scope?, delegation_valid?, tier?, expires_at? }
|
|
33
|
+
*/
|
|
34
|
+
export async function verifyAgent(token, opts = {}) {
|
|
35
|
+
const {
|
|
36
|
+
audience,
|
|
37
|
+
requireScopes = [],
|
|
38
|
+
minTier,
|
|
39
|
+
blockedOperators = [],
|
|
40
|
+
approvedOperators = null,
|
|
41
|
+
registryBaseUrl = DEFAULT_REGISTRY,
|
|
42
|
+
fetchImpl = fetch,
|
|
43
|
+
} = opts;
|
|
44
|
+
|
|
45
|
+
const deny = (reason, code, extra = {}) => ({ accepted: false, reason, code, ...extra });
|
|
46
|
+
|
|
47
|
+
if (!token) return deny('No AIT presented', 'no_token');
|
|
48
|
+
|
|
49
|
+
// 1. Audience: the agent must have addressed THIS platform.
|
|
50
|
+
if (audience) {
|
|
51
|
+
const payload = decodeAitPayload(token);
|
|
52
|
+
if (!payload || !payload.aud) return deny('AIT is missing an audience (aud) claim', 'missing_aud');
|
|
53
|
+
if (payload.aud !== audience) {
|
|
54
|
+
return deny(`AIT audience '${payload.aud}' does not match this platform ('${audience}')`, 'audience_mismatch');
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// 2. Registry verification (signature + revocation + chain walk).
|
|
59
|
+
let res;
|
|
60
|
+
try {
|
|
61
|
+
res = await registryGet(registryBaseUrl, `/verify?token=${encodeURIComponent(token)}`, { fetchImpl });
|
|
62
|
+
} catch {
|
|
63
|
+
return deny('Registry unreachable', 'registry_error');
|
|
64
|
+
}
|
|
65
|
+
const body = res.body || {};
|
|
66
|
+
if (res.status !== 200) {
|
|
67
|
+
return deny(body?.error?.message || 'Verification request failed', body?.error?.code || 'verify_failed');
|
|
68
|
+
}
|
|
69
|
+
if (body.valid !== true) {
|
|
70
|
+
// valid:false carries a stable `code` (invalid_signature|token_expired|agent_revoked|agent_suspended).
|
|
71
|
+
return deny(body.reason || 'AIT is not valid', body.code || 'invalid_ait', { agent_id: body.agent_id });
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const agentId = body.agent_id;
|
|
75
|
+
const operatorId = body.operator_id;
|
|
76
|
+
|
|
77
|
+
// 3. Operator allow / block.
|
|
78
|
+
if (blockedOperators.includes(operatorId)) {
|
|
79
|
+
return deny('Operator is blocked at this platform', 'operator_blocked', { agent_id: agentId, operator_id: operatorId });
|
|
80
|
+
}
|
|
81
|
+
if (approvedOperators && !approvedOperators.includes(operatorId)) {
|
|
82
|
+
return deny('Operator is not on this platform\'s approved list', 'operator_not_approved', { agent_id: agentId, operator_id: operatorId });
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// 4. Scope. Trustworthy scope is effective_scope, and only when the
|
|
86
|
+
// delegation actually validated. A direct AIT (no valid delegation) has
|
|
87
|
+
// no proven scope.
|
|
88
|
+
const delegationValid = body.delegation_valid === true;
|
|
89
|
+
const grantedScopes = delegationValid && Array.isArray(body.effective_scope) ? body.effective_scope : [];
|
|
90
|
+
if (requireScopes.length) {
|
|
91
|
+
const check = delegationValid ? coversAll(grantedScopes, requireScopes) : { ok: false, missing: requireScopes };
|
|
92
|
+
if (!check.ok) {
|
|
93
|
+
return deny(`Missing required scope(s): ${check.missing.join(', ')}`, 'insufficient_scope', {
|
|
94
|
+
agent_id: agentId,
|
|
95
|
+
operator_id: operatorId,
|
|
96
|
+
effective_scope: grantedScopes,
|
|
97
|
+
missing: check.missing,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// 5. Minimum tier (optional; needs a presentation-layer fetch).
|
|
103
|
+
let tier = null;
|
|
104
|
+
if (minTier) {
|
|
105
|
+
const info = await enrich(agentId, token, { registryBaseUrl, fetchImpl });
|
|
106
|
+
tier = info.tier;
|
|
107
|
+
if ((TIER_RANK[tier] || 0) < (TIER_RANK[minTier] || 0)) {
|
|
108
|
+
return deny(`Operator tier '${tier || 'unknown'}' is below the required '${minTier}'`, 'insufficient_tier', {
|
|
109
|
+
agent_id: agentId,
|
|
110
|
+
operator_id: operatorId,
|
|
111
|
+
tier,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
accepted: true,
|
|
118
|
+
agent_id: agentId,
|
|
119
|
+
operator_id: operatorId,
|
|
120
|
+
effective_scope: grantedScopes,
|
|
121
|
+
delegation_valid: delegationValid,
|
|
122
|
+
tier,
|
|
123
|
+
expires_at: body.expires_at || null,
|
|
124
|
+
};
|
|
125
|
+
}
|