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
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to `axis-platform-sdk`. Pre-release; not yet published to npm.
|
|
4
|
+
|
|
5
|
+
## [0.2.0] — 2026-06-25 (unreleased)
|
|
6
|
+
|
|
7
|
+
First npm publish + public repo. Adds the stateful half (ledger, blocklist,
|
|
8
|
+
reputation report-back) and reconciles it with Owyhee "The Door" (governor#27)
|
|
9
|
+
so the SDK and the product share one arrival/block shape.
|
|
10
|
+
|
|
11
|
+
### Added — the stateful half (ledger, blocklist, reputation report-back)
|
|
12
|
+
|
|
13
|
+
- **`AccessLedger` (`src/ledger.js`)** — the platform's "who showed up" record.
|
|
14
|
+
Logs every verdict to a pluggable store (default in-memory `MemoryLedgerStore`;
|
|
15
|
+
documented adapter shape for D1 / SQLite / Postgres). `recent()` / `byOperator()`
|
|
16
|
+
query helpers. `loggedGate(gate, ledger, fields)` wraps any gate so verdicts are
|
|
17
|
+
logged as a side effect (a store failure never changes the verdict). Only the
|
|
18
|
+
trustworthy `effective_scope` is recorded.
|
|
19
|
+
- **`Blocklist` (`src/blocklist.js`)** — a runtime, stateful block list over the
|
|
20
|
+
same adapter shape. Blocks by `operator_id` AND by `agent_id` (agent-level
|
|
21
|
+
blocking is the SDK's superset over The Door's operator-only `operator_blocks`).
|
|
22
|
+
`blockedOperatorIds()` feeds verifyAgent's `blockedOperators`; `checkVerdict()`
|
|
23
|
+
enforces agent-level blocks post-verify (needs the resolved agent_id).
|
|
24
|
+
`gatedWithBlocklist(gate, blocklist)` wraps a gate.
|
|
25
|
+
|
|
26
|
+
### Reconciled with The Door (single source of truth, no duplication)
|
|
27
|
+
|
|
28
|
+
- The ledger entry shape is now byte-compatible with The Door's `ArrivalRecord`
|
|
29
|
+
/ `arrivals` columns: carries `tier`, `delegation_valid`, `gate_id`,
|
|
30
|
+
`requested_action`, `display_name`; `created_at` is epoch ms (was an ISO `ts`);
|
|
31
|
+
`decision` uses the `auto_allow | denied | held | approved | booted` vocabulary
|
|
32
|
+
(was `accepted | denied`), with the manual-review states available via a
|
|
33
|
+
`recordEntry(..., { decision })` override.
|
|
34
|
+
- Blocklist meta is `{ reason, created_at }` (epoch ms), matching `operator_blocks`.
|
|
35
|
+
- The SDK is positioned as the **port + in-memory default**; The Door is the
|
|
36
|
+
canonical **D1-backed adapter**. README documents the mapping table. The SDK
|
|
37
|
+
ships the genuinely-new pieces The Door lacks (agent-level blocking, reputation
|
|
38
|
+
emit); The Door keeps its own D1 state layer and, post-publish, depends on this
|
|
39
|
+
package instead of vendoring it.
|
|
40
|
+
- **Reputation report-back (`src/reportback.js`)** — `reportFlag()` builds a
|
|
41
|
+
protocol-shaped negative **Trust Attestation** (AXIS Layer 3; SPEC §4.5),
|
|
42
|
+
signs it with the platform's own Ed25519 key (`getPlatformKey()`, WebCrypto,
|
|
43
|
+
generated + persisted via a pluggable key store), and POSTs `{ attestation,
|
|
44
|
+
platform_public_key, signature }` to a configurable `reputationUrl`. OFF by
|
|
45
|
+
default — unconfigured is a graceful no-op (never throws). `blockAndReport()`
|
|
46
|
+
blocks locally + reports. `buildAttestation` / `signAttestation` /
|
|
47
|
+
`verifyAttestation` exposed. Zero-dep (no Buffer; WebCrypto + inline base64url
|
|
48
|
+
+ inline JCS, byte-for-byte matching axis-protocol-sdk's signing convention).
|
|
49
|
+
- **`examples/bouncer-worker.js`** — reference stateful bouncer: a Worker admin
|
|
50
|
+
over the ledger + blocklist (`/admin/arrivals` enriched for display,
|
|
51
|
+
`/admin/boot` = block + report, plus a tiny HTML console). A reference, not a
|
|
52
|
+
product; in-memory stores.
|
|
53
|
+
|
|
54
|
+
### Notes
|
|
55
|
+
|
|
56
|
+
- The reputation index is a SEPARATE, future, commercial service — NOT the
|
|
57
|
+
canonical registry, which stays identity-only (Layer 1 + Layer 2). The
|
|
58
|
+
`axis-reputation` stub receiver accepts-and-discards until the index is built.
|
|
59
|
+
- New tests for ledger / blocklist / reportback + the Door-compatible shape.
|
|
60
|
+
Full suite: 40 passing (`node --test`).
|
|
61
|
+
|
|
62
|
+
## [0.1.0] — 2026-06-16 (unreleased)
|
|
63
|
+
|
|
64
|
+
First cut of the platform/verifier ("bouncer") side of AXIS.
|
|
65
|
+
|
|
66
|
+
### Added
|
|
67
|
+
|
|
68
|
+
- **`verifyAgent(token, opts)`** — verifies an AIT against the registry
|
|
69
|
+
(signature + revocation + delegation chain, all server-side) and applies a
|
|
70
|
+
platform's policy (audience match, required scopes against the trustworthy
|
|
71
|
+
`effective_scope`, blocked/approved operators, minimum verification tier).
|
|
72
|
+
Returns one structured verdict.
|
|
73
|
+
- **`aitGate(opts)` / `extractToken()` / `denialResponse()`** — a drop-in
|
|
74
|
+
request gate for Cloudflare Workers (and Request-like objects). Pulls the AIT
|
|
75
|
+
from `Authorization: Bearer`, `X-AXIS-Token`, or `?ait=`.
|
|
76
|
+
- **`SwitchAuthorizer`** — the free-tier gate engine and the first implementation
|
|
77
|
+
of the Authorizer port. Config-driven on/off gates with optional minimum tier,
|
|
78
|
+
required scopes, and operator allow/block lists. Its `policy` object is what a
|
|
79
|
+
"Door policy" screen edits and saves. The port is engine-agnostic so a paid
|
|
80
|
+
`EngineAuthorizer` (Permify / OpenFGA sidecar) can drop into the same slot.
|
|
81
|
+
- **`scopeCovers` / `coversAll`** — AXIS scope matcher, ported verbatim from the
|
|
82
|
+
operator-side gateway so both sides agree on scope semantics.
|
|
83
|
+
- **`enrich()`**, **`loadAccessPolicy()`**, **`decodeAitPayload()`** helpers.
|
|
84
|
+
- Worked example: `examples/toy-platform-worker.js` (a bouncer comments service
|
|
85
|
+
gated by a `SwitchAuthorizer`).
|
|
86
|
+
|
|
87
|
+
### Tested
|
|
88
|
+
|
|
89
|
+
- 15 unit tests (`node --test`) across scope matching, the verify verdict
|
|
90
|
+
matrix, and the SwitchAuthorizer gate logic. `loadAccessPolicy` additionally
|
|
91
|
+
verified live against `registry.axisprime.ai`.
|
|
92
|
+
|
|
93
|
+
### Notes
|
|
94
|
+
|
|
95
|
+
- Zero runtime dependencies; runs in Node 20+, Cloudflare Workers, browsers.
|
|
96
|
+
- Identity verification is fixed/core; only the authorization decision is
|
|
97
|
+
pluggable. The demo and the free tier need no external policy engine.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
Copyright (c) Kipple Labs, Inc.
|
|
2
|
+
|
|
3
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
you may not use this file except in compliance with the License.
|
|
5
|
+
You may obtain a copy of the License at
|
|
6
|
+
|
|
7
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
|
|
9
|
+
Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
See the License for the specific language governing permissions and
|
|
13
|
+
limitations under the License.
|
|
14
|
+
|
|
15
|
+
NOTE (pre-release): drop the full Apache-2.0 license text into this file
|
|
16
|
+
before publishing, to match the sibling axis-* repos.
|
package/NOTICE
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
axis-platform-sdk
|
|
2
|
+
Copyright 2026 Kipple Labs, Inc.
|
|
3
|
+
|
|
4
|
+
This product was originally developed by Joshua Ashcroft prior
|
|
5
|
+
to the formation of Kipple Labs, Inc. All intellectual property
|
|
6
|
+
was assigned to Kipple Labs, Inc. at corporate formation under
|
|
7
|
+
the Confidential Information and Invention Assignment Agreement
|
|
8
|
+
dated April 24, 2026.
|
|
9
|
+
|
|
10
|
+
The "AXIS," "AXIS Protocol," "AXIS Prime," "N7," and "Kipple Labs"
|
|
11
|
+
names are trademarks of Kipple Labs, Inc.
|
package/README.md
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
# axis-platform-sdk
|
|
2
|
+
|
|
3
|
+
The **platform side** of AXIS. The bouncer at the door.
|
|
4
|
+
|
|
5
|
+
The [`axis-protocol-sdk`](https://github.com/MachinesOfDesire/axis-protocol-sdk) is what an *agent* uses to prove who it is. This SDK is the other end of the wire: what a **consuming platform** uses when an AXIS agent shows up and wants to do something, so the platform can **verify** it, check its **scope**, and decide to **accept** or **boot** it.
|
|
6
|
+
|
|
7
|
+
> Status: v0.2. Built for the full-loop demo and as the adoption surface for third-party platforms. Owyhee "The Door" is the first production consumer (it depends on this package rather than vendoring it).
|
|
8
|
+
|
|
9
|
+
## Why this exists
|
|
10
|
+
|
|
11
|
+
The registry already does the hard part. `GET /verify?token=<AIT>` checks the signature, checks revocation, and walks the delegation chain server-side, returning the trustworthy `effective_scope`. This SDK packages the verdict + your platform's policy (audience, required scopes, blocked operators, minimum tier) into one call, so you integrate in a few lines instead of hand-rolling it.
|
|
12
|
+
|
|
13
|
+
## Install (once published)
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
npm install axis-platform-sdk
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Zero dependencies. Runs in Node 20+, Cloudflare Workers, and modern browsers.
|
|
20
|
+
|
|
21
|
+
## Quickstart — gate a Worker endpoint
|
|
22
|
+
|
|
23
|
+
```js
|
|
24
|
+
import { aitGate, denialResponse } from 'axis-platform-sdk';
|
|
25
|
+
|
|
26
|
+
const gate = aitGate({
|
|
27
|
+
audience: 'comments.mysite.com', // your platform's stable audience id
|
|
28
|
+
requireScopes: ['comments:write'], // what the agent must be allowed to do
|
|
29
|
+
// minTier: 'domain', // optionally require domain-verified+
|
|
30
|
+
// blockedOperators: ['axis:spammer:operator'],
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
export default {
|
|
34
|
+
async fetch(request) {
|
|
35
|
+
const verdict = await gate(request);
|
|
36
|
+
if (!verdict.accepted) return denialResponse(verdict); // 401/403 + reason
|
|
37
|
+
// verdict.agent_id is verified. Proceed.
|
|
38
|
+
return Response.json({ ok: true, by: verdict.agent_id });
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
The gate pulls the AIT from `Authorization: Bearer <ait>`, `X-AXIS-Token`, or `?ait=`.
|
|
44
|
+
|
|
45
|
+
## Or call the verifier directly
|
|
46
|
+
|
|
47
|
+
```js
|
|
48
|
+
import { verifyAgent } from 'axis-platform-sdk';
|
|
49
|
+
|
|
50
|
+
const verdict = await verifyAgent(token, {
|
|
51
|
+
audience: 'comments.mysite.com',
|
|
52
|
+
requireScopes: ['comments:write'],
|
|
53
|
+
});
|
|
54
|
+
// -> { accepted: true, agent_id, operator_id, effective_scope, delegation_valid, tier, expires_at }
|
|
55
|
+
// or { accepted: false, code, reason, ... }
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## API
|
|
59
|
+
|
|
60
|
+
- **`verifyAgent(token, opts)`** — the core. Verifies against the registry and applies your policy. Returns a structured verdict.
|
|
61
|
+
- `audience` — your platform id. The AIT's `aud` must equal it. (Matched locally: the registry only checks that `aud` is non-empty, not that it equals you. That check is yours.)
|
|
62
|
+
- `requireScopes` — checked against the trustworthy `effective_scope`.
|
|
63
|
+
- `minTier` — `email | domain | verified | kyb_individual | kyb_organization`.
|
|
64
|
+
- `blockedOperators` / `approvedOperators` — deny/allow lists by operator id.
|
|
65
|
+
- `registryBaseUrl` — defaults to `https://registry.axisprime.ai`.
|
|
66
|
+
- **`aitGate(opts)`** — returns `(request) => Promise<verdict>`; binds your policy to a request gate.
|
|
67
|
+
- **`denialResponse(verdict)`** — turns a denied verdict into a 401/403 `Response`.
|
|
68
|
+
- **`scopeCovers(granted, required)` / `coversAll(granted, required[])`** — the AXIS scope matcher (ported verbatim from the Governor's, so operator and platform sides agree).
|
|
69
|
+
- **`enrich(agentId, token, opts)`** — fetch the agent's presentation layer (display name, tier) for a console UI.
|
|
70
|
+
- **`loadAccessPolicy(platformBaseUrl)`** — read a platform's published `/.well-known/axis-access` door policy.
|
|
71
|
+
- **`decodeAitPayload(token)`** — read the AIT payload (claims) without verifying. For the `aud` check; never trust it for authorization.
|
|
72
|
+
- **`AccessLedger` / `MemoryLedgerStore` / `loggedGate(gate, ledger, opts)` / `recordEntry(verdict, opts)`** — the access ledger (who showed up). `loggedGate` wraps a gate so every verdict is logged.
|
|
73
|
+
- **`Blocklist` / `MemoryBlocklistStore` / `gatedWithBlocklist(gate, blocklist)`** — the runtime block list (by operator and by agent). `blockOperator` / `blockAgent` / `unblock*` / `isAgentBlocked` / `blockedOperatorIds` / `checkVerdict`.
|
|
74
|
+
- **`reportFlag(args, opts)` / `blockAndReport(blocklist, args, opts)`** — sign a negative Trust Attestation and send it to a reputation index (OFF by default).
|
|
75
|
+
- **`getPlatformKey(opts)` / `buildAttestation` / `signAttestation` / `verifyAttestation` / `MemoryKeyStore`** — the platform's Ed25519 key + TA build/sign/verify primitives (WebCrypto, zero-dep).
|
|
76
|
+
|
|
77
|
+
## Gates as policy: `SwitchAuthorizer` (the free-tier engine)
|
|
78
|
+
|
|
79
|
+
Identity verification is fixed and core. The authorization *decision* is a pluggable layer — the **Authorizer port**. `SwitchAuthorizer` is the free-tier implementation: config-driven on/off gates. Its `policy` object is exactly what a console's "door policy" screen edits and saves.
|
|
80
|
+
|
|
81
|
+
```js
|
|
82
|
+
import { SwitchAuthorizer, denialResponse } from 'axis-platform-sdk';
|
|
83
|
+
|
|
84
|
+
const door = new SwitchAuthorizer({
|
|
85
|
+
audience: 'comments.mysite.com',
|
|
86
|
+
defaultAllow: false,
|
|
87
|
+
gates: {
|
|
88
|
+
'comments:write': { enabled: true, requireScopes: ['comments:write'], minTier: 'domain' },
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
const verdict = await door.gate('comments:write')(request);
|
|
93
|
+
if (!verdict.accepted) return denialResponse(verdict);
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Flip `enabled: false` and the gate closes, no code change. The port is engine-agnostic: a paid `EngineAuthorizer` (Permify / OpenFGA sidecar) for granular relationship/attribute rules drops into the same slot with the same `authorize(token, gateId, ctx)` shape. The demo and free tier need no engine.
|
|
97
|
+
|
|
98
|
+
## Stateful half: ledger, blocklist, reputation report-back
|
|
99
|
+
|
|
100
|
+
`verifyAgent` / `aitGate` are stateless verdict machines. A self-hosting
|
|
101
|
+
platform also needs **state it owns**: a record of who showed up, a runtime
|
|
102
|
+
block list, and a way to report a bad actor onward. These three modules add that,
|
|
103
|
+
all zero-infra — the platform runs the stores in its OWN store (default
|
|
104
|
+
in-memory; plug in D1 / SQLite / Postgres via a documented adapter shape).
|
|
105
|
+
|
|
106
|
+
### Access ledger — "who's using my platform"
|
|
107
|
+
|
|
108
|
+
```js
|
|
109
|
+
import { aitGate, AccessLedger, loggedGate } from 'axis-platform-sdk';
|
|
110
|
+
|
|
111
|
+
const ledger = new AccessLedger(); // default in-memory store
|
|
112
|
+
const gate = loggedGate(aitGate({ audience }), ledger, { audience });
|
|
113
|
+
|
|
114
|
+
const verdict = await gate(request); // every verdict is logged
|
|
115
|
+
// ...
|
|
116
|
+
await ledger.recent({ limit: 25 }); // newest-first arrivals
|
|
117
|
+
await ledger.byOperator('axis:acme:op'); // arrivals from one operator
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
Each entry records `{ agent_id, operator_id, created_at, tier, delegation_valid,
|
|
121
|
+
effective_scope, gate_id, requested_action, display_name, decision, reason,
|
|
122
|
+
audience }` — the same shape as Owyhee "The Door"'s `arrivals` record, so the
|
|
123
|
+
SDK and the product share one arrival definition (see "Single source of truth"
|
|
124
|
+
below). `decision` is `auto_allow | denied | held | approved | booted`;
|
|
125
|
+
`created_at` is epoch ms. Only the trustworthy `effective_scope` is recorded,
|
|
126
|
+
never the AIT's self-declared scope. A ledger write failure never changes the
|
|
127
|
+
verdict.
|
|
128
|
+
|
|
129
|
+
### Persistent block / allow list — runtime, by operator AND by agent
|
|
130
|
+
|
|
131
|
+
The static `blockedOperators` / `approvedOperators` are config-time policy. The
|
|
132
|
+
`Blocklist` is runtime policy the platform mutates without a redeploy, and it
|
|
133
|
+
adds **agent-level** blocking (boot one agent without booting its operator).
|
|
134
|
+
|
|
135
|
+
```js
|
|
136
|
+
import { Blocklist, verifyAgent } from 'axis-platform-sdk';
|
|
137
|
+
|
|
138
|
+
const blocklist = new Blocklist();
|
|
139
|
+
await blocklist.blockAgent('axis:acme:bot', 'spammed'); // agent-level
|
|
140
|
+
await blocklist.blockOperator('axis:bad:op', 'whole op'); // operator-level
|
|
141
|
+
|
|
142
|
+
// Inject dynamic operator blocks into verify, then catch agent-level post-verify:
|
|
143
|
+
let verdict = await verifyAgent(token, {
|
|
144
|
+
audience,
|
|
145
|
+
blockedOperators: [...staticBlocked, ...(await blocklist.blockedOperatorIds())],
|
|
146
|
+
});
|
|
147
|
+
verdict = await blocklist.checkVerdict(verdict); // flips to denied if agent/op blocked
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
### Reputation report-back — sign a negative Trust Attestation and send it onward
|
|
151
|
+
|
|
152
|
+
When you boot an agent, you know something the network doesn't. `reportFlag`
|
|
153
|
+
builds a protocol-shaped **Trust Attestation** (AXIS Layer 3; SPEC §4.5), signs
|
|
154
|
+
it with the platform's own Ed25519 key (generated + persisted on first use via
|
|
155
|
+
`getPlatformKey`, WebCrypto), and POSTs `{ attestation, platform_public_key,
|
|
156
|
+
signature }` to a configurable reputation index.
|
|
157
|
+
|
|
158
|
+
```js
|
|
159
|
+
import { reportFlag, blockAndReport, Blocklist } from 'axis-platform-sdk';
|
|
160
|
+
|
|
161
|
+
// Report-back is OFF by default. Unconfigured -> graceful no-op (never throws).
|
|
162
|
+
await reportFlag(
|
|
163
|
+
{ platformId: 'axis:my-platform:door', agentId: 'axis:acme:bot', category: 'abuse:spam', reason: 'flooded comments' },
|
|
164
|
+
{ reputationUrl: 'https://axis-reputation.example/attestations' } // when the index exists
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
// Convenience: block locally AND report (local block is authoritative; report is best-effort).
|
|
168
|
+
const blocklist = new Blocklist();
|
|
169
|
+
await blockAndReport(blocklist, { platformId: 'axis:my-platform:door', agentId: 'axis:acme:bot', category: 'abuse', reason: 'spam' });
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
The reputation **index is a separate, future, commercial service** — NOT the
|
|
173
|
+
canonical registry (which stays identity-only, Layer 1 + Layer 2). The
|
|
174
|
+
[`axis-reputation`](https://github.com/MachinesOfDesire/axis-reputation) stub
|
|
175
|
+
receiver accepts-and-discards (verifies signature, returns 202) until the real
|
|
176
|
+
index is built. See `examples/bouncer-worker.js` for a reference admin surface
|
|
177
|
+
over the ledger + blocklist.
|
|
178
|
+
|
|
179
|
+
### Single source of truth: the SDK is the port, The Door is the adapter
|
|
180
|
+
|
|
181
|
+
The ledger and blocklist are a **port + in-memory default**, not a competing
|
|
182
|
+
implementation. Owyhee **"The Door"** (the inbound surface of the Owyhee
|
|
183
|
+
console) is the first production **adapter**:
|
|
184
|
+
|
|
185
|
+
| SDK (this package, the port) | The Door (the D1-backed product adapter) |
|
|
186
|
+
| ----------------------------------- | ----------------------------------------------- |
|
|
187
|
+
| `AccessLedger` + `recordEntry` | `arrivals` table + `recordArrival()` |
|
|
188
|
+
| `Blocklist` (operator-level) | `operator_blocks` table + `blockedOperators()` |
|
|
189
|
+
| `SwitchAuthorizer` `policy` | `door_policy` table (serialized policy) |
|
|
190
|
+
| `Blocklist` agent-level *(superset)* | *(not yet — an additive `agent_blocks` table)* |
|
|
191
|
+
| `reportFlag` / reputation emit *(new)* | *(not yet — the open emit half is here)* |
|
|
192
|
+
|
|
193
|
+
The entry/meta shapes above are deliberately byte-compatible with The Door's
|
|
194
|
+
columns (`created_at` epoch ms; the `auto_allow|denied|held|approved|booted`
|
|
195
|
+
decision vocabulary) so there is **one** arrival/block record across the SDK and
|
|
196
|
+
the product — fold in, don't duplicate. A platform that needs its own store
|
|
197
|
+
implements the documented adapter shape; The Door is the worked, deployed
|
|
198
|
+
example of doing exactly that over Cloudflare D1.
|
|
199
|
+
|
|
200
|
+
## Trust model (read this)
|
|
201
|
+
|
|
202
|
+
- **`effective_scope` is the only trustworthy scope.** It's the registry's server-side chain-walk result, returned when a valid delegation is presented. The AIT's self-declared `scope` is NOT trusted and is never used for `requireScopes`.
|
|
203
|
+
- **A direct AIT with no valid delegation has no proven scope.** Any non-empty `requireScopes` will deny it. That's intentional.
|
|
204
|
+
- **Audience matching is the platform's job.** The registry guarantees `aud` exists; you guarantee it's *you*.
|
|
205
|
+
|
|
206
|
+
## Where it sits
|
|
207
|
+
|
|
208
|
+
```
|
|
209
|
+
agent (axis-protocol-sdk) ──presents AIT──> YOUR PLATFORM (axis-platform-sdk)
|
|
210
|
+
│
|
|
211
|
+
└── GET /verify ──> registry (does the crypto)
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
This is distinct from the Governor (the operator-side outbound gateway) and from generic AI gateways (TrueFoundry, Portkey, etc.), which govern an operator's *outbound* LLM calls. This SDK is the *inbound* identity gate.
|
|
215
|
+
|
|
216
|
+
## License
|
|
217
|
+
|
|
218
|
+
Apache-2.0. (c) Kipple Labs, Inc.
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reference bouncer — the STATEFUL demo.
|
|
3
|
+
*
|
|
4
|
+
* The toy-platform-worker shows the stateless verdict path (verify + door
|
|
5
|
+
* policy). THIS worker adds the stateful half: an access ledger ("who showed
|
|
6
|
+
* up") and a runtime blocklist ("boot this one"), plus a tiny admin surface
|
|
7
|
+
* over both. It's a REFERENCE — wiring you'd copy into a real platform — not a
|
|
8
|
+
* product. The stores here are in-memory, so state resets when the isolate
|
|
9
|
+
* recycles; a real deployment plugs D1 / KV / Postgres into the same adapter
|
|
10
|
+
* shape (see ledger.js / blocklist.js).
|
|
11
|
+
*
|
|
12
|
+
* Run locally: wrangler dev examples/bouncer-worker.js
|
|
13
|
+
*
|
|
14
|
+
* Endpoints:
|
|
15
|
+
* POST /comment the gated action (verify + ledger + blocklist)
|
|
16
|
+
* GET /admin/arrivals recent agents, enriched for display
|
|
17
|
+
* POST /admin/boot { agent_id | operator_id, reason } -> block + report
|
|
18
|
+
* GET /admin a tiny HTML page over the two above
|
|
19
|
+
*/
|
|
20
|
+
import {
|
|
21
|
+
SwitchAuthorizer,
|
|
22
|
+
verifyAgent,
|
|
23
|
+
extractToken,
|
|
24
|
+
denialResponse,
|
|
25
|
+
AccessLedger,
|
|
26
|
+
Blocklist,
|
|
27
|
+
enrich,
|
|
28
|
+
blockAndReport,
|
|
29
|
+
} from '../src/index.js';
|
|
30
|
+
|
|
31
|
+
const AUDIENCE = 'comments.demo-platform.example';
|
|
32
|
+
const PLATFORM_ID = 'axis:demo-platform:door'; // this platform's AXIS id, the attestor on reports
|
|
33
|
+
// const REPUTATION_URL = 'https://axis-reputation.example/attestations'; // OFF by default
|
|
34
|
+
|
|
35
|
+
// Stateless verdict engine (the editable "Door policy").
|
|
36
|
+
const door = new SwitchAuthorizer({
|
|
37
|
+
audience: AUDIENCE,
|
|
38
|
+
defaultAllow: false,
|
|
39
|
+
gates: { 'comments:write': { enabled: true, requireScopes: ['comments:write'] } },
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
// Stateful stores (in-memory reference; swap in D1/KV/Postgres in production).
|
|
43
|
+
const ledger = new AccessLedger();
|
|
44
|
+
const blocklist = new Blocklist();
|
|
45
|
+
|
|
46
|
+
export default {
|
|
47
|
+
async fetch(request) {
|
|
48
|
+
const url = new URL(request.url);
|
|
49
|
+
|
|
50
|
+
// --- the gated action ---------------------------------------------------
|
|
51
|
+
if (url.pathname === '/comment' && request.method === 'POST') {
|
|
52
|
+
// Inject runtime operator-blocks into the policy, then verify.
|
|
53
|
+
const dynBlocked = await blocklist.blockedOperatorIds();
|
|
54
|
+
const base = door.optsForGate('comments:write');
|
|
55
|
+
let verdict = await verifyAgent(extractToken(request), {
|
|
56
|
+
...base,
|
|
57
|
+
blockedOperators: [...(base.blockedOperators || []), ...dynBlocked],
|
|
58
|
+
});
|
|
59
|
+
// Agent-level runtime block (needs the resolved agent_id from the verdict).
|
|
60
|
+
verdict = await blocklist.checkVerdict(verdict);
|
|
61
|
+
|
|
62
|
+
// Log the arrival regardless of decision.
|
|
63
|
+
await ledger.record(verdict, { audience: AUDIENCE }).catch(() => {});
|
|
64
|
+
|
|
65
|
+
if (!verdict.accepted) return denialResponse(verdict);
|
|
66
|
+
const body = await request.json().catch(() => ({}));
|
|
67
|
+
return Response.json({ ok: true, posted_by: verdict.agent_id, comment: body.text || '' });
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// --- admin: recent arrivals (enriched for display) ----------------------
|
|
71
|
+
if (url.pathname === '/admin/arrivals' && request.method === 'GET') {
|
|
72
|
+
const limit = Number(url.searchParams.get('limit') || 25);
|
|
73
|
+
const rows = await ledger.recent({ limit });
|
|
74
|
+
// Enrich accepted arrivals with display name / tier for the console. We
|
|
75
|
+
// have no AIT here (presentation layer needs one), so this resolves only
|
|
76
|
+
// the public layer — display_name/tier may be null. That's expected.
|
|
77
|
+
const enriched = await Promise.all(
|
|
78
|
+
rows.map(async (r) => {
|
|
79
|
+
let display_name = null;
|
|
80
|
+
let tier = null;
|
|
81
|
+
if (r.agent_id) {
|
|
82
|
+
try {
|
|
83
|
+
const info = await enrich(r.agent_id, null);
|
|
84
|
+
display_name = info.display_name;
|
|
85
|
+
tier = info.tier;
|
|
86
|
+
} catch {
|
|
87
|
+
/* registry unreachable; show raw id */
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return { ...r, display_name, tier };
|
|
91
|
+
})
|
|
92
|
+
);
|
|
93
|
+
return Response.json({ arrivals: enriched });
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// --- admin: boot an agent or operator (block + best-effort report) ------
|
|
97
|
+
if (url.pathname === '/admin/boot' && request.method === 'POST') {
|
|
98
|
+
const body = await request.json().catch(() => ({}));
|
|
99
|
+
const reason = body.reason || 'booted by platform admin';
|
|
100
|
+
if (body.agent_id) {
|
|
101
|
+
// Block the agent locally AND report it (no-op send unless REPUTATION_URL set).
|
|
102
|
+
const out = await blockAndReport(
|
|
103
|
+
blocklist,
|
|
104
|
+
{ platformId: PLATFORM_ID, agentId: body.agent_id, operatorId: body.operator_id, category: body.category || 'abuse', reason },
|
|
105
|
+
{ /* reputationUrl: REPUTATION_URL */ }
|
|
106
|
+
);
|
|
107
|
+
return Response.json({ ok: true, ...out });
|
|
108
|
+
}
|
|
109
|
+
if (body.operator_id) {
|
|
110
|
+
await blocklist.blockOperator(body.operator_id, reason);
|
|
111
|
+
return Response.json({ ok: true, blocked_operator: body.operator_id });
|
|
112
|
+
}
|
|
113
|
+
return Response.json({ ok: false, error: 'provide agent_id or operator_id' }, { status: 400 });
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// --- admin: tiny HTML console ------------------------------------------
|
|
117
|
+
if (url.pathname === '/admin') {
|
|
118
|
+
return new Response(ADMIN_HTML, { headers: { 'Content-Type': 'text/html; charset=utf-8' } });
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return new Response('AXIS stateful bouncer reference. See /admin.\n', { status: 200 });
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const ADMIN_HTML =`<!doctype html><meta charset=utf-8>
|
|
126
|
+
<title>AXIS bouncer — admin</title>
|
|
127
|
+
<style>
|
|
128
|
+
body{font:14px/1.5 system-ui,sans-serif;margin:2rem;max-width:60rem}
|
|
129
|
+
h1{font-size:1.2rem} table{border-collapse:collapse;width:100%;margin:1rem 0}
|
|
130
|
+
th,td{border:1px solid #ccc;padding:.35rem .5rem;text-align:left;font-size:13px}
|
|
131
|
+
.denied,.booted{color:#b00} .auto_allow,.approved{color:#070} .held{color:#a60}
|
|
132
|
+
button{cursor:pointer} code{background:#f3f3f3;padding:0 .25rem}
|
|
133
|
+
</style>
|
|
134
|
+
<h1>AXIS bouncer — arrivals</h1>
|
|
135
|
+
<p>Who showed up at <code>${AUDIENCE}</code>. Click <b>boot</b> to block + report an agent.</p>
|
|
136
|
+
<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>
|
|
137
|
+
<script>
|
|
138
|
+
async function load(){
|
|
139
|
+
const r=await fetch('/admin/arrivals?limit=50');const {arrivals}=await r.json();
|
|
140
|
+
const tb=document.querySelector('#t tbody');tb.innerHTML='';
|
|
141
|
+
for(const a of arrivals){
|
|
142
|
+
const tr=document.createElement('tr');
|
|
143
|
+
const name=a.display_name?a.display_name+' ':'';
|
|
144
|
+
const when=a.created_at?new Date(a.created_at).toISOString().replace('T',' ').slice(0,19):'';
|
|
145
|
+
tr.innerHTML='<td>'+when+'</td><td>'+name+'<code>'+(a.agent_id||'?')+'</code></td>'+
|
|
146
|
+
'<td><code>'+(a.operator_id||'?')+'</code></td><td>'+(a.effective_scope||[]).join(', ')+'</td>'+
|
|
147
|
+
'<td class="'+a.decision+'">'+a.decision+'</td>'+
|
|
148
|
+
'<td>'+(a.agent_id?'<button data-a="'+a.agent_id+'">boot</button>':'')+'</td>';
|
|
149
|
+
tb.appendChild(tr);
|
|
150
|
+
}
|
|
151
|
+
tb.querySelectorAll('button').forEach(b=>b.onclick=async()=>{
|
|
152
|
+
await fetch('/admin/boot',{method:'POST',headers:{'Content-Type':'application/json'},
|
|
153
|
+
body:JSON.stringify({agent_id:b.dataset.a,reason:'booted from console',category:'abuse'})});
|
|
154
|
+
load();
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
load();
|
|
158
|
+
</script>`;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Toy "bouncer" platform — the backend for the full-loop demo.
|
|
3
|
+
*
|
|
4
|
+
* A pretend comments service that only accepts AXIS-verified agents holding
|
|
5
|
+
* `comments:write`. It gates with a SwitchAuthorizer driven by a `door` policy
|
|
6
|
+
* object — which is exactly what a "Door policy" screen in Owyhee edits and
|
|
7
|
+
* saves. Flip `enabled` to false and the gate closes with no code change.
|
|
8
|
+
*
|
|
9
|
+
* Run locally: wrangler dev examples/toy-platform-worker.js
|
|
10
|
+
*/
|
|
11
|
+
import { SwitchAuthorizer, denialResponse } from '../src/index.js';
|
|
12
|
+
|
|
13
|
+
const AUDIENCE = 'comments.demo-platform.example';
|
|
14
|
+
|
|
15
|
+
// The free-tier gate engine. This object is the editable "Door policy".
|
|
16
|
+
const door = new SwitchAuthorizer({
|
|
17
|
+
audience: AUDIENCE,
|
|
18
|
+
defaultAllow: false,
|
|
19
|
+
gates: {
|
|
20
|
+
'comments:write': {
|
|
21
|
+
enabled: true,
|
|
22
|
+
requireScopes: ['comments:write'],
|
|
23
|
+
// minTier: 'domain', // require domain-verified+ to comment
|
|
24
|
+
// blockedOperators: ['axis:spammer:operator'],
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
export default {
|
|
30
|
+
async fetch(request) {
|
|
31
|
+
const url = new URL(request.url);
|
|
32
|
+
|
|
33
|
+
// The gated action. The bouncer verifies + applies the door policy.
|
|
34
|
+
if (url.pathname === '/comment' && request.method === 'POST') {
|
|
35
|
+
const verdict = await door.gate('comments:write')(request);
|
|
36
|
+
if (!verdict.accepted) return denialResponse(verdict); // 401/403 + reason
|
|
37
|
+
|
|
38
|
+
const body = await request.json().catch(() => ({}));
|
|
39
|
+
return Response.json({
|
|
40
|
+
ok: true,
|
|
41
|
+
posted_by: verdict.agent_id,
|
|
42
|
+
operator: verdict.operator_id,
|
|
43
|
+
scope: verdict.effective_scope,
|
|
44
|
+
comment: body.text || '',
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Publish our door policy so AIT issuers know our audience + requirements.
|
|
49
|
+
if (url.pathname === '/.well-known/axis-access') {
|
|
50
|
+
return Response.json({
|
|
51
|
+
axis_version: '0.3',
|
|
52
|
+
platform_id: AUDIENCE,
|
|
53
|
+
audience: AUDIENCE,
|
|
54
|
+
access_policy: { minimum_verification_level: 'email', required_scopes: ['comments:write'], allow_unverified: false },
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return new Response('AXIS bouncer demo. POST /comment with an AIT to get in.\n', { status: 200 });
|
|
59
|
+
},
|
|
60
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "axis-platform-sdk",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Platform-side SDK for the AXIS protocol. The verifier/'bouncer' side: when an AXIS agent shows up at your platform, verify its identity + delegation + scope and decide whether to accept, scope, or boot it. Zero runtime dependencies; runs in Node 20+, Cloudflare Workers, and modern browsers.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/index.js",
|
|
9
|
+
"./scope": "./src/scope.js",
|
|
10
|
+
"./gate": "./src/gate.js",
|
|
11
|
+
"./authorizer": "./src/authorizer.js",
|
|
12
|
+
"./ledger": "./src/ledger.js",
|
|
13
|
+
"./blocklist": "./src/blocklist.js",
|
|
14
|
+
"./reportback": "./src/reportback.js"
|
|
15
|
+
},
|
|
16
|
+
"scripts": {
|
|
17
|
+
"test": "node --test test/scope.test.js test/verify.test.js test/authorizer.test.js test/ledger.test.js test/blocklist.test.js test/reportback.test.js"
|
|
18
|
+
},
|
|
19
|
+
"keywords": [
|
|
20
|
+
"axis",
|
|
21
|
+
"axis-protocol",
|
|
22
|
+
"agent-identity",
|
|
23
|
+
"verifier",
|
|
24
|
+
"platform",
|
|
25
|
+
"authorization"
|
|
26
|
+
],
|
|
27
|
+
"author": "Kipple Labs, Inc.",
|
|
28
|
+
"license": "Apache-2.0",
|
|
29
|
+
"engines": {
|
|
30
|
+
"node": ">=20.0.0"
|
|
31
|
+
},
|
|
32
|
+
"homepage": "https://github.com/MachinesOfDesire/axis-platform-sdk",
|
|
33
|
+
"repository": {
|
|
34
|
+
"type": "git",
|
|
35
|
+
"url": "https://github.com/MachinesOfDesire/axis-platform-sdk.git"
|
|
36
|
+
},
|
|
37
|
+
"bugs": {
|
|
38
|
+
"url": "https://github.com/MachinesOfDesire/axis-platform-sdk/issues"
|
|
39
|
+
},
|
|
40
|
+
"files": [
|
|
41
|
+
"src/",
|
|
42
|
+
"examples/",
|
|
43
|
+
"README.md",
|
|
44
|
+
"CHANGELOG.md",
|
|
45
|
+
"LICENSE",
|
|
46
|
+
"NOTICE"
|
|
47
|
+
]
|
|
48
|
+
}
|