@sequenceholdings/artifact-studio 0.8.0 → 0.9.0

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 (44) hide show
  1. package/README.md +101 -3
  2. package/dist/api.d.ts +2 -1
  3. package/dist/api.js +15 -5
  4. package/dist/cli.d.ts +2 -0
  5. package/dist/cli.js +58 -20
  6. package/dist/gateway/authorization-cache.js +1 -1
  7. package/dist/gateway/control.d.ts +13 -1
  8. package/dist/gateway/control.js +3 -0
  9. package/dist/gateway/datadog-startup.d.ts +13 -0
  10. package/dist/gateway/datadog-startup.js +51 -0
  11. package/dist/gateway/hosted-proxy.d.ts +2 -0
  12. package/dist/gateway/hosted-proxy.js +9 -11
  13. package/dist/gateway/ingress-handler.js +2 -0
  14. package/dist/gateway/server.d.ts +1 -1
  15. package/dist/gateway/server.js +51 -20
  16. package/dist/gateway/session.d.ts +14 -0
  17. package/dist/gateway/session.js +31 -2
  18. package/dist/gateway-main.js +5 -3
  19. package/dist/next-build.d.ts +3 -1
  20. package/dist/next-build.js +10 -6
  21. package/dist/sdk.d.ts +2 -0
  22. package/dist/server/agent-responses.d.ts +425 -0
  23. package/dist/server/agent-responses.js +154 -0
  24. package/dist/server/broker-contract.d.ts +5 -0
  25. package/dist/server/broker-contract.js +6 -2
  26. package/dist/server/client.d.ts +2 -0
  27. package/dist/server/client.js +10 -2
  28. package/dist/server/processes.d.ts +11 -0
  29. package/dist/server/request-authentication.d.ts +42 -0
  30. package/dist/server/request-authentication.js +93 -0
  31. package/dist/server/request-context.d.ts +1 -0
  32. package/dist/server/request-context.js +1 -0
  33. package/dist/server.d.ts +2 -1
  34. package/dist/server.js +1 -1
  35. package/dist/templates/react-vite/package.json +1 -1
  36. package/dist/tunnel/broker.d.ts +14 -1
  37. package/dist/tunnel/broker.js +94 -10
  38. package/dist/tunnel/client.js +6 -0
  39. package/dist/tunnel/redis-types.d.ts +5 -1
  40. package/dist/tunnel/redis.d.ts +12 -0
  41. package/dist/tunnel/redis.js +102 -4
  42. package/dist/tunnel/transport.d.ts +3 -0
  43. package/package.json +3 -3
  44. package/templates/react-vite/package.json +1 -1
package/README.md CHANGED
@@ -163,6 +163,25 @@ workspace source awaiting a matching SDK release and platform deployment;
163
163
  they are not a newly supported public SDK or direct HTTP contract. See
164
164
  [VERSION-PIN.md](./VERSION-PIN.md) for compatibility requirements.
165
165
 
166
+ Human completion receipts require a genuine Auth0 browser authentication carried
167
+ by the platform. The gateway signs a separate, optional authentication proof
168
+ bound to the exact request context; the server SDK forwards it unchanged.
169
+ Legacy contexts continue to work without producing human receipts. Service mode,
170
+ external ingress, impersonation and an authored `assertedBy` never supply human
171
+ authentication. This proof requires the matching platform and gateway release.
172
+
173
+ `client.processes.start` accepts an optional `version` selector for a registered
174
+ immutable process version. Omitting it selects the active version. In the
175
+ upcoming SDK 0.8.1, use this selector to start a retained release after the
176
+ active process has been promoted.
177
+
178
+ The separate `expectedProcess: { version, bundleHash }` option asserts the
179
+ resolved target; it does not select a version. Atlas returns
180
+ `409 PROCESS_IDENTITY_MISMATCH` before creating a run with a different
181
+ definition. Retrying a known run ID compares its persisted definition, so a
182
+ lost response can recover the original run after a process promotion. Current
183
+ process admission and manifest capability checks still apply.
184
+
166
185
  On the request-scoped client returned by `createArtifactServerClient`, declare
167
186
  the exact process IDs the application reads or acts on:
168
187
 
@@ -350,15 +369,80 @@ until the host rollout completes. Host/session replacement closes the preview.
350
369
 
351
370
  ## Calling agents from a Next.js server
352
371
 
