@calimero-network/mero-js 22.7.0 → 22.9.0

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.
Files changed (76) hide show
  1. package/dist/account/account.d.ts +99 -4
  2. package/dist/account/account.js +0 -0
  3. package/dist/account/index.d.ts +6 -2
  4. package/dist/account/index.js +3 -1
  5. package/dist/admin-api/admin-client.d.ts +154 -2
  6. package/dist/admin-api/admin-client.js +255 -1
  7. package/dist/admin-api/admin-types.d.ts +193 -5
  8. package/dist/cloud/cloud-client.d.ts +38 -8
  9. package/dist/cloud/cloud-client.js +38 -21
  10. package/dist/cloud/connect.d.ts +101 -2
  11. package/dist/cloud/connect.js +84 -8
  12. package/dist/cloud/enrol-redirect.d.ts +195 -0
  13. package/dist/cloud/enrol-redirect.js +236 -0
  14. package/dist/cloud/index.d.ts +6 -2
  15. package/dist/cloud/index.js +7 -1
  16. package/dist/cloud/link-redirect.d.ts +85 -0
  17. package/dist/cloud/link-redirect.js +105 -0
  18. package/dist/crypto/internal.d.ts +15 -0
  19. package/dist/crypto/internal.js +18 -0
  20. package/dist/device-cert/device-cert.d.ts +96 -2
  21. package/dist/device-cert/device-cert.js +101 -11
  22. package/dist/device-cert/device-scope.d.ts +58 -0
  23. package/dist/device-cert/device-scope.js +76 -0
  24. package/dist/device-cert/index.d.ts +4 -2
  25. package/dist/device-cert/index.js +2 -1
  26. package/dist/events/sse.d.ts +22 -2
  27. package/dist/events/sse.js +20 -11
  28. package/dist/http-client/http-types.d.ts +18 -0
  29. package/dist/http-client/web-client.js +21 -1
  30. package/dist/index.browser.mjs +2 -2
  31. package/dist/index.browser.mjs.map +4 -4
  32. package/dist/index.cjs +1955 -496
  33. package/dist/index.cjs.map +4 -4
  34. package/dist/index.d.ts +13 -4
  35. package/dist/index.js +21 -2
  36. package/dist/index.mjs +1955 -496
  37. package/dist/index.mjs.map +4 -4
  38. package/dist/invitation/index.d.ts +2 -0
  39. package/dist/invitation/index.js +2 -0
  40. package/dist/invitation/invitation.d.ts +106 -0
  41. package/dist/invitation/invitation.js +104 -0
  42. package/dist/login/login.d.ts +11 -1
  43. package/dist/login/login.js +5 -4
  44. package/dist/login/session.d.ts +21 -5
  45. package/dist/login/session.js +7 -15
  46. package/dist/namespace-op/namespace-op.d.ts +14 -2
  47. package/dist/namespace-op/namespace-op.js +13 -19
  48. package/dist/relay/index.d.ts +2 -2
  49. package/dist/relay/index.js +1 -1
  50. package/dist/relay/nonce-source.d.ts +135 -0
  51. package/dist/relay/nonce-source.js +187 -19
  52. package/dist/relay/relay-client.d.ts +60 -5
  53. package/dist/relay/relay-client.js +84 -4
  54. package/dist/relay-attestation/attest-node-key.d.ts +66 -0
  55. package/dist/relay-attestation/attest-node-key.js +110 -0
  56. package/dist/relay-attestation/index.d.ts +1 -0
  57. package/dist/relay-attestation/index.js +1 -0
  58. package/dist/request-proof/authorizer.d.ts +48 -0
  59. package/dist/request-proof/authorizer.js +65 -0
  60. package/dist/request-proof/index.d.ts +4 -0
  61. package/dist/request-proof/index.js +2 -0
  62. package/dist/request-proof/request-proof.d.ts +56 -0
  63. package/dist/request-proof/request-proof.js +111 -0
  64. package/dist/rpc/index.d.ts +21 -0
  65. package/dist/rpc/index.js +20 -0
  66. package/dist/transport/index.d.ts +6 -0
  67. package/dist/transport/index.js +3 -0
  68. package/dist/transport/mero-client.d.ts +286 -0
  69. package/dist/transport/mero-client.js +379 -0
  70. package/dist/transport/relay-observer.d.ts +152 -0
  71. package/dist/transport/relay-observer.js +173 -0
  72. package/dist/transport/relay-transport.d.ts +73 -0
  73. package/dist/transport/relay-transport.js +94 -0
  74. package/dist/transport/types.d.ts +100 -0
  75. package/dist/transport/types.js +32 -0
  76. package/package.json +1 -1
