@serfab/cadre-core 1.14.0 → 1.16.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 (171) hide show
  1. package/README.md +293 -274
  2. package/dist/cadre-invite-protocol.d.ts +64 -29
  3. package/dist/cadre-invite-protocol.js +207 -38
  4. package/dist/cadre-invite-protocol.js.map +1 -1
  5. package/dist/cadre-join-request.d.ts +42 -0
  6. package/dist/cadre-join-request.js +144 -0
  7. package/dist/cadre-join-request.js.map +1 -0
  8. package/dist/cadre-node.d.ts +375 -79
  9. package/dist/cadre-node.js +758 -186
  10. package/dist/cadre-node.js.map +1 -1
  11. package/dist/control-database.d.ts +92 -12
  12. package/dist/control-database.js +323 -142
  13. package/dist/control-database.js.map +1 -1
  14. package/dist/control-schema.d.ts +1 -1
  15. package/dist/control-schema.js +19 -3
  16. package/dist/control-schema.js.map +1 -1
  17. package/dist/ed25519-key.d.ts +7 -9
  18. package/dist/ed25519-key.js +20 -10
  19. package/dist/ed25519-key.js.map +1 -1
  20. package/dist/fetch-impl.d.ts +12 -0
  21. package/dist/fetch-impl.js +23 -0
  22. package/dist/fetch-impl.js.map +1 -0
  23. package/dist/file-durable-slot.js +2 -1
  24. package/dist/file-durable-slot.js.map +1 -1
  25. package/dist/file-safe-name.d.ts +25 -0
  26. package/dist/file-safe-name.js +29 -0
  27. package/dist/file-safe-name.js.map +1 -0
  28. package/dist/formation-approval.js +5 -11
  29. package/dist/formation-approval.js.map +1 -1
  30. package/dist/fs-atomic.d.ts +0 -19
  31. package/dist/fs-atomic.js +3 -24
  32. package/dist/fs-atomic.js.map +1 -1
  33. package/dist/index.d.ts +12 -5
  34. package/dist/index.js +31 -6
  35. package/dist/index.js.map +1 -1
  36. package/dist/invitation-bootstrap.d.ts +50 -12
  37. package/dist/invitation-bootstrap.js +76 -13
  38. package/dist/invitation-bootstrap.js.map +1 -1
  39. package/dist/joined-strand-store.d.ts +7 -0
  40. package/dist/joined-strand-store.js +9 -0
  41. package/dist/joined-strand-store.js.map +1 -1
  42. package/dist/key-store-file.js +2 -1
  43. package/dist/key-store-file.js.map +1 -1
  44. package/dist/link-budget.d.ts +9 -4
  45. package/dist/link-budget.js +9 -4
  46. package/dist/link-budget.js.map +1 -1
  47. package/dist/machine-removal.d.ts +78 -0
  48. package/dist/machine-removal.js +166 -0
  49. package/dist/machine-removal.js.map +1 -0
  50. package/dist/membership-connection-gater.d.ts +6 -5
  51. package/dist/membership-connection-gater.js +6 -5
  52. package/dist/membership-connection-gater.js.map +1 -1
  53. package/dist/node-claim-payload.d.ts +55 -0
  54. package/dist/node-claim-payload.js +108 -4
  55. package/dist/node-claim-payload.js.map +1 -1
  56. package/dist/node-default-transports.d.ts +27 -0
  57. package/dist/node-default-transports.js +35 -0
  58. package/dist/node-default-transports.js.map +1 -0
  59. package/dist/node-transports.d.ts +30 -0
  60. package/dist/node-transports.js +40 -0
  61. package/dist/node-transports.js.map +1 -0
  62. package/dist/peer-dial.d.ts +124 -27
  63. package/dist/peer-dial.js +254 -30
  64. package/dist/peer-dial.js.map +1 -1
  65. package/dist/peer-join-backfill.js +6 -0
  66. package/dist/peer-join-backfill.js.map +1 -1
  67. package/dist/peer-record.d.ts +6 -45
  68. package/dist/peer-record.js +8 -50
  69. package/dist/peer-record.js.map +1 -1
  70. package/dist/primary-lan-address.d.ts +8 -0
  71. package/dist/primary-lan-address.js +46 -0
  72. package/dist/primary-lan-address.js.map +1 -0
  73. package/dist/provider/client.d.ts +131 -0
  74. package/dist/provider/client.js +226 -0
  75. package/dist/provider/client.js.map +1 -0
  76. package/dist/provider/descriptor.d.ts +41 -0
  77. package/dist/provider/descriptor.js +103 -0
  78. package/dist/provider/descriptor.js.map +1 -0
  79. package/dist/provider/index.d.ts +9 -0
  80. package/dist/provider/index.js +10 -0
  81. package/dist/provider/index.js.map +1 -0
  82. package/dist/provider/oauth.d.ts +125 -0
  83. package/dist/provider/oauth.js +199 -0
  84. package/dist/provider/oauth.js.map +1 -0
  85. package/dist/provider/rent-node.d.ts +101 -0
  86. package/dist/provider/rent-node.js +220 -0
  87. package/dist/provider/rent-node.js.map +1 -0
  88. package/dist/relay-addrs.d.ts +9 -4
  89. package/dist/relay-addrs.js +9 -4
  90. package/dist/relay-addrs.js.map +1 -1
  91. package/dist/relay-reservation.d.ts +51 -16
  92. package/dist/relay-reservation.js +107 -24
  93. package/dist/relay-reservation.js.map +1 -1
  94. package/dist/seed-bootstrap.d.ts +22 -5
  95. package/dist/seed-bootstrap.js +48 -25
  96. package/dist/seed-bootstrap.js.map +1 -1
  97. package/dist/strand-commit-durability.d.ts +54 -0
  98. package/dist/strand-commit-durability.js +74 -0
  99. package/dist/strand-commit-durability.js.map +1 -0
  100. package/dist/strand-database.d.ts +23 -8
  101. package/dist/strand-database.js +41 -13
  102. package/dist/strand-database.js.map +1 -1
  103. package/dist/strand-first-sync-gate.d.ts +7 -1
  104. package/dist/strand-first-sync-gate.js +7 -1
  105. package/dist/strand-first-sync-gate.js.map +1 -1
  106. package/dist/strand-formation-protocol.js +4 -1
  107. package/dist/strand-formation-protocol.js.map +1 -1
  108. package/dist/strand-fret-addrs.d.ts +19 -1
  109. package/dist/strand-fret-addrs.js +27 -9
  110. package/dist/strand-fret-addrs.js.map +1 -1
  111. package/dist/strand-instance-manager.d.ts +81 -19
  112. package/dist/strand-instance-manager.js +216 -105
  113. package/dist/strand-instance-manager.js.map +1 -1
  114. package/dist/strand-move.d.ts +124 -0
  115. package/dist/strand-move.js +438 -0
  116. package/dist/strand-move.js.map +1 -0
  117. package/dist/strand-network-config.js +7 -6
  118. package/dist/strand-network-config.js.map +1 -1
  119. package/dist/strand-peer-redial.d.ts +113 -0
  120. package/dist/strand-peer-redial.js +236 -0
  121. package/dist/strand-peer-redial.js.map +1 -0
  122. package/dist/strand-wake-protocol.js +10 -7
  123. package/dist/strand-wake-protocol.js.map +1 -1
  124. package/dist/types.d.ts +212 -4
  125. package/dist/types.js +10 -0
  126. package/dist/types.js.map +1 -1
  127. package/package.json +18 -10
  128. package/src/cadre-invite-protocol.ts +1052 -864
  129. package/src/cadre-join-request.ts +167 -0
  130. package/src/cadre-node.ts +10003 -9381
  131. package/src/control-database.ts +4663 -4451
  132. package/src/control-schema.ts +19 -3
  133. package/src/ed25519-key.ts +20 -10
  134. package/src/fetch-impl.ts +23 -0
  135. package/src/file-durable-slot.ts +64 -63
  136. package/src/file-safe-name.ts +32 -0
  137. package/src/formation-approval.ts +604 -610
  138. package/src/fs-atomic.ts +92 -117
  139. package/src/index.ts +71 -4
  140. package/src/invitation-bootstrap.ts +91 -13
  141. package/src/joined-strand-store.ts +508 -498
  142. package/src/key-store-file.ts +142 -141
  143. package/src/link-budget.ts +9 -4
  144. package/src/machine-removal.ts +216 -0
  145. package/src/membership-connection-gater.ts +638 -637
  146. package/src/node-claim-payload.ts +138 -4
  147. package/src/node-default-transports.ts +38 -0
  148. package/src/node-transports.ts +49 -0
  149. package/src/peer-dial.ts +604 -336
  150. package/src/peer-join-backfill.ts +6 -0
  151. package/src/peer-record.ts +159 -202
  152. package/src/primary-lan-address.ts +47 -0
  153. package/src/provider/client.ts +326 -0
  154. package/src/provider/descriptor.ts +129 -0
  155. package/src/provider/index.ts +59 -0
  156. package/src/provider/oauth.ts +285 -0
  157. package/src/provider/rent-node.ts +275 -0
  158. package/src/relay-addrs.ts +9 -4
  159. package/src/relay-reservation.ts +1211 -1121
  160. package/src/seed-bootstrap.ts +1502 -1471
  161. package/src/strand-commit-durability.ts +116 -0
  162. package/src/strand-database.ts +46 -14
  163. package/src/strand-first-sync-gate.ts +7 -1
  164. package/src/strand-formation-protocol.ts +1039 -1036
  165. package/src/strand-fret-addrs.ts +179 -145
  166. package/src/strand-instance-manager.ts +2026 -1883
  167. package/src/strand-move.ts +585 -0
  168. package/src/strand-network-config.ts +7 -6
  169. package/src/strand-peer-redial.ts +307 -0
  170. package/src/strand-wake-protocol.ts +10 -7
  171. package/src/types.ts +220 -4
