@serfab/cadre-core 1.14.0 → 1.15.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 +233 -45
  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 +789 -722
  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,326 @@
1
+ /**
2
+ * A typed client for a hosting provider's container API (`docs/architecture.md` → "Provider
3
+ * Integration"; the wire shapes are cadre-provider's, `packages/cadre-provider/README.md`).
4
+ * The interface is what `rentNode` and the apps depend on; a hand-written object implementing
5
+ * it is how tests avoid HTTP.
6
+ *
7
+ * One request helper does the HTTP: the bearer from the token source, JSON in and out, a 401
8
+ * answered once by `tokens.invalidate()` and a retry, every other non-2xx a
9
+ * {@link ProviderApiError} from the provider's `{ ok: false, error }` envelope. Dates arrive
10
+ * as ISO strings and are parsed into epoch milliseconds here, so nothing above the client
11
+ * handles date strings. Global `fetch` only: loads on React Native and in the browser.
12
+ */
13
+
14
+ import debug from 'debug';
15
+ import { requireFetch } from '../fetch-impl.js';
16
+ import type { TokenSource } from './oauth.js';
17
+
18
+ const log = debug('sereus:cadre:provider-client');
19
+
20
+ /** How a container was asked to join its cadre (`POST /containers` `join`). */
21
+ export type ContainerJoinRequest =
22
+ | { kind: 'claim' }
23
+ | { kind: 'invitation'; invitation: string };
24
+
25
+ /** The join as the node last reported it, folded onto the record by the provider's `/status` reads. */
26
+ export type ContainerJoinState =
27
+ | { kind: 'claim'; state: 'awaiting' }
28
+ | { kind: 'claim'; state: 'claimed'; ownerKey: string; partyId: string }
29
+ | { kind: 'invitation'; state: 'pending' }
30
+ | { kind: 'invitation'; state: 'accepted'; memberPeerId?: string }
31
+ | { kind: 'invitation'; state: 'failed'; error: string; retryable: boolean };
32
+
33
+ export type RentedContainerStatus = 'pending' | 'creating' | 'enrolling' | 'running' | 'stopping' | 'stopped' | 'error';
34
+
35
+ /** Why a container was stopped: its customer asked, or the customer's subscription lapsed. */
36
+ export type ContainerStopReason = 'customer' | 'non-payment';
37
+
38
+ /** A container as the API returns it to its customer (the provider's `RedactedContainer`), dates in epoch ms. */
39
+ export interface RentedContainer {
40
+ id: string;
41
+ /** The cadre the container serves; a claim container's is the placeholder `unclaimed` until the claim is folded. */
42
+ partyId: string;
43
+ profile: 'storage' | 'transaction';
44
+ status: RentedContainerStatus;
45
+ createdAt: number;
46
+ updatedAt: number;
47
+ /** The failure, when `status` is `error`. */
48
+ error?: string;
49
+ /** The node's peer id once it first came up. */
50
+ peerId?: string;
51
+ /** How the container joins; the credential itself never leaves the provider this way. */
52
+ join?: { kind: ContainerJoinRequest['kind'] };
53
+ joinState?: ContainerJoinState;
54
+ /** The owner key a claim named, once folded. */
55
+ ownerKey?: string;
56
+ stopReason?: ContainerStopReason;
57
+ tags: Record<string, string>;
58
+ }
59
+
60
+ /** The node's live `/status`, as far as an app reads it. */
61
+ export interface RentedContainerHealth {
62
+ status: 'healthy' | 'unhealthy' | 'starting';
63
+ peerId?: string;
64
+ multiaddrs?: string[];
65
+ }
66
+
67
+ /** `GET /containers/:id`: the record after a live read, the read itself, and the join state lifted out of the record. */
68
+ export interface RentedContainerStatusReport {
69
+ container: RentedContainer;
70
+ health?: RentedContainerHealth;
71
+ join?: ContainerJoinState;
72
+ }
73
+
74
+ export interface CreateContainerBody {
75
+ join: ContainerJoinRequest;
76
+ /** Default `storage`. */
77
+ profile?: 'storage' | 'transaction';
78
+ tags?: Record<string, string>;
79
+ }
80
+
81
+ export type SubscriptionStatus = 'active' | 'past-due' | 'lapsed';
82
+
83
+ /** `GET /billing/status`, dates in epoch ms. */
84
+ export interface BillingStatus {
85
+ planId: string;
86
+ subscription: {
87
+ status: SubscriptionStatus;
88
+ /** When the current period renews; null once lapsed. */
89
+ renewsAt: number | null;
90
+ /** `past-due` only: when the grace period ends and the containers are stopped. */
91
+ lapsesAt: number | null;
92
+ };
93
+ }
94
+
95
+ /** `GET /containers/:id/code`: the code the owner claims the container with, or not yet. */
96
+ export type NodeCodeAnswer =
97
+ | { code: string; peerId: string; multiaddrs: string[] }
98
+ /** The node has not reported its peer id and an announced address yet (503); poll. */
99
+ | { notReady: true };
100
+
101
+ /** A non-2xx answer the provider explained: its `error.code` (`NOT_FOUND`, `ALREADY_CLAIMED`, `QUOTA_EXCEEDED`, ...) and message. */
102
+ export class ProviderApiError extends Error {
103
+ constructor(readonly status: number, readonly code: string, message: string) {
104
+ super(`Provider answered ${status} ${code}: ${message}`);
105
+ this.name = 'ProviderApiError';
106
+ }
107
+ }
108
+
109
+ export interface ProviderClient {
110
+ createContainer(body: CreateContainerBody): Promise<RentedContainer>;
111
+ getContainer(id: string): Promise<RentedContainerStatusReport>;
112
+ listContainers(): Promise<RentedContainer[]>;
113
+ deleteContainer(id: string): Promise<void>;
114
+ /** Undefined when the customer has no billing record yet. */
115
+ getBillingStatus(): Promise<BillingStatus | undefined>;
116
+ /** `notReady` on 503; a `ProviderApiError` for the 409s (`NOT_CLAIMABLE`, `ALREADY_CLAIMED`, `CONTAINER_GONE`). */
117
+ getNodeCode(id: string): Promise<NodeCodeAnswer>;
118
+ }
119
+
120
+ export interface ProviderClientOptions {
121
+ /** The descriptor's `apiBaseUrl`; the bearer is sent here and nowhere else. */
122
+ apiBaseUrl: string;
123
+ tokens: TokenSource;
124
+ fetch?: typeof fetch;
125
+ }
126
+
127
+ export function createProviderClient(options: ProviderClientOptions): ProviderClient {
128
+ const doFetch = requireFetch(options.fetch);
129
+ const base = options.apiBaseUrl.replace(/\/+$/, '');
130
+ const tokens = options.tokens;
131
+
132
+ /** One authenticated JSON request; see the module doc for the 401 retry and the error envelope. */
133
+ async function request(method: string, path: string, body?: unknown, allow: readonly number[] = []): Promise<{ status: number; data: unknown }> {
134
+ const headers: Record<string, string> = { accept: 'application/json' };
135
+ if (body !== undefined) headers['content-type'] = 'application/json';
136
+ const encoded = body === undefined ? undefined : JSON.stringify(body);
137
+ const send = async (bearer: string): Promise<Response> => doFetch(`${base}/${path}`, {
138
+ method,
139
+ headers: { ...headers, authorization: `Bearer ${bearer}` },
140
+ body: encoded,
141
+ });
142
+
143
+ const first = await tokens.accessToken();
144
+ let response = await send(first);
145
+ if (response.status === 401) {
146
+ log('%s %s answered 401; refreshing the token and retrying once', method, path);
147
+ response = await send(await tokens.invalidate(first));
148
+ }
149
+ const parsed = await readEnvelope(response);
150
+ if (allow.includes(response.status)) return { status: response.status, data: parsed.data };
151
+ if (!response.ok || parsed.ok === false) {
152
+ throw new ProviderApiError(response.status, parsed.code ?? `HTTP_${response.status}`, parsed.message ?? `${method} ${path} failed`);
153
+ }
154
+ return { status: response.status, data: parsed.data };
155
+ }
156
+
157
+ return {
158
+ async createContainer(body) {
159
+ const { data } = await request('POST', 'containers', body);
160
+ return parseContainer(requireObject(data, 'POST containers').container);
161
+ },
162
+ async getContainer(id) {
163
+ const { data } = await request('GET', `containers/${encodeURIComponent(id)}`);
164
+ const report = requireObject(data, 'GET containers/:id');
165
+ const container = parseContainer(report.container);
166
+ const health = parseHealth(report.health);
167
+ return { container, ...(health ? { health } : {}), ...(container.joinState ? { join: container.joinState } : {}) };
168
+ },
169
+ async listContainers() {
170
+ const { data } = await request('GET', 'containers');
171
+ const list = requireObject(data, 'GET containers').containers;
172
+ if (!Array.isArray(list)) throw new Error('The provider\'s container list is not a list');
173
+ return list.map(parseContainer);
174
+ },
175
+ async deleteContainer(id) {
176
+ await request('DELETE', `containers/${encodeURIComponent(id)}`);
177
+ },
178
+ async getBillingStatus() {
179
+ const { data } = await request('GET', 'billing/status');
180
+ const billing = requireObject(data, 'GET billing/status').billing;
181
+ return billing === null || billing === undefined ? undefined : parseBilling(billing);
182
+ },
183
+ async getNodeCode(id) {
184
+ const { status, data } = await request('GET', `containers/${encodeURIComponent(id)}/code`, undefined, [503]);
185
+ if (status === 503) return { notReady: true };
186
+ const code = requireObject(data, 'GET containers/:id/code');
187
+ return {
188
+ code: requireString(code.code, 'code'),
189
+ peerId: requireString(code.peerId, 'peerId'),
190
+ multiaddrs: requireStrings(code.multiaddrs, 'multiaddrs'),
191
+ };
192
+ },
193
+ };
194
+ }
195
+
196
+ /** The provider's `{ ok, data }` / `{ ok: false, error: { code, message } }` envelope, tolerating a body that is not one (a proxy's error page). */
197
+ async function readEnvelope(response: Response): Promise<{ ok?: boolean; data?: unknown; code?: string; message?: string }> {
198
+ let body: unknown;
199
+ try {
200
+ body = await response.json();
201
+ } catch (error) {
202
+ log('answer %d carried a body that is not JSON: %o', response.status, error);
203
+ return {};
204
+ }
205
+ if (typeof body !== 'object' || body === null) return {};
206
+ const envelope = body as { ok?: unknown; data?: unknown; error?: unknown; message?: unknown };
207
+ const error = typeof envelope.error === 'object' && envelope.error !== null ? envelope.error as { code?: unknown; message?: unknown } : undefined;
208
+ return {
209
+ ok: typeof envelope.ok === 'boolean' ? envelope.ok : undefined,
210
+ data: envelope.data,
211
+ code: typeof error?.code === 'string' ? error.code : undefined,
212
+ // `DELETE` reports a failed termination as `{ ok: false, message }` with no `error`.
213
+ message: typeof error?.message === 'string' ? error.message : typeof envelope.message === 'string' ? envelope.message : undefined,
214
+ };
215
+ }
216
+
217
+ function parseContainer(value: unknown): RentedContainer {
218
+ const record = requireObject(value, 'a container record');
219
+ const container: RentedContainer = {
220
+ id: requireString(record.id, 'id'),
221
+ partyId: requireString(record.partyId, 'partyId'),
222
+ profile: record.profile === 'transaction' ? 'transaction' : 'storage',
223
+ status: requireStatus(record.status),
224
+ createdAt: parseIsoMs(record.createdAt, 'createdAt'),
225
+ updatedAt: parseIsoMs(record.updatedAt, 'updatedAt'),
226
+ tags: typeof record.tags === 'object' && record.tags !== null ? record.tags as Record<string, string> : {},
227
+ };
228
+ if (typeof record.error === 'string') container.error = record.error;
229
+ if (typeof record.peerId === 'string') container.peerId = record.peerId;
230
+ if (typeof record.ownerKey === 'string') container.ownerKey = record.ownerKey;
231
+ if (record.stopReason === 'customer' || record.stopReason === 'non-payment') container.stopReason = record.stopReason;
232
+ const join = record.join;
233
+ if (typeof join === 'object' && join !== null) {
234
+ const kind = (join as { kind?: unknown }).kind;
235
+ if (kind === 'claim' || kind === 'invitation') container.join = { kind };
236
+ }
237
+ if (record.joinState !== undefined) container.joinState = parseJoinState(record.joinState);
238
+ return container;
239
+ }
240
+
241
+ function parseJoinState(value: unknown): ContainerJoinState {
242
+ const state = requireObject(value, 'a join state');
243
+ const key = `${String(state.kind)}/${String(state.state)}`;
244
+ switch (key) {
245
+ case 'claim/awaiting':
246
+ return { kind: 'claim', state: 'awaiting' };
247
+ case 'claim/claimed':
248
+ return { kind: 'claim', state: 'claimed', ownerKey: requireString(state.ownerKey, 'joinState.ownerKey'), partyId: requireString(state.partyId, 'joinState.partyId') };
249
+ case 'invitation/pending':
250
+ return { kind: 'invitation', state: 'pending' };
251
+ case 'invitation/accepted':
252
+ return { kind: 'invitation', state: 'accepted', ...(typeof state.memberPeerId === 'string' ? { memberPeerId: state.memberPeerId } : {}) };
253
+ case 'invitation/failed':
254
+ return { kind: 'invitation', state: 'failed', error: requireString(state.error, 'joinState.error'), retryable: state.retryable === true };
255
+ default:
256
+ throw new Error(`The provider reported a join state this client does not know (${key})`);
257
+ }
258
+ }
259
+
260
+ function parseHealth(value: unknown): RentedContainerHealth | undefined {
261
+ if (typeof value !== 'object' || value === null) return undefined;
262
+ const health = value as { status?: unknown; peerId?: unknown; multiaddrs?: unknown };
263
+ const status = health.status === 'healthy' || health.status === 'unhealthy' || health.status === 'starting' ? health.status : undefined;
264
+ if (!status) return undefined;
265
+ return {
266
+ status,
267
+ ...(typeof health.peerId === 'string' ? { peerId: health.peerId } : {}),
268
+ ...(Array.isArray(health.multiaddrs) ? { multiaddrs: health.multiaddrs.filter((addr): addr is string => typeof addr === 'string') } : {}),
269
+ };
270
+ }
271
+
272
+ function parseBilling(value: unknown): BillingStatus {
273
+ const billing = requireObject(value, 'a billing record');
274
+ const subscription = requireObject(billing.subscription, 'a billing record\'s subscription');
275
+ const status = subscription.status;
276
+ if (status !== 'active' && status !== 'past-due' && status !== 'lapsed') {
277
+ throw new Error(`The provider reported a subscription status this client does not know (${String(status)})`);
278
+ }
279
+ return {
280
+ planId: requireString(billing.planId, 'planId'),
281
+ subscription: {
282
+ status,
283
+ renewsAt: parseNullableIsoMs(subscription.renewsAt, 'subscription.renewsAt'),
284
+ lapsesAt: parseNullableIsoMs(subscription.lapsesAt, 'subscription.lapsesAt'),
285
+ },
286
+ };
287
+ }
288
+
289
+ const CONTAINER_STATUSES: readonly RentedContainerStatus[] = ['pending', 'creating', 'enrolling', 'running', 'stopping', 'stopped', 'error'];
290
+
291
+ function requireStatus(value: unknown): RentedContainerStatus {
292
+ if (!CONTAINER_STATUSES.includes(value as RentedContainerStatus)) {
293
+ throw new Error(`The provider reported a container status this client does not know (${String(value)})`);
294
+ }
295
+ return value as RentedContainerStatus;
296
+ }
297
+
298
+ function requireObject(value: unknown, what: string): Record<string, unknown> {
299
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
300
+ throw new Error(`The provider's answer to ${what} is not the JSON object the API documents`);
301
+ }
302
+ return value as Record<string, unknown>;
303
+ }
304
+
305
+ function requireString(value: unknown, field: string): string {
306
+ if (typeof value !== 'string') throw new Error(`The provider's answer lacks the string field ${field}`);
307
+ return value;
308
+ }
309
+
310
+ function requireStrings(value: unknown, field: string): string[] {
311
+ if (!Array.isArray(value) || !value.every((entry): entry is string => typeof entry === 'string')) {
312
+ throw new Error(`The provider's answer lacks the string list ${field}`);
313
+ }
314
+ return value;
315
+ }
316
+
317
+ /** An ISO 8601 date string as epoch ms; the client's boundary, so nothing above it handles date strings. */
318
+ function parseIsoMs(value: unknown, field: string): number {
319
+ const ms = typeof value === 'string' ? Date.parse(value) : Number.NaN;
320
+ if (Number.isNaN(ms)) throw new Error(`The provider's answer carries no ISO date in ${field}`);
321
+ return ms;
322
+ }
323
+
324
+ function parseNullableIsoMs(value: unknown, field: string): number | null {
325
+ return value === null || value === undefined ? null : parseIsoMs(value, field);
326
+ }
@@ -0,0 +1,129 @@
1
+ /**
2
+ * The provider descriptor: what a hosting provider says about itself (`GET /descriptor`,
3
+ * `docs/architecture.md` → "Provider descriptor and sign-in"), as an app's store screen
4
+ * renders it and as the sign-in and the provider client read it. The parser refuses, with a
5
+ * plain message, a document the rest of this directory could not work with.
6
+ *
7
+ * Imports only the package's `fetch` helper: it loads on React Native and in the browser as
8
+ * well as Node.
9
+ */
10
+
11
+ import { requireFetch } from '../fetch-impl.js';
12
+
13
+ /** Version 1 of the descriptor, the only one an app reads. */
14
+ export interface ProviderDescriptor {
15
+ v: 1;
16
+ /** Stable identifier an app keys its rental records by. */
17
+ id: string;
18
+ name: string;
19
+ /** The root every API route hangs off (`https://nodes.acme.example/api/v1`); the bearer goes here and nowhere else. */
20
+ apiBaseUrl: string;
21
+ /** The sign-in endpoints, and the union of the scopes any registered app may be granted. */
22
+ oauth: {
23
+ authorizeUrl: string;
24
+ tokenUrl: string;
25
+ scopes: string[];
26
+ };
27
+ /** The public plan list, readable before sign-in. */
28
+ plansUrl: string;
29
+ termsUrl?: string;
30
+ /** Regions in the provider's own words. */
31
+ regions?: string[];
32
+ icon?: string;
33
+ }
34
+
35
+ /**
36
+ * Read a descriptor document. Refuses: any version but 1; a missing or blank `id` or `name`;
37
+ * an `apiBaseUrl`, `oauth.authorizeUrl`, `oauth.tokenUrl` or `plansUrl` that is not an
38
+ * absolute URL, or is `http` at a host other than loopback, since the node code and the tokens
39
+ * travel over them; a `termsUrl` or `icon` that is not an absolute web URL; `regions` or
40
+ * `oauth.scopes` that are not string lists. A descriptor without `oauth` is refused too:
41
+ * no app could sign in to that provider.
42
+ */
43
+ export function parseProviderDescriptor(json: unknown): ProviderDescriptor {
44
+ const doc = requireObject(json, 'A provider descriptor');
45
+ if (doc.v !== 1) throw new Error(`A provider descriptor must carry v: 1 (this one carries ${JSON.stringify(doc.v)})`);
46
+ const id = requireText(doc.id, 'id');
47
+ const name = requireText(doc.name, 'name');
48
+ const apiBaseUrl = requireApiUrl(doc.apiBaseUrl, 'apiBaseUrl');
49
+ const plansUrl = requireApiUrl(doc.plansUrl, 'plansUrl');
50
+ const oauth = requireObject(doc.oauth, 'A provider descriptor\'s oauth block');
51
+ const parsed: ProviderDescriptor = {
52
+ v: 1,
53
+ id,
54
+ name,
55
+ apiBaseUrl,
56
+ oauth: {
57
+ authorizeUrl: requireApiUrl(oauth.authorizeUrl, 'oauth.authorizeUrl'),
58
+ tokenUrl: requireApiUrl(oauth.tokenUrl, 'oauth.tokenUrl'),
59
+ scopes: requireStrings(oauth.scopes, 'oauth.scopes'),
60
+ },
61
+ plansUrl,
62
+ };
63
+ if (doc.termsUrl !== undefined) parsed.termsUrl = requireWebUrl(doc.termsUrl, 'termsUrl');
64
+ if (doc.icon !== undefined) parsed.icon = requireWebUrl(doc.icon, 'icon');
65
+ if (doc.regions !== undefined) parsed.regions = requireStrings(doc.regions, 'regions');
66
+ return parsed;
67
+ }
68
+
69
+ /** `GET url`, parsed with {@link parseProviderDescriptor}, for an app that holds a URL rather than the document. */
70
+ export async function fetchProviderDescriptor(url: string, fetchImpl?: typeof fetch): Promise<ProviderDescriptor> {
71
+ const doFetch = requireFetch(fetchImpl);
72
+ const response = await doFetch(url, { headers: { accept: 'application/json' } });
73
+ if (!response.ok) {
74
+ throw new Error(`The provider descriptor at ${url} answered ${response.status}`);
75
+ }
76
+ return parseProviderDescriptor(await response.json());
77
+ }
78
+
79
+ /** Hosts at which an API URL may be plain `http`: a dev run on this machine. */
80
+ const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]']);
81
+
82
+ function requireObject(value: unknown, what: string): Record<string, unknown> {
83
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
84
+ throw new Error(`${what} must be a JSON object`);
85
+ }
86
+ return value as Record<string, unknown>;
87
+ }
88
+
89
+ function requireText(value: unknown, field: string): string {
90
+ if (typeof value !== 'string' || value.trim() === '') {
91
+ throw new Error(`A provider descriptor's ${field} must be a non-blank string`);
92
+ }
93
+ return value;
94
+ }
95
+
96
+ function requireStrings(value: unknown, field: string): string[] {
97
+ if (!Array.isArray(value) || !value.every((entry): entry is string => typeof entry === 'string')) {
98
+ throw new Error(`A provider descriptor's ${field} must be a list of strings`);
99
+ }
100
+ return [...value];
101
+ }
102
+
103
+ /** An absolute `http(s)` URL, as a link a store screen opens. */
104
+ function requireWebUrl(value: unknown, field: string): string {
105
+ const text = requireText(value, field);
106
+ const url = parseUrl(text, field);
107
+ if (url.protocol !== 'https:' && url.protocol !== 'http:') {
108
+ throw new Error(`A provider descriptor's ${field} must be an http or https URL (${text} is not)`);
109
+ }
110
+ return text;
111
+ }
112
+
113
+ /** {@link requireWebUrl}, and `https` unless the host is loopback: tokens and the node code travel over it. */
114
+ function requireApiUrl(value: unknown, field: string): string {
115
+ const text = requireWebUrl(value, field);
116
+ const url = new URL(text);
117
+ if (url.protocol === 'http:' && !LOOPBACK_HOSTS.has(url.hostname)) {
118
+ throw new Error(`A provider descriptor's ${field} must be https unless its host is loopback (${text} is plain http at ${url.hostname})`);
119
+ }
120
+ return text;
121
+ }
122
+
123
+ function parseUrl(text: string, field: string): URL {
124
+ try {
125
+ return new URL(text);
126
+ } catch (error) {
127
+ throw new Error(`A provider descriptor's ${field} must be an absolute URL (${text} is not)`, { cause: error });
128
+ }
129
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Everything an app needs to rent a node from a hosting provider: the provider's descriptor,
3
+ * the sign-in handshake, the typed API client, and `rentNode` / `finishRental`. Every module
4
+ * here loads on React Native and in the browser as well as Node.
5
+ */
6
+
7
+ export { parseProviderDescriptor, fetchProviderDescriptor, type ProviderDescriptor } from './descriptor.js';
8
+ export {
9
+ ProviderAuthError,
10
+ pkceChallenge,
11
+ createPkce,
12
+ createAuthorizationState,
13
+ authorizationUrl,
14
+ readAuthorizationRedirect,
15
+ exchangeAuthorizationCode,
16
+ refreshTokens,
17
+ createTokenSource,
18
+ TOKEN_REFRESH_MARGIN_MS,
19
+ type Pkce,
20
+ type ProviderTokens,
21
+ type AuthorizationUrlOptions,
22
+ type AuthorizationRedirect,
23
+ type ExchangeAuthorizationCodeOptions,
24
+ type RefreshTokensOptions,
25
+ type TokenSource,
26
+ type TokenSourceOptions,
27
+ } from './oauth.js';
28
+ export {
29
+ createProviderClient,
30
+ ProviderApiError,
31
+ type ProviderClient,
32
+ type ProviderClientOptions,
33
+ type ContainerJoinRequest,
34
+ type ContainerJoinState,
35
+ type ContainerStopReason,
36
+ type RentedContainer,
37
+ type RentedContainerStatus,
38
+ type RentedContainerHealth,
39
+ type RentedContainerStatusReport,
40
+ type CreateContainerBody,
41
+ type SubscriptionStatus,
42
+ type BillingStatus,
43
+ type NodeCodeAnswer,
44
+ } from './client.js';
45
+ export {
46
+ rentNode,
47
+ finishRental,
48
+ RentNodeError,
49
+ DEFAULT_NODE_CODE_DEADLINE_MS,
50
+ NODE_CODE_POLL_MS,
51
+ RENTAL_INVITATION_TTL_MS,
52
+ type RentNodeJoin,
53
+ type RentNodeStep,
54
+ type RentNodeOptions,
55
+ type RentNodeResult,
56
+ type RentalFailed,
57
+ type RentNodeErrorCode,
58
+ type RentingNode,
59
+ } from './rent-node.js';