@aithos/sdk 0.1.0-alpha.5 → 0.1.0-alpha.7
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/README.md +45 -0
- package/dist/src/auth.d.ts +11 -0
- package/dist/src/auth.js +95 -1
- package/dist/src/ethos.js +217 -7
- package/dist/src/index.d.ts +4 -3
- package/dist/src/index.js +6 -2
- package/dist/src/mandates.d.ts +64 -1
- package/dist/src/mandates.js +104 -4
- package/dist/test/ethos-first-edition.test.d.ts +2 -0
- package/dist/test/ethos-first-edition.test.js +248 -0
- package/dist/test/mandates-compute.test.d.ts +2 -0
- package/dist/test/mandates-compute.test.js +256 -0
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -55,6 +55,51 @@ const reply = await sdk.compute.invokeBedrock({
|
|
|
55
55
|
console.log(reply.content);
|
|
56
56
|
```
|
|
57
57
|
|
|
58
|
+
## Delegating compute to an agent — opt-in token spending
|
|
59
|
+
|
|
60
|
+
To let an agent (or another user, or a third-party app) invoke Bedrock
|
|
61
|
+
**in your name**, with **your credits**, you mint a mandate. Token
|
|
62
|
+
spending is its own opt-in capability — passing it is a separate,
|
|
63
|
+
named, validated input that a consent UI can review. It is NEVER an
|
|
64
|
+
implicit side-effect of an ethos read/write scope.
|
|
65
|
+
|
|
66
|
+
```ts
|
|
67
|
+
// Mint a mandate that lets agent Bob read your public ethos AND
|
|
68
|
+
// spend up to 5 000 microcredits/day on Haiku, capped at 100 000
|
|
69
|
+
// microcredits over the whole mandate lifetime.
|
|
70
|
+
const mandate = await sdk.mandates.create({
|
|
71
|
+
granteeId: "urn:agent:bob",
|
|
72
|
+
scopes: ["ethos.read.public"],
|
|
73
|
+
ttlSeconds: 86_400,
|
|
74
|
+
compute: {
|
|
75
|
+
dailyCapMicrocredits: 5_000,
|
|
76
|
+
totalCapMicrocredits: 100_000,
|
|
77
|
+
maxCreditsPerCall: 500,
|
|
78
|
+
allowedModels: ["claude-haiku-4-5"],
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// Hand `mandate.bundle` (a `.aithos-delegate.json` Blob) to Bob.
|
|
83
|
+
// He imports it, then signs his own envelopes and calls
|
|
84
|
+
// sdk.compute.invokeBedrock({ mandateId: mandate.mandateId, … })
|
|
85
|
+
// — every invocation debits *your* wallet, capped per the budget
|
|
86
|
+
// you set.
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Three invariants the SDK enforces synchronously, before reaching the
|
|
90
|
+
network — they fail fast with a precise `AithosSDKError`:
|
|
91
|
+
|
|
92
|
+
- **No smuggling.** Adding `"compute.invoke"` directly to `scopes[]`
|
|
93
|
+
throws `mandates_invalid_scopes`. The `compute` namespace is the
|
|
94
|
+
only path, so a UI reviewing `compute` can never be bypassed.
|
|
95
|
+
- **No bearer compute.** A `compute` namespace without at least one
|
|
96
|
+
of `dailyCapMicrocredits` or `totalCapMicrocredits` throws
|
|
97
|
+
`mandates_invalid_compute`. Unbounded compute mandates are forbidden
|
|
98
|
+
by construction.
|
|
99
|
+
- **Compute-only is fine.** `scopes: []` is allowed when `compute` is
|
|
100
|
+
set — useful for agents that only consume tokens (e.g. creative
|
|
101
|
+
assistants) without seeing any of your data.
|
|
102
|
+
|
|
58
103
|
## What lives where
|
|
59
104
|
|
|
60
105
|
| Namespace | Purpose |
|
package/dist/src/auth.d.ts
CHANGED
|
@@ -4,8 +4,18 @@ import { DelegateActor } from "./internal/delegate-state.js";
|
|
|
4
4
|
import { OwnerSigners } from "./internal/owner-signers.js";
|
|
5
5
|
/** Default URL of the Aithos auth backend. */
|
|
6
6
|
export declare const DEFAULT_AUTH_BASE_URL = "https://auth.aithos.be";
|
|
7
|
+
/** Default URL of the Aithos primitives API (publish_identity, publish_ethos_edition, etc.). */
|
|
8
|
+
export declare const DEFAULT_API_BASE_URL = "https://api.aithos.be";
|
|
7
9
|
export interface AithosAuthConfig {
|
|
8
10
|
readonly authBaseUrl?: string;
|
|
11
|
+
/**
|
|
12
|
+
* Base URL of the Aithos primitives API (`api.aithos.be`). Used by
|
|
13
|
+
* {@link AithosAuth.signUp} to bootstrap the user's Ethos via
|
|
14
|
+
* `aithos.publish_identity` after the auth account is created. Override
|
|
15
|
+
* for staging or self-hosted deployments. Defaults to
|
|
16
|
+
* {@link DEFAULT_API_BASE_URL}.
|
|
17
|
+
*/
|
|
18
|
+
readonly apiBaseUrl?: string;
|
|
9
19
|
readonly fetch?: typeof fetch;
|
|
10
20
|
readonly window?: Pick<Window, "location" | "history">;
|
|
11
21
|
/** Pluggable JWT-session storage. Defaults to {@link defaultSessionStore}. */
|
|
@@ -82,6 +92,7 @@ export interface ImportMandateInput {
|
|
|
82
92
|
export declare class AithosAuth {
|
|
83
93
|
#private;
|
|
84
94
|
readonly authBaseUrl: string;
|
|
95
|
+
readonly apiBaseUrl: string;
|
|
85
96
|
constructor(config?: AithosAuthConfig);
|
|
86
97
|
/**
|
|
87
98
|
* Reload signing material and JWT session from the configured stores.
|
package/dist/src/auth.js
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
// JWT-less sessions (recovery / mandate sign-ins) are valid: the
|
|
21
21
|
// keyStore is the source of truth for "is the user signed in", the
|
|
22
22
|
// JWT is auxiliary for compute/wallet.
|
|
23
|
-
import { buildBlobPlaintext, createBrowserIdentity, decryptBlob, DEFAULT_KDF, deriveAuthAndEncKeys, encryptBlob, parseBlob, randomNonce, randomSalt, serializeBlob, zeroize, } from "@aithos/protocol-client";
|
|
23
|
+
import { buildBlobPlaintext, buildSignedEnvelope, createBrowserIdentity, decryptBlob, DEFAULT_KDF, deriveAuthAndEncKeys, encryptBlob, parseBlob, randomNonce, randomSalt, serializeBlob, signedDidDocument, zeroize, } from "@aithos/protocol-client";
|
|
24
24
|
import { loginChallenge, loginVerify, registerAccount, } from "./auth-api.js";
|
|
25
25
|
import { defaultSessionStore, } from "./session-store.js";
|
|
26
26
|
import { defaultKeyStore, } from "./key-store.js";
|
|
@@ -31,11 +31,14 @@ import { parseRecoveryFile, readRecoveryFileText, serializeRecoveryFile, } from
|
|
|
31
31
|
import { AithosSDKError } from "./types.js";
|
|
32
32
|
/** Default URL of the Aithos auth backend. */
|
|
33
33
|
export const DEFAULT_AUTH_BASE_URL = "https://auth.aithos.be";
|
|
34
|
+
/** Default URL of the Aithos primitives API (publish_identity, publish_ethos_edition, etc.). */
|
|
35
|
+
export const DEFAULT_API_BASE_URL = "https://api.aithos.be";
|
|
34
36
|
/* -------------------------------------------------------------------------- */
|
|
35
37
|
/* AithosAuth */
|
|
36
38
|
/* -------------------------------------------------------------------------- */
|
|
37
39
|
export class AithosAuth {
|
|
38
40
|
authBaseUrl;
|
|
41
|
+
apiBaseUrl;
|
|
39
42
|
#fetchImpl;
|
|
40
43
|
#win;
|
|
41
44
|
#sessionStore;
|
|
@@ -46,6 +49,7 @@ export class AithosAuth {
|
|
|
46
49
|
#delegates = new DelegateRegistry();
|
|
47
50
|
constructor(config = {}) {
|
|
48
51
|
this.authBaseUrl = trimSlash(config.authBaseUrl ?? DEFAULT_AUTH_BASE_URL);
|
|
52
|
+
this.apiBaseUrl = trimSlash(config.apiBaseUrl ?? DEFAULT_API_BASE_URL);
|
|
49
53
|
this.#fetchImpl = config.fetch ?? globalThis.fetch.bind(globalThis);
|
|
50
54
|
this.#win =
|
|
51
55
|
config.window ??
|
|
@@ -306,6 +310,16 @@ export class AithosAuth {
|
|
|
306
310
|
zeroize(authKey);
|
|
307
311
|
zeroize(encKey);
|
|
308
312
|
}
|
|
313
|
+
// Bootstrap the Ethos on api.aithos.be. Without this, every subsequent
|
|
314
|
+
// write (publish_ethos_edition, etc.) errors out with -32020
|
|
315
|
+
// "subject identity not published". We do this BEFORE hydrating local
|
|
316
|
+
// state so a bootstrap failure leaves the SDK in a clean
|
|
317
|
+
// "not signed in" state — the dev shows an error, the user retries.
|
|
318
|
+
// The auth account on auth.aithos.be DOES exist at this point, but
|
|
319
|
+
// without the local hydrate the user can't act on it. Self-heal on
|
|
320
|
+
// signIn (re-attempt publish_identity if missing) is planned for a
|
|
321
|
+
// follow-up release.
|
|
322
|
+
await this.#publishIdentity(identity);
|
|
309
323
|
// Hydrate in-memory state from the fresh identity.
|
|
310
324
|
if (this.#ownerSigners)
|
|
311
325
|
this.#ownerSigners.destroy();
|
|
@@ -558,6 +572,83 @@ export class AithosAuth {
|
|
|
558
572
|
await this.#keyStore.clearOwner().catch(() => { });
|
|
559
573
|
await this.#keyStore.clearAllDelegates().catch(() => { });
|
|
560
574
|
}
|
|
575
|
+
/* ------------------------------------------------------------------------ */
|
|
576
|
+
/* Internal — Ethos bootstrap */
|
|
577
|
+
/* ------------------------------------------------------------------------ */
|
|
578
|
+
/**
|
|
579
|
+
* Provision the user's Ethos on `api.aithos.be` by signing and POSTing an
|
|
580
|
+
* `aithos.publish_identity` envelope. Required after a fresh sign-up so
|
|
581
|
+
* subsequent edition publishes (`me.publish()`) don't fail with
|
|
582
|
+
* `-32020 subject identity not published`.
|
|
583
|
+
*
|
|
584
|
+
* Retries twice with exponential backoff on transient errors (network or
|
|
585
|
+
* 5xx). Throws {@link AithosSDKError} with code `ethos_bootstrap_failed`
|
|
586
|
+
* on definitive failure — the caller is expected to abort sign-up.
|
|
587
|
+
*
|
|
588
|
+
* @internal
|
|
589
|
+
*/
|
|
590
|
+
async #publishIdentity(identity) {
|
|
591
|
+
const url = `${this.apiBaseUrl}/mcp/primitives/write`;
|
|
592
|
+
const signedDoc = signedDidDocument(identity);
|
|
593
|
+
const params = {
|
|
594
|
+
did_document: signedDoc,
|
|
595
|
+
handle: identity.handle,
|
|
596
|
+
display_name: identity.displayName,
|
|
597
|
+
};
|
|
598
|
+
const envelope = buildSignedEnvelope({
|
|
599
|
+
iss: identity.did,
|
|
600
|
+
aud: url,
|
|
601
|
+
method: "aithos.publish_identity",
|
|
602
|
+
verificationMethod: `${identity.did}#root`,
|
|
603
|
+
params,
|
|
604
|
+
signer: identity.root,
|
|
605
|
+
});
|
|
606
|
+
const body = JSON.stringify({
|
|
607
|
+
jsonrpc: "2.0",
|
|
608
|
+
id: "publish_identity",
|
|
609
|
+
method: "aithos.publish_identity",
|
|
610
|
+
params: { ...params, _envelope: envelope },
|
|
611
|
+
});
|
|
612
|
+
// Two retries with backoff (300ms, 1500ms). Idempotent on the server
|
|
613
|
+
// side — replaying the same publish_identity for an existing DID is a
|
|
614
|
+
// no-op, so retries are safe even if the first attempt actually
|
|
615
|
+
// succeeded but the response was lost.
|
|
616
|
+
const delays = [0, 300, 1500];
|
|
617
|
+
let lastError;
|
|
618
|
+
for (const delay of delays) {
|
|
619
|
+
if (delay > 0)
|
|
620
|
+
await sleep(delay);
|
|
621
|
+
try {
|
|
622
|
+
const res = await this.#fetchImpl(url, {
|
|
623
|
+
method: "POST",
|
|
624
|
+
headers: { "content-type": "application/json" },
|
|
625
|
+
body,
|
|
626
|
+
});
|
|
627
|
+
// Transport errors (5xx, no body) — retry. JSON-RPC errors come
|
|
628
|
+
// back with HTTP 200 and an `error` field.
|
|
629
|
+
if (!res.ok && res.status >= 500) {
|
|
630
|
+
lastError = new Error(`HTTP ${res.status}`);
|
|
631
|
+
continue;
|
|
632
|
+
}
|
|
633
|
+
const json = (await res.json());
|
|
634
|
+
if (json.error) {
|
|
635
|
+
// JSON-RPC error: don't retry — these are deterministic
|
|
636
|
+
// (validation, permission, identity-already-tombstoned, …).
|
|
637
|
+
throw new AithosSDKError("ethos_bootstrap_failed", `publish_identity rejected: ${json.error.message}`, {
|
|
638
|
+
status: res.status,
|
|
639
|
+
data: { rpc_code: json.error.code, ...(json.error.data ?? {}) },
|
|
640
|
+
});
|
|
641
|
+
}
|
|
642
|
+
return; // success
|
|
643
|
+
}
|
|
644
|
+
catch (e) {
|
|
645
|
+
if (e instanceof AithosSDKError)
|
|
646
|
+
throw e;
|
|
647
|
+
lastError = e;
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
throw new AithosSDKError("ethos_bootstrap_failed", `publish_identity unreachable after ${delays.length} attempts: ${lastError?.message ?? "unknown"}`);
|
|
651
|
+
}
|
|
561
652
|
}
|
|
562
653
|
/* -------------------------------------------------------------------------- */
|
|
563
654
|
/* Helpers */
|
|
@@ -565,6 +656,9 @@ export class AithosAuth {
|
|
|
565
656
|
function trimSlash(url) {
|
|
566
657
|
return url.endsWith("/") ? url.slice(0, -1) : url;
|
|
567
658
|
}
|
|
659
|
+
function sleep(ms) {
|
|
660
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
661
|
+
}
|
|
568
662
|
function cleanCallbackParams(win, url) {
|
|
569
663
|
url.searchParams.delete("aithos_code");
|
|
570
664
|
url.searchParams.delete("aithos_error");
|
package/dist/src/ethos.js
CHANGED
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
// actor and forwards all real work into protocol-client's
|
|
26
26
|
// `loadEditSnapshot` / `publishZoneEdit` / `publishPublicZoneAsDelegate`
|
|
27
27
|
// / `publishPrivateZoneAsDelegate`.
|
|
28
|
-
import { addSectionToList, deleteSectionFromList, loadEditSnapshot, modifySectionInList, publishPrivateZoneAsDelegate, publishPublicZoneAsDelegate, publishZoneEdit, } from "@aithos/protocol-client";
|
|
28
|
+
import { addSectionToList, AithosRpcError, browserIdentityFromStored, buildSignedEnvelope, buildSignedFirstEditionFromSections, deleteSectionFromList, loadEditSnapshot, modifySectionInList, publishPrivateZoneAsDelegate, publishPublicZoneAsDelegate, publishZoneEdit, signedDidDocument, writeEndpoint, } from "@aithos/protocol-client";
|
|
29
29
|
import { delegateKeyPair } from "./internal/protocol-client-bridge.js";
|
|
30
30
|
import { AithosSDKError } from "./types.js";
|
|
31
31
|
export const ZONE_NAMES = ["public", "circle", "self"];
|
|
@@ -38,6 +38,15 @@ export class EthosClient {
|
|
|
38
38
|
#actor;
|
|
39
39
|
#snapshots = new Map();
|
|
40
40
|
#mutations = [];
|
|
41
|
+
/**
|
|
42
|
+
* Set to `true` when the server reports that no edition has been
|
|
43
|
+
* published yet for this subject (JSON-RPC code -32020 with the message
|
|
44
|
+
* `not found: edition for <did>`). Toggled lazily on the first read /
|
|
45
|
+
* publish attempt and cleared again after a successful first-edition
|
|
46
|
+
* publish so subsequent operations take the regular `publishZoneEdit`
|
|
47
|
+
* path.
|
|
48
|
+
*/
|
|
49
|
+
#ethosHasNoEditionYet = false;
|
|
41
50
|
constructor(actor) {
|
|
42
51
|
this.#actor = actor;
|
|
43
52
|
this.subjectDid = actor.subjectDid;
|
|
@@ -80,8 +89,15 @@ export class EthosClient {
|
|
|
80
89
|
// have staged mutations for. Build everything we need then call
|
|
81
90
|
// publishZoneEdit once.
|
|
82
91
|
if (this.#actor.kind === "owner") {
|
|
83
|
-
//
|
|
84
|
-
|
|
92
|
+
// First-edition path: no prior edition exists. Detected when the
|
|
93
|
+
// tolerant snapshot wrapper returned null on a previous read OR
|
|
94
|
+
// when this is the first network touch and the server responds
|
|
95
|
+
// with "not found: edition". Skip the snapshot fetch entirely and
|
|
96
|
+
// build a height=1 manifest from the staged mutations.
|
|
97
|
+
const snap = await this.#tryEnsureSnapshotOwner();
|
|
98
|
+
if (snap === null) {
|
|
99
|
+
return this.#publishFirstEditionOwner();
|
|
100
|
+
}
|
|
85
101
|
const newPublic = touched.has("public")
|
|
86
102
|
? this.#applyMutations("public", snap.publicSections)
|
|
87
103
|
: undefined;
|
|
@@ -175,12 +191,18 @@ export class EthosClient {
|
|
|
175
191
|
if (zone !== "public") {
|
|
176
192
|
throw new AithosSDKError("ethos_anonymous_private_zone", `anonymous reader cannot access the "${zone}" zone`);
|
|
177
193
|
}
|
|
178
|
-
const snap = await this.#
|
|
179
|
-
|
|
194
|
+
const snap = await this.#tryEnsureSnapshotAnonymous();
|
|
195
|
+
// No edition yet → no base sections; only staged mutations contribute.
|
|
196
|
+
const base = snap === null ? [] : snap.publicSections;
|
|
180
197
|
return this.#applyMutations(zone, base);
|
|
181
198
|
}
|
|
182
199
|
if (this.#actor.kind === "owner") {
|
|
183
|
-
const snap = await this.#
|
|
200
|
+
const snap = await this.#tryEnsureSnapshotOwner();
|
|
201
|
+
if (snap === null) {
|
|
202
|
+
// No edition yet for this owner — base sections are empty in every
|
|
203
|
+
// zone. Staged mutations apply on top of an empty list.
|
|
204
|
+
return this.#applyMutations(zone, []);
|
|
205
|
+
}
|
|
184
206
|
const base = baseSectionsFromSnapshot(snap, zone);
|
|
185
207
|
// Surface decryption errors clearly (if reading a private zone we
|
|
186
208
|
// can't unwrap, the snapshot has it in zoneDecryptErrors).
|
|
@@ -190,7 +212,10 @@ export class EthosClient {
|
|
|
190
212
|
return this.#applyMutations(zone, base);
|
|
191
213
|
}
|
|
192
214
|
// Delegate
|
|
193
|
-
const snap = await this.#
|
|
215
|
+
const snap = await this.#tryEnsureSnapshotDelegate();
|
|
216
|
+
if (snap === null) {
|
|
217
|
+
return this.#applyMutations(zone, []);
|
|
218
|
+
}
|
|
194
219
|
const base = baseSectionsFromSnapshot(snap, zone);
|
|
195
220
|
if (zone !== "public" && snap.zoneDecryptErrors?.[zone]) {
|
|
196
221
|
throw new AithosSDKError("ethos_zone_unreadable", `cannot read ${zone}: ${snap.zoneDecryptErrors[zone]}`);
|
|
@@ -283,6 +308,57 @@ export class EthosClient {
|
|
|
283
308
|
this.#cacheSnapshotAllZones(snap);
|
|
284
309
|
return snap;
|
|
285
310
|
}
|
|
311
|
+
/**
|
|
312
|
+
* Wrap {@link #ensureSnapshotOwner} so the "no edition published yet"
|
|
313
|
+
* server response (-32020 with message `not found: edition for <did>`)
|
|
314
|
+
* is converted into `null`. Once converted, {@link #ethosHasNoEditionYet}
|
|
315
|
+
* is set to short-circuit subsequent reads without re-hitting the network.
|
|
316
|
+
*
|
|
317
|
+
* Returns `null` to mean "subject has an identity but no editions yet —
|
|
318
|
+
* treat all zones as empty". Any other error is re-thrown.
|
|
319
|
+
*/
|
|
320
|
+
async #tryEnsureSnapshotOwner() {
|
|
321
|
+
if (this.#ethosHasNoEditionYet)
|
|
322
|
+
return null;
|
|
323
|
+
try {
|
|
324
|
+
return await this.#ensureSnapshotOwner();
|
|
325
|
+
}
|
|
326
|
+
catch (e) {
|
|
327
|
+
if (isNoEditionYetError(e)) {
|
|
328
|
+
this.#ethosHasNoEditionYet = true;
|
|
329
|
+
return null;
|
|
330
|
+
}
|
|
331
|
+
throw e;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
async #tryEnsureSnapshotDelegate() {
|
|
335
|
+
if (this.#ethosHasNoEditionYet)
|
|
336
|
+
return null;
|
|
337
|
+
try {
|
|
338
|
+
return await this.#ensureSnapshotDelegate();
|
|
339
|
+
}
|
|
340
|
+
catch (e) {
|
|
341
|
+
if (isNoEditionYetError(e)) {
|
|
342
|
+
this.#ethosHasNoEditionYet = true;
|
|
343
|
+
return null;
|
|
344
|
+
}
|
|
345
|
+
throw e;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
async #tryEnsureSnapshotAnonymous() {
|
|
349
|
+
if (this.#ethosHasNoEditionYet)
|
|
350
|
+
return null;
|
|
351
|
+
try {
|
|
352
|
+
return await this.#ensureSnapshotAnonymous();
|
|
353
|
+
}
|
|
354
|
+
catch (e) {
|
|
355
|
+
if (isNoEditionYetError(e)) {
|
|
356
|
+
this.#ethosHasNoEditionYet = true;
|
|
357
|
+
return null;
|
|
358
|
+
}
|
|
359
|
+
throw e;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
286
362
|
#cacheSnapshotAllZones(snap) {
|
|
287
363
|
for (const z of ZONE_NAMES)
|
|
288
364
|
this.#snapshots.set(z, snap);
|
|
@@ -291,6 +367,110 @@ export class EthosClient {
|
|
|
291
367
|
this.#mutations = [];
|
|
292
368
|
this.#snapshots.clear();
|
|
293
369
|
}
|
|
370
|
+
/* ------------------------------------------------------------------------ */
|
|
371
|
+
/* First-edition publish (owner) */
|
|
372
|
+
/* ------------------------------------------------------------------------ */
|
|
373
|
+
/**
|
|
374
|
+
* Publish height=1 for an owner whose Ethos identity exists on
|
|
375
|
+
* `api.aithos.be` (provisioned by `auth.signUp()` in alpha.6+) but who
|
|
376
|
+
* has no editions yet. Builds the manifest from the staged ADD
|
|
377
|
+
* mutations on the public zone and POSTs `aithos.publish_ethos_edition`.
|
|
378
|
+
*
|
|
379
|
+
* Limitations of the alpha.7 cut:
|
|
380
|
+
* - Public zone only. Staged mutations on circle/self are rejected
|
|
381
|
+
* with `ethos_first_edition_public_only` — those zones can be
|
|
382
|
+
* populated in subsequent editions via the regular
|
|
383
|
+
* `publishZoneEdit` path once the public zone has been seeded.
|
|
384
|
+
* - First-edition publishes don't accept update/delete mutations
|
|
385
|
+
* (there's nothing to update or delete yet) — those are rejected
|
|
386
|
+
* with `ethos_first_edition_invalid_op`.
|
|
387
|
+
*/
|
|
388
|
+
async #publishFirstEditionOwner() {
|
|
389
|
+
if (this.#actor.kind !== "owner") {
|
|
390
|
+
// Defensive — caller already checked this branch.
|
|
391
|
+
throw new AithosSDKError("ethos_invalid_actor", "expected owner actor");
|
|
392
|
+
}
|
|
393
|
+
// Validate the staged operation set. First edition = ADDs on public
|
|
394
|
+
// zone only.
|
|
395
|
+
const publicAdds = [];
|
|
396
|
+
for (const m of this.#mutations) {
|
|
397
|
+
if (m.kind !== "add") {
|
|
398
|
+
throw new AithosSDKError("ethos_first_edition_invalid_op", `first edition: cannot ${m.kind} a section before any edition exists; only addSection is supported on a fresh Ethos`, { data: { mutation: m } });
|
|
399
|
+
}
|
|
400
|
+
if (m.zone !== "public") {
|
|
401
|
+
throw new AithosSDKError("ethos_first_edition_public_only", `first edition: only the "public" zone is supported on a fresh Ethos; "${m.zone}" sections can be added after the first publish`, { data: { zone: m.zone } });
|
|
402
|
+
}
|
|
403
|
+
publicAdds.push({ section: m.section });
|
|
404
|
+
}
|
|
405
|
+
if (publicAdds.length === 0) {
|
|
406
|
+
// Should never reach here — publish() short-circuits on empty
|
|
407
|
+
// mutations. Belt-and-braces in case the contract drifts.
|
|
408
|
+
throw new AithosSDKError("ethos_first_edition_empty", "first edition: stage at least one public-zone section before publishing");
|
|
409
|
+
}
|
|
410
|
+
const identity = this.#actor.signers._unsafeStoredIdentity();
|
|
411
|
+
const browserId = browserIdentityFromStored(identity);
|
|
412
|
+
const signedDoc = signedDidDocument(browserId);
|
|
413
|
+
const built = buildSignedFirstEditionFromSections({
|
|
414
|
+
identity: browserId,
|
|
415
|
+
signedDidDoc: signedDoc,
|
|
416
|
+
publicSections: publicAdds.map((a) => a.section),
|
|
417
|
+
});
|
|
418
|
+
const url = writeEndpoint();
|
|
419
|
+
const params = {
|
|
420
|
+
manifest: built.manifest,
|
|
421
|
+
zones: {
|
|
422
|
+
public: { bytes_base64: bytesToBase64Padded(built.publicMarkdownBytes) },
|
|
423
|
+
},
|
|
424
|
+
};
|
|
425
|
+
const envelope = buildSignedEnvelope({
|
|
426
|
+
iss: browserId.did,
|
|
427
|
+
aud: url,
|
|
428
|
+
method: "aithos.publish_ethos_edition",
|
|
429
|
+
verificationMethod: `${browserId.did}#public`,
|
|
430
|
+
params,
|
|
431
|
+
signer: browserId.public,
|
|
432
|
+
});
|
|
433
|
+
const body = JSON.stringify({
|
|
434
|
+
jsonrpc: "2.0",
|
|
435
|
+
id: "publish_ethos_edition",
|
|
436
|
+
method: "aithos.publish_ethos_edition",
|
|
437
|
+
params: { ...params, _envelope: envelope },
|
|
438
|
+
});
|
|
439
|
+
let res;
|
|
440
|
+
try {
|
|
441
|
+
res = await fetch(url, {
|
|
442
|
+
method: "POST",
|
|
443
|
+
headers: { "content-type": "application/json" },
|
|
444
|
+
body,
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
catch (e) {
|
|
448
|
+
throw new AithosSDKError("ethos_publish_network", `publish_ethos_edition (first edition): network error: ${e.message ?? "unknown"}`);
|
|
449
|
+
}
|
|
450
|
+
let json;
|
|
451
|
+
try {
|
|
452
|
+
json = (await res.json());
|
|
453
|
+
}
|
|
454
|
+
catch {
|
|
455
|
+
throw new AithosSDKError("ethos_publish_invalid_response", `publish_ethos_edition (first edition): server returned non-JSON (HTTP ${res.status})`);
|
|
456
|
+
}
|
|
457
|
+
if (json.error) {
|
|
458
|
+
throw new AithosSDKError("ethos_first_edition_rejected", `publish_ethos_edition (first edition) rejected: ${json.error.message}`, {
|
|
459
|
+
status: res.status,
|
|
460
|
+
data: { rpc_code: json.error.code, ...(json.error.data ?? {}) },
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
// Success: clear the no-edition flag so subsequent reads/publishes
|
|
464
|
+
// take the regular next-edition path.
|
|
465
|
+
this.#ethosHasNoEditionYet = false;
|
|
466
|
+
this.#afterPublish();
|
|
467
|
+
return {
|
|
468
|
+
editionHeight: 1,
|
|
469
|
+
manifestHash: "", // protocol-client surfaces this on later editions; not on first
|
|
470
|
+
subjectDid: browserId.did,
|
|
471
|
+
zonesPublished: ["public"],
|
|
472
|
+
};
|
|
473
|
+
}
|
|
294
474
|
}
|
|
295
475
|
/* -------------------------------------------------------------------------- */
|
|
296
476
|
/* EthosZone — per-zone proxy */
|
|
@@ -410,6 +590,36 @@ function projectPublishResult(manifest, subjectDid, zones) {
|
|
|
410
590
|
zonesPublished: zones,
|
|
411
591
|
};
|
|
412
592
|
}
|
|
593
|
+
/**
|
|
594
|
+
* Detect the server-side "no edition published yet" response. Matches the
|
|
595
|
+
* exact error shape emitted by primitives-read's `notFound("edition for
|
|
596
|
+
* <did>")` helper: JSON-RPC code -32020 + message starting with `not
|
|
597
|
+
* found: edition for `. Other -32020 cases (e.g. "not found: manifest
|
|
598
|
+
* <did>@<height>" raised when the index advertises an edition the S3
|
|
599
|
+
* bucket can't serve) deliberately fall through — they're symptoms, not
|
|
600
|
+
* the "fresh subject" case we're trying to swallow.
|
|
601
|
+
*/
|
|
602
|
+
function isNoEditionYetError(e) {
|
|
603
|
+
if (!(e instanceof AithosRpcError))
|
|
604
|
+
return false;
|
|
605
|
+
if (e.code !== -32020)
|
|
606
|
+
return false;
|
|
607
|
+
return typeof e.message === "string"
|
|
608
|
+
&& e.message.startsWith("not found: edition for ");
|
|
609
|
+
}
|
|
610
|
+
/**
|
|
611
|
+
* Standard base64 with `=` padding — matches what protocol-client's
|
|
612
|
+
* publishZoneEdit uses for `zones.<zone>.bytes_base64`. The server is
|
|
613
|
+
* tolerant of either padded or unpadded variants per the API contract,
|
|
614
|
+
* but we mirror the existing wire to keep payloads byte-identical for
|
|
615
|
+
* easy diffing in dev tools.
|
|
616
|
+
*/
|
|
617
|
+
function bytesToBase64Padded(bytes) {
|
|
618
|
+
let bin = "";
|
|
619
|
+
for (let i = 0; i < bytes.length; i++)
|
|
620
|
+
bin += String.fromCharCode(bytes[i]);
|
|
621
|
+
return btoa(bin);
|
|
622
|
+
}
|
|
413
623
|
function randomHex(n) {
|
|
414
624
|
const bytes = new Uint8Array(Math.ceil(n / 2));
|
|
415
625
|
crypto.getRandomValues(bytes);
|
package/dist/src/index.d.ts
CHANGED
|
@@ -2,20 +2,21 @@ export declare const VERSION = "0.1.0-alpha.5";
|
|
|
2
2
|
export { AithosSDK } from "./sdk.js";
|
|
3
3
|
export type { AithosSDKConfig } from "./types.js";
|
|
4
4
|
export { AithosSDKError } from "./types.js";
|
|
5
|
+
export { AithosRpcError } from "@aithos/protocol-client";
|
|
5
6
|
export type { AithosSdkEndpoints } from "./endpoints.js";
|
|
6
7
|
export { DEFAULT_SDK_ENDPOINTS } from "./endpoints.js";
|
|
7
8
|
export type { ComputeMessage, InvokeBedrockArgs, InvokeBedrockResult, StopReason, } from "./compute.js";
|
|
8
9
|
export { ComputeNamespace } from "./compute.js";
|
|
9
10
|
export type { CreditPackId, CreateTopupSessionArgs, CreateTopupSessionResult, GetBalanceArgs, GetBalanceResult, } from "./wallet.js";
|
|
10
11
|
export { WalletNamespace } from "./wallet.js";
|
|
11
|
-
export { AithosAuth, DEFAULT_AUTH_BASE_URL } from "./auth.js";
|
|
12
|
+
export { AithosAuth, DEFAULT_API_BASE_URL, DEFAULT_AUTH_BASE_URL, } from "./auth.js";
|
|
12
13
|
export type { AithosAuthConfig, AithosSession, DelegateInfo, ImportMandateInput, OwnerInfo, SignInInput, SignInWithGoogleOptions, SignInWithRecoveryInput, SignUpInput, SignUpResult, } from "./auth.js";
|
|
13
14
|
export { DEFAULT_SESSION_STORAGE_KEY, defaultSessionStore, localStorageStore, noopStore, sessionStorageStore, type AithosSessionStore, } from "./session-store.js";
|
|
14
15
|
export { DEFAULT_KEYSTORE_DB_NAME, defaultKeyStore, indexedDbKeyStore, memoryKeyStore, type AithosKeyStore, type StoredDelegateKeys, type StoredOwnerKeys, } from "./key-store.js";
|
|
15
16
|
export { EthosClient, EthosNamespace, EthosZone, ZONE_NAMES, } from "./ethos.js";
|
|
16
17
|
export type { AddSectionInput, PublishResult, StagedChange, UpdateSectionPatch, ZoneName, } from "./ethos.js";
|
|
17
|
-
export { MandatesNamespace } from "./mandates.js";
|
|
18
|
-
export type { ActorSphere, CreateMandateInput, MintedMandate, OwnedMandate, Scope, } from "./mandates.js";
|
|
18
|
+
export { COMPUTE_INVOKE_SCOPE, MandatesNamespace } from "./mandates.js";
|
|
19
|
+
export type { ActorSphere, CreateMandateComputeInput, CreateMandateInput, MintedMandate, OwnedMandate, Scope, } from "./mandates.js";
|
|
19
20
|
export * as onboarding from "./onboarding.js";
|
|
20
21
|
export { createBrowserIdentity, browserIdentityFromStored, type BrowserIdentity, } from "@aithos/protocol-client";
|
|
21
22
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/src/index.js
CHANGED
|
@@ -20,6 +20,10 @@
|
|
|
20
20
|
export const VERSION = "0.1.0-alpha.5";
|
|
21
21
|
export { AithosSDK } from "./sdk.js";
|
|
22
22
|
export { AithosSDKError } from "./types.js";
|
|
23
|
+
// Re-export protocol-client's JSON-RPC error type so consumers can
|
|
24
|
+
// `instanceof`-check server-side errors and inspect the JSON-RPC code
|
|
25
|
+
// without taking a direct dependency on @aithos/protocol-client.
|
|
26
|
+
export { AithosRpcError } from "@aithos/protocol-client";
|
|
23
27
|
export { DEFAULT_SDK_ENDPOINTS } from "./endpoints.js";
|
|
24
28
|
export { ComputeNamespace } from "./compute.js";
|
|
25
29
|
export { WalletNamespace } from "./wallet.js";
|
|
@@ -28,7 +32,7 @@ export { WalletNamespace } from "./wallet.js";
|
|
|
28
32
|
// BrowserIdentity (sign-up creates one, sign-in restores it from the
|
|
29
33
|
// server). The class also owns the session store — see
|
|
30
34
|
// ./session-store.ts for pluggable persistence.
|
|
31
|
-
export { AithosAuth, DEFAULT_AUTH_BASE_URL } from "./auth.js";
|
|
35
|
+
export { AithosAuth, DEFAULT_API_BASE_URL, DEFAULT_AUTH_BASE_URL, } from "./auth.js";
|
|
32
36
|
// Session storage backends used by AithosAuth. `sessionStorageStore` is
|
|
33
37
|
// the default in browser environments ; pass another store at construction
|
|
34
38
|
// time if you need different persistence.
|
|
@@ -46,7 +50,7 @@ export { DEFAULT_KEYSTORE_DB_NAME, defaultKeyStore, indexedDbKeyStore, memoryKey
|
|
|
46
50
|
// the entry points.
|
|
47
51
|
export { EthosClient, EthosNamespace, EthosZone, ZONE_NAMES, } from "./ethos.js";
|
|
48
52
|
// `sdk.mandates` namespace — owner-side mandate lifecycle.
|
|
49
|
-
export { MandatesNamespace } from "./mandates.js";
|
|
53
|
+
export { COMPUTE_INVOKE_SCOPE, MandatesNamespace } from "./mandates.js";
|
|
50
54
|
// Onboarding re-exports kept for advanced callers — the curated API
|
|
51
55
|
// for first-run flows is `auth.signUp` (already shipped in J3).
|
|
52
56
|
export * as onboarding from "./onboarding.js";
|
package/dist/src/mandates.d.ts
CHANGED
|
@@ -1,12 +1,65 @@
|
|
|
1
1
|
import type { AithosAuth } from "./auth.js";
|
|
2
2
|
import type { AithosSdkEndpoints } from "./endpoints.js";
|
|
3
|
-
/** Capability scope the SDK accepts. Server-side ultimately decides.
|
|
3
|
+
/** Capability scope the SDK accepts. Server-side ultimately decides.
|
|
4
|
+
*
|
|
5
|
+
* Note: `compute.invoke` is intentionally NOT in this union. The token-
|
|
6
|
+
* spending capability is opt-in via the dedicated {@link CreateMandateInput.compute}
|
|
7
|
+
* namespace — see {@link MandatesNamespace.create}. Passing `compute.invoke`
|
|
8
|
+
* directly in `scopes` is rejected at runtime; the compiler can't enforce
|
|
9
|
+
* it (callers who up-cast to string[] would slip through), so the runtime
|
|
10
|
+
* check is the real gate. */
|
|
4
11
|
export type Scope = "ethos.read.public" | "ethos.read.circle" | "ethos.read.self" | "ethos.write.public" | "ethos.write.circle" | "ethos.write.self";
|
|
12
|
+
/**
|
|
13
|
+
* The opt-in scope that authorizes a delegate to spend the subject's
|
|
14
|
+
* compute credits via the Aithos compute proxy. Mirror of
|
|
15
|
+
* `COMPUTE_INVOKE_SCOPE` in `@aithos/protocol-core` v0.4.0.
|
|
16
|
+
*
|
|
17
|
+
* The SDK's `mandates.create()` injects this scope automatically when
|
|
18
|
+
* the caller passes a `compute` namespace, and refuses to mint a
|
|
19
|
+
* mandate where the caller put it directly into `scopes` — this is
|
|
20
|
+
* what makes "compute is a separate, conscious decision" hold at the
|
|
21
|
+
* API surface.
|
|
22
|
+
*/
|
|
23
|
+
export declare const COMPUTE_INVOKE_SCOPE: "compute.invoke";
|
|
5
24
|
/**
|
|
6
25
|
* Which sphere of the owner signs the mandate. Bounds the upper-most
|
|
7
26
|
* scope set the mandate can carry.
|
|
8
27
|
*/
|
|
9
28
|
export type ActorSphere = "public" | "circle" | "self";
|
|
29
|
+
/**
|
|
30
|
+
* Compute-spending capability — opt-in only, never implied by ethos
|
|
31
|
+
* scopes.
|
|
32
|
+
*
|
|
33
|
+
* When `compute` is set on {@link CreateMandateInput}, the SDK:
|
|
34
|
+
* 1. Adds the `compute.invoke` scope to the minted mandate.
|
|
35
|
+
* 2. Maps the caller's caps onto `constraints.compute` in the
|
|
36
|
+
* protocol's snake_case shape (= what the verifier reads).
|
|
37
|
+
* 3. Forbids the caller from passing `compute.invoke` in `scopes`
|
|
38
|
+
* directly — that would let an app slip the scope past a
|
|
39
|
+
* consent UI that only reviews `compute`.
|
|
40
|
+
*
|
|
41
|
+
* At least one of `dailyCapMicrocredits` or `totalCapMicrocredits` MUST
|
|
42
|
+
* be set: an unbounded compute mandate is the kind of bearer-token
|
|
43
|
+
* footgun this whole namespace exists to prevent. Validation happens
|
|
44
|
+
* at the SDK boundary (here) AND at the protocol layer (the
|
|
45
|
+
* server-side verifier rejects capless 0.4.0 mandates), so a bug in
|
|
46
|
+
* either tier still fails closed.
|
|
47
|
+
*
|
|
48
|
+
* `maxCreditsPerCall` is a per-invocation safety net for runaway
|
|
49
|
+
* single requests. `allowedModels`, when set, restricts which Bedrock
|
|
50
|
+
* model ids the delegate may target (the proxy's own allowlist still
|
|
51
|
+
* applies on top).
|
|
52
|
+
*/
|
|
53
|
+
export interface CreateMandateComputeInput {
|
|
54
|
+
/** Hard cap on credits debited per UTC day under this mandate. */
|
|
55
|
+
readonly dailyCapMicrocredits?: number;
|
|
56
|
+
/** Hard cap on credits debited over the whole mandate lifetime. */
|
|
57
|
+
readonly totalCapMicrocredits?: number;
|
|
58
|
+
/** Hard cap on credits debited by any single invocation. */
|
|
59
|
+
readonly maxCreditsPerCall?: number;
|
|
60
|
+
/** Allowlist of Bedrock model ids the delegate may invoke. */
|
|
61
|
+
readonly allowedModels?: readonly string[];
|
|
62
|
+
}
|
|
10
63
|
export interface CreateMandateInput {
|
|
11
64
|
/** Grantee URN — usually `urn:aithos:agent:<extension-id>` or similar. */
|
|
12
65
|
readonly granteeId: string;
|
|
@@ -23,6 +76,16 @@ export interface CreateMandateInput {
|
|
|
23
76
|
readonly scopes: readonly Scope[];
|
|
24
77
|
/** Lifetime in seconds. */
|
|
25
78
|
readonly ttlSeconds: number;
|
|
79
|
+
/**
|
|
80
|
+
* Opt-in compute (token-spending) capability — adds the
|
|
81
|
+
* `compute.invoke` scope and a bounded `constraints.compute` budget
|
|
82
|
+
* to the mandate. See {@link CreateMandateComputeInput}.
|
|
83
|
+
*
|
|
84
|
+
* NEVER add `compute.invoke` to `scopes` directly — the SDK rejects
|
|
85
|
+
* that path so the caller has to pass through this typed namespace,
|
|
86
|
+
* which is what a consent UI can review.
|
|
87
|
+
*/
|
|
88
|
+
readonly compute?: CreateMandateComputeInput;
|
|
26
89
|
}
|
|
27
90
|
export interface MintedMandate {
|
|
28
91
|
/** Unique mandate id (matches `mandate.id` inside the bundle). */
|
package/dist/src/mandates.js
CHANGED
|
@@ -16,6 +16,18 @@
|
|
|
16
16
|
import { buildSignedEnvelope, mintDelegateBundle, readRpc, } from "@aithos/protocol-client";
|
|
17
17
|
import { ownerKeyPair } from "./internal/protocol-client-bridge.js";
|
|
18
18
|
import { AithosSDKError } from "./types.js";
|
|
19
|
+
/**
|
|
20
|
+
* The opt-in scope that authorizes a delegate to spend the subject's
|
|
21
|
+
* compute credits via the Aithos compute proxy. Mirror of
|
|
22
|
+
* `COMPUTE_INVOKE_SCOPE` in `@aithos/protocol-core` v0.4.0.
|
|
23
|
+
*
|
|
24
|
+
* The SDK's `mandates.create()` injects this scope automatically when
|
|
25
|
+
* the caller passes a `compute` namespace, and refuses to mint a
|
|
26
|
+
* mandate where the caller put it directly into `scopes` — this is
|
|
27
|
+
* what makes "compute is a separate, conscious decision" hold at the
|
|
28
|
+
* API surface.
|
|
29
|
+
*/
|
|
30
|
+
export const COMPUTE_INVOKE_SCOPE = "compute.invoke";
|
|
19
31
|
export class MandatesNamespace {
|
|
20
32
|
#deps;
|
|
21
33
|
constructor(deps) {
|
|
@@ -29,12 +41,36 @@ export class MandatesNamespace {
|
|
|
29
41
|
*/
|
|
30
42
|
async create(input) {
|
|
31
43
|
const owner = this.#requireOwner();
|
|
32
|
-
|
|
33
|
-
|
|
44
|
+
// A mandate must carry at least one capability — either ethos
|
|
45
|
+
// scopes, the compute namespace, or both. A "compute-only" mandate
|
|
46
|
+
// (`scopes: []` + `compute: { ... }`) is legitimate: it gives the
|
|
47
|
+
// grantee no access to the subject's ethos data, only the right
|
|
48
|
+
// to spend a bounded amount of compute credits in their name.
|
|
49
|
+
// Useful for creative assistants, brainstorming agents, etc.
|
|
50
|
+
if (input.scopes.length === 0 && input.compute === undefined) {
|
|
51
|
+
throw new AithosSDKError("mandates_invalid_scopes", "scopes must be a non-empty list (or pass `compute` for a compute-only mandate)");
|
|
34
52
|
}
|
|
35
53
|
if (input.ttlSeconds <= 0) {
|
|
36
54
|
throw new AithosSDKError("mandates_invalid_ttl", "ttlSeconds must be > 0");
|
|
37
55
|
}
|
|
56
|
+
// Forbid `compute.invoke` smuggled in via `scopes[]`. The whole
|
|
57
|
+
// point of the dedicated `compute` namespace is that adding token-
|
|
58
|
+
// spending capability requires a typed, named, reviewable input —
|
|
59
|
+
// not a string lost in a generic list. Type-checking can't catch
|
|
60
|
+
// this (the union doesn't include the literal, but callers can
|
|
61
|
+
// up-cast); the runtime check is what holds.
|
|
62
|
+
if (input.scopes.some((s) => s === COMPUTE_INVOKE_SCOPE)) {
|
|
63
|
+
throw new AithosSDKError("mandates_invalid_scopes", `Pass token-spending capability via the dedicated 'compute' namespace, ` +
|
|
64
|
+
`not by adding "${COMPUTE_INVOKE_SCOPE}" to scopes[]. The namespace forces ` +
|
|
65
|
+
`an explicit budget and is what a consent UI reviews.`);
|
|
66
|
+
}
|
|
67
|
+
// Validate + project the compute namespace if present, then derive
|
|
68
|
+
// the final scopes/constraints to send to the protocol layer.
|
|
69
|
+
const computeProjection = projectCompute(input.compute);
|
|
70
|
+
const projectedScopes = [...input.scopes];
|
|
71
|
+
if (computeProjection) {
|
|
72
|
+
projectedScopes.push(COMPUTE_INVOKE_SCOPE);
|
|
73
|
+
}
|
|
38
74
|
const actorSphere = input.actorSphere ?? defaultSphereFromScopes(input.scopes);
|
|
39
75
|
const ownerStored = owner._unsafeStoredIdentity();
|
|
40
76
|
const result = await mintDelegateBundle({
|
|
@@ -42,15 +78,27 @@ export class MandatesNamespace {
|
|
|
42
78
|
granteeId: input.granteeId,
|
|
43
79
|
...(input.granteeLabel ? { granteeLabel: input.granteeLabel } : {}),
|
|
44
80
|
actorSphere,
|
|
45
|
-
scopes:
|
|
81
|
+
scopes: projectedScopes,
|
|
46
82
|
ttlSeconds: input.ttlSeconds,
|
|
83
|
+
// protocol-client v0.1.0-alpha.11 ships MandateConstraints without
|
|
84
|
+
// the `compute` field; the wire format accepts it though (the
|
|
85
|
+
// canonicalizer just serializes whatever's in the object). We
|
|
86
|
+
// up-cast through `unknown` to bypass the structural check until
|
|
87
|
+
// protocol-client picks up protocol-core 0.4.0 types.
|
|
88
|
+
...(computeProjection
|
|
89
|
+
? {
|
|
90
|
+
constraints: {
|
|
91
|
+
compute: computeProjection,
|
|
92
|
+
},
|
|
93
|
+
}
|
|
94
|
+
: {}),
|
|
47
95
|
});
|
|
48
96
|
const mandate = result.mandate;
|
|
49
97
|
return {
|
|
50
98
|
mandateId: mandate.id,
|
|
51
99
|
subjectDid: mandate.subject_did,
|
|
52
100
|
granteeId: input.granteeId,
|
|
53
|
-
scopes:
|
|
101
|
+
scopes: projectedScopes,
|
|
54
102
|
expiresAt: mandate.not_after ?? null,
|
|
55
103
|
bundle: result.bundleBlob,
|
|
56
104
|
filename: `aithos-delegate-${mandate.id}.json`,
|
|
@@ -164,6 +212,58 @@ function defaultSphereFromScopes(scopes) {
|
|
|
164
212
|
return "circle";
|
|
165
213
|
return "public";
|
|
166
214
|
}
|
|
215
|
+
/**
|
|
216
|
+
* Validate the SDK-side `compute` namespace and project it onto the
|
|
217
|
+
* snake_case shape the protocol layer canonicalizes into the mandate.
|
|
218
|
+
*
|
|
219
|
+
* Returns `null` if the caller passed nothing — that's the "no compute
|
|
220
|
+
* authorization" path. Throws {@link AithosSDKError} on any structural
|
|
221
|
+
* problem (camelCase mirror of the rules `validateComputeAuthorization`
|
|
222
|
+
* enforces server-side; we duplicate them here so a misuse fails at the
|
|
223
|
+
* SDK boundary with a precise error rather than only blowing up at mint).
|
|
224
|
+
*/
|
|
225
|
+
function projectCompute(c) {
|
|
226
|
+
if (c === undefined)
|
|
227
|
+
return null;
|
|
228
|
+
const hasDaily = typeof c.dailyCapMicrocredits === "number";
|
|
229
|
+
const hasTotal = typeof c.totalCapMicrocredits === "number";
|
|
230
|
+
if (!hasDaily && !hasTotal) {
|
|
231
|
+
throw new AithosSDKError("mandates_invalid_compute", "compute namespace requires at least one of dailyCapMicrocredits " +
|
|
232
|
+
"or totalCapMicrocredits — an unbounded compute mandate is a bearer " +
|
|
233
|
+
"token to drain the subject's wallet.");
|
|
234
|
+
}
|
|
235
|
+
for (const [field, value] of [
|
|
236
|
+
["dailyCapMicrocredits", c.dailyCapMicrocredits],
|
|
237
|
+
["totalCapMicrocredits", c.totalCapMicrocredits],
|
|
238
|
+
["maxCreditsPerCall", c.maxCreditsPerCall],
|
|
239
|
+
]) {
|
|
240
|
+
if (value === undefined)
|
|
241
|
+
continue;
|
|
242
|
+
if (!Number.isInteger(value) || value <= 0) {
|
|
243
|
+
throw new AithosSDKError("mandates_invalid_compute", `compute.${field} must be a positive integer (got ${value}).`);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
if (c.allowedModels !== undefined) {
|
|
247
|
+
if (!Array.isArray(c.allowedModels)) {
|
|
248
|
+
throw new AithosSDKError("mandates_invalid_compute", "compute.allowedModels must be an array of strings.");
|
|
249
|
+
}
|
|
250
|
+
for (const m of c.allowedModels) {
|
|
251
|
+
if (typeof m !== "string" || m.length === 0) {
|
|
252
|
+
throw new AithosSDKError("mandates_invalid_compute", `compute.allowedModels entries must be non-empty strings (got ${JSON.stringify(m)}).`);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
const wire = {};
|
|
257
|
+
if (hasDaily)
|
|
258
|
+
wire.daily_cap_microcredits = c.dailyCapMicrocredits;
|
|
259
|
+
if (hasTotal)
|
|
260
|
+
wire.total_cap_microcredits = c.totalCapMicrocredits;
|
|
261
|
+
if (c.maxCreditsPerCall !== undefined)
|
|
262
|
+
wire.max_credits_per_call = c.maxCreditsPerCall;
|
|
263
|
+
if (c.allowedModels !== undefined)
|
|
264
|
+
wire.allowed_models = [...c.allowedModels];
|
|
265
|
+
return wire;
|
|
266
|
+
}
|
|
167
267
|
function toOwnedMandate(it) {
|
|
168
268
|
return {
|
|
169
269
|
mandateId: requireString(it, "mandate_id"),
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Copyright 2026 Mathieu Colla
|
|
3
|
+
// Tests for the alpha.7 first-edition path in EthosClient — the case
|
|
4
|
+
// where an Ethos identity exists on api.aithos.be (provisioned by
|
|
5
|
+
// auth.signUp() since alpha.6) but no edition has been published yet.
|
|
6
|
+
//
|
|
7
|
+
// Two flows must work:
|
|
8
|
+
// 1. Reading any zone returns an empty list (instead of throwing
|
|
9
|
+
// "not found: edition").
|
|
10
|
+
// 2. Publishing for the first time builds height=1 from staged
|
|
11
|
+
// mutations and POSTs publish_ethos_edition directly, instead
|
|
12
|
+
// of going through publishZoneEdit (which requires a previous
|
|
13
|
+
// manifest).
|
|
14
|
+
//
|
|
15
|
+
// We mock global fetch end-to-end so the tests run offline.
|
|
16
|
+
import { strict as assert } from "node:assert";
|
|
17
|
+
import { afterEach, beforeEach, describe, it } from "node:test";
|
|
18
|
+
import { createBrowserIdentity } from "@aithos/protocol-client";
|
|
19
|
+
import { AithosAuth, AithosSDKError, EthosNamespace, memoryKeyStore, noopStore, } from "../src/index.js";
|
|
20
|
+
import { serializeRecoveryFile } from "../src/internal/recovery-file.js";
|
|
21
|
+
import { DEFAULT_SDK_ENDPOINTS } from "../src/endpoints.js";
|
|
22
|
+
let fetchCalls = [];
|
|
23
|
+
let savedFetch;
|
|
24
|
+
function installFetchMock(handlers) {
|
|
25
|
+
savedFetch = globalThis.fetch;
|
|
26
|
+
fetchCalls = [];
|
|
27
|
+
globalThis.fetch = (async (input, init) => {
|
|
28
|
+
const url = String(input);
|
|
29
|
+
const method = init?.method ?? "GET";
|
|
30
|
+
const bodyText = typeof init?.body === "string"
|
|
31
|
+
? init.body
|
|
32
|
+
: init?.body == null
|
|
33
|
+
? null
|
|
34
|
+
: String(init.body);
|
|
35
|
+
const body = bodyText ? JSON.parse(bodyText) : null;
|
|
36
|
+
const call = { url, method, body };
|
|
37
|
+
fetchCalls.push(call);
|
|
38
|
+
for (const h of handlers) {
|
|
39
|
+
if (!url.includes(h.url))
|
|
40
|
+
continue;
|
|
41
|
+
if (h.rpcMethod && body?.method !== h.rpcMethod)
|
|
42
|
+
continue;
|
|
43
|
+
const out = h.respond(call);
|
|
44
|
+
const status = out.status ?? 200;
|
|
45
|
+
return new Response(JSON.stringify(out.json), {
|
|
46
|
+
status,
|
|
47
|
+
headers: { "content-type": "application/json" },
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
throw new Error(`unhandled fetch: ${method} ${url} (rpc: ${body?.method ?? "n/a"})`);
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
function uninstallFetchMock() {
|
|
54
|
+
if (savedFetch) {
|
|
55
|
+
globalThis.fetch = savedFetch;
|
|
56
|
+
savedFetch = undefined;
|
|
57
|
+
}
|
|
58
|
+
fetchCalls = [];
|
|
59
|
+
}
|
|
60
|
+
function makeAuth() {
|
|
61
|
+
return new AithosAuth({
|
|
62
|
+
authBaseUrl: "https://auth.test",
|
|
63
|
+
apiBaseUrl: "https://api.test",
|
|
64
|
+
fetch: (() => {
|
|
65
|
+
throw new Error("AithosAuth.fetch must not be called in these tests");
|
|
66
|
+
}),
|
|
67
|
+
sessionStore: noopStore(),
|
|
68
|
+
keyStore: memoryKeyStore(),
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
function makeNamespace(auth) {
|
|
72
|
+
return new EthosNamespace({
|
|
73
|
+
auth,
|
|
74
|
+
endpoints: DEFAULT_SDK_ENDPOINTS,
|
|
75
|
+
// EthosNamespace itself doesn't read fetch from this slot today;
|
|
76
|
+
// protocol-client uses the global fetch we mock above.
|
|
77
|
+
fetch: globalThis.fetch.bind(globalThis),
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
async function signInAsAlice(auth) {
|
|
81
|
+
const id = createBrowserIdentity("alice", "Alice");
|
|
82
|
+
const { text } = serializeRecoveryFile(id);
|
|
83
|
+
const info = await auth.signInWithRecovery({ file: text });
|
|
84
|
+
return { did: info.did };
|
|
85
|
+
}
|
|
86
|
+
function noEditionYetResponse() {
|
|
87
|
+
// Mirrors the server's primitives-read `notFound("edition for <did>")`:
|
|
88
|
+
// JSON-RPC error code -32020, message starts with "not found: edition for ".
|
|
89
|
+
return {
|
|
90
|
+
json: {
|
|
91
|
+
jsonrpc: "2.0",
|
|
92
|
+
id: "aithos.get_ethos_manifest",
|
|
93
|
+
error: {
|
|
94
|
+
code: -32020,
|
|
95
|
+
message: "not found: edition for did:aithos:zSomething",
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
function publishOkResponse() {
|
|
101
|
+
return {
|
|
102
|
+
json: {
|
|
103
|
+
jsonrpc: "2.0",
|
|
104
|
+
id: "publish_ethos_edition",
|
|
105
|
+
result: { ok: true, height: 1, manifest_uri: "s3://aithos/.../manifest.json" },
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
/* -------------------------------------------------------------------------- */
|
|
110
|
+
/* Tests */
|
|
111
|
+
/* -------------------------------------------------------------------------- */
|
|
112
|
+
describe("EthosClient — fresh Ethos (no edition published yet)", () => {
|
|
113
|
+
beforeEach(() => {
|
|
114
|
+
fetchCalls = [];
|
|
115
|
+
});
|
|
116
|
+
afterEach(() => {
|
|
117
|
+
uninstallFetchMock();
|
|
118
|
+
});
|
|
119
|
+
it("zone(public).sections() returns [] when server says 'not found: edition'", async () => {
|
|
120
|
+
installFetchMock([
|
|
121
|
+
{
|
|
122
|
+
url: "/mcp/primitives/read",
|
|
123
|
+
rpcMethod: "aithos.get_ethos_manifest",
|
|
124
|
+
respond: noEditionYetResponse,
|
|
125
|
+
},
|
|
126
|
+
]);
|
|
127
|
+
const auth = makeAuth();
|
|
128
|
+
await signInAsAlice(auth);
|
|
129
|
+
const me = makeNamespace(auth).me();
|
|
130
|
+
const sections = await me.zone("public").sections();
|
|
131
|
+
assert.deepEqual(sections, []);
|
|
132
|
+
});
|
|
133
|
+
it("zone(public).sections() reflects locally staged adds even when no edition exists", async () => {
|
|
134
|
+
installFetchMock([
|
|
135
|
+
{
|
|
136
|
+
url: "/mcp/primitives/read",
|
|
137
|
+
rpcMethod: "aithos.get_ethos_manifest",
|
|
138
|
+
respond: noEditionYetResponse,
|
|
139
|
+
},
|
|
140
|
+
]);
|
|
141
|
+
const auth = makeAuth();
|
|
142
|
+
await signInAsAlice(auth);
|
|
143
|
+
const me = makeNamespace(auth).me();
|
|
144
|
+
me.zone("public").addSection({ title: "Hello", body: "World" });
|
|
145
|
+
const sections = await me.zone("public").sections();
|
|
146
|
+
assert.equal(sections.length, 1);
|
|
147
|
+
assert.equal(sections[0].title, "Hello");
|
|
148
|
+
});
|
|
149
|
+
it("publish() routes to publish_ethos_edition with height=1 on first publish", async () => {
|
|
150
|
+
let publishBody = null;
|
|
151
|
+
installFetchMock([
|
|
152
|
+
{
|
|
153
|
+
url: "/mcp/primitives/read",
|
|
154
|
+
rpcMethod: "aithos.get_ethos_manifest",
|
|
155
|
+
respond: noEditionYetResponse,
|
|
156
|
+
},
|
|
157
|
+
{
|
|
158
|
+
url: "/mcp/primitives/write",
|
|
159
|
+
rpcMethod: "aithos.publish_ethos_edition",
|
|
160
|
+
respond: (call) => {
|
|
161
|
+
publishBody = call.body;
|
|
162
|
+
return publishOkResponse();
|
|
163
|
+
},
|
|
164
|
+
},
|
|
165
|
+
]);
|
|
166
|
+
const auth = makeAuth();
|
|
167
|
+
const alice = await signInAsAlice(auth);
|
|
168
|
+
const me = makeNamespace(auth).me();
|
|
169
|
+
me.zone("public").addSection({ title: "First", body: "Hello." });
|
|
170
|
+
me.zone("public").addSection({ title: "Second", body: "World." });
|
|
171
|
+
const r = await me.publish();
|
|
172
|
+
assert.equal(r.editionHeight, 1);
|
|
173
|
+
assert.equal(r.subjectDid, alice.did);
|
|
174
|
+
assert.deepEqual(r.zonesPublished, ["public"]);
|
|
175
|
+
// Verify the wire shape: JSON-RPC publish_ethos_edition with a height=1
|
|
176
|
+
// manifest containing both staged sections.
|
|
177
|
+
assert.equal(publishBody.method, "aithos.publish_ethos_edition");
|
|
178
|
+
const manifest = publishBody.params.manifest;
|
|
179
|
+
assert.equal(manifest.edition.height, 1);
|
|
180
|
+
assert.equal(manifest.edition.prev_hash, null);
|
|
181
|
+
assert.equal(manifest.edition.supersedes, null);
|
|
182
|
+
assert.deepEqual(manifest.zones.public.section_titles, ["First", "Second"]);
|
|
183
|
+
// Envelope is signed under #public.
|
|
184
|
+
const env = publishBody.params._envelope;
|
|
185
|
+
assert.equal(env.method, "aithos.publish_ethos_edition");
|
|
186
|
+
assert.match(env.proof.verificationMethod, /#public$/);
|
|
187
|
+
});
|
|
188
|
+
it("publish() rejects circle/self mutations on first edition", async () => {
|
|
189
|
+
installFetchMock([
|
|
190
|
+
{
|
|
191
|
+
url: "/mcp/primitives/read",
|
|
192
|
+
rpcMethod: "aithos.get_ethos_manifest",
|
|
193
|
+
respond: noEditionYetResponse,
|
|
194
|
+
},
|
|
195
|
+
]);
|
|
196
|
+
const auth = makeAuth();
|
|
197
|
+
await signInAsAlice(auth);
|
|
198
|
+
const me = makeNamespace(auth).me();
|
|
199
|
+
me.zone("circle").addSection({ title: "Private", body: "..." });
|
|
200
|
+
await assert.rejects(() => me.publish(), (e) => e instanceof AithosSDKError && e.code === "ethos_first_edition_public_only");
|
|
201
|
+
});
|
|
202
|
+
it("publish() rejects update/delete operations on a fresh Ethos", async () => {
|
|
203
|
+
installFetchMock([
|
|
204
|
+
{
|
|
205
|
+
url: "/mcp/primitives/read",
|
|
206
|
+
rpcMethod: "aithos.get_ethos_manifest",
|
|
207
|
+
respond: noEditionYetResponse,
|
|
208
|
+
},
|
|
209
|
+
]);
|
|
210
|
+
const auth = makeAuth();
|
|
211
|
+
await signInAsAlice(auth);
|
|
212
|
+
const me = makeNamespace(auth).me();
|
|
213
|
+
// Stage a delete for a section that doesn't exist (no edition exists at all).
|
|
214
|
+
me.zone("public")["_parent"]; // type-safety placeholder; we use the public API
|
|
215
|
+
// EthosZone exposes deleteSection — go via that.
|
|
216
|
+
me.zone("public").deleteSection("sec_doesnotexist000");
|
|
217
|
+
await assert.rejects(() => me.publish(), (e) => e instanceof AithosSDKError && e.code === "ethos_first_edition_invalid_op");
|
|
218
|
+
});
|
|
219
|
+
it("publish() surfaces server JSON-RPC errors as ethos_first_edition_rejected", async () => {
|
|
220
|
+
installFetchMock([
|
|
221
|
+
{
|
|
222
|
+
url: "/mcp/primitives/read",
|
|
223
|
+
rpcMethod: "aithos.get_ethos_manifest",
|
|
224
|
+
respond: noEditionYetResponse,
|
|
225
|
+
},
|
|
226
|
+
{
|
|
227
|
+
url: "/mcp/primitives/write",
|
|
228
|
+
rpcMethod: "aithos.publish_ethos_edition",
|
|
229
|
+
respond: () => ({
|
|
230
|
+
json: {
|
|
231
|
+
jsonrpc: "2.0",
|
|
232
|
+
id: "publish_ethos_edition",
|
|
233
|
+
error: {
|
|
234
|
+
code: -32020,
|
|
235
|
+
message: "subject identity not published (call publish_identity first)",
|
|
236
|
+
},
|
|
237
|
+
},
|
|
238
|
+
}),
|
|
239
|
+
},
|
|
240
|
+
]);
|
|
241
|
+
const auth = makeAuth();
|
|
242
|
+
await signInAsAlice(auth);
|
|
243
|
+
const me = makeNamespace(auth).me();
|
|
244
|
+
me.zone("public").addSection({ title: "Hi", body: "There." });
|
|
245
|
+
await assert.rejects(() => me.publish(), (e) => e instanceof AithosSDKError && e.code === "ethos_first_edition_rejected");
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
//# sourceMappingURL=ethos-first-edition.test.js.map
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Copyright 2026 Mathieu Colla
|
|
3
|
+
/**
|
|
4
|
+
* Tests for the `compute` namespace on `MandatesNamespace.create`.
|
|
5
|
+
*
|
|
6
|
+
* Invariants under test (mirror of the protocol-core v0.4.0 invariants,
|
|
7
|
+
* enforced at the SDK boundary so callers fail fast with a precise
|
|
8
|
+
* error rather than only blowing up server-side):
|
|
9
|
+
*
|
|
10
|
+
* 1. Passing `compute.invoke` directly in `scopes[]` is rejected.
|
|
11
|
+
* The opt-in must go through the typed `compute` namespace, which
|
|
12
|
+
* is what a consent UI can review.
|
|
13
|
+
* 2. The `compute` namespace requires at least one of
|
|
14
|
+
* `dailyCapMicrocredits` or `totalCapMicrocredits` — an unbounded
|
|
15
|
+
* compute mandate is the bearer-token footgun this whole namespace
|
|
16
|
+
* exists to prevent.
|
|
17
|
+
* 3. Numeric caps must be positive integers.
|
|
18
|
+
* 4. `allowedModels`, when present, must be an array of non-empty
|
|
19
|
+
* strings.
|
|
20
|
+
* 5. A compute-only mandate (`scopes: []` + `compute: {…}`) is
|
|
21
|
+
* legitimate and accepted — the grantee gets no ethos data
|
|
22
|
+
* access, only bounded compute spending. Empty scopes WITHOUT
|
|
23
|
+
* compute is still rejected.
|
|
24
|
+
*
|
|
25
|
+
* Network-dependent paths (real mint, list, revoke) ride on the same
|
|
26
|
+
* integration suite as the rest of the namespace — see the note in
|
|
27
|
+
* `mandates.test.ts`. We intentionally don't go through `mintDelegateBundle`
|
|
28
|
+
* here; failures fire BEFORE that boundary.
|
|
29
|
+
*/
|
|
30
|
+
import { strict as assert } from "node:assert";
|
|
31
|
+
import { describe, it } from "node:test";
|
|
32
|
+
import { createBrowserIdentity } from "@aithos/protocol-client";
|
|
33
|
+
import { AithosAuth, AithosSDKError, COMPUTE_INVOKE_SCOPE, MandatesNamespace, memoryKeyStore, noopStore, } from "../src/index.js";
|
|
34
|
+
import { DEFAULT_SDK_ENDPOINTS } from "../src/endpoints.js";
|
|
35
|
+
import { serializeRecoveryFile } from "../src/internal/recovery-file.js";
|
|
36
|
+
function makeAuth() {
|
|
37
|
+
return new AithosAuth({
|
|
38
|
+
authBaseUrl: "https://auth.test",
|
|
39
|
+
fetch: (() => {
|
|
40
|
+
throw new Error("network not expected");
|
|
41
|
+
}),
|
|
42
|
+
sessionStore: noopStore(),
|
|
43
|
+
keyStore: memoryKeyStore(),
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
function makeMandates(auth) {
|
|
47
|
+
return new MandatesNamespace({
|
|
48
|
+
auth,
|
|
49
|
+
endpoints: DEFAULT_SDK_ENDPOINTS,
|
|
50
|
+
fetch: globalThis.fetch.bind(globalThis),
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
async function signInAsAlice(auth) {
|
|
54
|
+
const id = createBrowserIdentity("alice", "Alice");
|
|
55
|
+
const { text } = serializeRecoveryFile(id);
|
|
56
|
+
const info = await auth.signInWithRecovery({ file: text });
|
|
57
|
+
return info.did;
|
|
58
|
+
}
|
|
59
|
+
/* -------------------------------------------------------------------------- */
|
|
60
|
+
/* Constant export sanity */
|
|
61
|
+
/* -------------------------------------------------------------------------- */
|
|
62
|
+
describe("COMPUTE_INVOKE_SCOPE constant", () => {
|
|
63
|
+
it("is the canonical 'compute.invoke' string", () => {
|
|
64
|
+
assert.equal(COMPUTE_INVOKE_SCOPE, "compute.invoke");
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
/* -------------------------------------------------------------------------- */
|
|
68
|
+
/* Smuggling guard: compute.invoke in scopes[] is rejected */
|
|
69
|
+
/* -------------------------------------------------------------------------- */
|
|
70
|
+
describe("MandatesNamespace.create — compute.invoke smuggling guard", () => {
|
|
71
|
+
it("rejects a mandate that smuggles compute.invoke into scopes[]", async () => {
|
|
72
|
+
const auth = makeAuth();
|
|
73
|
+
await signInAsAlice(auth);
|
|
74
|
+
const m = makeMandates(auth);
|
|
75
|
+
await assert.rejects(() => m.create({
|
|
76
|
+
granteeId: "urn:agent:bob",
|
|
77
|
+
// Up-cast through string[] — this is exactly the path the
|
|
78
|
+
// runtime check has to catch. Type-checking can't (the union
|
|
79
|
+
// doesn't include "compute.invoke", but cast slips through).
|
|
80
|
+
scopes: ["ethos.read.public", "compute.invoke"],
|
|
81
|
+
ttlSeconds: 3600,
|
|
82
|
+
}), (e) => e instanceof AithosSDKError &&
|
|
83
|
+
e.code === "mandates_invalid_scopes" &&
|
|
84
|
+
/compute' namespace/.test(e.message));
|
|
85
|
+
});
|
|
86
|
+
it("rejects even when 'compute.invoke' is the ONLY scope passed", async () => {
|
|
87
|
+
const auth = makeAuth();
|
|
88
|
+
await signInAsAlice(auth);
|
|
89
|
+
const m = makeMandates(auth);
|
|
90
|
+
await assert.rejects(() => m.create({
|
|
91
|
+
granteeId: "urn:agent:bob",
|
|
92
|
+
scopes: ["compute.invoke"],
|
|
93
|
+
ttlSeconds: 3600,
|
|
94
|
+
}), (e) => e instanceof AithosSDKError &&
|
|
95
|
+
e.code === "mandates_invalid_scopes");
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
/* -------------------------------------------------------------------------- */
|
|
99
|
+
/* Compute namespace — input validation */
|
|
100
|
+
/* -------------------------------------------------------------------------- */
|
|
101
|
+
describe("MandatesNamespace.create — compute namespace validation", () => {
|
|
102
|
+
it("rejects compute namespace with no caps at all", async () => {
|
|
103
|
+
const auth = makeAuth();
|
|
104
|
+
await signInAsAlice(auth);
|
|
105
|
+
const m = makeMandates(auth);
|
|
106
|
+
await assert.rejects(() => m.create({
|
|
107
|
+
granteeId: "urn:agent:bob",
|
|
108
|
+
scopes: ["ethos.read.public"],
|
|
109
|
+
ttlSeconds: 3600,
|
|
110
|
+
compute: {}, // empty — neither daily nor total cap
|
|
111
|
+
}), (e) => e instanceof AithosSDKError &&
|
|
112
|
+
e.code === "mandates_invalid_compute" &&
|
|
113
|
+
/at least one of dailyCapMicrocredits or totalCapMicrocredits/.test(e.message));
|
|
114
|
+
});
|
|
115
|
+
it("rejects compute namespace with only allowedModels (no cap)", async () => {
|
|
116
|
+
const auth = makeAuth();
|
|
117
|
+
await signInAsAlice(auth);
|
|
118
|
+
const m = makeMandates(auth);
|
|
119
|
+
await assert.rejects(() => m.create({
|
|
120
|
+
granteeId: "urn:agent:bob",
|
|
121
|
+
scopes: ["ethos.read.public"],
|
|
122
|
+
ttlSeconds: 3600,
|
|
123
|
+
compute: { allowedModels: ["claude-haiku-4-5"] },
|
|
124
|
+
}), (e) => e instanceof AithosSDKError &&
|
|
125
|
+
e.code === "mandates_invalid_compute");
|
|
126
|
+
});
|
|
127
|
+
it("rejects non-positive caps", async () => {
|
|
128
|
+
const auth = makeAuth();
|
|
129
|
+
await signInAsAlice(auth);
|
|
130
|
+
const m = makeMandates(auth);
|
|
131
|
+
for (const bad of [0, -1, 3.14]) {
|
|
132
|
+
await assert.rejects(() => m.create({
|
|
133
|
+
granteeId: "urn:agent:bob",
|
|
134
|
+
scopes: ["ethos.read.public"],
|
|
135
|
+
ttlSeconds: 3600,
|
|
136
|
+
compute: { dailyCapMicrocredits: bad },
|
|
137
|
+
}), (e) => e instanceof AithosSDKError &&
|
|
138
|
+
e.code === "mandates_invalid_compute" &&
|
|
139
|
+
/must be a positive integer/.test(e.message), `expected rejection for dailyCapMicrocredits=${bad}`);
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
it("rejects malformed allowedModels (empty string entry)", async () => {
|
|
143
|
+
const auth = makeAuth();
|
|
144
|
+
await signInAsAlice(auth);
|
|
145
|
+
const m = makeMandates(auth);
|
|
146
|
+
await assert.rejects(() => m.create({
|
|
147
|
+
granteeId: "urn:agent:bob",
|
|
148
|
+
scopes: ["ethos.read.public"],
|
|
149
|
+
ttlSeconds: 3600,
|
|
150
|
+
compute: {
|
|
151
|
+
dailyCapMicrocredits: 5_000,
|
|
152
|
+
allowedModels: ["claude-haiku-4-5", ""],
|
|
153
|
+
},
|
|
154
|
+
}), (e) => e instanceof AithosSDKError &&
|
|
155
|
+
e.code === "mandates_invalid_compute" &&
|
|
156
|
+
/allowedModels entries must be non-empty strings/.test(e.message));
|
|
157
|
+
});
|
|
158
|
+
it("rejects allowedModels that is not an array", async () => {
|
|
159
|
+
const auth = makeAuth();
|
|
160
|
+
await signInAsAlice(auth);
|
|
161
|
+
const m = makeMandates(auth);
|
|
162
|
+
await assert.rejects(() => m.create({
|
|
163
|
+
granteeId: "urn:agent:bob",
|
|
164
|
+
scopes: ["ethos.read.public"],
|
|
165
|
+
ttlSeconds: 3600,
|
|
166
|
+
compute: {
|
|
167
|
+
dailyCapMicrocredits: 5_000,
|
|
168
|
+
// @ts-expect-error — runtime check guards against bad casts
|
|
169
|
+
allowedModels: "claude-haiku-4-5",
|
|
170
|
+
},
|
|
171
|
+
}), (e) => e instanceof AithosSDKError &&
|
|
172
|
+
e.code === "mandates_invalid_compute");
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
/* -------------------------------------------------------------------------- */
|
|
176
|
+
/* Read-only mandate is unaffected */
|
|
177
|
+
/* -------------------------------------------------------------------------- */
|
|
178
|
+
describe("MandatesNamespace.create — read-only mandates unaffected", () => {
|
|
179
|
+
it("does NOT reject a clean read-only mandate (no compute, no smuggling)", async () => {
|
|
180
|
+
// We can't run the full mint here because the installed
|
|
181
|
+
// @aithos/protocol-core (0.5.1, pre-0.4.0-mandate) doesn't yet
|
|
182
|
+
// accept compute.invoke on the public sphere whitelist — the real
|
|
183
|
+
// mint would still succeed for a read-only mandate, but it goes
|
|
184
|
+
// out to S3 etc. Instead we check the synchronous validation
|
|
185
|
+
// surface: passing a clean read-only input must not throw before
|
|
186
|
+
// the mint call.
|
|
187
|
+
//
|
|
188
|
+
// (The async `m.create(...)` would still error later on the
|
|
189
|
+
// network path — that's the integration suite's job.)
|
|
190
|
+
//
|
|
191
|
+
// What we ARE asserting here: no SDK-side validation rejection.
|
|
192
|
+
const auth = makeAuth();
|
|
193
|
+
await signInAsAlice(auth);
|
|
194
|
+
const m = makeMandates(auth);
|
|
195
|
+
// The mint will fail with a network error (fetch throws), but
|
|
196
|
+
// crucially NOT with a validation error — that proves our compute
|
|
197
|
+
// guard is precisely scoped to compute-related inputs.
|
|
198
|
+
await assert.rejects(() => m.create({
|
|
199
|
+
granteeId: "urn:viewer:bob",
|
|
200
|
+
scopes: ["ethos.read.public"],
|
|
201
|
+
ttlSeconds: 3600,
|
|
202
|
+
}), (e) => {
|
|
203
|
+
if (!(e instanceof AithosSDKError))
|
|
204
|
+
return true; // any non-validation error fine
|
|
205
|
+
const code = e.code;
|
|
206
|
+
// Anything except a validation rejection is fine — the network
|
|
207
|
+
// is not wired up in this fake auth.
|
|
208
|
+
return (code !== "mandates_invalid_scopes" &&
|
|
209
|
+
code !== "mandates_invalid_compute");
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
/* -------------------------------------------------------------------------- */
|
|
214
|
+
/* Compute-only mandate (scopes: [] + compute namespace) */
|
|
215
|
+
/* -------------------------------------------------------------------------- */
|
|
216
|
+
describe("MandatesNamespace.create — compute-only mandate", () => {
|
|
217
|
+
it("accepts an empty scopes[] when compute is provided (no ethos data access, just bounded spending)", async () => {
|
|
218
|
+
// Same caveat as the "read-only unaffected" suite: the installed
|
|
219
|
+
// @aithos/protocol-core does not yet whitelist compute.invoke on
|
|
220
|
+
// the public sphere, so the real `mintDelegateBundle` call would
|
|
221
|
+
// still reject downstream. What this test proves is that the
|
|
222
|
+
// SDK validation layer (a) does NOT throw `mandates_invalid_scopes`
|
|
223
|
+
// for the empty list when compute is set, and (b) does NOT throw
|
|
224
|
+
// `mandates_invalid_compute` for a properly-capped budget. The
|
|
225
|
+
// request is allowed to proceed past validation.
|
|
226
|
+
const auth = makeAuth();
|
|
227
|
+
await signInAsAlice(auth);
|
|
228
|
+
const m = makeMandates(auth);
|
|
229
|
+
await assert.rejects(() => m.create({
|
|
230
|
+
granteeId: "urn:agent:creative",
|
|
231
|
+
scopes: [], // no ethos data access
|
|
232
|
+
ttlSeconds: 3600,
|
|
233
|
+
compute: { dailyCapMicrocredits: 1_000 },
|
|
234
|
+
}), (e) => {
|
|
235
|
+
if (!(e instanceof AithosSDKError))
|
|
236
|
+
return true;
|
|
237
|
+
const code = e.code;
|
|
238
|
+
// Validation must NOT have rejected this input.
|
|
239
|
+
return (code !== "mandates_invalid_scopes" &&
|
|
240
|
+
code !== "mandates_invalid_compute");
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
it("STILL rejects empty scopes[] when compute is NOT provided", async () => {
|
|
244
|
+
const auth = makeAuth();
|
|
245
|
+
await signInAsAlice(auth);
|
|
246
|
+
const m = makeMandates(auth);
|
|
247
|
+
await assert.rejects(() => m.create({
|
|
248
|
+
granteeId: "urn:agent:bob",
|
|
249
|
+
scopes: [],
|
|
250
|
+
ttlSeconds: 3600,
|
|
251
|
+
}), (e) => e instanceof AithosSDKError &&
|
|
252
|
+
e.code === "mandates_invalid_scopes" &&
|
|
253
|
+
/compute-only mandate/.test(e.message));
|
|
254
|
+
});
|
|
255
|
+
});
|
|
256
|
+
//# sourceMappingURL=mandates-compute.test.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aithos/sdk",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.7",
|
|
4
4
|
"description": "Aithos SDK — high-level TypeScript developer kit for building agentic apps on the Aithos protocol. Wraps @aithos/protocol-client and exposes the Aithos compute proxy and wallet (Stripe top-up) endpoints.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"aithos",
|
|
@@ -52,10 +52,10 @@
|
|
|
52
52
|
"node": ">=20"
|
|
53
53
|
},
|
|
54
54
|
"peerDependencies": {
|
|
55
|
-
"@aithos/protocol-client": ">=0.1.0-alpha.
|
|
55
|
+
"@aithos/protocol-client": ">=0.1.0-alpha.12 <0.2.0"
|
|
56
56
|
},
|
|
57
57
|
"devDependencies": {
|
|
58
|
-
"@aithos/protocol-client": "^0.1.0-alpha.
|
|
58
|
+
"@aithos/protocol-client": "^0.1.0-alpha.12",
|
|
59
59
|
"@types/node": "^24.12.2",
|
|
60
60
|
"fake-indexeddb": "^6.2.5",
|
|
61
61
|
"typescript": "^5.9.2"
|