@@ -0,0 +1,285 @@
1
+ /**
2
+ * The app's side of signing in to a hosting provider: OAuth 2 authorization code with PKCE
3
+ * (RFC 6749 §4.1, RFC 7636), the flow for a public client that holds no secret, against the
4
+ * endpoints a provider descriptor names (`docs/architecture.md` → "Provider descriptor and
5
+ * sign-in"). Opening the browser is the app's or its kit's; this module builds the URL it
6
+ * opens, reads the redirect it comes back with, exchanges and refreshes tokens, and keeps a
7
+ * token fresh for the provider client ({@link createTokenSource}).
8
+ *
9
+ * Imports `@noble/hashes` for SHA-256 and the CSPRNG (`crypto.getRandomValues`, which the
10
+ * React Native kit polyfills) and `uint8arrays` for base64url, so it loads on React Native
11
+ * and in the browser as well as Node. Codes and tokens are credentials: none is ever logged.
12
+ */
13
+
14
+ import debug from 'debug';
15
+ import { sha256 } from '@noble/hashes/sha2.js';
16
+ import { randomBytes } from '@noble/hashes/utils.js';
17
+ import { toString as uint8ArrayToString } from 'uint8arrays';
18
+ import { requireFetch } from '../fetch-impl.js';
19
+ import type { ProviderDescriptor } from './descriptor.js';
20
+
21
+ const log = debug('sereus:cadre:provider-oauth');
22
+
23
+ /**
24
+ * A sign-in fault, with a machine-readable `code`: the provider's own OAuth error code
25
+ * (`invalid_grant`, `access_denied`, ... per RFC 6749 §5.2) when it answered one;
26
+ * `state-mismatch` or `missing-code` when the redirect could not be read; `invalid_response`
27
+ * when the token endpoint answered something that is not a token response; `reauthorize`
28
+ * when a refresh was refused, the app's cue to run the authorization again.
29
+ */
30
+ export class ProviderAuthError extends Error {
31
+ constructor(readonly code: string, description?: string, options?: { cause?: unknown }) {
32
+ super(description ? `${code}: ${description}` : code, options);
33
+ this.name = 'ProviderAuthError';
34
+ }
35
+ }
36
+
37
+ /** What a token exchange or refresh yields, with `expiresAt` already in epoch milliseconds. */
38
+ export interface ProviderTokens {
39
+ accessToken: string;
40
+ refreshToken: string;
41
+ /** Epoch ms when `accessToken` stops working. */
42
+ expiresAt: number;
43
+ scopes: string[];
44
+ }
45
+
46
+ /** A PKCE pair (RFC 7636): the verifier the app keeps, and the S256 challenge it sends first. */
47
+ export interface Pkce {
48
+ verifier: string;
49
+ challenge: string;
50
+ }
51
+
52
+ /** The S256 challenge for `verifier`: base64url(SHA-256(ascii(verifier))), unpadded. */
53
+ export function pkceChallenge(verifier: string): string {
54
+ return uint8ArrayToString(sha256(new TextEncoder().encode(verifier)), 'base64url');
55
+ }
56
+
57
+ /** A fresh pair: 32 random bytes as a 43-character base64url verifier, and its challenge. */
58
+ export function createPkce(): Pkce {
59
+ const verifier = uint8ArrayToString(randomBytes(32), 'base64url');
60
+ return { verifier, challenge: pkceChallenge(verifier) };
61
+ }
62
+
63
+ /** A random `state` for one authorization, which {@link readAuthorizationRedirect} checks the redirect against. */
64
+ export function createAuthorizationState(): string {
65
+ return uint8ArrayToString(randomBytes(16), 'base64url');
66
+ }
67
+
68
+ export interface AuthorizationUrlOptions {
69
+ /** The client id the app registered with the provider. */
70
+ clientId: string;
71
+ /** One of that client's registered redirect URIs, exactly as registered. */
72
+ redirectUri: string;
73
+ state: string;
74
+ /** The S256 challenge of the verifier the app keeps ({@link createPkce}). */
75
+ challenge: string;
76
+ /** Scopes to ask for; defaults to every scope the descriptor lists. */
77
+ scopes?: readonly string[];
78
+ }
79
+
80
+ /** The authorize URL to open in the system browser: `response_type=code`, the client, the redirect URI, the scopes space-joined, the state and the S256 challenge. */
81
+ export function authorizationUrl(descriptor: ProviderDescriptor, options: AuthorizationUrlOptions): string {
82
+ const url = new URL(descriptor.oauth.authorizeUrl);
83
+ url.searchParams.set('response_type', 'code');
84
+ url.searchParams.set('client_id', options.clientId);
85
+ url.searchParams.set('redirect_uri', options.redirectUri);
86
+ url.searchParams.set('scope', (options.scopes ?? descriptor.oauth.scopes).join(' '));
87
+ url.searchParams.set('state', options.state);
88
+ url.searchParams.set('code_challenge', options.challenge);
89
+ url.searchParams.set('code_challenge_method', 'S256');
90
+ return url.toString();
91
+ }
92
+
93
+ /** What the browser came back with: a code to exchange, or the provider's refusal (RFC 6749 §4.1.2.1). */
94
+ export type AuthorizationRedirect =
95
+ | { code: string }
96
+ | { error: string; description?: string };
97
+
98
+ /**
99
+ * Read the redirect URL the browser returned to the app. Throws `ProviderAuthError`
100
+ * (`state-mismatch`) when its `state` is not `expectedState`, whichever outcome it carries,
101
+ * and (`missing-code`) when it carries neither a code nor an error. The code is a credential
102
+ * and never appears in a message or a log line.
103
+ */
104
+ export function readAuthorizationRedirect(url: string, options: { expectedState: string }): AuthorizationRedirect {
105
+ const params = new URL(url).searchParams;
106
+ if (params.get('state') !== options.expectedState) {
107
+ throw new ProviderAuthError('state-mismatch', 'the redirect\'s state is not the one this authorization sent');
108
+ }
109
+ const error = params.get('error');
110
+ if (error !== null) {
111
+ const description = params.get('error_description');
112
+ return description === null ? { error } : { error, description };
113
+ }
114
+ const code = params.get('code');
115
+ if (code === null || code === '') {
116
+ throw new ProviderAuthError('missing-code', 'the redirect carries neither a code nor an error');
117
+ }
118
+ return { code };
119
+ }
120
+
121
+ export interface ExchangeAuthorizationCodeOptions {
122
+ clientId: string;
123
+ /** The redirect URI the authorization was sent with, exactly. */
124
+ redirectUri: string;
125
+ code: string;
126
+ /** The verifier of the challenge the authorization was sent with. */
127
+ verifier: string;
128
+ fetch?: typeof fetch;
129
+ }
130
+
131
+ /** `grant_type=authorization_code` at the descriptor's token endpoint, form-encoded; a refusal is a `ProviderAuthError` with the provider's error code. */
132
+ export async function exchangeAuthorizationCode(descriptor: ProviderDescriptor, options: ExchangeAuthorizationCodeOptions): Promise<ProviderTokens> {
133
+ return await requestTokens(descriptor, {
134
+ grant_type: 'authorization_code',
135
+ code: options.code,
136
+ code_verifier: options.verifier,
137
+ client_id: options.clientId,
138
+ redirect_uri: options.redirectUri,
139
+ }, options.fetch);
140
+ }
141
+
142
+ export interface RefreshTokensOptions {
143
+ clientId: string;
144
+ refreshToken: string;
145
+ fetch?: typeof fetch;
146
+ }
147
+
148
+ /** `grant_type=refresh_token`: rotates the pair (the presented one stops working); a refusal is a `ProviderAuthError` with the provider's error code. */
149
+ export async function refreshTokens(descriptor: ProviderDescriptor, options: RefreshTokensOptions): Promise<ProviderTokens> {
150
+ return await requestTokens(descriptor, {
151
+ grant_type: 'refresh_token',
152
+ refresh_token: options.refreshToken,
153
+ client_id: options.clientId,
154
+ }, options.fetch);
155
+ }
156
+
157
+ /** Hands the provider client a working bearer; see {@link createTokenSource}. */
158
+ export interface TokenSource {
159
+ /** The current access token, refreshed first when it is within a minute of expiry. */
160
+ accessToken(): Promise<string>;
161
+ /**
162
+ * The provider answered 401 to `stale`: refresh and return the new token. A refresh that
163
+ * already replaced `stale` is not repeated, so two requests refused at once cost one
164
+ * rotation.
165
+ */
166
+ invalidate(stale?: string): Promise<string>;
167
+ }
168
+
169
+ export interface TokenSourceOptions {
170
+ descriptor: ProviderDescriptor;
171
+ clientId: string;
172
+ /** The tokens the app holds, from the exchange or from its store. */
173
+ tokens: ProviderTokens;
174
+ /** Called with the new pair after every refresh; the old pair has stopped working by then. */
175
+ persist: (tokens: ProviderTokens) => void | Promise<void>;
176
+ fetch?: typeof fetch;
177
+ /** The clock, for tests. */
178
+ now?: () => number;
179
+ }
180
+
181
+ /** A token is refreshed this long before it expires, so a request does not leave with one about to lapse. */
182
+ export const TOKEN_REFRESH_MARGIN_MS = 60_000;
183
+
184
+ /**
185
+ * A {@link TokenSource} over one token pair: refreshes within {@link TOKEN_REFRESH_MARGIN_MS}
186
+ * of expiry and on `invalidate`, calls `persist` after every refresh, and shares one refresh
187
+ * among concurrent callers (a rotation spends the presented refresh token, so two at once
188
+ * would leave one caller with a refused pair). A refused refresh throws
189
+ * `ProviderAuthError('reauthorize')`: the pair is gone and the app signs in again; a network
190
+ * failure is rethrown as is, since the pair may still be good.
191
+ */
192
+ export function createTokenSource(options: TokenSourceOptions): TokenSource {
193
+ const now = options.now ?? Date.now;
194
+ let current = options.tokens;
195
+ let inFlight: Promise<ProviderTokens> | null = null;
196
+
197
+ const refresh = (): Promise<ProviderTokens> => {
198
+ if (!inFlight) {
199
+ inFlight = rotate().finally(() => { inFlight = null; });
200
+ }
201
+ return inFlight;
202
+ };
203
+
204
+ async function rotate(): Promise<ProviderTokens> {
205
+ let next: ProviderTokens;
206
+ try {
207
+ next = await refreshTokens(options.descriptor, { clientId: options.clientId, refreshToken: current.refreshToken, fetch: options.fetch });
208
+ } catch (error) {
209
+ if (error instanceof ProviderAuthError) {
210
+ log('token refresh refused (%s); the app must authorize again', error.code);
211
+ throw new ProviderAuthError('reauthorize', `the provider refused to refresh the token (${error.code})`, { cause: error });
212
+ }
213
+ throw error;
214
+ }
215
+ current = next;
216
+ await options.persist(next);
217
+ log('token refreshed; expires in %ds', Math.round((next.expiresAt - now()) / 1000));
218
+ return next;
219
+ }
220
+
221
+ return {
222
+ async accessToken(): Promise<string> {
223
+ const fresh = now() < current.expiresAt - TOKEN_REFRESH_MARGIN_MS ? current : await refresh();
224
+ return fresh.accessToken;
225
+ },
226
+ async invalidate(stale?: string): Promise<string> {
227
+ if (stale !== undefined && stale !== current.accessToken) return current.accessToken;
228
+ return (await refresh()).accessToken;
229
+ },
230
+ };
231
+ }
232
+
233
+ /** One token-endpoint request (RFC 6749 §4.1.3, §6): form-encoded in, a token response or a `{ error, error_description }` refusal out. */
234
+ async function requestTokens(descriptor: ProviderDescriptor, form: Record<string, string>, fetchImpl?: typeof fetch): Promise<ProviderTokens> {
235
+ const doFetch = requireFetch(fetchImpl);
236
+ const requestedAt = Date.now();
237
+ const response = await doFetch(descriptor.oauth.tokenUrl, {
238
+ method: 'POST',
239
+ headers: { 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json' },
240
+ body: new URLSearchParams(form).toString(),
241
+ });
242
+ const body = await readJson(response);
243
+ if (!response.ok) {
244
+ const error = body && typeof body.error === 'string' ? body.error : 'invalid_response';
245
+ const description = body && typeof body.error_description === 'string'
246
+ ? body.error_description
247
+ : `the token endpoint answered ${response.status}`;
248
+ throw new ProviderAuthError(error, description);
249
+ }
250
+ return tokensFrom(body, requestedAt);
251
+ }
252
+
253
+ /** The response's JSON object, or null when the body is not one. */
254
+ async function readJson(response: Response): Promise<Record<string, unknown> | null> {
255
+ try {
256
+ const parsed: unknown = await response.json();
257
+ return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) ? parsed as Record<string, unknown> : null;
258
+ } catch (error) {
259
+ log('token endpoint answered %d with a body that is not JSON: %o', response.status, error);
260
+ return null;
261
+ }
262
+ }
263
+
264
+ /** `{ access_token, expires_in, refresh_token, scope }` as {@link ProviderTokens}; `expires_in` counts from when the request left. */
265
+ function tokensFrom(body: Record<string, unknown> | null, requestedAt: number): ProviderTokens {
266
+ const accessToken = body?.access_token;
267
+ const refreshToken = body?.refresh_token;
268
+ const expiresIn = body?.expires_in;
269
+ if (typeof accessToken !== 'string' || accessToken === '') {
270
+ throw new ProviderAuthError('invalid_response', 'the token response carries no access_token');
271
+ }
272
+ if (typeof refreshToken !== 'string' || refreshToken === '') {
273
+ throw new ProviderAuthError('invalid_response', 'the token response carries no refresh_token, which a token source needs to outlive the access token');
274
+ }
275
+ if (typeof expiresIn !== 'number' || !Number.isFinite(expiresIn) || expiresIn <= 0) {
276
+ throw new ProviderAuthError('invalid_response', 'the token response carries no positive expires_in');
277
+ }
278
+ const scope = body?.scope;
279
+ return {
280
+ accessToken,
281
+ refreshToken,
282
+ expiresAt: requestedAt + expiresIn * 1000,
283
+ scopes: typeof scope === 'string' ? scope.split(' ').filter((entry) => entry !== '') : [],
284
+ };
285
+ }
@@ -0,0 +1,275 @@
1
+ /**
2
+ * Rent a node from a hosting provider and add it to this node's cadre, by whichever of the two
3
+ * join paths fits (`docs/architecture.md` → "Renting a node from an app"):
4
+ *
5
+ * - **invitation**, when the cadre has a member the container can reach from the provider's
6
+ * network (`CadreNode.invitationReach()` reads `anywhere`): mint a single-use, non-owner
7
+ * invitation and create the container with it; the container joins by itself and the app
8
+ * watches `getContainer(id).join`.
9
+ * - **claim**, otherwise (a phone that is the cadre's only member, or members reachable only on
10
+ * their LAN, which the provider's container is never on): create the container, poll for its
11
+ * node code, and claim it from this node.
12
+ *
13
+ * Nothing here persists anything: which container belongs to which cadre is the app's record,
14
+ * and {@link finishRental} is how an app resumes a rental it was killed in the middle of. The
15
+ * node code carries the claim secret, so neither the code nor the decoded payload is ever
16
+ * logged, only the container id and the peer id.
17
+ */
18
+
19
+ import debug from 'debug';
20
+ import type { CadreNode } from '../cadre-node.js';
21
+ import { decodeNodeClaimPayload } from '../node-claim-payload.js';
22
+ import { ProviderApiError, type ProviderClient, type RentedContainerStatusReport } from './client.js';
23
+
24
+ const log = debug('sereus:cadre:provider-rent');
25
+
26
+ export type RentNodeJoin = 'claim' | 'invitation';
27
+
28
+ /** Progress, as `RentNodeOptions.onProgress` reports it; `claiming` can last a while, see {@link rentNode}. */
29
+ export type RentNodeStep = 'creating' | 'waiting-for-code' | 'claiming' | 'inviting' | 'done';
30
+
31
+ export interface RentNodeOptions {
32
+ /** Default `storage`. */
33
+ profile?: 'storage' | 'transaction';
34
+ /** Called at each step; an exception it throws is logged and ignored. */
35
+ onProgress?: (step: RentNodeStep) => void;
36
+ /**
37
+ * How long to wait for the node code before giving up with `node-not-ready`; default
38
+ * {@link DEFAULT_NODE_CODE_DEADLINE_MS}. The container exists by then and
39
+ * {@link finishRental} resumes it.
40
+ */
41
+ nodeCodeDeadlineMs?: number;
42
+ /** How often the node code is asked for while it is not ready; default {@link NODE_CODE_POLL_MS}. */
43
+ nodeCodePollMs?: number;
44
+ }
45
+
46
+ export interface RentNodeResult {
47
+ containerId: string;
48
+ join: RentNodeJoin;
49
+ /** The node's peer id, known at once on the claim path and once the container reported it on the invitation path. */
50
+ peerId?: string;
51
+ }
52
+
53
+ /** {@link finishRental} on an invitation container whose node could not redeem: delete the container and rent again. */
54
+ export interface RentalFailed {
55
+ containerId: string;
56
+ outcome: 'failed';
57
+ error: string;
58
+ retryable: boolean;
59
+ }
60
+
61
+ export type RentNodeErrorCode =
62
+ /** This node cannot act as an owner of its cadre, so it can neither invite nor claim. */
63
+ | 'not-an-owner'
64
+ /** The node code was not ready by the deadline; the container exists, `finishRental` resumes it. */
65
+ | 'node-not-ready'
66
+ /** The container is `stopped` or `error`, or the provider reported it so while the code was awaited. */
67
+ | 'container-gone'
68
+ /** A claim container that another cadre's owner claimed. */
69
+ | 'claimed-by-another'
70
+ /** A container `rentNode` did not create: it carries no join. */
71
+ | 'not-a-rental';
72
+
73
+ export class RentNodeError extends Error {
74
+ constructor(readonly code: RentNodeErrorCode, readonly containerId?: string) {
75
+ super(`rentNode: ${code}${containerId ? ` (container ${containerId})` : ''}`);
76
+ this.name = 'RentNodeError';
77
+ }
78
+ }
79
+
80
+ /** The `CadreNode` surface renting needs; a hand-written object implementing it is how tests avoid a node. */
81
+ export type RentingNode = Pick<CadreNode, 'partyId' | 'isOwner' | 'invitationReach' | 'createCadreInvitation' | 'withdrawCadreInvitation' | 'claimNode'>;
82
+
83
+ /**
84
+ * The provider's enrolment window is 60 s and an image pull can precede it. Bounds the
85
+ * container's start, not an exchange over the link, so it is not derived from the link budget.
86
+ */
87
+ // eslint-disable-next-line no-restricted-syntax -- link-independent: bounds the container's start (an image pull plus the provider's 60 s enrolment window), not an exchange over the link
88
+ export const DEFAULT_NODE_CODE_DEADLINE_MS = 120_000;
89
+ export const NODE_CODE_POLL_MS = 2_000;
90
+ /** Lifetime of the invitation a rental mints: long enough for the container to start and redeem, short enough to limit a leaked bundle. */
91
+ export const RENTAL_INVITATION_TTL_MS = 15 * 60_000;
92
+
93
+ /**
94
+ * Rent a node and add it to `node`'s cadre; see the module doc for the path choice.
95
+ *
96
+ * The claim path's `claimNode` can take up to the per-peer dial limit plus the seed request
97
+ * when no address answers (its doc: 114.5 s at the default link); no timeout is added on top
98
+ * of it, and `onProgress('claiming')` is the app's cue to show a slow hint. Two calls at once
99
+ * on one node both mint or both create; serializing them is the app's.
100
+ *
101
+ * @throws {RentNodeError} `not-an-owner`, or `node-not-ready` once the container exists.
102
+ * @throws {ProviderApiError} when the provider refused a request (a lapsed subscription is `403 QUOTA_EXCEEDED`).
103
+ * @throws {ClaimRefusedError} or {PeerUnreachableError} from `claimNode`, as it throws them.
104
+ */
105
+ export async function rentNode(node: RentingNode, client: ProviderClient, options: RentNodeOptions = {}): Promise<RentNodeResult> {
106
+ await requireOwner(node);
107
+ const reach = await node.invitationReach();
108
+ log('renting a node for party %s; invitation reach %s → %s path', node.partyId, reach, reach === 'anywhere' ? 'invitation' : 'claim');
109
+ return reach === 'anywhere'
110
+ ? await rentByInvitation(node, client, options)
111
+ : await rentByClaim(node, client, options);
112
+ }
113
+
114
+ /**
115
+ * Resume a rental: for an app that was killed mid-flow or hit the node-code deadline.
116
+ *
117
+ * - a `stopped` or `error` container → `RentNodeError('container-gone')`;
118
+ * - a claim container not yet claimed → the code, then `claimNode`, as {@link rentNode} would;
119
+ * - a claim container claimed into this cadre → done. The provider withholds the code once a
120
+ * claim is folded, so the claim is not re-run; one claimed by another cadre's owner →
121
+ * `RentNodeError('claimed-by-another')`;
122
+ * - an invitation container → done while `pending` or `accepted` (the app watches `join`), or
123
+ * the failed outcome, after which the app deletes the container and rents again (the claim
124
+ * path is chosen then if the member that answered is gone).
125
+ */
126
+ export async function finishRental(node: RentingNode, client: ProviderClient, containerId: string, options: RentNodeOptions = {}): Promise<RentNodeResult | RentalFailed> {
127
+ await requireOwner(node);
128
+ const report = await client.getContainer(containerId);
129
+ const kind = joinKindOf(report, containerId);
130
+ if (kind === 'claim') {
131
+ return await finishClaim(node, client, report, options);
132
+ }
133
+ const join = report.join;
134
+ if (join?.kind === 'invitation' && join.state === 'failed') {
135
+ return { containerId, outcome: 'failed', error: join.error, retryable: join.retryable };
136
+ }
137
+ return invitationResult(report.container);
138
+ }
139
+
140
+ async function requireOwner(node: RentingNode): Promise<void> {
141
+ if (!await node.isOwner()) throw new RentNodeError('not-an-owner');
142
+ }
143
+
144
+ async function rentByInvitation(node: RentingNode, client: ProviderClient, options: RentNodeOptions): Promise<RentNodeResult> {
145
+ report(options, 'inviting');
146
+ const { invitation, encoded } = await node.createCadreInvitation({ grantsOwner: false, uses: 1, expiresInMs: RENTAL_INVITATION_TTL_MS });
147
+ report(options, 'creating');
148
+ let container;
149
+ try {
150
+ container = await client.createContainer({ join: { kind: 'invitation', invitation: encoded }, profile: options.profile ?? 'storage' });
151
+ } catch (error) {
152
+ await withdrawQuietly(node, invitation.invite.key);
153
+ throw error;
154
+ }
155
+ log('container %s created with an invitation; it joins by itself', container.id);
156
+ report(options, 'done');
157
+ return invitationResult(container);
158
+ }
159
+
160
+ /** A create that failed leaves no live invitation behind; a withdrawal that fails too is logged, and the create's error is what the caller sees. */
161
+ async function withdrawQuietly(node: RentingNode, key: string): Promise<void> {
162
+ try {
163
+ await node.withdrawCadreInvitation(key);
164
+ } catch (error) {
165
+ log('withdrawing invitation %s after a failed create failed too: %o', key, error);
166
+ }
167
+ }
168
+
169
+ async function rentByClaim(node: RentingNode, client: ProviderClient, options: RentNodeOptions): Promise<RentNodeResult> {
170
+ report(options, 'creating');
171
+ const container = await client.createContainer({ join: { kind: 'claim' }, profile: options.profile ?? 'storage' });
172
+ log('container %s created for a claim', container.id);
173
+ return await claimContainer(node, client, container.id, options);
174
+ }
175
+
176
+ /** Poll for the node code, then claim; the second half of the claim path, shared with {@link finishRental}. */
177
+ async function claimContainer(node: RentingNode, client: ProviderClient, containerId: string, options: RentNodeOptions): Promise<RentNodeResult> {
178
+ report(options, 'waiting-for-code');
179
+ const { code, peerId } = await awaitNodeCode(client, containerId, options);
180
+ report(options, 'claiming');
181
+ // The claim is the owner's signature; the app asked the user before calling.
182
+ await node.claimNode(decodeNodeClaimPayload(code));
183
+ log('container %s claimed; its node is %s', containerId, peerId);
184
+ report(options, 'done');
185
+ return { containerId, join: 'claim', peerId };
186
+ }
187
+
188
+ /**
189
+ * `getNodeCode` every poll interval until it answers or the deadline passes. A container that
190
+ * dies meanwhile (`409 CONTAINER_GONE`) is `container-gone`, the same code `finishRental` gives
191
+ * one found dead, so an app handles both at one site.
192
+ */
193
+ async function awaitNodeCode(client: ProviderClient, containerId: string, options: RentNodeOptions): Promise<{ code: string; peerId: string }> {
194
+ const deadline = Date.now() + (options.nodeCodeDeadlineMs ?? DEFAULT_NODE_CODE_DEADLINE_MS);
195
+ const poll = options.nodeCodePollMs ?? NODE_CODE_POLL_MS;
196
+ for (;;) {
197
+ const answer = await requestNodeCode(client, containerId);
198
+ if (!('notReady' in answer)) return answer;
199
+ if (Date.now() >= deadline) {
200
+ log('container %s has no node code by the deadline', containerId);
201
+ throw new RentNodeError('node-not-ready', containerId);
202
+ }
203
+ await sleep(poll);
204
+ }
205
+ }
206
+
207
+ async function requestNodeCode(client: ProviderClient, containerId: string): ReturnType<ProviderClient['getNodeCode']> {
208
+ try {
209
+ return await client.getNodeCode(containerId);
210
+ } catch (error) {
211
+ if (error instanceof ProviderApiError && error.code === 'CONTAINER_GONE') throw new RentNodeError('container-gone', containerId);
212
+ throw error;
213
+ }
214
+ }
215
+
216
+ async function finishClaim(node: RentingNode, client: ProviderClient, report: RentedContainerStatusReport, options: RentNodeOptions): Promise<RentNodeResult> {
217
+ const { container, join } = report;
218
+ if (join?.kind === 'claim' && join.state === 'claimed') return judgeClaimed(node, report);
219
+ try {
220
+ return await claimContainer(node, client, container.id, options);
221
+ } catch (error) {
222
+ // The node was claimed between the read and the code request; judge the fresh record.
223
+ if (error instanceof ProviderApiError && error.code === 'ALREADY_CLAIMED') {
224
+ return judgeClaimed(node, await client.getContainer(container.id));
225
+ }
226
+ throw error;
227
+ }
228
+ }
229
+
230
+ /**
231
+ * A claimed container is this cadre's when the claim named this party.
232
+ *
233
+ * NOTE: the row `claimNode` writes after the node accepts is not re-run here, because the
234
+ * provider withholds the code once the claim is folded. An app killed between the node
235
+ * accepting and that one local insert has a node that belongs to this owner with no row for it
236
+ * on this machine, the window the React Native app's accepted tradeoff for cadre-host claims
237
+ * names; the remedy is to delete the container and rent again. Revisit if a device run sees it.
238
+ */
239
+ function judgeClaimed(node: RentingNode, report: RentedContainerStatusReport): RentNodeResult {
240
+ const { container, join } = report;
241
+ if (join?.kind !== 'claim' || join.state !== 'claimed') throw new RentNodeError('container-gone', container.id);
242
+ if (join.partyId !== node.partyId) {
243
+ log('container %s was claimed into another party', container.id);
244
+ throw new RentNodeError('claimed-by-another', container.id);
245
+ }
246
+ return { containerId: container.id, join: 'claim', ...(container.peerId ? { peerId: container.peerId } : {}) };
247
+ }
248
+
249
+ /** Which path a container is on; refuses one that is gone or was never a rental. */
250
+ function joinKindOf(report: RentedContainerStatusReport, containerId: string): RentNodeJoin {
251
+ const { container, join } = report;
252
+ if (container.status === 'stopped' || container.status === 'error') throw new RentNodeError('container-gone', containerId);
253
+ // The join state is folded from the node's first `/status` read, so a container still
254
+ // starting carries only the kind it was created with.
255
+ const kind = join?.kind ?? container.join?.kind;
256
+ if (kind === undefined) throw new RentNodeError('not-a-rental', containerId);
257
+ return kind;
258
+ }
259
+
260
+ function invitationResult(container: { id: string; peerId?: string }): RentNodeResult {
261
+ return { containerId: container.id, join: 'invitation', ...(container.peerId ? { peerId: container.peerId } : {}) };
262
+ }
263
+
264
+ function report(options: RentNodeOptions, step: RentNodeStep): void {
265
+ try {
266
+ options.onProgress?.(step);
267
+ } catch (error) {
268
+ log('onProgress(%s) threw; ignored: %o', step, error);
269
+ }
270
+ }
271
+
272
+ function sleep(ms: number): Promise<void> {
273
+ // A plain timer, not an unref'd one: a Node process renting from a script must stay up for it.
274
+ return new Promise((resolve) => setTimeout(resolve, ms));
275
+ }
@@ -50,7 +50,10 @@
50
50
  * only once the relay-hop protocol id is in that peer's peer-store protocol list,
