@sequenceholdings/studio-cli 0.1.12 → 0.1.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/README.md +116 -30
  2. package/dist/artifact/delegate.d.ts +2 -2
  3. package/dist/artifact/delegate.js +31 -73
  4. package/dist/atlas-client.js +52 -37
  5. package/dist/auth-cmds/commands.d.ts +1 -1
  6. package/dist/auth-cmds/commands.js +12 -7
  7. package/dist/auth.d.ts +97 -19
  8. package/dist/auth.js +376 -81
  9. package/dist/config.d.ts +13 -3
  10. package/dist/config.js +41 -14
  11. package/dist/env-catalog.js +13 -3
  12. package/dist/env-flags.d.ts +2 -0
  13. package/dist/env-flags.js +2 -0
  14. package/dist/env-registry.d.ts +27 -0
  15. package/dist/env-registry.js +204 -0
  16. package/dist/envs/commands.d.ts +1 -1
  17. package/dist/envs/commands.js +65 -10
  18. package/dist/file-lock.d.ts +5 -0
  19. package/dist/file-lock.js +187 -0
  20. package/dist/functions/commands.d.ts +9 -1
  21. package/dist/functions/commands.js +71 -29
  22. package/dist/functions/manifest.d.ts +1 -0
  23. package/dist/functions/manifest.js +36 -0
  24. package/dist/login.d.ts +14 -3
  25. package/dist/login.js +60 -40
  26. package/dist/main.d.ts +2 -1
  27. package/dist/main.js +36 -12
  28. package/dist/orm/delegate.d.ts +9 -0
  29. package/dist/orm/delegate.js +36 -4
  30. package/dist/pat-hints.js +2 -2
  31. package/dist/pipeline/commands.d.ts +58 -0
  32. package/dist/pipeline/commands.js +330 -0
  33. package/dist/pipeline/lifecycle.d.ts +58 -0
  34. package/dist/pipeline/lifecycle.js +348 -0
  35. package/dist/pipeline/pinning.d.ts +5 -0
  36. package/dist/pipeline/pinning.js +9 -0
  37. package/dist/pipeline/templates.d.ts +11 -0
  38. package/dist/pipeline/templates.js +166 -0
  39. package/dist/process/build.d.ts +12 -1
  40. package/dist/process/build.js +49 -2
  41. package/dist/process/codegen.js +21 -1
  42. package/dist/process/commands.d.ts +19 -0
  43. package/dist/process/commands.js +153 -40
  44. package/dist/process/compiler-subprocess.d.ts +29 -0
  45. package/dist/process/compiler-subprocess.js +99 -0
  46. package/dist/process/compiler-worker.d.ts +1 -0
  47. package/dist/process/compiler-worker.js +38 -0
  48. package/dist/process/discover.d.ts +4 -1
  49. package/dist/process/discover.js +5 -2
  50. package/dist/process/lint.d.ts +8 -0
  51. package/dist/process/lint.js +125 -29
  52. package/dist/process/repo-install.d.ts +21 -0
  53. package/dist/process/repo-install.js +99 -0
  54. package/dist/process/simulate.js +14 -1
  55. package/dist/repos/commands.d.ts +1 -1
  56. package/dist/repos/commands.js +17 -12
  57. package/dist/secrets/commands.d.ts +1 -1
  58. package/dist/secrets/commands.js +18 -18
  59. package/package.json +9 -4
