@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.
- package/README.md +258 -38
- package/dist/agents/apply-chunks.d.ts +13 -0
- package/dist/agents/apply-chunks.js +43 -0
- package/dist/agents/commands.d.ts +10 -0
- package/dist/agents/commands.js +218 -0
- package/dist/agents/scaffold.d.ts +2 -0
- package/dist/agents/scaffold.js +77 -0
- package/dist/agents/source.d.ts +18 -0
- package/dist/agents/source.js +121 -0
- 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 +104 -24
- package/dist/auth.js +456 -94
- 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 +10 -10
- package/dist/functions/commands.js +87 -53
- package/dist/functions/manifest.d.ts +1 -0
- package/dist/functions/manifest.js +36 -0
- package/dist/functions/source-selection.d.ts +24 -0
- package/dist/functions/source-selection.js +67 -0
- package/dist/login.d.ts +8 -3
- package/dist/login.js +46 -34
- package/dist/main.d.ts +3 -1
- package/dist/main.js +41 -12
- package/dist/orm/delegate.js +25 -7
- 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 +33 -2
- 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 +84 -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 +12 -5
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,
|
|
@@ -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.
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
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
|
-
|
|
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
|
|
55
|
-
if (
|
|
56
|
-
|
|
57
|
-
|
|
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:
|
|
214
|
+
client_id: realm.m2mClientId,
|
|
63
215
|
client_secret: clientSecret,
|
|
64
|
-
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
90
|
-
'
|
|
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
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
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
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
const
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
127
|
-
throw new
|
|
296
|
+
catch (error) {
|
|
297
|
+
throw new NotLoggedInError(realm.name, `${reason} Automatic re-login failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
128
298
|
}
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
|
|
139
|
-
|
|
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
|
|
363
|
+
export async function tryGetAccessTokenWithMode(options) {
|
|
147
364
|
try {
|
|
148
|
-
return await
|
|
365
|
+
return await getAccessTokenWithMode({
|
|
366
|
+
allowInteractiveLogin: false,
|
|
367
|
+
env: options?.env,
|
|
368
|
+
targetUrl: options?.targetUrl,
|
|
369
|
+
});
|
|
149
370
|
}
|
|
150
371
|
catch (err) {
|
|
151
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
178
|
-
*
|
|
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
|
-
|
|
182
|
-
|
|
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
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
return
|
|
453
|
+
if (realm.m2mClientId && process.env[m2mSecretEnvName(realm)]?.trim()) {
|
|
454
|
+
return `${realm.m2mClientId}@clients`;
|
|
455
|
+
}
|
|
456
|
+
return null;
|
|
188
457
|
}
|
|
189
|
-
async function
|
|
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
|
-
|
|
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
|
-
|
|
203
|
-
//
|
|
204
|
-
const tmp = path
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
*
|
|
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. */
|