@sequenceholdings/studio-cli 0.1.13 → 0.1.21

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 (63) hide show
  1. package/README.md +258 -38
  2. package/dist/agents/apply-chunks.d.ts +13 -0
  3. package/dist/agents/apply-chunks.js +43 -0
  4. package/dist/agents/commands.d.ts +10 -0
  5. package/dist/agents/commands.js +218 -0
  6. package/dist/agents/scaffold.d.ts +2 -0
  7. package/dist/agents/scaffold.js +77 -0
  8. package/dist/agents/source.d.ts +18 -0
  9. package/dist/agents/source.js +121 -0
  10. package/dist/artifact/delegate.d.ts +2 -2
  11. package/dist/artifact/delegate.js +31 -73
  12. package/dist/atlas-client.js +52 -37
  13. package/dist/auth-cmds/commands.d.ts +1 -1
  14. package/dist/auth-cmds/commands.js +12 -7
  15. package/dist/auth.d.ts +104 -24
  16. package/dist/auth.js +456 -94
  17. package/dist/config.d.ts +3 -3
  18. package/dist/config.js +18 -13
  19. package/dist/env-catalog.js +13 -3
  20. package/dist/env-flags.d.ts +2 -0
  21. package/dist/env-flags.js +2 -0
  22. package/dist/env-registry.d.ts +27 -0
  23. package/dist/env-registry.js +204 -0
  24. package/dist/envs/commands.d.ts +1 -1
  25. package/dist/envs/commands.js +41 -3
  26. package/dist/file-lock.d.ts +5 -0
  27. package/dist/file-lock.js +187 -0
  28. package/dist/functions/commands.d.ts +10 -10
  29. package/dist/functions/commands.js +87 -53
  30. package/dist/functions/manifest.d.ts +1 -0
  31. package/dist/functions/manifest.js +36 -0
  32. package/dist/functions/source-selection.d.ts +24 -0
  33. package/dist/functions/source-selection.js +67 -0
  34. package/dist/login.d.ts +8 -3
  35. package/dist/login.js +46 -34
  36. package/dist/main.d.ts +3 -1
  37. package/dist/main.js +41 -12
  38. package/dist/orm/delegate.js +25 -7
  39. package/dist/pat-hints.js +2 -2
  40. package/dist/pipeline/commands.d.ts +58 -0
  41. package/dist/pipeline/commands.js +330 -0
  42. package/dist/pipeline/lifecycle.d.ts +58 -0
  43. package/dist/pipeline/lifecycle.js +348 -0
  44. package/dist/pipeline/pinning.d.ts +5 -0
  45. package/dist/pipeline/pinning.js +9 -0
  46. package/dist/pipeline/templates.d.ts +11 -0
  47. package/dist/pipeline/templates.js +166 -0
  48. package/dist/process/build.d.ts +4 -0
  49. package/dist/process/build.js +33 -2
  50. package/dist/process/codegen.js +19 -1
  51. package/dist/process/commands.js +97 -47
  52. package/dist/process/compiler-subprocess.d.ts +29 -0
  53. package/dist/process/compiler-subprocess.js +99 -0
  54. package/dist/process/compiler-worker.d.ts +1 -0
  55. package/dist/process/compiler-worker.js +38 -0
  56. package/dist/process/lint.d.ts +8 -0
  57. package/dist/process/lint.js +84 -29
  58. package/dist/process/repo-install.js +18 -2
  59. package/dist/repos/commands.d.ts +1 -1
  60. package/dist/repos/commands.js +17 -12
  61. package/dist/secrets/commands.d.ts +1 -1
  62. package/dist/secrets/commands.js +18 -18
  63. package/package.json +12 -5
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,
@@ -9,30 +15,125 @@ import { dirname, join } from 'node:path';
9
15
  *
10
16
  * Two token sources, in the SAME precedence order as seqapi's
11
17
  * `get_access_token` (`shared/seqapi/seqapi/auth.py`):