372
+ The upcoming SDK **0.8.1** adds typed, user-mode job admission. Obtain the
373
+ reviewed source tuple with `client.agents.getCurrentIdentity({ kind, id })` and
374
+ pass it as `expectedIdentity` to `createJob` or `createWorkflowJob`, together
375
+ with a stable `idempotencyKey`. An application should retain its reviewed tuple
376
+ and execution intent rather than silently replace a mismatched source with the
377
+ latest definition. The current user's permissions are checked at admission and
378
+ recovery; neither the tuple nor the key grants access.
379
+
380
+ Declare the exact read routes for current identity and these strict write
381
+ routes in the server manifest:
382
+
383
+ - `POST /api/agents/jobs/create-idempotent` for `createJob`.
384
+ - `POST /api/agents/workflows/*/jobs` for `createWorkflowJob`.
385
+
386
+ Keep the same key and semantic request when recovering an uncertain outcome.
387
+ Changed input or source pins under an existing key are refused. A matching
388
+ replay returns the original admission response without resolving a newer source
389
+ head; read the Job separately for its current execution status. These methods use distinct
390
+ strict endpoints so an older host fails before creating a legacy job that
391
+ ignores the idempotency contract. Do not fall back to the legacy create route
392
+ or generate another key merely because an acknowledgement was lost.
393
+
394
+ `client.agents.getCompletedRun({ agent, runId })` reads a completed response as
395
+ the current user. Declare its `GET /api/agents/jobs/*/completed-response` route
396
+ in `server.capabilities.api.read`. Both the run and private thread must belong
397
+ to that user, and current Agent, Job and Thread read grants remain enforced.
398
+ The bounded response carries JSON output, assistant text and citation resources;
399
+ it omits reasoning, tool inputs and arbitrary tool results. Reused or changed
400
+ conversations, incomplete runs and results over 1,000 messages or 8 MiB fail
401
+ explicitly. Service mode and machine credentials cannot use this method.
402
+
403
+ `client.agents.captureCompletedRun({ agent, runId })` returns the neutral response
404
+ with a historical `responseHash` and `sourceReceipt`. Explicit
405
+ `client.agents.captureCompletedConversation({ agent, runId })` additionally
406
+ captures permitted user/assistant text and tool inputs/results in
407
+ `response.context`. Declare the respective `completed-source` and
408
+ `completed-conversation` GET routes. Atlas requires current Agent, Job and Thread
409
+ read grants for the actual private source. These user-only captures reject changed
410
+ or unfinished history, recognizable credentials, unverified compaction context
411
+ and responses over 512 KiB. A receipt proves the captured bytes and actual actor;
412
+ receiving functions verify it under their own live evidence capability, and the
413
+ application separately validates its protected source/release association.
414
+
415
+ `client.agents.captureConversation({ agent, threadId })` explicitly captures a
416
+ sidebar transcript through `GET /api/agents/threads/*/conversation-source`.
417
+ Its response binds a durable settled `revision` and `cutoffAt`, with the same
418
+ permitted `context` and neutral `conversation` metrics. It has no invented agent
419
+ run ID. Active, interrupted or legacy conversations without proven settlement
420
+ are refused. The receiver uses `verifyConversationEvidence` and checks the
421
+ application's protected sidebar/source association before persisting it.
422
+
423
+ `client.agents.captureCompletedWorkflow({ workflow, runId })` explicitly
424
+ captures a completed standalone Workflow source as an authenticated human with
425
+ current access. The capturing user remains distinct from the original source
426
+ owner recorded in the evidence.
427
+ Declare `GET /api/agents/jobs/*/completed-workflow-source`. The typed response
428
+ contains the persisted merged root output and historical source evidence,
429
+ including real branch outcomes and the original job-admission association;
430
+ it is not an Agent conversation. Current Workflow Invoker and root/child Job
431
+ visibility remain required. Incomplete or changed sources and captures over
432
+ 512 KiB fail explicitly. A receiver verifies the historical bytes with its
433
+ separate Workflow evidence capability and checks the application's current
434
+ protected intent; a source receipt grants no current task or write authority.
435
+
353
436
  An application that declares `/api/agents/` in `server.capabilities.api.read`
354
437
  and `write` can reach a fixed set of agents API routes as the signed-in user:
355
438
 
356
439
  | Area | Routes |
357
440
  | --- | --- |
358
441
  | Chat | `POST chat`, `POST chat/abort`, `GET chat/resume` |
359
- | Agents | `GET agents`, `GET agents/:id` |
360
- | Threads | `GET threads`, `POST threads`, `GET threads/:id`, `GET threads/:id/messages`, `PATCH threads/:id/messages/:messageId` |
361
- | Jobs | `POST jobs`, `GET jobs/:id`, `DELETE jobs/:id` |
442
+ | Agents | `GET agents`, `GET agents/:id`, `GET agents/:id/current-identity` |
443
+ | Threads | `GET threads`, `POST threads`, `GET threads/:id`, `GET threads/:id/messages`, `GET threads/:id/conversation-source`, `PATCH threads/:id/messages/:messageId` |
444
+ | Jobs | `POST jobs`, `POST jobs/create-idempotent`, `GET jobs/:id`, `GET jobs/:id/completed-response`, `GET jobs/:id/completed-source`, `GET jobs/:id/completed-conversation`, `GET jobs/:id/completed-workflow-source`, `DELETE jobs/:id` |
445
+ | Workflows | `GET workflows/:id/current-identity`, `POST workflows/:id/jobs` |
362
446
  | Files | `POST files`, `GET files/:id`, `DELETE files/:id`, `GET files/:id/url` |
363
447
 
364
448
  Atlas answers them with the agents API's own handlers, so agent `can_invoke`,
@@ -416,3 +500,17 @@ streaming and file-transfer helpers retain their ordinary host tracing behavior.
416
500
  Tracing does not grant permissions, change service-account behavior or guarantee
417
501
  that every transport is instrumented. Traces availability depends on the deployed
418
502
  platform version and your app/resource permissions.
