@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.
@@ -0,0 +1,157 @@
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
+
10
+ export interface TokenResponse {
11
+ readonly access_token: string;
12
+ readonly refresh_token: string;
13
+ readonly token_type: string;
14
+ readonly expires_in: number;
15
+ readonly organization?: { readonly id: string; readonly name: string };
16
+ }
17
+
18
+ export interface HttpTransport {
19
+ fetch(url: string, init: RequestInit & { redirect: 'error' }): Promise<Response>;
20
+ }
21
+
22
+ export interface Clock {
23
+ now(): number;
24
+ }
25
+
26
+ export class DeviceFlowDeniedError extends Error {
27
+ constructor() {
28
+ super('Device authorization denied by user');
29
+ this.name = 'DeviceFlowDeniedError';
30
+ }
31
+ }
32
+
33
+ export class DeviceFlowExpiredError extends Error {
34
+ constructor() {
35
+ super('Device authorization code expired');
36
+ this.name = 'DeviceFlowExpiredError';
37
+ }
38
+ }
39
+
40
+ export class UnboundSessionError extends Error {
41
+ readonly verificationUri: string;
42
+ constructor(verificationUri: string) {
43
+ super(
44
+ `Session not bound to an organization. Re-approve at ${verificationUri} and select an organization.`,
45
+ );
46
+ this.name = 'UnboundSessionError';
47
+ this.verificationUri = verificationUri;
48
+ }
49
+ }
50
+
51
+ const DEFAULT_CLOCK: Clock = { now: () => Date.now() };
52
+
53
+ function sleep(ms: number, signal?: AbortSignal): Promise<void> {
54
+ return new Promise((resolve, reject) => {
55
+ if (signal?.aborted) {
56
+ reject(signal.reason ?? new DOMException('Aborted', 'AbortError'));
57
+ return;
58
+ }
59
+ const timer = setTimeout(resolve, ms);
60
+ signal?.addEventListener(
61
+ 'abort',
62
+ () => {
63
+ clearTimeout(timer);
64
+ reject(signal.reason ?? new DOMException('Aborted', 'AbortError'));
65
+ },
66
+ { once: true },
67
+ );
68
+ });
69
+ }
70
+
71
+ export async function requestDeviceCode(
72
+ deviceAuthorizationEndpoint: string,
73
+ clientId: string,
74
+ http: HttpTransport,
75
+ signal?: AbortSignal,
76
+ ): Promise<DeviceCodeResponse> {
77
+ const body = new URLSearchParams({
78
+ client_id: clientId,
79
+ scope: 'openid',
80
+ });
81
+
82
+ const init: RequestInit & { redirect: 'error' } = {
83
+ method: 'POST',
84
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
85
+ body: body.toString(),
86
+ redirect: 'error',
87
+ };
88
+ if (signal) init.signal = signal;
89
+
90
+ const response = await http.fetch(deviceAuthorizationEndpoint, init);
91
+
92
+ if (!response.ok) {
93
+ const text = await response.text();
94
+ throw new Error(`Device code request failed (${response.status}): ${text}`);
95
+ }
96
+
97
+ return (await response.json()) as DeviceCodeResponse;
98
+ }
99
+
100
+ export async function pollForToken(
101
+ tokenEndpoint: string,
102
+ clientId: string,
103
+ deviceCode: string,
104
+ interval: number,
105
+ expiresIn: number,
106
+ http: HttpTransport,
107
+ clock: Clock = DEFAULT_CLOCK,
108
+ signal?: AbortSignal,
109
+ ): Promise<TokenResponse> {
110
+ const deadline = clock.now() + expiresIn * 1000;
111
+ let currentInterval = interval;
112
+
113
+ while (clock.now() < deadline) {
114
+ if (signal?.aborted) {
115
+ throw signal.reason ?? new DOMException('Aborted', 'AbortError');
116
+ }
117
+
118
+ await sleep(currentInterval * 1000, signal);
119
+
120
+ const body = new URLSearchParams({
121
+ grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
122
+ device_code: deviceCode,
123
+ client_id: clientId,
124
+ });
125
+
126
+ const init: RequestInit & { redirect: 'error' } = {
127
+ method: 'POST',
128
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
129
+ body: body.toString(),
130
+ redirect: 'error',
131
+ };
132
+ if (signal) init.signal = signal;
133
+
134
+ const response = await http.fetch(tokenEndpoint, init);
135
+
136
+ if (response.ok) {
137
+ return (await response.json()) as TokenResponse;
138
+ }
139
+
140
+ const errorBody = (await response.json()) as { error: string };
141
+ switch (errorBody.error) {
142
+ case 'authorization_pending':
143
+ continue;
144
+ case 'slow_down':
145
+ currentInterval += 5;
146
+ continue;
147
+ case 'access_denied':
148
+ throw new DeviceFlowDeniedError();
149
+ case 'expired_token':
150
+ throw new DeviceFlowExpiredError();
151
+ default:
152
+ throw new Error(`Token endpoint error: ${errorBody.error}`);
153
+ }
154
+ }
155
+
156
+ throw new DeviceFlowExpiredError();
157
+ }
@@ -0,0 +1,149 @@
1
+ import type { HttpTransport } from './device-flow.ts';
2
+
3
+ const DISCOVERY_PATH = '/.well-known/openengine-hosted-target';
4
+ const MAX_DISCOVERY_BYTES = 64 * 1024;
5
+
6
+ export interface TargetSessionEndpoints {
7
+ readonly deviceAuthorizationEndpoint: string;
8
+ readonly tokenEndpoint: string;
9
+ readonly revocationEndpoint?: string;
10
+ readonly clientId: string;
11
+ }
12
+
13
+ export class TargetDiscoveryError extends Error {
14
+ constructor(message: string) {
15
+ super(`Target discovery failed: ${message}`);
16
+ this.name = 'TargetDiscoveryError';
17
+ }
18
+ }
19
+
20
+ function record(value: unknown, field: string): Record<string, unknown> {
21
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
22
+ throw new TargetDiscoveryError(`${field} must be an object`);
23
+ }
24
+ return value as Record<string, unknown>;
25
+ }
26
+
27
+ function stringField(source: Record<string, unknown>, field: string): string {
28
+ const value = source[field];
29
+ if (typeof value !== 'string' || value.length === 0) {
30
+ throw new TargetDiscoveryError(`${field} must be a non-empty string`);
31
+ }
32
+ return value;
33
+ }
34
+
35
+ function safeEndpoint(value: unknown, field: string, serviceOrigin: string): string {
36
+ if (typeof value !== 'string') {
37
+ throw new TargetDiscoveryError(`${field} must be an absolute URL`);
38
+ }
39
+ let endpoint: URL;
40
+ try {
41
+ endpoint = new URL(value);
42
+ } catch {
43
+ throw new TargetDiscoveryError(`${field} must be an absolute URL`);
44
+ }
45
+ if (endpoint.username || endpoint.password || endpoint.search || endpoint.hash) {
46
+ throw new TargetDiscoveryError(`${field} contains forbidden URL components`);
47
+ }
48
+ if (endpoint.origin !== serviceOrigin) {
49
+ throw new TargetDiscoveryError(`${field} must remain on the target origin`);
50
+ }
51
+ return endpoint.href;
52
+ }
53
+
54
+ async function readBoundedJson(response: Response): Promise<unknown> {
55
+ const declaredLength = response.headers.get('content-length');
56
+ if (declaredLength !== null && Number(declaredLength) > MAX_DISCOVERY_BYTES) {
57
+ throw new TargetDiscoveryError('response exceeds the size limit');
58
+ }
59
+ if (!response.body) return response.json();
60
+
61
+ const reader = response.body.getReader();
62
+ const chunks: Uint8Array[] = [];
63
+ let total = 0;
64
+ while (true) {
65
+ const { done, value } = await reader.read();
66
+ if (done) break;
67
+ total += value.byteLength;
68
+ if (total > MAX_DISCOVERY_BYTES) {
69
+ await reader.cancel();
70
+ throw new TargetDiscoveryError('response exceeds the size limit');
71
+ }
72
+ chunks.push(value);
73
+ }
74
+
75
+ const bytes = new Uint8Array(total);
76
+ let offset = 0;
77
+ for (const chunk of chunks) {
78
+ bytes.set(chunk, offset);
79
+ offset += chunk.byteLength;
80
+ }
81
+ try {
82
+ return JSON.parse(new TextDecoder().decode(bytes));
83
+ } catch {
84
+ throw new TargetDiscoveryError('response is not valid JSON');
85
+ }
86
+ }
87
+
88
+ async function fetchDocument(http: HttpTransport, url: string): Promise<Record<string, unknown>> {
89
+ const response = await http.fetch(url, {
90
+ method: 'GET',
91
+ headers: { Accept: 'application/json' },
92
+ redirect: 'error',
93
+ });
94
+ if (!response.ok) {
95
+ throw new TargetDiscoveryError(`request failed with status ${response.status}`);
96
+ }
97
+ return record(await readBoundedJson(response), 'response');
98
+ }
99
+
100
+ export async function discoverTargetSessionEndpoints(
101
+ targetUrl: string,
102
+ http: HttpTransport
103
+ ): Promise<TargetSessionEndpoints> {
104
+ const target = new URL(targetUrl);
105
+ const discovery = await fetchDocument(http, new URL(DISCOVERY_PATH, target).href);
106
+ if (discovery.kind !== 'openengine.hosted-target/v1') {
107
+ throw new TargetDiscoveryError('unsupported hosted-target version');
108
+ }
109
+ if (discovery.organization_binding !== 'device_approval') {
110
+ throw new TargetDiscoveryError('unsupported organization binding');
111
+ }
112
+
113
+ const oauth = record(discovery.oauth, 'oauth');
114
+ const metadataUrl = safeEndpoint(oauth.metadata_url, 'oauth.metadata_url', target.origin);
115
+ const deviceEndpoint = safeEndpoint(
116
+ oauth.device_authorization_endpoint,
117
+ 'oauth.device_authorization_endpoint',
118
+ target.origin
119
+ );
120
+ const tokenEndpoint = safeEndpoint(oauth.token_endpoint, 'oauth.token_endpoint', target.origin);
121
+ const clientId = stringField(oauth, 'client_id');
122
+
123
+ const metadata = await fetchDocument(http, metadataUrl);
124
+ const metadataDeviceEndpoint = safeEndpoint(
125
+ metadata.device_authorization_endpoint,
126
+ 'device_authorization_endpoint',
127
+ target.origin
128
+ );
129
+ const metadataTokenEndpoint = safeEndpoint(
130
+ metadata.token_endpoint,
131
+ 'token_endpoint',
132
+ target.origin
133
+ );
134
+ if (metadataDeviceEndpoint !== deviceEndpoint || metadataTokenEndpoint !== tokenEndpoint) {
135
+ throw new TargetDiscoveryError('OAuth metadata does not match hosted-target discovery');
136
+ }
137
+
138
+ const revocationEndpoint =
139
+ metadata.revocation_endpoint === undefined
140
+ ? undefined
141
+ : safeEndpoint(metadata.revocation_endpoint, 'revocation_endpoint', target.origin);
142
+
143
+ return {
144
+ deviceAuthorizationEndpoint: deviceEndpoint,
145
+ tokenEndpoint,
146
+ ...(revocationEndpoint === undefined ? {} : { revocationEndpoint }),
147
+ clientId,
148
+ };
149
+ }
@@ -0,0 +1,55 @@
1
+ export {
2
+ CredentialStoreUnavailableError,
3
+ KeyringCredentialStore,
4
+ FakeCredentialStore,
5
+ targetServiceKey,
6
+ TARGET_ACCOUNT,
7
+ type TargetCredentialStore,
8
+ } from './credential-store.ts';
9
+
10
+ export { acquireTargetLock } from './credential-lock.ts';
11
+
12
+ export {
13
+ requestDeviceCode,
14
+ pollForToken,
15
+ DeviceFlowDeniedError,
16
+ DeviceFlowExpiredError,
17
+ UnboundSessionError,
18
+ type DeviceCodeResponse,
19
+ type TokenResponse,
20
+ type HttpTransport,
21
+ type Clock,
22
+ } from './device-flow.ts';
23
+
24
+ export {
25
+ addTarget,
26
+ removeTarget,
27
+ getTarget,
28
+ listTargets,
29
+ updateTargetOrganization,
30
+ validateTargetName,
31
+ normalizeAndValidateUrl,
32
+ TargetNameInvalidError,
33
+ TargetNameExistsError,
34
+ TargetNotFoundError,
35
+ TargetUrlInvalidError,
36
+ type TargetRecord,
37
+ type SettingsPort,
38
+ } from './target-registry.ts';
39
+
40
+ export {
41
+ targetLogin,
42
+ refreshAccessToken,
43
+ getAccessTokenProvider,
44
+ revokeAndCleanup,
45
+ LoginRequiredError,
46
+ type BrowserOpener,
47
+ type TargetSessionDeps,
48
+ type TargetAccessTokenProvider,
49
+ } from './target-session.ts';
50
+
51
+ export {
52
+ discoverTargetSessionEndpoints,
53
+ TargetDiscoveryError,
54
+ type TargetSessionEndpoints,
55
+ } from './discovery.ts';
@@ -0,0 +1,174 @@
1
+ import crypto from 'node:crypto';
2
+
3
+ export interface TargetRecord {
4
+ readonly id: string;
5
+ readonly url: string;
6
+ readonly adapterVersion: string;
7
+ readonly deviceToken: string;
8
+ readonly organization?: { readonly id: string; readonly name: string };
9
+ readonly createdAt: string;
10
+ }
11
+
12
+ export class TargetNameInvalidError extends Error {
13
+ constructor(name: string) {
14
+ super(
15
+ `Invalid target name "${name}". Must be 1-64 characters, alphanumeric and hyphens only.`,
16
+ );
17
+ this.name = 'TargetNameInvalidError';
18
+ }
19
+ }
20
+
21
+ export class TargetNameExistsError extends Error {
22
+ constructor(name: string) {
23
+ super(`Target "${name}" already exists. Remove it first or choose a different name.`);
24
+ this.name = 'TargetNameExistsError';
25
+ }
26
+ }
27
+
28
+ export class TargetNotFoundError extends Error {
29
+ constructor(name: string) {
30
+ super(`Target "${name}" not found.`);
31
+ this.name = 'TargetNotFoundError';
32
+ }
33
+ }
34
+
35
+ export class TargetUrlInvalidError extends Error {
36
+ constructor(url: string, reason: string) {
37
+ super(`Invalid target URL "${url}": ${reason}`);
38
+ this.name = 'TargetUrlInvalidError';
39
+ }
40
+ }
41
+
42
+ const TARGET_NAME_PATTERN = /^[a-zA-Z0-9]([a-zA-Z0-9-]{0,62}[a-zA-Z0-9])?$/;
43
+ const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '::1', '[::1]']);
44
+
45
+ export function validateTargetName(name: string): void {
46
+ if (!TARGET_NAME_PATTERN.test(name) || name.length > 64) {
47
+ throw new TargetNameInvalidError(name);
48
+ }
49
+ }
50
+
51
+ export function normalizeAndValidateUrl(rawUrl: string): string {
52
+ let parsed: URL;
53
+ try {
54
+ parsed = new URL(rawUrl);
55
+ } catch {
56
+ throw new TargetUrlInvalidError(rawUrl, 'not a valid URL');
57
+ }
58
+
59
+ if (parsed.username || parsed.password) {
60
+ throw new TargetUrlInvalidError(rawUrl, 'URL must not contain userinfo');
61
+ }
62
+
63
+ if (parsed.search || parsed.hash) {
64
+ throw new TargetUrlInvalidError(rawUrl, 'URL must not contain query or fragment');
65
+ }
66
+
67
+ const isLoopback = LOOPBACK_HOSTS.has(parsed.hostname);
68
+ if (parsed.protocol !== 'https:' && !isLoopback) {
69
+ throw new TargetUrlInvalidError(rawUrl, 'HTTPS required for non-loopback targets');
70
+ }
71
+
72
+ let normalized = `${parsed.protocol}//${parsed.host}${parsed.pathname}`;
73
+ if (normalized.endsWith('/') && normalized.length > 1) {
74
+ normalized = normalized.slice(0, -1);
75
+ }
76
+ return normalized;
77
+ }
78
+
79
+ interface SettingsWithTargets {
80
+ _targets?: Record<string, TargetRecord>;
81
+ [key: string]: unknown;
82
+ }
83
+
84
+ export interface SettingsPort {
85
+ load(): SettingsWithTargets;
86
+ mutate(mutator: (settings: SettingsWithTargets) => void): void;
87
+ }
88
+
89
+ export function addTarget(
90
+ name: string,
91
+ rawUrl: string,
92
+ settings: SettingsPort,
93
+ ): TargetRecord {
94
+ validateTargetName(name);
95
+ const url = normalizeAndValidateUrl(rawUrl);
96
+
97
+ const existing = settings.load();
98
+ if (existing._targets?.[name]) {
99
+ throw new TargetNameExistsError(name);
100
+ }
101
+
102
+ const record: TargetRecord = {
103
+ id: crypto.randomUUID(),
104
+ url,
105
+ adapterVersion: 'v1',
106
+ deviceToken: crypto.randomUUID(),
107
+ createdAt: new Date().toISOString(),
108
+ };
109
+
110
+ settings.mutate((s) => {
111
+ if (!s._targets) {
112
+ s._targets = {};
113
+ }
114
+ s._targets[name] = record;
115
+ });
116
+
117
+ return record;
118
+ }
119
+
120
+ export function removeTarget(
121
+ name: string,
122
+ settings: SettingsPort,
123
+ ): TargetRecord {
124
+ const existing = settings.load();
125
+ const record = existing._targets?.[name];
126
+ if (!record) {
127
+ throw new TargetNotFoundError(name);
128
+ }
129
+
130
+ settings.mutate((s) => {
131
+ if (s._targets) {
132
+ delete s._targets[name];
133
+ }
134
+ });
135
+
136
+ return record;
137
+ }
138
+
139
+ export function getTarget(
140
+ name: string,
141
+ settings: SettingsPort,
142
+ ): TargetRecord | null {
143
+ const existing = settings.load();
144
+ return existing._targets?.[name] ?? null;
145
+ }
146
+
147
+ export function listTargets(
148
+ settings: SettingsPort,
149
+ ): Array<{ name: string; record: TargetRecord }> {
150
+ const existing = settings.load();
151
+ const targets = existing._targets ?? {};
152
+ return Object.entries(targets).map(([name, record]) => ({ name, record }));
153
+ }
154
+
155
+ export function updateTargetOrganization(
156
+ name: string,
157
+ organization: { id: string; name: string },
158
+ settings: SettingsPort,
159
+ ): void {
160
+ const existing = settings.load();
161
+ if (!existing._targets?.[name]) {
162
+ throw new TargetNotFoundError(name);
163
+ }
164
+
165
+ settings.mutate((s) => {
166
+ const target = s._targets?.[name];
167
+ if (target) {
168
+ (s._targets as Record<string, TargetRecord>)[name] = {
169
+ ...target,
170
+ organization,
171
+ };
172
+ }
173
+ });
174
+ }