@leavepulse/control-sdk 0.3.31

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 (46) hide show
  1. package/README.md +2 -0
  2. package/auth-types.ts +5296 -0
  3. package/client.ts +320 -0
  4. package/index.ts +110 -0
  5. package/models.ts +232 -0
  6. package/package.json +28 -0
  7. package/procedures.ts +451 -0
  8. package/resources/ControlAgentRelease.ts +40 -0
  9. package/resources/ControlAlert.ts +39 -0
  10. package/resources/ControlCfAccount.ts +82 -0
  11. package/resources/ControlDcimAcceptance.ts +126 -0
  12. package/resources/ControlDcimCable.ts +70 -0
  13. package/resources/ControlDcimComponent.ts +62 -0
  14. package/resources/ControlDcimDevice.ts +71 -0
  15. package/resources/ControlDcimFeed.ts +61 -0
  16. package/resources/ControlDcimLocation.ts +55 -0
  17. package/resources/ControlDcimOutlet.ts +61 -0
  18. package/resources/ControlDcimPdu.ts +58 -0
  19. package/resources/ControlDcimPort.ts +61 -0
  20. package/resources/ControlDcimPowerLink.ts +34 -0
  21. package/resources/ControlDcimRack.ts +61 -0
  22. package/resources/ControlEdge.ts +43 -0
  23. package/resources/ControlEnrollToken.ts +45 -0
  24. package/resources/ControlEnvGroup.ts +60 -0
  25. package/resources/ControlHost.ts +184 -0
  26. package/resources/ControlNode.ts +48 -0
  27. package/resources/ControlProject.ts +36 -0
  28. package/resources/ControlRule.ts +56 -0
  29. package/resources/ControlSchedule.ts +56 -0
  30. package/resources/ControlService.ts +101 -0
  31. package/runtime/cache-policy.ts +371 -0
  32. package/runtime/cache.ts +129 -0
  33. package/runtime/credentials.ts +139 -0
  34. package/runtime/device.ts +199 -0
  35. package/runtime/errors.ts +225 -0
  36. package/runtime/etag-store.ts +252 -0
  37. package/runtime/json.ts +25 -0
  38. package/runtime/oauth2.ts +150 -0
  39. package/runtime/page.ts +81 -0
  40. package/runtime/realtime-client.ts +339 -0
  41. package/runtime/realtime.ts +257 -0
  42. package/runtime/realtime_pb/leavepulse/realtime/v1/ws_pb.ts +464 -0
  43. package/runtime/resource.ts +84 -0
  44. package/runtime/snowflake.ts +7 -0
  45. package/runtime/transport.ts +404 -0
  46. package/types.ts +7279 -0
