@aithos/sdk 0.1.0-alpha.44 → 0.1.0-alpha.45

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.
@@ -1,3 +1,4 @@
1
+ import { type SignedMandate } from "@aithos/protocol-client";
1
2
  export interface AithosSchemaLite {
2
3
  readonly schema: string;
3
4
  readonly indexable: ReadonlySet<string>;
@@ -45,12 +46,26 @@ export interface CreateDataClientArgs {
45
46
  export interface DataClient {
46
47
  /** Get / create a collection handle. */
47
48
  collection(name: string): DataCollection;
48
- /** Initialize a new collection with an explicit schema. */
49
+ /** Initialize a new collection with an explicit schema. Throws
50
+ * `-32073 AITHOS_DATA_COLLECTION_EXISTS` if it already exists. */
49
51
  createCollection(args: {
50
52
  name: string;
51
53
  schema: string;
52
54
  forwardSecrecy?: "best_effort" | "strict";
53
55
  }): Promise<void>;
56
+ /**
57
+ * Get-or-create: create the collection if it doesn't exist, otherwise
58
+ * succeed silently. Idempotent — safe to call on every app boot before
59
+ * writing. Absorbs the `-32073 AITHOS_DATA_COLLECTION_EXISTS` conflict
60
+ * (and the concurrent-create race) so callers don't have to special-case
61
+ * "already there". Avoids the friction where `collection(name).insert(…)`
62
+ * on a never-created collection fails with `-32020`.
63
+ */
64
+ ensureCollection(args: {
65
+ name: string;
66
+ schema: string;
67
+ forwardSecrecy?: "best_effort" | "strict";
68
+ }): Promise<void>;
54
69
  /** List collections owned by this subject. */
55
70
  listCollections(): Promise<readonly {
56
71
  name: string;
@@ -102,9 +117,83 @@ export interface DataClient {
102
117
  getSchema(schemaId: string, opts?: {
103
118
  subjectDid?: string;
104
119
  }): Promise<object | null>;
120
+ /**
121
+ * Grant a mandate-holding delegate read access to one of this owner's
122
+ * collections, by re-wrapping the collection's CMK to the grantee's
123
+ * key and posting `aithos.data.authorize_app`.
124
+ *
125
+ * Owner-only. The CMK is unwrapped locally (the owner holds it), then
126
+ * re-wrapped X25519-HKDF-AEAD to the grantee's X25519 key (derived
127
+ * from `mandate.grantee.pubkey`). The platform never sees the CMK in
128
+ * clear — it only appends the wrap to the collection's envelope after
129
+ * verifying the mandate (data spec §4.5).
130
+ *
131
+ * Idempotent at the server: re-authorizing the same grantee on the
132
+ * same collection is a no-op. One wrap per grantee covers every record
133
+ * in the collection (O(1) authorization — the CMK is stable).
134
+ *
135
+ * The mandate must carry a `data.<collectionName>.{read|write|admin}`
136
+ * or `data.*.*` scope and a `grantee.pubkey`.
137
+ */
138
+ authorizeDelegate(args: {
139
+ collectionName: string;
140
+ mandate: SignedMandate;
141
+ }): Promise<void>;
142
+ /**
143
+ * Revoke a delegate's access to a collection (`aithos.data.revoke_app`).
144
+ * Owner-only, forward-only: after revocation the PDS refuses the
145
+ * delegate's reads (the mandate is marked revoked), and the delegate's
146
+ * wrap is dropped from the collection's authorization index. Already-read
147
+ * / cached plaintext on the delegate side is out of scope (a known limit
148
+ * of any key-sharing scheme — revocation blocks FUTURE access).
149
+ */
150
+ revokeDelegate(args: {
151
+ collectionName: string;
152
+ mandateId: string;
153
+ reason?: string;
154
+ }): Promise<void>;
155
+ /** Drop in-memory cache (CMK, collection metadata, …). */
156
+ reset(): void;
157
+ }
158
+ /**
159
+ * Read-only view over a subject's data collections, driven by a mandate
160
+ * the subject granted to a delegate (`data.<collection>.read`). Built by
161
+ * {@link createDelegateDataClient}.
162
+ *
163
+ * Mirror of {@link DataClient} minus every mutating verb: a delegate
164
+ * holding a read mandate can `get`/`list` and enumerate collections, but
165
+ * cannot insert, update, delete, create collections, register schemas, or
166
+ * re-delegate. Those throw `-32042` client-side (and the PDS rejects them
167
+ * server-side regardless).
168
+ */
169
+ export interface ReadonlyDataClient {
170
+ /** Get a read-only collection handle. */
171
+ collection(name: string): ReadonlyDataCollection;
172
+ /** List collections the delegate's mandate scopes can reach. */
173
+ listCollections(): Promise<readonly {
174
+ name: string;
175
+ schema: string;
176
+ record_count: number;
177
+ }[]>;
178
+ /** List gamma audit entries (read). */
179
+ listGammaEntries(opts?: {
180
+ limit?: number;
181
+ opPrefix?: string;
182
+ verify?: boolean;
183
+ }): Promise<unknown>;
105
184
  /** Drop in-memory cache (CMK, collection metadata, …). */
106
185
  reset(): void;
107
186
  }
187
+ export interface ReadonlyDataCollection {
188
+ readonly name: string;
189
+ /** Fetch one record by id (decrypted client-side via the re-wrapped CMK). */
190
+ get(recordId: string): Promise<Record<string, unknown> | null>;
191
+ /** List records, decrypted. Pagination via opaque cursor. */
192
+ list(opts?: ListOpts): Promise<{
193
+ items: Record<string, unknown>[];
194
+ nextCursor?: string;
195
+ }>;
196
+ }
108
197
  export interface DataCollection {
109
198
  readonly name: string;
110
199
  /**
@@ -150,4 +239,42 @@ export interface ListOpts {
150
239
  readonly cursor?: string;
151
240
  }
152
241
  export declare function createDataClient(args: CreateDataClientArgs): DataClient;
242
+ export interface CreateDelegateDataClientArgs {
243
+ /** PDS base URL (same endpoint the owner writes to). */
244
+ readonly pdsUrl: string;
245
+ /** DID of the SUBJECT whose data is being read (the mandate issuer). */
246
+ readonly subjectDid: string;
247
+ /**
248
+ * The full signed mandate the subject granted to this delegate. Must
249
+ * carry a `data.<collection>.read` (or wider) scope and a
250
+ * `grantee.pubkey` matching `delegateSeed`.
251
+ */
252
+ readonly mandate: SignedMandate;
253
+ /** The delegate's Ed25519 seed (32 bytes) — the grantee key the mandate
254
+ * is bound to. Used to sign envelopes AND to derive the X25519 key that
255
+ * unwraps the re-wrapped CMK. */
256
+ readonly delegateSeed: Uint8Array;
257
+ /**
258
+ * The delegate's Ed25519 public key, multibase-encoded. Defaults to
259
+ * `mandate.grantee.pubkey`. This is the bare verificationMethod the PDS
260
+ * binds the delegate envelope to.
261
+ */
262
+ readonly granteePubkeyMultibase?: string;
263
+ /** App-defined (vendor) schemas, as for {@link createDataClient}. */
264
+ readonly schemas?: readonly AithosSchemaLite[];
265
+ /** `fetch` override (tests). */
266
+ readonly fetch?: typeof fetch;
267
+ }
268
+ /**
269
+ * Build a read-only data client that reads a subject's collections under
270
+ * a mandate (delegate path). The returned {@link ReadonlyDataClient}
271
+ * signs every request as the delegate (bare-multibase verificationMethod
272
+ * + the mandate attached to the envelope), and decrypts records using the
273
+ * CMK the owner re-wrapped for this delegate via
274
+ * {@link DataClient.authorizeDelegate}.
275
+ *
276
+ * Writes are not available on the returned type and throw `-32042` if
277
+ * forced.
278
+ */
279
+ export declare function createDelegateDataClient(args: CreateDelegateDataClientArgs): ReadonlyDataClient;
153
280
  //# sourceMappingURL=data.d.ts.map
package/dist/src/data.js CHANGED
@@ -38,6 +38,7 @@ import { hkdf } from "@noble/hashes/hkdf.js";
38
38
  import { sha256, sha512 } from "@noble/hashes/sha2.js";
39
39
  import { XChaCha20Poly1305 } from "@stablelib/xchacha20poly1305";
40
40
  import * as ed from "@noble/ed25519";
41
+ import { multibaseToEd25519PublicKey, edPubToX25519Pub, } from "@aithos/protocol-client";
41
42
  import { contactsV1 } from "./data-schema-contacts-v1.js";
42
43
  import { signOwnerEnvelope } from "./internal/envelope.js";
43
44
  // noble/ed25519 v2 needs sha512 wired in for sync sign/verify
@@ -57,15 +58,48 @@ const SCHEMAS = new Map([
57
58
  export function createDataClient(args) {
58
59
  return new DataClientImpl(args);
59
60
  }
60
- /* -------------------------------------------------------------------------- */
61
- /* Implementation */
62
- /* -------------------------------------------------------------------------- */
61
+ /**
62
+ * Build a read-only data client that reads a subject's collections under
63
+ * a mandate (delegate path). The returned {@link ReadonlyDataClient}
64
+ * signs every request as the delegate (bare-multibase verificationMethod
65
+ * + the mandate attached to the envelope), and decrypts records using the
66
+ * CMK the owner re-wrapped for this delegate via
67
+ * {@link DataClient.authorizeDelegate}.
68
+ *
69
+ * Writes are not available on the returned type and throw `-32042` if
70
+ * forced.
71
+ */
72
+ export function createDelegateDataClient(args) {
73
+ const granteePubMb = args.granteePubkeyMultibase ??
74
+ args.mandate.grantee?.pubkey;
75
+ if (!granteePubMb) {
76
+ throw new Error("createDelegateDataClient: mandate.grantee.pubkey is missing; pass granteePubkeyMultibase explicitly");
77
+ }
78
+ return new DataClientImpl({
79
+ pdsUrl: args.pdsUrl,
80
+ did: args.subjectDid,
81
+ // In delegate mode the seed is used only to derive the X25519 key that
82
+ // unwraps the re-wrapped CMK; envelope signing goes through the
83
+ // delegate path (buildSignedEnvelope), never signOwnerEnvelope.
84
+ sphereSeed: args.delegateSeed,
85
+ verificationMethod: granteePubMb,
86
+ ...(args.schemas ? { schemas: args.schemas } : {}),
87
+ ...(args.fetch ? { fetch: args.fetch } : {}),
88
+ delegate: {
89
+ delegateSeed: args.delegateSeed,
90
+ granteePubkeyMultibase: granteePubMb,
91
+ mandate: args.mandate,
92
+ },
93
+ });
94
+ }
63
95
  class DataClientImpl {
64
96
  #pdsUrl;
65
97
  #did;
66
98
  #seed;
67
99
  #vm;
68
100
  #fetch;
101
+ /** Delegate session, or undefined for the owner path. */
102
+ #delegate;
69
103
  /**
70
104
  * Per-client schema overrides, populated from `args.schemas` at
71
105
  * construction. Looked up BEFORE the bundled SCHEMAS map (so an app
@@ -82,8 +116,22 @@ class DataClientImpl {
82
116
  this.#seed = args.sphereSeed;
83
117
  this.#vm = args.verificationMethod;
84
118
  this.#fetch = args.fetch ?? globalThis.fetch.bind(globalThis);
119
+ if (args.delegate)
120
+ this.#delegate = args.delegate;
85
121
  this.#localSchemas = new Map((args.schemas ?? []).map((s) => [s.schema, s]));
86
122
  }
123
+ /** Throw a read-only error when a mutating verb is called on a delegate
124
+ * client. The PDS rejects these server-side too; this is the fast,
125
+ * local guard with a precise message. */
126
+ #assertOwner(op) {
127
+ if (this.#delegate) {
128
+ const e = new Error(`sdk.data: "${op}" is not permitted for a delegate client (read-only mandate). ` +
129
+ `A data.<collection>.read mandate grants get/list only; writes require the owner ` +
130
+ `or a data.<collection>.write mandate (not yet supported on the delegate path).`);
131
+ e.code = -32042;
132
+ throw e;
133
+ }
134
+ }
87
135
  /**
88
136
  * Resolve a schema id to its lite definition. App-supplied schemas
89
137
  * (via `createDataClient({ schemas })`) take precedence over the
@@ -95,7 +143,45 @@ class DataClientImpl {
95
143
  collection(name) {
96
144
  return new DataCollectionImpl(this, name);
97
145
  }
146
+ async authorizeDelegate(args) {
147
+ this.#assertOwner("authorizeDelegate");
148
+ const granteePubMb = args.mandate.grantee?.pubkey;
149
+ if (!granteePubMb) {
150
+ throw new Error("sdk.data.authorizeDelegate: mandate.grantee.pubkey is required (data read mandates bind to a grantee key).");
151
+ }
152
+ // Ensure we hold the collection's CMK (owner unwrap path).
153
+ const state = await this._ensureCollection(args.collectionName);
154
+ const cmk = this.#cmkCache.get(args.collectionName);
155
+ if (!cmk) {
156
+ throw new Error(`sdk.data.authorizeDelegate: CMK for "${args.collectionName}" not loaded`);
157
+ }
158
+ // Derive the grantee's X25519 wrap-recipient key from its Ed25519
159
+ // public key (the same birational map the owner uses for its own key).
160
+ const granteeEdPub = multibaseToEd25519PublicKey(granteePubMb);
161
+ const granteeX25519Pub = edPubToX25519Pub(granteeEdPub);
162
+ const recipientDidUrl = delegateRecipientDidUrl(granteePubMb);
163
+ const wrap = this.#wrapCmkForRecipient({
164
+ cmk,
165
+ recipientPublicKey: granteeX25519Pub,
166
+ recipientDidUrl,
167
+ collectionUrn: state.urn,
168
+ });
169
+ await this.#call("/mcp/primitives/write", "aithos.data.authorize_app", {
170
+ collection_urn: state.urn,
171
+ mandate: args.mandate,
172
+ wrap,
173
+ });
174
+ }
175
+ async revokeDelegate(args) {
176
+ this.#assertOwner("revokeDelegate");
177
+ await this.#call("/mcp/primitives/write", "aithos.data.revoke_app", {
178
+ collection_urn: this.#collectionUrn(args.collectionName),
179
+ mandate_id: args.mandateId,
180
+ ...(args.reason ? { revocation: { reason: args.reason } } : {}),
181
+ });
182
+ }
98
183
  async createCollection(args) {
184
+ this.#assertOwner("createCollection");
99
185
  const cmk = randomBytes32();
100
186
  const recipientDidUrl = `${this.#did}#data-kex`;
101
187
  const collectionUrn = this.#collectionUrn(args.name);
@@ -124,6 +210,20 @@ class DataClientImpl {
124
210
  // CMK is retained in cache, only zero the local var if we didn't cache.
125
211
  }
126
212
  }
213
+ async ensureCollection(args) {
214
+ this.#assertOwner("ensureCollection");
215
+ try {
216
+ await this.createCollection(args);
217
+ }
218
+ catch (e) {
219
+ // -32073 AITHOS_DATA_COLLECTION_EXISTS → already created (possibly by a
220
+ // concurrent caller). That's the success case for get-or-create. Any
221
+ // other error propagates.
222
+ if (e.code === -32073)
223
+ return;
224
+ throw e;
225
+ }
226
+ }
127
227
  async listCollections() {
128
228
  const r = await this.#call("/mcp/primitives/read", "aithos.data.list_collections", {
129
229
  subject_did: this.#did,
@@ -146,6 +246,7 @@ class DataClientImpl {
146
246
  return this.#call("/mcp/primitives/read", "aithos.data.list_gamma_entries", params);
147
247
  }
148
248
  async registerSchema(schemaDoc) {
249
+ this.#assertOwner("registerSchema");
149
250
  if (schemaDoc === null || typeof schemaDoc !== "object" || Array.isArray(schemaDoc)) {
150
251
  throw new Error("sdk.data.registerSchema: schemaDoc must be a JSON object");
151
252
  }
@@ -210,16 +311,27 @@ class DataClientImpl {
210
311
  err.code = -32020;
211
312
  throw err;
212
313
  }
213
- // Look up our wrap and unwrap the CMK
214
- const ourRecipient = `${this.#did}#data-kex`;
314
+ // Look up our wrap and unwrap the CMK. Owner: the wrap addressed to
315
+ // `${did}#data-kex`, unwrapped with the owner sphere seed. Delegate:
316
+ // the wrap the owner re-wrapped for this grantee
317
+ // (`did:key:${granteePubkeyMultibase}#data-kex`), unwrapped with the
318
+ // delegate's own X25519 key (derived from its grantee seed).
319
+ const ourRecipient = this.#delegate
320
+ ? delegateRecipientDidUrl(this.#delegate.granteePubkeyMultibase)
321
+ : `${this.#did}#data-kex`;
215
322
  const wrap = meta.cmk_envelope.wraps.find((w) => w.recipient === ourRecipient);
216
323
  if (!wrap) {
217
- throw new Error(`sdk.data: no CMK wrap for ${ourRecipient} in collection ${name}. The collection was either created with a different recipient, or this client is not the owner.`);
324
+ throw new Error(this.#delegate
325
+ ? `sdk.data: no CMK wrap for ${ourRecipient} in collection "${name}". ` +
326
+ `The owner has not authorized this delegate on the collection yet — ` +
327
+ `ask them to call sdk.data...authorizeDelegate({ collectionName, mandate }).`
328
+ : `sdk.data: no CMK wrap for ${ourRecipient} in collection ${name}. The collection was either created with a different recipient, or this client is not the owner.`);
218
329
  }
330
+ const unwrapSeed = this.#delegate ? this.#delegate.delegateSeed : this.#seed;
219
331
  const cmk = this.#unwrapCmk({
220
332
  wrap,
221
333
  collectionUrn: meta.urn,
222
- privateKey: ed25519SeedToX25519PrivateKey(this.#seed),
334
+ privateKey: ed25519SeedToX25519PrivateKey(unwrapSeed),
223
335
  });
224
336
  this.#cmkCache.set(name, cmk);
225
337
  const schema = this.#resolveSchema(meta.schema);
@@ -234,6 +346,7 @@ class DataClientImpl {
234
346
  return state;
235
347
  }
236
348
  async _insert(state, record) {
349
+ this.#assertOwner("insert");
237
350
  const cmk = this.#cmkCache.get(state.name);
238
351
  if (!cmk)
239
352
  throw new Error("CMK not loaded");
@@ -288,6 +401,7 @@ class DataClientImpl {
288
401
  };
289
402
  }
290
403
  async _update(state, recordId, record) {
404
+ this.#assertOwner("update");
291
405
  const cmk = this.#cmkCache.get(state.name);
292
406
  if (!cmk)
293
407
  throw new Error("CMK not loaded");
@@ -306,6 +420,7 @@ class DataClientImpl {
306
420
  });
307
421
  }
308
422
  async _delete(state, recordId) {
423
+ this.#assertOwner("delete");
309
424
  await this.#call("/mcp/primitives/write", "aithos.data.delete_record", {
310
425
  collection_urn: state.urn,
311
426
  record_id: recordId,
@@ -314,14 +429,31 @@ class DataClientImpl {
314
429
  /* -- JSON-RPC dispatch -- */
315
430
  async #call(path, method, params) {
316
431
  const aud = `${this.#pdsUrl}${path}`;
317
- const envelope = await signOwnerEnvelope({
318
- iss: this.#did,
319
- aud,
320
- method,
321
- params,
322
- verificationMethod: this.#vm,
323
- signer: { sign: async (msg) => ed.sign(msg, this.#seed) },
324
- });
432
+ // Both paths use the SAME §11.2 envelope scheme (the data PDS verifies
433
+ // with @aithos/protocol-core, which canonicalizes the full envelope
434
+ // INCLUDING `proof` with proofValue=""). Delegate path: sign with the
435
+ // grantee key, bare-multibase verificationMethod, and attach the mandate
436
+ // so the PDS resolves the delegation and enforces its scopes.
437
+ const envelope = this.#delegate
438
+ ? await signOwnerEnvelope({
439
+ iss: this.#did, // the SUBJECT DID (mandate issuer), not the delegate
440
+ aud,
441
+ method,
442
+ params,
443
+ verificationMethod: this.#delegate.granteePubkeyMultibase,
444
+ signer: {
445
+ sign: async (msg) => ed.sign(msg, this.#delegate.delegateSeed),
446
+ },
447
+ mandate: this.#delegate.mandate,
448
+ })
449
+ : await signOwnerEnvelope({
450
+ iss: this.#did,
451
+ aud,
452
+ method,
453
+ params,
454
+ verificationMethod: this.#vm,
455
+ signer: { sign: async (msg) => ed.sign(msg, this.#seed) },
456
+ });
325
457
  const body = {
326
458
  jsonrpc: "2.0",
327
459
  id: makeUlid(),
@@ -508,6 +640,18 @@ function cryptoRandom(n) {
508
640
  function utf8(s) {
509
641
  return new TextEncoder().encode(s);
510
642
  }
643
+ /**
644
+ * Recipient DID URL used for a delegate's CMK wrap. Built from the
645
+ * grantee's Ed25519 public-key multibase so that (a) the owner side
646
+ * (`authorizeDelegate`) and the delegate side (`_ensureCollection`)
647
+ * derive the EXACT same string — it's bound into the wrap AAD and the
648
+ * HKDF info, so any mismatch fails the unwrap — and (b) the string
649
+ * contains `mandate.grantee.pubkey`, which the PDS `authorize_app`
650
+ * handler requires (`wrap.recipient.includes(grantee.pubkey)`).
651
+ */
652
+ function delegateRecipientDidUrl(granteePubkeyMultibase) {
653
+ return `did:key:${granteePubkeyMultibase}#data-kex`;
654
+ }
511
655
  function aadCmkWrap(collectionUrn, recipient) {
512
656
  const p = utf8("aithos-data-cmk-v1\0");
513
657
  const c = utf8(collectionUrn);
@@ -27,6 +27,6 @@ export type { AudienceSet, AppCreditPackId, CreateAppTopupSessionArgs, CreateApp
27
27
  export * as onboarding from "./onboarding.js";
28
28
  export { createBrowserIdentity, browserIdentityFromStored, type BrowserIdentity, } from "@aithos/protocol-client";
29
29
  export type { Section } from "@aithos/protocol-client";
30
- export { createDataClient, type CreateDataClientArgs, type DataClient, type DataCollection, type ListOpts, type AithosSchemaLite, } from "./data.js";
30
+ export { createDataClient, createDelegateDataClient, type CreateDataClientArgs, type CreateDelegateDataClientArgs, type DataClient, type DataCollection, type ReadonlyDataClient, type ReadonlyDataCollection, type ListOpts, type AithosSchemaLite, } from "./data.js";
31
31
  export { createAssetsClient, AssetsClient, type CreateAssetsClientArgs, type AttachedContext, type AssetUploadInput, type AssetUploadResult, type AssetFetchResult, type AssetBrief, type ListAssetsOpts, type ThumbnailUploadInput, type ThumbnailUploadResult, type RecipientResolver, type RecipientSet, } from "./assets.js";
32
32
  //# sourceMappingURL=index.d.ts.map
package/dist/src/index.js CHANGED
@@ -68,7 +68,7 @@ export { createBrowserIdentity, browserIdentityFromStored, } from "@aithos/proto
68
68
  // `sdk.data` namespace — Aithos data sub-protocol PDS client. Manages
69
69
  // the lifecycle of subject-owned, encrypted, schema-validated records.
70
70
  // See spec/data/ in the aithos-protocol repo.
71
- export { createDataClient, } from "./data.js";
71
+ export { createDataClient, createDelegateDataClient, } from "./data.js";
72
72
  // `sdk.assets` — Aithos assets sub-protocol PDS client. Upload,
73
73
  // fetch, list, ref/unref binary content (images, PDFs, audio, video)
74
74
  // owned by a subject. AEAD-encrypted per-asset under AMKs wrapped for
@@ -22,6 +22,15 @@ export interface SignedEnvelope {
22
22
  readonly exp: number;
23
23
  readonly nonce: string;
24
24
  readonly params_hash: string;
25
+ /**
26
+ * Full signed mandate — present ONLY for a delegate-signed envelope
27
+ * (§11.6). When present, `proof.verificationMethod` is the delegate's
28
+ * bare Ed25519 multibase (matching `mandate.grantee.pubkey`) and the
29
+ * server resolves the signer to that key after verifying the mandate.
30
+ * The field is part of the signed bytes (the signature commits to the
31
+ * delegation context), so it cannot be swapped out in transit.
32
+ */
33
+ readonly mandate?: unknown;
25
34
  readonly proof: {
26
35
  readonly type: "Ed25519Signature2020";
27
36
  readonly verificationMethod: string;
@@ -54,6 +63,13 @@ export interface SignOwnerEnvelopeArgs {
54
63
  * matching public key.
55
64
  */
56
65
  readonly verificationMethod: string;
66
+ /**
67
+ * Full signed mandate, attached to the envelope for a delegate-signed
68
+ * call (§11.6). When set, `verificationMethod` MUST be the delegate's
69
+ * bare Ed25519 multibase (matching `mandate.grantee.pubkey`) and
70
+ * `signer` MUST be the delegate's key. Omit for owner-path envelopes.
71
+ */
72
+ readonly mandate?: unknown;
57
73
  /** Envelope lifetime in seconds. Default 60. Server caps at 300. */
58
74
  readonly ttlSeconds?: number;
59
75
  /** Clock override for deterministic tests. Defaults to `new Date()`. */
@@ -47,6 +47,12 @@ export async function signOwnerEnvelope(args) {
47
47
  exp,
48
48
  nonce,
49
49
  params_hash: paramsHash,
50
+ // Attach the mandate (delegate path) so the signature commits to the
51
+ // delegation context. JCS sorts keys, so placement here is irrelevant
52
+ // to the canonical bytes — what matters is that the server (which
53
+ // canonicalizes the full envelope incl. mandate + proof/proofValue="")
54
+ // sees the exact same object.
55
+ ...(args.mandate !== undefined ? { mandate: args.mandate } : {}),
50
56
  proof: {
51
57
  type: "Ed25519Signature2020",
52
58
  verificationMethod: args.verificationMethod,
@@ -8,7 +8,28 @@ import type { AithosSdkEndpoints } from "./endpoints.js";
8
8
  * directly in `scopes` is rejected at runtime; the compiler can't enforce
9
9
  * it (callers who up-cast to string[] would slip through), so the runtime
10
10
  * check is the real gate. */
11
- export type Scope = "ethos.read.public" | "ethos.read.circle" | "ethos.read.self" | "ethos.write.public" | "ethos.write.circle" | "ethos.write.self";
11
+ export type Scope = "ethos.read.public" | "ethos.read.circle" | "ethos.read.self" | "ethos.write.public" | "ethos.write.circle" | "ethos.write.self" | DataScope;
12
+ /** Action a data mandate may authorize on a collection. `write` implies
13
+ * `read`; `admin` implies `write`. Mirrors the data sub-protocol grammar
14
+ * `data.<collection>.<action>` (Aithos-protocol `spec/data/04-mandates.md`
15
+ * §4.2) and the server-side check `requireScope` in data-backend. */
16
+ export type DataAction = "read" | "write" | "admin";
17
+ /**
18
+ * A data-access scope: `data.<collection>.<action>`, or the cross-collection
19
+ * wildcard `data.*.<action>`. Examples: `data.contacts.read`,
20
+ * `data.depots.write`, `data.*.read`.
21
+ *
22
+ * Note on `actor_sphere`: data mandates are minted under `actor_sphere:
23
+ * "self"` (the owner's highest-authority sphere). The sphere is *not* the
24
+ * access axis for data — the collection is. `actor_sphere` is informative
25
+ * here; the cryptographic binding is the grantee's key + the CMK wrap, per
26
+ * spec §4.4. A dedicated `#data` sphere key (independent rotation) MAY be
27
+ * introduced later without changing this scope grammar.
28
+ *
29
+ * Collection names MUST NOT contain `.` (the server splits the scope on
30
+ * `.` and reads the first three segments).
31
+ */
32
+ export type DataScope = `data.${string}.${DataAction}`;
12
33
  /**
13
34
  * The opt-in scope that authorizes a delegate to spend the subject's
14
35
  * compute credits via the Aithos compute proxy. Mirror of
@@ -64,6 +64,17 @@ export class MandatesNamespace {
64
64
  `not by adding "${COMPUTE_INVOKE_SCOPE}" to scopes[]. The namespace forces ` +
65
65
  `an explicit budget and is what a consent UI reviews.`);
66
66
  }
67
+ // Fail fast on malformed data scopes so the misuse surfaces at the SDK
68
+ // boundary, not as an opaque server rejection at first delegate call.
69
+ // Accepts `data.<collection>.<action>` and the wildcard `data.*.<action>`.
70
+ for (const s of input.scopes) {
71
+ if (s.startsWith("data.") &&
72
+ !isWellFormedDataScope(s)) {
73
+ throw new AithosSDKError("mandates_invalid_scopes", `Malformed data scope "${s}". Expected data.<collection>.<action> ` +
74
+ `(action = read | write | admin), e.g. "data.contacts.read" or ` +
75
+ `"data.*.read". Collection names must not contain ".".`);
76
+ }
77
+ }
67
78
  // Validate + project the compute namespace if present, then derive
68
79
  // the final scopes/constraints to send to the protocol layer.
69
80
  const computeProjection = projectCompute(input.compute);
@@ -207,11 +218,41 @@ export class MandatesNamespace {
207
218
  /* Helpers */
208
219
  /* -------------------------------------------------------------------------- */
209
220
  function defaultSphereFromScopes(scopes) {
210
- if (scopes.some((s) => s.endsWith(".self")))
221
+ // Data scopes are sphere-NEUTRAL: they're permitted under self & circle (and
222
+ // public once the allowlist includes them), and the data access axis is the
223
+ // collection, not the sphere (the sphere is informative — see {@link DataScope}).
224
+ // So the actor_sphere of a combined Ethos+data mandate is decided by the
225
+ // ETHOS scopes alone; data scopes neither raise nor lower it. This makes
226
+ // `ethos.read.public + data.X.read` default to `public`, `ethos.read.circle
227
+ // + data.X.read` to `circle`, etc. A caller can always override via
228
+ // `actorSphere`.
229
+ const ethos = scopes.filter((s) => !s.startsWith("data."));
230
+ // Ethos write scopes pin the sphere EXACTLY (a write mandate must be signed
231
+ // by the sphere it writes to — `validateScopesAgainstSphere`).
232
+ if (ethos.some((s) => s === "ethos.write.public"))
233
+ return "public";
234
+ if (ethos.some((s) => s === "ethos.write.circle"))
235
+ return "circle";
236
+ if (ethos.some((s) => s === "ethos.write.self"))
211
237
  return "self";
212
- if (scopes.some((s) => s.endsWith(".circle")))
238
+ // Ethos read scopes: narrowest sphere that permits them.
239
+ if (ethos.some((s) => s.endsWith(".self")))
240
+ return "self";
241
+ if (ethos.some((s) => s.endsWith(".circle")))
213
242
  return "circle";
214
- return "public";
243
+ // Remaining Ethos scopes (ethos.read.public / .all / gamma.read) → public.
244
+ if (ethos.length > 0)
245
+ return "public";
246
+ // Data-only mandate: sign under `self`. self is the owner's highest-trust
247
+ // sphere, always permits the scope at mint time (no dependency on the
248
+ // public-sphere allowlist), and keeps the data grant off the most-exposed
249
+ // #public key. (Override with `actorSphere` for a public/circle label.)
250
+ return "self";
251
+ }
252
+ /** `true` iff `s` is a well-formed data scope `data.<collection>.<action>`
253
+ * with no filter suffix and a non-empty, dot-free collection name. */
254
+ function isWellFormedDataScope(s) {
255
+ return /^data\.[^.]+\.(read|write|admin)$/.test(s);
215
256
  }
216
257
  /**
217
258
  * Validate the SDK-side `compute` namespace and project it onto the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aithos/sdk",
3
- "version": "0.1.0-alpha.44",
3
+ "version": "0.1.0-alpha.45",
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",