@aithos/sdk 0.1.0-alpha.45 → 0.1.0-alpha.48
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/apps.js +2 -13
- package/dist/src/auth-api.d.ts +81 -0
- package/dist/src/auth-api.js +80 -0
- package/dist/src/auth.d.ts +84 -0
- package/dist/src/auth.js +133 -1
- package/dist/src/data.d.ts +56 -0
- package/dist/src/data.js +229 -42
- package/dist/src/index.d.ts +2 -2
- package/dist/src/index.js +1 -1
- package/dist/src/internal/envelope.js +20 -121
- package/dist/src/mandates.d.ts +14 -1
- package/dist/src/mandates.js +3 -2
- package/dist/test/canonical-conformance.test.d.ts +2 -0
- package/dist/test/canonical-conformance.test.js +86 -0
- package/dist/test/envelope-core-conformance.test.d.ts +2 -0
- package/dist/test/envelope-core-conformance.test.js +75 -0
- package/package.json +3 -1
package/dist/src/apps.js
CHANGED
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
// in V0.2 once the sponsorship-api Lambda exists; for now, devs check
|
|
27
27
|
// their sponsorship via the AWS Console.
|
|
28
28
|
import { base64url, buildSignedEnvelope, sign } from "@aithos/protocol-client";
|
|
29
|
+
import { canonicalize } from "@aithos/protocol-core/canonical";
|
|
29
30
|
import { computeInvokeUrl, walletTopupCheckoutUrl } from "./endpoints.js";
|
|
30
31
|
import { ownerKeyPair } from "./internal/protocol-client-bridge.js";
|
|
31
32
|
import { AithosSDKError } from "./types.js";
|
|
@@ -426,18 +427,6 @@ function randomBytes(n) {
|
|
|
426
427
|
* the hash the signature commits to.
|
|
427
428
|
*/
|
|
428
429
|
function jcsCanonicalize(value) {
|
|
429
|
-
|
|
430
|
-
return JSON.stringify(value);
|
|
431
|
-
}
|
|
432
|
-
if (Array.isArray(value)) {
|
|
433
|
-
return "[" + value.map(jcsCanonicalize).join(",") + "]";
|
|
434
|
-
}
|
|
435
|
-
const obj = value;
|
|
436
|
-
const keys = Object.keys(obj).sort();
|
|
437
|
-
return ("{" +
|
|
438
|
-
keys
|
|
439
|
-
.map((k) => JSON.stringify(k) + ":" + jcsCanonicalize(obj[k]))
|
|
440
|
-
.join(",") +
|
|
441
|
-
"}");
|
|
430
|
+
return canonicalize(value);
|
|
442
431
|
}
|
|
443
432
|
//# sourceMappingURL=apps.js.map
|
package/dist/src/auth-api.d.ts
CHANGED
|
@@ -132,6 +132,87 @@ export type CustodialVerifyEmailApiResponse = {
|
|
|
132
132
|
* or past its TTL.
|
|
133
133
|
*/
|
|
134
134
|
export declare function custodialVerifyEmail(http: HttpClient, input: CustodialVerifyEmailApiInput): Promise<CustodialVerifyEmailApiResponse>;
|
|
135
|
+
/**
|
|
136
|
+
* Input for {@link custodialInvite}. The app authenticates via `apiKey`
|
|
137
|
+
* (server-only secret) or `publicKey` (browser-safe, Origin-gated). The
|
|
138
|
+
* `invitePayload` is an OPAQUE app string delivered verbatim to the invitee
|
|
139
|
+
* on {@link custodialAccept}. For Aithos invitations it's a delegate bundle
|
|
140
|
+
* (mandate + seed) serialized as JSON — but the auth backend never parses it;
|
|
141
|
+
* it stores it bound to a single-use token and returns it at accept time.
|
|
142
|
+
*/
|
|
143
|
+
export interface CustodialInviteApiInput {
|
|
144
|
+
readonly apiKey?: string;
|
|
145
|
+
readonly publicKey?: string;
|
|
146
|
+
readonly email: string;
|
|
147
|
+
/** Opaque payload delivered to the invitee on accept (e.g. a mandate bundle JSON). */
|
|
148
|
+
readonly invitePayload: string;
|
|
149
|
+
/** Token TTL in seconds. Backend clamps to its own min/max. Default backend-side. */
|
|
150
|
+
readonly ttlSeconds?: number;
|
|
151
|
+
/** Optional display name pre-filled on the pending account. */
|
|
152
|
+
readonly displayName?: string;
|
|
153
|
+
}
|
|
154
|
+
export interface CustodialInviteApiResponse {
|
|
155
|
+
readonly status: "invited";
|
|
156
|
+
readonly email: string;
|
|
157
|
+
readonly mailSent: boolean;
|
|
158
|
+
readonly mailMessageId?: string;
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Send an invitation magic link carrying an opaque payload. The backend
|
|
162
|
+
* mints a single-use token, stores `{ token, email, invite_payload, ttl }`,
|
|
163
|
+
* and emails the magic link (same SES path as the verification mail). The
|
|
164
|
+
* payload (e.g. a mandate bundle) stays server-side — it never rides the
|
|
165
|
+
* email URL. The invitee redeems it via {@link custodialAccept}.
|
|
166
|
+
*
|
|
167
|
+
* No password here: this is an invitation, not an account creation. The
|
|
168
|
+
* invitee chooses their password when they accept.
|
|
169
|
+
*/
|
|
170
|
+
export declare function custodialInvite(http: HttpClient, input: CustodialInviteApiInput): Promise<CustodialInviteApiResponse>;
|
|
171
|
+
export interface CustodialAcceptApiInput {
|
|
172
|
+
readonly email: string;
|
|
173
|
+
readonly token: string;
|
|
174
|
+
/**
|
|
175
|
+
* Password. For a brand-new account the invitee SETS it here; for an
|
|
176
|
+
* existing account they AUTHENTICATE with it. Required unless the backend
|
|
177
|
+
* accepts a session-bound redemption (not in v1).
|
|
178
|
+
*/
|
|
179
|
+
readonly password?: string;
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Result of redeeming an invitation. Always `signed_in` on success — the
|
|
183
|
+
* token is consumed, the account is created (or the existing one is
|
|
184
|
+
* authenticated), and a full session payload is returned alongside the
|
|
185
|
+
* `invitePayload` the inviter attached. `accountCreated` distinguishes the
|
|
186
|
+
* two cases for UX.
|
|
187
|
+
*/
|
|
188
|
+
export interface CustodialAcceptApiResponse {
|
|
189
|
+
readonly status: "signed_in";
|
|
190
|
+
readonly session: string;
|
|
191
|
+
readonly exp: number;
|
|
192
|
+
readonly did: string;
|
|
193
|
+
readonly handle: string;
|
|
194
|
+
readonly displayName: string;
|
|
195
|
+
readonly seed: Uint8Array;
|
|
196
|
+
readonly encKey: Uint8Array;
|
|
197
|
+
readonly blob: Uint8Array;
|
|
198
|
+
readonly blobNonce: Uint8Array;
|
|
199
|
+
readonly blobVersion: number;
|
|
200
|
+
/** The opaque payload the inviter attached (e.g. a mandate bundle JSON). */
|
|
201
|
+
readonly invitePayload: string;
|
|
202
|
+
/** True when a new account was provisioned; false when an existing one signed in. */
|
|
203
|
+
readonly accountCreated: boolean;
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Redeem an invitation token from the magic link. Consumes the token
|
|
207
|
+
* (single-use), creates the account with the supplied password (or
|
|
208
|
+
* authenticates an existing account), and returns a session + the inviter's
|
|
209
|
+
* `invitePayload`.
|
|
210
|
+
*
|
|
211
|
+
* Throws `auth_token_invalid_or_expired` (bad/consumed/expired token) or an
|
|
212
|
+
* auth error if an existing account's password is wrong / a new password is
|
|
213
|
+
* too weak.
|
|
214
|
+
*/
|
|
215
|
+
export declare function custodialAccept(http: HttpClient, input: CustodialAcceptApiInput): Promise<CustodialAcceptApiResponse>;
|
|
135
216
|
/** Re-send the verification mail for a pending account. The backend
|
|
136
217
|
* is anti-enum (always 200) and rate-limited 1/h/account, so this is
|
|
137
218
|
* safe to call even when the user state is unknown. Accepts the same
|
package/dist/src/auth-api.js
CHANGED
|
@@ -183,6 +183,86 @@ export async function custodialVerifyEmail(http, input) {
|
|
|
183
183
|
blobVersion: wire.blob_version,
|
|
184
184
|
};
|
|
185
185
|
}
|
|
186
|
+
/**
|
|
187
|
+
* Send an invitation magic link carrying an opaque payload. The backend
|
|
188
|
+
* mints a single-use token, stores `{ token, email, invite_payload, ttl }`,
|
|
189
|
+
* and emails the magic link (same SES path as the verification mail). The
|
|
190
|
+
* payload (e.g. a mandate bundle) stays server-side — it never rides the
|
|
191
|
+
* email URL. The invitee redeems it via {@link custodialAccept}.
|
|
192
|
+
*
|
|
193
|
+
* No password here: this is an invitation, not an account creation. The
|
|
194
|
+
* invitee chooses their password when they accept.
|
|
195
|
+
*/
|
|
196
|
+
export async function custodialInvite(http, input) {
|
|
197
|
+
if (!input.apiKey && !input.publicKey) {
|
|
198
|
+
throw new AithosSDKError("auth_missing_api_key", "inviteCustodial requires either apiKey or publicKey");
|
|
199
|
+
}
|
|
200
|
+
if (input.apiKey && input.publicKey) {
|
|
201
|
+
throw new AithosSDKError("auth_invalid_input", "inviteCustodial: pass exactly one of apiKey or publicKey, not both");
|
|
202
|
+
}
|
|
203
|
+
const bearer = (input.apiKey ?? input.publicKey);
|
|
204
|
+
const res = await http.fetchImpl(`${http.authBaseUrl}/auth/custodial/invite`, {
|
|
205
|
+
method: "POST",
|
|
206
|
+
headers: {
|
|
207
|
+
"content-type": "application/json",
|
|
208
|
+
authorization: `Bearer ${bearer}`,
|
|
209
|
+
},
|
|
210
|
+
body: JSON.stringify({
|
|
211
|
+
email: input.email,
|
|
212
|
+
invite_payload: input.invitePayload,
|
|
213
|
+
...(input.ttlSeconds !== undefined ? { ttl_seconds: input.ttlSeconds } : {}),
|
|
214
|
+
...(input.displayName ? { display_name: input.displayName } : {}),
|
|
215
|
+
}),
|
|
216
|
+
});
|
|
217
|
+
if (!res.ok)
|
|
218
|
+
throw await readError(res, "custodial_invite_failed");
|
|
219
|
+
const wire = (await res.json());
|
|
220
|
+
return {
|
|
221
|
+
status: "invited",
|
|
222
|
+
email: wire.email,
|
|
223
|
+
mailSent: wire.mail_sent,
|
|
224
|
+
...(wire.mail_message_id !== undefined ? { mailMessageId: wire.mail_message_id } : {}),
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* Redeem an invitation token from the magic link. Consumes the token
|
|
229
|
+
* (single-use), creates the account with the supplied password (or
|
|
230
|
+
* authenticates an existing account), and returns a session + the inviter's
|
|
231
|
+
* `invitePayload`.
|
|
232
|
+
*
|
|
233
|
+
* Throws `auth_token_invalid_or_expired` (bad/consumed/expired token) or an
|
|
234
|
+
* auth error if an existing account's password is wrong / a new password is
|
|
235
|
+
* too weak.
|
|
236
|
+
*/
|
|
237
|
+
export async function custodialAccept(http, input) {
|
|
238
|
+
const res = await http.fetchImpl(`${http.authBaseUrl}/auth/custodial/accept`, {
|
|
239
|
+
method: "POST",
|
|
240
|
+
headers: { "content-type": "application/json" },
|
|
241
|
+
body: JSON.stringify({
|
|
242
|
+
email: input.email,
|
|
243
|
+
token: input.token,
|
|
244
|
+
...(input.password ? { password: input.password } : {}),
|
|
245
|
+
}),
|
|
246
|
+
});
|
|
247
|
+
if (!res.ok)
|
|
248
|
+
throw await readError(res, "custodial_accept_failed");
|
|
249
|
+
const wire = (await res.json());
|
|
250
|
+
return {
|
|
251
|
+
status: "signed_in",
|
|
252
|
+
session: wire.session,
|
|
253
|
+
exp: wire.exp,
|
|
254
|
+
did: wire.did,
|
|
255
|
+
handle: wire.handle,
|
|
256
|
+
displayName: wire.display_name,
|
|
257
|
+
seed: b64ToBytes(wire.seed_b64),
|
|
258
|
+
encKey: b64ToBytes(wire.enc_key_b64),
|
|
259
|
+
blob: wire.blob_b64 ? b64ToBytes(wire.blob_b64) : new Uint8Array(0),
|
|
260
|
+
blobNonce: wire.blob_nonce_b64 ? b64ToBytes(wire.blob_nonce_b64) : new Uint8Array(0),
|
|
261
|
+
blobVersion: wire.blob_version,
|
|
262
|
+
invitePayload: wire.invite_payload,
|
|
263
|
+
accountCreated: wire.account_created,
|
|
264
|
+
};
|
|
265
|
+
}
|
|
186
266
|
/* ---- POST /auth/custodial/verify/resend -------------------------------- */
|
|
187
267
|
/** Re-send the verification mail for a pending account. The backend
|
|
188
268
|
* is anti-enum (always 200) and rate-limited 1/h/account, so this is
|
package/dist/src/auth.d.ts
CHANGED
|
@@ -152,6 +152,64 @@ export interface ImportMandateInput {
|
|
|
152
152
|
/** Delegate bundle as a Blob or already-decoded JSON string. */
|
|
153
153
|
readonly bundle: Blob | string;
|
|
154
154
|
}
|
|
155
|
+
/**
|
|
156
|
+
* Input to {@link AithosAuth.inviteCustodial}. Sends an invitation magic link
|
|
157
|
+
* that carries an opaque payload (typically a delegate bundle) to deliver to
|
|
158
|
+
* the invitee on accept. Mandate-agnostic: `mandateBundle` may be ANY mandate
|
|
159
|
+
* (read/write/append/ethos/compute…) — the auth backend stores it verbatim,
|
|
160
|
+
* bound to a single-use token, and never parses it.
|
|
161
|
+
*
|
|
162
|
+
* The app authenticates via `apiKey` (server-only secret) or `publicKey`
|
|
163
|
+
* (browser-safe, Origin-gated), or the constructor's default `publicKey`.
|
|
164
|
+
* No user password here — the invitee chooses it when they accept.
|
|
165
|
+
*/
|
|
166
|
+
export interface InviteCustodialInput {
|
|
167
|
+
readonly apiKey?: string;
|
|
168
|
+
readonly publicKey?: string;
|
|
169
|
+
/** Invitee email — receives the magic link. */
|
|
170
|
+
readonly email: string;
|
|
171
|
+
/**
|
|
172
|
+
* The mandate to deliver. Accept the SDK's `MintedMandate.bundle` (Blob),
|
|
173
|
+
* a JSON string, or a plain bundle object — all normalized to a JSON string.
|
|
174
|
+
*/
|
|
175
|
+
readonly mandateBundle: Blob | string | Record<string, unknown>;
|
|
176
|
+
/** Token TTL in seconds (backend clamps to its policy). */
|
|
177
|
+
readonly ttlSeconds?: number;
|
|
178
|
+
/** Optional display name pre-filled on the pending account. */
|
|
179
|
+
readonly displayName?: string;
|
|
180
|
+
}
|
|
181
|
+
/** Result of {@link AithosAuth.inviteCustodial}. */
|
|
182
|
+
export interface InviteCustodialResult {
|
|
183
|
+
readonly status: "invited";
|
|
184
|
+
readonly email: string;
|
|
185
|
+
readonly mailSent: boolean;
|
|
186
|
+
readonly mailMessageId?: string;
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Input to {@link AithosAuth.acceptInvite}. `email` and `token` come from the
|
|
190
|
+
* `?email=&token=` query string of the invitation link. `password` is set by
|
|
191
|
+
* the invitee for a NEW account, or used to authenticate an EXISTING one — so
|
|
192
|
+
* it's required whenever the user isn't already signed in.
|
|
193
|
+
*/
|
|
194
|
+
export interface AcceptInviteInput {
|
|
195
|
+
readonly email: string;
|
|
196
|
+
readonly token: string;
|
|
197
|
+
readonly password?: string;
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Result of {@link AithosAuth.acceptInvite}. The token is consumed, the
|
|
201
|
+
* session is hydrated (account created or existing one signed in), and the
|
|
202
|
+
* invited mandate has been imported into the keystore — `delegate` describes
|
|
203
|
+
* it. Read `delegate.subjectDid` to identify the issuer (e.g. resolve their
|
|
204
|
+
* public Ethos via `sdk.ethos.of(delegate.subjectDid)`).
|
|
205
|
+
*/
|
|
206
|
+
export interface AcceptInviteResult {
|
|
207
|
+
readonly status: "signed_in";
|
|
208
|
+
readonly session: AithosSession;
|
|
209
|
+
readonly delegate: DelegateInfo;
|
|
210
|
+
/** True when a new account was provisioned; false when an existing one signed in. */
|
|
211
|
+
readonly accountCreated: boolean;
|
|
212
|
+
}
|
|
155
213
|
/**
|
|
156
214
|
* Input to {@link AithosAuth.signUpCustodial}.
|
|
157
215
|
*
|
|
@@ -544,6 +602,32 @@ export declare class AithosAuth {
|
|
|
544
602
|
* past its 1h TTL — surface a "request a fresh link" CTA in that case.
|
|
545
603
|
*/
|
|
546
604
|
verifyEmail(input: VerifyEmailInput): Promise<VerifyEmailResult>;
|
|
605
|
+
/**
|
|
606
|
+
* Send an invitation magic link carrying a mandate. The issuer (owner)
|
|
607
|
+
* mints any mandate via {@link AithosSDK.mandates} (read/write/append/…),
|
|
608
|
+
* then calls this with the bundle: the auth backend stores it bound to a
|
|
609
|
+
* single-use token and emails the magic link. The mandate (and its delegate
|
|
610
|
+
* seed) never ride the email URL. The invitee redeems it via
|
|
611
|
+
* {@link acceptInvite}.
|
|
612
|
+
*
|
|
613
|
+
* Generic — knows nothing about the mandate's scope. Authenticate with
|
|
614
|
+
* `apiKey` (server) or `publicKey` (browser, Origin-gated).
|
|
615
|
+
*/
|
|
616
|
+
inviteCustodial(input: InviteCustodialInput): Promise<InviteCustodialResult>;
|
|
617
|
+
/**
|
|
618
|
+
* Redeem an invitation from the magic link: consume the token, sign in
|
|
619
|
+
* (create the account with `password`, or authenticate an existing one),
|
|
620
|
+
* and AUTO-IMPORT the mandate the inviter attached. Returns the session and
|
|
621
|
+
* the imported {@link DelegateInfo}.
|
|
622
|
+
*
|
|
623
|
+
* Mount this on the page declared as the invitation's verify/redirect URL;
|
|
624
|
+
* read `email` + `token` from `window.location.search`, collect the
|
|
625
|
+
* `password`, call this.
|
|
626
|
+
*
|
|
627
|
+
* Throws `auth_token_invalid_or_expired` (bad/consumed/expired token) or an
|
|
628
|
+
* auth error if an existing account's password is wrong / a new one is weak.
|
|
629
|
+
*/
|
|
630
|
+
acceptInvite(input: AcceptInviteInput): Promise<AcceptInviteResult>;
|
|
547
631
|
/**
|
|
548
632
|
* Re-send the verification mail for a pending account. Use when the
|
|
549
633
|
* user reports never having received the welcome mail, or when their
|
package/dist/src/auth.js
CHANGED
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
// keyStore is the source of truth for "is the user signed in", the
|
|
22
22
|
// JWT is auxiliary for compute/wallet.
|
|
23
23
|
import { browserIdentityFromStored, buildBlobPlaintext, buildSignedEnvelope, createBrowserIdentity, decryptBlob, DEFAULT_KDF, deriveAuthAndEncKeys, encryptBlob, parseBlob, randomNonce, randomSalt, serializeBlob, signedDidDocument, zeroize, } from "@aithos/protocol-client";
|
|
24
|
-
import { custodialResendVerify, custodialResetFinalize, custodialResetRequest, custodialSignIn, custodialSignUp, custodialVerifyEmail, loginChallenge, loginVerify, putBlob, registerAccount, } from "./auth-api.js";
|
|
24
|
+
import { custodialAccept, custodialInvite, custodialResendVerify, custodialResetFinalize, custodialResetRequest, custodialSignIn, custodialSignUp, custodialVerifyEmail, loginChallenge, loginVerify, putBlob, registerAccount, } from "./auth-api.js";
|
|
25
25
|
import { defaultSessionStore, } from "./session-store.js";
|
|
26
26
|
import { defaultKeyStore, } from "./key-store.js";
|
|
27
27
|
import { parseDelegateBundle, readDelegateBundleText, } from "./internal/delegate-bundle.js";
|
|
@@ -1029,6 +1029,138 @@ export class AithosAuth {
|
|
|
1029
1029
|
this.#sessionStore.set(session);
|
|
1030
1030
|
return { status: "signed_in", session, passwordMustChange: false };
|
|
1031
1031
|
}
|
|
1032
|
+
/**
|
|
1033
|
+
* Send an invitation magic link carrying a mandate. The issuer (owner)
|
|
1034
|
+
* mints any mandate via {@link AithosSDK.mandates} (read/write/append/…),
|
|
1035
|
+
* then calls this with the bundle: the auth backend stores it bound to a
|
|
1036
|
+
* single-use token and emails the magic link. The mandate (and its delegate
|
|
1037
|
+
* seed) never ride the email URL. The invitee redeems it via
|
|
1038
|
+
* {@link acceptInvite}.
|
|
1039
|
+
*
|
|
1040
|
+
* Generic — knows nothing about the mandate's scope. Authenticate with
|
|
1041
|
+
* `apiKey` (server) or `publicKey` (browser, Origin-gated).
|
|
1042
|
+
*/
|
|
1043
|
+
async inviteCustodial(input) {
|
|
1044
|
+
if (!input.email) {
|
|
1045
|
+
throw new AithosSDKError("auth_invalid_input", "inviteCustodial: email is required");
|
|
1046
|
+
}
|
|
1047
|
+
if (input.mandateBundle === undefined || input.mandateBundle === null) {
|
|
1048
|
+
throw new AithosSDKError("auth_invalid_input", "inviteCustodial: mandateBundle is required");
|
|
1049
|
+
}
|
|
1050
|
+
const apiKey = input.apiKey;
|
|
1051
|
+
const publicKey = input.publicKey ?? this.#publicKey;
|
|
1052
|
+
if (!apiKey && !publicKey) {
|
|
1053
|
+
throw new AithosSDKError("auth_missing_api_key", "inviteCustodial: pass apiKey, or publicKey, or set publicKey on the AithosAuth constructor");
|
|
1054
|
+
}
|
|
1055
|
+
// Normalize the bundle to a JSON string (the opaque invite payload).
|
|
1056
|
+
let invitePayload;
|
|
1057
|
+
if (typeof input.mandateBundle === "string") {
|
|
1058
|
+
invitePayload = input.mandateBundle;
|
|
1059
|
+
}
|
|
1060
|
+
else if (input.mandateBundle instanceof Blob) {
|
|
1061
|
+
invitePayload = await input.mandateBundle.text();
|
|
1062
|
+
}
|
|
1063
|
+
else {
|
|
1064
|
+
invitePayload = JSON.stringify(input.mandateBundle);
|
|
1065
|
+
}
|
|
1066
|
+
const result = await custodialInvite({ fetchImpl: this.#fetchImpl, authBaseUrl: this.authBaseUrl }, {
|
|
1067
|
+
email: input.email,
|
|
1068
|
+
invitePayload,
|
|
1069
|
+
...(apiKey ? { apiKey } : publicKey ? { publicKey } : {}),
|
|
1070
|
+
...(input.ttlSeconds !== undefined ? { ttlSeconds: input.ttlSeconds } : {}),
|
|
1071
|
+
...(input.displayName ? { displayName: input.displayName } : {}),
|
|
1072
|
+
});
|
|
1073
|
+
return {
|
|
1074
|
+
status: "invited",
|
|
1075
|
+
email: result.email,
|
|
1076
|
+
mailSent: result.mailSent,
|
|
1077
|
+
...(result.mailMessageId !== undefined ? { mailMessageId: result.mailMessageId } : {}),
|
|
1078
|
+
};
|
|
1079
|
+
}
|
|
1080
|
+
/**
|
|
1081
|
+
* Redeem an invitation from the magic link: consume the token, sign in
|
|
1082
|
+
* (create the account with `password`, or authenticate an existing one),
|
|
1083
|
+
* and AUTO-IMPORT the mandate the inviter attached. Returns the session and
|
|
1084
|
+
* the imported {@link DelegateInfo}.
|
|
1085
|
+
*
|
|
1086
|
+
* Mount this on the page declared as the invitation's verify/redirect URL;
|
|
1087
|
+
* read `email` + `token` from `window.location.search`, collect the
|
|
1088
|
+
* `password`, call this.
|
|
1089
|
+
*
|
|
1090
|
+
* Throws `auth_token_invalid_or_expired` (bad/consumed/expired token) or an
|
|
1091
|
+
* auth error if an existing account's password is wrong / a new one is weak.
|
|
1092
|
+
*/
|
|
1093
|
+
async acceptInvite(input) {
|
|
1094
|
+
if (!input.email || !input.token) {
|
|
1095
|
+
throw new AithosSDKError("auth_invalid_input", "acceptInvite: email and token are required");
|
|
1096
|
+
}
|
|
1097
|
+
const resp = await custodialAccept({ fetchImpl: this.#fetchImpl, authBaseUrl: this.authBaseUrl }, {
|
|
1098
|
+
email: input.email,
|
|
1099
|
+
token: input.token,
|
|
1100
|
+
...(input.password ? { password: input.password } : {}),
|
|
1101
|
+
});
|
|
1102
|
+
// Materialise the 4 sphere seeds + session — same shape as the verifyEmail
|
|
1103
|
+
// magic-link path. Kept inline (additive; verifyEmail is left untouched).
|
|
1104
|
+
if (resp.seed.byteLength !== 128) {
|
|
1105
|
+
zeroize(resp.seed);
|
|
1106
|
+
zeroize(resp.encKey);
|
|
1107
|
+
throw new AithosSDKError("auth_custodial_seed_format", `acceptInvite: expected 128-byte seed bundle, got ${resp.seed.byteLength}`);
|
|
1108
|
+
}
|
|
1109
|
+
const seedRoot = resp.seed.slice(0, 32);
|
|
1110
|
+
const seedPublic = resp.seed.slice(32, 64);
|
|
1111
|
+
const seedCircle = resp.seed.slice(64, 96);
|
|
1112
|
+
const seedSelf = resp.seed.slice(96, 128);
|
|
1113
|
+
const stored = {
|
|
1114
|
+
version: "0.1.0-hex",
|
|
1115
|
+
did: resp.did,
|
|
1116
|
+
handle: resp.handle,
|
|
1117
|
+
displayName: resp.displayName,
|
|
1118
|
+
seedsHex: {
|
|
1119
|
+
root: bytesToHex(seedRoot),
|
|
1120
|
+
public: bytesToHex(seedPublic),
|
|
1121
|
+
circle: bytesToHex(seedCircle),
|
|
1122
|
+
self: bytesToHex(seedSelf),
|
|
1123
|
+
},
|
|
1124
|
+
savedAt: new Date().toISOString(),
|
|
1125
|
+
};
|
|
1126
|
+
zeroize(resp.seed);
|
|
1127
|
+
zeroize(seedRoot);
|
|
1128
|
+
zeroize(seedPublic);
|
|
1129
|
+
zeroize(seedCircle);
|
|
1130
|
+
zeroize(seedSelf);
|
|
1131
|
+
zeroize(resp.encKey);
|
|
1132
|
+
const identity = browserIdentityFromStored({
|
|
1133
|
+
handle: stored.handle,
|
|
1134
|
+
displayName: stored.displayName,
|
|
1135
|
+
did: stored.did,
|
|
1136
|
+
seeds: stored.seedsHex,
|
|
1137
|
+
});
|
|
1138
|
+
await this.#publishIdentity(identity);
|
|
1139
|
+
if (this.#ownerSigners)
|
|
1140
|
+
this.#ownerSigners.destroy();
|
|
1141
|
+
this.#ownerSigners = OwnerSigners.fromStoredOwnerKeys(stored);
|
|
1142
|
+
await this.#keyStore.saveOwner(stored);
|
|
1143
|
+
const session = {
|
|
1144
|
+
session: resp.session,
|
|
1145
|
+
exp: resp.exp,
|
|
1146
|
+
did: resp.did,
|
|
1147
|
+
handle: resp.handle,
|
|
1148
|
+
blob_b64: bytesToB64Public(resp.blob),
|
|
1149
|
+
blob_nonce_b64: bytesToB64Public(resp.blobNonce),
|
|
1150
|
+
blob_version: resp.blobVersion,
|
|
1151
|
+
enc_key_b64: "",
|
|
1152
|
+
is_first_login: false,
|
|
1153
|
+
};
|
|
1154
|
+
this.#sessionStore.set(session);
|
|
1155
|
+
// Import the invited mandate into the keystore (generic — any scope).
|
|
1156
|
+
const delegate = await this.importMandate({ bundle: resp.invitePayload });
|
|
1157
|
+
return {
|
|
1158
|
+
status: "signed_in",
|
|
1159
|
+
session,
|
|
1160
|
+
delegate,
|
|
1161
|
+
accountCreated: resp.accountCreated,
|
|
1162
|
+
};
|
|
1163
|
+
}
|
|
1032
1164
|
/**
|
|
1033
1165
|
* Re-send the verification mail for a pending account. Use when the
|
|
1034
1166
|
* user reports never having received the welcome mail, or when their
|
package/dist/src/data.d.ts
CHANGED
|
@@ -277,4 +277,60 @@ export interface CreateDelegateDataClientArgs {
|
|
|
277
277
|
* forced.
|
|
278
278
|
*/
|
|
279
279
|
export declare function createDelegateDataClient(args: CreateDelegateDataClientArgs): ReadonlyDataClient;
|
|
280
|
+
/** An append-only handle on one collection: `insert` and nothing else. */
|
|
281
|
+
export interface AppendOnlyDataCollection {
|
|
282
|
+
readonly name: string;
|
|
283
|
+
/**
|
|
284
|
+
* Deposit a record. The DEK is sealed to the owner's public key, so the
|
|
285
|
+
* depositor cannot read this (or any) record back. Returns the record id.
|
|
286
|
+
*/
|
|
287
|
+
insert(record: Record<string, unknown>): Promise<string>;
|
|
288
|
+
}
|
|
289
|
+
/** A client holding a `data.<collection>.append` mandate. Insert-only. */
|
|
290
|
+
export interface AppendOnlyDataClient {
|
|
291
|
+
/** Get an append-only handle on a collection (schema supplied at
|
|
292
|
+
* construction — append clients cannot read collection metadata). */
|
|
293
|
+
collection(name: string): AppendOnlyDataCollection;
|
|
294
|
+
/** Drop in-memory cache. */
|
|
295
|
+
reset(): void;
|
|
296
|
+
}
|
|
297
|
+
export interface CreateAppendDataClientArgs {
|
|
298
|
+
/** PDS base URL (same endpoint the owner writes to). */
|
|
299
|
+
readonly pdsUrl: string;
|
|
300
|
+
/** DID of the SUBJECT who owns the target collection (the mandate issuer). */
|
|
301
|
+
readonly subjectDid: string;
|
|
302
|
+
/**
|
|
303
|
+
* The owner's `#data` Ed25519 public key (multibase z…). The depositor
|
|
304
|
+
* derives the owner's X25519 wrap target from it and seals each DEK to it.
|
|
305
|
+
* Source: the append mandate / invitation, or the owner's DID document.
|
|
306
|
+
*/
|
|
307
|
+
readonly ownerDataPubkeyMultibase: string;
|
|
308
|
+
/** The signed mandate carrying `data.<collection>.append`. */
|
|
309
|
+
readonly mandate: SignedMandate;
|
|
310
|
+
/** The depositor's Ed25519 seed (32 bytes) — the grantee key the mandate is
|
|
311
|
+
* bound to. Signs each insert envelope. NEVER used to read. */
|
|
312
|
+
readonly delegateSeed: Uint8Array;
|
|
313
|
+
/** Defaults to `mandate.grantee.pubkey`. */
|
|
314
|
+
readonly granteePubkeyMultibase?: string;
|
|
315
|
+
/**
|
|
316
|
+
* Schema of the target collection(s). The append client builds records
|
|
317
|
+
* locally (it cannot fetch collection metadata), so the caller MUST supply
|
|
318
|
+
* the schema(s) used by the collection it deposits into. The first entry is
|
|
319
|
+
* used as the default; multiple may be passed for multi-collection clients.
|
|
320
|
+
*/
|
|
321
|
+
readonly schema: AithosSchemaLite;
|
|
322
|
+
/** Additional schemas (looked up by id alongside `schema`). */
|
|
323
|
+
readonly schemas?: readonly AithosSchemaLite[];
|
|
324
|
+
/** `fetch` override (tests). */
|
|
325
|
+
readonly fetch?: typeof fetch;
|
|
326
|
+
}
|
|
327
|
+
/**
|
|
328
|
+
* Build an **append-only** data client from a `data.<collection>.append`
|
|
329
|
+
* mandate. The returned {@link AppendOnlyDataClient} can ONLY `insert`: it
|
|
330
|
+
* seals each record's DEK to the owner's public key (never the CMK), so it
|
|
331
|
+
* holds no read capability — it cannot decrypt anything in the collection,
|
|
332
|
+
* not even its own deposit. The PDS additionally enforces the append scope
|
|
333
|
+
* (insert allowed; get/list/update/delete refused).
|
|
334
|
+
*/
|
|
335
|
+
export declare function createAppendDataClient(args: CreateAppendDataClientArgs): AppendOnlyDataClient;
|
|
280
336
|
//# sourceMappingURL=data.d.ts.map
|
package/dist/src/data.js
CHANGED
|
@@ -39,6 +39,7 @@ import { sha256, sha512 } from "@noble/hashes/sha2.js";
|
|
|
39
39
|
import { XChaCha20Poly1305 } from "@stablelib/xchacha20poly1305";
|
|
40
40
|
import * as ed from "@noble/ed25519";
|
|
41
41
|
import { multibaseToEd25519PublicKey, edPubToX25519Pub, } from "@aithos/protocol-client";
|
|
42
|
+
import { canonicalize } from "@aithos/protocol-core/canonical";
|
|
42
43
|
import { contactsV1 } from "./data-schema-contacts-v1.js";
|
|
43
44
|
import { signOwnerEnvelope } from "./internal/envelope.js";
|
|
44
45
|
// noble/ed25519 v2 needs sha512 wired in for sync sign/verify
|
|
@@ -92,6 +93,50 @@ export function createDelegateDataClient(args) {
|
|
|
92
93
|
},
|
|
93
94
|
});
|
|
94
95
|
}
|
|
96
|
+
/**
|
|
97
|
+
* Build an **append-only** data client from a `data.<collection>.append`
|
|
98
|
+
* mandate. The returned {@link AppendOnlyDataClient} can ONLY `insert`: it
|
|
99
|
+
* seals each record's DEK to the owner's public key (never the CMK), so it
|
|
100
|
+
* holds no read capability — it cannot decrypt anything in the collection,
|
|
101
|
+
* not even its own deposit. The PDS additionally enforces the append scope
|
|
102
|
+
* (insert allowed; get/list/update/delete refused).
|
|
103
|
+
*/
|
|
104
|
+
export function createAppendDataClient(args) {
|
|
105
|
+
const granteePubMb = args.granteePubkeyMultibase ??
|
|
106
|
+
args.mandate.grantee?.pubkey;
|
|
107
|
+
if (!granteePubMb) {
|
|
108
|
+
throw new Error("createAppendDataClient: mandate.grantee.pubkey is missing; pass granteePubkeyMultibase explicitly");
|
|
109
|
+
}
|
|
110
|
+
const ownerKexPublicKey = edPubToX25519Pub(multibaseToEd25519PublicKey(args.ownerDataPubkeyMultibase));
|
|
111
|
+
const impl = new DataClientImpl({
|
|
112
|
+
pdsUrl: args.pdsUrl,
|
|
113
|
+
did: args.subjectDid,
|
|
114
|
+
// In deposit mode the seed signs envelopes; it is NEVER used to unwrap a
|
|
115
|
+
// CMK (the deposit client has none).
|
|
116
|
+
sphereSeed: args.delegateSeed,
|
|
117
|
+
verificationMethod: granteePubMb,
|
|
118
|
+
schemas: [args.schema, ...(args.schemas ?? [])],
|
|
119
|
+
...(args.fetch ? { fetch: args.fetch } : {}),
|
|
120
|
+
deposit: {
|
|
121
|
+
delegateSeed: args.delegateSeed,
|
|
122
|
+
granteePubkeyMultibase: granteePubMb,
|
|
123
|
+
mandate: args.mandate,
|
|
124
|
+
ownerKexPublicKey,
|
|
125
|
+
ownerKexDidUrl: `${args.subjectDid}#data-kex`,
|
|
126
|
+
},
|
|
127
|
+
});
|
|
128
|
+
const defaultSchema = args.schema;
|
|
129
|
+
return {
|
|
130
|
+
collection(name) {
|
|
131
|
+
const state = impl._depositCollectionState(name, defaultSchema);
|
|
132
|
+
return {
|
|
133
|
+
name,
|
|
134
|
+
insert: (record) => impl._insertDeposit(state, record),
|
|
135
|
+
};
|
|
136
|
+
},
|
|
137
|
+
reset: () => impl.reset(),
|
|
138
|
+
};
|
|
139
|
+
}
|
|
95
140
|
class DataClientImpl {
|
|
96
141
|
#pdsUrl;
|
|
97
142
|
#did;
|
|
@@ -100,6 +145,9 @@ class DataClientImpl {
|
|
|
100
145
|
#fetch;
|
|
101
146
|
/** Delegate session, or undefined for the owner path. */
|
|
102
147
|
#delegate;
|
|
148
|
+
/** Deposit (append-only) session, or undefined. Mutually exclusive with
|
|
149
|
+
* {@link DataClientImpl.#delegate}. */
|
|
150
|
+
#deposit;
|
|
103
151
|
/**
|
|
104
152
|
* Per-client schema overrides, populated from `args.schemas` at
|
|
105
153
|
* construction. Looked up BEFORE the bundled SCHEMAS map (so an app
|
|
@@ -118,6 +166,8 @@ class DataClientImpl {
|
|
|
118
166
|
this.#fetch = args.fetch ?? globalThis.fetch.bind(globalThis);
|
|
119
167
|
if (args.delegate)
|
|
120
168
|
this.#delegate = args.delegate;
|
|
169
|
+
if (args.deposit)
|
|
170
|
+
this.#deposit = args.deposit;
|
|
121
171
|
this.#localSchemas = new Map((args.schemas ?? []).map((s) => [s.schema, s]));
|
|
122
172
|
}
|
|
123
173
|
/** Throw a read-only error when a mutating verb is called on a delegate
|
|
@@ -394,7 +444,21 @@ class DataClientImpl {
|
|
|
394
444
|
if (opts.cursor)
|
|
395
445
|
params.cursor = opts.cursor;
|
|
396
446
|
const r = (await this.#call("/mcp/primitives/read", "aithos.data.list_records", params));
|
|
397
|
-
|
|
447
|
+
// A read/write delegate (CMK-holder) cannot decrypt append-only deposits
|
|
448
|
+
// (sealed to the owner key). Skip them rather than crash the whole list —
|
|
449
|
+
// the owner reading the same collection decrypts everything. -32044 is the
|
|
450
|
+
// client-side "deposit unreadable by this session" marker.
|
|
451
|
+
const items = [];
|
|
452
|
+
for (const it of r.items) {
|
|
453
|
+
try {
|
|
454
|
+
items.push(this.#decryptRecord(state, it));
|
|
455
|
+
}
|
|
456
|
+
catch (e) {
|
|
457
|
+
if (e.code === -32044)
|
|
458
|
+
continue;
|
|
459
|
+
throw e;
|
|
460
|
+
}
|
|
461
|
+
}
|
|
398
462
|
return {
|
|
399
463
|
items,
|
|
400
464
|
...(r.next_cursor ? { nextCursor: r.next_cursor } : {}),
|
|
@@ -434,17 +498,21 @@ class DataClientImpl {
|
|
|
434
498
|
// INCLUDING `proof` with proofValue=""). Delegate path: sign with the
|
|
435
499
|
// grantee key, bare-multibase verificationMethod, and attach the mandate
|
|
436
500
|
// so the PDS resolves the delegation and enforces its scopes.
|
|
437
|
-
|
|
501
|
+
// A mandate-bearing session (read-delegate OR append-deposit) signs as
|
|
502
|
+
// the grantee with the bare-multibase verificationMethod and attaches the
|
|
503
|
+
// mandate so the PDS resolves the delegation and enforces its scopes.
|
|
504
|
+
const mandateSession = this.#delegate ?? this.#deposit;
|
|
505
|
+
const envelope = mandateSession
|
|
438
506
|
? await signOwnerEnvelope({
|
|
439
507
|
iss: this.#did, // the SUBJECT DID (mandate issuer), not the delegate
|
|
440
508
|
aud,
|
|
441
509
|
method,
|
|
442
510
|
params,
|
|
443
|
-
verificationMethod:
|
|
511
|
+
verificationMethod: mandateSession.granteePubkeyMultibase,
|
|
444
512
|
signer: {
|
|
445
|
-
sign: async (msg) => ed.sign(msg,
|
|
513
|
+
sign: async (msg) => ed.sign(msg, mandateSession.delegateSeed),
|
|
446
514
|
},
|
|
447
|
-
mandate:
|
|
515
|
+
mandate: mandateSession.mandate,
|
|
448
516
|
})
|
|
449
517
|
: await signOwnerEnvelope({
|
|
450
518
|
iss: this.#did,
|
|
@@ -538,24 +606,95 @@ class DataClientImpl {
|
|
|
538
606
|
dek.fill(0);
|
|
539
607
|
}
|
|
540
608
|
}
|
|
609
|
+
/**
|
|
610
|
+
* Seal a record's payload for the append-only deposit path: encrypt under a
|
|
611
|
+
* fresh DEK, then seal that DEK to the OWNER's X25519 public key (never the
|
|
612
|
+
* CMK). Mirrors `@aithos/data-crypto` `wrapDEKForRecipient` byte-for-byte so
|
|
613
|
+
* the owner (SDK or CLI) can unwrap it. The depositor keeps no key material.
|
|
614
|
+
*/
|
|
615
|
+
#encryptPayloadForOwner(args) {
|
|
616
|
+
const dek = randomBytes32();
|
|
617
|
+
try {
|
|
618
|
+
const plaintext = new TextEncoder().encode(jcsCanonicalize(args.payload));
|
|
619
|
+
const nonce = randomBytes24();
|
|
620
|
+
const aad = aadRecord(this.#did, args.collectionName, args.recordId);
|
|
621
|
+
const ciphertext = new XChaCha20Poly1305(dek).seal(nonce, plaintext, aad);
|
|
622
|
+
// Seal the DEK to the owner's X25519 key (ECIES, fresh ephemeral).
|
|
623
|
+
const ephSk = x25519.utils.randomSecretKey();
|
|
624
|
+
const ephPk = x25519.getPublicKey(ephSk);
|
|
625
|
+
const shared = x25519.getSharedSecret(ephSk, args.ownerKexPublicKey);
|
|
626
|
+
const wrapKey = hkdf(sha256, shared, DEPOSIT_WRAP_SALT, utf8(args.ownerKexDidUrl), 32);
|
|
627
|
+
const wrapNonce = randomBytes24();
|
|
628
|
+
const wrapAad = aadDepositWrap(this.#did, args.collectionName, args.recordId, args.ownerKexDidUrl);
|
|
629
|
+
const wrappedKey = new XChaCha20Poly1305(wrapKey).seal(wrapNonce, dek, wrapAad);
|
|
630
|
+
wrapKey.fill(0);
|
|
631
|
+
shared.fill(0);
|
|
632
|
+
return {
|
|
633
|
+
alg: "xchacha20poly1305-ietf",
|
|
634
|
+
nonce: base64Std(nonce),
|
|
635
|
+
ciphertext: base64Std(ciphertext),
|
|
636
|
+
dek_wrapped_for_owner: {
|
|
637
|
+
recipient: args.ownerKexDidUrl,
|
|
638
|
+
alg: "x25519-hkdf-sha256-aead",
|
|
639
|
+
ephemeral_public: base64Std(ephPk),
|
|
640
|
+
wrap_nonce: base64Std(wrapNonce),
|
|
641
|
+
wrapped_key: base64Std(wrappedKey),
|
|
642
|
+
},
|
|
643
|
+
};
|
|
644
|
+
}
|
|
645
|
+
finally {
|
|
646
|
+
dek.fill(0);
|
|
647
|
+
}
|
|
648
|
+
}
|
|
541
649
|
#decryptRecord(state, raw) {
|
|
542
650
|
if (raw.deleted) {
|
|
543
651
|
// Soft-deleted record — payload was cleared.
|
|
544
652
|
return { ...raw.metadata, _deleted: true };
|
|
545
653
|
}
|
|
546
|
-
|
|
547
|
-
if (
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
654
|
+
let dek;
|
|
655
|
+
if (raw.payload.dek_wrapped_for_owner) {
|
|
656
|
+
// Append-only deposit: the DEK is sealed to the OWNER's #data-kex key.
|
|
657
|
+
// Only the owner (no delegate/deposit session) can open it. A read
|
|
658
|
+
// delegate holds the CMK, not the owner key, so it must skip deposits.
|
|
659
|
+
if (this.#delegate || this.#deposit) {
|
|
660
|
+
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).");
|
|
661
|
+
e.code = -32044; // AITHOS_DATA_DEPOSIT_UNREADABLE (client-side)
|
|
662
|
+
throw e;
|
|
663
|
+
}
|
|
664
|
+
const w = raw.payload.dek_wrapped_for_owner;
|
|
665
|
+
const ownerKexSk = ed25519SeedToX25519PrivateKey(this.#seed);
|
|
666
|
+
try {
|
|
667
|
+
const ephPk = fromBase64(w.ephemeral_public);
|
|
668
|
+
const shared = x25519.getSharedSecret(ownerKexSk, ephPk);
|
|
669
|
+
const wrapKey = hkdf(sha256, shared, DEPOSIT_WRAP_SALT, utf8(w.recipient), 32);
|
|
670
|
+
const wrapAad = aadDepositWrap(this.#did, state.name, raw.record_id, w.recipient);
|
|
671
|
+
dek = new XChaCha20Poly1305(wrapKey).open(fromBase64(w.wrap_nonce), fromBase64(w.wrapped_key), wrapAad);
|
|
672
|
+
wrapKey.fill(0);
|
|
673
|
+
shared.fill(0);
|
|
674
|
+
}
|
|
675
|
+
finally {
|
|
676
|
+
ownerKexSk.fill(0);
|
|
677
|
+
}
|
|
678
|
+
if (!dek)
|
|
679
|
+
throw new Error("sdk.data: deposit DEK unwrap failed (wrong owner key or AAD mismatch)");
|
|
680
|
+
}
|
|
681
|
+
else {
|
|
682
|
+
// CMK path (owner / read-write delegate).
|
|
683
|
+
const cmk = this.#cmkCache.get(state.name);
|
|
684
|
+
if (!cmk) {
|
|
685
|
+
throw new Error("sdk.data: CMK not loaded — call ensureCollection first");
|
|
686
|
+
}
|
|
687
|
+
if (raw.payload.dek_wrapped_for_cmk === undefined) {
|
|
688
|
+
throw new Error("sdk.data: record payload has neither dek_wrapped_for_cmk nor dek_wrapped_for_owner");
|
|
689
|
+
}
|
|
690
|
+
const wrapBuf = fromBase64(raw.payload.dek_wrapped_for_cmk);
|
|
691
|
+
const wrapNonce = wrapBuf.slice(0, 24);
|
|
692
|
+
const wrapped = wrapBuf.slice(24);
|
|
693
|
+
const dekAad = aadDekWrap(this.#did, state.name, raw.record_id);
|
|
694
|
+
dek = new XChaCha20Poly1305(cmk).open(wrapNonce, wrapped, dekAad);
|
|
695
|
+
if (!dek)
|
|
696
|
+
throw new Error("sdk.data: DEK unwrap failed");
|
|
697
|
+
}
|
|
559
698
|
try {
|
|
560
699
|
const nonce = fromBase64(raw.payload.nonce);
|
|
561
700
|
const ciphertext = fromBase64(raw.payload.ciphertext);
|
|
@@ -571,6 +710,38 @@ class DataClientImpl {
|
|
|
571
710
|
dek.fill(0);
|
|
572
711
|
}
|
|
573
712
|
}
|
|
713
|
+
/**
|
|
714
|
+
* Append-only deposit insert. Used by the {@link createAppendDataClient}
|
|
715
|
+
* path: builds the record locally (no CMK, no server schema fetch — the
|
|
716
|
+
* caller supplies the schema), seals the DEK to the owner key, and POSTs
|
|
717
|
+
* `insert_record`. The PDS enforces the `data.<col>.append` scope.
|
|
718
|
+
*/
|
|
719
|
+
async _insertDeposit(state, record) {
|
|
720
|
+
if (!this.#deposit) {
|
|
721
|
+
throw new Error("sdk.data: _insertDeposit called without a deposit session");
|
|
722
|
+
}
|
|
723
|
+
const { metadata, payload } = splitRecord(record, state.schema);
|
|
724
|
+
const recordId = `record_${makeUlid()}`;
|
|
725
|
+
const encrypted = this.#encryptPayloadForOwner({
|
|
726
|
+
collectionName: state.name,
|
|
727
|
+
recordId,
|
|
728
|
+
payload,
|
|
729
|
+
ownerKexPublicKey: this.#deposit.ownerKexPublicKey,
|
|
730
|
+
ownerKexDidUrl: this.#deposit.ownerKexDidUrl,
|
|
731
|
+
});
|
|
732
|
+
const r = (await this.#call("/mcp/primitives/write", "aithos.data.insert_record", {
|
|
733
|
+
collection_urn: state.urn,
|
|
734
|
+
record_id: recordId,
|
|
735
|
+
metadata,
|
|
736
|
+
payload: encrypted,
|
|
737
|
+
}));
|
|
738
|
+
return r.record_id;
|
|
739
|
+
}
|
|
740
|
+
/** Build a local collection state for the deposit path (no server fetch:
|
|
741
|
+
* append clients are not authorized to read collection metadata). */
|
|
742
|
+
_depositCollectionState(name, schema) {
|
|
743
|
+
return { name, urn: this.#collectionUrn(name), schema };
|
|
744
|
+
}
|
|
574
745
|
}
|
|
575
746
|
class DataCollectionImpl {
|
|
576
747
|
client;
|
|
@@ -676,6 +847,40 @@ function aadRecord(subjectDid, collectionName, recordId) {
|
|
|
676
847
|
const p = utf8("aithos-data-record-v1\0");
|
|
677
848
|
return concat3WithSeps(p, subjectDid, collectionName, recordId);
|
|
678
849
|
}
|
|
850
|
+
/**
|
|
851
|
+
* HKDF salt for the append-only deposit DEK wrap. Distinct from the CMK wrap
|
|
852
|
+
* salt so the two key-derivation domains never collide. MUST match
|
|
853
|
+
* `@aithos/data-crypto` `DEPOSIT_WRAP_SALT`.
|
|
854
|
+
*/
|
|
855
|
+
const DEPOSIT_WRAP_SALT = utf8("aithos-data-dek-deposit-wrap-v1");
|
|
856
|
+
/**
|
|
857
|
+
* AAD for the deposit DEK wrap:
|
|
858
|
+
* "aithos-data-dek-deposit-v1\0" ‖ subject ‖ \0 ‖ collection ‖ \0 ‖
|
|
859
|
+
* record ‖ \0 ‖ recipient_did_url
|
|
860
|
+
* MUST match `@aithos/data-crypto` `aadForDepositWrap`.
|
|
861
|
+
*/
|
|
862
|
+
function aadDepositWrap(subjectDid, collectionName, recordId, recipientDidUrl) {
|
|
863
|
+
const prefix = utf8("aithos-data-dek-deposit-v1\0");
|
|
864
|
+
const parts = [subjectDid, collectionName, recordId, recipientDidUrl].map(utf8);
|
|
865
|
+
const sep = new Uint8Array([0]);
|
|
866
|
+
let total = prefix.length;
|
|
867
|
+
for (let i = 0; i < parts.length; i++) {
|
|
868
|
+
total += parts[i].length + (i < parts.length - 1 ? sep.length : 0);
|
|
869
|
+
}
|
|
870
|
+
const out = new Uint8Array(total);
|
|
871
|
+
let off = 0;
|
|
872
|
+
out.set(prefix, off);
|
|
873
|
+
off += prefix.length;
|
|
874
|
+
for (let i = 0; i < parts.length; i++) {
|
|
875
|
+
out.set(parts[i], off);
|
|
876
|
+
off += parts[i].length;
|
|
877
|
+
if (i < parts.length - 1) {
|
|
878
|
+
out.set(sep, off);
|
|
879
|
+
off += sep.length;
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
return out;
|
|
883
|
+
}
|
|
679
884
|
function concat3WithSeps(prefix, a, b, c) {
|
|
680
885
|
const aa = utf8(a);
|
|
681
886
|
const bb = utf8(b);
|
|
@@ -785,30 +990,12 @@ function sha256Hex(s) {
|
|
|
785
990
|
/* -------------------------------------------------------------------------- */
|
|
786
991
|
/* JCS-style canonicalization (RFC 8785 subset) */
|
|
787
992
|
/* -------------------------------------------------------------------------- */
|
|
993
|
+
// Single canonicalization source of truth: @aithos/protocol-core. Kept as a
|
|
994
|
+
// local alias so the encryption-AAD call sites read naturally; the byte output
|
|
995
|
+
// is proven identical to the former hand-rolled JCS by
|
|
996
|
+
// test/canonical-conformance.test.ts (critical: this feeds pre-encryption
|
|
997
|
+
// canonicalization, so any drift would corrupt data).
|
|
788
998
|
function jcsCanonicalize(value) {
|
|
789
|
-
|
|
790
|
-
return "null";
|
|
791
|
-
if (value === undefined)
|
|
792
|
-
throw new Error("Cannot canonicalize undefined");
|
|
793
|
-
if (typeof value === "boolean")
|
|
794
|
-
return value ? "true" : "false";
|
|
795
|
-
if (typeof value === "number") {
|
|
796
|
-
if (!Number.isFinite(value))
|
|
797
|
-
throw new Error("non-finite number");
|
|
798
|
-
return value.toString();
|
|
799
|
-
}
|
|
800
|
-
if (typeof value === "string")
|
|
801
|
-
return JSON.stringify(value);
|
|
802
|
-
if (Array.isArray(value)) {
|
|
803
|
-
return "[" + value.map(jcsCanonicalize).join(",") + "]";
|
|
804
|
-
}
|
|
805
|
-
if (typeof value === "object") {
|
|
806
|
-
const obj = value;
|
|
807
|
-
const keys = Object.keys(obj).sort();
|
|
808
|
-
return ("{" +
|
|
809
|
-
keys.map((k) => JSON.stringify(k) + ":" + jcsCanonicalize(obj[k])).join(",") +
|
|
810
|
-
"}");
|
|
811
|
-
}
|
|
812
|
-
throw new Error(`Cannot canonicalize ${typeof value}`);
|
|
999
|
+
return canonicalize(value);
|
|
813
1000
|
}
|
|
814
1001
|
//# sourceMappingURL=data.js.map
|
package/dist/src/index.d.ts
CHANGED
|
@@ -14,7 +14,7 @@ export { WalletNamespace } from "./wallet.js";
|
|
|
14
14
|
export type { ComponentStyle, ExtractArgs, ExtractContent, ExtractData, ExtractForm, ExtractFormField, ExtractHeading, ExtractIconDeclaration, ExtractImage, ExtractLink, ExtractLogo, ExtractMeta, ExtractResult, ExtractSection, ExtractStructure, ExtractStyles, FetchAssetArgs, FetchAssetResult, PaletteEntry, VisualSignature, WebNamespaceDeps, } from "./web.js";
|
|
15
15
|
export { WebNamespace, WEB_EXTRACT_SCOPE } from "./web.js";
|
|
16
16
|
export { AithosAuth, DEFAULT_API_BASE_URL, DEFAULT_AUTH_BASE_URL, } from "./auth.js";
|
|
17
|
-
export type { AithosAuthConfig, AithosSession, ApplyPasswordResetInput, ApplyPasswordResetResult, CompleteSsoFirstLoginInput, CompleteSsoFirstLoginResult, CustodialSignInInput, CustodialSignInResult, CustodialSignUpInput, CustodialSignUpResult, DelegateInfo, ImportMandateInput, OwnerInfo, RequestPasswordResetInput, ResendVerificationInput, SignInInput, SignInWithGoogleOptions, SignInWithRecoveryInput, SignUpInput, SignUpResult, VerifyEmailInput, VerifyEmailResult, } from "./auth.js";
|
|
17
|
+
export type { AcceptInviteInput, AcceptInviteResult, AithosAuthConfig, AithosSession, ApplyPasswordResetInput, ApplyPasswordResetResult, CompleteSsoFirstLoginInput, CompleteSsoFirstLoginResult, CustodialSignInInput, CustodialSignInResult, CustodialSignUpInput, CustodialSignUpResult, DelegateInfo, ImportMandateInput, InviteCustodialInput, InviteCustodialResult, OwnerInfo, RequestPasswordResetInput, ResendVerificationInput, SignInInput, SignInWithGoogleOptions, SignInWithRecoveryInput, SignUpInput, SignUpResult, VerifyEmailInput, VerifyEmailResult, } from "./auth.js";
|
|
18
18
|
export type { SignedEnvelope } from "./internal/envelope.js";
|
|
19
19
|
export { DEFAULT_SESSION_STORAGE_KEY, defaultSessionStore, localStorageStore, noopStore, sessionStorageStore, type AithosSessionStore, } from "./session-store.js";
|
|
20
20
|
export { DEFAULT_KEYSTORE_DB_NAME, defaultKeyStore, indexedDbKeyStore, memoryKeyStore, type AithosKeyStore, type StoredDelegateKeys, type StoredOwnerKeys, } from "./key-store.js";
|
|
@@ -27,6 +27,6 @@ export type { AudienceSet, AppCreditPackId, CreateAppTopupSessionArgs, CreateApp
|
|
|
27
27
|
export * as onboarding from "./onboarding.js";
|
|
28
28
|
export { createBrowserIdentity, browserIdentityFromStored, type BrowserIdentity, } from "@aithos/protocol-client";
|
|
29
29
|
export type { Section } from "@aithos/protocol-client";
|
|
30
|
-
export { createDataClient, createDelegateDataClient, type CreateDataClientArgs, type CreateDelegateDataClientArgs, type DataClient, type DataCollection, type ReadonlyDataClient, type ReadonlyDataCollection, type ListOpts, type AithosSchemaLite, } from "./data.js";
|
|
30
|
+
export { createDataClient, createDelegateDataClient, createAppendDataClient, type CreateDataClientArgs, type CreateDelegateDataClientArgs, type CreateAppendDataClientArgs, type DataClient, type DataCollection, type ReadonlyDataClient, type ReadonlyDataCollection, type AppendOnlyDataClient, type AppendOnlyDataCollection, type ListOpts, type AithosSchemaLite, } from "./data.js";
|
|
31
31
|
export { createAssetsClient, AssetsClient, type CreateAssetsClientArgs, type AttachedContext, type AssetUploadInput, type AssetUploadResult, type AssetFetchResult, type AssetBrief, type ListAssetsOpts, type ThumbnailUploadInput, type ThumbnailUploadResult, type RecipientResolver, type RecipientSet, } from "./assets.js";
|
|
32
32
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/src/index.js
CHANGED
|
@@ -68,7 +68,7 @@ export { createBrowserIdentity, browserIdentityFromStored, } from "@aithos/proto
|
|
|
68
68
|
// `sdk.data` namespace — Aithos data sub-protocol PDS client. Manages
|
|
69
69
|
// the lifecycle of subject-owned, encrypted, schema-validated records.
|
|
70
70
|
// See spec/data/ in the aithos-protocol repo.
|
|
71
|
-
export { createDataClient, createDelegateDataClient, } from "./data.js";
|
|
71
|
+
export { createDataClient, createDelegateDataClient, createAppendDataClient, } from "./data.js";
|
|
72
72
|
// `sdk.assets` — Aithos assets sub-protocol PDS client. Upload,
|
|
73
73
|
// fetch, list, ref/unref binary content (images, PDFs, audio, video)
|
|
74
74
|
// owned by a subject. AEAD-encrypted per-asset under AMKs wrapped for
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
* with `src/data.ts` is intentional and tracked; consolidation into a
|
|
20
20
|
* shared `src/internal/canonical.ts` is planned for a follow-up release.
|
|
21
21
|
*/
|
|
22
|
-
import {
|
|
22
|
+
import { signEnvelopeWith } from "@aithos/protocol-core/envelope";
|
|
23
23
|
/**
|
|
24
24
|
* Build, canonicalize and sign an owner-path envelope per spec §11.2.
|
|
25
25
|
*
|
|
@@ -33,128 +33,27 @@ import { sha256 } from "@noble/hashes/sha2.js";
|
|
|
33
33
|
* (non-extractable keys) drop in without breaking callers.
|
|
34
34
|
*/
|
|
35
35
|
export async function signOwnerEnvelope(args) {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
36
|
+
// Delegate to the single envelope source of truth in @aithos/protocol-core.
|
|
37
|
+
// The canonicalization, params_hash, unsigned-envelope assembly and proof
|
|
38
|
+
// attachment all live there now; we only supply the pluggable async signer
|
|
39
|
+
// (preserving the WebCrypto-ready `EnvelopeSigner` abstraction). A
|
|
40
|
+
// conformance test proves this path is byte-identical to the seed-based
|
|
41
|
+
// core signer, which is itself byte-identical to the former hand-rolled
|
|
42
|
+
// implementation — so nothing changes on the wire.
|
|
43
|
+
const env = await signEnvelopeWith({
|
|
43
44
|
iss: args.iss,
|
|
44
45
|
aud: args.aud,
|
|
45
46
|
method: args.method,
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
type: "Ed25519Signature2020",
|
|
58
|
-
verificationMethod: args.verificationMethod,
|
|
59
|
-
created: new Date(iat * 1000).toISOString(),
|
|
60
|
-
proofValue: "",
|
|
61
|
-
},
|
|
62
|
-
};
|
|
63
|
-
const bytes = new TextEncoder().encode(jcsCanonicalize(unsigned));
|
|
64
|
-
const sig = await args.signer.sign(bytes);
|
|
65
|
-
return {
|
|
66
|
-
...unsigned,
|
|
67
|
-
proof: { ...unsigned.proof, proofValue: base64url(sig) },
|
|
68
|
-
};
|
|
69
|
-
}
|
|
70
|
-
/* -------------------------------------------------------------------------- */
|
|
71
|
-
/* Self-contained helpers (duplicated with src/data.ts for isolation) */
|
|
72
|
-
/* -------------------------------------------------------------------------- */
|
|
73
|
-
/** Cross-platform CSPRNG: Web Crypto in browser, Node WebCrypto in Node 19+. */
|
|
74
|
-
function cryptoRandom(n) {
|
|
75
|
-
const buf = new Uint8Array(n);
|
|
76
|
-
globalThis.crypto?.getRandomValues(buf);
|
|
77
|
-
return buf;
|
|
78
|
-
}
|
|
79
|
-
function makeUlid() {
|
|
80
|
-
// Lightweight ULID — millisecond timestamp + 80 bits of randomness.
|
|
81
|
-
// Crockford base32. For tests this is sufficient; production uses
|
|
82
|
-
// the canonical ulid package.
|
|
83
|
-
const tsBuf = new Uint8Array(6);
|
|
84
|
-
let ts = Date.now();
|
|
85
|
-
for (let i = 5; i >= 0; i--) {
|
|
86
|
-
tsBuf[i] = ts & 0xff;
|
|
87
|
-
ts = Math.floor(ts / 256);
|
|
88
|
-
}
|
|
89
|
-
const rndBuf = cryptoRandom(10);
|
|
90
|
-
const all = new Uint8Array(16);
|
|
91
|
-
all.set(tsBuf, 0);
|
|
92
|
-
all.set(rndBuf, 6);
|
|
93
|
-
const alphabet = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
|
|
94
|
-
let bits = 0;
|
|
95
|
-
let value = 0;
|
|
96
|
-
let out = "";
|
|
97
|
-
for (const b of all) {
|
|
98
|
-
value = (value << 8) | b;
|
|
99
|
-
bits += 8;
|
|
100
|
-
while (bits >= 5) {
|
|
101
|
-
out += alphabet[(value >> (bits - 5)) & 0x1f];
|
|
102
|
-
bits -= 5;
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
if (bits > 0)
|
|
106
|
-
out += alphabet[(value << (5 - bits)) & 0x1f];
|
|
107
|
-
return out.slice(0, 26);
|
|
108
|
-
}
|
|
109
|
-
/** Standard base64 (with `=` padding). Browser + Node compatible. */
|
|
110
|
-
function base64Std(bytes) {
|
|
111
|
-
let bin = "";
|
|
112
|
-
for (let i = 0; i < bytes.length; i++)
|
|
113
|
-
bin += String.fromCharCode(bytes[i]);
|
|
114
|
-
return btoa(bin);
|
|
115
|
-
}
|
|
116
|
-
/** base64url (URL-safe, no padding). */
|
|
117
|
-
function base64url(bytes) {
|
|
118
|
-
return base64Std(bytes).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
119
|
-
}
|
|
120
|
-
function sha256Hex(s) {
|
|
121
|
-
const d = sha256(new TextEncoder().encode(s));
|
|
122
|
-
let hex = "";
|
|
123
|
-
for (const b of d)
|
|
124
|
-
hex += b.toString(16).padStart(2, "0");
|
|
125
|
-
return hex;
|
|
126
|
-
}
|
|
127
|
-
/**
|
|
128
|
-
* JCS-style canonicalization (RFC 8785 subset).
|
|
129
|
-
*
|
|
130
|
-
* Sorted object keys, no whitespace, finite numbers via `toString()`,
|
|
131
|
-
* strings via `JSON.stringify` (escapes), arrays preserve order.
|
|
132
|
-
* `undefined` is rejected (RFC 8785 §3.2.2).
|
|
133
|
-
*/
|
|
134
|
-
function jcsCanonicalize(value) {
|
|
135
|
-
if (value === null)
|
|
136
|
-
return "null";
|
|
137
|
-
if (value === undefined)
|
|
138
|
-
throw new Error("Cannot canonicalize undefined");
|
|
139
|
-
if (typeof value === "boolean")
|
|
140
|
-
return value ? "true" : "false";
|
|
141
|
-
if (typeof value === "number") {
|
|
142
|
-
if (!Number.isFinite(value))
|
|
143
|
-
throw new Error("non-finite number");
|
|
144
|
-
return value.toString();
|
|
145
|
-
}
|
|
146
|
-
if (typeof value === "string")
|
|
147
|
-
return JSON.stringify(value);
|
|
148
|
-
if (Array.isArray(value)) {
|
|
149
|
-
return "[" + value.map(jcsCanonicalize).join(",") + "]";
|
|
150
|
-
}
|
|
151
|
-
if (typeof value === "object") {
|
|
152
|
-
const obj = value;
|
|
153
|
-
const keys = Object.keys(obj).sort();
|
|
154
|
-
return ("{" +
|
|
155
|
-
keys.map((k) => JSON.stringify(k) + ":" + jcsCanonicalize(obj[k])).join(",") +
|
|
156
|
-
"}");
|
|
157
|
-
}
|
|
158
|
-
throw new Error(`Cannot canonicalize ${typeof value}`);
|
|
47
|
+
params: args.params,
|
|
48
|
+
verificationMethod: args.verificationMethod,
|
|
49
|
+
sign: (bytes) => args.signer.sign(bytes),
|
|
50
|
+
ttlSeconds: args.ttlSeconds,
|
|
51
|
+
now: args.now,
|
|
52
|
+
nonce: args.nonce,
|
|
53
|
+
...(args.mandate !== undefined
|
|
54
|
+
? { mandate: args.mandate }
|
|
55
|
+
: {}),
|
|
56
|
+
});
|
|
57
|
+
return env;
|
|
159
58
|
}
|
|
160
59
|
//# sourceMappingURL=envelope.js.map
|
package/dist/src/mandates.d.ts
CHANGED
|
@@ -14,6 +14,19 @@ export type Scope = "ethos.read.public" | "ethos.read.circle" | "ethos.read.self
|
|
|
14
14
|
* `data.<collection>.<action>` (Aithos-protocol `spec/data/04-mandates.md`
|
|
15
15
|
* §4.2) and the server-side check `requireScope` in data-backend. */
|
|
16
16
|
export type DataAction = "read" | "write" | "admin";
|
|
17
|
+
/**
|
|
18
|
+
* A **lateral** data capability — deliberately OUTSIDE the
|
|
19
|
+
* `read ⊂ write ⊂ admin` hierarchy (the same way `gamma.write` sits beside
|
|
20
|
+
* the ethos scopes). Keeping it a separate type makes the security invariant
|
|
21
|
+
* structural rather than conventional: `append` can never be reached by
|
|
22
|
+
* widening a `write`/`admin` scope, so it cannot accidentally carry read.
|
|
23
|
+
*
|
|
24
|
+
* `append` authorizes `insert_record` ONLY (no read, update, or delete). The
|
|
25
|
+
* depositor seals each record's DEK to the owner's public key
|
|
26
|
+
* ({@link createAppendDataClient}) and holds no read capability — it cannot
|
|
27
|
+
* decrypt anything in the collection, not even its own deposit.
|
|
28
|
+
*/
|
|
29
|
+
export type DataLateralAction = "append";
|
|
17
30
|
/**
|
|
18
31
|
* A data-access scope: `data.<collection>.<action>`, or the cross-collection
|
|
19
32
|
* wildcard `data.*.<action>`. Examples: `data.contacts.read`,
|
|
@@ -29,7 +42,7 @@ export type DataAction = "read" | "write" | "admin";
|
|
|
29
42
|
* Collection names MUST NOT contain `.` (the server splits the scope on
|
|
30
43
|
* `.` and reads the first three segments).
|
|
31
44
|
*/
|
|
32
|
-
export type DataScope = `data.${string}.${DataAction}`;
|
|
45
|
+
export type DataScope = `data.${string}.${DataAction}` | `data.${string}.${DataLateralAction}`;
|
|
33
46
|
/**
|
|
34
47
|
* The opt-in scope that authorizes a delegate to spend the subject's
|
|
35
48
|
* compute credits via the Aithos compute proxy. Mirror of
|
package/dist/src/mandates.js
CHANGED
|
@@ -250,9 +250,10 @@ function defaultSphereFromScopes(scopes) {
|
|
|
250
250
|
return "self";
|
|
251
251
|
}
|
|
252
252
|
/** `true` iff `s` is a well-formed data scope `data.<collection>.<action>`
|
|
253
|
-
* with no filter suffix and a non-empty, dot-free collection name.
|
|
253
|
+
* with no filter suffix and a non-empty, dot-free collection name. The
|
|
254
|
+
* lateral `append` action is accepted alongside read/write/admin. */
|
|
254
255
|
function isWellFormedDataScope(s) {
|
|
255
|
-
return /^data\.[^.]+\.(read|write|admin)$/.test(s);
|
|
256
|
+
return /^data\.[^.]+\.(read|write|admin|append)$/.test(s);
|
|
256
257
|
}
|
|
257
258
|
/**
|
|
258
259
|
* Validate the SDK-side `compute` namespace and project it onto the
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Copyright 2026 Mathieu Colla
|
|
3
|
+
/**
|
|
4
|
+
* Safety gate for the canonicalization refactor.
|
|
5
|
+
*
|
|
6
|
+
* The SDK historically carried three hand-rolled copies of `jcsCanonicalize`
|
|
7
|
+
* (internal/envelope.ts, data.ts, apps.ts). data.ts uses it to canonicalize a
|
|
8
|
+
* record payload BEFORE encryption (it feeds the AAD/plaintext), so a single
|
|
9
|
+
* differing byte vs. the canonicalization used at decrypt/verify time would
|
|
10
|
+
* silently corrupt data. Before swapping those copies onto
|
|
11
|
+
* `@aithos/protocol-core`'s `canonicalize`, this test proves the two produce
|
|
12
|
+
* identical output across a representative corpus.
|
|
13
|
+
*
|
|
14
|
+
* Known, accepted divergence: lone UTF-16 surrogates. `JSON.stringify` escapes
|
|
15
|
+
* them (\uXXXX) whereas core emits the raw code unit. Lone surrogates never
|
|
16
|
+
* appear in Aithos payloads (record fields are well-formed JSON strings), so
|
|
17
|
+
* the corpus excludes them by design.
|
|
18
|
+
*/
|
|
19
|
+
import { describe, test } from "node:test";
|
|
20
|
+
import { strict as assert } from "node:assert";
|
|
21
|
+
import { canonicalize } from "@aithos/protocol-core/canonical";
|
|
22
|
+
/** Exact copy of the SDK's legacy jcsCanonicalize (pre-refactor reference). */
|
|
23
|
+
function jcsCanonicalize(value) {
|
|
24
|
+
if (value === null)
|
|
25
|
+
return "null";
|
|
26
|
+
if (value === undefined)
|
|
27
|
+
throw new Error("Cannot canonicalize undefined");
|
|
28
|
+
if (typeof value === "boolean")
|
|
29
|
+
return value ? "true" : "false";
|
|
30
|
+
if (typeof value === "number") {
|
|
31
|
+
if (!Number.isFinite(value))
|
|
32
|
+
throw new Error("non-finite number");
|
|
33
|
+
return value.toString();
|
|
34
|
+
}
|
|
35
|
+
if (typeof value === "string")
|
|
36
|
+
return JSON.stringify(value);
|
|
37
|
+
if (Array.isArray(value)) {
|
|
38
|
+
return "[" + value.map(jcsCanonicalize).join(",") + "]";
|
|
39
|
+
}
|
|
40
|
+
if (typeof value === "object") {
|
|
41
|
+
const obj = value;
|
|
42
|
+
const keys = Object.keys(obj).sort();
|
|
43
|
+
return ("{" +
|
|
44
|
+
keys
|
|
45
|
+
.map((k) => JSON.stringify(k) + ":" + jcsCanonicalize(obj[k]))
|
|
46
|
+
.join(",") +
|
|
47
|
+
"}");
|
|
48
|
+
}
|
|
49
|
+
throw new Error(`Cannot canonicalize ${typeof value}`);
|
|
50
|
+
}
|
|
51
|
+
const corpus = [
|
|
52
|
+
null,
|
|
53
|
+
true,
|
|
54
|
+
false,
|
|
55
|
+
0,
|
|
56
|
+
-1,
|
|
57
|
+
42,
|
|
58
|
+
Number.MAX_SAFE_INTEGER,
|
|
59
|
+
"",
|
|
60
|
+
"hello",
|
|
61
|
+
"with \"quotes\" and \\ backslash",
|
|
62
|
+
"tab\tnewline\nreturn\rbackspace\bform\f",
|
|
63
|
+
"control end",
|
|
64
|
+
"accented éàùçö and emoji 😀🚀 and 漢字",
|
|
65
|
+
"slash / and at @ and unicode nbsp",
|
|
66
|
+
[],
|
|
67
|
+
[1, 2, 3],
|
|
68
|
+
["z", "a", "m"],
|
|
69
|
+
[{ b: 1, a: 2 }, [3, [4, 5]]],
|
|
70
|
+
{},
|
|
71
|
+
{ b: 2, a: 1, c: 3 },
|
|
72
|
+
{ z: { y: { x: [1, "two", false, null] } } },
|
|
73
|
+
{ "key with spaces": 1, "weird:char": 2, "": "empty key" },
|
|
74
|
+
{ émoji: "😀", "漢字": 1, A: 0, a: 0 }, // mixed-case + non-ascii keys (UTF-16 order)
|
|
75
|
+
{
|
|
76
|
+
record: { name: "Aïko", tags: ["x", "y"], n: 7, active: true, meta: null },
|
|
77
|
+
},
|
|
78
|
+
];
|
|
79
|
+
describe("canonicalize conformance — core vs legacy SDK jcsCanonicalize", () => {
|
|
80
|
+
for (const [i, value] of corpus.entries()) {
|
|
81
|
+
test(`corpus[${i}] is byte-identical`, () => {
|
|
82
|
+
assert.equal(canonicalize(value), jcsCanonicalize(value));
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
//# sourceMappingURL=canonical-conformance.test.js.map
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Copyright 2026 Mathieu Colla
|
|
3
|
+
/**
|
|
4
|
+
* Conformance for the envelope-signing refactor.
|
|
5
|
+
*
|
|
6
|
+
* `signOwnerEnvelope` now delegates to `@aithos/protocol-core`'s
|
|
7
|
+
* `signEnvelopeWith` (pluggable async signer) instead of carrying a private
|
|
8
|
+
* JCS + signing implementation. These tests assert the migrated path is
|
|
9
|
+
* correct and stable:
|
|
10
|
+
*
|
|
11
|
+
* 1. the produced envelope's `proof.proofValue` verifies against the signer's
|
|
12
|
+
* public key over core's canonical signing bytes (i.e. the server will
|
|
13
|
+
* accept it);
|
|
14
|
+
* 2. `params_hash` equals core's `envelopeParamsHash(params)`;
|
|
15
|
+
* 3. with a fixed clock + nonce the output is deterministic (snapshot), which
|
|
16
|
+
* is what guarantees the wire format did not shift under the refactor.
|
|
17
|
+
*
|
|
18
|
+
* Note: this suite needs the SDK to resolve a build of `@aithos/protocol-core`
|
|
19
|
+
* that exposes `signEnvelopeWith` (>= 0.6.3). Run via `npm test`.
|
|
20
|
+
*/
|
|
21
|
+
import { describe, test } from "node:test";
|
|
22
|
+
import { strict as assert } from "node:assert";
|
|
23
|
+
import * as ed from "@noble/ed25519";
|
|
24
|
+
import { sha512 } from "@noble/hashes/sha512";
|
|
25
|
+
import { envelopeParamsHash, envelopeSigningBytes, } from "@aithos/protocol-core/envelope";
|
|
26
|
+
import { signOwnerEnvelope } from "../src/internal/envelope.js";
|
|
27
|
+
ed.etc.sha512Sync = (...m) => sha512(ed.etc.concatBytes(...m));
|
|
28
|
+
const SEED = new Uint8Array(32).fill(11);
|
|
29
|
+
const PUB = ed.getPublicKey(SEED);
|
|
30
|
+
const NOW = new Date("2026-05-31T12:00:00.000Z");
|
|
31
|
+
const NONCE = "01J0ENVELOPECONFORMANCE0000";
|
|
32
|
+
const ISS = "did:aithos:z6MkEnvelopeConformance";
|
|
33
|
+
const VM = `${ISS}#public`;
|
|
34
|
+
const base = {
|
|
35
|
+
iss: ISS,
|
|
36
|
+
aud: "https://api.aithos.be/mcp/primitives/write",
|
|
37
|
+
method: "aithos.data.insert_record",
|
|
38
|
+
params: { b: 2, a: 1, nested: { y: [3, 1, 2], x: "z" } },
|
|
39
|
+
verificationMethod: VM,
|
|
40
|
+
signer: { sign: async (bytes) => ed.sign(bytes, SEED) },
|
|
41
|
+
ttlSeconds: 60,
|
|
42
|
+
now: NOW,
|
|
43
|
+
nonce: NONCE,
|
|
44
|
+
};
|
|
45
|
+
describe("signOwnerEnvelope — core delegation conformance", () => {
|
|
46
|
+
test("proofValue verifies against signing bytes (server will accept)", async () => {
|
|
47
|
+
const env = await signOwnerEnvelope({ ...base });
|
|
48
|
+
const sig = b64urlDecode(env.proof.proofValue);
|
|
49
|
+
assert.equal(ed.verify(sig, envelopeSigningBytes(env), PUB), true);
|
|
50
|
+
});
|
|
51
|
+
test("params_hash matches core.envelopeParamsHash", async () => {
|
|
52
|
+
const env = await signOwnerEnvelope({ ...base });
|
|
53
|
+
assert.equal(env.params_hash, envelopeParamsHash(base.params));
|
|
54
|
+
});
|
|
55
|
+
test("deterministic under fixed clock + nonce (wire-format snapshot)", async () => {
|
|
56
|
+
const a = await signOwnerEnvelope({ ...base });
|
|
57
|
+
const b = await signOwnerEnvelope({ ...base });
|
|
58
|
+
assert.equal(JSON.stringify(a), JSON.stringify(b));
|
|
59
|
+
assert.equal(a["aithos-envelope"], "0.1.0");
|
|
60
|
+
assert.equal(a.iat, 1780228800);
|
|
61
|
+
assert.equal(a.exp, 1780228860);
|
|
62
|
+
assert.equal(a.nonce, NONCE);
|
|
63
|
+
assert.equal(a.proof.verificationMethod, VM);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
function b64urlDecode(s) {
|
|
67
|
+
const pad = s.length % 4 === 0 ? "" : "=".repeat(4 - (s.length % 4));
|
|
68
|
+
const b64 = s.replace(/-/g, "+").replace(/_/g, "/") + pad;
|
|
69
|
+
const bin = atob(b64);
|
|
70
|
+
const out = new Uint8Array(bin.length);
|
|
71
|
+
for (let i = 0; i < bin.length; i++)
|
|
72
|
+
out[i] = bin.charCodeAt(i);
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
75
|
+
//# sourceMappingURL=envelope-core-conformance.test.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aithos/sdk",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.48",
|
|
4
4
|
"description": "Aithos SDK — high-level TypeScript developer kit for building agentic apps on the Aithos protocol. Wraps @aithos/protocol-client and exposes the Aithos compute proxy and wallet (Stripe top-up) endpoints.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"aithos",
|
|
@@ -58,6 +58,7 @@
|
|
|
58
58
|
"peerDependencies": {
|
|
59
59
|
"@aithos/assets-crypto": ">=0.1.0-alpha.1 <0.2.0",
|
|
60
60
|
"@aithos/protocol-client": "^0.1.0-alpha.14",
|
|
61
|
+
"@aithos/protocol-core": ">=0.6.3 <0.7.0",
|
|
61
62
|
"react": "^18.0.0 || ^19.0.0"
|
|
62
63
|
},
|
|
63
64
|
"peerDependenciesMeta": {
|
|
@@ -71,6 +72,7 @@
|
|
|
71
72
|
"devDependencies": {
|
|
72
73
|
"@aithos/assets-crypto": "^0.1.0-alpha.1",
|
|
73
74
|
"@aithos/protocol-client": "^0.1.0-alpha.14",
|
|
75
|
+
"@aithos/protocol-core": ">=0.6.3 <0.7.0",
|
|
74
76
|
"@types/node": "^24.12.2",
|
|
75
77
|
"fake-indexeddb": "^6.2.5",
|
|
76
78
|
"typescript": "^5.9.2"
|