@hue-run/sdk 0.3.2 → 0.4.2

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/CLI.md +270 -47
  2. package/ENVIRONMENTS.md +11 -1
  3. package/README.md +19 -3
  4. package/dist/client.d.ts +5 -5
  5. package/dist/client.js +13 -6
  6. package/dist/environment/tools.d.ts +6 -1
  7. package/dist/environment/tools.js +7 -1
  8. package/dist/environment/types.d.ts +6 -1
  9. package/dist/evals/simulation.d.ts +12 -4
  10. package/dist/evals/simulation.js +34 -24
  11. package/dist/evals.d.ts +1 -1
  12. package/dist/receipt.js +36 -8
  13. package/dist/setup/application.d.ts +74 -0
  14. package/dist/setup/application.js +766 -0
  15. package/dist/setup/backend.d.ts +229 -0
  16. package/dist/setup/backend.js +855 -0
  17. package/dist/setup/checkpoint.js +100 -30
  18. package/dist/setup/cli.js +20 -4
  19. package/dist/setup/configure.d.ts +13 -0
  20. package/dist/setup/configure.js +454 -0
  21. package/dist/setup/credential.d.ts +2 -0
  22. package/dist/setup/credential.js +9 -0
  23. package/dist/setup/detect.js +4 -1
  24. package/dist/setup/installation.d.ts +118 -0
  25. package/dist/setup/installation.js +605 -0
  26. package/dist/setup/lock.d.ts +2 -0
  27. package/dist/setup/lock.js +38 -0
  28. package/dist/setup/machine.d.ts +1 -10
  29. package/dist/setup/machine.js +8 -7
  30. package/dist/setup/render.d.ts +3 -1
  31. package/dist/setup/render.js +209 -6
  32. package/dist/setup/runner.d.ts +26 -76
  33. package/dist/setup/runner.js +320 -45
  34. package/dist/setup/socket.d.ts +7 -0
  35. package/dist/setup/socket.js +144 -0
  36. package/dist/setup/source.d.ts +9 -0
  37. package/dist/setup/source.js +269 -0
  38. package/dist/setup/types.d.ts +16 -9
  39. package/dist/setup/types.js +1 -1
  40. package/dist/setup.d.ts +6 -2
  41. package/dist/setup.js +3 -0
  42. package/dist/types.d.ts +24 -0
  43. package/dist/version.d.ts +1 -1
  44. package/dist/version.js +1 -1
  45. package/package.json +2 -1
  46. package/setup-events.schema.json +16 -9
