@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 +87 -3
- package/dist/gateway/authorization-cache.js +1 -1
- package/dist/gateway/control.d.ts +13 -1
- package/dist/gateway/control.js +3 -0
- package/dist/gateway/datadog-startup.d.ts +13 -0
- package/dist/gateway/datadog-startup.js +51 -0
- package/dist/gateway/hosted-proxy.d.ts +2 -0
- package/dist/gateway/hosted-proxy.js +9 -11
- package/dist/gateway/ingress-handler.js +2 -0
- package/dist/gateway/server.d.ts +1 -1
- package/dist/gateway/server.js +51 -20
- package/dist/gateway/session.d.ts +14 -0
- package/dist/gateway/session.js +31 -2
- package/dist/gateway-main.js +5 -3
- package/dist/server/agent-responses.d.ts +425 -0
- package/dist/server/agent-responses.js +154 -0
- package/dist/server/broker-contract.d.ts +5 -0
- package/dist/server/broker-contract.js +6 -2
- package/dist/server/client.d.ts +2 -0
- package/dist/server/client.js +10 -2
- package/dist/server/processes.d.ts +11 -0
- package/dist/server/request-authentication.d.ts +42 -0
- package/dist/server/request-authentication.js +93 -0
- package/dist/server/request-context.d.ts +1 -0
- package/dist/server/request-context.js +1 -0
- package/dist/server.d.ts +2 -1
- package/dist/server.js +1 -1
- package/dist/tunnel/broker.d.ts +14 -1
- package/dist/tunnel/broker.js +94 -10
- package/dist/tunnel/client.js +6 -0
- package/dist/tunnel/redis-types.d.ts +5 -1
- package/dist/tunnel/redis.d.ts +12 -0
- package/dist/tunnel/redis.js +102 -4
- package/dist/tunnel/transport.d.ts +3 -0
- package/package.json +3 -3
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
|
|
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<{
|
package/dist/gateway/control.js
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 }) {
|
package/dist/gateway/server.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
};
|
package/dist/gateway/server.js
CHANGED
|
@@ -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,
|
|
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
|
|
177
|
-
|
|
178
|
-
|
|
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
|
-
|
|
267
|
+
track(ingress.handle(request, response));
|
|
254
268
|
return;
|
|
255
269
|
}
|
|
256
270
|
const startedAt = new Date().toISOString(), started = performance.now();
|
|
257
|
-
|
|
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
|
-
/**
|
|
399
|
+
/** Recorded observations not yet confirmed by Atlas; excludes responses still in flight. */
|
|
382
400
|
get pendingUsage() { return usage.pending; },
|
|
383
|
-
|
|
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
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
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 {};
|
package/dist/gateway/session.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/gateway-main.js
CHANGED
|
@@ -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
|
-
//
|
|
139
|
-
//
|
|
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
|
});
|