@aithos/sdk 0.1.0-alpha.6 → 0.1.0-alpha.60
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 +202 -7
- package/dist/src/agent-dispatch.d.ts +18 -0
- package/dist/src/agent-dispatch.js +178 -0
- package/dist/src/agent-loop.d.ts +94 -0
- package/dist/src/agent-loop.js +95 -0
- package/dist/src/agent-tools.d.ts +24 -0
- package/dist/src/agent-tools.js +147 -0
- package/dist/src/apps.d.ts +224 -0
- package/dist/src/apps.js +432 -0
- package/dist/src/assets.d.ts +225 -0
- package/dist/src/assets.js +534 -0
- package/dist/src/auth-api.d.ts +219 -0
- package/dist/src/auth-api.js +248 -0
- package/dist/src/auth.d.ts +591 -0
- package/dist/src/auth.js +947 -31
- package/dist/src/compute.d.ts +674 -6
- package/dist/src/compute.js +968 -20
- package/dist/src/data-schema-contacts-v1.d.ts +14 -0
- package/dist/src/data-schema-contacts-v1.js +28 -0
- package/dist/src/data.d.ts +368 -0
- package/dist/src/data.js +1124 -0
- package/dist/src/endpoints.d.ts +43 -0
- package/dist/src/endpoints.js +23 -0
- package/dist/src/ethos.d.ts +85 -0
- package/dist/src/ethos.js +463 -7
- package/dist/src/index.d.ts +22 -4
- package/dist/src/index.js +47 -2
- package/dist/src/internal/cmk-wrap.d.ts +41 -0
- package/dist/src/internal/cmk-wrap.js +132 -0
- package/dist/src/internal/delegate-bundle.js +7 -2
- package/dist/src/internal/envelope.d.ts +93 -0
- package/dist/src/internal/envelope.js +59 -0
- package/dist/src/internal/owner-signers.d.ts +5 -2
- package/dist/src/internal/owner-signers.js +22 -1
- package/dist/src/internal/recovery-file.d.ts +2 -0
- package/dist/src/internal/recovery-file.js +7 -0
- package/dist/src/key-store.d.ts +10 -0
- package/dist/src/key-store.js +6 -0
- package/dist/src/mandates.d.ts +58 -1
- package/dist/src/mandates.js +46 -3
- package/dist/src/migrate.d.ts +105 -0
- package/dist/src/migrate.js +367 -0
- package/dist/src/react/AithosAsset.d.ts +66 -0
- package/dist/src/react/AithosAsset.js +67 -0
- package/dist/src/react/context.d.ts +29 -0
- package/dist/src/react/context.js +31 -0
- package/dist/src/react/index.d.ts +29 -0
- package/dist/src/react/index.js +31 -0
- package/dist/src/react/use-aithos-asset.d.ts +39 -0
- package/dist/src/react/use-aithos-asset.js +118 -0
- package/dist/src/react/use-transcribe-pending.d.ts +21 -0
- package/dist/src/react/use-transcribe-pending.js +47 -0
- package/dist/src/rotate.d.ts +94 -0
- package/dist/src/rotate.js +298 -0
- package/dist/src/sdk.d.ts +36 -2
- package/dist/src/sdk.js +72 -1
- package/dist/src/transcribe-resilience.d.ts +57 -0
- package/dist/src/transcribe-resilience.js +203 -0
- package/dist/src/web.d.ts +279 -0
- package/dist/src/web.js +186 -0
- package/dist/test/agent-dispatch.test.d.ts +2 -0
- package/dist/test/agent-dispatch.test.js +222 -0
- package/dist/test/agent-loop.test.d.ts +2 -0
- package/dist/test/agent-loop.test.js +117 -0
- package/dist/test/agent-tools.test.d.ts +2 -0
- package/dist/test/agent-tools.test.js +50 -0
- package/dist/test/auth-j3.test.js +32 -1
- package/dist/test/canonical-conformance.test.d.ts +2 -0
- package/dist/test/canonical-conformance.test.js +86 -0
- package/dist/test/compute-delegate-path.test.d.ts +2 -0
- package/dist/test/compute-delegate-path.test.js +183 -0
- package/dist/test/compute.test.js +4 -0
- package/dist/test/converse.test.d.ts +2 -0
- package/dist/test/converse.test.js +162 -0
- package/dist/test/data-sphere.test.d.ts +2 -0
- package/dist/test/data-sphere.test.js +57 -0
- package/dist/test/endpoints.test.js +40 -1
- package/dist/test/envelope-core-conformance.test.d.ts +2 -0
- package/dist/test/envelope-core-conformance.test.js +75 -0
- package/dist/test/envelope.test.d.ts +2 -0
- package/dist/test/envelope.test.js +318 -0
- package/dist/test/ethos-first-edition.test.d.ts +2 -0
- package/dist/test/ethos-first-edition.test.js +371 -0
- package/dist/test/invoke-turn-sdk.test.d.ts +2 -0
- package/dist/test/invoke-turn-sdk.test.js +177 -0
- package/dist/test/migrate.test.d.ts +2 -0
- package/dist/test/migrate.test.js +340 -0
- package/dist/test/owner-data-client.test.d.ts +2 -0
- package/dist/test/owner-data-client.test.js +88 -0
- package/dist/test/rotate-ethos.test.d.ts +2 -0
- package/dist/test/rotate-ethos.test.js +151 -0
- package/dist/test/rotate.test.d.ts +2 -0
- package/dist/test/rotate.test.js +63 -0
- package/dist/test/schema-autoresolve.test.d.ts +2 -0
- package/dist/test/schema-autoresolve.test.js +146 -0
- package/dist/test/sdk.test.js +11 -2
- package/dist/test/signup-bootstrap.test.d.ts +2 -0
- package/dist/test/signup-bootstrap.test.js +311 -0
- package/dist/test/transcribe-invoke.test.d.ts +2 -0
- package/dist/test/transcribe-invoke.test.js +204 -0
- package/dist/test/transcribe.test.d.ts +2 -0
- package/dist/test/transcribe.test.js +186 -0
- package/dist/test/web.test.d.ts +2 -0
- package/dist/test/web.test.js +270 -0
- package/package.json +20 -3
package/dist/src/data.js
ADDED
|
@@ -0,0 +1,1124 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Copyright 2026 Mathieu Colla
|
|
3
|
+
/**
|
|
4
|
+
* `sdk.data` — the Aithos data sub-protocol PDS as a plain database.
|
|
5
|
+
*
|
|
6
|
+
* Records (contacts, prospects, messages, …) live under a subject's identity,
|
|
7
|
+
* encrypted client-side, sealed under the subject's dedicated **`#data`**
|
|
8
|
+
* sphere. A developer never chooses a sphere, never handles a key — the SDK
|
|
9
|
+
* derives everything from the session. There are exactly two ways in, both off
|
|
10
|
+
* the auth session:
|
|
11
|
+
*
|
|
12
|
+
* // 1. As the OWNER (signed in) — your own database:
|
|
13
|
+
* const db = auth.data; // === auth.ownerDataClient()
|
|
14
|
+
* const id = await db.collection("contacts").insert({ name: "Jean" });
|
|
15
|
+
* const leads = await db.collection("contacts").list({ filter: { status: "lead" } });
|
|
16
|
+
*
|
|
17
|
+
* // 2. As a DELEGATE (you imported a mandate) — the subject's database:
|
|
18
|
+
* const db = auth.delegateDataClient(); // single active mandate; or { subjectDid }
|
|
19
|
+
* await db.collection("prospects").insert({ ... }); // needs data.prospects.write
|
|
20
|
+
*
|
|
21
|
+
* `auth.data` returns whichever applies to how you connected, with an identical
|
|
22
|
+
* CRUD surface. Owner-only operations (createCollection, authorizeDelegate,
|
|
23
|
+
* registerSchema) belong to the owner and throw `-32042` on the delegate path:
|
|
24
|
+
* the owner holds the CMK and decides who may access the collection (a one-time
|
|
25
|
+
* onboarding step), then the delegate does record CRUD bounded by its scope.
|
|
26
|
+
*
|
|
27
|
+
* The low-level `createDataClient` / `createDelegateDataClient` (which take a
|
|
28
|
+
* raw seed) are NOT exported from the package — they exist only as internals of
|
|
29
|
+
* the session accessors above. This is deliberate: passing a seed by hand is
|
|
30
|
+
* exactly what let an app seal data under the wrong key.
|
|
31
|
+
*
|
|
32
|
+
* Under the hood the module handles: envelope signing per spec §11, CMK / DEK
|
|
33
|
+
* lifecycle (generate, wrap, unwrap), per-record AEAD encryption, the split
|
|
34
|
+
* between indexable metadata (server-visible) and encrypted payload
|
|
35
|
+
* (server-blind), and JSON-RPC dispatch.
|
|
36
|
+
*
|
|
37
|
+
* Vendor schemas (`aithos.x.<vendor>.<name>.v<N>`) are passed via the
|
|
38
|
+
* `{ schemas: [...] }` option on the session accessor; the SDK uses them to
|
|
39
|
+
* split records into indexable metadata and encrypted payload.
|
|
40
|
+
*
|
|
41
|
+
* Spec ref: spec/data/01..10 of the aithos-protocol repo.
|
|
42
|
+
*/
|
|
43
|
+
import { x25519 } from "@noble/curves/ed25519.js";
|
|
44
|
+
import { hkdf } from "@noble/hashes/hkdf.js";
|
|
45
|
+
import { sha256, sha512 } from "@noble/hashes/sha2.js";
|
|
46
|
+
import { XChaCha20Poly1305 } from "@stablelib/xchacha20poly1305";
|
|
47
|
+
import * as ed from "@noble/ed25519";
|
|
48
|
+
import { multibaseToEd25519PublicKey, edPubToX25519Pub, } from "@aithos/protocol-client";
|
|
49
|
+
import { canonicalize } from "@aithos/protocol-core/canonical";
|
|
50
|
+
import { contactsV1 } from "./data-schema-contacts-v1.js";
|
|
51
|
+
import { DEFAULT_SDK_ENDPOINTS } from "./endpoints.js";
|
|
52
|
+
import { signOwnerEnvelope } from "./internal/envelope.js";
|
|
53
|
+
// noble/ed25519 v2 needs sha512 wired in for sync sign/verify
|
|
54
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
55
|
+
ed.etc.sha512Sync = (...m) =>
|
|
56
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
57
|
+
sha512(ed.etc.concatBytes(...m));
|
|
58
|
+
/* -------------------------------------------------------------------------- */
|
|
59
|
+
/* Schema registry (local) */
|
|
60
|
+
/* -------------------------------------------------------------------------- */
|
|
61
|
+
const SCHEMAS = new Map([
|
|
62
|
+
[contactsV1.schema, contactsV1],
|
|
63
|
+
]);
|
|
64
|
+
/* -------------------------------------------------------------------------- */
|
|
65
|
+
/* Public factory */
|
|
66
|
+
/* -------------------------------------------------------------------------- */
|
|
67
|
+
export function createDataClient(args) {
|
|
68
|
+
return new DataClientImpl(args);
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Build a data client that operates on a subject's collections under a
|
|
72
|
+
* mandate (delegate path). It signs every request as the delegate
|
|
73
|
+
* (bare-multibase verificationMethod + the mandate attached to the
|
|
74
|
+
* envelope) and decrypts/encrypts records using the CMK the owner
|
|
75
|
+
* re-wrapped for this delegate via {@link DataClient.authorizeDelegate}.
|
|
76
|
+
*
|
|
77
|
+
* Record CRUD is bounded by the mandate scope: reads need
|
|
78
|
+
* `data.<col>.read`, writes need `data.<col>.write` (or `.admin` /
|
|
79
|
+
* wildcard) — enforced client-side and by the PDS. Owner-only operations
|
|
80
|
+
* (createCollection, authorizeDelegate, revokeDelegate, registerSchema)
|
|
81
|
+
* always throw `-32042`: the owner holds the CMK and controls access.
|
|
82
|
+
*
|
|
83
|
+
* @internal Prefer the session accessor `auth.data` (owner) / the delegate
|
|
84
|
+
* session over hand-constructing this with a raw seed.
|
|
85
|
+
*/
|
|
86
|
+
export function createDelegateDataClient(args) {
|
|
87
|
+
const granteePubMb = args.granteePubkeyMultibase ??
|
|
88
|
+
args.mandate.grantee?.pubkey;
|
|
89
|
+
if (!granteePubMb) {
|
|
90
|
+
throw new Error("createDelegateDataClient: mandate.grantee.pubkey is missing; pass granteePubkeyMultibase explicitly");
|
|
91
|
+
}
|
|
92
|
+
return new DataClientImpl({
|
|
93
|
+
pdsUrl: args.pdsUrl,
|
|
94
|
+
did: args.subjectDid,
|
|
95
|
+
// In delegate mode the seed is used only to derive the X25519 key that
|
|
96
|
+
// unwraps the re-wrapped CMK; envelope signing goes through the
|
|
97
|
+
// delegate path (buildSignedEnvelope), never signOwnerEnvelope.
|
|
98
|
+
sphereSeed: args.delegateSeed,
|
|
99
|
+
verificationMethod: granteePubMb,
|
|
100
|
+
...(args.schemas ? { schemas: args.schemas } : {}),
|
|
101
|
+
...(args.fetch ? { fetch: args.fetch } : {}),
|
|
102
|
+
delegate: {
|
|
103
|
+
delegateSeed: args.delegateSeed,
|
|
104
|
+
granteePubkeyMultibase: granteePubMb,
|
|
105
|
+
mandate: args.mandate,
|
|
106
|
+
},
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Build an **append-only** data client from a `data.<collection>.append`
|
|
111
|
+
* mandate. The returned {@link AppendOnlyDataClient} can ONLY `insert`: it
|
|
112
|
+
* seals each record's DEK to the owner's public key (never the CMK), so it
|
|
113
|
+
* holds no read capability — it cannot decrypt anything in the collection,
|
|
114
|
+
* not even its own deposit. The PDS additionally enforces the append scope
|
|
115
|
+
* (insert allowed; get/list/update/delete refused).
|
|
116
|
+
*/
|
|
117
|
+
export function createAppendDataClient(args) {
|
|
118
|
+
const granteePubMb = args.granteePubkeyMultibase ??
|
|
119
|
+
args.mandate.grantee?.pubkey;
|
|
120
|
+
if (!granteePubMb) {
|
|
121
|
+
throw new Error("createAppendDataClient: mandate.grantee.pubkey is missing; pass granteePubkeyMultibase explicitly");
|
|
122
|
+
}
|
|
123
|
+
const ownerKexPublicKey = edPubToX25519Pub(multibaseToEd25519PublicKey(args.ownerDataPubkeyMultibase));
|
|
124
|
+
const impl = new DataClientImpl({
|
|
125
|
+
pdsUrl: args.pdsUrl,
|
|
126
|
+
did: args.subjectDid,
|
|
127
|
+
// In deposit mode the seed signs envelopes; it is NEVER used to unwrap a
|
|
128
|
+
// CMK (the deposit client has none).
|
|
129
|
+
sphereSeed: args.delegateSeed,
|
|
130
|
+
verificationMethod: granteePubMb,
|
|
131
|
+
schemas: [args.schema, ...(args.schemas ?? [])],
|
|
132
|
+
...(args.fetch ? { fetch: args.fetch } : {}),
|
|
133
|
+
deposit: {
|
|
134
|
+
delegateSeed: args.delegateSeed,
|
|
135
|
+
granteePubkeyMultibase: granteePubMb,
|
|
136
|
+
mandate: args.mandate,
|
|
137
|
+
ownerKexPublicKey,
|
|
138
|
+
ownerKexDidUrl: `${args.subjectDid}#data-kex`,
|
|
139
|
+
},
|
|
140
|
+
});
|
|
141
|
+
const defaultSchema = args.schema;
|
|
142
|
+
return {
|
|
143
|
+
collection(name) {
|
|
144
|
+
const state = impl._depositCollectionState(name, defaultSchema);
|
|
145
|
+
return {
|
|
146
|
+
name,
|
|
147
|
+
insert: (record) => impl._insertDeposit(state, record),
|
|
148
|
+
};
|
|
149
|
+
},
|
|
150
|
+
reset: () => impl.reset(),
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
class DataClientImpl {
|
|
154
|
+
#pdsUrl;
|
|
155
|
+
#did;
|
|
156
|
+
#seed;
|
|
157
|
+
#vm;
|
|
158
|
+
#fetch;
|
|
159
|
+
/** Delegate session, or undefined for the owner path. */
|
|
160
|
+
#delegate;
|
|
161
|
+
/** Deposit (append-only) session, or undefined. Mutually exclusive with
|
|
162
|
+
* {@link DataClientImpl.#delegate}. */
|
|
163
|
+
#deposit;
|
|
164
|
+
/**
|
|
165
|
+
* Per-client schema overrides, populated from `args.schemas` at
|
|
166
|
+
* construction. Looked up BEFORE the bundled SCHEMAS map (so an app
|
|
167
|
+
* can override a core schema locally if it really wants to, though
|
|
168
|
+
* the immutability rule of spec §3.5 strongly discourages this).
|
|
169
|
+
*/
|
|
170
|
+
#localSchemas;
|
|
171
|
+
// Per-collection CMK cache: cleared on reset()
|
|
172
|
+
#cmkCache = new Map();
|
|
173
|
+
#colCache = new Map();
|
|
174
|
+
constructor(args) {
|
|
175
|
+
this.#pdsUrl = (args.pdsUrl ?? DEFAULT_SDK_ENDPOINTS.pds).replace(/\/$/, "");
|
|
176
|
+
this.#did = args.did;
|
|
177
|
+
this.#seed = args.sphereSeed;
|
|
178
|
+
this.#vm = args.verificationMethod;
|
|
179
|
+
this.#fetch = args.fetch ?? globalThis.fetch.bind(globalThis);
|
|
180
|
+
if (args.delegate)
|
|
181
|
+
this.#delegate = args.delegate;
|
|
182
|
+
if (args.deposit)
|
|
183
|
+
this.#deposit = args.deposit;
|
|
184
|
+
this.#localSchemas = new Map((args.schemas ?? []).map((s) => [s.schema, s]));
|
|
185
|
+
}
|
|
186
|
+
/** Owner-only operations: collection lifecycle (create/ensure), delegate
|
|
187
|
+
* management (authorize/revoke) and schema registration. A delegate — even
|
|
188
|
+
* with a write mandate — cannot perform these: the owner holds the CMK and
|
|
189
|
+
* controls who may access the collection. The PDS enforces the same. */
|
|
190
|
+
#assertOwner(op) {
|
|
191
|
+
if (this.#delegate) {
|
|
192
|
+
const e = new Error(`sdk.data: "${op}" is owner-only. A delegate (even with a write mandate) cannot ` +
|
|
193
|
+
`create collections, authorize/revoke delegates, or register schemas — the owner ` +
|
|
194
|
+
`holds the CMK and controls access. Record CRUD (insert/get/list/update/delete) ` +
|
|
195
|
+
`is available to a delegate whose mandate carries the matching scope.`);
|
|
196
|
+
e.code = -32042;
|
|
197
|
+
throw e;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
/** Record-write guard. Owner: always allowed. Delegate: allowed iff the
|
|
201
|
+
* mandate carries a write/admin scope for this collection (data.<col>.write,
|
|
202
|
+
* data.*.write, data.<col>.admin, data.*.admin). The PDS enforces the same;
|
|
203
|
+
* this is the fast, precise local error. */
|
|
204
|
+
#assertCanWrite(op, collectionName) {
|
|
205
|
+
if (!this.#delegate)
|
|
206
|
+
return; // owner can always write
|
|
207
|
+
const scopes = this.#delegate.mandate.scopes ?? [];
|
|
208
|
+
const needed = [
|
|
209
|
+
`data.${collectionName}.write`,
|
|
210
|
+
`data.*.write`,
|
|
211
|
+
`data.${collectionName}.admin`,
|
|
212
|
+
`data.*.admin`,
|
|
213
|
+
];
|
|
214
|
+
const ok = scopes.some((s) => needed.includes(s.split(".").slice(0, 3).join(".")));
|
|
215
|
+
if (!ok) {
|
|
216
|
+
const e = new Error(`sdk.data: "${op}" on "${collectionName}" requires a data.${collectionName}.write ` +
|
|
217
|
+
`(or .admin / wildcard) scope. This mandate grants: [${scopes.join(", ")}].`);
|
|
218
|
+
e.code = -32042;
|
|
219
|
+
throw e;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* Resolve a schema id to its lite definition. App-supplied schemas
|
|
224
|
+
* (via `createDataClient({ schemas })`) take precedence over the
|
|
225
|
+
* SDK-bundled core registry.
|
|
226
|
+
*/
|
|
227
|
+
#resolveSchema(schemaId) {
|
|
228
|
+
return this.#localSchemas.get(schemaId) ?? SCHEMAS.get(schemaId) ?? null;
|
|
229
|
+
}
|
|
230
|
+
collection(name) {
|
|
231
|
+
return new DataCollectionImpl(this, name);
|
|
232
|
+
}
|
|
233
|
+
async authorizeDelegate(args) {
|
|
234
|
+
this.#assertOwner("authorizeDelegate");
|
|
235
|
+
const granteePubMb = args.mandate.grantee?.pubkey;
|
|
236
|
+
if (!granteePubMb) {
|
|
237
|
+
throw new Error("sdk.data.authorizeDelegate: mandate.grantee.pubkey is required (data read mandates bind to a grantee key).");
|
|
238
|
+
}
|
|
239
|
+
// Ensure we hold the collection's CMK (owner unwrap path).
|
|
240
|
+
const state = await this._ensureCollection(args.collectionName);
|
|
241
|
+
const cmk = this.#cmkCache.get(args.collectionName);
|
|
242
|
+
if (!cmk) {
|
|
243
|
+
throw new Error(`sdk.data.authorizeDelegate: CMK for "${args.collectionName}" not loaded`);
|
|
244
|
+
}
|
|
245
|
+
// Derive the grantee's X25519 wrap-recipient key from its Ed25519
|
|
246
|
+
// public key (the same birational map the owner uses for its own key).
|
|
247
|
+
const granteeEdPub = multibaseToEd25519PublicKey(granteePubMb);
|
|
248
|
+
const granteeX25519Pub = edPubToX25519Pub(granteeEdPub);
|
|
249
|
+
const recipientDidUrl = delegateRecipientDidUrl(granteePubMb);
|
|
250
|
+
const wrap = this.#wrapCmkForRecipient({
|
|
251
|
+
cmk,
|
|
252
|
+
recipientPublicKey: granteeX25519Pub,
|
|
253
|
+
recipientDidUrl,
|
|
254
|
+
collectionUrn: state.urn,
|
|
255
|
+
});
|
|
256
|
+
await this.#call("/mcp/primitives/write", "aithos.data.authorize_app", {
|
|
257
|
+
collection_urn: state.urn,
|
|
258
|
+
mandate: args.mandate,
|
|
259
|
+
wrap,
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
async revokeDelegate(args) {
|
|
263
|
+
this.#assertOwner("revokeDelegate");
|
|
264
|
+
await this.#call("/mcp/primitives/write", "aithos.data.revoke_app", {
|
|
265
|
+
collection_urn: this.#collectionUrn(args.collectionName),
|
|
266
|
+
mandate_id: args.mandateId,
|
|
267
|
+
...(args.reason ? { revocation: { reason: args.reason } } : {}),
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
async createCollection(args) {
|
|
271
|
+
this.#assertOwner("createCollection");
|
|
272
|
+
const cmk = randomBytes32();
|
|
273
|
+
const recipientDidUrl = `${this.#did}#data-kex`;
|
|
274
|
+
const collectionUrn = this.#collectionUrn(args.name);
|
|
275
|
+
const recipientPublic = ed25519SeedToX25519PublicKey(this.#seed);
|
|
276
|
+
const wrap = this.#wrapCmkForRecipient({
|
|
277
|
+
cmk,
|
|
278
|
+
recipientPublicKey: recipientPublic,
|
|
279
|
+
recipientDidUrl,
|
|
280
|
+
collectionUrn,
|
|
281
|
+
});
|
|
282
|
+
try {
|
|
283
|
+
await this.#call("/mcp/primitives/write", "aithos.data.create_collection", {
|
|
284
|
+
subject_did: this.#did,
|
|
285
|
+
collection_name: args.name,
|
|
286
|
+
schema: args.schema,
|
|
287
|
+
...(args.forwardSecrecy ? { forward_secrecy: args.forwardSecrecy } : {}),
|
|
288
|
+
cmk_envelope: {
|
|
289
|
+
alg: "xchacha20poly1305-ietf",
|
|
290
|
+
wraps: [wrap],
|
|
291
|
+
},
|
|
292
|
+
});
|
|
293
|
+
// Cache CMK in memory for subsequent ops on this collection.
|
|
294
|
+
this.#cmkCache.set(args.name, cmk);
|
|
295
|
+
}
|
|
296
|
+
finally {
|
|
297
|
+
// CMK is retained in cache, only zero the local var if we didn't cache.
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
async ensureCollection(args) {
|
|
301
|
+
this.#assertOwner("ensureCollection");
|
|
302
|
+
try {
|
|
303
|
+
await this.createCollection(args);
|
|
304
|
+
}
|
|
305
|
+
catch (e) {
|
|
306
|
+
// -32073 AITHOS_DATA_COLLECTION_EXISTS → already created (possibly by a
|
|
307
|
+
// concurrent caller). That's the success case for get-or-create. Any
|
|
308
|
+
// other error propagates.
|
|
309
|
+
if (e.code === -32073)
|
|
310
|
+
return;
|
|
311
|
+
throw e;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
async listCollections() {
|
|
315
|
+
const r = await this.#call("/mcp/primitives/read", "aithos.data.list_collections", {
|
|
316
|
+
subject_did: this.#did,
|
|
317
|
+
});
|
|
318
|
+
const items = r.items ?? [];
|
|
319
|
+
return items.map((c) => ({
|
|
320
|
+
name: c.name,
|
|
321
|
+
schema: c.schema,
|
|
322
|
+
record_count: c.record_count,
|
|
323
|
+
}));
|
|
324
|
+
}
|
|
325
|
+
async listGammaEntries(opts = {}) {
|
|
326
|
+
const params = { subject_did: this.#did };
|
|
327
|
+
if (opts.limit !== undefined)
|
|
328
|
+
params.limit = opts.limit;
|
|
329
|
+
if (opts.opPrefix)
|
|
330
|
+
params.op_prefix = opts.opPrefix;
|
|
331
|
+
if (opts.verify)
|
|
332
|
+
params.verify = true;
|
|
333
|
+
return this.#call("/mcp/primitives/read", "aithos.data.list_gamma_entries", params);
|
|
334
|
+
}
|
|
335
|
+
async registerSchema(schemaDoc) {
|
|
336
|
+
this.#assertOwner("registerSchema");
|
|
337
|
+
if (schemaDoc === null || typeof schemaDoc !== "object" || Array.isArray(schemaDoc)) {
|
|
338
|
+
throw new Error("sdk.data.registerSchema: schemaDoc must be a JSON object");
|
|
339
|
+
}
|
|
340
|
+
const r = (await this.#call("/mcp/primitives/write", "aithos.data.register_schema", {
|
|
341
|
+
subject_did: this.#did,
|
|
342
|
+
schema_doc: schemaDoc,
|
|
343
|
+
}));
|
|
344
|
+
return {
|
|
345
|
+
schemaId: r.schema_id,
|
|
346
|
+
docHash: r.doc_hash,
|
|
347
|
+
created: r.created,
|
|
348
|
+
...(r.created_at ? { createdAt: r.created_at } : {}),
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
async getSchema(schemaId, opts = {}) {
|
|
352
|
+
const params = { schema: schemaId };
|
|
353
|
+
// Vendor schemas always need a subject_did to address the right
|
|
354
|
+
// registry ; for core schemas the PDS ignores it but we still send
|
|
355
|
+
// ours so the auth check (envelope iss === subject_did) lines up.
|
|
356
|
+
params.subject_did = opts.subjectDid ?? this.#did;
|
|
357
|
+
try {
|
|
358
|
+
const r = (await this.#call("/mcp/primitives/read", "aithos.data.get_schema", params));
|
|
359
|
+
return r.schema;
|
|
360
|
+
}
|
|
361
|
+
catch (e) {
|
|
362
|
+
if (e.code === -32070)
|
|
363
|
+
return null;
|
|
364
|
+
throw e;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
reset() {
|
|
368
|
+
for (const k of this.#cmkCache.values())
|
|
369
|
+
k.fill(0);
|
|
370
|
+
this.#cmkCache.clear();
|
|
371
|
+
this.#colCache.clear();
|
|
372
|
+
}
|
|
373
|
+
/* -- Internals used by DataCollection -- */
|
|
374
|
+
async _ensureCollection(name) {
|
|
375
|
+
const cached = this.#colCache.get(name);
|
|
376
|
+
if (cached)
|
|
377
|
+
return cached;
|
|
378
|
+
// Fetch metadata from PDS
|
|
379
|
+
const meta = (await this.#call("/mcp/primitives/read", "aithos.data.get_collection", {
|
|
380
|
+
subject_did: this.#did,
|
|
381
|
+
collection_name: name,
|
|
382
|
+
}));
|
|
383
|
+
// Defensive structural validation — some PDS responses have been
|
|
384
|
+
// observed (alpha.27 era) returning a meta object that lacks
|
|
385
|
+
// `cmk_envelope` for missing collections, instead of the documented
|
|
386
|
+
// -32020 JSON-RPC error. Without this check, the next line crashes
|
|
387
|
+
// with "Cannot read properties of undefined (reading 'wraps')" which
|
|
388
|
+
// bypasses the upper-layer's missing-collection handling. We re-emit
|
|
389
|
+
// a clean -32020 here so callers can detect "collection not found"
|
|
390
|
+
// uniformly.
|
|
391
|
+
if (!meta ||
|
|
392
|
+
!meta.cmk_envelope ||
|
|
393
|
+
!Array.isArray(meta.cmk_envelope.wraps) ||
|
|
394
|
+
!meta.urn ||
|
|
395
|
+
!meta.schema) {
|
|
396
|
+
const err = new Error(`sdk.data: collection "${name}" not found or malformed ` +
|
|
397
|
+
`(meta missing required field). PDS returned: ${JSON.stringify(meta).slice(0, 200)}`);
|
|
398
|
+
err.code = -32020;
|
|
399
|
+
throw err;
|
|
400
|
+
}
|
|
401
|
+
// Look up our wrap and unwrap the CMK. Owner: the wrap addressed to
|
|
402
|
+
// `${did}#data-kex`, unwrapped with the owner sphere seed. Delegate:
|
|
403
|
+
// the wrap the owner re-wrapped for this grantee
|
|
404
|
+
// (`did:key:${granteePubkeyMultibase}#data-kex`), unwrapped with the
|
|
405
|
+
// delegate's own X25519 key (derived from its grantee seed).
|
|
406
|
+
const ourRecipient = this.#delegate
|
|
407
|
+
? delegateRecipientDidUrl(this.#delegate.granteePubkeyMultibase)
|
|
408
|
+
: `${this.#did}#data-kex`;
|
|
409
|
+
// A collection may carry MORE THAN ONE wrap under our recipient label —
|
|
410
|
+
// e.g. after the #data-sphere dual-read migration an owner collection holds
|
|
411
|
+
// both the legacy-sphere-sealed wrap (kept so the old app keeps reading) and
|
|
412
|
+
// a #data-sealed wrap (both labelled `${did}#data-kex`). The label alone
|
|
413
|
+
// can't tell them apart (it's fixed regardless of the sealing key), so we
|
|
414
|
+
// try EVERY matching wrap with our key and keep the one that actually
|
|
415
|
+
// decrypts. For the common single-wrap case this is identical to the old
|
|
416
|
+
// `find` + unwrap behaviour.
|
|
417
|
+
const matching = meta.cmk_envelope.wraps.filter((w) => w.recipient === ourRecipient);
|
|
418
|
+
if (matching.length === 0) {
|
|
419
|
+
throw new Error(this.#delegate
|
|
420
|
+
? `sdk.data: no CMK wrap for ${ourRecipient} in collection "${name}". ` +
|
|
421
|
+
`The owner has not authorized this delegate on the collection yet — ` +
|
|
422
|
+
`ask them to call sdk.data...authorizeDelegate({ collectionName, mandate }).`
|
|
423
|
+
: `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.`);
|
|
424
|
+
}
|
|
425
|
+
const unwrapSeed = this.#delegate ? this.#delegate.delegateSeed : this.#seed;
|
|
426
|
+
const privateKey = ed25519SeedToX25519PrivateKey(unwrapSeed);
|
|
427
|
+
let cmk;
|
|
428
|
+
for (const wrap of matching) {
|
|
429
|
+
try {
|
|
430
|
+
cmk = this.#unwrapCmk({ wrap, collectionUrn: meta.urn, privateKey });
|
|
431
|
+
break;
|
|
432
|
+
}
|
|
433
|
+
catch {
|
|
434
|
+
// Wrong wrap for this key (e.g. the legacy-sphere wrap when we hold the
|
|
435
|
+
// #data key, or vice-versa) — try the next same-label wrap.
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
if (!cmk) {
|
|
439
|
+
throw new Error(`sdk.data: found ${matching.length} CMK wrap(s) for ${ourRecipient} in collection ${name}, ` +
|
|
440
|
+
`but none could be unwrapped with this client's key. The collection may be sealed to a ` +
|
|
441
|
+
`different sphere/key than the one this client was constructed with.`);
|
|
442
|
+
}
|
|
443
|
+
this.#cmkCache.set(name, cmk);
|
|
444
|
+
let schema = this.#resolveSchema(meta.schema);
|
|
445
|
+
if (!schema) {
|
|
446
|
+
// Auto-resolve a PUBLISHED vendor schema from the PDS. This lets any
|
|
447
|
+
// up-to-date client read ANY collection whose owner registered its schema
|
|
448
|
+
// (via registerSchema) without the reading app having to bundle the lite
|
|
449
|
+
// locally — the published JSON Schema's `aithos:indexable` / `aithos:auto`
|
|
450
|
+
// annotations are the authoritative field split (they mirror the writer's
|
|
451
|
+
// lite by convention). Falls through to the error if nothing is published.
|
|
452
|
+
try {
|
|
453
|
+
const published = await this.getSchema(meta.schema, { subjectDid: this.#did });
|
|
454
|
+
if (published)
|
|
455
|
+
schema = liteFromPublishedSchema(published);
|
|
456
|
+
}
|
|
457
|
+
catch {
|
|
458
|
+
// network / not-found — leave `schema` undefined (reads still work)
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
// Do NOT throw when the schema is unknown: reads decrypt from the CMK +
|
|
462
|
+
// metadata/payload alone (the schema is only needed to SPLIT on write).
|
|
463
|
+
// Leaving `schema` undefined keeps the collection fully readable; an
|
|
464
|
+
// insert/update will surface a precise "schema required to write" error.
|
|
465
|
+
const state = { name, urn: meta.urn, ...(schema ? { schema } : {}), schemaId: meta.schema };
|
|
466
|
+
this.#colCache.set(name, state);
|
|
467
|
+
return state;
|
|
468
|
+
}
|
|
469
|
+
async _insert(state, record) {
|
|
470
|
+
this.#assertCanWrite("insert", state.name);
|
|
471
|
+
const cmk = this.#cmkCache.get(state.name);
|
|
472
|
+
if (!cmk)
|
|
473
|
+
throw new Error("CMK not loaded");
|
|
474
|
+
const { metadata, payload } = splitRecord(record, requireSchema(state));
|
|
475
|
+
const recordId = `record_${makeUlid()}`;
|
|
476
|
+
const encrypted = this.#encryptPayload({
|
|
477
|
+
collectionName: state.name,
|
|
478
|
+
recordId,
|
|
479
|
+
payload,
|
|
480
|
+
cmk,
|
|
481
|
+
});
|
|
482
|
+
const r = (await this.#call("/mcp/primitives/write", "aithos.data.insert_record", {
|
|
483
|
+
collection_urn: state.urn,
|
|
484
|
+
record_id: recordId,
|
|
485
|
+
metadata,
|
|
486
|
+
payload: encrypted,
|
|
487
|
+
}));
|
|
488
|
+
return r.record_id;
|
|
489
|
+
}
|
|
490
|
+
async _get(state, recordId) {
|
|
491
|
+
let raw;
|
|
492
|
+
try {
|
|
493
|
+
raw = await this.#call("/mcp/primitives/read", "aithos.data.get_record", {
|
|
494
|
+
collection_urn: state.urn,
|
|
495
|
+
record_id: recordId,
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
catch (e) {
|
|
499
|
+
if (e.code === -32020)
|
|
500
|
+
return null;
|
|
501
|
+
throw e;
|
|
502
|
+
}
|
|
503
|
+
return this.#decryptRecord(state, raw);
|
|
504
|
+
}
|
|
505
|
+
async _list(state, opts = {}) {
|
|
506
|
+
const params = {
|
|
507
|
+
collection_urn: state.urn,
|
|
508
|
+
};
|
|
509
|
+
if (opts.filter)
|
|
510
|
+
params.filter = opts.filter;
|
|
511
|
+
if (opts.order)
|
|
512
|
+
params.order = opts.order;
|
|
513
|
+
if (opts.limit !== undefined)
|
|
514
|
+
params.limit = opts.limit;
|
|
515
|
+
if (opts.cursor)
|
|
516
|
+
params.cursor = opts.cursor;
|
|
517
|
+
const r = (await this.#call("/mcp/primitives/read", "aithos.data.list_records", params));
|
|
518
|
+
// A read/write delegate (CMK-holder) cannot decrypt append-only deposits
|
|
519
|
+
// (sealed to the owner key). Skip them rather than crash the whole list —
|
|
520
|
+
// the owner reading the same collection decrypts everything. -32044 is the
|
|
521
|
+
// client-side "deposit unreadable by this session" marker.
|
|
522
|
+
const items = [];
|
|
523
|
+
for (const it of r.items) {
|
|
524
|
+
try {
|
|
525
|
+
items.push(this.#decryptRecord(state, it));
|
|
526
|
+
}
|
|
527
|
+
catch (e) {
|
|
528
|
+
if (e.code === -32044)
|
|
529
|
+
continue;
|
|
530
|
+
throw e;
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
return {
|
|
534
|
+
items,
|
|
535
|
+
...(r.next_cursor ? { nextCursor: r.next_cursor } : {}),
|
|
536
|
+
};
|
|
537
|
+
}
|
|
538
|
+
async _update(state, recordId, record) {
|
|
539
|
+
this.#assertCanWrite("update", state.name);
|
|
540
|
+
const cmk = this.#cmkCache.get(state.name);
|
|
541
|
+
if (!cmk)
|
|
542
|
+
throw new Error("CMK not loaded");
|
|
543
|
+
const { metadata, payload } = splitRecord(record, requireSchema(state));
|
|
544
|
+
const encrypted = this.#encryptPayload({
|
|
545
|
+
collectionName: state.name,
|
|
546
|
+
recordId,
|
|
547
|
+
payload,
|
|
548
|
+
cmk,
|
|
549
|
+
});
|
|
550
|
+
await this.#call("/mcp/primitives/write", "aithos.data.update_record", {
|
|
551
|
+
collection_urn: state.urn,
|
|
552
|
+
record_id: recordId,
|
|
553
|
+
metadata,
|
|
554
|
+
payload: encrypted,
|
|
555
|
+
});
|
|
556
|
+
}
|
|
557
|
+
async _delete(state, recordId) {
|
|
558
|
+
this.#assertCanWrite("delete", state.name);
|
|
559
|
+
await this.#call("/mcp/primitives/write", "aithos.data.delete_record", {
|
|
560
|
+
collection_urn: state.urn,
|
|
561
|
+
record_id: recordId,
|
|
562
|
+
});
|
|
563
|
+
}
|
|
564
|
+
/* -- JSON-RPC dispatch -- */
|
|
565
|
+
async #call(path, method, params) {
|
|
566
|
+
const aud = `${this.#pdsUrl}${path}`;
|
|
567
|
+
// Both paths use the SAME §11.2 envelope scheme (the data PDS verifies
|
|
568
|
+
// with @aithos/protocol-core, which canonicalizes the full envelope
|
|
569
|
+
// INCLUDING `proof` with proofValue=""). Delegate path: sign with the
|
|
570
|
+
// grantee key, bare-multibase verificationMethod, and attach the mandate
|
|
571
|
+
// so the PDS resolves the delegation and enforces its scopes.
|
|
572
|
+
// A mandate-bearing session (read-delegate OR append-deposit) signs as
|
|
573
|
+
// the grantee with the bare-multibase verificationMethod and attaches the
|
|
574
|
+
// mandate so the PDS resolves the delegation and enforces its scopes.
|
|
575
|
+
const mandateSession = this.#delegate ?? this.#deposit;
|
|
576
|
+
const envelope = mandateSession
|
|
577
|
+
? await signOwnerEnvelope({
|
|
578
|
+
iss: this.#did, // the SUBJECT DID (mandate issuer), not the delegate
|
|
579
|
+
aud,
|
|
580
|
+
method,
|
|
581
|
+
params,
|
|
582
|
+
verificationMethod: mandateSession.granteePubkeyMultibase,
|
|
583
|
+
signer: {
|
|
584
|
+
sign: async (msg) => ed.sign(msg, mandateSession.delegateSeed),
|
|
585
|
+
},
|
|
586
|
+
mandate: mandateSession.mandate,
|
|
587
|
+
})
|
|
588
|
+
: await signOwnerEnvelope({
|
|
589
|
+
iss: this.#did,
|
|
590
|
+
aud,
|
|
591
|
+
method,
|
|
592
|
+
params,
|
|
593
|
+
verificationMethod: this.#vm,
|
|
594
|
+
signer: { sign: async (msg) => ed.sign(msg, this.#seed) },
|
|
595
|
+
});
|
|
596
|
+
const body = {
|
|
597
|
+
jsonrpc: "2.0",
|
|
598
|
+
id: makeUlid(),
|
|
599
|
+
method,
|
|
600
|
+
params: { ...params, _envelope: envelope },
|
|
601
|
+
};
|
|
602
|
+
const r = await this.#fetch(aud, {
|
|
603
|
+
method: "POST",
|
|
604
|
+
headers: { "content-type": "application/json" },
|
|
605
|
+
body: JSON.stringify(body),
|
|
606
|
+
});
|
|
607
|
+
const json = (await r.json());
|
|
608
|
+
if (json.error) {
|
|
609
|
+
const err = new Error(json.error.message);
|
|
610
|
+
err.code = json.error.code;
|
|
611
|
+
err.data = json.error.data;
|
|
612
|
+
throw err;
|
|
613
|
+
}
|
|
614
|
+
return json.result ?? {};
|
|
615
|
+
}
|
|
616
|
+
/* -- Crypto helpers -- */
|
|
617
|
+
#collectionUrn(name) {
|
|
618
|
+
return `urn:aithos:collection:${this.#did}:${name}`;
|
|
619
|
+
}
|
|
620
|
+
#wrapCmkForRecipient(args) {
|
|
621
|
+
const ephSk = x25519.utils.randomSecretKey();
|
|
622
|
+
const ephPk = x25519.getPublicKey(ephSk);
|
|
623
|
+
const shared = x25519.getSharedSecret(ephSk, args.recipientPublicKey);
|
|
624
|
+
const wrapKey = hkdf(sha256, shared, utf8("aithos-data-cmk-wrap-v1"), utf8(args.recipientDidUrl), 32);
|
|
625
|
+
const wrapNonce = randomBytes24();
|
|
626
|
+
const aad = aadCmkWrap(args.collectionUrn, args.recipientDidUrl);
|
|
627
|
+
const aead = new XChaCha20Poly1305(wrapKey);
|
|
628
|
+
const wrapped = aead.seal(wrapNonce, args.cmk, aad);
|
|
629
|
+
wrapKey.fill(0);
|
|
630
|
+
shared.fill(0);
|
|
631
|
+
return {
|
|
632
|
+
recipient: args.recipientDidUrl,
|
|
633
|
+
alg: "x25519-hkdf-sha256-aead",
|
|
634
|
+
ephemeral_public: base64Std(ephPk),
|
|
635
|
+
wrap_nonce: base64Std(wrapNonce),
|
|
636
|
+
wrapped_key: base64Std(wrapped),
|
|
637
|
+
};
|
|
638
|
+
}
|
|
639
|
+
#unwrapCmk(args) {
|
|
640
|
+
const ephPk = fromBase64(args.wrap.ephemeral_public);
|
|
641
|
+
const wrapNonce = fromBase64(args.wrap.wrap_nonce);
|
|
642
|
+
const wrappedKey = fromBase64(args.wrap.wrapped_key);
|
|
643
|
+
const shared = x25519.getSharedSecret(args.privateKey, ephPk);
|
|
644
|
+
const wrapKey = hkdf(sha256, shared, utf8("aithos-data-cmk-wrap-v1"), utf8(args.wrap.recipient), 32);
|
|
645
|
+
const aad = aadCmkWrap(args.collectionUrn, args.wrap.recipient);
|
|
646
|
+
const aead = new XChaCha20Poly1305(wrapKey);
|
|
647
|
+
const cmk = aead.open(wrapNonce, wrappedKey, aad);
|
|
648
|
+
wrapKey.fill(0);
|
|
649
|
+
shared.fill(0);
|
|
650
|
+
if (!cmk)
|
|
651
|
+
throw new Error("sdk.data: CMK unwrap failed (wrong key or AAD mismatch)");
|
|
652
|
+
return cmk;
|
|
653
|
+
}
|
|
654
|
+
#encryptPayload(args) {
|
|
655
|
+
const dek = randomBytes32();
|
|
656
|
+
try {
|
|
657
|
+
const plaintext = new TextEncoder().encode(jcsCanonicalize(args.payload));
|
|
658
|
+
const nonce = randomBytes24();
|
|
659
|
+
const aad = aadRecord(this.#did, args.collectionName, args.recordId);
|
|
660
|
+
const aead = new XChaCha20Poly1305(dek);
|
|
661
|
+
const ciphertext = aead.seal(nonce, plaintext, aad);
|
|
662
|
+
const dekWrapNonce = randomBytes24();
|
|
663
|
+
const dekAad = aadDekWrap(this.#did, args.collectionName, args.recordId);
|
|
664
|
+
const dekAead = new XChaCha20Poly1305(args.cmk);
|
|
665
|
+
const wrapped = dekAead.seal(dekWrapNonce, dek, dekAad);
|
|
666
|
+
const dekWrap = new Uint8Array(dekWrapNonce.length + wrapped.length);
|
|
667
|
+
dekWrap.set(dekWrapNonce, 0);
|
|
668
|
+
dekWrap.set(wrapped, dekWrapNonce.length);
|
|
669
|
+
return {
|
|
670
|
+
alg: "xchacha20poly1305-ietf",
|
|
671
|
+
nonce: base64Std(nonce),
|
|
672
|
+
ciphertext: base64Std(ciphertext),
|
|
673
|
+
dek_wrapped_for_cmk: base64Std(dekWrap),
|
|
674
|
+
};
|
|
675
|
+
}
|
|
676
|
+
finally {
|
|
677
|
+
dek.fill(0);
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
/**
|
|
681
|
+
* Seal a record's payload for the append-only deposit path: encrypt under a
|
|
682
|
+
* fresh DEK, then seal that DEK to the OWNER's X25519 public key (never the
|
|
683
|
+
* CMK). Mirrors `@aithos/data-crypto` `wrapDEKForRecipient` byte-for-byte so
|
|
684
|
+
* the owner (SDK or CLI) can unwrap it. The depositor keeps no key material.
|
|
685
|
+
*/
|
|
686
|
+
#encryptPayloadForOwner(args) {
|
|
687
|
+
const dek = randomBytes32();
|
|
688
|
+
try {
|
|
689
|
+
const plaintext = new TextEncoder().encode(jcsCanonicalize(args.payload));
|
|
690
|
+
const nonce = randomBytes24();
|
|
691
|
+
const aad = aadRecord(this.#did, args.collectionName, args.recordId);
|
|
692
|
+
const ciphertext = new XChaCha20Poly1305(dek).seal(nonce, plaintext, aad);
|
|
693
|
+
// Seal the DEK to the owner's X25519 key (ECIES, fresh ephemeral).
|
|
694
|
+
const ephSk = x25519.utils.randomSecretKey();
|
|
695
|
+
const ephPk = x25519.getPublicKey(ephSk);
|
|
696
|
+
const shared = x25519.getSharedSecret(ephSk, args.ownerKexPublicKey);
|
|
697
|
+
const wrapKey = hkdf(sha256, shared, DEPOSIT_WRAP_SALT, utf8(args.ownerKexDidUrl), 32);
|
|
698
|
+
const wrapNonce = randomBytes24();
|
|
699
|
+
const wrapAad = aadDepositWrap(this.#did, args.collectionName, args.recordId, args.ownerKexDidUrl);
|
|
700
|
+
const wrappedKey = new XChaCha20Poly1305(wrapKey).seal(wrapNonce, dek, wrapAad);
|
|
701
|
+
wrapKey.fill(0);
|
|
702
|
+
shared.fill(0);
|
|
703
|
+
return {
|
|
704
|
+
alg: "xchacha20poly1305-ietf",
|
|
705
|
+
nonce: base64Std(nonce),
|
|
706
|
+
ciphertext: base64Std(ciphertext),
|
|
707
|
+
dek_wrapped_for_owner: {
|
|
708
|
+
recipient: args.ownerKexDidUrl,
|
|
709
|
+
alg: "x25519-hkdf-sha256-aead",
|
|
710
|
+
ephemeral_public: base64Std(ephPk),
|
|
711
|
+
wrap_nonce: base64Std(wrapNonce),
|
|
712
|
+
wrapped_key: base64Std(wrappedKey),
|
|
713
|
+
},
|
|
714
|
+
};
|
|
715
|
+
}
|
|
716
|
+
finally {
|
|
717
|
+
dek.fill(0);
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
#decryptRecord(state, raw) {
|
|
721
|
+
if (raw.deleted) {
|
|
722
|
+
// Soft-deleted record — payload was cleared.
|
|
723
|
+
return { ...raw.metadata, _deleted: true };
|
|
724
|
+
}
|
|
725
|
+
let dek;
|
|
726
|
+
if (raw.payload.dek_wrapped_for_owner) {
|
|
727
|
+
// Append-only deposit: the DEK is sealed to the OWNER's #data-kex key.
|
|
728
|
+
// Only the owner (no delegate/deposit session) can open it. A read
|
|
729
|
+
// delegate holds the CMK, not the owner key, so it must skip deposits.
|
|
730
|
+
if (this.#delegate || this.#deposit) {
|
|
731
|
+
const e = new Error("sdk.data: record is an append-only deposit — only the collection owner can decrypt it (this client holds a delegate/deposit mandate, not the owner key).");
|
|
732
|
+
e.code = -32044; // AITHOS_DATA_DEPOSIT_UNREADABLE (client-side)
|
|
733
|
+
throw e;
|
|
734
|
+
}
|
|
735
|
+
const w = raw.payload.dek_wrapped_for_owner;
|
|
736
|
+
const ownerKexSk = ed25519SeedToX25519PrivateKey(this.#seed);
|
|
737
|
+
try {
|
|
738
|
+
const ephPk = fromBase64(w.ephemeral_public);
|
|
739
|
+
const shared = x25519.getSharedSecret(ownerKexSk, ephPk);
|
|
740
|
+
const wrapKey = hkdf(sha256, shared, DEPOSIT_WRAP_SALT, utf8(w.recipient), 32);
|
|
741
|
+
const wrapAad = aadDepositWrap(this.#did, state.name, raw.record_id, w.recipient);
|
|
742
|
+
dek = new XChaCha20Poly1305(wrapKey).open(fromBase64(w.wrap_nonce), fromBase64(w.wrapped_key), wrapAad);
|
|
743
|
+
wrapKey.fill(0);
|
|
744
|
+
shared.fill(0);
|
|
745
|
+
}
|
|
746
|
+
finally {
|
|
747
|
+
ownerKexSk.fill(0);
|
|
748
|
+
}
|
|
749
|
+
if (!dek)
|
|
750
|
+
throw new Error("sdk.data: deposit DEK unwrap failed (wrong owner key or AAD mismatch)");
|
|
751
|
+
}
|
|
752
|
+
else {
|
|
753
|
+
// CMK path (owner / read-write delegate).
|
|
754
|
+
const cmk = this.#cmkCache.get(state.name);
|
|
755
|
+
if (!cmk) {
|
|
756
|
+
throw new Error("sdk.data: CMK not loaded — call ensureCollection first");
|
|
757
|
+
}
|
|
758
|
+
if (raw.payload.dek_wrapped_for_cmk === undefined) {
|
|
759
|
+
throw new Error("sdk.data: record payload has neither dek_wrapped_for_cmk nor dek_wrapped_for_owner");
|
|
760
|
+
}
|
|
761
|
+
const wrapBuf = fromBase64(raw.payload.dek_wrapped_for_cmk);
|
|
762
|
+
const wrapNonce = wrapBuf.slice(0, 24);
|
|
763
|
+
const wrapped = wrapBuf.slice(24);
|
|
764
|
+
const dekAad = aadDekWrap(this.#did, state.name, raw.record_id);
|
|
765
|
+
dek = new XChaCha20Poly1305(cmk).open(wrapNonce, wrapped, dekAad);
|
|
766
|
+
if (!dek)
|
|
767
|
+
throw new Error("sdk.data: DEK unwrap failed");
|
|
768
|
+
}
|
|
769
|
+
try {
|
|
770
|
+
const nonce = fromBase64(raw.payload.nonce);
|
|
771
|
+
const ciphertext = fromBase64(raw.payload.ciphertext);
|
|
772
|
+
const aad = aadRecord(this.#did, state.name, raw.record_id);
|
|
773
|
+
const aead = new XChaCha20Poly1305(dek);
|
|
774
|
+
const plaintext = aead.open(nonce, ciphertext, aad);
|
|
775
|
+
if (!plaintext)
|
|
776
|
+
throw new Error("sdk.data: payload decrypt failed");
|
|
777
|
+
const payload = JSON.parse(new TextDecoder().decode(plaintext));
|
|
778
|
+
return { record_id: raw.record_id, ...raw.metadata, ...payload };
|
|
779
|
+
}
|
|
780
|
+
finally {
|
|
781
|
+
dek.fill(0);
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
/**
|
|
785
|
+
* Append-only deposit insert. Used by the {@link createAppendDataClient}
|
|
786
|
+
* path: builds the record locally (no CMK, no server schema fetch — the
|
|
787
|
+
* caller supplies the schema), seals the DEK to the owner key, and POSTs
|
|
788
|
+
* `insert_record`. The PDS enforces the `data.<col>.append` scope.
|
|
789
|
+
*/
|
|
790
|
+
async _insertDeposit(state, record) {
|
|
791
|
+
if (!this.#deposit) {
|
|
792
|
+
throw new Error("sdk.data: _insertDeposit called without a deposit session");
|
|
793
|
+
}
|
|
794
|
+
const { metadata, payload } = splitRecord(record, requireSchema(state));
|
|
795
|
+
const recordId = `record_${makeUlid()}`;
|
|
796
|
+
const encrypted = this.#encryptPayloadForOwner({
|
|
797
|
+
collectionName: state.name,
|
|
798
|
+
recordId,
|
|
799
|
+
payload,
|
|
800
|
+
ownerKexPublicKey: this.#deposit.ownerKexPublicKey,
|
|
801
|
+
ownerKexDidUrl: this.#deposit.ownerKexDidUrl,
|
|
802
|
+
});
|
|
803
|
+
const r = (await this.#call("/mcp/primitives/write", "aithos.data.insert_record", {
|
|
804
|
+
collection_urn: state.urn,
|
|
805
|
+
record_id: recordId,
|
|
806
|
+
metadata,
|
|
807
|
+
payload: encrypted,
|
|
808
|
+
}));
|
|
809
|
+
return r.record_id;
|
|
810
|
+
}
|
|
811
|
+
/** Build a local collection state for the deposit path (no server fetch:
|
|
812
|
+
* append clients are not authorized to read collection metadata). */
|
|
813
|
+
_depositCollectionState(name, schema) {
|
|
814
|
+
return { name, urn: this.#collectionUrn(name), schema, schemaId: schema.schema };
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
class DataCollectionImpl {
|
|
818
|
+
client;
|
|
819
|
+
name;
|
|
820
|
+
constructor(client, name) {
|
|
821
|
+
this.client = client;
|
|
822
|
+
this.name = name;
|
|
823
|
+
}
|
|
824
|
+
async insert(record) {
|
|
825
|
+
const state = await this.client._ensureCollection(this.name);
|
|
826
|
+
return this.client._insert(state, record);
|
|
827
|
+
}
|
|
828
|
+
async get(recordId) {
|
|
829
|
+
const state = await this.client._ensureCollection(this.name);
|
|
830
|
+
return this.client._get(state, recordId);
|
|
831
|
+
}
|
|
832
|
+
async list(opts) {
|
|
833
|
+
const state = await this.client._ensureCollection(this.name);
|
|
834
|
+
return this.client._list(state, opts);
|
|
835
|
+
}
|
|
836
|
+
async update(recordId, record) {
|
|
837
|
+
const state = await this.client._ensureCollection(this.name);
|
|
838
|
+
return this.client._update(state, recordId, record);
|
|
839
|
+
}
|
|
840
|
+
async delete(recordId) {
|
|
841
|
+
const state = await this.client._ensureCollection(this.name);
|
|
842
|
+
return this.client._delete(state, recordId);
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
/* -------------------------------------------------------------------------- */
|
|
846
|
+
/* Record split (metadata vs payload) */
|
|
847
|
+
/* -------------------------------------------------------------------------- */
|
|
848
|
+
/**
|
|
849
|
+
* Derive an {@link AithosSchemaLite} from a PUBLISHED JSON Schema document (the
|
|
850
|
+
* shape `aithos.data.get_schema` / `registerSchema` round-trip). The field
|
|
851
|
+
* split is read from the per-property annotations:
|
|
852
|
+
* - `aithos:indexable: true` → indexable (server-visible, filter/sort)
|
|
853
|
+
* - `aithos:auto: …` → auto (server-populated, e.g. created_at)
|
|
854
|
+
* - anything else → encrypted (AEAD'd client-side)
|
|
855
|
+
*
|
|
856
|
+
* These annotations are authoritative — by convention they mirror the writer's
|
|
857
|
+
* own lite — so a reader that never bundled the schema can still split records
|
|
858
|
+
* correctly. `defaults` is left empty (it only matters for inserts; the writer
|
|
859
|
+
* supplies its own).
|
|
860
|
+
*/
|
|
861
|
+
export function liteFromPublishedSchema(doc) {
|
|
862
|
+
const d = doc;
|
|
863
|
+
const props = d.properties ?? {};
|
|
864
|
+
const indexable = new Set();
|
|
865
|
+
const encrypted = new Set();
|
|
866
|
+
const auto = new Set();
|
|
867
|
+
for (const [k, v] of Object.entries(props)) {
|
|
868
|
+
const hasAuto = v?.["aithos:auto"] !== undefined;
|
|
869
|
+
const isIndexable = v?.["aithos:indexable"] === true;
|
|
870
|
+
if (hasAuto)
|
|
871
|
+
auto.add(k);
|
|
872
|
+
if (isIndexable)
|
|
873
|
+
indexable.add(k);
|
|
874
|
+
if (!isIndexable && !hasAuto)
|
|
875
|
+
encrypted.add(k);
|
|
876
|
+
}
|
|
877
|
+
return {
|
|
878
|
+
schema: d["aithos:schema"] ?? "",
|
|
879
|
+
indexable,
|
|
880
|
+
encrypted,
|
|
881
|
+
auto,
|
|
882
|
+
defaults: {},
|
|
883
|
+
};
|
|
884
|
+
}
|
|
885
|
+
/**
|
|
886
|
+
* Return the collection's write schema or throw a precise error. Writes need
|
|
887
|
+
* the schema to split a record into indexable metadata vs encrypted payload (and
|
|
888
|
+
* so the PDS can validate); reads never call this.
|
|
889
|
+
*/
|
|
890
|
+
function requireSchema(state) {
|
|
891
|
+
if (!state.schema) {
|
|
892
|
+
const e = new Error(`sdk.data: writing to "${state.name}" needs its schema "${state.schemaId}", which is ` +
|
|
893
|
+
`neither bundled in this client (createDataClient({ schemas: [...] })) nor published on ` +
|
|
894
|
+
`the PDS (registerSchema). Reads work without it — only inserts/updates require it.`);
|
|
895
|
+
e.code = -32602;
|
|
896
|
+
throw e;
|
|
897
|
+
}
|
|
898
|
+
return state.schema;
|
|
899
|
+
}
|
|
900
|
+
function splitRecord(record, schema) {
|
|
901
|
+
const metadata = {};
|
|
902
|
+
const payload = {};
|
|
903
|
+
for (const [k, v] of Object.entries(record)) {
|
|
904
|
+
if (schema.auto.has(k))
|
|
905
|
+
continue; // server-set
|
|
906
|
+
if (schema.indexable.has(k)) {
|
|
907
|
+
metadata[k] = v;
|
|
908
|
+
}
|
|
909
|
+
else if (schema.encrypted.has(k)) {
|
|
910
|
+
payload[k] = v;
|
|
911
|
+
}
|
|
912
|
+
else {
|
|
913
|
+
// Unknown field — drop. Server will reject in any case (additionalProperties: false).
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
return { metadata, payload };
|
|
917
|
+
}
|
|
918
|
+
/* -------------------------------------------------------------------------- */
|
|
919
|
+
/* Crypto helpers */
|
|
920
|
+
/* -------------------------------------------------------------------------- */
|
|
921
|
+
function randomBytes32() {
|
|
922
|
+
return cryptoRandom(32);
|
|
923
|
+
}
|
|
924
|
+
function randomBytes24() {
|
|
925
|
+
return cryptoRandom(24);
|
|
926
|
+
}
|
|
927
|
+
/** Cross-platform CSPRNG: Web Crypto in browser, Node WebCrypto in Node 19+. */
|
|
928
|
+
function cryptoRandom(n) {
|
|
929
|
+
const buf = new Uint8Array(n);
|
|
930
|
+
// globalThis.crypto exists in browsers and in Node 19+
|
|
931
|
+
globalThis.crypto?.getRandomValues(buf);
|
|
932
|
+
return buf;
|
|
933
|
+
}
|
|
934
|
+
function utf8(s) {
|
|
935
|
+
return new TextEncoder().encode(s);
|
|
936
|
+
}
|
|
937
|
+
/**
|
|
938
|
+
* Recipient DID URL used for a delegate's CMK wrap. Built from the
|
|
939
|
+
* grantee's Ed25519 public-key multibase so that (a) the owner side
|
|
940
|
+
* (`authorizeDelegate`) and the delegate side (`_ensureCollection`)
|
|
941
|
+
* derive the EXACT same string — it's bound into the wrap AAD and the
|
|
942
|
+
* HKDF info, so any mismatch fails the unwrap — and (b) the string
|
|
943
|
+
* contains `mandate.grantee.pubkey`, which the PDS `authorize_app`
|
|
944
|
+
* handler requires (`wrap.recipient.includes(grantee.pubkey)`).
|
|
945
|
+
*/
|
|
946
|
+
function delegateRecipientDidUrl(granteePubkeyMultibase) {
|
|
947
|
+
return `did:key:${granteePubkeyMultibase}#data-kex`;
|
|
948
|
+
}
|
|
949
|
+
function aadCmkWrap(collectionUrn, recipient) {
|
|
950
|
+
const p = utf8("aithos-data-cmk-v1\0");
|
|
951
|
+
const c = utf8(collectionUrn);
|
|
952
|
+
const sep = new Uint8Array([0]);
|
|
953
|
+
const r = utf8(recipient);
|
|
954
|
+
const out = new Uint8Array(p.length + c.length + sep.length + r.length);
|
|
955
|
+
let off = 0;
|
|
956
|
+
out.set(p, off);
|
|
957
|
+
off += p.length;
|
|
958
|
+
out.set(c, off);
|
|
959
|
+
off += c.length;
|
|
960
|
+
out.set(sep, off);
|
|
961
|
+
off += sep.length;
|
|
962
|
+
out.set(r, off);
|
|
963
|
+
return out;
|
|
964
|
+
}
|
|
965
|
+
function aadDekWrap(subjectDid, collectionName, recordId) {
|
|
966
|
+
const p = utf8("aithos-data-dek-v1\0");
|
|
967
|
+
return concat3WithSeps(p, subjectDid, collectionName, recordId);
|
|
968
|
+
}
|
|
969
|
+
function aadRecord(subjectDid, collectionName, recordId) {
|
|
970
|
+
const p = utf8("aithos-data-record-v1\0");
|
|
971
|
+
return concat3WithSeps(p, subjectDid, collectionName, recordId);
|
|
972
|
+
}
|
|
973
|
+
/**
|
|
974
|
+
* HKDF salt for the append-only deposit DEK wrap. Distinct from the CMK wrap
|
|
975
|
+
* salt so the two key-derivation domains never collide. MUST match
|
|
976
|
+
* `@aithos/data-crypto` `DEPOSIT_WRAP_SALT`.
|
|
977
|
+
*/
|
|
978
|
+
const DEPOSIT_WRAP_SALT = utf8("aithos-data-dek-deposit-wrap-v1");
|
|
979
|
+
/**
|
|
980
|
+
* AAD for the deposit DEK wrap:
|
|
981
|
+
* "aithos-data-dek-deposit-v1\0" ‖ subject ‖ \0 ‖ collection ‖ \0 ‖
|
|
982
|
+
* record ‖ \0 ‖ recipient_did_url
|
|
983
|
+
* MUST match `@aithos/data-crypto` `aadForDepositWrap`.
|
|
984
|
+
*/
|
|
985
|
+
function aadDepositWrap(subjectDid, collectionName, recordId, recipientDidUrl) {
|
|
986
|
+
const prefix = utf8("aithos-data-dek-deposit-v1\0");
|
|
987
|
+
const parts = [subjectDid, collectionName, recordId, recipientDidUrl].map(utf8);
|
|
988
|
+
const sep = new Uint8Array([0]);
|
|
989
|
+
let total = prefix.length;
|
|
990
|
+
for (let i = 0; i < parts.length; i++) {
|
|
991
|
+
total += parts[i].length + (i < parts.length - 1 ? sep.length : 0);
|
|
992
|
+
}
|
|
993
|
+
const out = new Uint8Array(total);
|
|
994
|
+
let off = 0;
|
|
995
|
+
out.set(prefix, off);
|
|
996
|
+
off += prefix.length;
|
|
997
|
+
for (let i = 0; i < parts.length; i++) {
|
|
998
|
+
out.set(parts[i], off);
|
|
999
|
+
off += parts[i].length;
|
|
1000
|
+
if (i < parts.length - 1) {
|
|
1001
|
+
out.set(sep, off);
|
|
1002
|
+
off += sep.length;
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
return out;
|
|
1006
|
+
}
|
|
1007
|
+
function concat3WithSeps(prefix, a, b, c) {
|
|
1008
|
+
const aa = utf8(a);
|
|
1009
|
+
const bb = utf8(b);
|
|
1010
|
+
const cc = utf8(c);
|
|
1011
|
+
const sep = new Uint8Array([0]);
|
|
1012
|
+
const total = prefix.length + aa.length + sep.length + bb.length + sep.length + cc.length;
|
|
1013
|
+
const out = new Uint8Array(total);
|
|
1014
|
+
let off = 0;
|
|
1015
|
+
out.set(prefix, off);
|
|
1016
|
+
off += prefix.length;
|
|
1017
|
+
out.set(aa, off);
|
|
1018
|
+
off += aa.length;
|
|
1019
|
+
out.set(sep, off);
|
|
1020
|
+
off += sep.length;
|
|
1021
|
+
out.set(bb, off);
|
|
1022
|
+
off += bb.length;
|
|
1023
|
+
out.set(sep, off);
|
|
1024
|
+
off += sep.length;
|
|
1025
|
+
out.set(cc, off);
|
|
1026
|
+
return out;
|
|
1027
|
+
}
|
|
1028
|
+
/**
|
|
1029
|
+
* Derive a 32-byte X25519 private key from an Ed25519 seed via SHA-512
|
|
1030
|
+
* truncation + clamping. Mirrors libsodium's
|
|
1031
|
+
* crypto_sign_ed25519_sk_to_curve25519 for the secret-key side. Used so
|
|
1032
|
+
* a single Ed25519 sphere seed can both sign envelopes and key-agree
|
|
1033
|
+
* with mandate recipients.
|
|
1034
|
+
*/
|
|
1035
|
+
function ed25519SeedToX25519PrivateKey(seed) {
|
|
1036
|
+
if (seed.length !== 32)
|
|
1037
|
+
throw new Error("Ed25519 seed must be 32 bytes");
|
|
1038
|
+
const h = sha512(seed);
|
|
1039
|
+
const sk = new Uint8Array(h.slice(0, 32));
|
|
1040
|
+
// Clamp per X25519 spec
|
|
1041
|
+
sk[0] = sk[0] & 248;
|
|
1042
|
+
sk[31] = sk[31] & 127;
|
|
1043
|
+
sk[31] = sk[31] | 64;
|
|
1044
|
+
return sk;
|
|
1045
|
+
}
|
|
1046
|
+
function ed25519SeedToX25519PublicKey(seed) {
|
|
1047
|
+
const sk = ed25519SeedToX25519PrivateKey(seed);
|
|
1048
|
+
try {
|
|
1049
|
+
return x25519.getPublicKey(sk);
|
|
1050
|
+
}
|
|
1051
|
+
finally {
|
|
1052
|
+
sk.fill(0);
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
function makeUlid() {
|
|
1056
|
+
// Lightweight ULID — millisecond timestamp + 80 bits of randomness.
|
|
1057
|
+
// Crockford base32. For tests this is sufficient; production uses
|
|
1058
|
+
// the canonical ulid package.
|
|
1059
|
+
const tsBuf = new Uint8Array(6);
|
|
1060
|
+
let ts = Date.now();
|
|
1061
|
+
for (let i = 5; i >= 0; i--) {
|
|
1062
|
+
tsBuf[i] = ts & 0xff;
|
|
1063
|
+
ts = Math.floor(ts / 256);
|
|
1064
|
+
}
|
|
1065
|
+
const rndBuf = cryptoRandom(10);
|
|
1066
|
+
const all = new Uint8Array(16);
|
|
1067
|
+
all.set(tsBuf, 0);
|
|
1068
|
+
all.set(rndBuf, 6);
|
|
1069
|
+
const alphabet = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
|
|
1070
|
+
let bits = 0;
|
|
1071
|
+
let value = 0;
|
|
1072
|
+
let out = "";
|
|
1073
|
+
for (const b of all) {
|
|
1074
|
+
value = (value << 8) | b;
|
|
1075
|
+
bits += 8;
|
|
1076
|
+
while (bits >= 5) {
|
|
1077
|
+
out += alphabet[(value >> (bits - 5)) & 0x1f];
|
|
1078
|
+
bits -= 5;
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
if (bits > 0)
|
|
1082
|
+
out += alphabet[(value << (5 - bits)) & 0x1f];
|
|
1083
|
+
return out.slice(0, 26);
|
|
1084
|
+
}
|
|
1085
|
+
/** Standard base64 (with `=` padding). Browser + Node compatible. */
|
|
1086
|
+
function base64Std(bytes) {
|
|
1087
|
+
let bin = "";
|
|
1088
|
+
for (let i = 0; i < bytes.length; i++)
|
|
1089
|
+
bin += String.fromCharCode(bytes[i]);
|
|
1090
|
+
return btoa(bin);
|
|
1091
|
+
}
|
|
1092
|
+
/** base64url (URL-safe, no padding). */
|
|
1093
|
+
function base64url(bytes) {
|
|
1094
|
+
return base64Std(bytes).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
1095
|
+
}
|
|
1096
|
+
function fromBase64(s) {
|
|
1097
|
+
// Accept either standard base64 or base64url
|
|
1098
|
+
const std = s.replace(/-/g, "+").replace(/_/g, "/");
|
|
1099
|
+
const padded = std + "=".repeat((4 - (std.length % 4)) % 4);
|
|
1100
|
+
const bin = atob(padded);
|
|
1101
|
+
const out = new Uint8Array(bin.length);
|
|
1102
|
+
for (let i = 0; i < bin.length; i++)
|
|
1103
|
+
out[i] = bin.charCodeAt(i);
|
|
1104
|
+
return out;
|
|
1105
|
+
}
|
|
1106
|
+
function sha256Hex(s) {
|
|
1107
|
+
const d = sha256(new TextEncoder().encode(s));
|
|
1108
|
+
let hex = "";
|
|
1109
|
+
for (const b of d)
|
|
1110
|
+
hex += b.toString(16).padStart(2, "0");
|
|
1111
|
+
return hex;
|
|
1112
|
+
}
|
|
1113
|
+
/* -------------------------------------------------------------------------- */
|
|
1114
|
+
/* JCS-style canonicalization (RFC 8785 subset) */
|
|
1115
|
+
/* -------------------------------------------------------------------------- */
|
|
1116
|
+
// Single canonicalization source of truth: @aithos/protocol-core. Kept as a
|
|
1117
|
+
// local alias so the encryption-AAD call sites read naturally; the byte output
|
|
1118
|
+
// is proven identical to the former hand-rolled JCS by
|
|
1119
|
+
// test/canonical-conformance.test.ts (critical: this feeds pre-encryption
|
|
1120
|
+
// canonicalization, so any drift would corrupt data).
|
|
1121
|
+
function jcsCanonicalize(value) {
|
|
1122
|
+
return canonicalize(value);
|
|
1123
|
+
}
|
|
1124
|
+
//# sourceMappingURL=data.js.map
|