51
51
  * and that list is written exclusively by IDENTIFY — which `@optimystic/db-p2p`
52
52
  * namespaces per network, so a cadre node and a stock relay never identify each
53
- * other. See `relay-reservation.ts`'s module doc and `docs/architecture.md`.
53
+ * other. A node with configured relays is built with that discovery switched off
54
+ * altogether when it has a transport list to wrap (`node-transports.ts`), because it
55
+ * CAN reach a relay-capable peer in its own namespace and take the configured relay's
56
+ * slot. See `relay-reservation.ts`'s module doc and `docs/architecture.md`.
54
57
  */
55
58
 
56
59
  import { multiaddr, type Component } from '@multiformats/multiaddr';
@@ -191,9 +194,11 @@ const WS_TRANSPORT_SWITCH_PORT = 0;
191
194
  *
192
195
  * - **WebSocket is DERIVED.** It is the one non-TCP transport the shipped configs need
193
196
  * (the React Native reference app's companion drone; `cadre start --ws-port`), and
194
- * `@optimystic/db-p2p` already knows how to add it. `cadre-core` grows no dependency
195
- * and decides no transport policy — it flips db-p2p's own switch
196
- * ({@link WS_TRANSPORT_SWITCH_PORT}).
197
+ * `@optimystic/db-p2p` already knows how to add it, so `cadre-core` flips db-p2p's own
198
+ * switch ({@link WS_TRANSPORT_SWITCH_PORT}). A node with configured relays is the one
199
+ * case where cadre passes the transport list itself (`node-transports.ts`, to switch
200
+ * relay discovery off); when the embedder gave none, that list comes from
201
+ * `network.defaultTransports`, which reads the same switch.
197
202
  * - **Everything else is REFUSED.** Deriving `/quic-v1`, `/webrtc` or `/webtransport`
198
203
  * would mean importing transport packages into every `cadre-core` consumer including
199
204
  * the React Native and browser bundles (against the cross-platform rule in