@@ -0,0 +1,187 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { link, mkdir, open, readFile, rename, stat, unlink } from 'node:fs/promises';
3
+ import { dirname } from 'node:path';
4
+ const LOCK_TIMEOUT_MS = 10_000;
5
+ const LOCK_STALE_MS = 30_000;
6
+ function hasErrorCode(error, code) {
7
+ return error instanceof Error && Reflect.get(error, 'code') === code;
8
+ }
9
+ function ownerProcessIsAlive(owner) {
10
+ const separator = owner.indexOf('-');
11
+ const pid = Number(owner.slice(0, separator));
12
+ if (separator <= 0 || !Number.isSafeInteger(pid) || pid <= 0)
13
+ return false;
14
+ try {
15
+ process.kill(pid, 0);
16
+ return true;
17
+ }
18
+ catch (error) {
19
+ return !hasErrorCode(error, 'ESRCH');
20
+ }
21
+ }
22
+ async function createExclusiveFile(path, contents) {
23
+ const temporary = `${path}.publish.${process.pid}.${randomUUID()}`;
24
+ const handle = await open(temporary, 'wx', 0o600);
25
+ try {
26
+ await handle.writeFile(contents, { encoding: 'utf8' });
27
+ }
28
+ catch (error) {
29
+ await handle.close().catch(() => undefined);
30
+ await unlink(temporary).catch(() => undefined);
31
+ throw error;
32
+ }
33
+ try {
34
+ await handle.close();
35
+ }
36
+ catch (error) {
37
+ await unlink(temporary).catch(() => undefined);
38
+ throw error;
39
+ }
40
+ try {
41
+ await link(temporary, path);
42
+ }
43
+ finally {
44
+ await unlink(temporary).catch(() => undefined);
45
+ }
46
+ }
47
+ async function restoreClaimedLock(reapPath, lockPath) {
48
+ try {
49
+ await link(reapPath, lockPath);
50
+ }
51
+ catch (error) {
52
+ if (!hasErrorCode(error, 'EEXIST'))
53
+ throw error;
54
+ }
55
+ }
56
+ async function claimObservedOwnerFile({ claimLabel, observedOwner, path, }) {
57
+ const claimedPath = `${path}.${claimLabel}.${process.pid}.${randomUUID()}`;
58
+ try {
59
+ await rename(path, claimedPath);
60
+ }
61
+ catch (error) {
62
+ if (hasErrorCode(error, 'ENOENT'))
63
+ return undefined;
64
+ throw error;
65
+ }
66
+ try {
67
+ if ((await readFile(claimedPath, 'utf8')) !== observedOwner) {
68
+ await restoreClaimedLock(claimedPath, path);
69
+ await unlink(claimedPath);
70
+ return undefined;
71
+ }
72
+ return claimedPath;
73
+ }
74
+ catch {
75
+ // The process-unique claim may have been concurrently cleaned up.
76
+ return undefined;
77
+ }
78
+ }
79
+ async function tryReapOrphanedGuard(guardPath) {
80
+ let observedOwner;
81
+ try {
82
+ observedOwner = await readFile(guardPath, 'utf8');
83
+ }
84
+ catch {
85
+ return;
86
+ }
87
+ if (ownerProcessIsAlive(observedOwner))
88
+ return;
89
+ const orphanPath = await claimObservedOwnerFile({
90
+ claimLabel: 'orphan',
91
+ observedOwner,
92
+ path: guardPath,
93
+ });
94
+ if (orphanPath) {
95
+ await unlink(orphanPath).catch(() => undefined);
96
+ }
97
+ }
98
+ export async function tryReapStaleLock(lockPath, observedOwner) {
99
+ const guardPath = `${lockPath}.reaper`;
100
+ try {
101
+ await createExclusiveFile(guardPath, `${process.pid}-${randomUUID()}`);
102
+ }
103
+ catch (error) {
104
+ if (hasErrorCode(error, 'EEXIST')) {
105
+ await tryReapOrphanedGuard(guardPath);
106
+ return;
107
+ }
108
+ throw error;
109
+ }
110
+ try {
111
+ let stillCurrent;
112
+ let stillStale;
113
+ try {
114
+ stillCurrent = (await readFile(lockPath, 'utf8')) === observedOwner;
115
+ stillStale = Date.now() - (await stat(lockPath)).mtimeMs > LOCK_STALE_MS;
116
+ }
117
+ catch {
118
+ return;
119
+ }
120
+ if (!stillCurrent || !stillStale || ownerProcessIsAlive(observedOwner))
121
+ return;
122
+ const reapPath = await claimObservedOwnerFile({
123
+ claimLabel: 'reap',
124
+ observedOwner,
125
+ path: lockPath,
126
+ });
127
+ if (reapPath) {
128
+ await unlink(reapPath).catch(() => undefined);
129
+ }
130
+ }
131
+ finally {
132
+ try {
133
+ await unlink(guardPath);
134
+ }
135
+ catch {
136
+ // The guard was already removed; never mask the protected operation.
137
+ }
138
+ }
139
+ }
140
+ export async function withCrossProcessFileLock({ operation, path, }) {
141
+ const lockPath = `${path}.lock`;
142
+ const owner = `${process.pid}-${randomUUID()}`;
143
+ const deadline = Date.now() + LOCK_TIMEOUT_MS;
144
+ await mkdir(dirname(path), { recursive: true });
145
+ while (true) {
146
+ try {
147
+ await createExclusiveFile(lockPath, owner);
148
+ break;
149
+ }
150
+ catch (error) {
151
+ if (!hasErrorCode(error, 'EEXIST'))
152
+ throw error;
153
+ let observed;
154
+ let isStale;
155
+ try {
156
+ observed = await readFile(lockPath, 'utf8');
157
+ isStale = Date.now() - (await stat(lockPath)).mtimeMs > LOCK_STALE_MS;
158
+ }
159
+ catch (checkError) {
160
+ if (hasErrorCode(checkError, 'ENOENT'))
161
+ continue;
162
+ throw checkError;
163
+ }
164
+ if (isStale && !ownerProcessIsAlive(observed)) {
165
+ await tryReapStaleLock(lockPath, observed);
166
+ continue;
167
+ }
168
+ if (Date.now() >= deadline) {
169
+ throw new Error(`Timed out waiting for another CLI to update ${path}.`);
170
+ }
171
+ await new Promise((resolve) => setTimeout(resolve, 50));
172
+ }
173
+ }
174
+ try {
175
+ return await operation();
176
+ }
177
+ finally {
178
+ try {
179
+ if ((await readFile(lockPath, 'utf8')) === owner) {
180
+ await unlink(lockPath);
181
+ }
182
+ }
183
+ catch {
184
+ // Never mask the operation result when the lock was already recovered.
185
+ }
186
+ }
187
+ }
@@ -1,3 +1,4 @@
1
+ import { type AuthMode } from '../auth.js';
1
2
  import { type ResolvedEnv } from '../config.js';
2
3
  import type { ParsedArgs } from '../process/commands.js';
3
4
  import { type ManagedFunctionManifest } from './manifest.js';
