@sequenceholdings/studio-cli 0.1.13 → 0.1.18

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 (53) hide show
  1. package/README.md +94 -30
  2. package/dist/artifact/delegate.d.ts +2 -2
  3. package/dist/artifact/delegate.js +31 -73
  4. package/dist/atlas-client.js +52 -37
  5. package/dist/auth-cmds/commands.d.ts +1 -1
  6. package/dist/auth-cmds/commands.js +12 -7
  7. package/dist/auth.d.ts +97 -19
  8. package/dist/auth.js +376 -81
  9. package/dist/config.d.ts +3 -3
  10. package/dist/config.js +18 -13
  11. package/dist/env-catalog.js +13 -3
  12. package/dist/env-flags.d.ts +2 -0
  13. package/dist/env-flags.js +2 -0
  14. package/dist/env-registry.d.ts +27 -0
  15. package/dist/env-registry.js +204 -0
  16. package/dist/envs/commands.d.ts +1 -1
  17. package/dist/envs/commands.js +41 -3
  18. package/dist/file-lock.d.ts +5 -0
  19. package/dist/file-lock.js +187 -0
  20. package/dist/functions/commands.d.ts +9 -1
  21. package/dist/functions/commands.js +71 -29
  22. package/dist/functions/manifest.d.ts +1 -0
  23. package/dist/functions/manifest.js +36 -0
  24. package/dist/login.d.ts +8 -3
  25. package/dist/login.js +46 -34
  26. package/dist/main.d.ts +2 -1
  27. package/dist/main.js +35 -12
  28. package/dist/orm/delegate.js +15 -2
  29. package/dist/pat-hints.js +2 -2
  30. package/dist/pipeline/commands.d.ts +58 -0
  31. package/dist/pipeline/commands.js +330 -0
  32. package/dist/pipeline/lifecycle.d.ts +58 -0
  33. package/dist/pipeline/lifecycle.js +348 -0
  34. package/dist/pipeline/pinning.d.ts +5 -0
  35. package/dist/pipeline/pinning.js +9 -0
  36. package/dist/pipeline/templates.d.ts +11 -0
  37. package/dist/pipeline/templates.js +166 -0
  38. package/dist/process/build.d.ts +4 -0
  39. package/dist/process/build.js +31 -1
  40. package/dist/process/codegen.js +19 -1
  41. package/dist/process/commands.js +97 -47
  42. package/dist/process/compiler-subprocess.d.ts +29 -0
  43. package/dist/process/compiler-subprocess.js +99 -0
  44. package/dist/process/compiler-worker.d.ts +1 -0
  45. package/dist/process/compiler-worker.js +38 -0
  46. package/dist/process/lint.d.ts +8 -0
  47. package/dist/process/lint.js +76 -29
  48. package/dist/process/repo-install.js +18 -2
  49. package/dist/repos/commands.d.ts +1 -1
  50. package/dist/repos/commands.js +17 -12
  51. package/dist/secrets/commands.d.ts +1 -1
  52. package/dist/secrets/commands.js +18 -18
  53. package/package.json +8 -3
package/dist/auth.js CHANGED
@@ -1,7 +1,13 @@
1
- import { chmod, mkdir, readFile, rename, writeFile } from 'node:fs/promises';
1
+ import { randomUUID } from 'node:crypto';
2
2
  import { existsSync } from 'node:fs';
3
+ import { chmod, readFile, rename, unlink, writeFile } from 'node:fs/promises';
3
4
  import { homedir } from 'node:os';
