@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,249 @@
1
+ import type { TargetCredentialStore } from './credential-store.ts';
2
+ import type { TargetRecord, SettingsPort } from './target-registry.ts';
3
+ import {
4
+ requestDeviceCode,
5
+ pollForToken,
6
+ UnboundSessionError,
7
+ type HttpTransport,
8
+ type Clock,
9
+ type TokenResponse,
10
+ } from './device-flow.ts';
11
+ import { targetServiceKey, TARGET_ACCOUNT } from './credential-store.ts';
12
+ import { updateTargetOrganization } from './target-registry.ts';
13
+
14
+ export class LoginRequiredError extends Error {
15
+ readonly targetName: string;
16
+ constructor(targetName: string) {
17
+ super(`Login required. Run: zeroshot target login ${targetName}`);
18
+ this.name = 'LoginRequiredError';
19
+ this.targetName = targetName;
20
+ }
21
+ }
22
+
23
+ export interface BrowserOpener {
24
+ open(url: string): Promise<void>;
25
+ }
26
+
27
+ export interface TargetSessionDeps {
28
+ readonly http: HttpTransport;
29
+ readonly clock: Clock;
30
+ readonly browserOpener: BrowserOpener;
31
+ readonly stderr: { write(s: string): void };
32
+ readonly discoveryEndpoints: {
33
+ readonly deviceAuthorizationEndpoint: string;
34
+ readonly tokenEndpoint: string;
35
+ readonly revocationEndpoint?: string;
36
+ readonly clientId: string;
37
+ };
38
+ }
39
+
40
+ export async function targetLogin(
41
+ targetName: string,
42
+ target: TargetRecord,
43
+ credentialStore: TargetCredentialStore,
44
+ acquireLock: () => Promise<() => Promise<void>>,
45
+ settings: SettingsPort,
46
+ deps: TargetSessionDeps
47
+ ): Promise<{ organization: { id: string; name: string } }> {
48
+ const { http, clock, browserOpener, stderr, discoveryEndpoints } = deps;
49
+ const { deviceAuthorizationEndpoint, tokenEndpoint, clientId } = discoveryEndpoints;
50
+
51
+ const codeResponse = await requestDeviceCode(deviceAuthorizationEndpoint, clientId, http);
52
+
53
+ stderr.write(
54
+ `\nOpen this URL to authorize:\n ${codeResponse.verification_uri}\n\nEnter code: ${codeResponse.user_code}\n\n`
55
+ );
56
+
57
+ if (codeResponse.verification_uri_complete) {
58
+ try {
59
+ await browserOpener.open(codeResponse.verification_uri_complete);
60
+ } catch {
61
+ // Browser open is best-effort
62
+ }
63
+ }
64
+
65
+ const tokenResponse = await pollForToken(
66
+ tokenEndpoint,
67
+ clientId,
68
+ codeResponse.device_code,
69
+ codeResponse.interval,
70
+ codeResponse.expires_in,
71
+ http,
72
+ clock
73
+ );
74
+
75
+ if (!tokenResponse.organization) {
76
+ throw new UnboundSessionError(codeResponse.verification_uri);
77
+ }
78
+
79
+ const serviceKey = targetServiceKey(target.id);
80
+ const release = await acquireLock();
81
+ try {
82
+ await credentialStore.set(serviceKey, TARGET_ACCOUNT, tokenResponse.refresh_token);
83
+ } finally {
84
+ await release();
85
+ }
86
+
87
+ updateTargetOrganization(targetName, tokenResponse.organization, settings);
88
+
89
+ return { organization: tokenResponse.organization };
90
+ }
91
+
92
+ export async function refreshAccessToken(
93
+ targetName: string,
94
+ target: TargetRecord,
95
+ credentialStore: TargetCredentialStore,
96
+ acquireLock: () => Promise<() => Promise<void>>,
97
+ deps: Pick<TargetSessionDeps, 'http' | 'discoveryEndpoints'>
98
+ ): Promise<{ accessToken: string; expiresIn: number }> {
99
+ const { http, discoveryEndpoints } = deps;
100
+ const { tokenEndpoint, revocationEndpoint, clientId } = discoveryEndpoints;
101
+ const serviceKey = targetServiceKey(target.id);
102
+
103
+ const release = await acquireLock();
104
+ try {
105
+ const currentRefreshToken = await credentialStore.get(serviceKey, TARGET_ACCOUNT);
106
+ if (!currentRefreshToken) {
107
+ throw new LoginRequiredError(targetName);
108
+ }
109
+
110
+ const body = new URLSearchParams({
111
+ grant_type: 'refresh_token',
112
+ refresh_token: currentRefreshToken,
113
+ client_id: clientId,
114
+ });
115
+
116
+ let tokenResponse: TokenResponse;
117
+ const response = await http.fetch(tokenEndpoint, {
118
+ method: 'POST',
119
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
120
+ body: body.toString(),
121
+ redirect: 'error',
122
+ });
123
+
124
+ if (!response.ok) {
125
+ const errorBody = (await response.json().catch(() => ({ error: 'unknown' }))) as {
126
+ error: string;
127
+ };
128
+ if (errorBody.error === 'invalid_grant') {
129
+ await credentialStore.delete(serviceKey, TARGET_ACCOUNT);
130
+ throw new LoginRequiredError(targetName);
131
+ }
132
+ throw new Error(`Token refresh failed (${response.status}): ${errorBody.error}`);
133
+ }
134
+
135
+ tokenResponse = (await response.json()) as TokenResponse;
136
+
137
+ try {
138
+ await credentialStore.set(serviceKey, TARGET_ACCOUNT, tokenResponse.refresh_token);
139
+ } catch {
140
+ await bestEffortRevoke(tokenResponse.refresh_token, revocationEndpoint, clientId, http);
141
+ await credentialStore.delete(serviceKey, TARGET_ACCOUNT);
142
+ throw new LoginRequiredError(targetName);
143
+ }
144
+
145
+ return { accessToken: tokenResponse.access_token, expiresIn: tokenResponse.expires_in };
146
+ } finally {
147
+ await release();
148
+ }
149
+ }
150
+
151
+ async function bestEffortRevoke(
152
+ token: string,
153
+ revocationEndpoint: string | undefined,
154
+ clientId: string,
155
+ http: HttpTransport
156
+ ): Promise<void> {
157
+ if (!revocationEndpoint) return;
158
+ try {
159
+ const body = new URLSearchParams({
160
+ token,
161
+ client_id: clientId,
162
+ token_type_hint: 'refresh_token',
163
+ });
164
+ await http.fetch(revocationEndpoint, {
165
+ method: 'POST',
166
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
167
+ body: body.toString(),
168
+ redirect: 'error',
169
+ });
170
+ } catch {
171
+ // Best-effort
172
+ }
173
+ }
174
+
175
+ export interface TargetAccessTokenProvider {
176
+ getAccessToken(signal?: AbortSignal): Promise<string>;
177
+ }
178
+
179
+ export function getAccessTokenProvider(
180
+ targetName: string,
181
+ target: TargetRecord,
182
+ credentialStore: TargetCredentialStore,
183
+ acquireLock: () => Promise<() => Promise<void>>,
184
+ deps: Pick<TargetSessionDeps, 'http' | 'discoveryEndpoints'>,
185
+ clock: Clock = { now: () => Date.now() }
186
+ ): TargetAccessTokenProvider {
187
+ let cachedToken: string | null = null;
188
+ let expiresAt = 0;
189
+
190
+ return {
191
+ async getAccessToken(_signal?: AbortSignal): Promise<string> {
192
+ if (cachedToken && clock.now() < expiresAt - 30_000) {
193
+ return cachedToken;
194
+ }
195
+ const result = await refreshAccessToken(
196
+ targetName,
197
+ target,
198
+ credentialStore,
199
+ acquireLock,
200
+ deps
201
+ );
202
+ cachedToken = result.accessToken;
203
+ expiresAt = clock.now() + result.expiresIn * 1000;
204
+ return result.accessToken;
205
+ },
206
+ };
207
+ }
208
+
209
+ export async function revokeAndCleanup(
210
+ target: TargetRecord,
211
+ credentialStore: TargetCredentialStore,
212
+ acquireLock: () => Promise<() => Promise<void>>,
213
+ deps: Pick<TargetSessionDeps, 'http' | 'discoveryEndpoints'>,
214
+ force: boolean
215
+ ): Promise<void> {
216
+ const { http, discoveryEndpoints } = deps;
217
+ const { revocationEndpoint, clientId } = discoveryEndpoints;
218
+ const serviceKey = targetServiceKey(target.id);
219
+
220
+ const release = await acquireLock();
221
+ try {
222
+ const refreshToken = await credentialStore.get(serviceKey, TARGET_ACCOUNT);
223
+ if (refreshToken && revocationEndpoint) {
224
+ const body = new URLSearchParams({
225
+ token: refreshToken,
226
+ client_id: clientId,
227
+ token_type_hint: 'refresh_token',
228
+ });
229
+ try {
230
+ const response = await http.fetch(revocationEndpoint, {
231
+ method: 'POST',
232
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
233
+ body: body.toString(),
234
+ redirect: 'error',
235
+ });
236
+ if (!response.ok && !force) {
237
+ throw new Error(
238
+ `Remote revocation failed (${response.status}). Use --force to remove anyway.`
239
+ );
240
+ }
241
+ } catch (err) {
242
+ if (!force) throw err;
243
+ }
244
+ }
245
+ await credentialStore.delete(serviceKey, TARGET_ACCOUNT);
246
+ } finally {
247
+ await release();
248
+ }
249
+ }