@aithos/sdk 0.1.0-alpha.24 → 0.1.0-alpha.26
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/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 +97 -0
- package/dist/src/data.js +616 -0
- package/dist/src/index.d.ts +3 -1
- package/dist/src/index.js +5 -1
- package/package.json +1 -1
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bundled copy of the `aithos.contacts.v1` schema.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors `spec/data/schemas/aithos.contacts.v1.json` of the
|
|
5
|
+
* Aithos-protocol repo. The SDK uses this to split a record into its
|
|
6
|
+
* indexable (metadata) and encrypted (payload) parts on insert and to
|
|
7
|
+
* recombine them on read.
|
|
8
|
+
*
|
|
9
|
+
* In a later iteration the SDK will fetch / resolve schemas dynamically
|
|
10
|
+
* from a registry; for v0.1 we bundle the core schemas.
|
|
11
|
+
*/
|
|
12
|
+
import type { AithosSchemaLite } from "./data.js";
|
|
13
|
+
export declare const contactsV1: AithosSchemaLite;
|
|
14
|
+
//# sourceMappingURL=data-schema-contacts-v1.d.ts.map
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Copyright 2026 Mathieu Colla
|
|
3
|
+
export const contactsV1 = {
|
|
4
|
+
schema: "aithos.contacts.v1",
|
|
5
|
+
indexable: new Set([
|
|
6
|
+
"name",
|
|
7
|
+
"email",
|
|
8
|
+
"phone_hash",
|
|
9
|
+
"status",
|
|
10
|
+
"tags",
|
|
11
|
+
"source",
|
|
12
|
+
"created_at",
|
|
13
|
+
"modified_at",
|
|
14
|
+
"last_contacted_at",
|
|
15
|
+
]),
|
|
16
|
+
encrypted: new Set([
|
|
17
|
+
"phone",
|
|
18
|
+
"notes",
|
|
19
|
+
"conversation_log",
|
|
20
|
+
"form_responses",
|
|
21
|
+
"custom_fields",
|
|
22
|
+
]),
|
|
23
|
+
auto: new Set(["created_at", "modified_at"]),
|
|
24
|
+
defaults: {
|
|
25
|
+
status: "lead",
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
//# sourceMappingURL=data-schema-contacts-v1.js.map
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
export interface AithosSchemaLite {
|
|
2
|
+
readonly schema: string;
|
|
3
|
+
readonly indexable: ReadonlySet<string>;
|
|
4
|
+
readonly encrypted: ReadonlySet<string>;
|
|
5
|
+
readonly auto: ReadonlySet<string>;
|
|
6
|
+
readonly defaults: Readonly<Record<string, unknown>>;
|
|
7
|
+
}
|
|
8
|
+
export interface CreateDataClientArgs {
|
|
9
|
+
/** Base URL of the deployed PDS (e.g. https://abc.execute-api.eu-west-3.amazonaws.com). */
|
|
10
|
+
readonly pdsUrl: string;
|
|
11
|
+
/** Subject DID — typically did:key:… in dev, did:aithos:… in prod. */
|
|
12
|
+
readonly did: string;
|
|
13
|
+
/**
|
|
14
|
+
* Ed25519 sphere seed (32 bytes). For did:key this is the same as
|
|
15
|
+
* the key embedded in the DID itself. For did:aithos this is the
|
|
16
|
+
* subject's `#data` (or `#public`) sphere key.
|
|
17
|
+
*/
|
|
18
|
+
readonly sphereSeed: Uint8Array;
|
|
19
|
+
/**
|
|
20
|
+
* The verification method URL within the DID document used to sign
|
|
21
|
+
* envelopes. For did:key this is `<did>#<multibase>`. For did:aithos
|
|
22
|
+
* this is `<did>#data` (or `#public`).
|
|
23
|
+
*/
|
|
24
|
+
readonly verificationMethod: string;
|
|
25
|
+
/** Optional fetch implementation. Defaults to globalThis.fetch. */
|
|
26
|
+
readonly fetch?: typeof fetch;
|
|
27
|
+
}
|
|
28
|
+
export interface DataClient {
|
|
29
|
+
/** Get / create a collection handle. */
|
|
30
|
+
collection(name: string): DataCollection;
|
|
31
|
+
/** Initialize a new collection with an explicit schema. */
|
|
32
|
+
createCollection(args: {
|
|
33
|
+
name: string;
|
|
34
|
+
schema: string;
|
|
35
|
+
forwardSecrecy?: "best_effort" | "strict";
|
|
36
|
+
}): Promise<void>;
|
|
37
|
+
/** List collections owned by this subject. */
|
|
38
|
+
listCollections(): Promise<readonly {
|
|
39
|
+
name: string;
|
|
40
|
+
schema: string;
|
|
41
|
+
record_count: number;
|
|
42
|
+
}[]>;
|
|
43
|
+
/** List gamma audit entries. */
|
|
44
|
+
listGammaEntries(opts?: {
|
|
45
|
+
limit?: number;
|
|
46
|
+
opPrefix?: string;
|
|
47
|
+
verify?: boolean;
|
|
48
|
+
}): Promise<unknown>;
|
|
49
|
+
/** Drop in-memory cache (CMK, collection metadata, …). */
|
|
50
|
+
reset(): void;
|
|
51
|
+
}
|
|
52
|
+
export interface DataCollection {
|
|
53
|
+
readonly name: string;
|
|
54
|
+
/**
|
|
55
|
+
* Insert a record. The object MAY contain both indexable and
|
|
56
|
+
* encrypted fields per the schema; the SDK splits them.
|
|
57
|
+
*/
|
|
58
|
+
insert(record: Record<string, unknown>): Promise<string>;
|
|
59
|
+
/** Fetch one record by id (decrypted client-side). */
|
|
60
|
+
get(recordId: string): Promise<Record<string, unknown> | null>;
|
|
61
|
+
/** List records, decrypted. Pagination via opaque cursor. */
|
|
62
|
+
list(opts?: ListOpts): Promise<{
|
|
63
|
+
items: Record<string, unknown>[];
|
|
64
|
+
nextCursor?: string;
|
|
65
|
+
}>;
|
|
66
|
+
/**
|
|
67
|
+
* Replace a record. Same shape as insert; the SDK splits indexable
|
|
68
|
+
* vs encrypted again per the schema.
|
|
69
|
+
*/
|
|
70
|
+
update(recordId: string, record: Record<string, unknown>): Promise<void>;
|
|
71
|
+
/** Soft-delete a record. */
|
|
72
|
+
delete(recordId: string): Promise<void>;
|
|
73
|
+
}
|
|
74
|
+
export interface ListOpts {
|
|
75
|
+
readonly filter?: {
|
|
76
|
+
readonly equals?: {
|
|
77
|
+
field: string;
|
|
78
|
+
value: unknown;
|
|
79
|
+
};
|
|
80
|
+
readonly contains?: {
|
|
81
|
+
field: string;
|
|
82
|
+
value: string;
|
|
83
|
+
};
|
|
84
|
+
readonly tagsAny?: readonly string[];
|
|
85
|
+
readonly tagsAll?: readonly string[];
|
|
86
|
+
readonly range?: {
|
|
87
|
+
field: string;
|
|
88
|
+
gte?: string;
|
|
89
|
+
lte?: string;
|
|
90
|
+
};
|
|
91
|
+
};
|
|
92
|
+
readonly order?: "newest" | "oldest";
|
|
93
|
+
readonly limit?: number;
|
|
94
|
+
readonly cursor?: string;
|
|
95
|
+
}
|
|
96
|
+
export declare function createDataClient(args: CreateDataClientArgs): DataClient;
|
|
97
|
+
//# sourceMappingURL=data.d.ts.map
|
package/dist/src/data.js
ADDED
|
@@ -0,0 +1,616 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Copyright 2026 Mathieu Colla
|
|
3
|
+
/**
|
|
4
|
+
* `sdk.data` — high-level API for the Aithos data sub-protocol PDS.
|
|
5
|
+
*
|
|
6
|
+
* The Aithos data sub-protocol stores operational records (contacts,
|
|
7
|
+
* messages, ...) under a subject's identity, encrypted client-side,
|
|
8
|
+
* accessible to authorized apps via signed mandates. This module is the
|
|
9
|
+
* ergonomic façade developers consume:
|
|
10
|
+
*
|
|
11
|
+
* const client = createDataClient({ pdsUrl, did, sphereSeed });
|
|
12
|
+
* const contacts = client.collection("contacts");
|
|
13
|
+
* const id = await contacts.insert({ name: "Jean", phone: "+33..." });
|
|
14
|
+
* const list = await contacts.list({ filter: { status: "lead" } });
|
|
15
|
+
*
|
|
16
|
+
* Under the hood the module handles: envelope signing per spec §11,
|
|
17
|
+
* CMK / DEK lifecycle (generate, wrap, unwrap), per-record AEAD
|
|
18
|
+
* encryption, split between indexable metadata (server-visible) and
|
|
19
|
+
* encrypted payload (server-blind), JSON-RPC dispatch.
|
|
20
|
+
*
|
|
21
|
+
* It does not (yet) handle: mandate-delegate authentication on the
|
|
22
|
+
* caller side (the SDK only signs as owner in v0.1), schema
|
|
23
|
+
* publication (only the bundled `aithos.contacts.v1` is recognized),
|
|
24
|
+
* forward-secrecy CMK rotation primitives. Those land in later
|
|
25
|
+
* Sub-jalons.
|
|
26
|
+
*
|
|
27
|
+
* Spec ref: spec/data/01..10 of the aithos-protocol repo.
|
|
28
|
+
*/
|
|
29
|
+
import { x25519 } from "@noble/curves/ed25519.js";
|
|
30
|
+
import { hkdf } from "@noble/hashes/hkdf.js";
|
|
31
|
+
import { sha256, sha512 } from "@noble/hashes/sha2.js";
|
|
32
|
+
import { XChaCha20Poly1305 } from "@stablelib/xchacha20poly1305";
|
|
33
|
+
import * as ed from "@noble/ed25519";
|
|
34
|
+
import { contactsV1 } from "./data-schema-contacts-v1.js";
|
|
35
|
+
// noble/ed25519 v2 needs sha512 wired in for sync sign/verify
|
|
36
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
37
|
+
ed.etc.sha512Sync = (...m) =>
|
|
38
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
39
|
+
sha512(ed.etc.concatBytes(...m));
|
|
40
|
+
/* -------------------------------------------------------------------------- */
|
|
41
|
+
/* Schema registry (local) */
|
|
42
|
+
/* -------------------------------------------------------------------------- */
|
|
43
|
+
const SCHEMAS = new Map([
|
|
44
|
+
[contactsV1.schema, contactsV1],
|
|
45
|
+
]);
|
|
46
|
+
/* -------------------------------------------------------------------------- */
|
|
47
|
+
/* Public factory */
|
|
48
|
+
/* -------------------------------------------------------------------------- */
|
|
49
|
+
export function createDataClient(args) {
|
|
50
|
+
return new DataClientImpl(args);
|
|
51
|
+
}
|
|
52
|
+
/* -------------------------------------------------------------------------- */
|
|
53
|
+
/* Implementation */
|
|
54
|
+
/* -------------------------------------------------------------------------- */
|
|
55
|
+
class DataClientImpl {
|
|
56
|
+
#pdsUrl;
|
|
57
|
+
#did;
|
|
58
|
+
#seed;
|
|
59
|
+
#vm;
|
|
60
|
+
#fetch;
|
|
61
|
+
// Per-collection CMK cache: cleared on reset()
|
|
62
|
+
#cmkCache = new Map();
|
|
63
|
+
#colCache = new Map();
|
|
64
|
+
constructor(args) {
|
|
65
|
+
this.#pdsUrl = args.pdsUrl.replace(/\/$/, "");
|
|
66
|
+
this.#did = args.did;
|
|
67
|
+
this.#seed = args.sphereSeed;
|
|
68
|
+
this.#vm = args.verificationMethod;
|
|
69
|
+
this.#fetch = args.fetch ?? globalThis.fetch.bind(globalThis);
|
|
70
|
+
}
|
|
71
|
+
collection(name) {
|
|
72
|
+
return new DataCollectionImpl(this, name);
|
|
73
|
+
}
|
|
74
|
+
async createCollection(args) {
|
|
75
|
+
const cmk = randomBytes32();
|
|
76
|
+
const recipientDidUrl = `${this.#did}#data-kex`;
|
|
77
|
+
const collectionUrn = this.#collectionUrn(args.name);
|
|
78
|
+
const recipientPublic = ed25519SeedToX25519PublicKey(this.#seed);
|
|
79
|
+
const wrap = this.#wrapCmkForRecipient({
|
|
80
|
+
cmk,
|
|
81
|
+
recipientPublicKey: recipientPublic,
|
|
82
|
+
recipientDidUrl,
|
|
83
|
+
collectionUrn,
|
|
84
|
+
});
|
|
85
|
+
try {
|
|
86
|
+
await this.#call("/mcp/primitives/write", "aithos.data.create_collection", {
|
|
87
|
+
subject_did: this.#did,
|
|
88
|
+
collection_name: args.name,
|
|
89
|
+
schema: args.schema,
|
|
90
|
+
...(args.forwardSecrecy ? { forward_secrecy: args.forwardSecrecy } : {}),
|
|
91
|
+
cmk_envelope: {
|
|
92
|
+
alg: "xchacha20poly1305-ietf",
|
|
93
|
+
wraps: [wrap],
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
// Cache CMK in memory for subsequent ops on this collection.
|
|
97
|
+
this.#cmkCache.set(args.name, cmk);
|
|
98
|
+
}
|
|
99
|
+
finally {
|
|
100
|
+
// CMK is retained in cache, only zero the local var if we didn't cache.
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
async listCollections() {
|
|
104
|
+
const r = await this.#call("/mcp/primitives/read", "aithos.data.list_collections", {
|
|
105
|
+
subject_did: this.#did,
|
|
106
|
+
});
|
|
107
|
+
const items = r.items ?? [];
|
|
108
|
+
return items.map((c) => ({
|
|
109
|
+
name: c.name,
|
|
110
|
+
schema: c.schema,
|
|
111
|
+
record_count: c.record_count,
|
|
112
|
+
}));
|
|
113
|
+
}
|
|
114
|
+
async listGammaEntries(opts = {}) {
|
|
115
|
+
const params = { subject_did: this.#did };
|
|
116
|
+
if (opts.limit !== undefined)
|
|
117
|
+
params.limit = opts.limit;
|
|
118
|
+
if (opts.opPrefix)
|
|
119
|
+
params.op_prefix = opts.opPrefix;
|
|
120
|
+
if (opts.verify)
|
|
121
|
+
params.verify = true;
|
|
122
|
+
return this.#call("/mcp/primitives/read", "aithos.data.list_gamma_entries", params);
|
|
123
|
+
}
|
|
124
|
+
reset() {
|
|
125
|
+
for (const k of this.#cmkCache.values())
|
|
126
|
+
k.fill(0);
|
|
127
|
+
this.#cmkCache.clear();
|
|
128
|
+
this.#colCache.clear();
|
|
129
|
+
}
|
|
130
|
+
/* -- Internals used by DataCollection -- */
|
|
131
|
+
async _ensureCollection(name) {
|
|
132
|
+
const cached = this.#colCache.get(name);
|
|
133
|
+
if (cached)
|
|
134
|
+
return cached;
|
|
135
|
+
// Fetch metadata from PDS
|
|
136
|
+
const meta = (await this.#call("/mcp/primitives/read", "aithos.data.get_collection", {
|
|
137
|
+
subject_did: this.#did,
|
|
138
|
+
collection_name: name,
|
|
139
|
+
}));
|
|
140
|
+
// Look up our wrap and unwrap the CMK
|
|
141
|
+
const ourRecipient = `${this.#did}#data-kex`;
|
|
142
|
+
const wrap = meta.cmk_envelope.wraps.find((w) => w.recipient === ourRecipient);
|
|
143
|
+
if (!wrap) {
|
|
144
|
+
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.`);
|
|
145
|
+
}
|
|
146
|
+
const cmk = this.#unwrapCmk({
|
|
147
|
+
wrap,
|
|
148
|
+
collectionUrn: meta.urn,
|
|
149
|
+
privateKey: ed25519SeedToX25519PrivateKey(this.#seed),
|
|
150
|
+
});
|
|
151
|
+
this.#cmkCache.set(name, cmk);
|
|
152
|
+
const schema = SCHEMAS.get(meta.schema);
|
|
153
|
+
if (!schema) {
|
|
154
|
+
throw new Error(`sdk.data: schema "${meta.schema}" not known to the SDK`);
|
|
155
|
+
}
|
|
156
|
+
const state = { name, urn: meta.urn, schema };
|
|
157
|
+
this.#colCache.set(name, state);
|
|
158
|
+
return state;
|
|
159
|
+
}
|
|
160
|
+
async _insert(state, record) {
|
|
161
|
+
const cmk = this.#cmkCache.get(state.name);
|
|
162
|
+
if (!cmk)
|
|
163
|
+
throw new Error("CMK not loaded");
|
|
164
|
+
const { metadata, payload } = splitRecord(record, state.schema);
|
|
165
|
+
const recordId = `record_${makeUlid()}`;
|
|
166
|
+
const encrypted = this.#encryptPayload({
|
|
167
|
+
collectionName: state.name,
|
|
168
|
+
recordId,
|
|
169
|
+
payload,
|
|
170
|
+
cmk,
|
|
171
|
+
});
|
|
172
|
+
const r = (await this.#call("/mcp/primitives/write", "aithos.data.insert_record", {
|
|
173
|
+
collection_urn: state.urn,
|
|
174
|
+
record_id: recordId,
|
|
175
|
+
metadata,
|
|
176
|
+
payload: encrypted,
|
|
177
|
+
}));
|
|
178
|
+
return r.record_id;
|
|
179
|
+
}
|
|
180
|
+
async _get(state, recordId) {
|
|
181
|
+
let raw;
|
|
182
|
+
try {
|
|
183
|
+
raw = await this.#call("/mcp/primitives/read", "aithos.data.get_record", {
|
|
184
|
+
collection_urn: state.urn,
|
|
185
|
+
record_id: recordId,
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
catch (e) {
|
|
189
|
+
if (e.code === -32020)
|
|
190
|
+
return null;
|
|
191
|
+
throw e;
|
|
192
|
+
}
|
|
193
|
+
return this.#decryptRecord(state, raw);
|
|
194
|
+
}
|
|
195
|
+
async _list(state, opts = {}) {
|
|
196
|
+
const params = {
|
|
197
|
+
collection_urn: state.urn,
|
|
198
|
+
};
|
|
199
|
+
if (opts.filter)
|
|
200
|
+
params.filter = opts.filter;
|
|
201
|
+
if (opts.order)
|
|
202
|
+
params.order = opts.order;
|
|
203
|
+
if (opts.limit !== undefined)
|
|
204
|
+
params.limit = opts.limit;
|
|
205
|
+
if (opts.cursor)
|
|
206
|
+
params.cursor = opts.cursor;
|
|
207
|
+
const r = (await this.#call("/mcp/primitives/read", "aithos.data.list_records", params));
|
|
208
|
+
const items = r.items.map((it) => this.#decryptRecord(state, it));
|
|
209
|
+
return {
|
|
210
|
+
items,
|
|
211
|
+
...(r.next_cursor ? { nextCursor: r.next_cursor } : {}),
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
async _update(state, recordId, record) {
|
|
215
|
+
const cmk = this.#cmkCache.get(state.name);
|
|
216
|
+
if (!cmk)
|
|
217
|
+
throw new Error("CMK not loaded");
|
|
218
|
+
const { metadata, payload } = splitRecord(record, state.schema);
|
|
219
|
+
const encrypted = this.#encryptPayload({
|
|
220
|
+
collectionName: state.name,
|
|
221
|
+
recordId,
|
|
222
|
+
payload,
|
|
223
|
+
cmk,
|
|
224
|
+
});
|
|
225
|
+
await this.#call("/mcp/primitives/write", "aithos.data.update_record", {
|
|
226
|
+
collection_urn: state.urn,
|
|
227
|
+
record_id: recordId,
|
|
228
|
+
metadata,
|
|
229
|
+
payload: encrypted,
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
async _delete(state, recordId) {
|
|
233
|
+
await this.#call("/mcp/primitives/write", "aithos.data.delete_record", {
|
|
234
|
+
collection_urn: state.urn,
|
|
235
|
+
record_id: recordId,
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
/* -- JSON-RPC dispatch -- */
|
|
239
|
+
async #call(path, method, params) {
|
|
240
|
+
const aud = `${this.#pdsUrl}${path}`;
|
|
241
|
+
const envelope = this.#signEnvelope({ aud, method, params });
|
|
242
|
+
const body = {
|
|
243
|
+
jsonrpc: "2.0",
|
|
244
|
+
id: makeUlid(),
|
|
245
|
+
method,
|
|
246
|
+
params: { ...params, _envelope: envelope },
|
|
247
|
+
};
|
|
248
|
+
const r = await this.#fetch(aud, {
|
|
249
|
+
method: "POST",
|
|
250
|
+
headers: { "content-type": "application/json" },
|
|
251
|
+
body: JSON.stringify(body),
|
|
252
|
+
});
|
|
253
|
+
const json = (await r.json());
|
|
254
|
+
if (json.error) {
|
|
255
|
+
const err = new Error(json.error.message);
|
|
256
|
+
err.code = json.error.code;
|
|
257
|
+
err.data = json.error.data;
|
|
258
|
+
throw err;
|
|
259
|
+
}
|
|
260
|
+
return json.result ?? {};
|
|
261
|
+
}
|
|
262
|
+
/* -- Envelope signing (inlined subset of @aithos/protocol-core/envelope) -- */
|
|
263
|
+
#signEnvelope(args) {
|
|
264
|
+
const now = Math.floor(Date.now() / 1000);
|
|
265
|
+
const exp = now + 60;
|
|
266
|
+
const nonce = makeUlid();
|
|
267
|
+
const paramsHash = "sha256-" + sha256Hex(jcsCanonicalize(args.params));
|
|
268
|
+
const unsigned = {
|
|
269
|
+
"aithos-envelope": "0.1.0",
|
|
270
|
+
iss: this.#did,
|
|
271
|
+
aud: args.aud,
|
|
272
|
+
method: args.method,
|
|
273
|
+
iat: now,
|
|
274
|
+
exp,
|
|
275
|
+
nonce,
|
|
276
|
+
params_hash: paramsHash,
|
|
277
|
+
proof: {
|
|
278
|
+
type: "Ed25519Signature2020",
|
|
279
|
+
verificationMethod: this.#vm,
|
|
280
|
+
created: new Date(now * 1000).toISOString(),
|
|
281
|
+
proofValue: "",
|
|
282
|
+
},
|
|
283
|
+
};
|
|
284
|
+
const bytes = new TextEncoder().encode(jcsCanonicalize(unsigned));
|
|
285
|
+
const sig = ed.sign(bytes, this.#seed);
|
|
286
|
+
return {
|
|
287
|
+
...unsigned,
|
|
288
|
+
proof: { ...unsigned.proof, proofValue: base64url(sig) },
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
/* -- Crypto helpers -- */
|
|
292
|
+
#collectionUrn(name) {
|
|
293
|
+
return `urn:aithos:collection:${this.#did}:${name}`;
|
|
294
|
+
}
|
|
295
|
+
#wrapCmkForRecipient(args) {
|
|
296
|
+
const ephSk = x25519.utils.randomSecretKey();
|
|
297
|
+
const ephPk = x25519.getPublicKey(ephSk);
|
|
298
|
+
const shared = x25519.getSharedSecret(ephSk, args.recipientPublicKey);
|
|
299
|
+
const wrapKey = hkdf(sha256, shared, utf8("aithos-data-cmk-wrap-v1"), utf8(args.recipientDidUrl), 32);
|
|
300
|
+
const wrapNonce = randomBytes24();
|
|
301
|
+
const aad = aadCmkWrap(args.collectionUrn, args.recipientDidUrl);
|
|
302
|
+
const aead = new XChaCha20Poly1305(wrapKey);
|
|
303
|
+
const wrapped = aead.seal(wrapNonce, args.cmk, aad);
|
|
304
|
+
wrapKey.fill(0);
|
|
305
|
+
shared.fill(0);
|
|
306
|
+
return {
|
|
307
|
+
recipient: args.recipientDidUrl,
|
|
308
|
+
alg: "x25519-hkdf-sha256-aead",
|
|
309
|
+
ephemeral_public: base64Std(ephPk),
|
|
310
|
+
wrap_nonce: base64Std(wrapNonce),
|
|
311
|
+
wrapped_key: base64Std(wrapped),
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
#unwrapCmk(args) {
|
|
315
|
+
const ephPk = fromBase64(args.wrap.ephemeral_public);
|
|
316
|
+
const wrapNonce = fromBase64(args.wrap.wrap_nonce);
|
|
317
|
+
const wrappedKey = fromBase64(args.wrap.wrapped_key);
|
|
318
|
+
const shared = x25519.getSharedSecret(args.privateKey, ephPk);
|
|
319
|
+
const wrapKey = hkdf(sha256, shared, utf8("aithos-data-cmk-wrap-v1"), utf8(args.wrap.recipient), 32);
|
|
320
|
+
const aad = aadCmkWrap(args.collectionUrn, args.wrap.recipient);
|
|
321
|
+
const aead = new XChaCha20Poly1305(wrapKey);
|
|
322
|
+
const cmk = aead.open(wrapNonce, wrappedKey, aad);
|
|
323
|
+
wrapKey.fill(0);
|
|
324
|
+
shared.fill(0);
|
|
325
|
+
if (!cmk)
|
|
326
|
+
throw new Error("sdk.data: CMK unwrap failed (wrong key or AAD mismatch)");
|
|
327
|
+
return cmk;
|
|
328
|
+
}
|
|
329
|
+
#encryptPayload(args) {
|
|
330
|
+
const dek = randomBytes32();
|
|
331
|
+
try {
|
|
332
|
+
const plaintext = new TextEncoder().encode(jcsCanonicalize(args.payload));
|
|
333
|
+
const nonce = randomBytes24();
|
|
334
|
+
const aad = aadRecord(this.#did, args.collectionName, args.recordId);
|
|
335
|
+
const aead = new XChaCha20Poly1305(dek);
|
|
336
|
+
const ciphertext = aead.seal(nonce, plaintext, aad);
|
|
337
|
+
const dekWrapNonce = randomBytes24();
|
|
338
|
+
const dekAad = aadDekWrap(this.#did, args.collectionName, args.recordId);
|
|
339
|
+
const dekAead = new XChaCha20Poly1305(args.cmk);
|
|
340
|
+
const wrapped = dekAead.seal(dekWrapNonce, dek, dekAad);
|
|
341
|
+
const dekWrap = new Uint8Array(dekWrapNonce.length + wrapped.length);
|
|
342
|
+
dekWrap.set(dekWrapNonce, 0);
|
|
343
|
+
dekWrap.set(wrapped, dekWrapNonce.length);
|
|
344
|
+
return {
|
|
345
|
+
alg: "xchacha20poly1305-ietf",
|
|
346
|
+
nonce: base64Std(nonce),
|
|
347
|
+
ciphertext: base64Std(ciphertext),
|
|
348
|
+
dek_wrapped_for_cmk: base64Std(dekWrap),
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
finally {
|
|
352
|
+
dek.fill(0);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
#decryptRecord(state, raw) {
|
|
356
|
+
if (raw.deleted) {
|
|
357
|
+
// Soft-deleted record — payload was cleared.
|
|
358
|
+
return { ...raw.metadata, _deleted: true };
|
|
359
|
+
}
|
|
360
|
+
const cmk = this.#cmkCache.get(state.name);
|
|
361
|
+
if (!cmk) {
|
|
362
|
+
throw new Error("sdk.data: CMK not loaded — call ensureCollection first");
|
|
363
|
+
}
|
|
364
|
+
// Unwrap DEK
|
|
365
|
+
const wrapBuf = fromBase64(raw.payload.dek_wrapped_for_cmk);
|
|
366
|
+
const wrapNonce = wrapBuf.slice(0, 24);
|
|
367
|
+
const wrapped = wrapBuf.slice(24);
|
|
368
|
+
const dekAad = aadDekWrap(this.#did, state.name, raw.record_id);
|
|
369
|
+
const dekAead = new XChaCha20Poly1305(cmk);
|
|
370
|
+
const dek = dekAead.open(wrapNonce, wrapped, dekAad);
|
|
371
|
+
if (!dek)
|
|
372
|
+
throw new Error("sdk.data: DEK unwrap failed");
|
|
373
|
+
try {
|
|
374
|
+
const nonce = fromBase64(raw.payload.nonce);
|
|
375
|
+
const ciphertext = fromBase64(raw.payload.ciphertext);
|
|
376
|
+
const aad = aadRecord(this.#did, state.name, raw.record_id);
|
|
377
|
+
const aead = new XChaCha20Poly1305(dek);
|
|
378
|
+
const plaintext = aead.open(nonce, ciphertext, aad);
|
|
379
|
+
if (!plaintext)
|
|
380
|
+
throw new Error("sdk.data: payload decrypt failed");
|
|
381
|
+
const payload = JSON.parse(new TextDecoder().decode(plaintext));
|
|
382
|
+
return { record_id: raw.record_id, ...raw.metadata, ...payload };
|
|
383
|
+
}
|
|
384
|
+
finally {
|
|
385
|
+
dek.fill(0);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
class DataCollectionImpl {
|
|
390
|
+
client;
|
|
391
|
+
name;
|
|
392
|
+
constructor(client, name) {
|
|
393
|
+
this.client = client;
|
|
394
|
+
this.name = name;
|
|
395
|
+
}
|
|
396
|
+
async insert(record) {
|
|
397
|
+
const state = await this.client._ensureCollection(this.name);
|
|
398
|
+
return this.client._insert(state, record);
|
|
399
|
+
}
|
|
400
|
+
async get(recordId) {
|
|
401
|
+
const state = await this.client._ensureCollection(this.name);
|
|
402
|
+
return this.client._get(state, recordId);
|
|
403
|
+
}
|
|
404
|
+
async list(opts) {
|
|
405
|
+
const state = await this.client._ensureCollection(this.name);
|
|
406
|
+
return this.client._list(state, opts);
|
|
407
|
+
}
|
|
408
|
+
async update(recordId, record) {
|
|
409
|
+
const state = await this.client._ensureCollection(this.name);
|
|
410
|
+
return this.client._update(state, recordId, record);
|
|
411
|
+
}
|
|
412
|
+
async delete(recordId) {
|
|
413
|
+
const state = await this.client._ensureCollection(this.name);
|
|
414
|
+
return this.client._delete(state, recordId);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
/* -------------------------------------------------------------------------- */
|
|
418
|
+
/* Record split (metadata vs payload) */
|
|
419
|
+
/* -------------------------------------------------------------------------- */
|
|
420
|
+
function splitRecord(record, schema) {
|
|
421
|
+
const metadata = {};
|
|
422
|
+
const payload = {};
|
|
423
|
+
for (const [k, v] of Object.entries(record)) {
|
|
424
|
+
if (schema.auto.has(k))
|
|
425
|
+
continue; // server-set
|
|
426
|
+
if (schema.indexable.has(k)) {
|
|
427
|
+
metadata[k] = v;
|
|
428
|
+
}
|
|
429
|
+
else if (schema.encrypted.has(k)) {
|
|
430
|
+
payload[k] = v;
|
|
431
|
+
}
|
|
432
|
+
else {
|
|
433
|
+
// Unknown field — drop. Server will reject in any case (additionalProperties: false).
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
return { metadata, payload };
|
|
437
|
+
}
|
|
438
|
+
/* -------------------------------------------------------------------------- */
|
|
439
|
+
/* Crypto helpers */
|
|
440
|
+
/* -------------------------------------------------------------------------- */
|
|
441
|
+
function randomBytes32() {
|
|
442
|
+
return cryptoRandom(32);
|
|
443
|
+
}
|
|
444
|
+
function randomBytes24() {
|
|
445
|
+
return cryptoRandom(24);
|
|
446
|
+
}
|
|
447
|
+
/** Cross-platform CSPRNG: Web Crypto in browser, Node WebCrypto in Node 19+. */
|
|
448
|
+
function cryptoRandom(n) {
|
|
449
|
+
const buf = new Uint8Array(n);
|
|
450
|
+
// globalThis.crypto exists in browsers and in Node 19+
|
|
451
|
+
globalThis.crypto?.getRandomValues(buf);
|
|
452
|
+
return buf;
|
|
453
|
+
}
|
|
454
|
+
function utf8(s) {
|
|
455
|
+
return new TextEncoder().encode(s);
|
|
456
|
+
}
|
|
457
|
+
function aadCmkWrap(collectionUrn, recipient) {
|
|
458
|
+
const p = utf8("aithos-data-cmk-v1\0");
|
|
459
|
+
const c = utf8(collectionUrn);
|
|
460
|
+
const sep = new Uint8Array([0]);
|
|
461
|
+
const r = utf8(recipient);
|
|
462
|
+
const out = new Uint8Array(p.length + c.length + sep.length + r.length);
|
|
463
|
+
let off = 0;
|
|
464
|
+
out.set(p, off);
|
|
465
|
+
off += p.length;
|
|
466
|
+
out.set(c, off);
|
|
467
|
+
off += c.length;
|
|
468
|
+
out.set(sep, off);
|
|
469
|
+
off += sep.length;
|
|
470
|
+
out.set(r, off);
|
|
471
|
+
return out;
|
|
472
|
+
}
|
|
473
|
+
function aadDekWrap(subjectDid, collectionName, recordId) {
|
|
474
|
+
const p = utf8("aithos-data-dek-v1\0");
|
|
475
|
+
return concat3WithSeps(p, subjectDid, collectionName, recordId);
|
|
476
|
+
}
|
|
477
|
+
function aadRecord(subjectDid, collectionName, recordId) {
|
|
478
|
+
const p = utf8("aithos-data-record-v1\0");
|
|
479
|
+
return concat3WithSeps(p, subjectDid, collectionName, recordId);
|
|
480
|
+
}
|
|
481
|
+
function concat3WithSeps(prefix, a, b, c) {
|
|
482
|
+
const aa = utf8(a);
|
|
483
|
+
const bb = utf8(b);
|
|
484
|
+
const cc = utf8(c);
|
|
485
|
+
const sep = new Uint8Array([0]);
|
|
486
|
+
const total = prefix.length + aa.length + sep.length + bb.length + sep.length + cc.length;
|
|
487
|
+
const out = new Uint8Array(total);
|
|
488
|
+
let off = 0;
|
|
489
|
+
out.set(prefix, off);
|
|
490
|
+
off += prefix.length;
|
|
491
|
+
out.set(aa, off);
|
|
492
|
+
off += aa.length;
|
|
493
|
+
out.set(sep, off);
|
|
494
|
+
off += sep.length;
|
|
495
|
+
out.set(bb, off);
|
|
496
|
+
off += bb.length;
|
|
497
|
+
out.set(sep, off);
|
|
498
|
+
off += sep.length;
|
|
499
|
+
out.set(cc, off);
|
|
500
|
+
return out;
|
|
501
|
+
}
|
|
502
|
+
/**
|
|
503
|
+
* Derive a 32-byte X25519 private key from an Ed25519 seed via SHA-512
|
|
504
|
+
* truncation + clamping. Mirrors libsodium's
|
|
505
|
+
* crypto_sign_ed25519_sk_to_curve25519 for the secret-key side. Used so
|
|
506
|
+
* a single Ed25519 sphere seed can both sign envelopes and key-agree
|
|
507
|
+
* with mandate recipients.
|
|
508
|
+
*/
|
|
509
|
+
function ed25519SeedToX25519PrivateKey(seed) {
|
|
510
|
+
if (seed.length !== 32)
|
|
511
|
+
throw new Error("Ed25519 seed must be 32 bytes");
|
|
512
|
+
const h = sha512(seed);
|
|
513
|
+
const sk = new Uint8Array(h.slice(0, 32));
|
|
514
|
+
// Clamp per X25519 spec
|
|
515
|
+
sk[0] = sk[0] & 248;
|
|
516
|
+
sk[31] = sk[31] & 127;
|
|
517
|
+
sk[31] = sk[31] | 64;
|
|
518
|
+
return sk;
|
|
519
|
+
}
|
|
520
|
+
function ed25519SeedToX25519PublicKey(seed) {
|
|
521
|
+
const sk = ed25519SeedToX25519PrivateKey(seed);
|
|
522
|
+
try {
|
|
523
|
+
return x25519.getPublicKey(sk);
|
|
524
|
+
}
|
|
525
|
+
finally {
|
|
526
|
+
sk.fill(0);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
function makeUlid() {
|
|
530
|
+
// Lightweight ULID — millisecond timestamp + 80 bits of randomness.
|
|
531
|
+
// Crockford base32. For tests this is sufficient; production uses
|
|
532
|
+
// the canonical ulid package.
|
|
533
|
+
const tsBuf = new Uint8Array(6);
|
|
534
|
+
let ts = Date.now();
|
|
535
|
+
for (let i = 5; i >= 0; i--) {
|
|
536
|
+
tsBuf[i] = ts & 0xff;
|
|
537
|
+
ts = Math.floor(ts / 256);
|
|
538
|
+
}
|
|
539
|
+
const rndBuf = cryptoRandom(10);
|
|
540
|
+
const all = new Uint8Array(16);
|
|
541
|
+
all.set(tsBuf, 0);
|
|
542
|
+
all.set(rndBuf, 6);
|
|
543
|
+
const alphabet = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
|
|
544
|
+
let bits = 0;
|
|
545
|
+
let value = 0;
|
|
546
|
+
let out = "";
|
|
547
|
+
for (const b of all) {
|
|
548
|
+
value = (value << 8) | b;
|
|
549
|
+
bits += 8;
|
|
550
|
+
while (bits >= 5) {
|
|
551
|
+
out += alphabet[(value >> (bits - 5)) & 0x1f];
|
|
552
|
+
bits -= 5;
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
if (bits > 0)
|
|
556
|
+
out += alphabet[(value << (5 - bits)) & 0x1f];
|
|
557
|
+
return out.slice(0, 26);
|
|
558
|
+
}
|
|
559
|
+
/** Standard base64 (with `=` padding). Browser + Node compatible. */
|
|
560
|
+
function base64Std(bytes) {
|
|
561
|
+
let bin = "";
|
|
562
|
+
for (let i = 0; i < bytes.length; i++)
|
|
563
|
+
bin += String.fromCharCode(bytes[i]);
|
|
564
|
+
return btoa(bin);
|
|
565
|
+
}
|
|
566
|
+
/** base64url (URL-safe, no padding). */
|
|
567
|
+
function base64url(bytes) {
|
|
568
|
+
return base64Std(bytes).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
569
|
+
}
|
|
570
|
+
function fromBase64(s) {
|
|
571
|
+
// Accept either standard base64 or base64url
|
|
572
|
+
const std = s.replace(/-/g, "+").replace(/_/g, "/");
|
|
573
|
+
const padded = std + "=".repeat((4 - (std.length % 4)) % 4);
|
|
574
|
+
const bin = atob(padded);
|
|
575
|
+
const out = new Uint8Array(bin.length);
|
|
576
|
+
for (let i = 0; i < bin.length; i++)
|
|
577
|
+
out[i] = bin.charCodeAt(i);
|
|
578
|
+
return out;
|
|
579
|
+
}
|
|
580
|
+
function sha256Hex(s) {
|
|
581
|
+
const d = sha256(new TextEncoder().encode(s));
|
|
582
|
+
let hex = "";
|
|
583
|
+
for (const b of d)
|
|
584
|
+
hex += b.toString(16).padStart(2, "0");
|
|
585
|
+
return hex;
|
|
586
|
+
}
|
|
587
|
+
/* -------------------------------------------------------------------------- */
|
|
588
|
+
/* JCS-style canonicalization (RFC 8785 subset) */
|
|
589
|
+
/* -------------------------------------------------------------------------- */
|
|
590
|
+
function jcsCanonicalize(value) {
|
|
591
|
+
if (value === null)
|
|
592
|
+
return "null";
|
|
593
|
+
if (value === undefined)
|
|
594
|
+
throw new Error("Cannot canonicalize undefined");
|
|
595
|
+
if (typeof value === "boolean")
|
|
596
|
+
return value ? "true" : "false";
|
|
597
|
+
if (typeof value === "number") {
|
|
598
|
+
if (!Number.isFinite(value))
|
|
599
|
+
throw new Error("non-finite number");
|
|
600
|
+
return value.toString();
|
|
601
|
+
}
|
|
602
|
+
if (typeof value === "string")
|
|
603
|
+
return JSON.stringify(value);
|
|
604
|
+
if (Array.isArray(value)) {
|
|
605
|
+
return "[" + value.map(jcsCanonicalize).join(",") + "]";
|
|
606
|
+
}
|
|
607
|
+
if (typeof value === "object") {
|
|
608
|
+
const obj = value;
|
|
609
|
+
const keys = Object.keys(obj).sort();
|
|
610
|
+
return ("{" +
|
|
611
|
+
keys.map((k) => JSON.stringify(k) + ":" + jcsCanonicalize(obj[k])).join(",") +
|
|
612
|
+
"}");
|
|
613
|
+
}
|
|
614
|
+
throw new Error(`Cannot canonicalize ${typeof value}`);
|
|
615
|
+
}
|
|
616
|
+
//# sourceMappingURL=data.js.map
|
package/dist/src/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export declare const VERSION = "0.1.0-alpha.
|
|
1
|
+
export declare const VERSION = "0.1.0-alpha.26";
|
|
2
2
|
export { AithosSDK } from "./sdk.js";
|
|
3
3
|
export type { AithosSDKConfig } from "./types.js";
|
|
4
4
|
export { AithosSDKError } from "./types.js";
|
|
@@ -21,4 +21,6 @@ export { COMPUTE_INVOKE_SCOPE, MandatesNamespace } from "./mandates.js";
|
|
|
21
21
|
export type { ActorSphere, CreateMandateComputeInput, CreateMandateInput, MintedMandate, OwnedMandate, Scope, } from "./mandates.js";
|
|
22
22
|
export * as onboarding from "./onboarding.js";
|
|
23
23
|
export { createBrowserIdentity, browserIdentityFromStored, type BrowserIdentity, } from "@aithos/protocol-client";
|
|
24
|
+
export type { Section } from "@aithos/protocol-client";
|
|
25
|
+
export { createDataClient, type CreateDataClientArgs, type DataClient, type DataCollection, type ListOpts, type AithosSchemaLite, } from "./data.js";
|
|
24
26
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/src/index.js
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
// Public types specific to the SDK (`AithosSDKConfig`, `AithosSDKError`)
|
|
18
18
|
// are exported from here. Endpoint config (`AithosSdkEndpoints`,
|
|
19
19
|
// `DEFAULT_SDK_ENDPOINTS`) likewise.
|
|
20
|
-
export const VERSION = "0.1.0-alpha.
|
|
20
|
+
export const VERSION = "0.1.0-alpha.26";
|
|
21
21
|
export { AithosSDK } from "./sdk.js";
|
|
22
22
|
export { AithosSDKError } from "./types.js";
|
|
23
23
|
// Re-export protocol-client's JSON-RPC error type so consumers can
|
|
@@ -58,4 +58,8 @@ export * as onboarding from "./onboarding.js";
|
|
|
58
58
|
// Convenience direct re-exports of the most-used identity primitives so the
|
|
59
59
|
// quick-start example doesn't need a namespace import.
|
|
60
60
|
export { createBrowserIdentity, browserIdentityFromStored, } from "@aithos/protocol-client";
|
|
61
|
+
// `sdk.data` namespace — Aithos data sub-protocol PDS client. Manages
|
|
62
|
+
// the lifecycle of subject-owned, encrypted, schema-validated records.
|
|
63
|
+
// See spec/data/ in the aithos-protocol repo.
|
|
64
|
+
export { createDataClient, } from "./data.js";
|
|
61
65
|
//# sourceMappingURL=index.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aithos/sdk",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.26",
|
|
4
4
|
"description": "Aithos SDK \u2014 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",
|