@@ -0,0 +1,404 @@
1
+ // LeavePulse SDK — transport abstraction.
2
+ //
3
+ // The SDK never knows how requests are authenticated or sent. It only calls
4
+ // `transport.request(...)`. Adapters supply the real mechanism:
5
+ // - BrowserTransport: wraps the Nuxt `useApi()` channel (cookie + CSRF).
6
+ // - BearerTransport: Authorization: Bearer for external consumers.
7
+ // - MockTransport: canned responses for tests.
8
+
9
+ import type { CredentialProvider } from "./credentials";
10
+ import { StaticCredential } from "./credentials";
11
+ import { httpErrorFor, RateLimited, ServerError, Unauthorized } from "./errors";
12
+ import { parseJson } from "./json";
13
+
14
+ export type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
15
+
16
+ /**
17
+ * Which backend a request targets. `platform` is the BFF (`/v1/*`); `auth` is
18
+ * the auth-service core (`/auth/*`) carrying login/refresh/oauth. The adapter
19
+ * maps each channel to a base URL and to its own auth mechanism (cookie+CSRF
20
+ * for the web, local refresh for the launcher, bearer for services). The SDK
21
+ * never learns *how* a channel authenticates — only which one to hit.
22
+ */
23
+ export type Channel = "platform" | "auth";
24
+
25
+ /**
26
+ * A file to upload, in the style of discord.py's `File`. Wrap a `Blob`/`File`
27
+ * (or raw bytes) with a filename and optional content type, then hand it to a
28
+ * multipart SDK method (e.g. `server.iconsUpload(new LeavePulseFile(blob))`).
29
+ */
30
+ export class LeavePulseFile {
31
+ readonly data: Blob;
32
+ readonly filename: string;
33
+ readonly contentType?: string;
34
+
35
+ constructor(
36
+ data: Blob | ArrayBuffer | Uint8Array,
37
+ filename = "upload",
38
+ contentType?: string,
39
+ ) {
40
+ this.data =
41
+ data instanceof Blob
42
+ ? data
43
+ : new Blob(
44
+ [data as BlobPart],
45
+ contentType ? { type: contentType } : {},
46
+ );
47
+ this.filename = filename;
48
+ this.contentType = contentType;
49
+ }
50
+
51
+ /** The part as a `Blob` carrying the right content type, for `FormData`. */
52
+ toBlob(): Blob {
53
+ if (this.contentType && this.data.type !== this.contentType) {
54
+ return new Blob([this.data], { type: this.contentType });
55
+ }
56
+ return this.data;
57
+ }
58
+ }
59
+
60
+ /** A `multipart/form-data` upload: one file part plus scalar form fields. */
61
+ export interface MultipartBody {
62
+ /** Form field name for the binary part (e.g. `file`, `avatar`). */
63
+ fileField: string;
64
+ file?: LeavePulseFile;
65
+ /** Scalar fields sent alongside the file; undefined values are dropped. */
66
+ fields?: Record<string, string | number | boolean | undefined>;
67
+ }
68
+
69
+ export interface TransportRequest {
70
+ method: HttpMethod;
71
+ /** Path relative to the channel root, e.g. `/v1/projects/1` or `/auth/login`. */
72
+ path: string;
73
+ /** Backend channel; defaults to `platform` when omitted. */
74
+ channel?: Channel;
75
+ /** Query parameters; undefined values are dropped. */
76
+ query?: Record<string, string | number | boolean | undefined>;
77
+ /** JSON request body. */
78
+ body?: unknown;
79
+ /** Multipart upload body; mutually exclusive with `body`. */
80
+ multipart?: MultipartBody;
81
+ /**
82
+ * `application/x-www-form-urlencoded` body; mutually exclusive with `body`
83
+ * and `multipart`. Required by the OAuth2 `/auth/oauth2/token` endpoint,
84
+ * which consumes form-encoded (not JSON) grant requests.
85
+ */
86
+ form?: Record<string, string>;
87
+ /**
88
+ * Send `If-None-Match` with this ETag so the server can answer `304 Not
89
+ * Modified` and skip resending an unchanged body. Only meaningful through
90
+ * `conditional()`.
91
+ */
92
+ ifNoneMatch?: string;
93
+ /**
94
+ * Send `If-Match` with this ETag for an optimistic-concurrency write: the
95
+ * server rejects the request (`412 Precondition Failed`) if the resource has
96
+ * changed since the validator was read. Only meaningful through
97
+ * `requestWithIfMatch()`.
98
+ */
99
+ ifMatch?: string;
100
+ }
101
+
102
+ /**
103
+ * Result of a conditional (`If-None-Match`) request:
104
+ * - `modified`: the body changed (or there was no prior ETag) — `data` holds
105
+ * it, `etag` is the new validator to cache.
106
+ * - `not_modified`: the server returned `304` — reuse the cached copy.
107
+ * - `not_found`: the resource returned `404` (e.g. nothing published yet).
108
+ */
109
+ export type ConditionalResult<T> =
110
+ | { status: "modified"; data: T; etag?: string }
111
+ | { status: "not_modified"; etag?: string }
112
+ | { status: "not_found" };
113
+
114
+ export interface Transport {
115
+ request<T>(req: TransportRequest): Promise<T>;
116
+ /**
117
+ * Like `request`, but treats `304`/`404` as outcomes rather than errors and
118
+ * surfaces the response ETag — for caching resources (launch manifests,
119
+ * server registries) without refetching unchanged bodies.
120
+ */
121
+ conditional<T>(req: TransportRequest): Promise<ConditionalResult<T>>;
122
+ /**
123
+ * Like `request`, but sends an `If-Match` validator for an optimistic-
124
+ * concurrency write. Optional: transports that predate it (or can't carry the
125
+ * header) are reached through `request` instead, so the validator is best-
126
+ * effort — mirrors the Rust trait's defaulted method.
127
+ */
128
+ requestWithIfMatch?<T>(req: TransportRequest, ifMatch?: string): Promise<T>;
129
+ }
130
+
131
+ /** Assemble a `FormData` body from a multipart descriptor. */
132
+ export function buildFormData(multipart: MultipartBody): FormData {
133
+ const form = new FormData();
134
+ if (multipart.file) {
135
+ form.append(
136
+ multipart.fileField,
137
+ multipart.file.toBlob(),
138
+ multipart.file.filename,
139
+ );
140
+ }
141
+ for (const [key, value] of Object.entries(multipart.fields ?? {})) {
142
+ if (value !== undefined) form.append(key, String(value));
143
+ }
144
+ return form;
145
+ }
146
+
147
+ /** Build a path with an encoded query string from a TransportRequest. */
148
+ export function buildPath(req: TransportRequest): string {
149
+ if (!req.query) return req.path;
150
+ const params = new URLSearchParams();
151
+ for (const [key, value] of Object.entries(req.query)) {
152
+ if (value !== undefined) params.set(key, String(value));
153
+ }
154
+ const qs = params.toString();
155
+ return qs ? `${req.path}?${qs}` : req.path;
156
+ }
157
+
158
+ /** Tuning for a transport's automatic retry behaviour. */
159
+ export interface RetryOptions {
160
+ /** Max automatic retries on 429 / 5xx (default 2). Set 0 to disable. */
161
+ maxRetries?: number;
162
+ /** Base backoff in ms for 5xx exponential backoff (default 250). */
163
+ backoffBaseMs?: number;
164
+ /** Cap on any single backoff wait, ms (default 10000). */
165
+ backoffMaxMs?: number;
166
+ }
167
+
168
+ const DEFAULT_RETRY: Required<RetryOptions> = {
169
+ maxRetries: 2,
170
+ backoffBaseMs: 250,
171
+ backoffMaxMs: 10_000,
172
+ };
173
+
174
+ const sleep = (ms: number): Promise<void> =>
175
+ new Promise((resolve) => setTimeout(resolve, ms));
176
+
177
+ /** Parse a `Retry-After` header (seconds or HTTP-date) into seconds. */
178
+ function parseRetryAfter(value: string | null): number | undefined {
179
+ if (!value) return undefined;
180
+ const secs = Number(value);
181
+ if (Number.isFinite(secs)) return secs;
182
+ const date = Date.parse(value);
183
+ if (!Number.isNaN(date)) return Math.max(0, (date - Date.now()) / 1000);
184
+ return undefined;
185
+ }
186
+
187
+ /** Build the request body + headers for one HTTP attempt from a transport
188
+ * request. Mutually exclusive body kinds: `multipart` (FormData, no explicit
189
+ * Content-Type — the runtime adds the boundary), `form` (URL-encoded), or
190
+ * `body` (JSON). */
191
+ function buildBody(req: TransportRequest): {
192
+ body: BodyInit | undefined;
193
+ contentType?: string;
194
+ } {
195
+ if (req.multipart) {
196
+ // Do NOT set Content-Type: the runtime adds the multipart boundary.
197
+ return { body: buildFormData(req.multipart) };
198
+ }
199
+ if (req.form) {
200
+ const params = new URLSearchParams();
201
+ for (const [key, value] of Object.entries(req.form)) params.set(key, value);
202
+ return { body: params, contentType: "application/x-www-form-urlencoded" };
203
+ }
204
+ if (req.body !== undefined) {
205
+ return { body: JSON.stringify(req.body), contentType: "application/json" };
206
+ }
207
+ return { body: undefined };
208
+ }
209
+
210
+ /**
211
+ * Bearer transport driven by a {@link CredentialProvider} rather than a fixed
212
+ * token. Before every request it asks `provider.token()` for the current
213
+ * bearer; on a `401`, if `provider.refresh` exists it refreshes **once** and
214
+ * retries the request **once** — covering device-flow / OAuth2 / launcher
215
+ * tokens that rotate. Uses the global `fetch`; pass `fetchImpl` to override
216
+ * (Node, tests). Automatically retries 429 (honouring `Retry-After`) and 5xx
217
+ * (exponential backoff) up to `retry.maxRetries`, then raises a typed
218
+ * `HTTPException`. `BrowserTransport` (cookie) stays separate — the browser
219
+ * owns that session.
220
+ */
221
+ export class AuthenticatedTransport implements Transport {
222
+ protected readonly retry: Required<RetryOptions>;
223
+
224
+ /**
225
+ * @param baseUrl platform (`/v1`) base URL.
226
+ * @param provider supplies (and optionally refreshes) the bearer token.
227
+ * @param fetchImpl `fetch` override (Node, tests).
228
+ * @param authBaseUrl optional auth-service base URL for the `auth` channel;
229
+ * defaults to `baseUrl` when the auth core is co-hosted.
230
+ * @param retry automatic-retry tuning (429 / 5xx).
231
+ */
232
+ constructor(
233
+ private readonly baseUrl: string,
234
+ private readonly provider: CredentialProvider,
235
+ private readonly fetchImpl: typeof fetch = fetch,
236
+ private readonly authBaseUrl?: string,
237
+ retry: RetryOptions = {},
238
+ /**
239
+ * When set, every request carries `X-On-Behalf-Of: <subject>`. Only a bot
240
+ * account may use this: the bot's own credential still authenticates the
241
+ * call, and the platform resolves `subject` (`<source>:<id>`, e.g.
242
+ * `discord:123` or `leavepulse:42`) to the human the bot acts for. The
243
+ * effective permissions are the intersection of the bot's and the human's
244
+ * — on-behalf never escalates. Set via {@link onBehalfOf}.
245
+ */
246
+ private readonly onBehalfSubject?: string,
247
+ ) {
248
+ this.retry = { ...DEFAULT_RETRY, ...retry };
249
+ }
250
+
251
+ /**
252
+ * Return a transport that sends every request on behalf of `subject`,
253
+ * sharing this transport's credential and config. `subject` is
254
+ * `<source>:<id>` — `discord:<id>`, `telegram:<id>`, or `leavepulse:<userId>`.
255
+ * Intended for bot accounts; the platform ignores the header for non-bots.
256
+ */
257
+ onBehalfOf(subject: string): AuthenticatedTransport {
258
+ return new AuthenticatedTransport(
259
+ this.baseUrl,
260
+ this.provider,
261
+ this.fetchImpl,
262
+ this.authBaseUrl,
263
+ this.retry,
264
+ subject,
265
+ );
266
+ }
267
+
268
+ /** One HTTP attempt with the freshly-fetched bearer applied. */
269
+ private async fetchOnce(
270
+ req: TransportRequest,
271
+ url: string,
272
+ ): Promise<Response> {
273
+ const token = await this.provider.token();
274
+ const { body, contentType } = buildBody(req);
275
+ return this.fetchImpl(url, {
276
+ method: req.method,
277
+ headers: {
278
+ Authorization: `Bearer ${token}`,
279
+ ...(contentType ? { "Content-Type": contentType } : {}),
280
+ ...(req.ifNoneMatch ? { "If-None-Match": req.ifNoneMatch } : {}),
281
+ ...(req.ifMatch ? { "If-Match": req.ifMatch } : {}),
282
+ ...(this.onBehalfSubject
283
+ ? { "X-On-Behalf-Of": this.onBehalfSubject }
284
+ : {}),
285
+ },
286
+ body,
287
+ });
288
+ }
289
+
290
+ /**
291
+ * Dispatch the request with the retry budget applied. Returns the raw
292
+ * `Response` for any status `passThrough` accepts (so callers can handle
293
+ * 304/404 themselves); every other non-2xx becomes a typed `HTTPException`.
294
+ * A `401` triggers a single `provider.refresh()` + retry when available.
295
+ */
296
+ protected async send(
297
+ req: TransportRequest,
298
+ passThrough: (status: number) => boolean = () => false,
299
+ ): Promise<Response> {
300
+ const base =
301
+ req.channel === "auth"
302
+ ? (this.authBaseUrl ?? this.baseUrl)
303
+ : this.baseUrl;
304
+ const url = base.replace(/\/$/, "") + buildPath(req);
305
+
306
+ let attempt = 0;
307
+ let refreshed = false;
308
+ for (;;) {
309
+ const response = await this.fetchOnce(req, url);
310
+
311
+ if (response.ok || passThrough(response.status)) return response;
312
+
313
+ const retryAfter = parseRetryAfter(response.headers.get("retry-after"));
314
+ const error = httpErrorFor(
315
+ response.status,
316
+ req,
317
+ await safeText(response),
318
+ retryAfter,
319
+ );
320
+
321
+ // On 401, refresh the credential once and retry once before surfacing.
322
+ if (
323
+ error instanceof Unauthorized &&
324
+ !refreshed &&
325
+ this.provider.refresh
326
+ ) {
327
+ refreshed = true;
328
+ await this.provider.refresh();
329
+ continue;
330
+ }
331
+
332
+ // Retry on rate-limit / transient server errors while budget remains.
333
+ const retriable =
334
+ error instanceof RateLimited || error instanceof ServerError;
335
+ if (retriable && attempt < this.retry.maxRetries) {
336
+ const waitMs =
337
+ error instanceof RateLimited && error.retryAfter !== undefined
338
+ ? error.retryAfter * 1000
339
+ : Math.min(
340
+ this.retry.backoffBaseMs * 2 ** attempt,
341
+ this.retry.backoffMaxMs,
342
+ );
343
+ attempt += 1;
344
+ await sleep(waitMs);
345
+ continue;
346
+ }
347
+ throw error;
348
+ }
349
+ }
350
+
351
+ async request<T>(req: TransportRequest): Promise<T> {
352
+ const response = await this.send(req);
353
+ if (response.status === 204) return undefined as T;
354
+ // parseJson (not response.json()) keeps 64-bit Snowflake ids precise.
355
+ return parseJson(await response.text()) as T;
356
+ }
357
+
358
+ async requestWithIfMatch<T>(
359
+ req: TransportRequest,
360
+ ifMatch?: string,
361
+ ): Promise<T> {
362
+ return this.request<T>({ ...req, ifMatch });
363
+ }
364
+
365
+ async conditional<T>(req: TransportRequest): Promise<ConditionalResult<T>> {
366
+ const response = await this.send(
367
+ req,
368
+ (status) => status === 304 || status === 404,
369
+ );
370
+ if (response.status === 404) return { status: "not_found" };
371
+ const etag = response.headers.get("etag") ?? undefined;
372
+ if (response.status === 304) return { status: "not_modified", etag };
373
+ const data = parseJson(await response.text()) as T;
374
+ return { status: "modified", data, etag };
375
+ }
376
+ }
377
+
378
+ /**
379
+ * Bearer-token transport for external consumers (no cookies) holding a single
380
+ * pre-acquired token. A thin specialization of {@link AuthenticatedTransport}
381
+ * over a {@link StaticCredential}, kept for back-compat: the constructor
382
+ * signature `(baseUrl, token, fetchImpl?, authBaseUrl?, retry?)` is unchanged.
383
+ * For tokens that rotate (device flow, OAuth2, launcher) use
384
+ * `AuthenticatedTransport` with a refreshing credential instead.
385
+ */
386
+ export class BearerTransport extends AuthenticatedTransport {
387
+ constructor(
388
+ baseUrl: string,
389
+ token: string,
390
+ fetchImpl: typeof fetch = fetch,
391
+ authBaseUrl?: string,
392
+ retry: RetryOptions = {},
393
+ ) {
394
+ super(baseUrl, StaticCredential(token), fetchImpl, authBaseUrl, retry);
395
+ }
396
+ }
397
+
398
+ async function safeText(response: Response): Promise<string> {
399
+ try {
400
+ return await response.text();
401
+ } catch {
402
+ return "";
403
+ }
404
+ }