@sequenceholdings/artifact-studio 0.8.0 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md 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`,
@@ -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
  };
@@ -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
  });