@calimero-network/mero-js 22.7.0 → 22.8.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 (70) 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 +138 -2
  6. package/dist/admin-api/admin-client.js +237 -1
  7. package/dist/admin-api/admin-types.d.ts +159 -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 +85 -2
  21. package/dist/device-cert/device-cert.js +100 -9
  22. package/dist/device-cert/index.d.ts +2 -2
  23. package/dist/device-cert/index.js +1 -1
  24. package/dist/events/sse.d.ts +22 -2
  25. package/dist/events/sse.js +20 -11
  26. package/dist/http-client/http-types.d.ts +18 -0
  27. package/dist/http-client/web-client.js +21 -1
  28. package/dist/index.browser.mjs +2 -2
  29. package/dist/index.browser.mjs.map +4 -4
  30. package/dist/index.cjs +1666 -365
  31. package/dist/index.cjs.map +4 -4
  32. package/dist/index.d.ts +9 -4
  33. package/dist/index.js +16 -2
  34. package/dist/index.mjs +1666 -365
  35. package/dist/index.mjs.map +4 -4
  36. package/dist/login/login.d.ts +11 -1
  37. package/dist/login/login.js +5 -4
  38. package/dist/login/session.d.ts +21 -5
  39. package/dist/login/session.js +7 -15
  40. package/dist/namespace-op/namespace-op.d.ts +14 -2
  41. package/dist/namespace-op/namespace-op.js +9 -5
  42. package/dist/relay/index.d.ts +2 -2
  43. package/dist/relay/index.js +1 -1
  44. package/dist/relay/nonce-source.d.ts +135 -0
  45. package/dist/relay/nonce-source.js +187 -19
  46. package/dist/relay/relay-client.d.ts +60 -5
  47. package/dist/relay/relay-client.js +84 -4
  48. package/dist/relay-attestation/attest-node-key.d.ts +66 -0
  49. package/dist/relay-attestation/attest-node-key.js +110 -0
  50. package/dist/relay-attestation/index.d.ts +1 -0
  51. package/dist/relay-attestation/index.js +1 -0
  52. package/dist/request-proof/authorizer.d.ts +48 -0
  53. package/dist/request-proof/authorizer.js +65 -0
  54. package/dist/request-proof/index.d.ts +4 -0
  55. package/dist/request-proof/index.js +2 -0
  56. package/dist/request-proof/request-proof.d.ts +56 -0
  57. package/dist/request-proof/request-proof.js +111 -0
  58. package/dist/rpc/index.d.ts +21 -0
  59. package/dist/rpc/index.js +20 -0
  60. package/dist/transport/index.d.ts +6 -0
  61. package/dist/transport/index.js +3 -0
  62. package/dist/transport/mero-client.d.ts +286 -0
  63. package/dist/transport/mero-client.js +379 -0
  64. package/dist/transport/relay-observer.d.ts +152 -0
  65. package/dist/transport/relay-observer.js +173 -0
  66. package/dist/transport/relay-transport.d.ts +73 -0
  67. package/dist/transport/relay-transport.js +94 -0
  68. package/dist/transport/types.d.ts +100 -0
  69. package/dist/transport/types.js +32 -0
  70. package/package.json +1 -1