503
+
504
+
505
+ ### Microphone dictation in an isolated application
506
+
507
+ Explicitly declare browser API read `/api/files/speech/config/get` and write
508
+ `/api/files/speech/stt` to opt an isolated application iframe into microphone
509
+ requests. Both exact paths are required; broad API wildcards do not enable it.
510
+ The browser still asks the user for permission. Existing sandbox origin privileges
511
+ are unchanged, and camera/geolocation remain unavailable. Legacy opaque-origin
512
+ static frames do not receive microphone delegation.
513
+ Upload with `api.upload` option `{ fieldName: 'audio' }`; ordinary uploads still
514
+ default to `file`. Atlas transcription trims silence by default; multipart
515
+ `trimSilence=false` opts out. Speech uses the declared host paths without requiring
516
+ the separate general Next host API rollout flag.
package/dist/api.d.ts CHANGED
@@ -12,11 +12,12 @@ export declare function getJson<T>({ baseUrl, token, path }: ApiClientOptions &
12
12
  export declare function getJsonOr404<T>({ baseUrl, token, path }: ApiClientOptions & {
13
13
  path: string;
14
14
  }): Promise<T | null>;
15
- export declare function postJson<T>({ baseUrl, token, path, body, retry503, signal, }: ApiClientOptions & {
15
+ export declare function postJson<T>({ baseUrl, token, path, body, retry503, signal, headers, }: ApiClientOptions & {
16
16
  path: string;
17
17
  body?: unknown;
18
18
  retry503?: boolean;
19
19
  signal?: AbortSignal;
20
+ headers?: Record<string, string>;
20
21
  }): Promise<T>;
21
22
  export declare function deleteJson({ baseUrl, token, path, signal }: ApiClientOptions & {
22
23
  path: string;
package/dist/api.js CHANGED
@@ -19,7 +19,17 @@ const DEFAULT_RETRY_AFTER_SECONDS = 2;
19
19
  * Content-Type) always win over anything here. Malformed JSON is ignored — a
20
20
  * bad bypass header should never crash a deploy before the request is even made.
21
21
  */
22
- function extraHeaders() {
22
+ function extraHeaders(baseUrl) {
23
+ // Preview gateway credentials belong only to the target, never to a separately
24
+ // authenticated source repository. An unbound header set fails closed.
25
+ const target = process.env.ARTIFACT_STUDIO_BASE_URL;
26
+ try {
27
+ if (!target || new URL(target).origin !== new URL(baseUrl).origin)
28
+ return {};
29
+ }
30
+ catch {
31
+ return {};
32
+ }
23
33
  const raw = process.env.ARTIFACT_STUDIO_EXTRA_HEADERS?.trim();
24
34
  if (!raw)
25
35
  return {};
@@ -33,7 +43,7 @@ function extraHeaders() {
33
43
  // Prevent case-variant duplicates from being merged by Headers/fetch
34
44
  // (e.g. `authorization` + `Authorization` => "a, b"). The client always
35
45
  // sets these explicitly below, so ignore any env-supplied variants.
36
- if (lower === 'authorization' || lower === 'content-type')
46
+ if (lower === 'authorization' || lower === 'content-type' || lower === 'x-sequence-source-authorization')
37
47
  continue;
38
48
  if (typeof value === 'string')
39
49
  headers[key] = value;
@@ -64,7 +74,7 @@ async function authenticatedFetch({ baseUrl, init = {}, path, token, retry503 =
64
74
  ...init,
65
75
  redirect: 'manual',
66
76
  headers: {
67
- ...extraHeaders(),
77
+ ...extraHeaders(baseUrl),
68
78
  ...init.headers,
69
79
  Authorization: `Bearer ${token}`,
70
80
  },
@@ -84,7 +94,7 @@ export async function getJsonOr404({ baseUrl, token, path }) {
84
94
  throw new ArtifactApiError(await responseError('GET', path, response), response.status);
85
95
  return response.json();
86
96
  }
87
- export async function postJson({ baseUrl, token, path, body, retry503 = true, signal, }) {
97
+ export async function postJson({ baseUrl, token, path, body, retry503 = true, signal, headers, }) {
88
98
  const response = await authenticatedFetch({
89
99
  baseUrl,
90
100
  token,
@@ -92,7 +102,7 @@ export async function postJson({ baseUrl, token, path, body, retry503 = true, si
92
102
  retry503,
93
103
  init: {
94
104
  method: 'POST',
95
- headers: { 'Content-Type': 'application/json' },
105
+ headers: { ...headers, 'Content-Type': 'application/json' },
96
106
  body: body === undefined ? undefined : JSON.stringify(body),
97
107
  ...(signal ? { signal } : {}),
98
108
  },
package/dist/cli.d.ts CHANGED
@@ -32,6 +32,8 @@ export declare function getConfiguredDefaultEnv(): Promise<string | undefined>;
32
32
  */
33
33
  export interface TokenProviderOptions {
34
34
  allowInteractiveLogin: boolean;
35
+ /** A separate source session; omitted for target requests. Never a caller URL. */
36
+ sourceEnvironment?: string;
35
37
  }
36
38
  export interface ProvidedToken {
37
39
  authMode: SourceAuthMode;
package/dist/cli.js CHANGED
@@ -3,7 +3,7 @@ import { constants, existsSync } from 'node:fs';
3
3
  import { dirname, join, relative, resolve } from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  import { getJson, getJsonOr404, postJson } from './api.js';
6
- import { ENV_URLS, readLocalConfig, resolveEnvironment, writeLocalConfig, } from './config.js';
6
+ import { ENV_URLS, knownEnvUrls, readLocalConfig, resolveEnvironment, writeLocalConfig, } from './config.js';
7
7
  import { writeBuildArtifact } from './build.js';
8
8
  import { buildArtifactStudioProjectIsolated } from './build-subprocess.js';
9
9
  import { resolveArtifactStudioPeerVersions, } from './peer-versions.js';
@@ -33,9 +33,9 @@ const USAGE = `usage:
33
33
  seq-studio artifact list --env <env> [--json]
34
34
  seq-studio artifact show <slug-or-id> --env <env> [--json]
35
35
  seq-studio artifact validate [dir]
36
- seq-studio artifact build [dir | --repo <ns>/<name> | --git-url <url>] [--ref <ref>] [--path <dir>] [--out dist/artifact-bundle.json]
37
- seq-studio artifact plan [dir | --repo <ns>/<name> | --git-url <url>] [--ref <ref>] [--path <dir>] --env <env>
38
- seq-studio artifact deploy [dir | --repo <ns>/<name> | --git-url <url>] [--ref <ref>] [--path <dir>] --env <env> [--skip-unchanged] [--no-create] [--project <id>]
36
+ seq-studio artifact build [dir | --repo <ns>/<name> | --git-url <url>] [--ref <ref>] [--path <dir>] [--source-env <env>] [--out dist/artifact-bundle.json]
37
+ seq-studio artifact plan [dir | --repo <ns>/<name> | --git-url <url>] [--ref <ref>] [--path <dir>] [--source-env <env>] --env <env>
38
+ seq-studio artifact deploy [dir | --repo <ns>/<name> | --git-url <url>] [--ref <ref>] [--path <dir>] [--source-env <env>] --env <env> [--skip-unchanged] [--no-create] [--project <id>]
39
39
  seq-studio artifact dev [dir] --env <env> [--once] [--key default] [--link <pkgDir>[,<pkgDir>]] [--port <port>]
40
40
  seq-studio artifact promote <deployment-id> --env <env>
41
41
  seq-studio artifact pull <project-id> --env <env> --out <dir>
@@ -51,7 +51,7 @@ const USAGE = `usage:
51
51
  --repo / --git-url repository (default: its root). The target project comes from
52
52
  the source's artifact.bundle.yml project_id.
53
53
 
54
- Official (active) deploys to deployed environments require \`main\` with a
54
+ Official (active) deploys require \`main\` (or \`staging\` for staging targets) with a
55
55
  resolved git commit and a clean artifact source directory. Dirty files
56
56
  outside a nested local source directory are ignored with a warning.
57
57
  Deployments targeting \`local\` may use any branch, including dirty working
@@ -61,7 +61,11 @@ const USAGE = `usage:
61
61
  Interactive --repo builds clone over smart-HTTP using your seq-studio login
62
62
  and repository read access. No PAT is needed. Headless M2M builds use JSON
63
63
  materialize and accept only platform-managed --repo sources. Use --env to
64
- select the same environment for source and deployment.
64
+ select the target environment. Next.js build/plan/deploy accept --source-env
65
+ to read a Git Service repository from a separately authenticated environment.
66
+ Log in to both environments; source read and target deploy permissions apply.
67
+ The target must support verified cross-environment builds. Source credentials
68
+ are used only to fetch source, never passed to the build or stored with it.
65
69
 
66
70
  Next.js: init --template nextjs, then pnpm install with Node.js 24. dev starts
67
71
  a local Next server and an owner-only Atlas preview tunnel (no cloud credentials).
@@ -114,9 +118,9 @@ function validateKnownOptions(args) {
114
118
  link: { values: ['env', 'project'], booleans: [] }, env: { values: [], booleans: [] },
115
119
  status: { values: ['env'], booleans: [] }, list: { values: ['env'], booleans: ['json'] },
116
120
  show: { values: ['env'], booleans: ['json'] }, validate: { values: [], booleans: [] },
117
- build: { values: ['env', 'repo', 'git-url', 'ref', 'path', 'out'], booleans: [] },
118
- plan: { values: ['env', 'repo', 'git-url', 'ref', 'path'], booleans: [] },
119
- deploy: { values: ['env', 'repo', 'git-url', 'ref', 'path', 'project', 'out'], booleans: ['skip-unchanged', 'no-create'] },
121
+ build: { values: ['source-env', 'env', 'repo', 'git-url', 'ref', 'path', 'out'], booleans: [] },
122
+ plan: { values: ['source-env', 'env', 'repo', 'git-url', 'ref', 'path'], booleans: [] },
123
+ deploy: { values: ['source-env', 'env', 'repo', 'git-url', 'ref', 'path', 'project', 'out'], booleans: ['skip-unchanged', 'no-create'] },
120
124
  dev: { values: ['env', 'key', 'link', 'port'], booleans: ['once'] },
121
125
  promote: { values: ['env'], booleans: [] }, pull: { values: ['env', 'out'], booleans: [] },
122
126
  rollback: { values: ['env'], booleans: [] },
@@ -441,16 +445,17 @@ function reportBuildDiagnostics(result) {
441
445
  }
442
446
  async function buildCommand(args) {
443
447
  const spec = parseSourceSpec(args);
448
+ const sourceEnvironment = sourceEnvironmentFromArgs(args);
444
449
  // Local builds stay offline. Public git URLs need no bearer token, but an
445
450
  // optional auth lookup still identifies a selected M2M principal so the
446
451
  // source policy can reject arbitrary CI input before cloning.
447
- const auth = spec.kind === 'git-service'
448
- ? await getRequiredAuth(args)
452
+ const auth = spec.kind === 'git-service' || sourceEnvironment
453
+ ? await getSourceAuth(args)
449
454
  : spec.kind === 'git-url'
450
455
  ? await getOptionalAuth(args)
451
456
  : null;
452
457
  const remote = spec.kind === 'git-service' && auth
453
- ? { authMode: auth.authMode, baseUrl: (await envFromArgs(args)).url, token: auth.token }
458
+ ? { authMode: auth.authMode, baseUrl: (sourceEnvironment ?? await envFromArgs(args)).url, token: auth.token }
454
459
  : auth
455
460
  ? { authMode: auth.authMode }
456
461
  : {};
@@ -459,6 +464,7 @@ async function buildCommand(args) {
459
464
  if (isNextArtifactManifest(await readArtifactStudioManifest(source.dir))) {
460
465
  return await buildNextCommand({ args, source, active: false });
461
466
  }
467
+ assertSourceEnvironmentSupportsManifest(args);
462
468
  const result = await buildPreparedArtifactSource(source);
463
469
  reportBuildDiagnostics(result);
464
470
  // A remote source's dir is a temp dir we tear down below, so default the
@@ -483,10 +489,12 @@ async function planCommand(args) {
483
489
  const auth = await getOptionalAuth(args);
484
490
  const token = auth?.token ?? null;
485
491
  const spec = parseSourceSpec(args);
492
+ const sourceEnvironment = sourceEnvironmentFromArgs(args);
493
+ const sourceAuth = sourceEnvironment ? await getSourceAuth(args) : auth;
486
494
  const source = await resolveArtifactSource(spec, {
487
- ...(auth ? { authMode: auth.authMode } : {}),
488
- baseUrl: env.url,
489
- token,
495
+ ...(sourceAuth ? { authMode: sourceAuth.authMode } : {}),
496
+ baseUrl: (sourceEnvironment ?? env).url,
497
+ token: sourceAuth?.token ?? null,
490
498
  });
491
499
  try {
492
500
  const manifest = await readArtifactStudioManifest(source.dir);
@@ -494,6 +502,7 @@ async function planCommand(args) {
494
502
  const { planNextProject } = await import('./next-build.js');
495
503
  return await planNextProject({ root: source.dir, manifest, environment: env.name });
496
504
  }
505
+ assertSourceEnvironmentSupportsManifest(args);
497
506
  const result = await buildPreparedArtifactSource(source);
498
507
  reportBuildDiagnostics(result);
499
508
  console.log(`[seq-studio] plan ${result.manifest.artifact.project_id} -> ${env.name}`);
@@ -539,10 +548,12 @@ async function deployCommand(args) {
539
548
  const { token } = auth;
540
549
  const spec = parseSourceSpec(args);
541
550
  const explicitProjectId = typeof args.flags.project === 'string' ? args.flags.project : null;
551
+ const sourceEnvironment = sourceEnvironmentFromArgs(args);
552
+ const sourceAuth = sourceEnvironment ? await getSourceAuth(args) : auth;
542
553
  const source = await resolveArtifactSource(spec, {
543
- authMode: auth.authMode,
544
- baseUrl: env.url,
545
- token,
554
+ authMode: sourceAuth.authMode,
555
+ baseUrl: (sourceEnvironment ?? env).url,
556
+ token: sourceAuth.token,
546
557
  });
547
558
  try {
548
559
  // Fail closed before build / project create for deployed environments.
@@ -558,6 +569,7 @@ async function deployCommand(args) {
558
569
  if (isNextArtifactManifest(manifest)) {
559
570
  return await buildNextCommand({ args, source, active: true });
560
571
  }
572
+ assertSourceEnvironmentSupportsManifest(args);
561
573
  // --skip-unchanged: hash the raw source (no build needed) and no-op when
562
574
  // the remote active deployment already matches *and* was built with the
563
575
  // same CLI/atlas-ui pin. Lets CI redeploy every artifact on every release
@@ -618,8 +630,10 @@ async function buildNextCommand({ args, source, active }) {
618
630
  if (active) {
619
631
  assertActiveDeployProvenance({ ...source.provenance, environment: env.name });
620
632
  }
621
- const repositoryId = active || spec.kind === 'git-service'
622
- ? await resolveNextRepositoryId({ spec, repository, baseUrl: env.url, token })
633
+ const sourceEnvironment = sourceEnvironmentFromArgs(args);
634
+ const sourceToken = sourceEnvironment ? (await getSourceAuth(args)).token : token;
635
+ const repositoryId = active || spec.kind === 'git-service' || sourceEnvironment
636
+ ? await resolveNextRepositoryId({ spec, repository, baseUrl: (sourceEnvironment ?? env).url, token: sourceToken })
623
637
  : undefined;
624
638
  const project = await ensureRemoteProject({
625
639
  env: env.url, token, result: { manifest }, linkConfigDir: spec.kind === 'local' ? source.dir : null,
@@ -632,6 +646,7 @@ async function buildNextCommand({ args, source, active }) {
632
646
  return submitNextBuild({
633
647
  root: source.dir, sourceRoot: source.sourceRoot, manifest, projectId: project.id, baseUrl: env.url,
634
648
  getToken: () => getRequiredToken(args), provenance: source.provenance, repository, repositoryId,
649
+ ...(sourceEnvironment ? { sourceEnvironment: sourceEnvironment.name, sourceToken } : {}),
635
650
  channel: active ? 'active' : 'preview', out: typeof args.flags.out === 'string' ? resolve(args.flags.out) : undefined,
636
651
  });
637
652
  }
@@ -1027,6 +1042,29 @@ export async function getConfiguredDefaultEnv() {
1027
1042
  async function envFromArgs(args) {
1028
1043
  return resolveEnvironment(typeof args.flags.env === 'string' ? args.flags.env : undefined, await getConfiguredDefaultEnv());
1029
1044
  }
1045
+ /** Source selection must never inherit the target URL override. */
1046
+ function sourceEnvironmentFromArgs(args) {
1047
+ const name = args.flags['source-env'];
1048
+ if (typeof name !== 'string')
1049
+ return undefined;
1050
+ const url = knownEnvUrls()[name];
1051
+ if (typeof url !== 'string' || !url)
1052
+ throw new Error(`Unknown source environment "${name}". Run seq-studio envs list and login to the source environment.`);
1053
+ return { name, url };
1054
+ }
1055
+ async function getSourceAuth(args) {
1056
+ const source = sourceEnvironmentFromArgs(args);
1057
+ if (!source)
1058
+ return getRequiredAuth(args);
1059
+ const auth = await resolveAuth({ allowInteractiveLogin: true, sourceEnvironment: source.name });
1060
+ if (!auth)
1061
+ throw new Error(`Authenticate to the source with seq-studio login -e ${source.name}.`);
1062
+ return auth;
1063
+ }
1064
+ function assertSourceEnvironmentSupportsManifest(args) {
1065
+ if (args.flags['source-env'])
1066
+ throw new Error('--source-env is supported for Next.js repository builds only.');
1067
+ }
1030
1068
  let tokenProvider = null;
1031
1069
  export function setTokenProvider(provider) {
1032
1070
  tokenProvider = provider;
@@ -121,7 +121,7 @@ function digest(parts) {
121
121
  * bridge values and the cookie's own expiry do not change the decision. */
122
122
  export function gatewayAuthorizationKey(session) {
123
123
  return digest(['session-v1', session.kind, session.projectId, session.ownerId, session.environment, session.publicOrigin,
124
- session.sessionId, session.deploymentId, session.frameworkBuildId, session.published, session.lattice ?? null, session.taskRoutes ?? null]);
124
+ session.sessionId, session.deploymentId, session.frameworkBuildId, session.published, session.lattice ?? null, session.taskRoutes ?? null, session.authentication ?? null]);
125
125
  }
126
126
  /** A tunnel credential is keyed by its hash, never retained in the key itself. */
127
127
  export function tunnelAuthorizationKey({ token, sessionId, epoch }) {
@@ -1,6 +1,6 @@
1
1
  import { z } from 'zod';
2
2
  import type { TunnelLease } from '../tunnel/index.js';
3
- import type { GatewaySession } from './session.js';
3
+ import { type GatewaySession } from './session.js';
4
4
  import type { GatewayUsageEvent } from './usage.js';
5
5
  import { type IngressAuditBatch, type IngressExchangeRequest, type IngressExchangeResponse, type IngressGrantRequest, type IngressGrantResponse, type IngressRevokeRequest, type IngressSnapshot } from '../server/ingress-control-contract.js';
6
6
  export declare const gatewayTargetSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
@@ -22,6 +22,10 @@ export declare const gatewayTargetSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
22
22
  processId: z.ZodNullable<z.ZodString>;
23
23
  }, z.core.$strict>>;
24
24
  taskRoutes: z.ZodOptional<z.ZodArray<z.ZodString>>;
25
+ authentication: z.ZodOptional<z.ZodObject<{
26
+ method: z.ZodLiteral<"auth0_user">;
27
+ sub: z.ZodString;
28
+ }, z.core.$strict>>;
25
29
  manifest: z.ZodOptional<z.ZodObject<{
26
30
  server: z.ZodObject<{
27
31
  resources: z.ZodObject<{
@@ -58,6 +62,10 @@ export declare const gatewayTargetSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
58
62
  processId: z.ZodNullable<z.ZodString>;
59
63
  }, z.core.$strict>>;
60
64
  taskRoutes: z.ZodOptional<z.ZodArray<z.ZodString>>;
65
+ authentication: z.ZodOptional<z.ZodObject<{
66
+ method: z.ZodLiteral<"auth0_user">;
67
+ sub: z.ZodString;
68
+ }, z.core.$strict>>;
61
69
  manifest: z.ZodOptional<z.ZodObject<{
62
70
  server: z.ZodObject<{
63
71
  resources: z.ZodObject<{
@@ -88,6 +96,10 @@ export declare const gatewayTargetSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
88
96
  processId: z.ZodNullable<z.ZodString>;
89
97
  }, z.core.$strict>>;
90
98
  taskRoutes: z.ZodOptional<z.ZodArray<z.ZodString>>;
99
+ authentication: z.ZodOptional<z.ZodObject<{
100
+ method: z.ZodLiteral<"auth0_user">;
101
+ sub: z.ZodString;
102
+ }, z.core.$strict>>;
91
103
  manifest: z.ZodOptional<z.ZodObject<{
92
104
  server: z.ZodObject<{
93
105
  resources: z.ZodObject<{
@@ -1,6 +1,7 @@
1
1
  import { createHash, timingSafeEqual } from 'node:crypto';
2
2
  import { z } from 'zod';
3
3
  import { normalizePublicOrigin } from '../tunnel/protocol.js';
4
+ import { gatewayAuthenticationSchema } from './session.js';
4
5
  import { artifactRequestContextSchema } from '../server/request-context.js';
5
6
  import { ARTIFACT_NEXT_CONTROL_PATH, artifactNextFrameworkBuildIdSchema } from '../runtime-contracts.js';
6
7
  import { untilAborted } from './abort.js';
@@ -13,6 +14,7 @@ const base = {
13
14
  capabilities: z.array(id).max(256).default([]), serviceAccountId: id.nullable().default(null),
14
15
  lattice: artifactRequestContextSchema.shape.lattice.optional(),
15
16
  taskRoutes: artifactRequestContextSchema.shape.taskRoutes,
17
+ authentication: gatewayAuthenticationSchema.optional(),
16
18
  manifest: z.object({ server: z.object({ resources: z.object({
17
19
  concurrency: z.number().int().min(1).max(80), max_instances: z.number().int().min(1).max(100), timeout_seconds: z.number().int().min(1).max(300),
18
20
  }) }) }).optional(),
@@ -228,6 +230,7 @@ export class ArtifactGatewayControl {
228
230
  published: target.kind !== 'development' && (target.published ?? target.active), bridge,
229
231
  ...(target.lattice ? { lattice: target.lattice } : {}),
230
232
  ...(target.taskRoutes ? { taskRoutes: target.taskRoutes } : {}),
233
+ ...(target.authentication ? { authentication: target.authentication } : {}),
231
234
  };
232
235
  return { session, target };
233
236
  }
@@ -0,0 +1,13 @@
1
+ /** Messages contain configuration names, never supplied values or connection errors. */
2
+ export declare class DatadogStartupError extends Error {
3
+ }
4
+ /**
5
+ * Replaces the sidecar's TCP startup probe only in the opt-in image mode.
6
+ * The app remains serverless-init's direct child, so its existing signal and
7
+ * drain handlers still own shutdown. This checks collector startup, not remote
8
+ * telemetry delivery; the app's /health probe continues to check its own readiness.
9
+ */
10
+ export declare function waitForDatadogStartup({ env, timeoutMs, }: {
11
+ env: Record<string, string | undefined>;
12
+ timeoutMs?: number;
13
+ }): Promise<void>;
@@ -0,0 +1,51 @@
1
+ import { createConnection } from 'node:net';
2
+ import { setTimeout as delay } from 'node:timers/promises';
3
+ /** Messages contain configuration names, never supplied values or connection errors. */
4
+ export class DatadogStartupError extends Error {
5
+ }
6
+ function collectorListening({ port, timeoutMs }) {
7
+ return new Promise(resolve => {
8
+ const socket = createConnection({ host: '127.0.0.1', port });
9
+ let finished = false;
10
+ const finish = (ready) => {
11
+ if (finished)
12
+ return;
13
+ finished = true;
14
+ clearTimeout(timer);
15
+ socket.destroy();
16
+ resolve(ready);
17
+ };
18
+ const timer = setTimeout(() => finish(false), timeoutMs);
19
+ socket.once('connect', () => finish(true));
20
+ socket.once('error', () => finish(false));
21
+ });
22
+ }
23
+ /**
24
+ * Replaces the sidecar's TCP startup probe only in the opt-in image mode.
25
+ * The app remains serverless-init's direct child, so its existing signal and
26
+ * drain handlers still own shutdown. This checks collector startup, not remote
27
+ * telemetry delivery; the app's /health probe continues to check its own readiness.
28
+ */
29
+ export async function waitForDatadogStartup({ env, timeoutMs = 30_000, }) {
30
+ const mode = env.ARTIFACT_NEXT_DATADOG_IN_CONTAINER;
31
+ if (mode === undefined || mode === 'false')
32
+ return;
33
+ if (mode !== 'true')
34
+ throw new DatadogStartupError('Invalid ARTIFACT_NEXT_DATADOG_IN_CONTAINER: expected true or false');
35
+ const configuredPort = env.DD_HEALTH_PORT ?? '5555';
36
+ const port = Number(configuredPort);
37
+ if (!/^\d+$/.test(configuredPort) || !Number.isSafeInteger(port) || port < 1 || port > 65535)
38
+ throw new DatadogStartupError('Invalid DD_HEALTH_PORT: expected an integer from 1 to 65535');
39
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0 || timeoutMs > 30_000)
40
+ throw new DatadogStartupError('Invalid Datadog startup budget: expected at most 30000 milliseconds');
41
+ // A monotonic deadline includes both connection attempts and retry delays.
42
+ const deadline = performance.now() + timeoutMs;
43
+ while (performance.now() < deadline) {
44
+ if (await collectorListening({ port, timeoutMs: Math.min(1_000, deadline - performance.now()) }))
45
+ return;
46
+ const remaining = deadline - performance.now();
47
+ if (remaining > 0)
48
+ await delay(Math.min(100, remaining));
49
+ }
50
+ throw new DatadogStartupError('Datadog collector did not become ready within the startup budget');
51
+ }
@@ -18,6 +18,8 @@ export interface HostedArtifactProxyOptions {
18
18
  deploymentId: string;
19
19
  };
20
20
  requestContext: string;
21
+ /** Optional actual-human proof generated alongside this exact request context by the gateway. */
22
+ requestAuthentication?: string;
21
23
  /** Admission may inspect a native form prefix and replay it without buffering the body. */
22
24
  body?: AsyncIterable<Uint8Array>;
23
25
  identityTokenProvider?: (input: {
@@ -9,6 +9,7 @@ import { createReleaseFormTransform } from '../server/release-forms.js';
9
9
  import { declaredContentLength, INGRESS_PROXY_ERROR_CODES, INGRESS_PROXY_LIMITS, writeIngressResponseHead } from './ingress-response.js';
10
10
  import { resolveHostedTarget } from './runtime-transport.js';
11
11
  import { gatewayElapsedMs } from './usage.js';
12
+ import { ArtifactRequestHeaderError, appendArtifactAuthenticationHeader, assertArtifactRequestHeaderBudget, stripArtifactCloudHeaders, validatedArtifactAuthenticationHeader } from '../server/request-authentication.js';
12
13
  export { cloudRunOrigin, resolveHostedTarget } from './runtime-transport.js';
13
14
  export const HOSTED_ARTIFACT_PROXY_LIMITS = Object.freeze({ maxRequestBytes: 32 * 1024 * 1024, maxResponseBytes: 32 * 1024 * 1024, timeoutMs: 60_000, maxTimeoutMs: 300_000 });
14
15
  const googleAuth = new GoogleAuth();
@@ -40,12 +41,6 @@ async function defaultIdentityTokenProvider({ audience }) {
40
41
  export function hostedIdentityToken(input) {
41
42
  return defaultIdentityTokenProvider(input);
42
43
  }
43
- function stripCloudHeaders(headers) {
44
- for (const name of Object.keys(headers)) {
45
- if (name === 'x-serverless-authorization' || name.startsWith('x-goog-'))
46
- delete headers[name];
47
- }
48
- }
49
44
  /** HTTP-only streaming hop. Authentication, CSRF and release admission happen before calling this. */
50
45
  export function proxyHostedArtifact(options) {
51
46
  return new HostedExchange(options).run();
@@ -92,11 +87,11 @@ class HostedExchange {
92
87
  request.once('error', this.finish);
93
88
  this.timer = setTimeout(() => this.fail(504), this.timeoutMs);
94
89
  this.timer.unref();
95
- void this.prepare().catch(() => this.fail(this.unavailableStatus));
90
+ void this.prepare().catch(error => this.fail(error instanceof ArtifactRequestHeaderError ? 400 : this.unavailableStatus));
96
91
  });
97
92
  }
98
93
  async prepare() {
99
- const { request, response, target, requestContext } = this.options;
94
+ const { request, response, target, requestContext, requestAuthentication } = this.options;
100
95
  const { upstream, audience } = resolveHostedTarget({ target });
101
96
  // Cloud Run revision tags select immutable transport destinations, but IAM
102
97
  // requires the canonical service audience. Both are control-plane owned;
@@ -107,6 +102,7 @@ class HostedExchange {
107
102
  validateHeaderValue('x-sequence-artifact-context', requestContext);
108
103
  if (!requestContext || requestContext.length > 16384)
109
104
  throw new Error('Invalid request context');
105
+ validatedArtifactAuthenticationHeader({ token: requestAuthentication, contextToken: requestContext, external: Boolean(this.options.ingress) });
110
106
  }
111
107
  catch {
112
108
  this.fail(400);
@@ -127,7 +123,9 @@ class HostedExchange {
127
123
  this.fail(this.options.ingress ? 400 : 502);
128
124
  return;
129
125
  }
130
- stripCloudHeaders(headers);
126
+ stripArtifactCloudHeaders({ headers });
127
+ headers['x-sequence-artifact-context'] = requestContext;
128
+ appendArtifactAuthenticationHeader({ headers, token: requestAuthentication, contextToken: requestContext });
131
129
  if (audience) {
132
130
  const provider = this.options.identityTokenProvider ?? defaultIdentityTokenProvider;
133
131
  const identityStarted = performance.now();
@@ -147,7 +145,7 @@ class HostedExchange {
147
145
  // HTML forms receive release markers. Avoid compression when possible;
148
146
  // runtimes that still compress HTML are decoded as a bounded stream below.
149
147
  headers['accept-encoding'] = 'identity';
150
- headers['x-sequence-artifact-context'] = requestContext;
148
+ assertArtifactRequestHeaderBudget({ headers });
151
149
  const destination = this.resolveDestination(upstream);
152
150
  const transport = destination.protocol === 'https:' ? httpsRequest : httpRequest;
153
151
  const upstreamStarted = performance.now();
@@ -204,7 +202,7 @@ class HostedExchange {
204
202
  // Read before sanitising, which removes content-length as a hop header.
205
203
  const declaredLength = declaredContentLength(incoming.headers['content-length']);
206
204
  const headers = sanitizeResponseHeaders(incoming.headers);
207
- stripCloudHeaders(headers);
205
+ stripArtifactCloudHeaders({ headers });
208
206
  const response = this.options.response;
209
207
  if (this.options.ingress) {
210
208
  // Decided on the head, before any body byte; never the HTML release-marker path.
@@ -191,6 +191,8 @@ export function createIngressHandler({ options, runtimeDomain, protocol, isDrain
191
191
  };
192
192
  response.once('finish', report);
193
193
  response.once('close', report);
194
+ if (response.destroyed)
195
+ report();
194
196
  }
195
197
  /** Preflights are answered locally and never forwarded; a refusal carries no CORS headers. */
196
198
  function preflight({ request, response, origin, allowed, methods, headers }) {
@@ -41,7 +41,7 @@ export interface ArtifactGatewayOptions {
41
41
  }
42
42
  export declare function createArtifactGateway(options: ArtifactGatewayOptions): {
43
43
  server: import("http").Server<typeof IncomingMessage, typeof ServerResponse>;
44
- /** Observations recorded but not yet confirmed by Atlas: what a forced exit would lose. */
44
+ /** Recorded observations not yet confirmed by Atlas; excludes responses still in flight. */
45
45
  readonly pendingUsage: number;
46
46
  drain(): Promise<void>;
47
47
  };