@the-open-engine/zeroshot 6.27.0 → 6.28.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.
package/cli/index.js CHANGED
@@ -3958,7 +3958,7 @@ for (const providerName of VALID_PROVIDERS) {
3958
3958
 
3959
3959
  // Settings management
3960
3960
  const settingsCmd = program.command('settings').description('Manage zeroshot settings');
3961
- const INTERNAL_SETTINGS_KEYS = new Set(['lastUpdateCheckClaim']);
3961
+ const INTERNAL_SETTINGS_KEYS = new Set(['lastUpdateCheckClaim', '_targets']);
3962
3962
 
3963
3963
  function visibleSettingKeys() {
3964
3964
  return Object.keys(DEFAULT_SETTINGS).filter((key) => !INTERNAL_SETTINGS_KEYS.has(key));
@@ -4339,8 +4339,12 @@ function parseSettingValue(value) {
4339
4339
 
4340
4340
  function resetGlobalSettings() {
4341
4341
  mutateSettings((settings) => {
4342
+ const preserved = {};
4343
+ for (const key of INTERNAL_SETTINGS_KEYS) {
4344
+ if (key in settings) preserved[key] = settings[key];
4345
+ }
4342
4346
  for (const key of Object.keys(settings)) delete settings[key];
4343
- Object.assign(settings, JSON.parse(JSON.stringify({ ...DEFAULT_SETTINGS })));
4347
+ Object.assign(settings, JSON.parse(JSON.stringify({ ...DEFAULT_SETTINGS })), preserved);
4344
4348
  });
4345
4349
  }
4346
4350
 
@@ -4486,6 +4490,211 @@ settingsCmd.action(() => {
4486
4490
  formatSettingsList(settings, true);
4487
4491
  });
4488
4492
 
4493
+ // Target management commands
4494
+ // Target modules are compiled during install/package preparation for the Node 18+ CLI.
4495
+ const importTarget = (mod) => import(`../lib/target/${mod}.js`);
4496
+ const targetCmd = program.command('target').description('Manage named remote targets');
4497
+
4498
+ targetCmd
4499
+ .command('add <name>')
4500
+ .description('Register a named remote target')
4501
+ .requiredOption('--url <url>', 'Service URL for the target')
4502
+ .action(async (name, options) => {
4503
+ try {
4504
+ const { addTarget } = await importTarget('target-registry');
4505
+ const settingsPort = {
4506
+ load: () => loadSettings(),
4507
+ mutate: (fn) => mutateSettings(fn),
4508
+ };
4509
+ const record = addTarget(name, options.url, settingsPort);
4510
+ console.log(chalk.green(`✓ Target "${name}" added (${record.url})`));
4511
+ } catch (error) {
4512
+ console.error(chalk.red(error.message));
4513
+ process.exit(1);
4514
+ }
4515
+ });
4516
+
4517
+ targetCmd
4518
+ .command('login <name>')
4519
+ .description('Authenticate with a remote target via device login')
4520
+ .action(async (name) => {
4521
+ try {
4522
+ const { getTarget } = await importTarget('target-registry');
4523
+ const { targetLogin } = await importTarget('target-session');
4524
+ const { KeyringCredentialStore } = await importTarget('credential-store');
4525
+ const { acquireTargetLock } = await importTarget('credential-lock');
4526
+ const { discoverTargetSessionEndpoints } = await importTarget('discovery');
4527
+
4528
+ const settingsPort = {
4529
+ load: () => loadSettings(),
4530
+ mutate: (fn) => mutateSettings(fn),
4531
+ };
4532
+
4533
+ const target = getTarget(name, settingsPort);
4534
+ if (!target) {
4535
+ console.error(chalk.red(`Target "${name}" not found.`));
4536
+ process.exit(1);
4537
+ }
4538
+
4539
+ const http = { fetch: (url, init) => fetch(url, init) };
4540
+ const discoveryEndpoints = await discoverTargetSessionEndpoints(target.url, http);
4541
+
4542
+ const credentialStore = await KeyringCredentialStore.create();
4543
+ const openPkg = await import('open');
4544
+ const browserOpen = openPkg.default || openPkg;
4545
+
4546
+ const result = await targetLogin(
4547
+ name,
4548
+ target,
4549
+ credentialStore,
4550
+ () => acquireTargetLock(target.id),
4551
+ settingsPort,
4552
+ {
4553
+ http,
4554
+ clock: { now: () => Date.now() },
4555
+ browserOpener: {
4556
+ open: async (url) => {
4557
+ await browserOpen(url);
4558
+ },
4559
+ },
4560
+ stderr: process.stderr,
4561
+ discoveryEndpoints,
4562
+ }
4563
+ );
4564
+
4565
+ console.log(
4566
+ chalk.green(`✓ Logged in to "${name}" (organization: ${result.organization.name})`)
4567
+ );
4568
+ } catch (error) {
4569
+ console.error(chalk.red(error.message));
4570
+ process.exit(1);
4571
+ }
4572
+ });
4573
+
4574
+ targetCmd
4575
+ .command('list')
4576
+ .description('List registered remote targets')
4577
+ .option('--json', 'Output as JSON')
4578
+ .action(async (options) => {
4579
+ try {
4580
+ const { listTargets } = await importTarget('target-registry');
4581
+ const settingsPort = {
4582
+ load: () => loadSettings(),
4583
+ mutate: (fn) => mutateSettings(fn),
4584
+ };
4585
+
4586
+ const targets = listTargets(settingsPort);
4587
+
4588
+ if (options.json) {
4589
+ const output = targets.map(({ name, record }) => ({
4590
+ name,
4591
+ id: record.id,
4592
+ url: record.url,
4593
+ organization: record.organization ?? null,
4594
+ loggedIn: false,
4595
+ createdAt: record.createdAt,
4596
+ }));
4597
+
4598
+ // Try to check keyring presence for each target
4599
+ try {
4600
+ const { KeyringCredentialStore } = await importTarget('credential-store');
4601
+ const { targetServiceKey, TARGET_ACCOUNT } = await importTarget('credential-store');
4602
+ const store = await KeyringCredentialStore.create();
4603
+ for (const item of output) {
4604
+ const matchingTarget = targets.find((t) => t.name === item.name);
4605
+ if (matchingTarget) {
4606
+ const cred = await store.get(
4607
+ targetServiceKey(matchingTarget.record.id),
4608
+ TARGET_ACCOUNT
4609
+ );
4610
+ item.loggedIn = cred !== null;
4611
+ }
4612
+ }
4613
+ } catch {
4614
+ // Keyring unavailable, all show as not logged in
4615
+ }
4616
+
4617
+ console.log(JSON.stringify(output, null, 2));
4618
+ return;
4619
+ }
4620
+
4621
+ if (targets.length === 0) {
4622
+ console.log(
4623
+ chalk.dim(
4624
+ 'No targets registered. Use `zeroshot target add <name> --url <url>` to add one.'
4625
+ )
4626
+ );
4627
+ return;
4628
+ }
4629
+
4630
+ for (const { name, record } of targets) {
4631
+ const org = record.organization ? ` (org: ${record.organization.name})` : '';
4632
+ console.log(` ${chalk.bold(name)} ${record.url}${org}`);
4633
+ }
4634
+ } catch (error) {
4635
+ console.error(chalk.red(error.message));
4636
+ process.exit(1);
4637
+ }
4638
+ });
4639
+
4640
+ targetCmd
4641
+ .command('remove <name>')
4642
+ .description('Remove a named remote target')
4643
+ .option('--force', 'Remove even if remote revocation fails')
4644
+ .action(async (name, options) => {
4645
+ try {
4646
+ const { getTarget, removeTarget } = await importTarget('target-registry');
4647
+ const { revokeAndCleanup } = await importTarget('target-session');
4648
+ const { acquireTargetLock } = await importTarget('credential-lock');
4649
+ const { discoverTargetSessionEndpoints } = await importTarget('discovery');
4650
+
4651
+ const settingsPort = {
4652
+ load: () => loadSettings(),
4653
+ mutate: (fn) => mutateSettings(fn),
4654
+ };
4655
+
4656
+ const target = getTarget(name, settingsPort);
4657
+ if (!target) {
4658
+ console.error(chalk.red(`Target "${name}" not found.`));
4659
+ process.exit(1);
4660
+ }
4661
+
4662
+ // Try to revoke and cleanup keyring
4663
+ try {
4664
+ const { KeyringCredentialStore } = await importTarget('credential-store');
4665
+ const credentialStore = await KeyringCredentialStore.create();
4666
+ const http = { fetch: (url, init) => fetch(url, init) };
4667
+ const discoveryEndpoints = await discoverTargetSessionEndpoints(target.url, http);
4668
+ await revokeAndCleanup(
4669
+ target,
4670
+ credentialStore,
4671
+ () => acquireTargetLock(target.id),
4672
+ {
4673
+ http,
4674
+ discoveryEndpoints,
4675
+ },
4676
+ !!options.force
4677
+ );
4678
+ } catch (error) {
4679
+ if (!options.force) {
4680
+ console.error(chalk.red(error.message));
4681
+ process.exit(1);
4682
+ }
4683
+ // Force mode: continue with removal
4684
+ }
4685
+
4686
+ removeTarget(name, settingsPort);
4687
+ console.log(chalk.green(`✓ Target "${name}" removed`));
4688
+ } catch (error) {
4689
+ console.error(chalk.red(error.message));
4690
+ process.exit(1);
4691
+ }
4692
+ });
4693
+
4694
+ targetCmd.action(() => {
4695
+ targetCmd.help();
4696
+ });
4697
+
4489
4698
  // Providers management
4490
4699
  const providersCmd = program.command('providers').description('Manage AI providers');
4491
4700
  providersCmd.action(async () => {
@@ -0,0 +1 @@
1
+ export declare function acquireTargetLock(targetId: string): Promise<() => Promise<void>>;
@@ -0,0 +1,38 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.acquireTargetLock = acquireTargetLock;
7
+ const node_fs_1 = require("node:fs");
8
+ const node_path_1 = __importDefault(require("node:path"));
9
+ const node_os_1 = __importDefault(require("node:os"));
10
+ // @ts-expect-error no declaration file for proper-lockfile
11
+ const proper_lockfile_1 = __importDefault(require("proper-lockfile"));
12
+ const LOCK_STALE_MS = 10_000;
13
+ const LOCK_RETRIES = 100;
14
+ const LOCK_RETRY_MIN_TIMEOUT_MS = 50;
15
+ const LOCK_RETRY_MAX_TIMEOUT_MS = 5_000;
16
+ async function acquireTargetLock(targetId) {
17
+ const lockDir = node_path_1.default.join(node_os_1.default.homedir(), '.zeroshot');
18
+ await node_fs_1.promises.mkdir(lockDir, { recursive: true });
19
+ const lockTarget = node_path_1.default.join(lockDir, `target-${targetId}.lock`);
20
+ try {
21
+ await node_fs_1.promises.writeFile(lockTarget, '', { flag: 'wx' });
22
+ }
23
+ catch (err) {
24
+ if (err.code !== 'EEXIST')
25
+ throw err;
26
+ }
27
+ const release = await proper_lockfile_1.default.lock(lockTarget, {
28
+ stale: LOCK_STALE_MS,
29
+ retries: {
30
+ retries: LOCK_RETRIES,
31
+ minTimeout: LOCK_RETRY_MIN_TIMEOUT_MS,
32
+ maxTimeout: LOCK_RETRY_MAX_TIMEOUT_MS,
33
+ },
34
+ });
35
+ return async () => {
36
+ await release();
37
+ };
38
+ }
@@ -0,0 +1,27 @@
1
+ export declare class CredentialStoreUnavailableError extends Error {
2
+ constructor(message?: string);
3
+ }
4
+ export interface TargetCredentialStore {
5
+ get(service: string, account: string): Promise<string | null>;
6
+ set(service: string, account: string, token: string): Promise<void>;
7
+ delete(service: string, account: string): Promise<void>;
8
+ }
9
+ export declare function targetServiceKey(targetId: string): string;
10
+ export declare const TARGET_ACCOUNT = "refresh-token";
11
+ export declare class KeyringCredentialStore implements TargetCredentialStore {
12
+ private readonly Entry;
13
+ private constructor();
14
+ static create(): Promise<KeyringCredentialStore>;
15
+ get(service: string, account: string): Promise<string | null>;
16
+ set(service: string, account: string, token: string): Promise<void>;
17
+ delete(service: string, account: string): Promise<void>;
18
+ }
19
+ export declare class FakeCredentialStore implements TargetCredentialStore {
20
+ private readonly store;
21
+ private key;
22
+ get(service: string, account: string): Promise<string | null>;
23
+ set(service: string, account: string, token: string): Promise<void>;
24
+ delete(service: string, account: string): Promise<void>;
25
+ has(service: string, account: string): boolean;
26
+ clear(): void;
27
+ }
@@ -0,0 +1,113 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.FakeCredentialStore = exports.KeyringCredentialStore = exports.TARGET_ACCOUNT = exports.CredentialStoreUnavailableError = void 0;
37
+ exports.targetServiceKey = targetServiceKey;
38
+ class CredentialStoreUnavailableError extends Error {
39
+ constructor(message) {
40
+ super(message ??
41
+ 'OS secure store unavailable. Install libsecret (Linux), or run on macOS/Windows. No plaintext fallback.');
42
+ this.name = 'CredentialStoreUnavailableError';
43
+ }
44
+ }
45
+ exports.CredentialStoreUnavailableError = CredentialStoreUnavailableError;
46
+ function targetServiceKey(targetId) {
47
+ return `zeroshot-target-${targetId}`;
48
+ }
49
+ exports.TARGET_ACCOUNT = 'refresh-token';
50
+ class KeyringCredentialStore {
51
+ Entry;
52
+ constructor(Entry) {
53
+ this.Entry = Entry;
54
+ }
55
+ static async create() {
56
+ let keyringModule;
57
+ try {
58
+ keyringModule = await Promise.resolve().then(() => __importStar(require('@napi-rs/keyring')));
59
+ }
60
+ catch {
61
+ throw new CredentialStoreUnavailableError();
62
+ }
63
+ if (!keyringModule.Entry) {
64
+ throw new CredentialStoreUnavailableError();
65
+ }
66
+ return new KeyringCredentialStore(keyringModule.Entry);
67
+ }
68
+ async get(service, account) {
69
+ try {
70
+ const entry = new this.Entry(service, account);
71
+ return entry.getPassword();
72
+ }
73
+ catch {
74
+ return null;
75
+ }
76
+ }
77
+ async set(service, account, token) {
78
+ const entry = new this.Entry(service, account);
79
+ entry.setPassword(token);
80
+ }
81
+ async delete(service, account) {
82
+ try {
83
+ const entry = new this.Entry(service, account);
84
+ entry.deletePassword();
85
+ }
86
+ catch {
87
+ // Already deleted or not present
88
+ }
89
+ }
90
+ }
91
+ exports.KeyringCredentialStore = KeyringCredentialStore;
92
+ class FakeCredentialStore {
93
+ store = new Map();
94
+ key(service, account) {
95
+ return `${service}::${account}`;
96
+ }
97
+ async get(service, account) {
98
+ return this.store.get(this.key(service, account)) ?? null;
99
+ }
100
+ async set(service, account, token) {
101
+ this.store.set(this.key(service, account), token);
102
+ }
103
+ async delete(service, account) {
104
+ this.store.delete(this.key(service, account));
105
+ }
106
+ has(service, account) {
107
+ return this.store.has(this.key(service, account));
108
+ }
109
+ clear() {
110
+ this.store.clear();
111
+ }
112
+ }
113
+ exports.FakeCredentialStore = FakeCredentialStore;
@@ -0,0 +1,38 @@
1
+ export interface DeviceCodeResponse {
2
+ readonly device_code: string;
3
+ readonly user_code: string;
4
+ readonly verification_uri: string;
5
+ readonly verification_uri_complete?: string;
6
+ readonly expires_in: number;
7
+ readonly interval: number;
8
+ }
9
+ export interface TokenResponse {
10
+ readonly access_token: string;
11
+ readonly refresh_token: string;
12
+ readonly token_type: string;
13
+ readonly expires_in: number;
14
+ readonly organization?: {
15
+ readonly id: string;
16
+ readonly name: string;
17
+ };
18
+ }
19
+ export interface HttpTransport {
20
+ fetch(url: string, init: RequestInit & {
21
+ redirect: 'error';
22
+ }): Promise<Response>;
23
+ }
24
+ export interface Clock {
25
+ now(): number;
26
+ }
27
+ export declare class DeviceFlowDeniedError extends Error {
28
+ constructor();
29
+ }
30
+ export declare class DeviceFlowExpiredError extends Error {
31
+ constructor();
32
+ }
33
+ export declare class UnboundSessionError extends Error {
34
+ readonly verificationUri: string;
35
+ constructor(verificationUri: string);
36
+ }
37
+ export declare function requestDeviceCode(deviceAuthorizationEndpoint: string, clientId: string, http: HttpTransport, signal?: AbortSignal): Promise<DeviceCodeResponse>;
38
+ export declare function pollForToken(tokenEndpoint: string, clientId: string, deviceCode: string, interval: number, expiresIn: number, http: HttpTransport, clock?: Clock, signal?: AbortSignal): Promise<TokenResponse>;
@@ -0,0 +1,104 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.UnboundSessionError = exports.DeviceFlowExpiredError = exports.DeviceFlowDeniedError = void 0;
4
+ exports.requestDeviceCode = requestDeviceCode;
5
+ exports.pollForToken = pollForToken;
6
+ class DeviceFlowDeniedError extends Error {
7
+ constructor() {
8
+ super('Device authorization denied by user');
9
+ this.name = 'DeviceFlowDeniedError';
10
+ }
11
+ }
12
+ exports.DeviceFlowDeniedError = DeviceFlowDeniedError;
13
+ class DeviceFlowExpiredError extends Error {
14
+ constructor() {
15
+ super('Device authorization code expired');
16
+ this.name = 'DeviceFlowExpiredError';
17
+ }
18
+ }
19
+ exports.DeviceFlowExpiredError = DeviceFlowExpiredError;
20
+ class UnboundSessionError extends Error {
21
+ verificationUri;
22
+ constructor(verificationUri) {
23
+ super(`Session not bound to an organization. Re-approve at ${verificationUri} and select an organization.`);
24
+ this.name = 'UnboundSessionError';
25
+ this.verificationUri = verificationUri;
26
+ }
27
+ }
28
+ exports.UnboundSessionError = UnboundSessionError;
29
+ const DEFAULT_CLOCK = { now: () => Date.now() };
30
+ function sleep(ms, signal) {
31
+ return new Promise((resolve, reject) => {
32
+ if (signal?.aborted) {
33
+ reject(signal.reason ?? new DOMException('Aborted', 'AbortError'));
34
+ return;
35
+ }
36
+ const timer = setTimeout(resolve, ms);
37
+ signal?.addEventListener('abort', () => {
38
+ clearTimeout(timer);
39
+ reject(signal.reason ?? new DOMException('Aborted', 'AbortError'));
40
+ }, { once: true });
41
+ });
42
+ }
43
+ async function requestDeviceCode(deviceAuthorizationEndpoint, clientId, http, signal) {
44
+ const body = new URLSearchParams({
45
+ client_id: clientId,
46
+ scope: 'openid',
47
+ });
48
+ const init = {
49
+ method: 'POST',
50
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
51
+ body: body.toString(),
52
+ redirect: 'error',
53
+ };
54
+ if (signal)
55
+ init.signal = signal;
56
+ const response = await http.fetch(deviceAuthorizationEndpoint, init);
57
+ if (!response.ok) {
58
+ const text = await response.text();
59
+ throw new Error(`Device code request failed (${response.status}): ${text}`);
60
+ }
61
+ return (await response.json());
62
+ }
63
+ async function pollForToken(tokenEndpoint, clientId, deviceCode, interval, expiresIn, http, clock = DEFAULT_CLOCK, signal) {
64
+ const deadline = clock.now() + expiresIn * 1000;
65
+ let currentInterval = interval;
66
+ while (clock.now() < deadline) {
67
+ if (signal?.aborted) {
68
+ throw signal.reason ?? new DOMException('Aborted', 'AbortError');
69
+ }
70
+ await sleep(currentInterval * 1000, signal);
71
+ const body = new URLSearchParams({
72
+ grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
73
+ device_code: deviceCode,
74
+ client_id: clientId,
75
+ });
76
+ const init = {
77
+ method: 'POST',
78
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
79
+ body: body.toString(),
80
+ redirect: 'error',
81
+ };
82
+ if (signal)
83
+ init.signal = signal;
84
+ const response = await http.fetch(tokenEndpoint, init);
85
+ if (response.ok) {
86
+ return (await response.json());
87
+ }
88
+ const errorBody = (await response.json());
89
+ switch (errorBody.error) {
90
+ case 'authorization_pending':
91
+ continue;
92
+ case 'slow_down':
93
+ currentInterval += 5;
94
+ continue;
95
+ case 'access_denied':
96
+ throw new DeviceFlowDeniedError();
97
+ case 'expired_token':
98
+ throw new DeviceFlowExpiredError();
99
+ default:
100
+ throw new Error(`Token endpoint error: ${errorBody.error}`);
101
+ }
102
+ }
103
+ throw new DeviceFlowExpiredError();
104
+ }
@@ -0,0 +1,11 @@
1
+ import type { HttpTransport } from './device-flow.ts';
2
+ export interface TargetSessionEndpoints {
3
+ readonly deviceAuthorizationEndpoint: string;
4
+ readonly tokenEndpoint: string;
5
+ readonly revocationEndpoint?: string;
6
+ readonly clientId: string;
7
+ }
8
+ export declare class TargetDiscoveryError extends Error {
9
+ constructor(message: string);
10
+ }
11
+ export declare function discoverTargetSessionEndpoints(targetUrl: string, http: HttpTransport): Promise<TargetSessionEndpoints>;
@@ -0,0 +1,120 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.TargetDiscoveryError = void 0;
4
+ exports.discoverTargetSessionEndpoints = discoverTargetSessionEndpoints;
5
+ const DISCOVERY_PATH = '/.well-known/openengine-hosted-target';
6
+ const MAX_DISCOVERY_BYTES = 64 * 1024;
7
+ class TargetDiscoveryError extends Error {
8
+ constructor(message) {
9
+ super(`Target discovery failed: ${message}`);
10
+ this.name = 'TargetDiscoveryError';
11
+ }
12
+ }
13
+ exports.TargetDiscoveryError = TargetDiscoveryError;
14
+ function record(value, field) {
15
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
16
+ throw new TargetDiscoveryError(`${field} must be an object`);
17
+ }
18
+ return value;
19
+ }
20
+ function stringField(source, field) {
21
+ const value = source[field];
22
+ if (typeof value !== 'string' || value.length === 0) {
23
+ throw new TargetDiscoveryError(`${field} must be a non-empty string`);
24
+ }
25
+ return value;
26
+ }
27
+ function safeEndpoint(value, field, serviceOrigin) {
28
+ if (typeof value !== 'string') {
29
+ throw new TargetDiscoveryError(`${field} must be an absolute URL`);
30
+ }
31
+ let endpoint;
32
+ try {
33
+ endpoint = new URL(value);
34
+ }
35
+ catch {
36
+ throw new TargetDiscoveryError(`${field} must be an absolute URL`);
37
+ }
38
+ if (endpoint.username || endpoint.password || endpoint.search || endpoint.hash) {
39
+ throw new TargetDiscoveryError(`${field} contains forbidden URL components`);
40
+ }
41
+ if (endpoint.origin !== serviceOrigin) {
42
+ throw new TargetDiscoveryError(`${field} must remain on the target origin`);
43
+ }
44
+ return endpoint.href;
45
+ }
46
+ async function readBoundedJson(response) {
47
+ const declaredLength = response.headers.get('content-length');
48
+ if (declaredLength !== null && Number(declaredLength) > MAX_DISCOVERY_BYTES) {
49
+ throw new TargetDiscoveryError('response exceeds the size limit');
50
+ }
51
+ if (!response.body)
52
+ return response.json();
53
+ const reader = response.body.getReader();
54
+ const chunks = [];
55
+ let total = 0;
56
+ while (true) {
57
+ const { done, value } = await reader.read();
58
+ if (done)
59
+ break;
60
+ total += value.byteLength;
61
+ if (total > MAX_DISCOVERY_BYTES) {
62
+ await reader.cancel();
63
+ throw new TargetDiscoveryError('response exceeds the size limit');
64
+ }
65
+ chunks.push(value);
66
+ }
67
+ const bytes = new Uint8Array(total);
68
+ let offset = 0;
69
+ for (const chunk of chunks) {
70
+ bytes.set(chunk, offset);
71
+ offset += chunk.byteLength;
72
+ }
73
+ try {
74
+ return JSON.parse(new TextDecoder().decode(bytes));
75
+ }
76
+ catch {
77
+ throw new TargetDiscoveryError('response is not valid JSON');
78
+ }
79
+ }
80
+ async function fetchDocument(http, url) {
81
+ const response = await http.fetch(url, {
82
+ method: 'GET',
83
+ headers: { Accept: 'application/json' },
84
+ redirect: 'error',
85
+ });
86
+ if (!response.ok) {
87
+ throw new TargetDiscoveryError(`request failed with status ${response.status}`);
88
+ }
89
+ return record(await readBoundedJson(response), 'response');
90
+ }
91
+ async function discoverTargetSessionEndpoints(targetUrl, http) {
92
+ const target = new URL(targetUrl);
93
+ const discovery = await fetchDocument(http, new URL(DISCOVERY_PATH, target).href);
94
+ if (discovery.kind !== 'openengine.hosted-target/v1') {
95
+ throw new TargetDiscoveryError('unsupported hosted-target version');
96
+ }
97
+ if (discovery.organization_binding !== 'device_approval') {
98
+ throw new TargetDiscoveryError('unsupported organization binding');
99
+ }
100
+ const oauth = record(discovery.oauth, 'oauth');
101
+ const metadataUrl = safeEndpoint(oauth.metadata_url, 'oauth.metadata_url', target.origin);
102
+ const deviceEndpoint = safeEndpoint(oauth.device_authorization_endpoint, 'oauth.device_authorization_endpoint', target.origin);
103
+ const tokenEndpoint = safeEndpoint(oauth.token_endpoint, 'oauth.token_endpoint', target.origin);
104
+ const clientId = stringField(oauth, 'client_id');
105
+ const metadata = await fetchDocument(http, metadataUrl);
106
+ const metadataDeviceEndpoint = safeEndpoint(metadata.device_authorization_endpoint, 'device_authorization_endpoint', target.origin);
107
+ const metadataTokenEndpoint = safeEndpoint(metadata.token_endpoint, 'token_endpoint', target.origin);
108
+ if (metadataDeviceEndpoint !== deviceEndpoint || metadataTokenEndpoint !== tokenEndpoint) {
109
+ throw new TargetDiscoveryError('OAuth metadata does not match hosted-target discovery');
110
+ }
111
+ const revocationEndpoint = metadata.revocation_endpoint === undefined
112
+ ? undefined
113
+ : safeEndpoint(metadata.revocation_endpoint, 'revocation_endpoint', target.origin);
114
+ return {
115
+ deviceAuthorizationEndpoint: deviceEndpoint,
116
+ tokenEndpoint,
117
+ ...(revocationEndpoint === undefined ? {} : { revocationEndpoint }),
118
+ clientId,
119
+ };
120
+ }