4
- import { dirname, join } from 'node:path';
5
+ import { join } from 'node:path';
6
+ import { validateDeploymentAudience, validateDeploymentBaseUrl, } from '@sequenceholdings/artifact-studio/deployment-validation';
7
+ import { withCrossProcessFileLock } from './file-lock.js';
8
+ function hasErrorCode(error, code) {
9
+ return error instanceof Error && Reflect.get(error, 'code') === code;
10
+ }
5
11
  /**
6
12
  * seq-studio and seqapi share one token file and one Auth0 application:
7
13
  * same tenant, client id, audience, and token shape. Either CLI can log in,
@@ -14,10 +20,10 @@ import { dirname, join } from 'node:path';
14
20
  * cloud agents with no interactive login can still push.
15
21
  * (M2M carries app scopes but NO user identity / workspace membership
16
22
  * — see the `atlas-test-access` rule.)
17
- * 2. Cached user token — read from the seqapi token file and refreshed
18
- * via the Auth0 refresh-token grant when near expiry.
23
+ * 2. Cached user access token — read from the seqapi token file. When it
24
+ * expires, an interactive session performs a bounded PKCE login again.
19
25
  *
20
- * Login and refresh both write the shared file. This mirrors
26
+ * Login writes the shared file. This mirrors
21
27
  * `seqapi._save_tokens` exactly:
22
28
  * same fields, same shape, same 0o600 permissions, atomic write via
23
29
  * tmpfile + rename. The M2M token is in-memory only (never persisted).
@@ -26,13 +32,107 @@ import { dirname, join } from 'node:path';
26
32
  // CLI also hard-codes them — there's a single Sequence Auth0 tenant for
27
33
  // all Sequence CLIs.
28
34
  export const AUTH0_DOMAIN = 'dev-n1t8ts403fp8oyxp.us.auth0.com';
35
+ const AUTH0_CUSTOM_DOMAIN = 'login.seqholdings.com';
29
36
  export const AUTH0_CLIENT_ID = 'GD9riCDWocfc66odpWBjwBiX43qqAX8r';
30
37
  export const AUTH0_AUDIENCE = 'https://api.studio.com';
31
38
  const AUTH0_M2M_CLIENT_ID = '5TLffZqvLq4ztLDjZhwKVJCpB5VNrWj0';
32
- // In-memory cache for the M2M token (seconds-based, mirrors seqapi's
39
+ /** The realm name the built-in Sequence environments' tokens live under —
40
+ * also the implicit realm of the legacy flat fields in tokens.json. */
41
+ export const SEQUENCE_REALM = 'sequence';
42
+ const SEQUENCE_BUILTIN_ENVS = new Set(['local', 'staging', 'production', 'banksouth']);
43
+ export function isSequenceAuthEnvName(envName) {
44
+ return (envName === SEQUENCE_REALM ||
45
+ SEQUENCE_BUILTIN_ENVS.has(envName) ||
46
+ envName === 'preview' ||
47
+ envName.startsWith('preview:'));
48
+ }
49
+ // Both hostnames terminate at Sequence's one Auth0 tenant. Keep this explicit:
50
+ // discovery must never be able to select an arbitrary credential recipient.
51
+ const TRUSTED_AUTH0_DOMAINS = new Set([AUTH0_DOMAIN, AUTH0_CUSTOM_DOMAIN]);
52
+ /**
53
+ * A discovery response is not a trust anchor for the host that receives an
54
+ * M2M client secret. Even another valid *.auth0.com tenant is untrusted;
55
+ * additional Auth0 tenants/custom domains require an explicit code-reviewed
56
+ * allowlist entry.
57
+ */
58
+ export function validateAuth0Domain(domain) {
59
+ const normalized = domain.trim().toLowerCase();
60
+ if (!TRUSTED_AUTH0_DOMAINS.has(normalized)) {
61
+ throw new Error(`Untrusted Auth0 domain '${domain}': expected one of ` +
62
+ `${JSON.stringify([...TRUSTED_AUTH0_DOMAINS])}. Discovery cannot authorize ` +
63
+ 'a new host to receive an M2M client secret.');
64
+ }
65
+ return normalized;
66
+ }
67
+ export const SEQUENCE_AUTH_REALM = {
68
+ name: SEQUENCE_REALM,
69
+ domain: AUTH0_DOMAIN,
70
+ clientId: AUTH0_CLIENT_ID,
71
+ audience: AUTH0_AUDIENCE,
72
+ m2mClientId: AUTH0_M2M_CLIENT_ID,
73
+ };
74
+ function seqapiConfigPath() {
75
+ return join(seqapiTokenDir(), 'config.json');
76
+ }
77
+ /**
78
+ * Resolve the auth realm for an environment name. Undefined, built-ins, and
79
+ * per-PR preview targets map to the shared Sequence realm. Preview URLs are
80
+ * constrained to Sequence's preview domain by the environment resolver. Every
81
+ * other explicit name must have a valid seqapi registry entry; otherwise fail
82
+ * closed so a shared Sequence bearer token can never be sent to a tenant URL.
83
+ */
84
+ export async function realmForEnv(envName) {
85
+ if (!envName || isSequenceAuthEnvName(envName)) {
86
+ return SEQUENCE_AUTH_REALM;
87
+ }
88
+ const path = seqapiConfigPath();
89
+ const registrationError = new Error(`Environment '${envName}' has no tenant auth registration in ${path}. ` +
90
+ `Register it with: seq-studio envs add ${envName} <url>`);
91
+ if (!existsSync(path))
92
+ throw registrationError;
93
+ let registered;
94
+ try {
95
+ const parsed = JSON.parse(await readFile(path, 'utf8'));
96
+ registered =
97
+ typeof parsed === 'object' && parsed !== null
98
+ ? (Reflect.get(parsed, 'environments') ?? {})
99
+ : {};
100
+ }
101
+ catch (error) {
102
+ throw new Error(`Could not read tenant auth registry ${path}. ` +
103
+ `Re-register '${envName}' with: seq-studio envs add ${envName} <url>`, { cause: error });
104
+ }
105
+ const entry = registered[envName];
106
+ if (!entry)
107
+ throw registrationError;
108
+ const { auth0 } = entry;
109
+ if (!entry.url || !auth0?.domain || !auth0.client_id || !auth0.audience) {
110
+ throw new Error(`Environment '${envName}' in ${path} is missing Auth0 config. ` +
111
+ `Re-register it with: seq-studio envs add ${envName} <url>`);
112
+ }
113
+ const baseUrl = validateDeploymentBaseUrl(entry.url);
114
+ validateDeploymentAudience({ audience: auth0.audience, baseUrl });
115
+ return {
116
+ name: envName,
117
+ domain: validateAuth0Domain(auth0.domain),
118
+ clientId: auth0.client_id,
119
+ audience: auth0.audience,
120
+ organization: auth0.organization ?? undefined,
121
+ m2mClientId: auth0.m2m_client_id ?? undefined,
122
+ };
123
+ }
124
+ /** Env var(s) carrying a realm's M2M client secret — the Sequence realm keeps
125
+ * the legacy bare name; OpCo realms use a suffixed name so one shell can hold
126
+ * several credentials unambiguously. Mirrors seqapi's `_m2m_secret_env_names`. */
127
+ export function m2mSecretEnvName(realm) {
128
+ if (realm.name === SEQUENCE_REALM)
129
+ return 'AUTH0_M2M_CLIENT_SECRET';
130
+ return `AUTH0_M2M_CLIENT_SECRET_${realm.name.toUpperCase().replaceAll('-', '_')}`;
131
+ }
132
+ // In-memory per-realm cache for M2M tokens (seconds-based, mirrors seqapi's
33
133
  // `_m2m_cache`). Reused while > 60s from expiry to avoid re-minting on every