@@ -0,0 +1,855 @@
1
+ import { createHue } from "../client.js";
2
+ import { isLoopbackHost } from "../config.js";
3
+ import { verifySetupTrace } from "../receipt.js";
4
+ import { validSetupCredentialIdentity } from "./credential.js";
5
+ import { spawn } from "node:child_process";
6
+ import { randomUUID } from "node:crypto";
7
+ import { pathToFileURL } from "node:url";
8
+ import { configureSetupProject, validateSetupConfiguration, } from "./configure.js";
9
+ import { exerciseSetupApplication, installSetupRuntime, planSetupApplication, wireSetupApplication, } from "./application.js";
10
+ import { FileSetupInstallationStore, } from "./installation.js";
11
+ const MAX_RESPONSE_BYTES = 64 * 1024;
12
+ const DEFAULT_ORIGIN = "https://app.hue.run";
13
+ const SETUP_ERROR_STATUSES = new Map([
14
+ ["SETUP_INVALID_REQUEST", 400],
15
+ ["SETUP_UNAUTHORIZED", 401],
16
+ ["SETUP_ACCOUNT_REQUIRED", 401],
17
+ ["SETUP_FORBIDDEN", 403],
18
+ ["SETUP_CHANGED", 409],
19
+ ["SETUP_DESTINATION_REQUIRED", 409],
20
+ ["SETUP_HANDOFF_LIMIT", 409],
21
+ ["SETUP_REVOKED", 409],
22
+ ["SETUP_EXPIRED", 410],
23
+ ["SETUP_BODY_TOO_LARGE", 413],
24
+ ["SETUP_RATE_LIMITED", 429],
25
+ ["SETUP_CAPACITY", 429],
26
+ ["SETUP_QUOTA", 429],
27
+ ["SETUP_DISABLED", 503],
28
+ ["SETUP_BUSY", 503],
29
+ ["SETUP_UNAVAILABLE", 503],
30
+ ]);
31
+ /** Sanitized setup failure with a stable local or protocol code. */
32
+ export class SetupBackendError extends Error {
33
+ code;
34
+ status;
35
+ retryAfterMillis;
36
+ constructor(
37
+ /** Stable local code or exact `SETUP_*` server code. */
38
+ code, message,
39
+ /** Validated HTTP status when the failure came from the server. */
40
+ status,
41
+ /** Bounded server-requested retry delay, when provided. */
42
+ retryAfterMillis, options) {
43
+ super(message, options);
44
+ this.code = code;
45
+ this.status = status;
46
+ this.retryAfterMillis = retryAfterMillis;
47
+ this.name = "SetupBackendError";
48
+ }
49
+ }
50
+ async function openLocalHandoff(localHandoffUrl) {
51
+ const executable = process.platform === "darwin"
52
+ ? "open"
53
+ : process.platform === "win32"
54
+ ? "explorer.exe"
55
+ : "xdg-open";
56
+ await new Promise((resolve, reject) => {
57
+ const child = spawn(executable, [localHandoffUrl], {
58
+ detached: true,
59
+ stdio: "ignore",
60
+ shell: false,
61
+ });
62
+ child.once("error", reject);
63
+ child.once("spawn", () => {
64
+ child.unref();
65
+ resolve();
66
+ });
67
+ });
68
+ }
69
+ function record(value) {
70
+ return value !== null && typeof value === "object" && !Array.isArray(value);
71
+ }
72
+ function exactKeys(value, keys) {
73
+ return Object.keys(value).sort().join("\0") === [...keys].sort().join("\0");
74
+ }
75
+ function boundedId(value) {
76
+ return (typeof value === "string" && value.length > 0 && value.length <= 256 && !/[\s\0]/u.test(value));
77
+ }
78
+ function parseOrigin(value) {
79
+ let url;
80
+ try {
81
+ url = new URL(value);
82
+ }
83
+ catch {
84
+ throw new SetupBackendError("invalid_origin", "Hue setup requires a valid origin.");
85
+ }
86
+ if ((url.protocol !== "https:" && !(url.protocol === "http:" && isLoopbackHost(url.hostname))) ||
87
+ url.username ||
88
+ url.password ||
89
+ url.pathname !== "/" ||
90
+ url.search ||
91
+ url.hash)
92
+ throw new SetupBackendError("invalid_origin", "Hue setup requires an HTTPS origin, except for an explicit loopback HTTP test origin.");
93
+ return url.origin;
94
+ }
95
+ function isoDate(value) {
96
+ return typeof value === "string" && value.length <= 40 && Number.isFinite(Date.parse(value));
97
+ }
98
+ function uuidV4(value) {
99
+ return (typeof value === "string" &&
100
+ /^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/u.test(value));
101
+ }
102
+ function parseClaimHandoff(value) {
103
+ if (value === null)
104
+ return null;
105
+ if (!record(value) ||
106
+ !exactKeys(value, ["id", "state", "expiresAt", "sessionExpiresAt"]) ||
107
+ !uuidV4(value.id) ||
108
+ !["pending", "consumed", "expired", "revoked"].includes(value.state) ||
109
+ !isoDate(value.expiresAt) ||
110
+ !(value.sessionExpiresAt === null || isoDate(value.sessionExpiresAt)) ||
111
+ (value.state === "pending" && value.sessionExpiresAt !== null))
112
+ throw new SetupBackendError("invalid_response", "Hue returned an invalid setup response.");
113
+ return {
114
+ id: value.id,
115
+ state: value.state,
116
+ expiresAt: value.expiresAt,
117
+ sessionExpiresAt: value.sessionExpiresAt,
118
+ };
119
+ }
120
+ function parsePreflight(value) {
121
+ if (!record(value) ||
122
+ !exactKeys(value, [
123
+ "protocolVersion",
124
+ "state",
125
+ "capturePolicy",
126
+ "limits",
127
+ "lifetime",
128
+ "privacyNotice",
129
+ "securityUrl",
130
+ ]))
131
+ throw new SetupBackendError("invalid_response", "Hue returned an invalid setup preflight response.");
132
+ const limits = value.limits;
133
+ const lifetime = value.lifetime;
134
+ const privacyNotice = value.privacyNotice;
135
+ if (value.protocolVersion !== 1 ||
136
+ (value.state !== "available" && value.state !== "inactive") ||
137
+ value.capturePolicy !== "metadata-only-v1" ||
138
+ !record(limits) ||
139
+ !exactKeys(limits, ["traces", "spans", "bytes"]) ||
140
+ limits.traces !== 100 ||
141
+ limits.spans !== 1000 ||
142
+ limits.bytes !== 2097152 ||
143
+ !record(lifetime) ||
144
+ !exactKeys(lifetime, ["expiresAfterSeconds", "purgeAfterSeconds"]) ||
145
+ lifetime.expiresAfterSeconds !== 86400 ||
146
+ lifetime.purgeAfterSeconds !== 691200 ||
147
+ !record(privacyNotice) ||
148
+ !exactKeys(privacyNotice, ["url", "effectiveDate"]) ||
149
+ privacyNotice.url !== "https://hue.run/privacy" ||
150
+ privacyNotice.effectiveDate !== "2026-08-24" ||
151
+ value.securityUrl !== "https://trust.hue.run/")
152
+ throw new SetupBackendError("invalid_response", "Hue returned an invalid setup preflight response.");
153
+ return {
154
+ protocolVersion: 1,
155
+ state: value.state,
156
+ capturePolicy: "metadata-only-v1",
157
+ limits: { traces: 100, spans: 1000, bytes: 2097152 },
158
+ lifetime: { expiresAfterSeconds: 86400, purgeAfterSeconds: 691200 },
159
+ privacyNotice: { url: "https://hue.run/privacy", effectiveDate: "2026-08-24" },
160
+ securityUrl: "https://trust.hue.run/",
161
+ };
162
+ }
163
+ function parseStatus(value, installationId, credentialResponse = false) {
164
+ if (!record(value))
165
+ throw new SetupBackendError("invalid_response", "Hue returned an invalid setup response.");
166
+ const keys = [
167
+ "protocolVersion",
168
+ "installationId",
169
+ "state",
170
+ "project",
171
+ "credentialVersion",
172
+ "capturePolicy",
173
+ "expiresAt",
174
+ "limits",
175
+ "usage",
176
+ "claimHandoff",
177
+ "endpoints",
178
+ ];
179
+ if (!exactKeys(value, credentialResponse ? [...keys, "credential"] : keys))
180
+ throw new SetupBackendError("invalid_response", "Hue returned an invalid setup response.");
181
+ const project = value.project;
182
+ const limits = value.limits;
183
+ const usage = value.usage;
184
+ const endpoints = value.endpoints;
185
+ const claimHandoff = parseClaimHandoff(value.claimHandoff);
186
+ if (value.protocolVersion !== 1 ||
187
+ value.installationId !== installationId ||
188
+ !["active", "claimed", "expired", "purged"].includes(value.state) ||
189
+ !(project === null ||
190
+ (record(project) &&
191
+ exactKeys(project, ["id", "organizationId"]) &&
192
+ boundedId(project.id) &&
193
+ boundedId(project.organizationId))) ||
194
+ (value.credentialVersion !== 0 && value.credentialVersion !== 1) ||
195
+ value.capturePolicy !== "metadata-only-v1" ||
196
+ !(value.expiresAt === null || isoDate(value.expiresAt)) ||
197
+ !record(limits) ||
198
+ !exactKeys(limits, ["traces", "spans", "bytes"]) ||
199
+ limits.traces !== 100 ||
200
+ limits.spans !== 1000 ||
201
+ limits.bytes !== 2097152 ||
202
+ !record(usage) ||
203
+ !exactKeys(usage, ["traces", "spans", "bytes"]) ||
204
+ ![usage.traces, usage.spans, usage.bytes].every((item) => Number.isSafeInteger(item) && item >= 0) ||
205
+ !record(endpoints) ||
206
+ !exactKeys(endpoints, ["otlp", "receipt"]) ||
207
+ endpoints.otlp !== "/api/v1/otlp/v1/traces" ||
208
+ endpoints.receipt !== "/api/v1/setup/traces/{traceId}/receipt")
209
+ throw new SetupBackendError("invalid_response", "Hue returned an invalid setup response.");
210
+ if (value.state === "active" &&
211
+ (value.expiresAt === null || project === null || value.credentialVersion !== 0)) {
212
+ throw new SetupBackendError("invalid_response", "Hue returned an invalid setup response.");
213
+ }
214
+ if (value.state === "claimed" &&
215
+ (value.expiresAt !== null || value.credentialVersion !== 1 || project === null))
216
+ throw new SetupBackendError("invalid_response", "Hue returned an invalid setup response.");
217
+ return {
218
+ protocolVersion: 1,
219
+ installationId,
220
+ state: value.state,
221
+ project: project,
222
+ credentialVersion: value.credentialVersion,
223
+ capturePolicy: "metadata-only-v1",
224
+ expiresAt: value.expiresAt,
225
+ limits: { traces: 100, spans: 1000, bytes: 2097152 },
226
+ usage: usage,
227
+ claimHandoff,
228
+ endpoints: {
229
+ otlp: "/api/v1/otlp/v1/traces",
230
+ receipt: "/api/v1/setup/traces/{traceId}/receipt",
231
+ },
232
+ };
233
+ }
234
+ function parseCredential(value, installationId) {
235
+ const status = parseStatus(value, installationId, true);
236
+ if (!record(value) || !record(value.credential))
237
+ throw new SetupBackendError("invalid_response", "Hue returned an invalid setup credential response.");
238
+ const credential = value.credential;
239
+ if (!exactKeys(credential, ["apiKey", "keyId", "capabilities", "version", "kind"]) ||
240
+ credential.kind !== "anonymous_trial" ||
241
+ !validSetupCredentialIdentity(credential.apiKey, credential.keyId) ||
242
+ !Array.isArray(credential.capabilities) ||
243
+ credential.capabilities.length !== 1 ||
244
+ credential.capabilities[0] !== "setup_telemetry_write" ||
245
+ (credential.version !== 0 && credential.version !== 1) ||
246
+ credential.version !== status.credentialVersion)
247
+ throw new SetupBackendError("invalid_response", "Hue returned an invalid setup credential response.");
248
+ return {
249
+ ...status,
250
+ credential: {
251
+ kind: "anonymous_trial",
252
+ apiKey: credential.apiKey,
253
+ keyId: credential.keyId,
254
+ capabilities: ["setup_telemetry_write"],
255
+ version: credential.version,
256
+ },
257
+ };
258
+ }
259
+ function retryAfter(header) {
260
+ if (!header)
261
+ return 0;
262
+ if (/^\d+$/u.test(header))
263
+ return Math.min(Number(header) * 1000, 24 * 60 * 60 * 1000);
264
+ const parsed = Date.parse(header);
265
+ return Number.isFinite(parsed)
266
+ ? Math.min(Math.max(0, parsed - Date.now()), 24 * 60 * 60 * 1000)
267
+ : 0;
268
+ }
269
+ function validateClaimUrl(value, origin) {
270
+ if (typeof value !== "string")
271
+ throw new SetupBackendError("invalid_response", "Hue returned an invalid private handoff.");
272
+ let claim;
273
+ try {
274
+ claim = new URL(value);
275
+ }
276
+ catch {
277
+ throw new SetupBackendError("invalid_response", "Hue returned an invalid private handoff.");
278
+ }
279
+ if (claim.href !== value ||
280
+ claim.origin !== origin ||
281
+ claim.username ||
282
+ claim.password ||
283
+ claim.pathname !== "/setup/claim" ||
284
+ claim.search ||
285
+ !/^#[A-Za-z0-9_-]{43}$/u.test(claim.hash))
286
+ throw new SetupBackendError("invalid_response", "Hue returned an invalid private handoff.");
287
+ return value;
288
+ }
289
+ function parseClaimHandoffResponse(value, installationId, handoffId, origin) {
290
+ if (!record(value) ||
291
+ !exactKeys(value, ["protocolVersion", "installationId", "handoff", "claimUrl"]) ||
292
+ value.protocolVersion !== 1 ||
293
+ value.installationId !== installationId)
294
+ throw new SetupBackendError("invalid_response", "Hue returned an invalid private handoff.");
295
+ const handoff = parseClaimHandoff(value.handoff);
296
+ if (!handoff || handoff.id !== handoffId)
297
+ throw new SetupBackendError("invalid_response", "Hue returned an invalid private handoff.");
298
+ if (handoff.state === "pending")
299
+ return { handoff, claimUrl: validateClaimUrl(value.claimUrl, origin) };
300
+ if (value.claimUrl !== null)
301
+ throw new SetupBackendError("invalid_response", "Hue returned an invalid private handoff.");
302
+ return { handoff, claimUrl: null };
303
+ }
304
+ async function pause(milliseconds, signal) {
305
+ if (signal?.aborted)
306
+ throw new Error("Setup interrupted");
307
+ await new Promise((resolve, reject) => {
308
+ const timer = setTimeout(resolve, milliseconds);
309
+ signal?.addEventListener("abort", () => {
310
+ clearTimeout(timer);
311
+ reject(new Error("Setup interrupted"));
312
+ }, { once: true });
313
+ });
314
+ }
315
+ /** Real implementation of the frozen Setup HTTP protocol v1. */
316
+ export class SetupBackendAdapter {
317
+ /** Exact normalized Hue origin used by every request. */
318
+ origin;
319
+ /** Project/origin-scoped owner-only installation store. */
320
+ store;
321
+ fetcher;
322
+ requestTimeoutMillis;
323
+ receiptTimeoutMillis;
324
+ browserOpener;
325
+ commandRunner;
326
+ installation;
327
+ applicationPlan;
328
+ installationStatus;
329
+ /** Discards cached facts after the command-wide lock has been acquired. */
330
+ resetLocalCache() {
331
+ this.installation = undefined;
332
+ this.applicationPlan = undefined;
333
+ this.installationStatus = undefined;
334
+ }
335
+ constructor(options) {
336
+ this.origin = parseOrigin(options.origin ?? DEFAULT_ORIGIN);
337
+ this.store = new FileSetupInstallationStore(options.projectRoot, this.origin);
338
+ this.fetcher = options.fetch ?? globalThis.fetch;
339
+ this.requestTimeoutMillis = options.requestTimeoutMillis ?? 10_000;
340
+ this.receiptTimeoutMillis = options.receiptTimeoutMillis ?? 10_000;
341
+ this.browserOpener = options.openBrowser ?? openLocalHandoff;
342
+ this.commandRunner = options.commandRunner;
343
+ if (!Number.isInteger(this.requestTimeoutMillis) ||
344
+ this.requestTimeoutMillis < 100 ||
345
+ this.requestTimeoutMillis > 60_000 ||
346
+ !Number.isInteger(this.receiptTimeoutMillis) ||
347
+ this.receiptTimeoutMillis < 100 ||
348
+ this.receiptTimeoutMillis > 60_000)
349
+ throw new TypeError("Setup request and receipt timeouts must be 100–60000 milliseconds");
350
+ }
351
+ /** Persists installation UUID and proof before returning control to any network operation. */
352
+ async prepare() {
353
+ this.installation ??= await this.store.loadOrCreate();
354
+ await this.store.ensureIgnored();
355
+ return this.installation;
356
+ }
357
+ /** Reads local state without creating files or contacting Hue. */
358
+ async localInstallation() {
359
+ if (this.installation)
360
+ return this.installation;
361
+ this.installation = await this.store.load();
362
+ return this.installation;
363
+ }
364
+ async readJson(response) {
365
+ if (!/(?:^|,)\s*no-store\s*(?:,|$)/iu.test(response.headers.get("cache-control") ?? ""))
366
+ throw new SetupBackendError("invalid_response", "Hue returned an unsafe cacheable setup response.");
367
+ if (!/^application\/json(?:\s*;|$)/iu.test(response.headers.get("content-type") ?? ""))
368
+ throw new SetupBackendError("invalid_response", "Hue returned an invalid setup response content type.");
369
+ const length = response.headers.get("content-length");
370
+ if (length && /^\d+$/u.test(length) && Number(length) > MAX_RESPONSE_BYTES) {
371
+ await response.body?.cancel();
372
+ throw new SetupBackendError("invalid_response", "Hue returned an oversized setup response.");
373
+ }
374
+ const reader = response.body?.getReader();
375
+ if (!reader)
376
+ throw new SetupBackendError("invalid_response", "Hue returned an empty setup response.");
377
+ const chunks = [];
378
+ let size = 0;
379
+ try {
380
+ for (;;) {
381
+ const item = await reader.read();
382
+ if (item.done)
383
+ break;
384
+ size += item.value.byteLength;
385
+ if (size > MAX_RESPONSE_BYTES) {
386
+ await reader.cancel();
387
+ throw new SetupBackendError("invalid_response", "Hue returned an oversized setup response.");
388
+ }
389
+ chunks.push(item.value);
390
+ }
391
+ }
392
+ finally {
393
+ reader.releaseLock();
394
+ }
395
+ try {
396
+ return JSON.parse(Buffer.concat(chunks).toString("utf8"));
397
+ }
398
+ catch {
399
+ throw new SetupBackendError("invalid_response", "Hue returned invalid setup JSON.");
400
+ }
401
+ }
402
+ async publicPreflightRequest(signal) {
403
+ for (let attempt = 0; attempt < 3; attempt += 1) {
404
+ if (signal?.aborted)
405
+ throw new Error("Setup interrupted");
406
+ try {
407
+ const timeout = AbortSignal.timeout(this.requestTimeoutMillis);
408
+ const requestSignal = signal ? AbortSignal.any([signal, timeout]) : timeout;
409
+ const response = await this.fetcher(new URL("/api/v1/setup/preflight", this.origin), {
410
+ method: "GET",
411
+ headers: { Accept: "application/json" },
412
+ redirect: "manual",
413
+ credentials: "omit",
414
+ cache: "no-store",
415
+ signal: requestSignal,
416
+ });
417
+ if (response.status >= 300 && response.status < 400) {
418
+ await response.body?.cancel();
419
+ throw new SetupBackendError("invalid_response", "Hue setup preflight refused a redirect.", response.status);
420
+ }
421
+ const value = await this.readJson(response);
422
+ if (response.status === 200)
423
+ return parsePreflight(value);
424
+ const code = record(value) && typeof value.code === "string" ? value.code : undefined;
425
+ if (!code ||
426
+ !record(value) ||
427
+ !exactKeys(value, ["protocolVersion", "code"]) ||
428
+ value.protocolVersion !== 1 ||
429
+ SETUP_ERROR_STATUSES.get(code) !== response.status)
430
+ throw new SetupBackendError("invalid_response", "Hue returned an invalid setup preflight error.", response.status);
431
+ const wait = retryAfter(response.headers.get("retry-after"));
432
+ const delay = Math.max(wait, 250 * 2 ** attempt);
433
+ if ((response.status === 429 || response.status === 503) && attempt < 2 && delay <= 5000) {
434
+ await pause(delay, signal);
435
+ continue;
436
+ }
437
+ throw new SetupBackendError(code, `Hue setup stopped with ${code}.`, response.status, wait);
438
+ }
439
+ catch (error) {
440
+ if (error instanceof SetupBackendError)
441
+ throw error;
442
+ if (signal?.aborted)
443
+ throw new Error("Setup interrupted");
444
+ if (attempt < 2) {
445
+ await pause(250 * 2 ** attempt, signal);
446
+ continue;
447
+ }
448
+ }
449
+ }
450
+ throw new SetupBackendError("transport", "Hue setup could not reach the configured origin.");
451
+ }
452
+ async request(method, path, body, attempts, signal, onAttempt) {
453
+ const installation = await this.prepare();
454
+ const encoded = body === undefined ? undefined : JSON.stringify(body);
455
+ if (encoded && Buffer.byteLength(encoded) > 2048)
456
+ throw new Error("Setup request body is too large");
457
+ for (let attempt = 0; attempt < attempts; attempt += 1) {
458
+ if (signal?.aborted)
459
+ throw new Error("Setup interrupted");
460
+ await onAttempt?.();
461
+ try {
462
+ const timeout = AbortSignal.timeout(this.requestTimeoutMillis);
463
+ const requestSignal = signal ? AbortSignal.any([signal, timeout]) : timeout;
464
+ const response = await this.fetcher(new URL(path, this.origin), {
465
+ method,
466
+ headers: {
467
+ Authorization: `Bearer hue_install_${installation.installationSecret}`,
468
+ Accept: "application/json",
469
+ ...(encoded ? { "Content-Type": "application/json" } : {}),
470
+ },
471
+ ...(encoded ? { body: encoded } : {}),
472
+ redirect: "manual",
473
+ credentials: "omit",
474
+ cache: "no-store",
475
+ signal: requestSignal,
476
+ });
477
+ if (response.status >= 300 && response.status < 400) {
478
+ await response.body?.cancel();
479
+ throw new SetupBackendError("invalid_response", "Hue setup refused a redirect.", response.status);
480
+ }
481
+ const value = await this.readJson(response);
482
+ if (response.status === 200)
483
+ return value;
484
+ const code = record(value) && typeof value.code === "string" ? value.code : undefined;
485
+ if (!code ||
486
+ !record(value) ||
487
+ !exactKeys(value, ["protocolVersion", "code"]) ||
488
+ value.protocolVersion !== 1 ||
489
+ SETUP_ERROR_STATUSES.get(code) !== response.status)
490
+ throw new SetupBackendError("invalid_response", "Hue returned an invalid setup error.", response.status);
491
+ const wait = retryAfter(response.headers.get("retry-after"));
492
+ const retriable = response.status === 429 || response.status === 503;
493
+ const delay = Math.max(wait, 250 * 2 ** attempt);
494
+ if (retriable && attempt + 1 < attempts && delay <= 5000) {
495
+ await pause(delay, signal);
496
+ continue;
497
+ }
498
+ throw new SetupBackendError(code, `Hue setup stopped with ${code}.`, response.status, wait);
499
+ }
500
+ catch (error) {
501
+ if (error instanceof SetupBackendError)
502
+ throw error;
503
+ if (signal?.aborted)
504
+ throw new Error("Setup interrupted");
505
+ if (attempt + 1 < attempts) {
506
+ await pause(250 * 2 ** attempt, signal);
507
+ continue;
508
+ }
509
+ }
510
+ }
511
+ throw new SetupBackendError("transport", "Hue setup could not reach the configured origin.");
512
+ }
513
+ /** Creates once or recovers the same installation. At most two provision writes occur per invocation. */
514
+ async provision(signal) {
515
+ const installation = await this.prepare();
516
+ const cutoff = Date.now() - 60 * 60 * 1000;
517
+ installation.provisionAttempts = installation.provisionAttempts.filter((value) => Date.parse(value) >= cutoff);
518
+ if (installation.provisionAttempts.length >= 5)
519
+ throw new SetupBackendError("SETUP_RATE_LIMITED", "This installation has used its bounded provisioning budget; retry after one hour.", 429);
520
+ const attempts = Math.min(2, 5 - installation.provisionAttempts.length);
521
+ const value = await this.request("POST", "/api/v1/setup/installations", { protocolVersion: 1, installationId: installation.installationId }, attempts, signal, async () => {
522
+ installation.provisionAttempts.push(new Date().toISOString());
523
+ await this.store.save(installation);
524
+ });
525
+ this.installationStatus = parseStatus(value, installation.installationId);
526
+ return this.installationStatus;
527
+ }
528
+ /** Reads and validates the current installation status without provisioning. */
529
+ async status(signal) {
530
+ const installation = await this.prepare();
531
+ const value = await this.request("GET", `/api/v1/setup/installations/${installation.installationId}`, undefined, 3, signal);
532
+ const status = parseStatus(value, installation.installationId);
533
+ this.installationStatus = status;
534
+ if (status.state !== "active")
535
+ await this.store.removeClaimHandoff();
536
+ return status;
537
+ }
538
+ /** Requests or recovers one proof-bound handoff and opens only its owner-local file URL. */
539
+ async prepareClaimHandoff(status, openBrowser, restart = false, signal) {
540
+ if (status.state !== "active")
541
+ throw new SetupBackendError("invalid_response", "Hue does not permit a browser handoff for this installation state.");
542
+ const installation = await this.prepare();
543
+ let stored = installation.claimHandoff;
544
+ if (!restart && status.claimHandoff?.state === "consumed") {
545
+ // Never rotate or replay an exchanged browser capability while its session is live.
546
+ await this.store.removeClaimHandoff();
547
+ return {
548
+ opened: false,
549
+ state: "consumed",
550
+ restartRequired: !status.claimHandoff.sessionExpiresAt ||
551
+ Date.parse(status.claimHandoff.sessionExpiresAt) <= Date.now(),
552
+ };
553
+ }
554
+ // A persisted request without a response is retried with exactly the same ID, even
555
+ // when the owner repeats --restart after interruption. Never extend that handoff TTL.
556
+ if (restart && !(stored && stored.state === undefined)) {
557
+ stored = {
558
+ id: randomUUID().toLowerCase(),
559
+ previousHandoffId: status.claimHandoff?.id ?? null,
560
+ };
561
+ installation.claimHandoff = stored;
562
+ await this.store.save(installation);
563
+ }
564
+ else if (stored) {
565
+ if (status.claimHandoff &&
566
+ status.claimHandoff.id !== stored.id &&
567
+ !(stored.state === undefined && stored.previousHandoffId === status.claimHandoff.id)) {
568
+ await this.status(signal);
569
+ throw new SetupBackendError("SETUP_CHANGED", "The browser handoff changed. Refresh status and choose explicitly whether to restart it.", 409);
570
+ }
571
+ }
572
+ else if (status.claimHandoff) {
573
+ if (status.claimHandoff.state !== "pending")
574
+ return {
575
+ opened: false,
576
+ state: status.claimHandoff.state,
577
+ restartRequired: true,
578
+ };
579
+ stored = { id: status.claimHandoff.id, previousHandoffId: null };
580
+ installation.claimHandoff = stored;
581
+ await this.store.save(installation);
582
+ }
583
+ else {
584
+ stored = { id: randomUUID().toLowerCase(), previousHandoffId: null };
585
+ installation.claimHandoff = stored;
586
+ await this.store.save(installation);
587
+ }
588
+ let value;
589
+ try {
590
+ value = await this.request("POST", `/api/v1/setup/installations/${installation.installationId}/claim-handoff`, {
591
+ protocolVersion: 1,
592
+ handoffId: stored.id,
593
+ previousHandoffId: stored.previousHandoffId,
594
+ }, 3, signal);
595
+ }
596
+ catch (error) {
597
+ if (error instanceof SetupBackendError && error.code === "SETUP_CHANGED") {
598
+ await this.status(signal);
599
+ }
600
+ throw error;
601
+ }
602
+ const result = parseClaimHandoffResponse(value, installation.installationId, stored.id, this.origin);
603
+ installation.claimHandoff = {
604
+ id: result.handoff.id,
605
+ previousHandoffId: stored.previousHandoffId,
606
+ state: result.handoff.state,
607
+ expiresAt: result.handoff.expiresAt,
608
+ sessionExpiresAt: result.handoff.sessionExpiresAt,
609
+ };
610
+ await this.store.save(installation);
611
+ if (!result.claimUrl) {
612
+ await this.store.removeClaimHandoff();
613
+ return {
614
+ opened: false,
615
+ state: result.handoff.state,
616
+ restartRequired: result.handoff.state !== "consumed" ||
617
+ !result.handoff.sessionExpiresAt ||
618
+ Date.parse(result.handoff.sessionExpiresAt) <= Date.now(),
619
+ };
620
+ }
621
+ const path = await this.store.saveClaimHandoff(result.claimUrl);
622
+ if (!openBrowser)
623
+ return { opened: false, state: result.handoff.state, restartRequired: false };
624
+ try {
625
+ await this.browserOpener(pathToFileURL(path).href);
626
+ return { opened: true, state: result.handoff.state, restartRequired: false };
627
+ }
628
+ catch {
629
+ return { opened: false, state: result.handoff.state, restartRequired: false };
630
+ }
631
+ }
632
+ /** Retrieves an idempotent generation and persists it before it can be used by project config. */
633
+ async credentials(credentialVersion, signal) {
634
+ const installation = await this.prepare();
635
+ const value = await this.request("POST", `/api/v1/setup/installations/${installation.installationId}/credentials`, { protocolVersion: 1, credentialVersion }, 3, signal);
636
+ const result = parseCredential(value, installation.installationId);
637
+ this.installationStatus = result;
638
+ if (result.credential.version !== credentialVersion)
639
+ throw new SetupBackendError("invalid_response", "Hue returned a credential generation different from the requested generation.");
640
+ if (credentialVersion === 1 &&
641
+ installation.credential?.version === 0 &&
642
+ !installation.revocationCredential)
643
+ installation.revocationCredential = installation.credential;
644
+ if (credentialVersion === 0)
645
+ delete installation.revocationCredential;
646
+ installation.credential = {
647
+ kind: "anonymous_trial",
648
+ capabilities: ["setup_telemetry_write"],
649
+ apiKey: result.credential.apiKey,
650
+ keyId: result.credential.keyId,
651
+ version: result.credential.version,
652
+ };
653
+ if (installation.probe?.credentialVersion !== credentialVersion)
654
+ delete installation.probe;
655
+ if (installation.applicationEvidence?.credentialVersion !== credentialVersion &&
656
+ credentialVersion === 0)
657
+ delete installation.applicationEvidence;
658
+ if (installation.applicationAttempt?.credentialVersion !== credentialVersion &&
659
+ credentialVersion === 0)
660
+ delete installation.applicationAttempt;
661
+ await this.store.save(installation);
662
+ if (credentialVersion === 1) {
663
+ delete installation.claimHandoff;
664
+ await this.store.save(installation);
665
+ await this.store.removeClaimHandoff();
666
+ }
667
+ return result;
668
+ }
669
+ /** Installs the exact Hue runtime through the one unambiguous project package manager. */
670
+ async installRuntime(project) {
671
+ this.applicationPlan ??= await planSetupApplication(project);
672
+ return installSetupRuntime(project, this.applicationPlan, this.commandRunner);
673
+ }
674
+ /** Writes secret-free config and an owned middleware block into the recognized application. */
675
+ async configure(project) {
676
+ const installation = await this.prepare();
677
+ if (!installation.credential)
678
+ throw new Error("Setup credential is not available");
679
+ this.applicationPlan ??= await planSetupApplication(project);
680
+ const changes = await configureSetupProject(this.store, installation, project);
681
+ const wiring = await wireSetupApplication(this.store, installation, this.applicationPlan);
682
+ if (wiring)
683
+ changes.push(wiring);
684
+ return changes;
685
+ }
686
+ /** Fails before mutation on unsafe project state, then reads public technical availability. */
687
+ async preflight(project, signal, checkAvailability = true) {
688
+ this.applicationPlan = await planSetupApplication(project);
689
+ const installation = await this.localInstallation();
690
+ await validateSetupConfiguration(this.store, installation, project);
691
+ return checkAvailability ? this.publicPreflightRequest(signal) : undefined;
692
+ }
693
+ /** Runs one request through the recognized existing app and privately checkpoints its IDs. */
694
+ async exerciseApplication(project, signal) {
695
+ const installation = await this.prepare();
696
+ this.applicationPlan ??= await planSetupApplication(project);
697
+ await exerciseSetupApplication(this.store, installation, this.applicationPlan, signal);
698
+ }
699
+ async verifyStoredApplication(signal) {
700
+ if (signal?.aborted)
701
+ throw new Error("Setup interrupted");
702
+ const installation = await this.prepare();
703
+ const credential = installation.credential;
704
+ const evidence = installation.applicationEvidence;
705
+ if (!credential ||
706
+ !evidence ||
707
+ !(evidence.credentialVersion === credential.version ||
708
+ (evidence.credentialVersion === 0 && credential.version === 1)))
709
+ return undefined;
710
+ const verification = await verifySetupTrace({
711
+ apiKey: credential.apiKey,
712
+ baseUrl: this.origin,
713
+ }, evidence.traceId, {
714
+ expectedSpanIds: [evidence.spanId],
715
+ timeoutMillis: this.receiptTimeoutMillis,
716
+ }, this.fetcher, signal);
717
+ if (signal?.aborted)
718
+ throw new Error("Setup interrupted");
719
+ if (!verification.verified || !verification.receipt)
720
+ return undefined;
721
+ const receipt = verification.receipt;
722
+ await this.validateReceiptProject(receipt, signal);
723
+ if (receipt.traceId !== evidence.traceId ||
724
+ receipt.spanCount <= 0 ||
725
+ receipt.missingSpanIds.length !== 0 ||
726
+ receipt.matchedSpanIds.length !== 1 ||
727
+ receipt.matchedSpanIds[0] !== evidence.spanId ||
728
+ receipt.fields.input ||
729
+ receipt.fields.output)
730
+ throw new SetupBackendError("invalid_response", "Hue returned invalid metadata-only application evidence.");
731
+ evidence.verified = true;
732
+ await this.store.save(installation);
733
+ return { ...evidence, receipt };
734
+ }
735
+ /** Verifies only IDs emitted by the existing application request; it never creates a probe. */
736
+ async verifyApplication(signal) {
737
+ return this.verifyStoredApplication(signal);
738
+ }
739
+ async validateReceiptProject(receipt, signal) {
740
+ const status = this.installationStatus ?? (await this.status(signal));
741
+ const url = new URL(receipt.traceUrl);
742
+ if (!status.project ||
743
+ url.searchParams.get("projectId") !== status.project.id ||
744
+ url.searchParams.get("organizationId") !== status.project.organizationId)
745
+ throw new SetupBackendError("invalid_response", "Hue returned receipt evidence for a different project or organization.");
746
+ }
747
+ async verifyStoredProbe(signal) {
748
+ if (signal?.aborted)
749
+ throw new Error("Setup interrupted");
750
+ const installation = await this.prepare();
751
+ const credential = installation.credential;
752
+ const probe = installation.probe;
753
+ if (!credential || !probe || probe.credentialVersion !== credential.version)
754
+ return undefined;
755
+ const verification = await verifySetupTrace({
756
+ apiKey: credential.apiKey,
757
+ baseUrl: this.origin,
758
+ }, probe.traceId, {
759
+ expectedSpanIds: [probe.spanId],
760
+ timeoutMillis: this.receiptTimeoutMillis,
761
+ }, this.fetcher, signal);
762
+ if (signal?.aborted)
763
+ throw new Error("Setup interrupted");
764
+ if (!verification.verified || !verification.receipt)
765
+ return undefined;
766
+ const receipt = verification.receipt;
767
+ await this.validateReceiptProject(receipt, signal);
768
+ if (receipt.traceId !== probe.traceId ||
769
+ receipt.spanCount <= 0 ||
770
+ receipt.missingSpanIds.length !== 0 ||
771
+ receipt.matchedSpanIds.length !== 1 ||
772
+ receipt.matchedSpanIds[0] !== probe.spanId ||
773
+ receipt.fields.input ||
774
+ receipt.fields.output)
775
+ throw new SetupBackendError("invalid_response", "Hue returned invalid metadata-only probe evidence.");
776
+ probe.verified = true;
777
+ await this.store.save(installation);
778
+ return { traceId: probe.traceId, spanId: probe.spanId, receipt };
779
+ }
780
+ /** Exports one real metadata-only span, awaits flush, and verifies its exact stored IDs. */
781
+ async verifyProbe(signal) {
782
+ const installation = await this.prepare();
783
+ if (!installation.credential)
784
+ throw new Error("Setup credential is not available");
785
+ if (installation.probe?.credentialVersion === installation.credential.version)
786
+ return this.verifyStoredProbe(signal);
787
+ const hue = createHue({
788
+ apiKey: installation.credential.apiKey,
789
+ baseUrl: this.origin,
790
+ serviceName: "hue-setup-probe",
791
+ captureContent: false,
792
+ timeoutMillis: this.requestTimeoutMillis,
793
+ });
794
+ try {
795
+ await hue.withSpan("hue.metadata", async (span) => {
796
+ installation.probe = {
797
+ traceId: span.traceId,
798
+ spanId: span.spanId,
799
+ credentialVersion: installation.credential.version,
800
+ verified: false,
801
+ };
802
+ await this.store.save(installation);
803
+ });
804
+ await hue.flush();
805
+ }
806
+ catch (error) {
807
+ delete installation.probe;
808
+ await this.store.save(installation);
809
+ throw error;
810
+ }
811
+ finally {
812
+ await hue.shutdownSafe({ timeoutMillis: this.requestTimeoutMillis });
813
+ }
814
+ if (signal?.aborted)
815
+ throw new Error("Setup interrupted");
816
+ return this.verifyStoredProbe(signal);
817
+ }
818
+ /** Confirms a superseded telemetry key cannot read the newly verified receipt. */
819
+ async verifyRevokedCredential(oldApiKey, evidence, signal) {
820
+ if (signal?.aborted)
821
+ throw new Error("Setup interrupted");
822
+ // A generic receipt rejects even an active setup key. Only the dedicated route,
823
+ // positively verified with the replacement key first, can prove revocation.
824
+ const url = new URL(`/api/v1/setup/traces/${evidence.traceId}/receipt`, this.origin);
825
+ url.searchParams.set("expectedSpanId", evidence.spanId);
826
+ let response;
827
+ try {
828
+ response = await this.fetcher(url, {
829
+ headers: { Authorization: `Bearer ${oldApiKey}`, Accept: "application/json" },
830
+ redirect: "manual",
831
+ credentials: "omit",
832
+ cache: "no-store",
833
+ signal: signal
834
+ ? AbortSignal.any([signal, AbortSignal.timeout(this.requestTimeoutMillis)])
835
+ : AbortSignal.timeout(this.requestTimeoutMillis),
836
+ });
837
+ await response.body?.cancel();
838
+ }
839
+ catch {
840
+ if (signal?.aborted)
841
+ throw new Error("Setup interrupted");
842
+ throw new SetupBackendError("transport", "Hue setup could not verify anonymous credential revocation.");
843
+ }
844
+ if (signal?.aborted)
845
+ throw new Error("Setup interrupted");
846
+ if (response.status !== 401)
847
+ throw new SetupBackendError("unverified", "The superseded anonymous key was not confirmed revoked.");
848
+ const installation = await this.prepare();
849
+ if (installation.revocationCredential?.apiKey === oldApiKey) {
850
+ delete installation.revocationCredential;
851
+ installation.anonymousKeyRevoked = true;
852
+ await this.store.save(installation);
853
+ }
854
+ }
855
+ }