@@ -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,5 @@
1
- import { concat, derivePublicKey, fromHex, hex, importSigningKey, u32le, u64le, } from '../crypto/internal.js';
1
+ import { concat, fromHex, hex, u32le, u64le } from '../crypto/internal.js';
2
+ import { resolveSigner } from '../signer/signer.js';
2
3
  /**
3
4
  * Core's `SIGNED_NAMESPACE_OP_SCHEMA_VERSION`. A node refuses any other value.
4
5
  *
@@ -122,10 +123,13 @@ export async function signMemberJoinOp(input) {
122
123
  ? concat(new Uint8Array([ROOT_OP.MemberJoinedAt]), fromHex(input.member, 'member', 32), encodeSignedInvitation(input.invitation), u64le(joinedAt), credential)
123
124
  : concat(new Uint8Array([ROOT_OP.MemberJoined]), fromHex(input.member, 'member', 32), encodeSignedInvitation(input.invitation), credential);
124
125
  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)));
126
+ const signingKey = await resolveSigner(input.deviceSecret, input.signer, 'deviceSecret');
127
+ // Named apart from the signer itself: this is the public half the op carries
128
+ // as its `signer` field, which every peer compares with the credential's
129
+ // `sign_pk` before applying the join.
130
+ const signerPublicKey = fromHex(signingKey.publicKey, 'signer.publicKey', 32);
131
+ 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);
132
+ const signature = await signingKey.sign(concat(NAMESPACE_SIGN_DOMAIN, signable));
129
133
  // The signed struct repeats the signable fields, then the signature, then the
130
134
  // admitter endorsement. The domain is a signing prefix only and is never part
131
135
  // 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;
@@ -41,6 +41,11 @@ export function createMemoryNonceSource(start = 1) {
41
41
  next += 1n;
42
42
  return Promise.resolve(value);
43
43
  },
44
+ advanceTo(floor) {
45
+ if (floor > next)
46
+ next = floor;
47
+ return Promise.resolve();
48
+ },
44
49
  };
45
50
  }
46
51
  /**
@@ -67,29 +72,192 @@ export function createLocalStorageNonceSource(key, storage) {
67
72
  if (!store) {
68
73
  throw new Error('no localStorage in this runtime: pass a storage implementation, or use createMemoryNonceSource for a process that owns its whole sequence');
69
74
  }
75
+ // A missing, empty, or corrupted value means "no sequence recorded", and the
76
+ // safe reading of that is not 1 — a cleared storage on a device that has
77
+ // already written would replay. But the client cannot know, and 1 is the only
78
+ // defensible start, so it is what a fresh key gets. This is why the doc above
79
+ // tells apps to key per device: a device whose storage was cleared should be
80
+ // re-paired rather than resumed — or handed a recovering source, which asks
81
+ // the node what it already spent instead of guessing.
82
+ const lastHandedOut = () => {
83
+ const raw = store.getItem(key);
84
+ if (raw === null || raw === '')
85
+ return 0n;
86
+ try {
87
+ const parsed = BigInt(raw);
88
+ return parsed > 0n ? parsed : 0n;
89
+ }
90
+ catch {
91
+ return 0n;
92
+ }
93
+ };
70
94
  return {
71
95
  next() {
72
- const raw = store.getItem(key);
73
- // A missing, empty, or corrupted value means "no sequence recorded", and
74
- // the safe reading of that is not 1 — a cleared storage on a device that
75
- // has already written would replay. But the client cannot know, and 1 is
76
- // the only defensible start, so it is what a fresh key gets. This is why
77
- // the doc above tells apps to key per device: a device whose storage was
78
- // cleared should be re-paired rather than resumed.
79
- let current = 0n;
80
- if (raw !== null && raw !== '') {
81
- try {
82
- const parsed = BigInt(raw);
83
- if (parsed > 0n)
84
- current = parsed;
85
- }
86
- catch {
87
- current = 0n;
88
- }
89
- }
90
- const value = current + 1n;
96
+ const value = lastHandedOut() + 1n;
91
97
  store.setItem(key, value.toString());
92
98
  return Promise.resolve(value);
93
99
  },
100
+ advanceTo(floor) {
101
+ // Stored is the last number handed out, so the floor is recorded as the
102
+ // one below it — persisted rather than held in memory, which is the whole
103
+ // point: the next cold start resumes above the jump even if the node
104
+ // cannot be reached then.
105
+ const wanted = floor > 0n ? floor - 1n : 0n;
106
+ if (wanted > lastHandedOut())
107
+ store.setItem(key, wanted.toString());
108
+ return Promise.resolve();
109
+ },
110
+ };
111
+ }
112
+ /**
113
+ * A lookup a delegated client can actually use.
114
+ *
115
+ * {@link WarrantNonceLookup} names the device key because the admin route takes
116
+ * it in the path. The delegated route does not: it reads the key from the
117
+ * certificate it verifies, so the only way to ask is to present that
118
+ * certificate. This adapter bridges the two — it satisfies the interface, and
119
+ * ignores the device key argument because the credential already determines it.
120
+ *
121
+ * That the argument is ignored is not an oversight to tidy away. The whole
122
+ * point of the delegated route is that the device key is not caller-supplied:
123
+ * a key passed alongside the proof would be a second spelling of a fact the
124
+ * signature already fixes, and two spellings can differ. The adapter therefore
125
+ * has nothing to check it against, and pretending otherwise would invent a
126
+ * comparison the protocol deliberately does not make.
127
+ *
128
+ * Use with {@link createRecoveringNonceSource} exactly as an `AdminApiClient`
129
+ * would be used — on a relay, this is the form that works.
130
+ */
131
+ export function authorNonceLookup(options) {
132
+ return {
133
+ getWarrantNonce: (contextId, _authorDeviceKey) => options.client.getWarrantNonceAsAuthor(contextId, options.authorProof),
134
+ };
135
+ }
136
+ /** Thrown when every node asked says this device has spent `u64::MAX` here. */
137
+ export class WarrantNonceExhaustedError extends Error {
138
+ constructor(contextId, authorDeviceKey) {
139
+ super(`author device ${authorDeviceKey} has spent every nonce in context ${contextId}; it must re-key to write again`);
140
+ this.contextId = contextId;
141
+ this.authorDeviceKey = authorDeviceKey;
142
+ this.name = 'WarrantNonceExhaustedError';
143
+ }
144
+ }
145
+ /**
146
+ * A nonce source that asks the node where the sequence stands before its first
147
+ * mint, then carries on locally.
148
+ *
149
+ * # Why this is not the default
150
+ *
151
+ * It costs a round trip and an admin credential, and an author that never lost
152
+ * its counter gains nothing from it. It earns its place in exactly one
153
+ * situation, which is common rather than exotic: a client whose local state is
154
+ * gone. A browser keyholder lives in storage that is partitioned per top-level
155
+ * site and cleared on a schedule it does not control, so for it, losing the
156
+ * counter is the normal lifecycle. Without this, such a client either restarts
157
+ * at 1 — replaying every nonce it already spent, each write refused, which
158
+ * reads to the user as the app silently not saving — or guesses upward, one
159
+ * burnt round trip per wrong guess.
160
+ *
161
+ * # What it does
162
+ *
163
+ * The lookup happens once, lazily, on the first {@link NonceSource.next}: a
164
+ * source that is constructed and never used costs nothing, and an app that
165
+ * builds its relay client at startup does not pay a network call for it. The
166
+ * answer becomes a **floor**, and every nonce handed out afterwards is
167
+ * `max(localCounter, floor)`. It never goes backwards from either input, which
168
+ * is the one property a nonce source owes its caller.
169
+ *
170
+ * If the local counter is the one that is ahead, it wins — the node may simply
171
+ * not have applied this client's recent warrants yet.
172
+ *
173
+ * The floor is also pushed into the local source through
174
+ * {@link NonceSource.advanceTo} when it has one, so a persistent counter
175
+ * remembers the jump.
176
+ *
177
+ * # Exhaustion
178
+ *
179
+ * A node reporting no `nextNonce` means `u64::MAX` is spent and this device can
180
+ * never write here again. `next()` rejects with
181
+ * {@link WarrantNonceExhaustedError} rather than guessing, because every guess
182
+ * from there is refused forever.
183
+ *
184
+ * # On an older node
185
+ *
186
+ * The route is new and absent from core `master` builds, which answer 404 —
187
+ * {@link AdminApiClient.getWarrantNonce} surfaces that as
188
+ * `WarrantNonceRouteUnavailableError`. It reaches the caller as a lookup
189
+ * failure, so `onLookupFailure` decides: refuse to mint, or fall back to the
190
+ * local counter.
191
+ */
192
+ export function createRecoveringNonceSource(options) {
193
+ const lookups = Array.isArray(options.lookups)
194
+ ? options.lookups
195
+ : [options.lookups];
196
+ if (lookups.length === 0) {
197
+ throw new Error('a recovering nonce source needs at least one node to ask');
198
+ }
199
+ const local = options.local ?? createMemoryNonceSource(1);
200
+ const onLookupFailure = options.onLookupFailure ?? 'throw';
201
+ let floor = null;
202
+ // Memoised so concurrent first calls ask once. Cleared on failure, since a
203
+ // node that was unreachable a moment ago may answer the next call — caching
204
+ // the failure would make one flaky request poison the whole session.
205
+ let pending = null;
206
+ const resolveFloor = async () => {
207
+ const answers = await Promise.allSettled(lookups.map((lookup) => lookup.getWarrantNonce(options.contextId, options.authorDeviceKey)));
208
+ let highest = null;
209
+ let exhausted = false;
210
+ for (const answer of answers) {
211
+ if (answer.status !== 'fulfilled')
212
+ continue;
213
+ const state = answer.value;
214
+ if (state.kind === 'exhausted') {
215
+ exhausted = true;
216
+ continue;
217
+ }
218
+ if (highest === null || state.nextNonce > highest)
219
+ highest = state.nextNonce;
220
+ }
221
+ // Exhaustion is only decisive when nothing usable came back. One node
222
+ // saturated while another still has room is not a thing the ledger can
223
+ // produce — the sequence is the device's, not the node's — but if it ever
224
+ // is, the usable answer is the actionable one.
225
+ if (highest === null && exhausted) {
226
+ throw new WarrantNonceExhaustedError(options.contextId, options.authorDeviceKey);
227
+ }
228
+ if (highest === null) {
229
+ const first = answers.find((a) => a.status === 'rejected');
230
+ const reason = first && first.status === 'rejected' ? first.reason : undefined;
231
+ if (onLookupFailure === 'local') {
232
+ floor = 0n;
233
+ return;
234
+ }
235
+ throw reason instanceof Error
236
+ ? reason
237
+ : new Error(`no node could answer where the warrant-nonce sequence stands: ${String(reason)}`);
238
+ }
239
+ floor = highest;
240
+ await local.advanceTo?.(highest);
241
+ };
242
+ return {
243
+ async next() {
244
+ if (floor === null) {
245
+ pending ?? (pending = resolveFloor().finally(() => {
246
+ pending = null;
247
+ }));
248
+ await pending;
249
+ }
250
+ const mine = await local.next();
251
+ const value = floor !== null && floor > mine ? floor : mine;
252
+ // Advance the floor past what was just handed out, so a local source that
253
+ // is behind cannot re-offer this number on the next call.
254
+ floor = value + 1n;
255
+ return value;
256
+ },
257
+ async advanceTo(wanted) {
258
+ if (floor === null || wanted > floor)
259
+ floor = wanted;
260
+ await local.advanceTo?.(wanted);
261
+ },
94
262
  };
95
263
  }