@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.
- package/README.md +94 -30
- package/dist/artifact/delegate.d.ts +2 -2
- package/dist/artifact/delegate.js +31 -73
- package/dist/atlas-client.js +52 -37
- package/dist/auth-cmds/commands.d.ts +1 -1
- package/dist/auth-cmds/commands.js +12 -7
- package/dist/auth.d.ts +97 -19
- package/dist/auth.js +376 -81
- package/dist/config.d.ts +3 -3
- package/dist/config.js +18 -13
- package/dist/env-catalog.js +13 -3
- package/dist/env-flags.d.ts +2 -0
- package/dist/env-flags.js +2 -0
- package/dist/env-registry.d.ts +27 -0
- package/dist/env-registry.js +204 -0
- package/dist/envs/commands.d.ts +1 -1
- package/dist/envs/commands.js +41 -3
- package/dist/file-lock.d.ts +5 -0
- package/dist/file-lock.js +187 -0
- package/dist/functions/commands.d.ts +9 -1
- package/dist/functions/commands.js +71 -29
- package/dist/functions/manifest.d.ts +1 -0
- package/dist/functions/manifest.js +36 -0
- package/dist/login.d.ts +8 -3
- package/dist/login.js +46 -34
- package/dist/main.d.ts +2 -1
- package/dist/main.js +35 -12
- package/dist/orm/delegate.js +15 -2
- package/dist/pat-hints.js +2 -2
- package/dist/pipeline/commands.d.ts +58 -0
- package/dist/pipeline/commands.js +330 -0
- package/dist/pipeline/lifecycle.d.ts +58 -0
- package/dist/pipeline/lifecycle.js +348 -0
- package/dist/pipeline/pinning.d.ts +5 -0
- package/dist/pipeline/pinning.js +9 -0
- package/dist/pipeline/templates.d.ts +11 -0
- package/dist/pipeline/templates.js +166 -0
- package/dist/process/build.d.ts +4 -0
- package/dist/process/build.js +31 -1
- package/dist/process/codegen.js +19 -1
- package/dist/process/commands.js +97 -47
- package/dist/process/compiler-subprocess.d.ts +29 -0
- package/dist/process/compiler-subprocess.js +99 -0
- package/dist/process/compiler-worker.d.ts +1 -0
- package/dist/process/compiler-worker.js +38 -0
- package/dist/process/lint.d.ts +8 -0
- package/dist/process/lint.js +76 -29
- package/dist/process/repo-install.js +18 -2
- package/dist/repos/commands.d.ts +1 -1
- package/dist/repos/commands.js +17 -12
- package/dist/secrets/commands.d.ts +1 -1
- package/dist/secrets/commands.js +18 -18
- package/package.json +8 -3
package/dist/auth.js
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
1
|
-
import {
|
|
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 {
|
|
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
|
|
18
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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:
|
|
174
|
+
client_id: realm.m2mClientId,
|
|
63
175
|
client_secret: clientSecret,
|
|
64
|
-
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
90
|
-
'
|
|
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
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
* resolve the same identity for the same
|
|
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
|
|
102
|
-
|
|
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
|
|
106
|
-
if (!tokens)
|
|
107
|
-
|
|
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
|
-
|
|
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
|
-
|
|
130
|
-
|
|
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
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
293
|
+
return {
|
|
294
|
+
authMode: 'user',
|
|
295
|
+
token: await reauthenticateOrThrow({
|
|
296
|
+
reason: `Access token expired [${realm.name}].`,
|
|
297
|
+
realm,
|
|
298
|
+
}),
|
|
137
299
|
};
|
|
138
|
-
|
|
139
|
-
|
|
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
|
|
309
|
+
export async function tryGetAccessTokenWithMode(options) {
|
|
147
310
|
try {
|
|
148
|
-
return await
|
|
311
|
+
return await getAccessTokenWithMode({
|
|
312
|
+
allowInteractiveLogin: false,
|
|
313
|
+
env: options?.env,
|
|
314
|
+
targetUrl: options?.targetUrl,
|
|
315
|
+
});
|
|
149
316
|
}
|
|
150
317
|
catch (err) {
|
|
151
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
178
|
-
* else the `sub` of
|
|
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
|
-
|
|
182
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
203
|
-
//
|
|
204
|
-
const tmp = path
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
*
|
|
31
|
-
*
|
|
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
|
-
*
|
|
50
|
-
*
|
|
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 (
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
74
|
-
|
|
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://
|
|
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.`);
|