@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
@@ -4,7 +4,7 @@ import { z } from 'zod';
4
4
  import { ArtifactTunnelBroker } from '../tunnel/index.js';
5
5
  import { assertSafeRequest, normalizePublicOrigin } from '../tunnel/protocol.js';
6
6
  import { GatewayControlError } from './control.js';
7
- import { decodeGatewaySession, gatewaySessionCookies, renewGatewaySession, signGatewayContext } from './session.js';
7
+ import { decodeGatewaySession, gatewaySessionCookies, renewGatewaySession, signGatewayRequest } from './session.js';
8
8
  import { proxyHostedArtifact } from './hosted-proxy.js';
9
9
  import { bootstrapPage } from './bootstrap-page.js';
10
10
  import { routeSequenceRequest } from './lattice-invoke.js';
@@ -95,7 +95,9 @@ export function createArtifactGateway(options) {
95
95
  const authorizations = new PositiveDecisionCache({ ttlMs: options.authorizationCacheTtlMs });
96
96
  const tunnelAuthorizations = new PositiveDecisionCache({ ttlMs: options.authorizationCacheTtlMs });
97
97
  const usage = new GatewayUsageReporter({ send: events => options.control.usage(events), onFailure: failure => options.onError?.(failure) });
98
+ const requests = new Set();
98
99
  let draining = false;
100
+ let drainPromise;
99
101
  const isIngressRequest = (request) => resemblesIngressHost({ rawHost: header(request, 'host'), runtimeDomain: options.runtimeDomain });
100
102
  const staticFiles = options.staticFiles ?? new StaticArtifactFiles({ fetch: ({ deploymentId, path }) => options.control.staticFile({ deploymentId, path, parentOrigin }) });
101
103
  /** Admission for the unauthenticated endpoints; the release frees the in-flight permit. */
@@ -173,10 +175,11 @@ export function createArtifactGateway(options) {
173
175
  const target = await decision.promise;
174
176
  const requestId = randomUUID();
175
177
  // A static deployment has no server of its own to read a request context.
176
- const result = { session, target, requestId, authorizationCache: decision.source, requestContext: target.kind === 'static' ? '' : signGatewayContext({
177
- session: { ...session, lattice: target.lattice, taskRoutes: target.taskRoutes }, method, path: request.url ?? '/', privateKey: options.contextPrivateKey, keyId: options.contextKeyId,
178
- capabilities: target.capabilities, serviceAccountId: target.serviceAccountId, requestId,
179
- }) };
178
+ const signed = target.kind === 'static' ? { requestContext: '' } : signGatewayRequest({
179
+ session: { ...session, lattice: target.lattice, taskRoutes: target.taskRoutes }, method, path: request.url ?? '/', privateKey: options.contextPrivateKey, keyId: options.contextKeyId,
180
+ capabilities: target.capabilities, serviceAccountId: target.serviceAccountId, requestId,
181
+ });
182
+ const result = { session, target, requestId, authorizationCache: decision.source, ...signed };
180
183
  activeRequests.set(request, result);
181
184
  return result;
182
185
  }
@@ -206,7 +209,8 @@ export function createArtifactGateway(options) {
206
209
  const authorized = await authorize(request);
207
210
  if (authorized.target.kind !== 'development' || authorized.target.sessionId !== sessionId)
208
211
  return null;
209
- return { ownerId: authorized.session.ownerId, publicOrigin: authorized.session.publicOrigin, requestContext: authorized.requestContext };
212
+ return { ownerId: authorized.session.ownerId, publicOrigin: authorized.session.publicOrigin, requestContext: authorized.requestContext,
213
+ ...(authorized.requestAuthentication ? { requestAuthentication: authorized.requestAuthentication } : {}) };
210
214
  },
211
215
  validateLease: async (lease) => {
212
216
  const credential = leases.get(lease.sessionId);
@@ -247,14 +251,24 @@ export function createArtifactGateway(options) {
247
251
  const proxyDevelopment = options.ingress?.appEnv === 'development' ? createDevelopmentIngressProxy({ broker }) : undefined;
248
252
  const ingress = createIngressHandler({ options: options.ingress, runtimeDomain: options.runtimeDomain, protocol: new URL(gatewayOrigin).protocol, isDraining: () => draining, onError: options.onError, usage, proxyDevelopment });
249
253
  const server = createServer({ maxHeaderSize: 32 * 1024, requestTimeout: 60_000, headersTimeout: 15_000 }, (request, response) => {
254
+ // server.close() can run before a destroyed response emits close. Join that event's
255
+ // usage finalizer AND the handler: authorization may finish after the browser disconnects.
256
+ const finished = new Promise(resolve => {
257
+ response.once('finish', resolve);
258
+ response.once('close', resolve);
259
+ });
260
+ const track = (handler) => {
261
+ const settled = Promise.allSettled([handler, finished]).then(() => { requests.delete(settled); });
262
+ requests.add(settled);
263
+ };
250
264
  // Before readiness, bootstrap and requestOrigin(): an ingress host, canonical or not, never
251
265
  // reaches a session-path handler or the 401/404→403 remap below (design §(e) "Branch placement").
252
266
  if (isIngressRequest(request)) {
253
- void ingress.handle(request, response);
267
+ track(ingress.handle(request, response));
254
268
  return;
255
269
  }
256
270
  const startedAt = new Date().toISOString(), started = performance.now();
257
- void (async () => {
271
+ track((async () => {
258
272
  if (isReadinessRequest({ request, gatewayOrigin, runtimeDomain: options.runtimeDomain })) {
259
273
  const ready = !draining && (options.ready?.() ?? true);
260
274
  json(response, ready ? 200 : 503, ready ? 'ready' : 'unavailable');
@@ -294,7 +308,7 @@ export function createArtifactGateway(options) {
294
308
  }
295
309
  }
296
310
  const authorizationStarted = performance.now();
297
- const { session, target, requestContext, requestId, authorizationCache } = await authorize(request);
311
+ const { session, target, requestContext, requestAuthentication, requestId, authorizationCache } = await authorize(request);
298
312
  // Immutable files Atlas already authorized this session for: no admission, context or usage.
299
313
  if (target.kind === 'static') {
300
314
  await serveStaticTarget({ request, response, target, files: staticFiles, renew: () => renewCookie({ options, request, response, session, target }), parentOrigin: session.bridge.parentOrigin });
@@ -340,7 +354,7 @@ export function createArtifactGateway(options) {
340
354
  if (response.destroyed)
341
355
  return;
342
356
  const { body } = await admitHostedMutation({ request, expectedDeploymentId: target.deploymentId, expectedFrameworkBuildId: target.frameworkBuildId, active: target.active, published: session.published });
343
- await proxyHostedArtifact({ request, response, target, requestContext, body, limits: { timeoutMs: admitted.timeoutMs }, gatewayTimings });
357
+ await proxyHostedArtifact({ request, response, target, requestContext, requestAuthentication, body, limits: { timeoutMs: admitted.timeoutMs }, gatewayTimings });
344
358
  })().catch(error => {
345
359
  options.onError?.({ kind: error instanceof ReleaseAdmissionError ? 'release_mismatch' : error instanceof GatewayControlError ? 'authorization_denied'
346
360
  : error instanceof ArtifactAdmissionError ? 'admission_rejected' : 'request_failed' });
@@ -348,7 +362,7 @@ export function createArtifactGateway(options) {
348
362
  if (error instanceof ArtifactAdmissionError && error.retryAfterSeconds && !response.headersSent)
349
363
  response.setHeader('retry-after', String(error.retryAfterSeconds));
350
364
  json(response, status === 401 || status === 404 ? 403 : status, error instanceof ReleaseAdmissionError ? 'update_required' : 'artifact_request_failed');
351
- });
365
+ }));
352
366
  });
353
367
  server.on('upgrade', (request, socket, head) => {
354
368
  void (async () => {
@@ -366,6 +380,10 @@ export function createArtifactGateway(options) {
366
380
  return;
367
381
  }
368
382
  const authorized = await authorize(request);
383
+ if (draining) {
384
+ rejectUpgrade(socket, 503);
385
+ return;
386
+ }
369
387
  if (authorized.target.kind !== 'development') {
370
388
  rejectUpgrade(socket);
371
389
  return;
@@ -378,18 +396,31 @@ export function createArtifactGateway(options) {
378
396
  handleClientErrors({ server, ingress });
379
397
  return {
380
398
  server,
381
- /** Observations recorded but not yet confirmed by Atlas: what a forced exit would lose. */
399
+ /** Recorded observations not yet confirmed by Atlas; excludes responses still in flight. */
382
400
  get pendingUsage() { return usage.pending; },
383
- async drain() {
401
+ drain() {
402
+ if (drainPromise)
403
+ return drainPromise;
384
404
  draining = true;
405
+ broker.beginDrain();
406
+ const closed = new Promise(resolve => server.close(() => resolve()));
385
407
  server.closeIdleConnections();
386
- broker.close();
387
- leases.clear();
388
- authorizations.clear();
389
- tunnelAuthorizations.clear();
390
- // The usage timer keeps flushing while in-flight requests finish.
391
- await new Promise(resolve => server.close(() => resolve()));
392
- await usage.close();
408
+ drainPromise = (async () => {
409
+ while (requests.size)
410
+ await Promise.all(requests);
411
+ // Active responses may have become idle since close() first stopped admission.
412
+ server.closeIdleConnections();
413
+ // Keep lease checks and revocation live until local and remote HTTP streams finish.
414
+ await broker.drain();
415
+ await closed;
416
+ while (requests.size)
417
+ await Promise.all(requests);
418
+ leases.clear();
419
+ authorizations.clear();
420
+ tunnelAuthorizations.clear();
421
+ await usage.close();
422
+ })();
423
+ return drainPromise;
393
424
  },
394
425
  };
395
426
  }
@@ -1,6 +1,11 @@
1
1
  import { z } from 'zod';
2
2
  export declare const GATEWAY_SESSION_COOKIE = "__Host-atlas-artifact-session";
3
3
  export declare function gatewayReleaseCookieName(deploymentId: string): string;
4
+ /** Only the authenticated Atlas bootstrap control reply supplies this private cookie member. */
5
+ export declare const gatewayAuthenticationSchema: z.ZodObject<{
6
+ method: z.ZodLiteral<"auth0_user">;
7
+ sub: z.ZodString;
8
+ }, z.core.$strict>;
4
9
  declare const sessionSchema: z.ZodObject<{
5
10
  version: z.ZodLiteral<1>;
6
11
  kind: z.ZodEnum<{
@@ -30,6 +35,10 @@ declare const sessionSchema: z.ZodObject<{
30
35
  processId: z.ZodNullable<z.ZodString>;
31
36
  }, z.core.$strict>>;
32
37
  taskRoutes: z.ZodOptional<z.ZodArray<z.ZodString>>;
38
+ authentication: z.ZodOptional<z.ZodObject<{
39
+ method: z.ZodLiteral<"auth0_user">;
40
+ sub: z.ZodString;
41
+ }, z.core.$strict>>;
33
42
  }, z.core.$strict>;
34
43
  export type GatewaySession = z.infer<typeof sessionSchema>;
35
44
  /** Active use renews the idle window, but never extends the original admission's eight-hour bound. */
@@ -85,4 +94,9 @@ export declare function signGatewayContext({ session, method, path, privateKey,
85
94
  /** Seconds until exp. Browser requests stay at 60; a Lattice run may last up to 300. */
86
95
  ttlSeconds?: number;
87
96
  }): string;
97
+ /** Signs a proof only for the exact context this call just minted from its authenticated session. */
98
+ export declare function signGatewayRequest(input: Parameters<typeof signGatewayContext>[0]): {
99
+ requestContext: string;
100
+ requestAuthentication?: string;
101
+ };
88
102
  export {};
@@ -1,8 +1,9 @@
1
- import { createHmac, createPrivateKey, randomUUID, sign, timingSafeEqual } from 'node:crypto';
1
+ import { createHash, createHmac, createPrivateKey, randomUUID, sign, timingSafeEqual } from 'node:crypto';
2
2
  import { z } from 'zod';
3
3
  import { ARTIFACT_CONTEXT_MAX_AGE_SECONDS, ARTIFACT_LATTICE_CONTEXT_MAX_AGE_SECONDS, artifactRequestContextSchema } from '../server/request-context.js';
4
4
  import { normalizePublicOrigin } from '../tunnel/protocol.js';
5
5
  import { artifactNextFrameworkBuildIdSchema } from '../runtime-contracts.js';
6
+ import { ARTIFACT_REQUEST_AUTHENTICATION_MAX_LENGTH } from '../server/request-authentication.js';
6
7
  export const GATEWAY_SESSION_COOKIE = '__Host-atlas-artifact-session';
7
8
  export function gatewayReleaseCookieName(deploymentId) {
8
9
  if (!/^[a-zA-Z0-9_-]{1,128}$/.test(deploymentId))
@@ -16,6 +17,8 @@ catch {
16
17
  return false;
17
18
  } });
18
19
  const id = z.string().min(1).max(255);
20
+ /** Only the authenticated Atlas bootstrap control reply supplies this private cookie member. */
21
+ export const gatewayAuthenticationSchema = z.object({ method: z.literal('auth0_user'), sub: id }).strict();
19
22
  const sessionSchema = z.object({
20
23
  version: z.literal(1), kind: z.enum(['development', 'hosted', 'static']),
21
24
  sessionId: id.nullable(), deploymentId: id.nullable(),
@@ -24,6 +27,7 @@ const sessionSchema = z.object({
24
27
  bridge: artifactRequestContextSchema.shape.bridge,
25
28
  lattice: artifactRequestContextSchema.shape.lattice.optional(),
26
29
  taskRoutes: artifactRequestContextSchema.shape.taskRoutes,
30
+ authentication: gatewayAuthenticationSchema.optional(),
27
31
  }).strict().refine(value => value.kind === 'development'
28
32
  ? value.sessionId !== null && value.deploymentId === null && value.frameworkBuildId === null && !value.published
29
33
  // A static deployment has no Next build: its assets are named by the deployment itself.
@@ -33,7 +37,8 @@ const sessionSchema = z.object({
33
37
  function sameSessionScope(left, right) {
34
38
  return left.ownerId === right.ownerId && left.projectId === right.projectId &&
35
39
  left.environment === right.environment && left.publicOrigin === right.publicOrigin &&
36
- left.kind === right.kind && left.published === right.published;
40
+ left.kind === right.kind && left.published === right.published &&
41
+ left.authentication?.method === right.authentication?.method && left.authentication?.sub === right.authentication?.sub;
37
42
  }
38
43
  /** Active use renews the idle window, but never extends the original admission's eight-hour bound. */
39
44
  export function renewGatewaySession({ session, authorizedExpiresAt, now = Date.now() }) {
@@ -161,3 +166,27 @@ export function signGatewayContext({ session, method, path, privateKey, keyId, n
161
166
  const signed = `${header}.${payload}`;
162
167
  return `${signed}.${sign(null, Buffer.from(signed), key).toString('base64url')}`;
163
168
  }
169
+ /** Signs a proof only for the exact context this call just minted from its authenticated session. */
170
+ export function signGatewayRequest(input) {
171
+ const requestContext = signGatewayContext(input);
172
+ const authentication = input.session.authentication;
173
+ if (!authentication || authentication.method !== 'auth0_user' || authentication.sub !== input.session.ownerId) {
174
+ return { requestContext };
175
+ }
176
+ // This is our own just-signed payload, never a caller-supplied context token.
177
+ const context = artifactRequestContextSchema.parse(JSON.parse(Buffer.from(requestContext.split('.')[1], 'base64url').toString('utf8')));
178
+ const header = Buffer.from(JSON.stringify({ alg: 'EdDSA', typ: 'JWT', kid: input.keyId })).toString('base64url');
179
+ const payload = Buffer.from(JSON.stringify({
180
+ purpose: 'artifact-request-authentication', v: 1, iss: context.iss, aud: context.aud,
181
+ authenticationMethod: authentication.method, sub: authentication.sub,
182
+ contextJti: context.jti, contextSha256: createHash('sha256').update(requestContext).digest('hex'),
183
+ projectId: context.projectId, environment: context.environment, method: context.method,
184
+ pathSha256: createHash('sha256').update(context.path).digest('hex'),
185
+ iat: context.iat, exp: context.exp,
186
+ })).toString('base64url');
187
+ const unsigned = `${header}.${payload}`;
188
+ const requestAuthentication = `${unsigned}.${sign(null, Buffer.from(unsigned), createPrivateKey(input.privateKey)).toString('base64url')}`;
189
+ if (requestAuthentication.length > ARTIFACT_REQUEST_AUTHENTICATION_MAX_LENGTH)
190
+ throw new Error('Artifact authentication proof exceeds the header limit');
191
+ return { requestContext, requestAuthentication };
192
+ }
@@ -10,6 +10,7 @@ import { RedisTunnelOwnership, RedisTunnelRelay } from './tunnel/redis.js';
10
10
  import { ArtifactRequestAdmission } from './gateway/admission.js';
11
11
  import { GatewayControlAdmission } from './gateway/control-admission.js';
12
12
  import { createIngressRuntime, IngressSecretsError } from './gateway/ingress-runtime.js';
13
+ import { DatadogStartupError, waitForDatadogStartup } from './gateway/datadog-startup.js';
13
14
  function required(name) {
14
15
  const value = process.env[name];
15
16
  if (!value)
@@ -20,6 +21,7 @@ function required(name) {
20
21
  async function main() {
21
22
  if (process.env.ARTIFACT_NEXT_ENABLED !== 'true')
22
23
  throw new Error('Artifact Next gateway is disabled');
24
+ await waitForDatadogStartup({ env: process.env });
23
25
  const redisUrl = required('ARTIFACT_NEXT_REDIS_URL');
24
26
  if (new URL(redisUrl).protocol !== 'rediss:')
25
27
  throw new Error('Artifact tunnel routing requires private Redis with TLS');
@@ -135,9 +137,9 @@ async function main() {
135
137
  process.once('SIGINT', () => { void stop(); });
136
138
  }
137
139
  void main().catch((error) => {
138
- // Only an ingress-secrets refusal is described: its message names variables, never values, by
139
- // construction. Other errors (Redis, URLs) may carry connection strings.
140
- const reason = error instanceof IngressSecretsError ? error.message : undefined;
140
+ // These startup refusals contain only fixed descriptions and configuration names.
141
+ // Other errors (Redis, URLs) may carry connection strings.
142
+ const reason = error instanceof IngressSecretsError || error instanceof DatadogStartupError ? error.message : undefined;
141
143
  process.stderr.write(`${JSON.stringify({ event: 'artifact_gateway.start_failed', ...(reason ? { reason } : {}) })}\n`);
142
144
  process.exit(1);
143
145
  });
@@ -15,7 +15,7 @@ export declare function resolveNextRepositoryId({ spec, repository, baseUrl, tok
15
15
  baseUrl: string;
16
16
  token: string;
17
17
  }): Promise<string>;
18
- export declare function submitNextBuild({ root, sourceRoot, manifest, projectId, baseUrl, getToken, provenance, repository, repositoryId, channel, out }: {
18
+ export declare function submitNextBuild({ root, sourceRoot, manifest, projectId, baseUrl, getToken, provenance, repository, repositoryId, sourceEnvironment, sourceToken, channel, out }: {
19
19
  root: string;
20
20
  sourceRoot: string;
21
21
  manifest: ArtifactStudioNextManifest;
@@ -25,6 +25,8 @@ export declare function submitNextBuild({ root, sourceRoot, manifest, projectId,
25
25
  provenance: SourceProvenance;
26
26
  repository: string;
27
27
  repositoryId?: string;
28
+ sourceEnvironment?: string;
29
+ sourceToken?: string;
28
30
  channel: 'active' | 'preview';
29
31
  out?: string;
30
32
  }): Promise<number>;
@@ -58,16 +58,16 @@ export async function resolveNextRepositoryId({ spec, repository, baseUrl, token
58
58
  return match[1];
59
59
  }
60
60
  catch { /* Opaque labels/SSH origins are valid preview provenance but cannot attest a release. */ }
61
- throw new Error('Official Next.js releases require a Git Service repository in this Atlas environment. Push the reviewed revision, then run artifact deploy --repo <namespace>/<name> --ref main --env <env> (or --ref staging for a staging environment).');
61
+ throw new Error('Official Next.js releases require a Git Service repository in the selected source environment. Use --source-env <env> when its repository lives in another environment. Push the reviewed revision, then run artifact deploy --repo <namespace>/<name> --ref main --env <env> (or --ref staging for a staging environment).');
62
62
  }
63
63
  /**
64
64
  * A verified repository build names a commit and the application's directory in it;
65
65
  * anything else uploads the committed source snapshot. The repository root is omitted
66
66
  * so the request stays valid for Atlas versions that predate `sourceRoot`.
67
67
  */
68
- function nextBuildRequestBody({ manifest, files, commit, provenance, repository, repositoryId, sourceRoot, channel }) {
68
+ function nextBuildRequestBody({ manifest, files, commit, provenance, repository, repositoryId, sourceEnvironment, sourceRoot, channel }) {
69
69
  if (repositoryId)
70
- return { repositoryId, commit, ...(sourceRoot === '.' ? {} : { sourceRoot }), idempotencyKey: randomUUID(), channel };
70
+ return { repositoryId, ...(sourceEnvironment ? { sourceEnvironment } : {}), commit, ...(sourceRoot === '.' ? {} : { sourceRoot }), idempotencyKey: randomUUID(), channel };
71
71
  return {
72
72
  manifest,
73
73
  files: files.map(({ path, content, encoding }) => ({ path, content, encoding: encoding ?? 'utf8' })),
@@ -75,7 +75,7 @@ function nextBuildRequestBody({ manifest, files, commit, provenance, repository,
75
75
  idempotencyKey: randomUUID(), channel,
76
76
  };
77
77
  }
78
- export async function submitNextBuild({ root, sourceRoot, manifest, projectId, baseUrl, getToken, provenance, repository, repositoryId, channel, out }) {
78
+ export async function submitNextBuild({ root, sourceRoot, manifest, projectId, baseUrl, getToken, provenance, repository, repositoryId, sourceEnvironment, sourceToken, channel, out }) {
79
79
  if (!provenance.gitCommit || !/^[a-f0-9]{40}$/i.test(provenance.gitCommit)) {
80
80
  throw new Error('Commit the application source before requesting a hosted build. Use artifact dev for uncommitted local development.');
81
81
  }
@@ -84,8 +84,12 @@ export async function submitNextBuild({ root, sourceRoot, manifest, projectId, b
84
84
  if (channel === 'active' && !repositoryId)
85
85
  throw new Error('An official release requires a verified Git Service repository.');
86
86
  const path = `/api/artifact-studio/projects/${encodeURIComponent(projectId)}/next`;
87
- const body = nextBuildRequestBody({ manifest, files: source.files, commit: provenance.gitCommit, provenance, repository, repositoryId, sourceRoot, channel });
88
- const response = operationSchema.parse(await postJson({ baseUrl, token: await getToken(), path: `${path}/builds${repositoryId ? '/from-repository' : ''}`, body }));
87
+ const body = nextBuildRequestBody({ manifest, files: source.files, commit: provenance.gitCommit, provenance, repository, repositoryId, sourceEnvironment, sourceRoot, channel });
88
+ if (sourceEnvironment && !sourceToken)
89
+ throw new Error('Cross-environment builds require a separately authenticated source session.');
90
+ const response = operationSchema.parse(await postJson({ baseUrl, token: await getToken(), path: `${path}/builds${repositoryId ? '/from-repository' : ''}`, body, ...(sourceEnvironment ? { headers: { 'x-sequence-source-authorization': `Bearer ${sourceToken}` } } : {}) }));
91
+ if (sourceEnvironment)
92
+ console.log(`[seq-studio] source: ${sourceEnvironment} / ${repository} -> target: ${baseUrl}`);
89
93
  const directory = repositoryId && sourceRoot !== '.' ? ` (${sourceRoot})` : '';
90
94
  console.log(`[seq-studio] Atlas build ${response.operation.id} (${channel}) from ${provenance.gitCommit.slice(0, 12)}${directory}`);
91
95
  console.log(`${baseUrl}/artifact-studio/projects/${projectId}`);
package/dist/sdk.d.ts CHANGED
@@ -44,6 +44,8 @@ export interface SequenceApiFetchResponse<TBody = unknown> {
44
44
  headers?: Record<string, string>;
45
45
  }
46
46
  export interface SequenceApiUploadOptions {
47
+ /** Multipart binary field name. Defaults to file. */
48
+ fieldName?: string;
47
49
  timeoutMs?: number;
48
50
  /**
49
51
  * HTTP method for the multipart request. Defaults to POST; PUT is allowed