@@ -0,0 +1,2 @@
1
+ export { signGroupInvitation, encodeGroupInvitation, groupInvitationHash, defaultAdmitters, MAX_INVITATION_VALIDITY_SECS, INVITED_ROLE, } from './invitation.js';
2
+ export type { GroupInvitationInput } from './invitation.js';
@@ -0,0 +1,2 @@
1
+ // Minting a group invitation off-node, with a device key.
2
+ export { signGroupInvitation, encodeGroupInvitation, groupInvitationHash, defaultAdmitters, MAX_INVITATION_VALIDITY_SECS, INVITED_ROLE, } from './invitation.js';
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Minting a group invitation without a node.
3
+ *
4
+ * An invitation is not a governance op. Nothing is published when one is made:
5
+ * it is a bearer credential, `GroupInvitationFromAdmin` borsh-encoded, hashed
6
+ * with SHA-256 and signed by the inviter's key. A node mints one with its own
7
+ * namespace key; an account with **no node** signs it here with its device key.
8
+ *
9
+ * Every peer that later folds the join resolves `inviter_identity` — the
10
+ * signing key — to an account through the namespace's device bindings, and then
11
+ * checks that account holds admin or `CAN_INVITE_MEMBERS` at the join's causal
12
+ * cut. So the device key must be **bound** in the namespace first. It is when
13
+ * the account joined through an invitation; an account that was added by
14
+ * account (`MemberAdded`) has no binding, and a relay has to carry its device
15
+ * link first — see `AdminApiClient.linkAccountDevice` and `signDeviceScope`.
16
+ * Without that, every peer refuses the join as signed by a key "bound to no
17
+ * account".
18
+ *
19
+ * Derived from core:
20
+ *
21
+ * - `GroupInvitationFromAdmin` / `SignedGroupOpenInvitation` —
22
+ * `crates/context/config/src/types.rs`
23
+ * - the signing — `crates/context/src/handlers/create_group_invitation.rs`:
24
+ * `sign(sha256(borsh(invitation)))`
25
+ * - the admitter default — `NamespaceMembershipService::default_admitters`
26
+ *
27
+ * The unit tests pin bytes and a signature produced by core's own types.
28
+ */
29
+ import type { GroupInvitationFromAdmin, GroupMember, SignedGroupOpenInvitation } from '../admin-api/admin-types.js';
30
+ import { type Signer } from '../signer/signer.js';
31
+ /**
32
+ * Core's `MAX_INVITATION_VALIDITY_SECS`: one day. Core clamps what a node mints
33
+ * to it, and this does the same, so a leaked invitation is redeemable no longer
34
+ * than one a node would have issued.
35
+ */
36
+ export declare const MAX_INVITATION_VALIDITY_SECS: number;
37
+ /** `invited_role` values, as core numbers them. */
38
+ export declare const INVITED_ROLE: {
39
+ readonly Admin: 0;
40
+ readonly Member: 1;
41
+ readonly ReadOnly: 2;
42
+ };
43
+ /** Borsh-encode the signed body of an invitation, field for field with core. */
44
+ export declare function encodeGroupInvitation(body: GroupInvitationFromAdmin): Uint8Array;
45
+ /** `sha256(borsh(invitation))`: the 32 bytes the inviter's key signs. */
46
+ export declare function groupInvitationHash(body: GroupInvitationFromAdmin): Promise<Uint8Array>;
47
+ /**
48
+ * The admitters a node names when its caller names none: the group's admins,
49
+ * sorted by account bytes and deduplicated, as core's `BTreeSet` leaves them.
50
+ *
51
+ * Core also adds TEE-admitted accounts, which a member list cannot tell apart
52
+ * from members by role alone; name those in `admitters` explicitly.
53
+ */
54
+ export declare function defaultAdmitters(members: readonly GroupMember[]): string[];
55
+ /** What {@link signGroupInvitation} needs. */
56
+ export interface GroupInvitationInput {
57
+ /** The group (or namespace) invited to, 64 hex. */
58
+ groupId: string;
59
+ /**
60
+ * The account the inviter acts as, 64 hex. An unsigned bootstrap hint the
61
+ * joiner seeds the group's admin view from; it confers nothing.
62
+ */
63
+ inviterAccount: string;
64
+ /** The device key as a {@link Signer}. Pass this or {@link deviceSecret}. */
65
+ signer?: Signer;
66
+ /** The device signing secret, 64 hex. Pass this or {@link signer}. */
67
+ deviceSecret?: string;
68
+ /**
69
+ * Accounts permitted to admit a claim, 64 hex each. Signed.
70
+ *
71
+ * Omitted or empty, it defaults to the group's admins, which is what a node
72
+ * does — pass {@link members} (from `listGroupMembers`) to derive them. An
73
+ * empty list would mean *claimable by broadcast*, which publishes the
74
+ * invitation to every subscriber of the namespace topic, so this never
75
+ * produces one.
76
+ */
77
+ admitters?: readonly string[];
78
+ /** The group's members, to default {@link admitters} from. */
79
+ members?: readonly GroupMember[];
80
+ /**
81
+ * The role granted, as {@link INVITED_ROLE} numbers it. Defaults to Member.
82
+ * Only an admin may invite an admin; peers refuse the join otherwise.
83
+ */
84
+ invitedRole?: number;
85
+ /**
86
+ * Seconds from now until it expires, clamped to
87
+ * {@link MAX_INVITATION_VALIDITY_SECS} (also the default).
88
+ */
89
+ validForSecs?: number;
90
+ /** Unix seconds to count from. Defaults to the clock; for tests. */
91
+ now?: number;
92
+ /** The 32-byte uniqueness nonce. Defaults to fresh random bytes. */
93
+ nonce?: Uint8Array;
94
+ /** Unsigned bootstrap: the group's application id, 64 hex. */
95
+ applicationId?: string;
96
+ /** Unsigned bootstrap: the group's `app_key` (core's `bytecode_id`), 64 hex. */
97
+ appKey?: string;
98
+ /** Unsigned bootstrap: multiaddrs for the admitters, `/p2p/<peer>` included. */
99
+ admitterAddrs?: readonly string[];
100
+ }
101
+ /**
102
+ * Sign a group invitation with a device key, returning it in the JSON shape a
103
+ * node's `createGroupInvitation` returns — so it goes to `joinGroup`,
104
+ * `signMemberJoinOp` and `admitJoin` unchanged.
105
+ */
106
+ export declare function signGroupInvitation(input: GroupInvitationInput): Promise<SignedGroupOpenInvitation>;
@@ -0,0 +1,104 @@
1
+ import { concat, fromHex, hex, u32le, u64le } from '../crypto/internal.js';
2
+ import { resolveSigner } from '../signer/signer.js';
3
+ /**
4
+ * Core's `MAX_INVITATION_VALIDITY_SECS`: one day. Core clamps what a node mints
5
+ * to it, and this does the same, so a leaked invitation is redeemable no longer
6
+ * than one a node would have issued.
7
+ */
8
+ export const MAX_INVITATION_VALIDITY_SECS = 24 * 60 * 60;
9
+ /** `invited_role` values, as core numbers them. */
10
+ export const INVITED_ROLE = { Admin: 0, Member: 1, ReadOnly: 2 };
11
+ /** Borsh-encode the signed body of an invitation, field for field with core. */
12
+ export function encodeGroupInvitation(body) {
13
+ const admitters = body.admitters ?? [];
14
+ return concat(
15
+ // `SignerId` and `ContextGroupId` cross JSON as 32 numbers; `AccountId`
16
+ // crosses as hex. Same width, different spelling.
17
+ bytes32(body.inviter_identity, 'inviter_identity'), bytes32(body.group_id, 'group_id'), u64le(body.expiration_timestamp), bytes32(body.secret_salt, 'secret_salt'), new Uint8Array([body.invited_role]), u32le(admitters.length), ...admitters.map((a, i) => fromHex(a, `admitters[${i}]`, 32)));
18
+ }
19
+ /** `sha256(borsh(invitation))`: the 32 bytes the inviter's key signs. */
20
+ export async function groupInvitationHash(body) {
21
+ return new Uint8Array(await crypto.subtle.digest('SHA-256', encodeGroupInvitation(body)));
22
+ }
23
+ /**
24
+ * The admitters a node names when its caller names none: the group's admins,
25
+ * sorted by account bytes and deduplicated, as core's `BTreeSet` leaves them.
26
+ *
27
+ * Core also adds TEE-admitted accounts, which a member list cannot tell apart
28
+ * from members by role alone; name those in `admitters` explicitly.
29
+ */
30
+ export function defaultAdmitters(members) {
31
+ const admins = new Set(members.filter((m) => m.role === 'Admin').map((m) => m.identity.toLowerCase()));
32
+ // Lowercase hex sorts as the bytes do.
33
+ return [...admins].sort();
34
+ }
35
+ /**
36
+ * Sign a group invitation with a device key, returning it in the JSON shape a
37
+ * node's `createGroupInvitation` returns — so it goes to `joinGroup`,
38
+ * `signMemberJoinOp` and `admitJoin` unchanged.
39
+ */
40
+ export async function signGroupInvitation(input) {
41
+ const signer = await resolveSigner(input.deviceSecret, input.signer, 'deviceSecret');
42
+ const inviterKey = fromHex(signer.publicKey, 'signer.publicKey', 32);
43
+ const groupId = fromHex(input.groupId, 'groupId', 32);
44
+ fromHex(input.inviterAccount, 'inviterAccount', 32);
45
+ let admitters = [...(input.admitters ?? [])].map((a) => a.trim().toLowerCase());
46
+ if (admitters.length === 0) {
47
+ if (!input.members) {
48
+ throw new Error('name the admitters, or pass the group members to default them to its admins: ' +
49
+ 'an invitation with no admitters is claimable by broadcast');
50
+ }
51
+ admitters = defaultAdmitters(input.members);
52
+ if (admitters.length === 0) {
53
+ throw new Error('the member list names no admin, so there is nobody to default the admitters to');
54
+ }
55
+ }
56
+ admitters.forEach((a, i) => fromHex(a, `admitters[${i}]`, 32));
57
+ const invitedRole = input.invitedRole ?? INVITED_ROLE.Member;
58
+ if (![0, 1, 2].includes(invitedRole)) {
59
+ throw new Error(`invitedRole must be 0 (Admin), 1 (Member) or 2 (ReadOnly), got ${invitedRole}`);
60
+ }
61
+ const now = input.now ?? Math.floor(Date.now() / 1000);
62
+ const validFor = Math.min(input.validForSecs ?? MAX_INVITATION_VALIDITY_SECS, MAX_INVITATION_VALIDITY_SECS);
63
+ if (!Number.isInteger(validFor) || validFor <= 0) {
64
+ throw new Error(`validForSecs must be a positive whole number, got ${input.validForSecs}`);
65
+ }
66
+ const nonce = input.nonce ?? crypto.getRandomValues(new Uint8Array(32));
67
+ if (nonce.length !== 32) {
68
+ throw new Error(`nonce must be 32 bytes, got ${nonce.length}`);
69
+ }
70
+ const invitation = {
71
+ inviter_identity: Array.from(inviterKey),
72
+ group_id: Array.from(groupId),
73
+ expiration_timestamp: now + validFor,
74
+ secret_salt: Array.from(nonce),
75
+ invited_role: invitedRole,
76
+ admitters,
77
+ };
78
+ const signature = await signer.sign(await groupInvitationHash(invitation));
79
+ if (signature.length !== 64) {
80
+ throw new Error(`signer returned ${signature.length} bytes, expected a 64-byte Ed25519 signature`);
81
+ }
82
+ const signed = {
83
+ invitation,
84
+ inviter_signature: hex(signature),
85
+ inviter_account: input.inviterAccount.trim().toLowerCase(),
86
+ };
87
+ if (input.admitterAddrs?.length)
88
+ signed.admitter_addrs = [...input.admitterAddrs];
89
+ if (input.applicationId) {
90
+ signed.application_id = Array.from(fromHex(input.applicationId, 'applicationId', 32));
91
+ }
92
+ if (input.appKey)
93
+ signed.app_key = Array.from(fromHex(input.appKey, 'appKey', 32));
94
+ // The brand marks a value as one to pass through unchanged. This one is
95
+ // signed, so it qualifies the same way a node's does.
96
+ return signed;
97
+ }
98
+ /** A required `[u8; 32]`, as JSON carries it: 32 byte values. */
99
+ function bytes32(value, label) {
100
+ if (value.length !== 32) {
101
+ throw new Error(`${label} must be 32 bytes, got ${value.length}`);
102
+ }
103
+ return new Uint8Array(value);
104
+ }
@@ -31,6 +31,7 @@
31
31
  * order is implicit in a `derive`, so nothing on that side forces anyone to
