@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 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 |
@@ -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
- // Need the snapshot regardless we read base sections + zoneBytes.
84
- const snap = await this.#ensureSnapshotOwner();
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.#ensureSnapshotAnonymous();
179
- const base = snap.publicSections;
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.#ensureSnapshotOwner();
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.#ensureSnapshotDelegate();
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);
@@ -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";
@@ -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). */
@@ -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
- if (input.scopes.length === 0) {
33
- throw new AithosSDKError("mandates_invalid_scopes", "scopes must be a non-empty list");
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: [...input.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: input.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,2 @@
1
+ export {};
2
+ //# sourceMappingURL=ethos-first-edition.test.d.ts.map
@@ -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,2 @@
1
+ export {};
2
+ //# sourceMappingURL=mandates-compute.test.d.ts.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.5",
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.11 <0.2.0"
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.11",
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"