@sequenceholdings/studio-cli 0.1.24 → 0.1.26

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/auth.js CHANGED
@@ -40,6 +40,12 @@ const AUTH0_M2M_CLIENT_ID = '5TLffZqvLq4ztLDjZhwKVJCpB5VNrWj0';
40
40
  * also the implicit realm of the legacy flat fields in tokens.json. */
41
41
  export const SEQUENCE_REALM = 'sequence';
42
42
  const SEQUENCE_BUILTIN_ENVS = new Set(['local', 'staging', 'production', 'banksouth']);
43
+ const LOOPBACK_HOSTNAMES = new Set(['localhost', '127.0.0.1', '::1']);
44
+ const SEQUENCE_STAGING_AUTH0_AUDIENCE = 'https://staging.sequence.seqholdings.com/api';
45
+ const TRUSTED_LOOPBACK_AUTH0_AUDIENCES = new Set([
46
+ AUTH0_AUDIENCE,
47
+ SEQUENCE_STAGING_AUTH0_AUDIENCE,
48
+ ]);
43
49
  export function isSequenceAuthEnvName(envName) {
44
50
  return (envName === SEQUENCE_REALM ||
45
51
  SEQUENCE_BUILTIN_ENVS.has(envName) ||
@@ -76,14 +82,116 @@ export const SEQUENCE_AUTH_REALM = {
76
82
  function seqapiConfigPath() {
77
83
  return join(seqapiTokenDir(), 'config.json');
78
84
  }
85
+ function isRecord(value) {
86
+ return typeof value === 'object' && value !== null;
87
+ }
88
+ function isLoopbackEnvName(envName) {
89
+ return envName === 'local' || envName.startsWith('local:');
90
+ }
91
+ function loopbackTargetUrl({ configuredTargetUrl, envName, }) {
92
+ if (configuredTargetUrl)
93
+ return configuredTargetUrl;
94
+ return envName === 'local' ? 'http://localhost:5001' : undefined;
95
+ }
96
+ function optionalString({ field, value, }) {
97
+ if (value === undefined || value === null)
98
+ return undefined;
99
+ if (typeof value === 'string')
100
+ return value;
101
+ throw new Error(`Local Auth0 discovery returned invalid '${field}'.`);
102
+ }
103
+ function parseDiscoveredAuth0(payload) {
104
+ const auth0 = isRecord(payload) ? Reflect.get(payload, 'auth0') : undefined;
105
+ const auth0Record = isRecord(auth0) ? auth0 : {};
106
+ const domain = Reflect.get(auth0Record, 'domain');
107
+ const clientId = Reflect.get(auth0Record, 'clientId');
108
+ const audience = Reflect.get(auth0Record, 'audience');
109
+ if (typeof domain !== 'string' ||
110
+ !domain ||
111
+ typeof clientId !== 'string' ||
112
+ !clientId ||
113
+ typeof audience !== 'string' ||
114
+ !audience) {
115
+ throw new Error('Local Auth0 discovery returned incomplete required config.');
116
+ }
117
+ return {
118
+ domain,
119
+ clientId,
120
+ audience,
121
+ organization: optionalString({
122
+ field: 'organization',
123
+ value: Reflect.get(auth0Record, 'organization'),
124
+ }),
125
+ m2mClientId: optionalString({
126
+ field: 'm2mClientId',
127
+ value: Reflect.get(auth0Record, 'm2mClientId'),
128
+ }),
129
+ };
130
+ }
131
+ async function discoverLoopbackRealm({ envName, fetchImpl = fetch, targetUrl, }) {
132
+ const baseUrl = validateDeploymentBaseUrl(targetUrl);
133
+ const hostname = new URL(baseUrl).hostname.toLowerCase();
134
+ if (!LOOPBACK_HOSTNAMES.has(hostname))
135
+ return SEQUENCE_AUTH_REALM;
136
+ let response;
137
+ try {
138
+ response = await fetchImpl(`${baseUrl}/api/auth/cli-config`, {
139
+ redirect: 'error',
140
+ signal: AbortSignal.timeout(2_000),
141
+ });
142
+ }
143
+ catch {
144
+ // Preserve the historical realm choice while local Atlas is stopped. The
145
+ // subsequent API request reports that the local server is unavailable.
146
+ return SEQUENCE_AUTH_REALM;
147
+ }
148
+ if (!response.ok) {
149
+ throw new Error(`Could not discover Auth0 config for local environment '${envName}': ` +
150
+ `${baseUrl}/api/auth/cli-config returned HTTP ${response.status}.`);
151
+ }
152
+ const payload = await response.json();
153
+ const { audience, clientId, domain, m2mClientId, organization } = parseDiscoveredAuth0(payload);
154
+ if (!TRUSTED_LOOPBACK_AUTH0_AUDIENCES.has(audience)) {
155
+ throw new Error(`Untrusted Auth0 audience '${audience}' for local environment '${envName}'.`);
156
+ }
157
+ const trustedDomain = validateAuth0Domain(domain);
158
+ if (clientId === AUTH0_CLIENT_ID && audience === AUTH0_AUDIENCE) {
159
+ return SEQUENCE_AUTH_REALM;
160
+ }
161
+ return {
162
+ name: 'local',
163
+ domain: trustedDomain,
164
+ clientId,
165
+ audience,
166
+ organization,
167
+ m2mClientId,
168
+ };
169
+ }
170
+ async function maybeDiscoverLoopbackRealm({ envName, options, }) {
171
+ if (!envName || !isLoopbackEnvName(envName))
172
+ return undefined;
173
+ const targetUrl = loopbackTargetUrl({
174
+ configuredTargetUrl: options.targetUrl,
175
+ envName,
176
+ });
177
+ if (!targetUrl)
178
+ return undefined;
179
+ return discoverLoopbackRealm({
180
+ envName,
181
+ fetchImpl: options.fetchImpl,
182
+ targetUrl,
183
+ });
184
+ }
79
185
  /**
80
186
  * Resolve the auth realm for an environment name. Undefined, built-ins, and
81
- * per-PR preview targets map to the shared Sequence realm. Preview URLs are
82
- * constrained to Sequence's preview domain by the environment resolver. Every
83
- * other explicit name must have a valid seqapi registry entry; otherwise fail
84
- * closed so a shared Sequence bearer token can never be sent to a tenant URL.
187
+ * per-PR preview targets map to the shared Sequence realm, except loopback
188
+ * targets can advertise the isolated Sequence-staging realm used by contractor
189
+ * local dev. Every other explicit name must have a valid seqapi registry entry.
85
190
  */
86
- export async function realmForEnv(envName) {
191
+ export async function realmForEnv(envName, options = {}) {
192
+ const discoveredRealm = await maybeDiscoverLoopbackRealm({ envName, options });
193
+ if (discoveredRealm)
194
+ return discoveredRealm;
87
195
  if (!envName || isSequenceAuthEnvName(envName)) {
88
196
  return SEQUENCE_AUTH_REALM;
89
197
  }
@@ -303,7 +411,12 @@ function validateRealmTarget({ realm, targetUrl, }) {
303
411
  try {
304
412
  const baseUrl = validateDeploymentBaseUrl(targetUrl);
305
413
  if (realm.name !== SEQUENCE_REALM) {
306
- validateDeploymentAudience({ audience: realm.audience, baseUrl });
414
+ const hostname = new URL(baseUrl).hostname.toLowerCase();
415
+ const isReviewedLoopbackRealm = LOOPBACK_HOSTNAMES.has(hostname) &&
416
+ TRUSTED_LOOPBACK_AUTH0_AUDIENCES.has(realm.audience);
417
+ if (!isReviewedLoopbackRealm) {
418
+ validateDeploymentAudience({ audience: realm.audience, baseUrl });
419
+ }
307
420
  }
308
421
  }
309
422
  catch (error) {
@@ -312,7 +425,7 @@ function validateRealmTarget({ realm, targetUrl, }) {
312
425
  }
313
426
  }
314
427
  export async function getAccessTokenWithMode(options) {
315
- const realm = await realmForEnv(options?.env);
428
+ const realm = await realmForEnv(options?.env, { targetUrl: options?.targetUrl });
316
429
  if (options?.targetUrl)
317
430
  validateRealmTarget({ realm, targetUrl: options.targetUrl });
318
431
  const forceM2m = authModePrefersM2m();
@@ -377,7 +490,7 @@ export async function tryGetAccessTokenWithMode(options) {
377
490
  // Do not replace an invalid tenant registration with the Sequence realm:
378
491
  // that could hide a rejected discovery token host or inspect the wrong
379
492
  // M2M secret. Config-resolution errors must surface unchanged.
380
- const realm = await realmForEnv(options.env);
493
+ const realm = await realmForEnv(options.env, { targetUrl: options.targetUrl });
381
494
  if (process.env[m2mSecretEnvName(realm)]?.trim())
382
495
  throw err;
383
496
  }
package/dist/config.d.ts CHANGED
@@ -24,14 +24,18 @@ export declare function defaultConfig(): LatticeConfig;
24
24
  * Read the effective config. Merge precedence (later wins):
25
25
  *
26
26
  * 1. built-in `local`
27
- * 2. the cached discovered catalog (`~/.config/lattice/environments.json`)
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`)
27
+ * 2. local worktrees discovered from `.wt.env`
28
+ * 3. the cached discovered catalog (`~/.config/lattice/environments.json`)
29
+ * 4. user entries in config.toml (an override for `local`, or net-new envs)
30
+ * 5. OpCo registrations shared with seqapi (`~/.config/sequence-api/config.json`)
30
31
  *
31
32
  * Registered OpCo routes are authoritative for their names so a lower-trust
32
33
  * config.toml override cannot send a tenant token to another origin.
33
34
  */
34
35
  export declare function readConfig(): Promise<LatticeConfig>;
36
+ export declare function localAuthRealmOptions(envName?: string): Promise<{
37
+ targetUrl?: string;
38
+ }>;
35
39
  /** Write the config to disk, creating the dir if missing. */
36
40
  export declare function writeConfig(config: LatticeConfig): Promise<void>;
37
41
  export interface ResolvedEnv {
package/dist/config.js CHANGED
@@ -1,7 +1,9 @@
1
- import { mkdir, readFile, writeFile } from 'node:fs/promises';
1
+ import { execFile } from 'node:child_process';
2
+ import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises';
2
3
  import { existsSync } from 'node:fs';
3
4
  import { homedir } from 'node:os';
4
- import { dirname, join } from 'node:path';
5
+ import { basename, dirname, join } from 'node:path';
6
+ import { promisify } from 'node:util';
5
7
  import { parse as parseToml, stringify as stringifyToml } from 'smol-toml';
6
8
  import { M2mTokenError } from './auth.js';
7
9
  import { fetchCatalog, readCachedCatalog, } from './env-catalog.js';
@@ -26,7 +28,69 @@ export const PREVIEW_ENV_PREFIX = 'preview:';
26
28
  const BUILT_IN_ENV_URLS = {
27
29
  local: 'http://localhost:5001',
28
30
  };
31
+ const execFileAsync = promisify(execFile);
32
+ const WORKTREE_ENV_PREFIX = 'local:';
33
+ const WORKTREE_NAME_PATTERN = /^[a-z0-9][a-z0-9._-]*$/;
29
34
  export const ENV_NAMES = Object.keys(BUILT_IN_ENV_URLS);
35
+ async function atlasPortFromWorktreeEnv(directory) {
36
+ let text;
37
+ try {
38
+ text = await readFile(join(directory, '.wt.env'), 'utf8');
39
+ }
40
+ catch {
41
+ return undefined;
42
+ }
43
+ const value = text
44
+ .split('\n')
45
+ .map((line) => line.split('=', 2))
46
+ .find(([key]) => key?.trim() === 'ATLAS_PORT')?.[1]
47
+ ?.trim();
48
+ if (!value || !/^\d+$/.test(value))
49
+ return undefined;
50
+ const port = Number(value);
51
+ return port >= 1 && port <= 65_535 ? port : undefined;
52
+ }
53
+ async function worktreeDirectories() {
54
+ const root = process.env.WT_ROOT ?? join(homedir(), 'studio-worktrees');
55
+ const directories = [];
56
+ try {
57
+ const entries = await readdir(root, { withFileTypes: true });
58
+ directories.push(...entries
59
+ .filter((entry) => entry.isDirectory())
60
+ .map((entry) => join(root, entry.name)));
61
+ }
62
+ catch {
63
+ // The default worktree root is optional.
64
+ }
65
+ try {
66
+ const { stdout } = await execFileAsync('git', ['worktree', 'list', '--porcelain']);
67
+ const gitWorktrees = stdout
68
+ .split('\n')
69
+ .filter((line) => line.startsWith('worktree '))
70
+ .map((line) => line.slice('worktree '.length))
71
+ .slice(1);
72
+ directories.push(...gitWorktrees);
73
+ }
74
+ catch {
75
+ // Global CLI use outside a git checkout still supports WT_ROOT discovery.
76
+ }
77
+ return [...new Set(directories)];
78
+ }
79
+ async function worktreeEnvironmentUrls() {
80
+ const environments = {};
81
+ for (const directory of await worktreeDirectories()) {
82
+ const name = basename(directory);
83
+ if (!WORKTREE_NAME_PATTERN.test(name))
84
+ continue;
85
+ const port = await atlasPortFromWorktreeEnv(directory);
86
+ if (port === undefined)
87
+ continue;
88
+ environments[`${WORKTREE_ENV_PREFIX}${name}`] ??= {
89
+ url: `http://localhost:${port}`,
90
+ };
91
+ }
92
+ return environments;
93
+ }
30
94
  export function globalConfigDir() {
31
95
  return join(homedir(), '.config', 'lattice');
32
96
  }
@@ -44,15 +108,17 @@ export function defaultConfig() {
44
108
  * Read the effective config. Merge precedence (later wins):
45
109
  *
46
110
  * 1. built-in `local`
47
- * 2. the cached discovered catalog (`~/.config/lattice/environments.json`)
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`)
111
+ * 2. local worktrees discovered from `.wt.env`
112
+ * 3. the cached discovered catalog (`~/.config/lattice/environments.json`)
113
+ * 4. user entries in config.toml (an override for `local`, or net-new envs)
114
+ * 5. OpCo registrations shared with seqapi (`~/.config/sequence-api/config.json`)
50
115
  *
51
116
  * Registered OpCo routes are authoritative for their names so a lower-trust
52
117
  * config.toml override cannot send a tenant token to another origin.
53
118
  */
54
119
  export async function readConfig() {
55
120
  const merged = defaultConfig();
121
+ Object.assign(merged.envs, await worktreeEnvironmentUrls());
56
122
  const catalog = await readCachedCatalog();
57
123
  if (catalog) {
58
124
  merged.tier = catalog.tier;
@@ -80,6 +146,12 @@ export async function readConfig() {
80
146
  }
81
147
  return merged;
82
148
  }
149
+ export async function localAuthRealmOptions(envName) {
150
+ if (!envName || (envName !== 'local' && !envName.startsWith('local:'))) {
151
+ return {};
152
+ }
153
+ return { targetUrl: (await readConfig()).envs[envName]?.url };
154
+ }
83
155
  /** Write the config to disk, creating the dir if missing. */
84
156
  export async function writeConfig(config) {
85
157
  const path = configPath();
@@ -14,7 +14,8 @@ const ENVS_USAGE = `usage:
14
14
  catalog is cached at ~/.config/lattice/environments.json and also refreshes
15
15
  lazily the first time you pass an -e <env> that isn't cached yet.
16
16
 
17
- Custom entries in ${configPath()} are always honored on top.
17
+ Local worktrees are discovered from their .wt.env files. Custom entries in
18
+ ${configPath()} are always honored on top.
18
19
  `;
19
20
  export async function runEnvsCommand(sub, rest) {
20
21
  switch (sub) {
@@ -97,11 +98,13 @@ async function listCommand() {
97
98
  for (const [name, { url }] of Object.entries(config.envs)) {
98
99
  const source = name === 'local' && !discovered.has(name)
99
100
  ? 'built-in'
100
- : discovered.has(name)
101
- ? 'discovered'
102
- : registeredNames.has(name)
103
- ? 'registered'
104
- : 'config.toml';
101
+ : name.startsWith('local:')
102
+ ? 'worktree'
103
+ : discovered.has(name)
104
+ ? 'discovered'
105
+ : registeredNames.has(name)
106
+ ? 'registered'
107
+ : 'config.toml';
105
108
  console.log(` ${name.padEnd(width)} ${url} (${source})`);
106
109
  }
107
110
  if (config.tier === 'anonymous') {
@@ -59,13 +59,13 @@ export async function validateLocalBundle({ rootDir, files, }) {
59
59
  if (existsSync(join(rootDir, 'package-lock.json')) || existsSync(join(rootDir, 'yarn.lock'))) {
60
60
  issues.push({
61
61
  level: 'warning',
62
- message: 'Found a non-pnpm lockfile (package-lock.json / yarn.lock). It is ignored — the deploy worker resolves dependencies against Chainguard, Sequence\'s internal registry. With Chainguard credentials, `pnpm install` produces a pinned lockfile for a faster deploy; without them this is fine as-is.',
62
+ message: 'Found a non-pnpm lockfile (package-lock.json / yarn.lock). It is ignored; dependencies are resolved server-side at deploy time.',
63
63
  });
64
64
  }
65
65
  else {
66
66
  issues.push({
67
67
  level: 'info',
68
- message: 'No pnpm-lock.yaml — the deploy worker will resolve dependencies against Chainguard, Sequence\'s internal registry. With Chainguard credentials, `pnpm install` produces a pinned lockfile for a faster deploy; without them this is fine as-is.',
68
+ message: 'No pnpm-lock.yaml — dependencies are resolved server-side at deploy time.',
69
69
  });
70
70
  }
71
71
  }
@@ -76,7 +76,7 @@ export async function validateLocalBundle({ rootDir, files, }) {
76
76
  if (classifyLockfileOrigin(lockfileText) !== 'chainguard') {
77
77
  issues.push({
78
78
  level: 'warning',
79
- message: 'pnpm-lock.yaml was not resolved against Chainguard the deploy worker will discard it and re-resolve (slower, and versions may differ). This is expected without Chainguard credentials; with them, regenerate against Chainguard (registry=https://libraries.cgr.dev/javascript/) for a fast, pinned deploy.',
79
+ message: 'pnpm-lock.yaml cannot be used for this deployment, so dependencies are resolved server-side at deploy time. Resolved versions may differ.',
80
80
  });
81
81
  }
82
82
  }
@@ -202,6 +202,12 @@ limits:
202
202
  max_instances: 3
203
203
  invoke_rate_per_minute: 60
204
204
 
205
+ # Optional source-IP gate, checked before invocation permissions. Omit or leave
206
+ # empty for unrestricted source IPs.
207
+ # invocation:
208
+ # allowed_ip_ranges:
209
+ # - 203.0.113.0/24
210
+
205
211
  # Env-var names the function expects (UPPER_SNAKE_CASE). Values are read
206
212
  # from a local .env file at deploy time — they are never committed or bundled.
207
213
  secrets: []
@@ -325,10 +331,6 @@ export async function functionsInitCommand(args) {
325
331
  console.log('');
326
332
  console.log('Next steps (init is only needed when creating a function from scratch):');
327
333
  console.log(` cd ${target}`);
328
- console.log(' pnpm install # installs deps for local dev + pins versions via Chainguard.');
329
- console.log(' # Requires Chainguard credentials (Sequence-internal). Without');
330
- console.log(' # them this 401s — delete the scaffolded .npmrc and install from');
331
- console.log(' # public npm instead (the deploy worker re-resolves server-side).');
332
334
  console.log(' seq-studio functions build # local pre-flight checks');
333
335
  console.log(' # Add secret names to managed-function.yml (secrets: [MY_SECRET]) and values to .env');
334
336
  console.log(' seq-studio functions deploy -e <env> # upload, apply secrets, then deploy');
@@ -387,6 +389,15 @@ async function buildFromResolvedSource({ spec, source, }) {
387
389
  // ---------------------------------------------------------------------------
388
390
  // deploy
389
391
  // ---------------------------------------------------------------------------
392
+ function buildInvocationIpPreviewLines({ ranges }) {
393
+ if (ranges.length === 0) {
394
+ return [`${LOG} invocation IPs: unrestricted`];
395
+ }
396
+ return [
397
+ `${LOG} invocation IPs:`,
398
+ ...ranges.map((range) => `${LOG} ${range}`),
399
+ ];
400
+ }
390
401
  async function getFunctionDetail(ctx, functionId) {
391
402
  try {
392
403
  return await getJson({
@@ -561,7 +572,9 @@ async function deployFromResolvedSource({ args, spec, ctx: earlyCtx, source, })
561
572
  preview.push(...buildEgressPreviewLines(LOG, [
562
573
  ...manifestEgressHosts(manifest.egress),
563
574
  ...manifestEgressIpRanges(manifest.egress),
564
- ]));
575
+ ]), ...buildInvocationIpPreviewLines({
576
+ ranges: manifest.invocation.allowed_ip_ranges,
577
+ }));
565
578
  if (undeclaredEnvKeys.length > 0) {
566
579
  preview.push(`${LOG} note: .env has ${undeclaredEnvKeys.join(', ')} — not declared in manifest, ignored`);
567
580
  }
@@ -70,6 +70,9 @@ export declare const managedFunctionManifestSchema: z.ZodObject<{
70
70
  max_instances: z.ZodDefault<z.ZodNumber>;
71
71
  invoke_rate_per_minute: z.ZodDefault<z.ZodNumber>;
72
72
  }, z.core.$strip>>;
73
+ invocation: z.ZodDefault<z.ZodObject<{
74
+ allowed_ip_ranges: z.ZodDefault<z.ZodArray<z.ZodString>>;
75
+ }, z.core.$strip>>;
73
76
  secrets: z.ZodDefault<z.ZodArray<z.ZodString>>;
74
77
  egress: z.ZodDefault<z.ZodArray<z.ZodString>>;
75
78
  service_account: z.ZodOptional<z.ZodString>;
@@ -1,3 +1,4 @@
1
+ import { isIP } from 'node:net';
1
2
  import { z } from 'zod';
2
3
  /**
3
4
  * CLI-side mirror of the server manifest schema at
@@ -515,6 +516,43 @@ const SERVICE_ACCOUNT_REF_RE = /^([a-z][a-z0-9-]{1,98}|[0-9a-fA-F]{8}-[0-9a-fA-F
515
516
  * atlas/src/server/services/managed-functions/manifest.ts.
516
517
  */
517
518
  const GRAPHQL_OPERATION_NAME_RE = /^[_A-Za-z][_0-9A-Za-z]*$/;
519
+ /**
520
+ * Fast local validation for the declarative invocation policy. Atlas remains
521
+ * authoritative and canonicalizes network addresses during version upload;
522
+ * this mirror prevents a malformed bundle from reaching the network.
523
+ */
524
+ const invocationIpRangeSchema = z
525
+ .string()
526
+ .trim()
527
+ .min(1)
528
+ .max(128)
529
+ .superRefine((value, ctx) => {
530
+ const parts = value.split('/');
531
+ if (parts.length > 2) {
532
+ ctx.addIssue({ code: 'custom', message: `invalid IP address or CIDR range: ${value}` });
533
+ return;
534
+ }
535
+ const [address, prefixText] = parts;
536
+ const family = isIP(address ?? '');
537
+ if (family === 0) {
538
+ ctx.addIssue({ code: 'custom', message: `invalid IP address or CIDR range: ${value}` });
539
+ return;
540
+ }
541
+ if (prefixText === undefined)
542
+ return;
543
+ if (!/^\d+$/.test(prefixText)) {
544
+ ctx.addIssue({ code: 'custom', message: `invalid CIDR prefix: ${value}` });
545
+ return;
546
+ }
547
+ const prefix = Number(prefixText);
548
+ const maxPrefix = family === 4 ? 32 : 128;
549
+ if (prefix < 1 || prefix > maxPrefix) {
550
+ ctx.addIssue({
551
+ code: 'custom',
552
+ message: `CIDR prefix must be between 1 and ${maxPrefix}: ${value}`,
553
+ });
554
+ }
555
+ });
518
556
  export const managedFunctionManifestSchema = z.object({
519
557
  schema_version: z.literal(1).default(1),
520
558
  function: z.object({
@@ -552,6 +590,16 @@ export const managedFunctionManifestSchema = z.object({
552
590
  max_instances: 3,
553
591
  invoke_rate_per_minute: 60,
554
592
  }),
593
+ /**
594
+ * Source-IP gate applied by the Atlas invocation gateway before FGA.
595
+ * Empty or omitted means unrestricted. The policy is versioned with the
596
+ * bundle and becomes live atomically with that version.
597
+ */
598
+ invocation: z
599
+ .object({
600
+ allowed_ip_ranges: z.array(invocationIpRangeSchema).max(64).default([]),
601
+ })
602
+ .default({ allowed_ip_ranges: [] }),
555
603
  secrets: z
556
604
  .array(z.string().regex(SECRET_NAME_RE, 'secret names must be UPPER_SNAKE_CASE'))
557
605
  .max(32)
package/dist/login.js CHANGED
@@ -5,7 +5,7 @@ import { spawn } from 'node:child_process';
5
5
  import { homedir } from 'node:os';
6
6
  import { join } from 'node:path';
7
7
  import { deleteRealmTokens, realmForEnv, saveTokens, seqapiTokenPath, SEQUENCE_AUTH_REALM, SEQUENCE_REALM, verifyTokenMatchesRealm, } from './auth.js';
8
- import { manualEnvConfigHint } from './config.js';
8
+ import { localAuthRealmOptions, manualEnvConfigHint } from './config.js';
9
9
  import { bootstrapUrl, clearCatalog, fetchCatalog } from './env-catalog.js';
10
10
  const DEFAULT_REDIRECT_PORT = 5099;
11
11
  const DEFAULT_LOGIN_TIMEOUT_MS = 300_000;
@@ -216,7 +216,7 @@ export async function refreshCatalogAfterLogin() {
216
216
  console.log(manualEnvConfigHint());
217
217
  }
218
218
  export async function login(envName) {
219
- const realm = await realmForEnv(envName);
219
+ const realm = await realmForEnv(envName, await localAuthRealmOptions(envName));
220
220
  if (envName && realm.name === SEQUENCE_REALM && envName !== SEQUENCE_REALM) {
221
221
  // A name that only exists in the Sequence catalog (staging, banksouth…)
222
222
  // resolves to the Sequence realm — one login covers all of those. A typo'd
@@ -241,7 +241,7 @@ function legacyArtifactTokenPath() {
241
241
  return join(homedir(), '.config', 'sequence-artifact-studio', 'tokens.json');
242
242
  }
243
243
  export async function logout(envName) {
244
- const realm = await realmForEnv(envName);
244
+ const realm = await realmForEnv(envName, await localAuthRealmOptions(envName));
245
245
  await deleteRealmTokens(realm.name);
246
246
  if (realm.name === SEQUENCE_REALM) {
247
247
  await rm(legacyArtifactTokenPath(), { force: true });
@@ -38,13 +38,17 @@ const PIPELINE_USAGE = `usage:
38
38
  module (default export, authoring, or silverlakeAuthoring export name)
39
39
 
40
40
  seq-studio pipeline plan --repo pipelines/<slug> --ref <sha|branch> -e <env> [--target <id>] [--json]
41
+ enqueue a durable plan, poll progress, then print the completed
41
42
  materialize + SDK/graph + compile + live-diff + provision findings
42
43
  (no Databricks CLI / DAB validate on Atlas); exit 1 on destructive findings
43
44
 
44
45
  seq-studio pipeline deploy --repo pipelines/<slug> --ref <sha|branch> -e <env>
45
- [--target <id>] [--approved-by <sub>] [--no-wait] [--json]
46
+ [--target <id>] [--approved-by <sub>] [--run-now] [--no-wait] [--json]
46
47
  plan then enqueue Trigger deploy (DAB bundle validate hard-gates before
47
- bundle deploy); use a full 40-hex SHA for explicit rollback deployments
48
+ bundle deploy). Default is plant-only: jobs/pipelines are created, not
49
+ run. Pass --run-now to run in-unit producer roots after bundle deploy
50
+ and wait before serving sync create. Use a full 40-hex SHA for explicit
51
+ rollback deployments
48
52
 
49
53
  seq-studio pipeline promote --stage <slug> --version <v> -e <env>
50
54
  [--target <id>] [--repo pipelines/<slug>] [--approved-by <you>] [--no-wait]