32
32
  * notice this file depends on both. This module's test asserts the same vectors.
33
33
  */
34
+ import { type Signer } from '../signer/signer.js';
34
35
  /**
35
36
  * The client surface a session is bound to.
36
37
  *
@@ -87,8 +88,17 @@ export interface LoginStatementInput {
87
88
  *
88
89
  * Never sent anywhere. It signs locally and only the signature travels, which
89
90
  * is what lets a statement be minted by something holding no node.
91
+ *
92
+ * Mutually exclusive with {@link LoginStatementInput.signer}, and the weaker
93
+ * of the two in a browser: a secret that exists as a string can be read by
94
+ * anything running on the origin.
95
+ */
96
+ deviceSecret?: string;
97
+ /**
98
+ * The device's signer — use instead of
99
+ * {@link LoginStatementInput.deviceSecret} when the key cannot be exported.
90
100
  */
91
- deviceSecret: string;
101
+ signer?: Signer;
92
102
  }
93
103
  /**
94
104
  * Sign a login statement and return it hex-encoded, ready to present with an
@@ -31,7 +31,8 @@
31
31
  * order is implicit in a `derive`, so nothing on that side forces anyone to
32
32
  * notice this file depends on both. This module's test asserts the same vectors.
33
33
  */
34
- import { concat, derivePublicKey, domainHash, fromHex, hex, importSigningKey, u32le, u64le, } from '../crypto/internal.js';
34
+ import { concat, domainHash, fromHex, hex, u32le, u64le, } from '../crypto/internal.js';
35
+ import { resolveSigner } from '../signer/signer.js';
35
36
  const SIGN_DOMAIN = new TextEncoder().encode('calimero.auth.login.v1');