12
- * 1. M2M service accountAuth0 client-credentials grant, used when
13
- * `AUTH0_M2M_CLIENT_SECRET` is set. This is the headless path: CI /
14
- * cloud agents with no interactive login can still push.
18
+ * 1. Cached user access token read from the seqapi token file. When it
19
+ * expires, an interactive session performs a bounded PKCE login again.
20
+ * 2. M2M service account Auth0 client-credentials grant when the realm's
21
+ * `AUTH0_M2M_CLIENT_SECRET` (or suffixed OpCo variant) is set. Used when
22
+ * no valid user session exists, or when `SEQAPI_AUTH_MODE=m2m` forces it.
15
23
  * (M2M carries app scopes but NO user identity / workspace membership
16
24
  * — 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.
19
25
  *
20
- * Login and refresh both write the shared file. This mirrors
21
- * `seqapi._save_tokens` exactly:
26
+ * Login writes the shared file. This mirrors `seqapi._save_tokens` exactly:
22
27
  * same fields, same shape, same 0o600 permissions, atomic write via
23
- * tmpfile + rename. The M2M token is in-memory only (never persisted).
28
+ * tmpfile + rename. M2M tokens are cached in-process and also under the
29
+ * token file's `m2m` key so short-lived CLI processes reuse a grant.
24
30
  */
25
31
  // Match `shared/seqapi/seqapi/config.py`. Hard-coded because the seqapi
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
- // call within a single process (e.g. a long `artifact dev` watch).
35
- let m2mCache = null;
134
+ // call within a single process (e.g. a long `artifact dev` watch). Disk
135
+ // persistence under tokens.json `m2m` covers cross-process reuse.
136
+ const m2mCache = new Map();
36
137
  /**
37
138
  * A configured M2M credential failed to mint a token. Typed so callers that
38
139
  * normally swallow discovery errors (lazy catalog refresh) can still surface
@@ -41,27 +142,78 @@ let m2mCache = null;
41
142
  export class M2mTokenError extends Error {
42
143
  name = 'M2mTokenError';
43
144
  }
145
+ function authModePrefersM2m() {
146
+ const value = process.env.SEQAPI_AUTH_MODE?.trim().toLowerCase() ?? 'auto';
147
+ return value === 'm2m';
148
+ }
149
+ function tokenStillValid(entry) {
150
+ const now = Date.now() / 1000;
151
+ return Boolean(entry?.accessToken && (entry.expiresAt ?? 0) > now + 60);
152
+ }
153
+ async function loadPersistedM2m(realmName) {
154
+ if (!existsSync(seqapiTokenPath()))
155
+ return null;
156
+ return withTokenFileLock(async () => {
157
+ const file = await readTokenFile();
158
+ const entry = file?.m2m?.[realmName];
159
+ if (!entry?.access_token)
160
+ return null;
161
+ return {
162
+ accessToken: entry.access_token,
163
+ expiresAt: entry.expires_at ?? 0,
164
+ };
165
+ });
166
+ }
167
+ async function persistM2m({ realmName, accessToken, expiresAt, }) {
168
+ await withTokenFileLock(async () => {
169
+ const existing = (await readTokenFile()) ?? {};
170
+ stripPersistedRefreshTokens(existing);
171
+ const m2m = { ...existing.m2m, [realmName]: { access_token: accessToken, expires_at: expiresAt } };
172
+ await writeTokenFile({ ...existing, m2m });
173
+ });
174
+ }
44
175
  /**
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
47
- * (so the caller falls back to the user token). Throws on a configured-but-
48
- * rejected secret, mirroring seqapi's `_get_m2m_token` (`raise_for_status`).
176
+ * Mint an M2M access token via the Auth0 client-credentials grant when the
177
+ * realm's secret env var is set. Returns null when the secret is unset.
178
+ * Throws on a configured-but-rejected secret, mirroring seqapi's
179
+ * `_get_m2m_token`. Cache order: in-process shared token file → Auth0.
180
+ * The realm's own secret var is required — a Sequence secret in the shell is
181
+ * never sent to a tenant's Auth0 client.
49
182
  */
50
- async function getM2mToken() {
51
- const clientSecret = process.env.AUTH0_M2M_CLIENT_SECRET;
183
+ async function getM2mToken(realm = SEQUENCE_AUTH_REALM) {
184
+ if (!realm.m2mClientId)
185
+ return null;
186
+ const clientSecret = process.env[m2mSecretEnvName(realm)];
52
187
  if (!clientSecret)
53
188
  return null;
54
- 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`, {
189
+ const cached = m2mCache.get(realm.name);
190
+ if (cached && tokenStillValid(cached)) {
191
+ verifyTokenMatchesRealm({
192
+ accessToken: cached.accessToken,
193
+ realm,
194
+ requireOrganization: false,
195
+ });
196
+ return cached.accessToken;
197
+ }
198
+ const persisted = await loadPersistedM2m(realm.name);
199
+ if (persisted && tokenStillValid(persisted)) {
200
+ verifyTokenMatchesRealm({
201
+ accessToken: persisted.accessToken,
202
+ realm,
203
+ requireOrganization: false,
204
+ });
205
+ m2mCache.set(realm.name, persisted);
206
+ return persisted.accessToken;
207
+ }
208
+ const response = await fetch(`https://${realm.domain}/oauth/token`, {
58
209
  method: 'POST',
210
+ redirect: 'manual',
59
211
  headers: { 'Content-Type': 'application/json' },
60
212
  body: JSON.stringify({
61
213
  grant_type: 'client_credentials',
62
- client_id: AUTH0_M2M_CLIENT_ID,
214
+ client_id: realm.m2mClientId,
63
215
  client_secret: clientSecret,
64
- audience: AUTH0_AUDIENCE,
216
+ audience: realm.audience,
65
217
  }),
66
218
  });
67
219
  if (!response.ok) {
@@ -71,11 +223,22 @@ async function getM2mToken() {
71
223
  if (!data.access_token) {
72
224
  throw new M2mTokenError('Auth0 M2M response missing access_token');
73
225
  }
74
- m2mCache = {
226
+ verifyTokenMatchesRealm({
227
+ accessToken: data.access_token,
228
+ realm,
229
+ requireOrganization: false,
230
+ });
231
+ const entry = {
75
232
  accessToken: data.access_token,
76
233
  expiresAt: Date.now() / 1000 + (data.expires_in ?? 7200),
77
234
  };
78
- return m2mCache.accessToken;
235
+ m2mCache.set(realm.name, entry);
236
+ await persistM2m({
237
+ realmName: realm.name,
238
+ accessToken: entry.accessToken,
239
+ expiresAt: entry.expiresAt,
240
+ });
241
+ return entry.accessToken;
79
242
  }
80
243
  export function seqapiTokenDir() {
81
244
  return join(homedir(), '.config', 'sequence-api');
@@ -85,108 +248,214 @@ export function seqapiTokenPath() {
85
248
  }
86
249
  export class NotLoggedInError extends Error {
87
250
  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 ' +
251
+ constructor(realmName = SEQUENCE_REALM, reason) {
252
+ const loginHint = realmName === SEQUENCE_REALM
253
+ ? 'Run: seq-studio login'
254
+ : `Run: seq-studio login --env ${realmName} (or: seqapi login -e ${realmName})`;
255
+ const secretHint = realmName === SEQUENCE_REALM
256
+ ? 'AUTH0_M2M_CLIENT_SECRET'
257
+ : `AUTH0_M2M_CLIENT_SECRET_${realmName.toUpperCase().replaceAll('-', '_')}`;
258
+ super(`${reason ?? `Not logged in [${realmName}].`} ${loginHint}\n` +
259
+ `For headless contexts (CI / cloud agents), set ${secretHint} for ` +
91
260
  'service-account (M2M) access instead.');
92
261
  }
93
262
  }
94
263
  /**
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.
264
+ * Return a valid access token for an environment's auth realm.
265
+ *
266
+ * Precedence (default `SEQAPI_AUTH_MODE=auto`), matching seqapi:
267
+ * 1. Valid cached user access token
268
+ * 2. M2M client-credentials when the realm's secret env var is set
269
+ * 3. Bounded PKCE login when browser auto-login is enabled
270
+ *
271
+ * Set `SEQAPI_AUTH_MODE=m2m` to skip the user token. No `env` (or a built-in
272
+ * Sequence env) means the shared Sequence realm.
100
273
  */
101
- export async function getAccessToken() {
102
- const m2m = await getM2mToken();
103
- if (m2m)
104
- return m2m;
105
- const tokens = await loadTokens();
106
- if (!tokens)
107
- throw new NotLoggedInError();
108
- const now = Date.now() / 1000;
109
- if (tokens.access_token && (tokens.expires_at ?? 0) > now + 60) {
110
- return tokens.access_token;
274
+ export class UnsafeAuthTargetError extends Error {
275
+ }
276
+ const AUTO_LOGIN_TIMEOUT_MS = 120_000;
277
+ function autoLoginEnabled() {
278
+ const value = process.env.SEQAPI_AUTO_LOGIN?.trim().toLowerCase() ?? '1';
279
+ return !['0', 'false', 'no', 'off'].includes(value);
280
+ }
281
+ function autoLoginTimeoutMs() {
282
+ const raw = process.env.SEQAPI_AUTO_LOGIN_TIMEOUT?.trim();
283
+ if (!raw)
284
+ return AUTO_LOGIN_TIMEOUT_MS;
285
+ const seconds = Number(raw);
286
+ return Number.isFinite(seconds) && seconds > 0 ? seconds * 1_000 : AUTO_LOGIN_TIMEOUT_MS;
287
+ }
288
+ async function reauthenticateOrThrow({ reason, realm, }) {
289
+ if (!autoLoginEnabled()) {
290
+ throw new NotLoggedInError(realm.name, reason);
111
291
  }
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');
292
+ try {
293
+ const { loginWithPkce } = await import('./login.js');
294
+ return await loginWithPkce({ realm, timeoutMs: autoLoginTimeoutMs() });
125
295
  }
126
- if (!response.ok) {
127
- throw new Error(`Auth0 token refresh failed (${response.status}): ${await response.text()}`);
296
+ catch (error) {
297
+ throw new NotLoggedInError(realm.name, `${reason} Automatic re-login failed: ${error instanceof Error ? error.message : String(error)}`);
128
298
  }
129
- const refreshed = (await response.json());
130
- if (!refreshed.access_token) {
131
- throw new Error('Auth0 refresh response missing access_token');
299
+ }
300
+ function validateRealmTarget({ realm, targetUrl, }) {
301
+ try {
302
+ const baseUrl = validateDeploymentBaseUrl(targetUrl);
303
+ if (realm.name !== SEQUENCE_REALM) {
304
+ validateDeploymentAudience({ audience: realm.audience, baseUrl });
305
+ }
306
+ }
307
+ catch (error) {
308
+ throw new UnsafeAuthTargetError(`Refusing to send '${realm.name}' credentials to ${targetUrl}: ` +
309
+ `${error instanceof Error ? error.message : String(error)}`, { cause: error });
310
+ }
311
+ }
312
+ export async function getAccessTokenWithMode(options) {
313
+ const realm = await realmForEnv(options?.env);
314
+ if (options?.targetUrl)
315
+ validateRealmTarget({ realm, targetUrl: options.targetUrl });
316
+ const forceM2m = authModePrefersM2m();
317
+ if (!forceM2m) {
318
+ const tokens = await loadCachedUserTokens(realm.name);
319
+ const now = Date.now() / 1000;
320
+ if (tokens?.access_token && (tokens.expires_at ?? 0) > now + 60) {
321
+ verifyTokenMatchesRealm({ accessToken: tokens.access_token, realm });
322
+ return { authMode: 'user', token: tokens.access_token };
323
+ }
324
+ }
325
+ const m2m = await getM2mToken(realm);
326
+ if (m2m)
327
+ return { authMode: 'm2m', token: m2m };
328
+ if (forceM2m) {
329
+ throw new NotLoggedInError(realm.name, `SEQAPI_AUTH_MODE=m2m but no M2M credentials for [${realm.name}].`);
330
+ }
331
+ const tokens = await loadCachedUserTokens(realm.name);
332
+ if (!tokens) {
333
+ if (options?.allowInteractiveLogin === false) {
334
+ throw new NotLoggedInError(realm.name);
335
+ }
336
+ return {
337
+ authMode: 'user',
338
+ token: await reauthenticateOrThrow({
339
+ reason: `Not logged in [${realm.name}].`,
340
+ realm,
341
+ }),
342
+ };
132
343
  }
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),
344
+ if (options?.allowInteractiveLogin === false) {
345
+ throw new NotLoggedInError(realm.name, `Access token expired [${realm.name}].`);
346
+ }
347
+ return {
348
+ authMode: 'user',
349
+ token: await reauthenticateOrThrow({
350
+ reason: `Access token expired [${realm.name}].`,
351
+ realm,
352
+ }),
137
353
  };
138
- await saveTokens(updated);
139
- return updated.access_token;
354
+ }
355
+ export async function getAccessToken(options) {
356
+ return (await getAccessTokenWithMode(options)).token;
140
357
  }
141
358
  /**
142
359
  * Try to load a token without throwing. Returns `null` for any error
143
360
  * condition (missing file, parse error, no token fields). Used by
144
361
  * `doctor` for non-fatal "are you logged in?" checks.
145
362
  */
146
- export async function tryGetAccessToken(options) {
363
+ export async function tryGetAccessTokenWithMode(options) {
147
364
  try {
148
- return await getAccessToken();
365
+ return await getAccessTokenWithMode({
366
+ allowInteractiveLogin: false,
367
+ env: options?.env,
368
+ targetUrl: options?.targetUrl,
369
+ });
149
370
  }
150
371
  catch (err) {
151
- if (options?.failClosedForM2m && process.env.AUTH0_M2M_CLIENT_SECRET?.trim()) {
372
+ if (err instanceof UnsafeAuthTargetError)
152
373
  throw err;
374
+ if (options?.failClosedForM2m) {
375
+ // Do not replace an invalid tenant registration with the Sequence realm:
376
+ // that could hide a rejected discovery token host or inspect the wrong
377
+ // M2M secret. Config-resolution errors must surface unchanged.
378
+ const realm = await realmForEnv(options.env);
379
+ if (process.env[m2mSecretEnvName(realm)]?.trim())
380
+ throw err;
153
381
  }
154
382
  return null;
155
383
  }
156
384
  }
385
+ export async function tryGetAccessToken(options) {
386
+ return (await tryGetAccessTokenWithMode(options))?.token ?? null;
387
+ }
157
388
  /**
158
389
  * Decode the `sub` claim from a JWT without verification. Verification is the
159
390
  * server's job — this is only used to compare identities locally (e.g. to
160
391
  * bind the cached environment catalog to the identity that fetched it).
161
392
  */
162
- export function decodeJwtSub(token) {
393
+ function decodeJwtClaims(token) {
163
394
  const payload = token.split('.')[1];
164
395
  if (!payload)
165
396
  return null;
166
397
  try {
167
398
  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;
399
+ return typeof parsed === 'object' && parsed !== null ? parsed : null;
170
400
  }
171
401
  catch {
172
402
  return null;
173
403
  }
174
404
  }
405
+ /**
406
+ * Refuse to persist a token minted for another audience or Auth0
407
+ * organization. Auth0/server-side JWT verification remains authoritative;
408
+ * this local decode is only a cross-realm confusion check on a token received
409
+ * directly from the configured Auth0 tenant over TLS.
410
+ */
411
+ export function verifyTokenMatchesRealm({ accessToken, realm, requireOrganization = true, }) {
412
+ const claims = decodeJwtClaims(accessToken);
413
+ if (!claims)
414
+ return; // Opaque or undecodable token: nothing to check locally.
415
+ const aud = Reflect.get(claims, 'aud');
416
+ const audiences = Array.isArray(aud)
417
+ ? aud.filter((value) => typeof value === 'string')
418
+ : typeof aud === 'string'
419
+ ? [aud]
420
+ : [];
421
+ if (!audiences.includes(realm.audience)) {
422
+ throw new Error(`Auth0 returned a token for audience ${JSON.stringify(audiences)}, expected ` +
423
+ `'${realm.audience}' — refusing to store it. Check the '${realm.name}' ` +
424
+ 'environment registration (seqapi env add).');
425
+ }
426
+ const organization = Reflect.get(claims, 'org_id');
427
+ if (requireOrganization && realm.organization && organization !== realm.organization) {
428
+ throw new Error(`Auth0 returned a token for organization ${JSON.stringify(organization)}, ` +
429
+ `expected '${realm.organization}' — refusing to store it.`);
430
+ }
431
+ }
432
+ export function decodeJwtSub(token) {
433
+ const claims = decodeJwtClaims(token);
434
+ const sub = claims ? Reflect.get(claims, 'sub') : null;
435
+ return typeof sub === 'string' && sub ? sub : null;
436
+ }
175
437
  /**
176
438
  * 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).
439
+ * network call. Matches token resolution precedence: a valid user session
440
+ * wins over an ambient M2M secret unless `SEQAPI_AUTH_MODE=m2m`.
179
441
  */
180
- export async function currentIdentitySubject() {
181
- if (process.env.AUTH0_M2M_CLIENT_SECRET?.trim()) {
182
- return `${AUTH0_M2M_CLIENT_ID}@clients`;
442
+ export async function currentIdentitySubject(options = {}) {
443
+ const realm = await realmForEnv(options.env);
444
+ const forceM2m = authModePrefersM2m();
445
+ if (!forceM2m) {
446
+ const tokens = await loadCachedUserTokens(realm.name);
447
+ const now = Date.now() / 1000;
448
+ if (tokens?.access_token && (tokens.expires_at ?? 0) > now + 60) {
449
+ verifyTokenMatchesRealm({ accessToken: tokens.access_token, realm });
450
+ return decodeJwtSub(tokens.access_token);
451
+ }
183
452
  }
184
- const tokens = await loadTokens();
185
- if (!tokens?.access_token)
186
- return null;
187
- return decodeJwtSub(tokens.access_token);
453
+ if (realm.m2mClientId && process.env[m2mSecretEnvName(realm)]?.trim()) {
454
+ return `${realm.m2mClientId}@clients`;
455
+ }
456
+ return null;
188
457
  }
189
- async function loadTokens() {
458
+ async function readTokenFile() {
190
459
  const path = seqapiTokenPath();
191
460
  if (!existsSync(path))
192
461
  return null;
@@ -197,14 +466,107 @@ async function loadTokens() {
197
466
  return null;
198
467
  }
199
468
  }
200
- export async function saveTokens(tokens) {
469
+ function stripPersistedRefreshTokens(file) {
470
+ let changed = false;
471
+ if ('refresh_token' in file) {
472
+ delete file.refresh_token;
473
+ changed = true;
474
+ }
475
+ if (file.realms) {
476
+ for (const entry of Object.values(file.realms)) {
477
+ if ('refresh_token' in entry) {
478
+ delete entry.refresh_token;
479
+ changed = true;
480
+ }
481
+ }
482
+ }
483
+ return changed;
484
+ }
485
+ export async function loadCachedUserTokens(realmName = SEQUENCE_REALM) {
486
+ if (!existsSync(seqapiTokenPath()))
487
+ return null;
488
+ return withTokenFileLock(async () => {
489
+ const file = await readTokenFile();
490
+ if (!file)
491
+ return null;
492
+ if (stripPersistedRefreshTokens(file)) {
493
+ await writeTokenFile(file);
494
+ }
495
+ const entry = realmName === SEQUENCE_REALM ? file : file.realms?.[realmName];
496
+ if (!entry?.access_token)
497
+ return null;
498
+ return {
499
+ access_token: entry.access_token,
500
+ expires_at: entry.expires_at,
501
+ };
502
+ });
503
+ }
504
+ async function withTokenFileLock(operation) {
505
+ return withCrossProcessFileLock({ operation, path: seqapiTokenPath() });
506
+ }
507
+ async function writeTokenFile(tokens) {
201
508
  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);
509
+ // Use a process-unique temp file before the atomic rename. Both CLIs share
510
+ // this protocol and may update the file at the same time.
511
+ const tmp = `${path}.${process.pid}.${randomUUID()}.tmp`;
512
+ try {
513
+ await writeFile(tmp, JSON.stringify(tokens, null, 2), {
514
+ encoding: 'utf8',
515
+ mode: 0o600,
516
+ flag: 'wx',
517
+ });
518
+ await chmod(tmp, 0o600);
519
+ await rename(tmp, path);
520
+ }
521
+ finally {
522
+ try {
523
+ await unlink(tmp);
524
+ }
525
+ catch (error) {
526
+ if (!hasErrorCode(error, 'ENOENT'))
527
+ throw error;
528
+ }
529
+ }
530
+ }
531
+ export async function saveTokens(tokens, realmName = SEQUENCE_REALM) {
532
+ await withTokenFileLock(async () => {
533
+ // Merge while holding the cross-process lock so concurrent seqapi and
534
+ // seq-studio logins cannot discard another realm's session.
535
+ const existing = (await readTokenFile()) ?? {};
536
+ stripPersistedRefreshTokens(existing);
537
+ const merged = realmName === SEQUENCE_REALM
538
+ ? { ...existing, ...tokens }
539
+ : { ...existing, realms: { ...existing.realms, [realmName]: tokens } };
540
+ await writeTokenFile(merged);
541
+ });
542
+ }
543
+ export async function deleteRealmTokens(realmName) {
544
+ await withTokenFileLock(async () => {
545
+ const existing = (await readTokenFile()) ?? {};
546
+ stripPersistedRefreshTokens(existing);
547
+ const updated = { ...existing };
548
+ if (realmName === SEQUENCE_REALM) {
549
+ delete updated.access_token;
550
+ delete updated.expires_at;
551
+ }
552
+ else {
553
+ const realms = { ...existing.realms };
554
+ delete realms[realmName];
555
+ if (Object.keys(realms).length > 0) {
556
+ updated.realms = realms;
557
+ }
558
+ else {
559
+ delete updated.realms;
560
+ }
561
+ }
562
+ const m2m = { ...existing.m2m };
563
+ delete m2m[realmName];
564
+ if (Object.keys(m2m).length > 0) {
565
+ updated.m2m = m2m;
566
+ }
567
+ else {
568
+ delete updated.m2m;
569
+ }
570
+ await writeTokenFile(updated);
571
+ });
210
572
  }
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. */