@asteroidai/cli 1.9.11 → 1.9.27
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/dist/asteroid.js +675 -328
- package/dist/core.js +287 -270
- package/package.json +1 -1
- package/src/account.ts +287 -10
- package/src/auth/credentials.ts +45 -7
- package/src/auth/store.ts +115 -7
- package/src/generated/commands.ts +199 -182
- package/src/program.ts +13 -1
- package/src/reference.ts +25 -1
- package/src/run.test.ts +289 -5
- package/src/run.ts +14 -4
- package/src/sync/commands.ts +0 -48
- package/src/sync/run.ts +60 -26
- package/src/sync/sync.test.ts +55 -16
- package/src/sync/workflow-pull.ts +21 -18
- package/src/sync/workflow-push.ts +6 -0
- package/src/types.ts +2 -0
- package/src/ui.ts +5 -0
package/package.json
CHANGED
package/src/account.ts
CHANGED
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
import { UsageError } from "./args";
|
|
2
2
|
import {
|
|
3
3
|
API_KEY_OVERRIDES,
|
|
4
|
+
type Auth,
|
|
4
5
|
AuthError,
|
|
6
|
+
isStaffLogin,
|
|
7
|
+
NotMemberError,
|
|
5
8
|
orgFromToken,
|
|
6
9
|
rememberOrg,
|
|
7
10
|
resolveAuth,
|
|
11
|
+
staffHint,
|
|
8
12
|
UNSET_API_KEY,
|
|
9
13
|
} from "./auth/credentials";
|
|
10
|
-
import { CredentialStore } from "./auth/store";
|
|
14
|
+
import { CredentialStore, type StaffToken } from "./auth/store";
|
|
11
15
|
import {
|
|
12
16
|
authorizeDevice,
|
|
13
17
|
pollDevice,
|
|
@@ -15,14 +19,16 @@ import {
|
|
|
15
19
|
type WorkosOrg,
|
|
16
20
|
} from "./auth/workos";
|
|
17
21
|
import {
|
|
22
|
+
type Config,
|
|
18
23
|
findOrg,
|
|
19
24
|
loadConfig,
|
|
20
25
|
mergeOrgs,
|
|
26
|
+
type Org,
|
|
21
27
|
orgLabel,
|
|
22
28
|
requestedOrg,
|
|
23
29
|
saveConfig,
|
|
24
30
|
} from "./config";
|
|
25
|
-
import { send } from "./http";
|
|
31
|
+
import { ApiError, send } from "./http";
|
|
26
32
|
import { clean, render } from "./output";
|
|
27
33
|
import type { Deps } from "./run";
|
|
28
34
|
|
|
@@ -128,11 +134,21 @@ export async function orgs(
|
|
|
128
134
|
deps.fetch,
|
|
129
135
|
);
|
|
130
136
|
const list = loadConfig(deps.env).orgs ?? [];
|
|
137
|
+
const staff = (await new CredentialStore(deps.env).staffTokens()).filter(
|
|
138
|
+
(held) => held.expiresAt > Date.now(),
|
|
139
|
+
);
|
|
131
140
|
const active = auth.org?.workosId;
|
|
132
141
|
if (json) {
|
|
133
142
|
deps.stdout(
|
|
134
143
|
`${JSON.stringify(
|
|
135
|
-
|
|
144
|
+
[
|
|
145
|
+
...list.map((org) => ({ ...org, active: org.workosId === active })),
|
|
146
|
+
...staff.map((held) => ({
|
|
147
|
+
...held.org,
|
|
148
|
+
active: held.org.workosId === active,
|
|
149
|
+
staff: { reason: held.reason, expiresAt: held.expiresAt },
|
|
150
|
+
})),
|
|
151
|
+
],
|
|
136
152
|
null,
|
|
137
153
|
2,
|
|
138
154
|
)}\n`,
|
|
@@ -141,17 +157,259 @@ export async function orgs(
|
|
|
141
157
|
}
|
|
142
158
|
for (const org of list)
|
|
143
159
|
deps.stdout(`${org.workosId === active ? "*" : " "} ${orgLabel(org)}\n`);
|
|
160
|
+
for (const held of staff)
|
|
161
|
+
deps.stdout(
|
|
162
|
+
`${held.org.workosId === active ? "*" : " "} ${orgLabel(held.org)} staff access, ${timeLeft(held.expiresAt)} left\n`,
|
|
163
|
+
);
|
|
164
|
+
return 0;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function timeLeft(expiresAt: number): string {
|
|
168
|
+
const minutes = Math.max(0, Math.round((expiresAt - Date.now()) / 60_000));
|
|
169
|
+
return minutes >= 60
|
|
170
|
+
? `${Math.floor(minutes / 60)}h${String(minutes % 60).padStart(2, "0")}m`
|
|
171
|
+
: `${minutes}m`;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function isMember(config: Config, org: Org): boolean {
|
|
175
|
+
return (config.orgs ?? []).some((o) => o.workosId === org.workosId);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** The org whose sign-in calls the mint: the default, or else any of yours. */
|
|
179
|
+
function homeOrg(config: Config): Org {
|
|
180
|
+
const org =
|
|
181
|
+
config.org && isMember(config, config.org) ? config.org : config.orgs?.[0];
|
|
182
|
+
if (!org)
|
|
183
|
+
throw new AuthError(
|
|
184
|
+
"staff access needs a sign-in to one of your own organizations. Run `asteroid login`.",
|
|
185
|
+
);
|
|
186
|
+
return org;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const STAFF_TOKEN_PATH = "/users/v2/staff-tokens";
|
|
190
|
+
|
|
191
|
+
interface MintedStaffToken {
|
|
192
|
+
token: string;
|
|
193
|
+
grantId: string;
|
|
194
|
+
organizationId: string;
|
|
195
|
+
organizationName: string;
|
|
196
|
+
workosOrganizationId?: string;
|
|
197
|
+
reason: string;
|
|
198
|
+
expiresAt: string;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async function mintStaffToken(
|
|
202
|
+
deps: Deps,
|
|
203
|
+
config: Config,
|
|
204
|
+
ref: string,
|
|
205
|
+
reason: string,
|
|
206
|
+
): Promise<StaffToken> {
|
|
207
|
+
const home = await resolveAuth(
|
|
208
|
+
deps.env,
|
|
209
|
+
config,
|
|
210
|
+
homeOrg(config).workosId,
|
|
211
|
+
deps.fetch,
|
|
212
|
+
);
|
|
213
|
+
let minted: MintedStaffToken;
|
|
214
|
+
try {
|
|
215
|
+
const response = await send(
|
|
216
|
+
deps.env.apiUrl,
|
|
217
|
+
home,
|
|
218
|
+
{
|
|
219
|
+
method: "POST",
|
|
220
|
+
path: STAFF_TOKEN_PATH,
|
|
221
|
+
query: new URLSearchParams(),
|
|
222
|
+
json: { organization: ref, reason },
|
|
223
|
+
},
|
|
224
|
+
false,
|
|
225
|
+
deps.fetch,
|
|
226
|
+
);
|
|
227
|
+
minted = response.json as MintedStaffToken;
|
|
228
|
+
} catch (error) {
|
|
229
|
+
if (error instanceof ApiError && error.status === 403)
|
|
230
|
+
throw new AuthError(
|
|
231
|
+
`you are not a member of ${clean(ref)}, and staff access is for Asteroid staff only.`,
|
|
232
|
+
);
|
|
233
|
+
if (error instanceof ApiError && error.status === 404)
|
|
234
|
+
throw new UsageError(
|
|
235
|
+
`unknown organization ${clean(ref)}. Pass its id or its WorkOS id (org_...).`,
|
|
236
|
+
);
|
|
237
|
+
throw error;
|
|
238
|
+
}
|
|
239
|
+
return {
|
|
240
|
+
token: minted.token,
|
|
241
|
+
grantId: minted.grantId,
|
|
242
|
+
org: {
|
|
243
|
+
workosId: minted.workosOrganizationId ?? minted.organizationId,
|
|
244
|
+
id: minted.organizationId,
|
|
245
|
+
name: minted.organizationName,
|
|
246
|
+
},
|
|
247
|
+
reason: minted.reason,
|
|
248
|
+
expiresAt: Date.parse(minted.expiresAt),
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
async function revokeStaffToken(
|
|
253
|
+
deps: Deps,
|
|
254
|
+
home: Auth,
|
|
255
|
+
held: StaffToken,
|
|
256
|
+
): Promise<boolean> {
|
|
257
|
+
try {
|
|
258
|
+
await send(
|
|
259
|
+
deps.env.apiUrl,
|
|
260
|
+
home,
|
|
261
|
+
{
|
|
262
|
+
method: "DELETE",
|
|
263
|
+
path: `${STAFF_TOKEN_PATH}/${encodeURIComponent(held.grantId)}`,
|
|
264
|
+
query: new URLSearchParams(),
|
|
265
|
+
},
|
|
266
|
+
false,
|
|
267
|
+
deps.fetch,
|
|
268
|
+
);
|
|
269
|
+
return true;
|
|
270
|
+
} catch (error) {
|
|
271
|
+
// Gone already: revoked, or never recorded. Either way it no longer works.
|
|
272
|
+
if (error instanceof ApiError && error.status === 404) return true;
|
|
273
|
+
deps.stderr(
|
|
274
|
+
`Could not revoke staff access to ${orgLabel(held.org)}: ${clean(error instanceof Error ? error.message : String(error))}. It ends at ${new Date(held.expiresAt).toISOString()}.\n`,
|
|
275
|
+
);
|
|
276
|
+
return false;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** `use --exit`: revoke every staff token now and go back to your own org. */
|
|
281
|
+
async function exitStaff(deps: Deps): Promise<number> {
|
|
282
|
+
const store = new CredentialStore(deps.env);
|
|
283
|
+
const held = await store.staffTokens();
|
|
284
|
+
const config = loadConfig(deps.env);
|
|
285
|
+
if (held.length === 0) {
|
|
286
|
+
deps.stderr("No staff access to leave.\n");
|
|
287
|
+
return 0;
|
|
288
|
+
}
|
|
289
|
+
const home = await resolveAuth(
|
|
290
|
+
deps.env,
|
|
291
|
+
config,
|
|
292
|
+
homeOrg(config).workosId,
|
|
293
|
+
deps.fetch,
|
|
294
|
+
);
|
|
295
|
+
const left: StaffToken[] = [];
|
|
296
|
+
const kept: StaffToken[] = [];
|
|
297
|
+
for (const token of held) {
|
|
298
|
+
// Keep a token whose grant is still live, so a retry can revoke it.
|
|
299
|
+
const ended =
|
|
300
|
+
token.expiresAt <= Date.now() ||
|
|
301
|
+
(await revokeStaffToken(deps, home, token));
|
|
302
|
+
if (!ended) {
|
|
303
|
+
kept.push(token);
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
await store.dropStaffToken(token.grantId);
|
|
307
|
+
left.push(token);
|
|
308
|
+
}
|
|
309
|
+
const back = held.find((t) => t.from)?.from ?? homeOrg(config);
|
|
310
|
+
saveConfig(deps.env, { ...loadConfig(deps.env), org: back });
|
|
311
|
+
if (left.length > 0)
|
|
312
|
+
deps.stderr(
|
|
313
|
+
`Left staff access to ${left.map((t) => orgLabel(t.org)).join(", ")}.\n`,
|
|
314
|
+
);
|
|
315
|
+
if (kept.length > 0)
|
|
316
|
+
deps.stderr(
|
|
317
|
+
`Still holding staff access to ${kept.map((t) => orgLabel(t.org)).join(", ")}. Run \`asteroid use --exit\` again to retry.\n`,
|
|
318
|
+
);
|
|
319
|
+
deps.stderr(`Now using ${orgLabel(back)}.\n`);
|
|
320
|
+
return kept.length === 0 ? 0 : 1;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
async function askReason(deps: Deps, org: string): Promise<string | undefined> {
|
|
324
|
+
if (!deps.ui) return undefined;
|
|
325
|
+
const reason = await deps.ui.text(
|
|
326
|
+
`You are not a member of ${clean(org)}. Why do you need staff access? This is audited.`,
|
|
327
|
+
);
|
|
328
|
+
return reason?.trim() || undefined;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** The staff path of `use`: mint a token for an org you are not a member of. */
|
|
332
|
+
async function useAsStaff(
|
|
333
|
+
deps: Deps,
|
|
334
|
+
config: Config,
|
|
335
|
+
ref: string,
|
|
336
|
+
reason: string | undefined,
|
|
337
|
+
staff: boolean,
|
|
338
|
+
): Promise<number> {
|
|
339
|
+
const store = new CredentialStore(deps.env);
|
|
340
|
+
const held = await store.staffToken(ref);
|
|
341
|
+
if (held && held.expiresAt - 30_000 > Date.now() && !reason) {
|
|
342
|
+
saveConfig(deps.env, { ...config, org: held.org });
|
|
343
|
+
deps.stderr(
|
|
344
|
+
`Now using ${orgLabel(held.org)} with staff access, ${timeLeft(held.expiresAt)} left.\n`,
|
|
345
|
+
);
|
|
346
|
+
return 0;
|
|
347
|
+
}
|
|
348
|
+
if (!reason && staff) reason = await askReason(deps, ref);
|
|
349
|
+
if (!reason)
|
|
350
|
+
throw new UsageError(
|
|
351
|
+
`you are not a member of ${clean(ref)}.${staffHint(ref, staff)}${staff ? " The reason is audited." : ""}`,
|
|
352
|
+
);
|
|
353
|
+
// The mint takes an id, not a name. A held token knows the id.
|
|
354
|
+
const minted = await mintStaffToken(
|
|
355
|
+
deps,
|
|
356
|
+
config,
|
|
357
|
+
held?.org.id ?? ref,
|
|
358
|
+
reason,
|
|
359
|
+
);
|
|
360
|
+
const previous = config.org
|
|
361
|
+
? isMember(config, config.org)
|
|
362
|
+
? config.org
|
|
363
|
+
: (await store.staffToken(config.org.workosId))?.from
|
|
364
|
+
: undefined;
|
|
365
|
+
try {
|
|
366
|
+
await store.saveStaffToken(
|
|
367
|
+
{ ...minted, from: previous ?? held?.from },
|
|
368
|
+
held?.grantId,
|
|
369
|
+
);
|
|
370
|
+
} catch (error) {
|
|
371
|
+
// Nothing holds the token, so nothing could revoke it later. End it now.
|
|
372
|
+
const home = await resolveAuth(
|
|
373
|
+
deps.env,
|
|
374
|
+
config,
|
|
375
|
+
homeOrg(config).workosId,
|
|
376
|
+
deps.fetch,
|
|
377
|
+
);
|
|
378
|
+
await revokeStaffToken(deps, home, minted);
|
|
379
|
+
throw error;
|
|
380
|
+
}
|
|
381
|
+
saveConfig(deps.env, { ...loadConfig(deps.env), org: minted.org });
|
|
382
|
+
deps.stderr(
|
|
383
|
+
`Staff access to ${orgLabel(minted.org)} for ${timeLeft(minted.expiresAt)}, until ${new Date(minted.expiresAt).toLocaleTimeString()}. ` +
|
|
384
|
+
`Reason: ${clean(minted.reason)}. This is audited. Run \`asteroid use --exit\` to leave.\n`,
|
|
385
|
+
);
|
|
144
386
|
return 0;
|
|
145
387
|
}
|
|
146
388
|
|
|
389
|
+
export interface UseOptions {
|
|
390
|
+
reason?: string;
|
|
391
|
+
exit?: boolean;
|
|
392
|
+
}
|
|
393
|
+
|
|
147
394
|
export async function use(
|
|
148
395
|
deps: Deps,
|
|
149
396
|
ref: string | undefined,
|
|
397
|
+
options: UseOptions = {},
|
|
150
398
|
): Promise<number> {
|
|
151
399
|
if (deps.env.apiKey)
|
|
152
400
|
throw new UsageError(
|
|
153
401
|
`${API_KEY_OVERRIDES} \`use\` only switches the login's organization. ${UNSET_API_KEY}`,
|
|
154
402
|
);
|
|
403
|
+
if (options.exit) {
|
|
404
|
+
if (ref || options.reason)
|
|
405
|
+
throw new UsageError("usage: asteroid use --exit");
|
|
406
|
+
return exitStaff(deps);
|
|
407
|
+
}
|
|
408
|
+
// `--reason --exit` must not mint a grant whose reason is "--exit".
|
|
409
|
+
if (options.reason?.startsWith("-"))
|
|
410
|
+
throw new UsageError(
|
|
411
|
+
`--reason needs a value, not ${clean(options.reason)}. Give the reason without a leading -.`,
|
|
412
|
+
);
|
|
155
413
|
const config = loadConfig(deps.env);
|
|
156
414
|
if (!ref && deps.ui && (config.orgs?.length ?? 0) > 0) {
|
|
157
415
|
ref = await deps.ui.select(
|
|
@@ -164,12 +422,22 @@ export async function use(
|
|
|
164
422
|
if (!ref) return 1;
|
|
165
423
|
}
|
|
166
424
|
if (!ref) throw new UsageError("usage: asteroid use <org>");
|
|
425
|
+
const login = await new CredentialStore(deps.env).readLogin();
|
|
426
|
+
if (!login) throw new AuthError("not signed in. Run `asteroid login`.");
|
|
427
|
+
const staff = isStaffLogin(login);
|
|
167
428
|
const org = findOrg(ref, config.orgs ?? []);
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
);
|
|
172
|
-
|
|
429
|
+
// Not one of yours: an id or name the sign-in does not list. A bare WorkOS id
|
|
430
|
+
// may still be yours, so the refresh decides.
|
|
431
|
+
if (!org || (!isMember(config, org) && options.reason))
|
|
432
|
+
return useAsStaff(deps, config, ref, options.reason, staff);
|
|
433
|
+
let auth: Auth;
|
|
434
|
+
try {
|
|
435
|
+
auth = await resolveAuth(deps.env, config, org.workosId, deps.fetch);
|
|
436
|
+
} catch (error) {
|
|
437
|
+
if (error instanceof NotMemberError)
|
|
438
|
+
return useAsStaff(deps, config, ref, options.reason, staff);
|
|
439
|
+
throw error;
|
|
440
|
+
}
|
|
173
441
|
const updated = loadConfig(deps.env);
|
|
174
442
|
updated.org =
|
|
175
443
|
updated.orgs?.find((o) => o.workosId === org.workosId) ?? auth.org ?? org;
|
|
@@ -207,6 +475,7 @@ export async function whoami(
|
|
|
207
475
|
);
|
|
208
476
|
return 0;
|
|
209
477
|
}
|
|
478
|
+
const grant = body.staffGrant as { reason?: string } | undefined;
|
|
210
479
|
const person = body.person as { email?: string } | undefined;
|
|
211
480
|
const orgId = body.organizationId as string | undefined;
|
|
212
481
|
const organization = auth.org ? orgLabel(auth.org) : (orgId ?? "none");
|
|
@@ -215,9 +484,17 @@ export async function whoami(
|
|
|
215
484
|
as: person?.email ?? body.id,
|
|
216
485
|
kind: body.kind,
|
|
217
486
|
credential: body.credential,
|
|
218
|
-
source:
|
|
487
|
+
source:
|
|
488
|
+
auth.kind === "api-key"
|
|
489
|
+
? "ASTEROID_API_KEY"
|
|
490
|
+
: auth.staff
|
|
491
|
+
? "asteroid use (staff access)"
|
|
492
|
+
: "asteroid login",
|
|
219
493
|
organization,
|
|
220
|
-
...(
|
|
494
|
+
...(grant ? { staffReason: grant.reason } : {}),
|
|
495
|
+
...(auth.expiresAt === undefined
|
|
496
|
+
? {}
|
|
497
|
+
: { tokenExpiresIn: timeLeft(auth.expiresAt) }),
|
|
221
498
|
})}\n`,
|
|
222
499
|
);
|
|
223
500
|
return 0;
|
package/src/auth/credentials.ts
CHANGED
|
@@ -7,11 +7,15 @@ import {
|
|
|
7
7
|
orgLabel,
|
|
8
8
|
saveConfig,
|
|
9
9
|
} from "../config";
|
|
10
|
-
import {
|
|
10
|
+
import { clean } from "../output";
|
|
11
|
+
import { CredentialStore, type Login, type StaffToken } from "./store";
|
|
11
12
|
import { refresh, tokenClaims, WorkosError } from "./workos";
|
|
12
13
|
|
|
13
14
|
export class AuthError extends Error {}
|
|
14
15
|
|
|
16
|
+
/** The sign-in is fine, but the person is not a member of the org. */
|
|
17
|
+
export class NotMemberError extends AuthError {}
|
|
18
|
+
|
|
15
19
|
export const API_KEY_OVERRIDES =
|
|
16
20
|
"ASTEROID_API_KEY is set, so the CLI uses that key and ignores `asteroid login`.";
|
|
17
21
|
export const UNSET_API_KEY =
|
|
@@ -22,6 +26,30 @@ export interface Auth {
|
|
|
22
26
|
headers: Record<string, string>;
|
|
23
27
|
org?: Org;
|
|
24
28
|
expiresAt?: number;
|
|
29
|
+
/** Set when the token is a staff token from `asteroid use <org> --reason`. */
|
|
30
|
+
staff?: { grantId: string; reason: string };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** An Asteroid sign-in. It only decides which hints to show: the server checks the admin list. */
|
|
34
|
+
export function isStaffLogin(login: Login | undefined): boolean {
|
|
35
|
+
return login?.email?.toLowerCase().endsWith("@asteroid.ai") ?? false;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The `--reason` hint, for staff only. Empty for everyone else. */
|
|
39
|
+
export function staffHint(ref: string, staff: boolean): string {
|
|
40
|
+
return staff
|
|
41
|
+
? ` Asteroid staff: run \`asteroid use ${clean(ref)} --reason <why>\`.`
|
|
42
|
+
: "";
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function staffAuth(held: StaffToken): Auth {
|
|
46
|
+
return {
|
|
47
|
+
kind: "bearer",
|
|
48
|
+
headers: { Authorization: `Bearer ${held.token}` },
|
|
49
|
+
org: held.org,
|
|
50
|
+
expiresAt: held.expiresAt,
|
|
51
|
+
staff: { grantId: held.grantId, reason: held.reason },
|
|
52
|
+
};
|
|
25
53
|
}
|
|
26
54
|
|
|
27
55
|
export function orgFromToken(token: string): Org | undefined {
|
|
@@ -55,7 +83,7 @@ function bearer(token: string, expiresAt: number): Auth {
|
|
|
55
83
|
const SSO_OR_MFA =
|
|
56
84
|
/^(sso_required|mfa_.*|organization_authentication_methods_required)$/;
|
|
57
85
|
|
|
58
|
-
function refreshError(error: WorkosError, org: Org): AuthError {
|
|
86
|
+
function refreshError(error: WorkosError, org: Org, login: Login): AuthError {
|
|
59
87
|
if (error.code === "invalid_grant") {
|
|
60
88
|
return new AuthError("your sign-in has expired. Run `asteroid login`.");
|
|
61
89
|
}
|
|
@@ -65,8 +93,8 @@ function refreshError(error: WorkosError, org: Org): AuthError {
|
|
|
65
93
|
);
|
|
66
94
|
}
|
|
67
95
|
if (error.code === "organization_membership_not_found") {
|
|
68
|
-
return new
|
|
69
|
-
`you are not a member of ${orgLabel(org)}.
|
|
96
|
+
return new NotMemberError(
|
|
97
|
+
`you are not a member of ${orgLabel(org)}.${staffHint(org.id ?? org.workosId, isStaffLogin(login))}`,
|
|
70
98
|
);
|
|
71
99
|
}
|
|
72
100
|
return new AuthError(
|
|
@@ -84,16 +112,26 @@ export async function resolveAuth(
|
|
|
84
112
|
return { kind: "api-key", headers: { "X-Api-Key": env.apiKey } };
|
|
85
113
|
|
|
86
114
|
const store = new CredentialStore(env);
|
|
87
|
-
|
|
115
|
+
const login = await store.readLogin();
|
|
116
|
+
if (!login) {
|
|
88
117
|
throw new AuthError(
|
|
89
118
|
"not signed in. Run `asteroid login`, or set ASTEROID_API_KEY.",
|
|
90
119
|
);
|
|
91
120
|
}
|
|
92
121
|
|
|
122
|
+
const target = orgRef ?? config.org?.workosId;
|
|
123
|
+
const held = target ? await store.staffToken(target) : undefined;
|
|
124
|
+
if (held) {
|
|
125
|
+
if (held.expiresAt - 30_000 > Date.now()) return staffAuth(held);
|
|
126
|
+
throw new AuthError(
|
|
127
|
+
`your staff access to ${orgLabel(held.org)} has expired.${staffHint(held.org.id ?? held.org.workosId, true)}`,
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
93
131
|
const org = orgRef ? findOrg(orgRef, config.orgs ?? []) : config.org;
|
|
94
132
|
if (orgRef && !org) {
|
|
95
133
|
throw new AuthError(
|
|
96
|
-
`unknown organization ${orgRef}. Run \`asteroid orgs\` to list yours
|
|
134
|
+
`unknown organization ${clean(orgRef)}. Run \`asteroid orgs\` to list yours.${staffHint(orgRef, isStaffLogin(login))}`,
|
|
97
135
|
);
|
|
98
136
|
}
|
|
99
137
|
if (!org) {
|
|
@@ -134,7 +172,7 @@ export async function resolveAuth(
|
|
|
134
172
|
rememberOrg(env, config, auth.org);
|
|
135
173
|
return auth;
|
|
136
174
|
} catch (error) {
|
|
137
|
-
if (error instanceof WorkosError) throw refreshError(error, org);
|
|
175
|
+
if (error instanceof WorkosError) throw refreshError(error, org, login);
|
|
138
176
|
throw error;
|
|
139
177
|
}
|
|
140
178
|
});
|
package/src/auth/store.ts
CHANGED
|
@@ -2,12 +2,13 @@ import { mkdirSync, rmSync } from "node:fs";
|
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import lockfile from "proper-lockfile";
|
|
4
4
|
|
|
5
|
-
import { type Env, readJson, writePrivateJson } from "../config";
|
|
5
|
+
import { type Env, type Org, readJson, writePrivateJson } from "../config";
|
|
6
6
|
import { WorkosError } from "./workos";
|
|
7
7
|
|
|
8
8
|
const SERVICE = "asteroid-cli";
|
|
9
9
|
const LOCK_STALE_MS = 30_000;
|
|
10
10
|
const ACCOUNT = "refresh-token";
|
|
11
|
+
const STAFF_ACCOUNT = "staff-tokens";
|
|
11
12
|
|
|
12
13
|
export interface Login {
|
|
13
14
|
refreshToken: string;
|
|
@@ -19,17 +20,30 @@ export interface CachedToken {
|
|
|
19
20
|
expiresAt: number;
|
|
20
21
|
}
|
|
21
22
|
|
|
23
|
+
/** A staff token for one org that is not yours. It lasts 8 h and is audited. */
|
|
24
|
+
export interface StaffToken {
|
|
25
|
+
token: string;
|
|
26
|
+
grantId: string;
|
|
27
|
+
org: Org;
|
|
28
|
+
reason: string;
|
|
29
|
+
expiresAt: number;
|
|
30
|
+
/** The default org before `asteroid use` took staff access. */
|
|
31
|
+
from?: Org;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
type StaffTokens = Record<string, StaffToken>;
|
|
35
|
+
|
|
22
36
|
interface Keyring {
|
|
23
37
|
get(): string | null;
|
|
24
38
|
set(value: string): void;
|
|
25
39
|
delete(): void;
|
|
26
40
|
}
|
|
27
41
|
|
|
28
|
-
async function keyring(): Promise<Keyring | undefined> {
|
|
42
|
+
async function keyring(account = ACCOUNT): Promise<Keyring | undefined> {
|
|
29
43
|
if (process.env.ASTEROID_NO_KEYCHAIN) return undefined;
|
|
30
44
|
try {
|
|
31
45
|
const { Entry } = await import("@napi-rs/keyring");
|
|
32
|
-
const entry = new Entry(SERVICE,
|
|
46
|
+
const entry = new Entry(SERVICE, account);
|
|
33
47
|
entry.getPassword();
|
|
34
48
|
return {
|
|
35
49
|
get: () => entry.getPassword() ?? null,
|
|
@@ -72,6 +86,10 @@ export class CredentialStore {
|
|
|
72
86
|
return join(this.env.configDir, "tokens.json");
|
|
73
87
|
}
|
|
74
88
|
|
|
89
|
+
private get staffFile() {
|
|
90
|
+
return join(this.env.configDir, "staff-tokens.json");
|
|
91
|
+
}
|
|
92
|
+
|
|
75
93
|
async readLogin(): Promise<Login | undefined> {
|
|
76
94
|
const ring = await keyring();
|
|
77
95
|
const raw = ring ? tryGet(ring).value : null;
|
|
@@ -102,23 +120,113 @@ export class CredentialStore {
|
|
|
102
120
|
const cleared = current.ok && (!current.value || trySet(ring!, null));
|
|
103
121
|
rmSync(this.loginFile, { force: true });
|
|
104
122
|
rmSync(this.tokenFile, { force: true });
|
|
105
|
-
|
|
123
|
+
const staffCleared = await this.withStaffLock(() =>
|
|
124
|
+
this.writeStaffTokens({}),
|
|
125
|
+
);
|
|
126
|
+
return staffCleared && cleared;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Every staff token held, expired ones included. */
|
|
130
|
+
async staffTokens(): Promise<StaffToken[]> {
|
|
131
|
+
const ring = await keyring(STAFF_ACCOUNT);
|
|
132
|
+
const raw = ring ? tryGet(ring).value : null;
|
|
133
|
+
const tokens = raw
|
|
134
|
+
? (JSON.parse(raw) as StaffTokens)
|
|
135
|
+
: readJson<StaffTokens>(this.staffFile, {});
|
|
136
|
+
return Object.values(tokens);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** The staff token for an org, matched by WorkOS id, id or name. */
|
|
140
|
+
async staffToken(ref: string): Promise<StaffToken | undefined> {
|
|
141
|
+
const lower = ref.toLowerCase();
|
|
142
|
+
return (await this.staffTokens()).find(
|
|
143
|
+
({ org }) =>
|
|
144
|
+
org.workosId.toLowerCase() === lower ||
|
|
145
|
+
org.id?.toLowerCase() === lower ||
|
|
146
|
+
org.name?.toLowerCase() === lower,
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Saves a staff token and drops expired ones. `replaces` is dropped too. */
|
|
151
|
+
async saveStaffToken(token: StaffToken, replaces?: string) {
|
|
152
|
+
await this.withStaffLock(async () => {
|
|
153
|
+
const tokens: StaffTokens = {};
|
|
154
|
+
for (const held of await this.staffTokens())
|
|
155
|
+
if (held.expiresAt > Date.now() && held.grantId !== replaces)
|
|
156
|
+
tokens[held.org.workosId] = held;
|
|
157
|
+
tokens[token.org.workosId] = token;
|
|
158
|
+
if (!(await this.writeStaffTokens(tokens)))
|
|
159
|
+
throw new WorkosError(
|
|
160
|
+
"keychain",
|
|
161
|
+
"could not save the staff token in the OS keychain. Unlock it and try again.",
|
|
162
|
+
);
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async dropStaffToken(grantId: string) {
|
|
167
|
+
await this.withStaffLock(async () => {
|
|
168
|
+
const tokens: StaffTokens = {};
|
|
169
|
+
for (const held of await this.staffTokens())
|
|
170
|
+
if (held.grantId !== grantId) tokens[held.org.workosId] = held;
|
|
171
|
+
if (!(await this.writeStaffTokens(tokens)))
|
|
172
|
+
throw new WorkosError(
|
|
173
|
+
"keychain",
|
|
174
|
+
"could not remove the staff token from the OS keychain. Unlock it and try again.",
|
|
175
|
+
);
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
private async writeStaffTokens(tokens: StaffTokens): Promise<boolean> {
|
|
180
|
+
const empty = Object.keys(tokens).length === 0;
|
|
181
|
+
const ring = await keyring(STAFF_ACCOUNT);
|
|
182
|
+
if (ring && trySet(ring, empty ? null : JSON.stringify(tokens))) {
|
|
183
|
+
rmSync(this.staffFile, { force: true });
|
|
184
|
+
return true;
|
|
185
|
+
}
|
|
186
|
+
const stale = ring ? tryGet(ring) : { ok: true, value: null };
|
|
187
|
+
if (!stale.ok || (stale.value && !trySet(ring!, null))) return false;
|
|
188
|
+
if (empty) rmSync(this.staffFile, { force: true });
|
|
189
|
+
else writePrivateJson(this.staffFile, tokens);
|
|
190
|
+
return true;
|
|
106
191
|
}
|
|
107
192
|
|
|
108
193
|
async withRefreshLock<T>(work: () => Promise<T>): Promise<T> {
|
|
194
|
+
return this.withLock(
|
|
195
|
+
"refresh",
|
|
196
|
+
"another asteroid command is refreshing the sign-in.",
|
|
197
|
+
work,
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Two commands at once must not lose a grant they could still revoke. */
|
|
202
|
+
private async withStaffLock<T>(work: () => Promise<T>): Promise<T> {
|
|
203
|
+
return this.withLock(
|
|
204
|
+
"staff",
|
|
205
|
+
"another asteroid command is saving staff access.",
|
|
206
|
+
work,
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
private async withLock<T>(
|
|
211
|
+
name: string,
|
|
212
|
+
busy: string,
|
|
213
|
+
work: () => Promise<T>,
|
|
214
|
+
): Promise<T> {
|
|
109
215
|
mkdirSync(this.env.configDir, { recursive: true, mode: 0o700 });
|
|
110
|
-
const lockfilePath = join(this.env.configDir,
|
|
216
|
+
const lockfilePath = join(this.env.configDir, `${name}.lock`);
|
|
111
217
|
let release: () => Promise<void>;
|
|
112
218
|
try {
|
|
113
|
-
|
|
219
|
+
// Each lock names its own target: proper-lockfile tracks locks by target.
|
|
220
|
+
release = await lockfile.lock(join(this.env.configDir, name), {
|
|
114
221
|
lockfilePath,
|
|
222
|
+
realpath: false,
|
|
115
223
|
stale: LOCK_STALE_MS,
|
|
116
224
|
retries: { retries: 60, minTimeout: 100, maxTimeout: 500 },
|
|
117
225
|
});
|
|
118
226
|
} catch {
|
|
119
227
|
throw new WorkosError(
|
|
120
228
|
"locked",
|
|
121
|
-
|
|
229
|
+
`${busy} Try again, or delete ${lockfilePath} if none is running.`,
|
|
122
230
|
);
|
|
123
231
|
}
|
|
124
232
|
try {
|