34
134
  // call within a single process (e.g. a long `artifact dev` watch).
35
- let m2mCache = null;
135
+ const m2mCache = new Map();
36
136
  /**
37
137
  * A configured M2M credential failed to mint a token. Typed so callers that
38
138
  * normally swallow discovery errors (lazy catalog refresh) can still surface
@@ -42,26 +142,38 @@ export class M2mTokenError extends Error {
42
142
  name = 'M2mTokenError';
43
143
  }
44
144
  /**
45
- * Mint an M2M access token via the Auth0 client-credentials grant when
46
- * `AUTH0_M2M_CLIENT_SECRET` is set. Returns null when the secret is unset
145
+ * Mint an M2M access token via the Auth0 client-credentials grant when the
146
+ * realm's secret env var is set. Returns null when the secret is unset
47
147
  * (so the caller falls back to the user token). Throws on a configured-but-
48
148
  * rejected secret, mirroring seqapi's `_get_m2m_token` (`raise_for_status`).
149
+ * The realm's own secret var is required — a Sequence secret in the shell is
150
+ * never sent to a tenant's Auth0 client.
49
151
  */
50
- async function getM2mToken() {
51
- const clientSecret = process.env.AUTH0_M2M_CLIENT_SECRET;
152
+ async function getM2mToken(realm = SEQUENCE_AUTH_REALM) {
153
+ if (!realm.m2mClientId)
154
+ return null;
155
+ const clientSecret = process.env[m2mSecretEnvName(realm)];
52
156
  if (!clientSecret)
53
157
  return null;
54
158
  const now = Date.now() / 1000;
55
- if (m2mCache && m2mCache.expiresAt > now + 60)
56
- return m2mCache.accessToken;
57
- const response = await fetch(`https://${AUTH0_DOMAIN}/oauth/token`, {
159
+ const cached = m2mCache.get(realm.name);
160
+ if (cached && cached.expiresAt > now + 60) {
161
+ verifyTokenMatchesRealm({
162
+ accessToken: cached.accessToken,
163
+ realm,
164
+ requireOrganization: false,
165
+ });
166
+ return cached.accessToken;
167
+ }
168
+ const response = await fetch(`https://${realm.domain}/oauth/token`, {
58
169
  method: 'POST',
170
+ redirect: 'manual',
59
171
  headers: { 'Content-Type': 'application/json' },
60
172
  body: JSON.stringify({
61
173
  grant_type: 'client_credentials',
62
- client_id: AUTH0_M2M_CLIENT_ID,
174
+ client_id: realm.m2mClientId,
63
175
  client_secret: clientSecret,
64
- audience: AUTH0_AUDIENCE,
176
+ audience: realm.audience,
65
177
  }),
66
178
  });
67
179
  if (!response.ok) {
@@ -71,11 +183,17 @@ async function getM2mToken() {
71
183
  if (!data.access_token) {
72
184
  throw new M2mTokenError('Auth0 M2M response missing access_token');
73
185
  }
74
- m2mCache = {
186
+ verifyTokenMatchesRealm({
187
+ accessToken: data.access_token,
188
+ realm,
189
+ requireOrganization: false,
190
+ });
191
+ const entry = {
75
192
  accessToken: data.access_token,
76
193
  expiresAt: Date.now() / 1000 + (data.expires_in ?? 7200),
77
194
  };
78
- return m2mCache.accessToken;
195
+ m2mCache.set(realm.name, entry);
196
+ return entry.accessToken;
79
197
  }
80
198
  export function seqapiTokenDir() {
81
199
  return join(homedir(), '.config', 'sequence-api');
@@ -85,108 +203,200 @@ export function seqapiTokenPath() {
85
203
  }
86
204
  export class NotLoggedInError extends Error {
87
205
  name = 'NotLoggedInError';
88
- constructor() {
89
- super('Not logged in. Run: seq-studio login\n' +
90
- 'For headless contexts (CI / cloud agents), set AUTH0_M2M_CLIENT_SECRET for ' +
206
+ constructor(realmName = SEQUENCE_REALM, reason) {
207
+ const loginHint = realmName === SEQUENCE_REALM
208
+ ? 'Run: seq-studio login'
209
+ : `Run: seq-studio login --env ${realmName} (or: seqapi login -e ${realmName})`;
210
+ const secretHint = realmName === SEQUENCE_REALM
211
+ ? 'AUTH0_M2M_CLIENT_SECRET'
212
+ : `AUTH0_M2M_CLIENT_SECRET_${realmName.toUpperCase().replaceAll('-', '_')}`;
213
+ super(`${reason ?? `Not logged in [${realmName}].`} ${loginHint}\n` +
214
+ `For headless contexts (CI / cloud agents), set ${secretHint} for ` +
91
215
  'service-account (M2M) access instead.');
92
216
  }
93
217
  }
94
218
  /**
95
- * Return a valid access token. Tries the M2M service account first when
96
- * `AUTH0_M2M_CLIENT_SECRET` is set, then falls back to the cached user
97
- * token (refreshed via the Auth0 refresh-token grant when within 60s of
98
- * expiry). Same precedence as seqapi's `get_access_token`, so both CLIs
99
- * resolve the same identity for the same environment.
219
+ * Return a valid access token for an environment's auth realm. Tries the
220
+ * realm's M2M service account first (secret env var), then falls back to the
221
+ * cached user access token. Expired user sessions perform bounded PKCE login
222
+ * when browser auto-login is enabled. Same precedence and token file as seqapi's
223
+ * `get_access_token`, so both CLIs resolve the same identity for the same
224
+ * environment. No `env` (or a built-in Sequence env) means the shared
225
+ * Sequence realm — the legacy behavior every existing caller gets unchanged.
100
226
  */
101
- export async function getAccessToken() {
102
- const m2m = await getM2mToken();
227
+ export class UnsafeAuthTargetError extends Error {
228
+ }
229
+ const AUTO_LOGIN_TIMEOUT_MS = 120_000;
230
+ function autoLoginEnabled() {
231
+ const value = process.env.SEQAPI_AUTO_LOGIN?.trim().toLowerCase() ?? '1';
232
+ return !['0', 'false', 'no', 'off'].includes(value);
233
+ }
234
+ function autoLoginTimeoutMs() {
235
+ const raw = process.env.SEQAPI_AUTO_LOGIN_TIMEOUT?.trim();
236
+ if (!raw)
237
+ return AUTO_LOGIN_TIMEOUT_MS;
238
+ const seconds = Number(raw);
239
+ return Number.isFinite(seconds) && seconds > 0 ? seconds * 1_000 : AUTO_LOGIN_TIMEOUT_MS;
240
+ }
241
+ async function reauthenticateOrThrow({ reason, realm, }) {
242
+ if (!autoLoginEnabled()) {
243
+ throw new NotLoggedInError(realm.name, reason);
244
+ }
245
+ try {
246
+ const { loginWithPkce } = await import('./login.js');
247
+ return await loginWithPkce({ realm, timeoutMs: autoLoginTimeoutMs() });
248
+ }
249
+ catch (error) {
250
+ throw new NotLoggedInError(realm.name, `${reason} Automatic re-login failed: ${error instanceof Error ? error.message : String(error)}`);
251
+ }
252
+ }
253
+ function validateRealmTarget({ realm, targetUrl, }) {
254
+ try {
255
+ const baseUrl = validateDeploymentBaseUrl(targetUrl);
256
+ if (realm.name !== SEQUENCE_REALM) {
257
+ validateDeploymentAudience({ audience: realm.audience, baseUrl });
258
+ }
259
+ }
260
+ catch (error) {
261
+ throw new UnsafeAuthTargetError(`Refusing to send '${realm.name}' credentials to ${targetUrl}: ` +
262
+ `${error instanceof Error ? error.message : String(error)}`, { cause: error });
263
+ }
264
+ }
265
+ export async function getAccessTokenWithMode(options) {
266
+ const realm = await realmForEnv(options?.env);
267
+ if (options?.targetUrl)
268
+ validateRealmTarget({ realm, targetUrl: options.targetUrl });
269
+ const m2m = await getM2mToken(realm);
103
270
  if (m2m)
104
- return m2m;
105
- const tokens = await loadTokens();
106
- if (!tokens)
107
- throw new NotLoggedInError();
271
+ return { authMode: 'm2m', token: m2m };
272
+ const tokens = await loadCachedUserTokens(realm.name);
273
+ if (!tokens) {
274
+ if (options?.allowInteractiveLogin === false) {
275
+ throw new NotLoggedInError(realm.name);
276
+ }
277
+ return {
278
+ authMode: 'user',
279
+ token: await reauthenticateOrThrow({
280
+ reason: `Not logged in [${realm.name}].`,
281
+ realm,
282
+ }),
283
+ };
284
+ }
108
285
  const now = Date.now() / 1000;
109
286
  if (tokens.access_token && (tokens.expires_at ?? 0) > now + 60) {
110
- return tokens.access_token;
111
- }
112
- if (!tokens.refresh_token)
113
- throw new NotLoggedInError();
114
- const response = await fetch(`https://${AUTH0_DOMAIN}/oauth/token`, {
115
- method: 'POST',
116
- headers: { 'Content-Type': 'application/json' },
117
- body: JSON.stringify({
118
- grant_type: 'refresh_token',
119
- client_id: AUTH0_CLIENT_ID,
120
- refresh_token: tokens.refresh_token,
121
- }),
122
- });
123
- if (response.status === 401 || response.status === 403) {
124
- throw new Error('Refresh token expired or revoked. Re-authenticate with: seq-studio login');
125
- }
126
- if (!response.ok) {
127
- throw new Error(`Auth0 token refresh failed (${response.status}): ${await response.text()}`);
287
+ verifyTokenMatchesRealm({ accessToken: tokens.access_token, realm });
288
+ return { authMode: 'user', token: tokens.access_token };
128
289
  }
129
- const refreshed = (await response.json());
130
- if (!refreshed.access_token) {
131
- throw new Error('Auth0 refresh response missing access_token');
290
+ if (options?.allowInteractiveLogin === false) {
291
+ throw new NotLoggedInError(realm.name, `Access token expired [${realm.name}].`);
132
292
  }
133
- const updated = {
134
- access_token: refreshed.access_token,
135
- refresh_token: refreshed.refresh_token ?? tokens.refresh_token,
136
- expires_at: Date.now() / 1000 + (refreshed.expires_in ?? 86400),
293
+ return {
294
+ authMode: 'user',
295
+ token: await reauthenticateOrThrow({
296
+ reason: `Access token expired [${realm.name}].`,
297
+ realm,
298
+ }),
137
299
  };
138
- await saveTokens(updated);
139
- return updated.access_token;
300
+ }
301
+ export async function getAccessToken(options) {
302
+ return (await getAccessTokenWithMode(options)).token;
140
303
  }
141
304
  /**
142
305
  * Try to load a token without throwing. Returns `null` for any error
143
306
  * condition (missing file, parse error, no token fields). Used by
144
307
  * `doctor` for non-fatal "are you logged in?" checks.
145
308
  */
146
- export async function tryGetAccessToken(options) {
309
+ export async function tryGetAccessTokenWithMode(options) {
147
310
  try {
148
- return await getAccessToken();
311
+ return await getAccessTokenWithMode({
312
+ allowInteractiveLogin: false,
313
+ env: options?.env,
314
+ targetUrl: options?.targetUrl,
315
+ });
149
316
  }
150
317
  catch (err) {
151
- if (options?.failClosedForM2m && process.env.AUTH0_M2M_CLIENT_SECRET?.trim()) {
318
+ if (err instanceof UnsafeAuthTargetError)
152
319
  throw err;
320
+ if (options?.failClosedForM2m) {
321
+ // Do not replace an invalid tenant registration with the Sequence realm:
322
+ // that could hide a rejected discovery token host or inspect the wrong
323
+ // M2M secret. Config-resolution errors must surface unchanged.
324
+ const realm = await realmForEnv(options.env);
325
+ if (process.env[m2mSecretEnvName(realm)]?.trim())
326
+ throw err;
153
327
  }
154
328
  return null;
155
329
  }
156
330
  }
331
+ export async function tryGetAccessToken(options) {
332
+ return (await tryGetAccessTokenWithMode(options))?.token ?? null;
333
+ }
157
334
  /**
158
335
  * Decode the `sub` claim from a JWT without verification. Verification is the
159
336
  * server's job — this is only used to compare identities locally (e.g. to
160
337
  * bind the cached environment catalog to the identity that fetched it).
161
338
  */
162
- export function decodeJwtSub(token) {
339
+ function decodeJwtClaims(token) {
163
340
  const payload = token.split('.')[1];
164
341
  if (!payload)
165
342
  return null;
166
343
  try {
167
344
  const parsed = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
168
- const sub = typeof parsed === 'object' && parsed !== null ? Reflect.get(parsed, 'sub') : null;
169
- return typeof sub === 'string' && sub ? sub : null;
345
+ return typeof parsed === 'object' && parsed !== null ? parsed : null;
170
346
  }
171
347
  catch {
172
348
  return null;
173
349
  }
174
350
  }
351
+ /**
352
+ * Refuse to persist a token minted for another audience or Auth0
353
+ * organization. Auth0/server-side JWT verification remains authoritative;
354
+ * this local decode is only a cross-realm confusion check on a token received
355
+ * directly from the configured Auth0 tenant over TLS.
356
+ */
357
+ export function verifyTokenMatchesRealm({ accessToken, realm, requireOrganization = true, }) {
358
+ const claims = decodeJwtClaims(accessToken);
359
+ if (!claims)
360
+ return; // Opaque or undecodable token: nothing to check locally.
361
+ const aud = Reflect.get(claims, 'aud');
362
+ const audiences = Array.isArray(aud)
363
+ ? aud.filter((value) => typeof value === 'string')
364
+ : typeof aud === 'string'
365
+ ? [aud]
366
+ : [];
367
+ if (!audiences.includes(realm.audience)) {
368
+ throw new Error(`Auth0 returned a token for audience ${JSON.stringify(audiences)}, expected ` +
369
+ `'${realm.audience}' — refusing to store it. Check the '${realm.name}' ` +
370
+ 'environment registration (seqapi env add).');
371
+ }
372
+ const organization = Reflect.get(claims, 'org_id');
373
+ if (requireOrganization && realm.organization && organization !== realm.organization) {
374
+ throw new Error(`Auth0 returned a token for organization ${JSON.stringify(organization)}, ` +
375
+ `expected '${realm.organization}' — refusing to store it.`);
376
+ }
377
+ }
378
+ export function decodeJwtSub(token) {
379
+ const claims = decodeJwtClaims(token);
380
+ const sub = claims ? Reflect.get(claims, 'sub') : null;
381
+ return typeof sub === 'string' && sub ? sub : null;
382
+ }
175
383
  /**
176
384
  * The Auth0 subject the CLI would authenticate as right now, without any
177
- * network call: the fixed M2M client subject when the secret is configured,
178
- * else the `sub` of the cached user token, else null (anonymous).
385
+ * network call: the requested realm's M2M client subject when its secret is
386
+ * configured, else the `sub` of that realm's cached user token, else null.
179
387
  */
180
- export async function currentIdentitySubject() {
181
- if (process.env.AUTH0_M2M_CLIENT_SECRET?.trim()) {
182
- return `${AUTH0_M2M_CLIENT_ID}@clients`;
388
+ export async function currentIdentitySubject(options = {}) {
389
+ const realm = await realmForEnv(options.env);
390
+ if (realm.m2mClientId && process.env[m2mSecretEnvName(realm)]?.trim()) {
391
+ return `${realm.m2mClientId}@clients`;
183
392
  }
184
- const tokens = await loadTokens();
393
+ const tokens = await loadCachedUserTokens(realm.name);
185
394
  if (!tokens?.access_token)
186
395
  return null;
396
+ verifyTokenMatchesRealm({ accessToken: tokens.access_token, realm });
187
397
  return decodeJwtSub(tokens.access_token);
188
398
  }
189
- async function loadTokens() {
399
+ async function readTokenFile() {
190
400
  const path = seqapiTokenPath();
191
401
  if (!existsSync(path))
192
402
  return null;
@@ -197,14 +407,99 @@ async function loadTokens() {
197
407
  return null;
198
408
  }
199
409
  }
200
- export async function saveTokens(tokens) {
410
+ function stripPersistedRefreshTokens(file) {
411
+ let changed = false;
412
+ if ('refresh_token' in file) {
413
+ delete file.refresh_token;
414
+ changed = true;
415
+ }
416
+ if (file.realms) {
417
+ for (const entry of Object.values(file.realms)) {
418
+ if ('refresh_token' in entry) {
419
+ delete entry.refresh_token;
420
+ changed = true;
421
+ }
422
+ }
423
+ }
424
+ return changed;
425
+ }
426
+ export async function loadCachedUserTokens(realmName = SEQUENCE_REALM) {
427
+ if (!existsSync(seqapiTokenPath()))
428
+ return null;
429
+ return withTokenFileLock(async () => {
430
+ const file = await readTokenFile();
431
+ if (!file)
432
+ return null;
433
+ if (stripPersistedRefreshTokens(file)) {
434
+ await writeTokenFile(file);
435
+ }
436
+ const entry = realmName === SEQUENCE_REALM ? file : file.realms?.[realmName];
437
+ if (!entry?.access_token)
438
+ return null;
439
+ return {
440
+ access_token: entry.access_token,
441
+ expires_at: entry.expires_at,
442
+ };
443
+ });
444
+ }
445
+ async function withTokenFileLock(operation) {
446
+ return withCrossProcessFileLock({ operation, path: seqapiTokenPath() });
447
+ }
448
+ async function writeTokenFile(tokens) {
201
449
  const path = seqapiTokenPath();
202
- await mkdir(dirname(path), { recursive: true });
203
- // Mirror seqapi's atomic-write pattern: write to tmpfile then rename.
204
- const tmp = path + '.tmp';
205
- await writeFile(tmp, JSON.stringify(tokens, null, 2), { encoding: 'utf8', mode: 0o600 });
206
- // writeFile's mode only applies when creating a file. Reset it explicitly in
207
- // case a prior interrupted login left a permissive tmp file behind.
208
- await chmod(tmp, 0o600);
209
- await rename(tmp, path);
450
+ // Use a process-unique temp file before the atomic rename. Both CLIs share
451
+ // this protocol and may update the file at the same time.
452
+ const tmp = `${path}.${process.pid}.${randomUUID()}.tmp`;
453
+ try {
454
+ await writeFile(tmp, JSON.stringify(tokens, null, 2), {
455
+ encoding: 'utf8',
456
+ mode: 0o600,
457
+ flag: 'wx',
458
+ });
459
+ await chmod(tmp, 0o600);
460
+ await rename(tmp, path);
461
+ }
462
+ finally {
463
+ try {
464
+ await unlink(tmp);
465
+ }
466
+ catch (error) {
467
+ if (!hasErrorCode(error, 'ENOENT'))
468
+ throw error;
469
+ }
470
+ }
471
+ }
472
+ export async function saveTokens(tokens, realmName = SEQUENCE_REALM) {
473
+ await withTokenFileLock(async () => {
474
+ // Merge while holding the cross-process lock so concurrent seqapi and
475
+ // seq-studio logins cannot discard another realm's session.
476
+ const existing = (await readTokenFile()) ?? {};
477
+ stripPersistedRefreshTokens(existing);
478
+ const merged = realmName === SEQUENCE_REALM
479
+ ? { ...existing, ...tokens }
480
+ : { ...existing, realms: { ...existing.realms, [realmName]: tokens } };
481
+ await writeTokenFile(merged);
482
+ });
483
+ }
484
+ export async function deleteRealmTokens(realmName) {
485
+ await withTokenFileLock(async () => {
486
+ const existing = (await readTokenFile()) ?? {};
487
+ stripPersistedRefreshTokens(existing);
488
+ const updated = { ...existing };
489
+ if (realmName === SEQUENCE_REALM) {
490
+ delete updated.access_token;
491
+ delete updated.expires_at;
492
+ }
493
+ else {
494
+ const realms = { ...existing.realms };
495
+ delete realms[realmName];
496
+ if (Object.keys(realms).length > 0) {
497
+ updated.realms = realms;
498
+ }
499
+ else {
500
+ delete updated.realms;
501
+ }
502
+ }
503
+ await writeTokenFile(updated);
504
+ });
210
505
  }
package/dist/config.d.ts CHANGED
@@ -26,10 +26,10 @@ export declare function defaultConfig(): LatticeConfig;
26
26
  * 1. built-in `local`
27
27
  * 2. the cached discovered catalog (`~/.config/lattice/environments.json`)
28
28
  * 3. user entries in config.toml (an override for `local`, or net-new envs)
29
+ * 4. OpCo registrations shared with seqapi (`~/.config/sequence-api/config.json`)
29
30
  *
30
- * If the config.toml file does not exist, defaults are returned — no
31
- * auto-write, since seq-studio should work out of the box without any
32
- * state on disk.
31
+ * Registered OpCo routes are authoritative for their names so a lower-trust
32
+ * config.toml override cannot send a tenant token to another origin.
33
33
  */
34
34
  export declare function readConfig(): Promise<LatticeConfig>;
35
35
  /** Write the config to disk, creating the dir if missing. */
package/dist/config.js CHANGED
@@ -5,6 +5,7 @@ import { dirname, join } from 'node:path';
5
5
  import { parse as parseToml, stringify as stringifyToml } from 'smol-toml';
6
6
  import { M2mTokenError } from './auth.js';
7
7
  import { fetchCatalog, readCachedCatalog, } from './env-catalog.js';
8
+ import { readRegisteredEnvironmentUrls } from './env-registry.js';
8
9
  import { resolvePreviewFromSlug } from './preview.js';
9
10
  /**
10
11
  * `--env preview:<branch-or-slug>` targets a per-PR Vercel preview deployment
@@ -45,10 +46,10 @@ export function defaultConfig() {
45
46
  * 1. built-in `local`
46
47
  * 2. the cached discovered catalog (`~/.config/lattice/environments.json`)
47
48
  * 3. user entries in config.toml (an override for `local`, or net-new envs)
49
+ * 4. OpCo registrations shared with seqapi (`~/.config/sequence-api/config.json`)
48
50
  *
49
- * If the config.toml file does not exist, defaults are returned — no
50
- * auto-write, since seq-studio should work out of the box without any
51
- * state on disk.
51
+ * Registered OpCo routes are authoritative for their names so a lower-trust
52
+ * config.toml override cannot send a tenant token to another origin.
52
53
  */
53
54
  export async function readConfig() {
54
55
  const merged = defaultConfig();
@@ -60,18 +61,22 @@ export async function readConfig() {
60
61
  }
61
62
  }
62
63
  const path = configPath();
63
- if (!existsSync(path))
64
- return merged;
65
- const raw = parseToml(await readFile(path, 'utf8'));
66
- if (raw.env) {
67
- for (const [name, value] of Object.entries(raw.env)) {
68
- if (typeof value?.url === 'string') {
69
- merged.envs[name] = { url: value.url };
64
+ if (existsSync(path)) {
65
+ const raw = parseToml(await readFile(path, 'utf8'));
66
+ if (raw.env) {
67
+ for (const [name, value] of Object.entries(raw.env)) {
68
+ if (typeof value?.url === 'string') {
69
+ merged.envs[name] = { url: value.url };
70
+ }
70
71
  }
71
72
  }
73
+ if (typeof raw.default_env === 'string') {
74
+ merged.defaultEnv = raw.default_env;
75
+ }
72
76
  }
73
- if (typeof raw.default_env === 'string') {
74
- merged.defaultEnv = raw.default_env;
77
+ const registered = await readRegisteredEnvironmentUrls();
78
+ for (const [name, value] of Object.entries(registered)) {
79
+ merged.envs[name] = value;
75
80
  }
76
81
  return merged;
77
82
  }
@@ -111,7 +116,7 @@ export function manualEnvConfigHint() {
111
116
  return (`Or add an environment manually in ${configPath()} (append-safe):\n` +
112
117
  `\n` +
113
118
  ` [env.my-env]\n` +
114
- ` url = "https://your-atlas-host.example.com"\n` +
119
+ ` url = "https://my-atlas.seqholdings.com"\n` +
115
120
  `\n` +
116
121
  `To make it the default, put \`default_env = "my-env"\` at the TOP of ` +
117
122
  `that file — before any [env.*] table.`);