@@ -32,9 +33,16 @@ export interface VersionSummary {
32
33
  }
33
34
  export declare function flagBool(flags: ParsedArgs['flags'], ...keys: string[]): boolean;
34
35
  export interface CommandContext {
36
+ authMode?: AuthMode;
35
37
  env: ResolvedEnv;
36
38
  token: string;
37
39
  }
40
+ export declare function requestedEnvironment(args: ParsedArgs): string | undefined;
41
+ /**
42
+ * Resolve env + token for Atlas network commands. Requires an explicit
43
+ * `-e/--env` so partners never hit the built-in `local` default and get an
44
+ * opaque `fetch failed` (same contract as `functions deploy`).
45
+ */
38
46
  export declare function buildContext(args: ParsedArgs): Promise<CommandContext>;
39
47
  export declare function clientOptions(ctx: CommandContext): {
40
48
  baseUrl: string;
@@ -82,5 +90,5 @@ export declare function functionsRollbackCommand(args: ParsedArgs): Promise<numb
82
90
  /** Minimal dotenv parser — KEY=VALUE lines, quotes stripped, comments skipped. */
83
91
  export declare function parseDotenv(content: string): Record<string, string>;
84
92
  export declare function functionsDeleteCommand(args: ParsedArgs): Promise<number>;
85
- export declare const FUNCTIONS_USAGE = "usage:\n seq-studio functions init <dir> scaffold manifest + TypeScript hello-world handler\n seq-studio functions build [--dir d] local pre-flight (manifest, lockfile, size)\n seq-studio functions deploy [-e env] [-m msg] preview + confirm secrets + upload and deploy\n seq-studio functions list [-e env] [--match-local] functions visible on the environment\n seq-studio functions show [-e env] [--fn slug] detail for one function (versions, secrets)\n seq-studio functions logs [-e env] [--limit N] [--since t] Cloud Logging snapshot (reader-gated)\n seq-studio functions promote <version> [-e env] make a version live\n seq-studio functions rollback [<version>] [-e env] redeploy a prior version\n seq-studio functions delete [--yes] archive function + tear down GCP resources\n (version history is retained)\n\n Flags: -e/--env <env|preview:<slug>> (see: seq-studio envs list) \u00B7 --fn <slug> \u00B7 --dir <path>\n --from-env-file <path> (default: .env) source file for secret values\n --no-wait \u00B7 --yes\n --no-provision (deploy) update-only: error instead of registering a new\n shell, writing secret values, or attaching secrets (CI sweep)\n\n Source for build/deploy: a local --dir (default .), a platform git-service\n repo (--repo <ns>/<name>), or any git URL (--git-url <url>). --ref selects a\n branch/tag/commit (default: the repo's default branch). Remote sources record\n the pinned commit as provenance (never dirty) and NEVER read a repo-committed\n .env for secret values \u2014 provision secrets server-side or pass a local\n --from-env-file (resolved against your cwd).\n\n --repo clones over smart-HTTP and REQUIRES a git PAT in ATLAS_GIT_PAT\n (repo:read scope \u2014 `seq-studio auth pat create --scopes repo:read`, or\n Atlas \u2192 Settings \u2192 Tokens). It's the same token you clone the repo with;\n --env + seq-studio login are still needed to resolve the repo and deploy.\n";
93
+ export declare const FUNCTIONS_USAGE = "usage:\n seq-studio functions init <dir> scaffold manifest + TypeScript hello-world handler\n seq-studio functions build [--dir d] local pre-flight (manifest, lockfile, size)\n seq-studio functions deploy -e <env> [-m msg] preview + confirm secrets + upload and deploy\n seq-studio functions list -e <env> [--match-local] functions visible on the environment\n seq-studio functions show -e <env> [--fn slug] detail for one function (versions, secrets)\n seq-studio functions logs -e <env> [--limit N] [--since t] Cloud Logging snapshot (reader-gated)\n seq-studio functions promote <version> -e <env> make a version live\n seq-studio functions rollback [<version>] -e <env> redeploy a prior version\n seq-studio functions delete -e <env> [--yes] archive function + tear down GCP resources\n (version history is retained)\n\n Flags: -e/--env <env|preview:<slug>> (required for network commands; see: seq-studio envs list) \u00B7 --fn <slug> \u00B7 --dir <path>\n --from-env-file <path> (default: .env) source file for secret values\n --no-wait \u00B7 --yes\n --no-provision (deploy) update-only: error instead of registering a new\n shell, writing secret values, or attaching secrets (CI sweep)\n\n Source for build/deploy: a local --dir (default .), a platform git-service\n repo (--repo <ns>/<name>), or a public HTTPS git URL (--git-url <url>). --ref selects a\n branch/tag/commit (default: the repo's default branch). Remote sources record\n the pinned commit as provenance (never dirty) and NEVER read a repo-committed\n .env for secret values \u2014 provision secrets server-side or pass a local\n --from-env-file (resolved against your cwd).\n\n Interactive --repo builds clone over smart-HTTP and require a repo:read git\n PAT in ATLAS_GIT_PAT (`seq-studio auth pat create --scopes repo:read`, or\n Atlas \u2192 Settings \u2192 Tokens). Headless M2M builds use JSON materialize and\n accept only platform-managed --repo sources. Interactive builds also need\n --env + seq-studio login to resolve the repo and deploy.\n";
86
94
  export declare function runFunctionsCommand(sub: string | undefined, args: ParsedArgs): Promise<number>;
@@ -6,10 +6,11 @@ import { promisify } from 'node:util';
6
6
  import { createInterface } from 'node:readline';
7
7
  import { Writable } from 'node:stream';
8
8
  import { load as parseYaml } from 'js-yaml';
9
- import { getAccessToken } from '../auth.js';
9
+ import { getAccessTokenWithMode, tryGetAccessTokenWithMode } from '../auth.js';
10
10
  import { resolveEnvWithDiscovery } from '../config.js';
11
11
  import { AtlasApiError, deleteJson, getJson, postJson } from '../atlas-client.js';
12
12
  import { clarifyApplyFailureReason, printCliError } from '../cli-errors.js';
13
+ import { REQUIRE_EXPLICIT_ENV_MESSAGE } from '../env-flags.js';
13
14
  import { confirmYes } from '../prompt.js';
14
15
  import { collectBundleFiles, isDirectory, validateLocalBundle, } from './bundle.js';
15
16
  import { managedFunctionManifestSchema, MF_MANIFEST_FILENAME, manifestEgressHosts, manifestEgressIpRanges, } from './manifest.js';
@@ -23,15 +24,48 @@ const POLL_TIMEOUT_MS = 20 * 60 * 1000;
23
24
  export function flagBool(flags, ...keys) {
24
25
  return keys.some((key) => flags[key] === true || flags[key] === 'true');
25
26
  }
27
+ export function requestedEnvironment(args) {
28
+ return ((typeof args.flags.env === 'string' ? args.flags.env : undefined) ??
29
+ (typeof args.flags.e === 'string' ? args.flags.e : undefined));
30
+ }
31
+ /**
32
+ * Resolve env + token for Atlas network commands. Requires an explicit
33
+ * `-e/--env` so partners never hit the built-in `local` default and get an
34
+ * opaque `fetch failed` (same contract as `functions deploy`).
35
+ */
26
36
  export async function buildContext(args) {
27
- const requested = (typeof args.flags.env === 'string' ? args.flags.env : undefined) ??
28
- (typeof args.flags.e === 'string' ? args.flags.e : undefined);
37
+ const requested = requestedEnvironment(args);
38
+ if (!requested) {
39
+ throw new Error(REQUIRE_EXPLICIT_ENV_MESSAGE);
40
+ }
29
41
  const env = await resolveEnvWithDiscovery({ requested });
30
- const token = await getAccessToken();
31
- return { env, token };
42
+ const auth = await getAccessTokenWithMode({ env: env.name, targetUrl: env.url });
43
+ return { authMode: auth.authMode, env, token: auth.token };
32
44
  }
33
45
  export function clientOptions(ctx) {
34
- return { baseUrl: ctx.env.url, token: ctx.token };
46
+ return {
47
+ baseUrl: ctx.env.url,
48
+ token: ctx.token,
49
+ };
50
+ }
51
+ function sourceClientOptions(ctx) {
52
+ return {
53
+ ...(ctx.authMode ? { authMode: ctx.authMode } : {}),
54
+ ...clientOptions(ctx),
55
+ };
56
+ }
57
+ async function buildSourceOptions({ args, spec, }) {
58
+ if (spec.kind === 'git-service') {
59
+ return sourceClientOptions(await buildContext(args));
60
+ }
61
+ if (spec.kind === 'git-url') {
62
+ const auth = await tryGetAccessTokenWithMode({
63
+ failClosedForM2m: true,
64
+ env: requestedEnvironment(args),
65
+ });
66
+ return auth ? { authMode: auth.authMode } : {};
67
+ }
68
+ return {};
35
69
  }
36
70
  export async function readManifestOptional(dir) {
37
71
  const path = join(dir, MF_MANIFEST_FILENAME);
@@ -316,9 +350,10 @@ export async function functionsInitCommand(args) {
316
350
  // ---------------------------------------------------------------------------
317
351
  export async function functionsBuildCommand(args) {
318
352
  const spec = parseFunctionsSourceSpec(args);
319
- // Local and git-url sources build offline; only a git-service repo needs
320
- // the target environment + token to fetch its tree.
321
- const remote = spec.kind === 'git-service' ? clientOptions(await buildContext(args)) : {};
353
+ // A public git URL needs no bearer token, but optional auth resolution still
354
+ // identifies a selected M2M principal so policy can reject arbitrary CI
355
+ // input before cloning. Local builds remain completely offline.
356
+ const remote = await buildSourceOptions({ args, spec });
322
357
  const source = await resolveArtifactSource(spec, remote);
323
358
  try {
324
359
  return await buildFromResolvedSource({ spec, source });
@@ -376,13 +411,19 @@ async function getFunctionDetail(ctx, functionId) {
376
411
  }
377
412
  }
378
413
  export async function functionsDeployCommand(args) {
414
+ // Fail before source resolution / auth so a missing `-e` never becomes
415
+ // `fetch failed` against localhost. `buildContext` enforces the same rule.
416
+ if (!requestedEnvironment(args)) {
417
+ console.error(`${LOG} ${REQUIRE_EXPLICIT_ENV_MESSAGE}`);
418
+ return 1;
419
+ }
379
420
  const spec = parseFunctionsSourceSpec(args);
380
- // Only a --repo source needs env + auth up front materializing it fetches
381
- // the tree from the target environment's git service. Local / git-url
382
- // sources defer auth until after local validation, so bad input (missing
383
- // manifest, bad --from-env-file) fails fast instead of failing on auth.
384
- const ctx = spec.kind === 'git-service' ? await buildContext(args) : null;
385
- const source = await resolveArtifactSource(spec, ctx ? clientOptions(ctx) : {});
421
+ // Remote deploys resolve auth up front: --repo needs it to fetch the tree,
422
+ // while --git-url must reject a selected M2M principal before cloning.
423
+ // Local sources still defer auth until after validation so malformed local
424
+ // input fails before any login/network work.
425
+ const ctx = spec.kind !== 'local' ? await buildContext(args) : null;
426
+ const source = await resolveArtifactSource(spec, ctx ? sourceClientOptions(ctx) : {});
386
427
  try {
387
428
  return await deployFromResolvedSource({ args, spec, ctx, source });
388
429
  }
@@ -837,7 +878,7 @@ async function listVersions(ctx, functionId) {
837
878
  export async function functionsPromoteCommand(args) {
838
879
  const versionName = args.positional[0];
839
880
  if (!versionName) {
840
- console.error('usage: seq-studio functions promote <version> [-e env] [--fn slug]');
881
+ console.error('usage: seq-studio functions promote <version> -e <env> [--fn slug]');
841
882
  return 1;
842
883
  }
843
884
  const ctx = await buildContext(args);
@@ -932,32 +973,33 @@ export async function functionsDeleteCommand(args) {
932
973
  export const FUNCTIONS_USAGE = `usage:
933
974
  seq-studio functions init <dir> scaffold manifest + TypeScript hello-world handler
934
975
  seq-studio functions build [--dir d] local pre-flight (manifest, lockfile, size)
935
- seq-studio functions deploy [-e env] [-m msg] preview + confirm secrets + upload and deploy
936
- seq-studio functions list [-e env] [--match-local] functions visible on the environment
937
- seq-studio functions show [-e env] [--fn slug] detail for one function (versions, secrets)
938
- seq-studio functions logs [-e env] [--limit N] [--since t] Cloud Logging snapshot (reader-gated)
939
- seq-studio functions promote <version> [-e env] make a version live
940
- seq-studio functions rollback [<version>] [-e env] redeploy a prior version
941
- seq-studio functions delete [--yes] archive function + tear down GCP resources
976
+ seq-studio functions deploy -e <env> [-m msg] preview + confirm secrets + upload and deploy
977
+ seq-studio functions list -e <env> [--match-local] functions visible on the environment
978
+ seq-studio functions show -e <env> [--fn slug] detail for one function (versions, secrets)
979
+ seq-studio functions logs -e <env> [--limit N] [--since t] Cloud Logging snapshot (reader-gated)
980
+ seq-studio functions promote <version> -e <env> make a version live
981
+ seq-studio functions rollback [<version>] -e <env> redeploy a prior version
982
+ seq-studio functions delete -e <env> [--yes] archive function + tear down GCP resources
942
983
  (version history is retained)
943
984
 
944
- Flags: -e/--env <env|preview:<slug>> (see: seq-studio envs list) · --fn <slug> · --dir <path>
985
+ Flags: -e/--env <env|preview:<slug>> (required for network commands; see: seq-studio envs list) · --fn <slug> · --dir <path>
945
986
  --from-env-file <path> (default: .env) source file for secret values
946
987
  --no-wait · --yes
947
988
  --no-provision (deploy) update-only: error instead of registering a new
948
989
  shell, writing secret values, or attaching secrets (CI sweep)
949
990
 
950
991
  Source for build/deploy: a local --dir (default .), a platform git-service
951
- repo (--repo <ns>/<name>), or any git URL (--git-url <url>). --ref selects a
992
+ repo (--repo <ns>/<name>), or a public HTTPS git URL (--git-url <url>). --ref selects a
952
993
  branch/tag/commit (default: the repo's default branch). Remote sources record
953
994
  the pinned commit as provenance (never dirty) and NEVER read a repo-committed
954
995
  .env for secret values — provision secrets server-side or pass a local
955
996
  --from-env-file (resolved against your cwd).
956
997
 
957
- --repo clones over smart-HTTP and REQUIRES a git PAT in ATLAS_GIT_PAT
958
- (repo:read scope \`seq-studio auth pat create --scopes repo:read\`, or
959
- Atlas → Settings → Tokens). It's the same token you clone the repo with;
960
- --env + seq-studio login are still needed to resolve the repo and deploy.
998
+ Interactive --repo builds clone over smart-HTTP and require a repo:read git
999
+ PAT in ATLAS_GIT_PAT (\`seq-studio auth pat create --scopes repo:read\`, or
1000
+ Atlas → Settings → Tokens). Headless M2M builds use JSON materialize and
1001
+ accept only platform-managed --repo sources. Interactive builds also need
1002
+ --env + seq-studio login to resolve the repo and deploy.
961
1003
  `;
962
1004
  export async function runFunctionsCommand(sub, args) {
963
1005
  try {
@@ -57,6 +57,7 @@ export declare const managedFunctionManifestSchema: z.ZodObject<{
57
57
  }, z.core.$strip>>;
58
58
  secrets: z.ZodDefault<z.ZodArray<z.ZodString>>;
59
59
  egress: z.ZodDefault<z.ZodArray<z.ZodString>>;
60
+ service_account: z.ZodOptional<z.ZodString>;
60
61
  input_schema: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
61
62
  output_schema: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
62
63
  capabilities: z.ZodDefault<z.ZodObject<{
@@ -488,6 +488,11 @@ function validateCapabilityGates({ gates, uses, roles, }) {
488
488
  }
489
489
  return errors;
490
490
  }
491
+ /**
492
+ * Platform service-account reference: slug (strictly lowercase) or uuid id.
493
+ * Mirrors atlas/src/server/services/managed-functions/manifest.ts.
494
+ */
495
+ const SERVICE_ACCOUNT_REF_RE = /^([a-z][a-z0-9-]{1,98}|[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})$/;
491
496
  export const managedFunctionManifestSchema = z.object({
492
497
  schema_version: z.literal(1).default(1),
493
498
  function: z.object({
@@ -538,6 +543,17 @@ export const managedFunctionManifestSchema = z.object({
538
543
  }
539
544
  })
540
545
  .default([]),
546
+ /**
547
+ * The platform service account this function ACTS AS for ORM data access
548
+ * (slug or id). Required whenever `capabilities.data` reaches a namespace,
549
+ * rejected otherwise. Resolved and authorized server-side at activation —
550
+ * the deploy actor must hold `actor` on the account when the attachment
551
+ * changes. Mirrors atlas/src/server/services/managed-functions/manifest.ts.
552
+ */
553
+ service_account: z
554
+ .string()
555
+ .regex(SERVICE_ACCOUNT_REF_RE, 'service_account must be a platform service-account slug (lowercase alphanumeric with hyphens) or uuid')
556
+ .optional(),
541
557
  input_schema: z.record(z.string(), z.unknown()).optional(),
542
558
  output_schema: z.record(z.string(), z.unknown()).optional(),
543
559
  /**
@@ -583,4 +599,24 @@ export const managedFunctionManifestSchema = z.object({
583
599
  })) {
584
600
  ctx.addIssue({ code: 'custom', message, path: ['capabilities', 'gates'] });
585
601
  }
602
+ // A namespace is "reached" when its block declares at least one table,
603
+ // action, or raw query — the same rule the server's floor reconciliation
604
+ // uses (readManifestDataNamespaces).
605
+ const reachesData = Object.values(manifest.capabilities.data).some((block) => block.tables.length > 0 || block.actions.length > 0 || block.query);
606
+ if (reachesData && manifest.service_account === undefined) {
607
+ ctx.addIssue({
608
+ code: 'custom',
609
+ message: 'capabilities.data requires a top-level service_account — the platform service ' +
610
+ 'account this function acts as for ORM data access (a platform operator creates ' +
611
+ 'the account; the deployer needs the actor role on it)',
612
+ path: ['service_account'],
613
+ });
614
+ }
615
+ if (!reachesData && manifest.service_account !== undefined) {
616
+ ctx.addIssue({
617
+ code: 'custom',
618
+ message: 'service_account is only used for ORM data access — declare capabilities.data or remove it',
619
+ path: ['service_account'],
620
+ });
621
+ }
586
622
  });
package/dist/login.d.ts CHANGED
@@ -1,12 +1,23 @@
1
+ import { type AuthRealm } from './auth.js';
1
2
  interface LoginOptions {
2
3
  port?: number;
3
4
  timeoutMs?: number;
4
5
  fetchImpl?: typeof fetch;
5
6
  now?: () => number;
6
7
  openBrowser?: (authorizationUrl: string) => void | Promise<void>;
8
+ /** Auth0 login context — the Sequence realm by default, or a registered
9
+ * OpCo environment's realm (`seq-studio envs add`), which pins the Auth0
10
+ * Organization so the token carries the org context tenant APIs require. */
11
+ realm?: AuthRealm;
7
12
  }
8
13
  export declare function openSystemBrowser(authorizationUrl: string): Promise<void>;
9
- export declare function loginWithPkce({ fetchImpl, now, openBrowser, port, timeoutMs, }: LoginOptions): Promise<void>;
10
- export declare function login(): Promise<void>;
11
- export declare function logout(): Promise<void>;
14
+ export declare function loginWithPkce({ fetchImpl, now, openBrowser, port, realm, timeoutMs, }: LoginOptions): Promise<string>;
15
+ /**
16
+ * Best-effort catalog refresh after a successful login. Drop the old cache
17
+ * first so a prior identity's tier never lingers; discovery failures must not
18
+ * fail login. Exported for unit tests.
19
+ */
20
+ export declare function refreshCatalogAfterLogin(): Promise<void>;
21
+ export declare function login(envName?: string): Promise<void>;
22
+ export declare function logout(envName?: string): Promise<void>;
12
23
  export {};
package/dist/login.js CHANGED
@@ -4,8 +4,9 @@ import { createServer } from 'node:http';
4
4
  import { spawn } from 'node:child_process';
5
5
  import { homedir } from 'node:os';
6
6
  import { join } from 'node:path';
7
- import { AUTH0_AUDIENCE, AUTH0_CLIENT_ID, AUTH0_DOMAIN, saveTokens, seqapiTokenPath, } from './auth.js';
8
- import { clearCatalog, fetchCatalog } from './env-catalog.js';
7
+ import { deleteRealmTokens, realmForEnv, saveTokens, seqapiTokenPath, SEQUENCE_AUTH_REALM, SEQUENCE_REALM, verifyTokenMatchesRealm, } from './auth.js';
8
+ import { manualEnvConfigHint } from './config.js';
9
+ import { bootstrapUrl, clearCatalog, fetchCatalog } from './env-catalog.js';
9
10
  const DEFAULT_REDIRECT_PORT = 5099;
10
11
  const DEFAULT_LOGIN_TIMEOUT_MS = 300_000;
11
12
  function base64Url(input) {
@@ -21,16 +22,18 @@ function configuredPort() {
21
22
  }
22
23
  return port;
23
24
  }
24
- function authorizationUrl({ challenge, redirectUri, state, }) {
25
- const url = new URL(`https://${AUTH0_DOMAIN}/authorize`);
25
+ function authorizationUrl({ challenge, realm, redirectUri, state, }) {
26
+ const url = new URL(`https://${realm.domain}/authorize`);
26
27
  url.searchParams.set('response_type', 'code');
27
- url.searchParams.set('client_id', AUTH0_CLIENT_ID);
28
+ url.searchParams.set('client_id', realm.clientId);
28
29
  url.searchParams.set('redirect_uri', redirectUri);
29
- url.searchParams.set('scope', 'openid profile email offline_access');
30
- url.searchParams.set('audience', AUTH0_AUDIENCE);
30
+ url.searchParams.set('scope', 'openid profile email');
31
+ url.searchParams.set('audience', realm.audience);
31
32
  url.searchParams.set('code_challenge', challenge);
32
33
  url.searchParams.set('code_challenge_method', 'S256');
33
34
  url.searchParams.set('state', state);
35
+ if (realm.organization)
36
+ url.searchParams.set('organization', realm.organization);
34
37
  return url.toString();
35
38
  }
36
39
  export async function openSystemBrowser(authorizationUrl) {
@@ -141,21 +144,16 @@ function parseTokenResponse(value) {
141
144
  throw new Error('Auth0 token response was not an object.');
142
145
  }
143
146
  const accessToken = Reflect.get(value, 'access_token');
144
- const refreshToken = Reflect.get(value, 'refresh_token');
145
147
  const expiresIn = Reflect.get(value, 'expires_in');
146
148
  if (typeof accessToken !== 'string' || !accessToken) {
147
149
  throw new Error('Auth0 token response missing access_token.');
148
150
  }
149
- if (typeof refreshToken !== 'string' || !refreshToken) {
150
- throw new Error('No refresh token returned. Ensure Auth0 offline access is enabled.');
151
- }
152
151
  return {
153
152
  accessToken,
154
- refreshToken,
155
153
  expiresIn: typeof expiresIn === 'number' ? expiresIn : 86_400,
156
154
  };
157
155
  }
158
- export async function loginWithPkce({ fetchImpl = fetch, now = Date.now, openBrowser = openSystemBrowser, port = configuredPort(), timeoutMs = DEFAULT_LOGIN_TIMEOUT_MS, }) {
156
+ export async function loginWithPkce({ fetchImpl = fetch, now = Date.now, openBrowser = openSystemBrowser, port = configuredPort(), realm = SEQUENCE_AUTH_REALM, timeoutMs = DEFAULT_LOGIN_TIMEOUT_MS, }) {
159
157
  const verifier = base64Url(randomBytes(32));
160
158
  const challenge = base64Url(createHash('sha256').update(verifier).digest());
161
159
  const state = base64Url(randomBytes(32));
@@ -164,20 +162,21 @@ export async function loginWithPkce({ fetchImpl = fetch, now = Date.now, openBro
164
162
  port,
165
163
  timeoutMs,
166
164
  onListening: (redirectUri) => {
167
- const url = authorizationUrl({ challenge, redirectUri, state });
168
- console.error(`Opening browser for Sequence login. If it does not open, visit:\n${url}`);
165
+ const url = authorizationUrl({ challenge, realm, redirectUri, state });
166
+ console.error(`Opening browser for Sequence login [${realm.name}]. If it does not open, visit:\n${url}`);
169
167
  void Promise.resolve(openBrowser(url)).catch((error) => {
170
168
  const message = error instanceof Error ? error.message : String(error);
171
169
  console.error(`Could not open a browser automatically: ${message}`);
172
170
  });
173
171
  },
174
172
  });
175
- const tokenResponse = await fetchImpl(`https://${AUTH0_DOMAIN}/oauth/token`, {
173
+ const tokenResponse = await fetchImpl(`https://${realm.domain}/oauth/token`, {
176
174
  method: 'POST',
175
+ redirect: 'manual',
177
176
  headers: { 'Content-Type': 'application/json' },
178
177
  body: JSON.stringify({
179
178
  grant_type: 'authorization_code',
180
- client_id: AUTH0_CLIENT_ID,
179
+ client_id: realm.clientId,
181
180
  code: callback.code,
182
181
  redirect_uri: callback.redirectUri,
183
182
  code_verifier: verifier,
@@ -187,47 +186,68 @@ export async function loginWithPkce({ fetchImpl = fetch, now = Date.now, openBro
187
186
  throw new Error(`Auth0 token exchange failed (${tokenResponse.status}): ${await tokenResponse.text()}`);
188
187
  }
189
188
  const tokens = parseTokenResponse(await tokenResponse.json());
189
+ verifyTokenMatchesRealm({ accessToken: tokens.accessToken, realm });
190
190
  await saveTokens({
191
191
  access_token: tokens.accessToken,
192
- refresh_token: tokens.refreshToken,
193
192
  expires_at: now() / 1_000 + tokens.expiresIn,
194
- });
193
+ }, realm.name);
194
+ return tokens.accessToken;
195
195
  }
196
- export async function login() {
197
- await loginWithPkce({});
198
- console.log(`Authenticated. Tokens saved to ${seqapiTokenPath()}.`);
199
- // Refresh the environment catalog for the new identity immediately, so a
200
- // previously cached catalog from another identity (or tier) never lingers
201
- // past a login. Drop the old cache first — if the refresh below fails, the
202
- // prior identity's catalog must not survive the login either.
203
- // Best-effort: discovery being unreachable must not fail login.
196
+ /**
197
+ * Best-effort catalog refresh after a successful login. Drop the old cache
198
+ * first so a prior identity's tier never lingers; discovery failures must not
199
+ * fail login. Exported for unit tests.
200
+ */
201
+ export async function refreshCatalogAfterLogin() {
204
202
  await clearCatalog();
205
203
  try {
206
204
  const catalog = await fetchCatalog();
207
205
  if (catalog) {
208
206
  console.log(`Environment catalog refreshed (tier: ${catalog.tier}, ` +
209
207
  `${catalog.environments.length} environment(s)).`);
208
+ return;
210
209
  }
211
210
  }
212
211
  catch {
213
- console.log('Could not refresh the environment catalog run: seq-studio envs refresh');
212
+ // Network / non-auth discovery errors fall through to the same hint.
213
+ }
214
+ console.log(`Could not load the environment catalog from ${bootstrapUrl()} — ` +
215
+ 'run: seq-studio envs refresh');
216
+ console.log(manualEnvConfigHint());
217
+ }
218
+ export async function login(envName) {
219
+ const realm = await realmForEnv(envName);
220
+ if (envName && realm.name === SEQUENCE_REALM && envName !== SEQUENCE_REALM) {
221
+ // A name that only exists in the Sequence catalog (staging, banksouth…)
222
+ // resolves to the Sequence realm — one login covers all of those. A typo'd
223
+ // OpCo name would silently do a Sequence login otherwise, so say which
224
+ // realm we're using.
225
+ console.error(`'${envName}' uses the shared Sequence login ` +
226
+ '(register OpCo environments with: seq-studio envs add <name> <url>).');
227
+ }
228
+ await loginWithPkce({ realm });
229
+ console.log(`Authenticated [${realm.name}]. Short-lived access token saved to ${seqapiTokenPath()}.`);
230
+ // The environment catalog is a Sequence-deployment surface; OpCo realm
231
+ // logins target a single known deployment and have nothing to discover.
232
+ if (realm.name === SEQUENCE_REALM) {
233
+ await refreshCatalogAfterLogin();
214
234
  }
215
235
  }
216
236
  /**
217
- * Pre-unification artifact-studio token file. `seq-studio artifact` commands
218
- * still fall back to it (artifact-studio's `getAccessToken`
219
- * `readTokenConfig`, see shared/services/artifact-studio/src/config.ts), so
220
- * logout must clear it too or artifact commands would stay authenticated
221
- * after a successful logout.
237
+ * Pre-unification artifact-studio token file. New builds never read it, but
238
+ * logout still removes it so legacy bearer material does not linger on disk.
222
239
  */
223
240
  function legacyArtifactTokenPath() {
224
241
  return join(homedir(), '.config', 'sequence-artifact-studio', 'tokens.json');
225
242
  }
226
- export async function logout() {
227
- await rm(seqapiTokenPath(), { force: true });
228
- await rm(legacyArtifactTokenPath(), { force: true });
229
- // Drop the cached environment catalog so visibility downgrades with the
230
- // identity a logged-out terminal must not keep the old tier's env list.
231
- await clearCatalog();
232
- console.log('Logged out of seq-studio and seqapi.');
243
+ export async function logout(envName) {
244
+ const realm = await realmForEnv(envName);
245
+ await deleteRealmTokens(realm.name);
246
+ if (realm.name === SEQUENCE_REALM) {
247
+ await rm(legacyArtifactTokenPath(), { force: true });
248
+ // The environment catalog belongs to the Sequence identity; tenant logout
249
+ // must not discard a still-authenticated Sequence user's cached tier.
250
+ await clearCatalog();
251
+ }
252
+ console.log(`Logged out [${realm.name}] from seq-studio and seqapi.`);
233
253
  }