36
37
  /** The borsh enum tag for each variant, in core's declaration order. */
37
38
  const AUDIENCE_TAG = {
@@ -91,8 +92,8 @@ export async function signLoginStatement(input) {
91
92
  const node = fromHex(input.node, 'node', 32);
92
93
  const challenge = fromHex(input.challenge, 'challenge', 32);
93
94
  const sessionKey = fromHex(input.sessionKey, 'sessionKey', 32);
94
- const key = await importSigningKey(input.deviceSecret);
95
- const deviceKey = await derivePublicKey(input.deviceSecret);
95
+ const signer = await resolveSigner(input.deviceSecret, input.signer, 'deviceSecret');
96
+ const deviceKey = fromHex(signer.publicKey, 'signer.publicKey', 32);
96
97
  const issuedAt = u64le(input.issuedAt);
97
98
  const expiresAt = u64le(input.expiresAt);
98
99
  const preimage = await domainHash(SIGN_DOMAIN, [
@@ -104,6 +105,6 @@ export async function signLoginStatement(input) {
104
105
  issuedAt,
105
106
  expiresAt,
106
107
  ]);
107
- const signature = new Uint8Array(await crypto.subtle.sign({ name: 'Ed25519' }, key, preimage));
108
+ const signature = await signer.sign(preimage);
108
109
  return hex(concat(node, audienceWire(input.audience), challenge, sessionKey, deviceKey, issuedAt, expiresAt, signature));
109
110
  }
@@ -31,6 +31,7 @@
31
31
  * cannot be escalated into use of the key that authorised it.
32
32
  */
33
33
  import { type Audience } from './login.js';
34
+ import { type Signer } from '../signer/signer.js';
34
35
  /** A freshly minted ephemeral keypair, hex-encoded. */
35
36
  export interface SessionKeyPair {
36
37
  /** The public half — what the statement is signed over. */
@@ -59,8 +60,18 @@ export interface LoginConfig {
59
60
  * module note.
60
61
  */
61
62
  node: string;
62
- /** The device's ed25519 signing secret, hex (32 bytes). Never sent. */
63
- deviceSecret: string;
63
+ /**
64
+ * The device's ed25519 signing secret, hex (32 bytes). Never sent.
65
+ *
66
+ * Mutually exclusive with {@link LoginConfig.signer}, and the weaker of the
67
+ * two in a browser — see {@link Signer}.
68
+ */
69
+ deviceSecret?: string;
70
+ /**
71
+ * The device's signer — use instead of {@link LoginConfig.deviceSecret} when
72
+ * the key cannot be exported to hex.
73
+ */
74
+ signer?: Signer;
64
75
  /**
65
76
  * The device's account proof, hex — the credential certifying that this
66
77
  * device belongs to its account.
@@ -73,9 +84,14 @@ export interface LoginConfig {
73
84
  /** The client surface this session is bound to. */
74
85
  audience: Audience;
75
86
  /**
76
- * How this client names itself to the node. Defaults to the audience's own
77
- * spelling, which is the honest answer: a session bound to an origin should
78
- * say that origin.
87
+ * The token request's `client_name`. Defaults to {@link LoginConfig.nodeUrl}.
88
+ *
89
+ * mero-auth records this value as the session's `node_url` and, on every
90
+ * later request, refuses one whose Host is not that URL's host ("Token is not
91
+ * valid for this host"). So it must name the node being logged in to — which
92
+ * is what core's own clients send. The audience is not a substitute: a browser
93
+ * page's origin is a different host from the relay it talks to, and a token
94
+ * bound to the page's host is refused on the first call.
79
95
  */
80
96
  clientName?: string;
81
97
  /** Seconds the statement stays valid. Defaults to {@link DEFAULT_TTL_SECONDS}. */
@@ -33,6 +33,7 @@
33
33
  import { HTTPError } from '../http-client/web-client.js';
34
34
  import { derivePublicKey, hex } from '../crypto/internal.js';
35
35
  import { signLoginStatement } from './login.js';
36
+ import { resolveSigner } from '../signer/signer.js';
36
37
  /** Seconds a statement stays valid unless told otherwise. */
37
38
  const DEFAULT_TTL_SECONDS = 300;
38
39
  /**
@@ -47,17 +48,6 @@ export async function generateSessionKey() {
47
48
  const secret = hex(crypto.getRandomValues(new Uint8Array(32)));
48
49
  return { publicKey: hex(await derivePublicKey(secret)), secret };
49
50
  }
50
- /** The audience's own spelling, used as the default client name. */
51
- function audienceLabel(audience) {
52
- switch (audience.kind) {
53
- case 'webOrigin':
54
- return audience.origin;
55
- case 'codeSigningId':
56
- return audience.id;
57
- case 'cli':
58
- return 'cli';
59
- }
60
- }
61
51
  /**
62
52
  * Log in with a device key and return the session.
63
53
  *
@@ -75,9 +65,9 @@ export async function login(config) {
75
65
  const timer = setTimeout(() => controller.abort(), timeoutMs);
76
66
  let response;
77
67
  try {
68
+ // Called as `globalThis.fetch(...)`, never through a detached reference:
69
+ // a browser's fetch throws "Illegal invocation" when its receiver is lost.
78
70
  const requestInit = { ...init, signal: controller.signal };
79
- // globalThis.fetch must be called as a method on globalThis, not through a
80
- // variable, or browsers throw "Illegal invocation".
81
71
  response = config.fetch
82
72
  ? await config.fetch(`${baseUrl}${path}`, requestInit)
83
73
  : await globalThis.fetch(`${baseUrl}${path}`, requestInit);
@@ -113,7 +103,9 @@ export async function login(config) {
113
103
  sessionKey: session.publicKey,
114
104
  issuedAt,
115
105
  expiresAt: issuedAt + (config.ttlSeconds ?? DEFAULT_TTL_SECONDS),
116
- deviceSecret: config.deviceSecret,
106
+ // Resolved once and handed on, so a config carrying a secret and a config
107
+ // carrying a `CryptoKey` take exactly the same path from here down.
108
+ signer: await resolveSigner(config.deviceSecret, config.signer, 'deviceSecret'),
117
109
  });
118
110
  // `timestamp` is required and the request refuses unknown fields, so a body
119
111
  // missing it is rejected during deserialization — before the provider runs,
@@ -126,7 +118,7 @@ export async function login(config) {
126
118
  body: JSON.stringify({
127
119
  auth_method: 'account_proof',
128
120
  public_key: session.publicKey,
129
- client_name: config.clientName ?? audienceLabel(config.audience),
121
+ client_name: config.clientName ?? baseUrl,
130
122
  timestamp: issuedAt,
131
123
  provider_data: {
132
124
  challenge,
@@ -14,6 +14,7 @@
14
14
  * op, which is what the e2e does.
15
15
  */
16
16
  import type { SignedGroupOpenInvitation } from '../admin-api/admin-types.js';
17
+ import { type Signer } from '../signer/signer.js';
17
18
  /**
18
19
  * Core's `SIGNED_NAMESPACE_OP_SCHEMA_VERSION`. A node refuses any other value.
19
20
  *
@@ -55,8 +56,19 @@ export interface SignMemberJoinInput {
55
56
  * when applying the join and refuses otherwise.
56
57
  */
57
58
  readonly credential: string;
58
- /** The device signing secret, 64 hex. Must match the credential's `sign_pk`. */
59
- readonly deviceSecret: string;
59
+ /**
60
+ * The device signing secret, 64 hex. Must match the credential's `sign_pk`.
61
+ *
62
+ * Mutually exclusive with {@link SignMemberJoinInput.signer}. A browser should
63
+ * prefer the signer: joining is the one governance op a keyholder signs for
64
+ * itself, and it should not need the key in a form script can read.
65
+ */
66
+ readonly deviceSecret?: string;
67
+ /**
68
+ * The joiner's signer — use instead of
69
+ * {@link SignMemberJoinInput.deviceSecret} when the key cannot be exported.
70
+ */
71
+ readonly signer?: Signer;
60
72
  /**
61
73
  * Heads this op is written against. Empty means "signed against an empty head"
62
74
  * — which is NOT the same as genesis, and a node with a non-empty head will
@@ -1,4 +1,6 @@
1
- import { concat, derivePublicKey, fromHex, hex, importSigningKey, u32le, u64le, } from '../crypto/internal.js';
1
+ import { encodeGroupInvitation } from '../invitation/invitation.js';
2
+ import { concat, fromHex, hex, u32le, u64le } from '../crypto/internal.js';
3
+ import { resolveSigner } from '../signer/signer.js';
2
4
  /**
3
5
  * Core's `SIGNED_NAMESPACE_OP_SCHEMA_VERSION`. A node refuses any other value.
4
6
  *
@@ -37,13 +39,6 @@ const ROOT_OP = {
37
39
  NamespaceCreatedV2: 9,
38
40
  MemberJoinedViaTeeAttestation: 10,
39
41
  };
40
- /** A required `[u8; 32]`, as JSON carries it: 32 byte values. */
41
- function bytes32(value, label) {
42
- if (value.length !== 32) {
43
- throw new Error(`${label} must be 32 bytes, got ${value.length}`);
44
- }
45
- return new Uint8Array(value);
46
- }
47
42
  /**
48
43
  * Borsh `Option<[u8; 32]>` from the byte array JSON carries it as.
49
44
  *
@@ -77,15 +72,11 @@ function borshString(value) {
77
72
  */
78
73
  export function encodeSignedInvitation(signed) {
79
74
  const body = signed.invitation;
80
- const admitters = body.admitters ?? [];
81
75
  const admitterAddrs = signed.admitter_addrs ?? [];
82
76
  return concat(
83
- // GroupInvitationFromAdmin.
84
- //
85
- // `SignerId` and `ContextGroupId` cross JSON as 32 numbers; `AccountId`
86
- // crosses as hex. Same width, different spelling, and reading either the
87
- // wrong way yields 32 bytes that encode cleanly and verify as nothing.
88
- bytes32(body.inviter_identity, 'inviter_identity'), bytes32(body.group_id, 'group_id'), u64le(body.expiration_timestamp), bytes32(body.secret_salt, 'secret_salt'), new Uint8Array([body.invited_role]), u32le(admitters.length), ...admitters.map((a, i) => fromHex(a, `admitters[${i}]`, 32)),
77
+ // GroupInvitationFromAdmin, from the one encoder the SDK's own invitation
78
+ // signer uses: the bytes signed and the bytes carried must not drift.
79
+ encodeGroupInvitation(body),
89
80
  // ...then the enclosing SignedGroupOpenInvitation, in declaration order.
90
81
  // `inviter_account` sits between the signature and the addresses; putting it
91
82
  // anywhere else still encodes 32 bytes and still verifies as nothing.
@@ -122,10 +113,13 @@ export async function signMemberJoinOp(input) {
122
113
  ? concat(new Uint8Array([ROOT_OP.MemberJoinedAt]), fromHex(input.member, 'member', 32), encodeSignedInvitation(input.invitation), u64le(joinedAt), credential)
123
114
  : concat(new Uint8Array([ROOT_OP.MemberJoined]), fromHex(input.member, 'member', 32), encodeSignedInvitation(input.invitation), credential);
124
115
  const op = concat(new Uint8Array([NAMESPACE_OP_ROOT]), rootOp);
125
- const key = await importSigningKey(input.deviceSecret);
126
- const signer = await derivePublicKey(input.deviceSecret);
127
- const signable = concat(new Uint8Array([SIGNED_NAMESPACE_OP_SCHEMA_VERSION]), fromHex(input.namespaceId, 'namespaceId', 32), u32le(parents.length), ...parents.map((p, i) => fromHex(p, `parentOpHashes[${i}]`, 32)), signer, u64le(input.nonce), op);
128
- const signature = new Uint8Array(await crypto.subtle.sign('Ed25519', key, concat(NAMESPACE_SIGN_DOMAIN, signable)));
116
+ const signingKey = await resolveSigner(input.deviceSecret, input.signer, 'deviceSecret');
117
+ // Named apart from the signer itself: this is the public half the op carries
118
+ // as its `signer` field, which every peer compares with the credential's
119
+ // `sign_pk` before applying the join.
120
+ const signerPublicKey = fromHex(signingKey.publicKey, 'signer.publicKey', 32);
121
+ const signable = concat(new Uint8Array([SIGNED_NAMESPACE_OP_SCHEMA_VERSION]), fromHex(input.namespaceId, 'namespaceId', 32), u32le(parents.length), ...parents.map((p, i) => fromHex(p, `parentOpHashes[${i}]`, 32)), signerPublicKey, u64le(input.nonce), op);
122
+ const signature = await signingKey.sign(concat(NAMESPACE_SIGN_DOMAIN, signable));
129
123
  // The signed struct repeats the signable fields, then the signature, then the
130
124
  // admitter endorsement. The domain is a signing prefix only and is never part
131
125
  // of the encoded op.
@@ -1,4 +1,4 @@
1
1
  export { RelayClient, IntentRefusedError } from './relay-client.js';
2
2
  export type { RelayClientConfig, RelayDescription, IntentResult, CreationDescription, CreateContextInput, CreatedContext, GovernanceDescription, GovernInput, GovernResult, FoundNamespaceInput, FoundedNamespace, } from './relay-client.js';
3
- export { createMemoryNonceSource, createLocalStorageNonceSource } from './nonce-source.js';
4
- export type { NonceSource } from './nonce-source.js';
3
+ export { createMemoryNonceSource, createLocalStorageNonceSource, createRecoveringNonceSource, authorNonceLookup, WarrantNonceExhaustedError, } from './nonce-source.js';
4
+ export type { NonceSource, WarrantNonceLookup, RecoveringNonceSourceOptions, } from './nonce-source.js';
@@ -1,2 +1,2 @@
1
1
  export { RelayClient, IntentRefusedError } from './relay-client.js';
2
- export { createMemoryNonceSource, createLocalStorageNonceSource } from './nonce-source.js';
2
+ export { createMemoryNonceSource, createLocalStorageNonceSource, createRecoveringNonceSource, authorNonceLookup, WarrantNonceExhaustedError, } from './nonce-source.js';
@@ -22,9 +22,22 @@
22
22
  * is the one you have to ask for. It is also why a source may jump: reserving a
23
23
  * block and losing the tail is the correct trade.
24
24
  */
25
+ import type { WarrantNonceState } from '../admin-api/admin-types.js';
25
26
  /** Produces the next nonce for one author device. Must never go backwards. */
26
27
  export interface NonceSource {
27
28
  next(): Promise<bigint>;
29
+ /**
30
+ * Raise the floor of the sequence to `floor`, if this source can record it.
31
+ *
32
+ * Optional, and additive: a source that does not implement it still works
33
+ * everywhere a `NonceSource` is taken, and {@link createRecoveringNonceSource}
34
+ * keeps the floor itself in that case. Implementing it is what lets a
35
+ * *persistent* source remember a jump, so a later cold start that cannot
36
+ * reach a node resumes above the mark rather than below it.
37
+ *
38
+ * Never lowers: a smaller `floor` than the source's own position is ignored.
39
+ */
40
+ advanceTo?(floor: bigint): Promise<void>;
28
41
  }
29
42
  /**
30
43
  * A counter in memory, starting at `start`.
@@ -58,3 +71,125 @@ export declare function createLocalStorageNonceSource(key: string, storage?: {
58
71
  getItem(k: string): string | null;
59
72
  setItem(k: string, v: string): void;
60
73
  }): NonceSource;
74
+ /** A node (or anything else) that can answer where a device's sequence stands. */
75
+ export interface WarrantNonceLookup {
76
+ getWarrantNonce(contextId: string, authorDeviceKey: string): Promise<WarrantNonceState>;
77
+ }
78
+ /**
79
+ * A lookup a delegated client can actually use.
80
+ *
81
+ * {@link WarrantNonceLookup} names the device key because the admin route takes
82
+ * it in the path. The delegated route does not: it reads the key from the
83
+ * certificate it verifies, so the only way to ask is to present that
84
+ * certificate. This adapter bridges the two — it satisfies the interface, and
85
+ * ignores the device key argument because the credential already determines it.
86
+ *
87
+ * That the argument is ignored is not an oversight to tidy away. The whole
88
+ * point of the delegated route is that the device key is not caller-supplied:
89
+ * a key passed alongside the proof would be a second spelling of a fact the
90
+ * signature already fixes, and two spellings can differ. The adapter therefore
91
+ * has nothing to check it against, and pretending otherwise would invent a
92
+ * comparison the protocol deliberately does not make.
93
+ *
94
+ * Use with {@link createRecoveringNonceSource} exactly as an `AdminApiClient`
95
+ * would be used — on a relay, this is the form that works.
96
+ */
97
+ export declare function authorNonceLookup(options: {
98
+ /** Anything that can reach the node's admin path; an `AdminApiClient` does. */
99
+ client: {
100
+ getWarrantNonceAsAuthor(contextId: string, authorProof: string): Promise<WarrantNonceState>;
101
+ };
102
+ /** Hex borsh `AccountProof<DeviceCert>` — the same one the relay client sends. */
103
+ authorProof: string;
104
+ }): WarrantNonceLookup;
105
+ /** Thrown when every node asked says this device has spent `u64::MAX` here. */
106
+ export declare class WarrantNonceExhaustedError extends Error {
107
+ readonly contextId: string;
108
+ readonly authorDeviceKey: string;
109
+ name: string;
110
+ constructor(contextId: string, authorDeviceKey: string);
111
+ }
112
+ /** What {@link createRecoveringNonceSource} needs to know. */
113
+ export interface RecoveringNonceSourceOptions {
114
+ /**
115
+ * The node(s) to ask. Pass an {@link AdminApiClient} — it satisfies this
116
+ * structurally — or several, one per relay this author writes through.
117
+ *
118
+ * With more than one, the **highest** answer wins. Nonce state folds per peer,
119
+ * so a node can only be behind a client whose warrants it has not applied,
120
+ * never ahead of the truth; taking the highest is therefore the only reading
121
+ * that cannot hand out a nonce somebody has already spent.
122
+ */
123
+ lookups: WarrantNonceLookup | readonly WarrantNonceLookup[];
124
+ contextId: string;
125
+ /** The author device's SIGNING key, base58 or hex. Not its `deviceId`. */
126
+ authorDeviceKey: string;
127
+ /**
128
+ * The client's own counter, folded in with `max`. Defaults to an in-memory
129
+ * one starting at 1.
130
+ *
131
+ * Pass {@link createLocalStorageNonceSource} to keep both halves: the local
132
+ * counter carries the sequence forward between calls, the node supplies the
133
+ * floor when that counter has been cleared.
134
+ */
135
+ local?: NonceSource;
136
+ /**
137
+ * What to do when no node can be reached on the cold-start lookup.
138
+ *
139
+ * `'throw'` (the default) refuses to mint. That is the conservative reading:
140
+ * a source that quietly fell back to a cleared local counter would replay
141
+ * spent nonces, which is the exact failure this exists to prevent, and it
142
+ * would do it invisibly. `'local'` mints from the local counter anyway —
143
+ * correct only when that counter is known to be intact and the app would
144
+ * rather write offline than not at all.
145
+ */
146
+ onLookupFailure?: 'throw' | 'local';
147
+ }
148
+ /**
149
+ * A nonce source that asks the node where the sequence stands before its first
150
+ * mint, then carries on locally.
151
+ *
152
+ * # Why this is not the default
153
+ *
154
+ * It costs a round trip and an admin credential, and an author that never lost
155
+ * its counter gains nothing from it. It earns its place in exactly one
156
+ * situation, which is common rather than exotic: a client whose local state is
157
+ * gone. A browser keyholder lives in storage that is partitioned per top-level
158
+ * site and cleared on a schedule it does not control, so for it, losing the
159
+ * counter is the normal lifecycle. Without this, such a client either restarts
160
+ * at 1 — replaying every nonce it already spent, each write refused, which
161
+ * reads to the user as the app silently not saving — or guesses upward, one
162
+ * burnt round trip per wrong guess.
163
+ *
164
+ * # What it does
165
+ *
166
+ * The lookup happens once, lazily, on the first {@link NonceSource.next}: a
167
+ * source that is constructed and never used costs nothing, and an app that
168
+ * builds its relay client at startup does not pay a network call for it. The
169
+ * answer becomes a **floor**, and every nonce handed out afterwards is
170
+ * `max(localCounter, floor)`. It never goes backwards from either input, which
171
+ * is the one property a nonce source owes its caller.
172
+ *
173
+ * If the local counter is the one that is ahead, it wins — the node may simply
174
+ * not have applied this client's recent warrants yet.
175
+ *
176
+ * The floor is also pushed into the local source through
177
+ * {@link NonceSource.advanceTo} when it has one, so a persistent counter
178
+ * remembers the jump.
179
+ *
180
+ * # Exhaustion
181
+ *
182
+ * A node reporting no `nextNonce` means `u64::MAX` is spent and this device can
183
+ * never write here again. `next()` rejects with
184
+ * {@link WarrantNonceExhaustedError} rather than guessing, because every guess
185
+ * from there is refused forever.
186
+ *
187
+ * # On an older node
188
+ *
189
+ * The route is new and absent from core `master` builds, which answer 404 —
190
+ * {@link AdminApiClient.getWarrantNonce} surfaces that as
191
+ * `WarrantNonceRouteUnavailableError`. It reaches the caller as a lookup
192
+ * failure, so `onLookupFailure` decides: refuse to mint, or fall back to the
193
+ * local counter.
194
+ */
195
+ export declare function createRecoveringNonceSource(options: RecoveringNonceSourceOptions): NonceSource;