@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.
- package/README.md +101 -3
- package/dist/api.d.ts +2 -1
- package/dist/api.js +15 -5
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +58 -20
- 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/next-build.d.ts +3 -1
- package/dist/next-build.js +10 -6
- package/dist/sdk.d.ts +2 -0
- 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/templates/react-vite/package.json +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/templates/react-vite/package.json +1 -1
package/dist/server/client.js
CHANGED
|
@@ -11,6 +11,7 @@ import { ARTIFACT_REQUEST_CONTEXT_HEADER, ARTIFACT_RUNTIME_TOKEN_HEADER, ARTIFAC
|
|
|
11
11
|
import { verifyArtifactExternalContext } from './external-context.js';
|
|
12
12
|
import { isExternalContextToken } from './internal/signed-token.js';
|
|
13
13
|
import { createApiClient } from './api-client.js';
|
|
14
|
+
import { createArtifactAgentsClient } from './agent-responses.js';
|
|
14
15
|
import { pause, transientConnectionCode } from './connection-retry.js';
|
|
15
16
|
import { principalFromContext } from './principal.js';
|
|
16
17
|
import { artifactRuntimeConfigSchema, readArtifactRuntimeConfig } from './runtime-config.js';
|
|
@@ -52,7 +53,12 @@ export async function createArtifactServerClient({ headers, runtime, fetch: fetc
|
|
|
52
53
|
throw new ArtifactServerError(401, 'runtime_changed', 'Artifact runtime disconnected or changed; reload the preview');
|
|
53
54
|
return current;
|
|
54
55
|
};
|
|
55
|
-
|
|
56
|
+
// Opaque proof of the gateway's actual browser authentication. Atlas verifies it
|
|
57
|
+
// against this exact signed context; it grants nothing when copied to another request.
|
|
58
|
+
const authenticationToken = headers.get('x-sequence-artifact-authentication') ?? undefined;
|
|
59
|
+
if (authenticationToken !== undefined && authenticationToken.length > 4096)
|
|
60
|
+
throw new ArtifactServerError(401, 'invalid_request_context', 'Artifact gateway authentication proof exceeds its header limit');
|
|
61
|
+
return buildClient({ context, principal: principalFromContext(context), token, authenticationToken, getRuntime, fetcher, mode: 'user', logSink });
|
|
56
62
|
}
|
|
57
63
|
function buildClient(args) {
|
|
58
64
|
const { context, principal, mode, logSink } = args;
|
|
@@ -89,6 +95,7 @@ function buildClient(args) {
|
|
|
89
95
|
mode,
|
|
90
96
|
log,
|
|
91
97
|
api: createApiClient({ mode, request, json }),
|
|
98
|
+
agents: createArtifactAgentsClient({ mode, json }),
|
|
92
99
|
storage: storageClient({ request: (method, path, body) => storageJson(artifactServerRequestSchema.parse({ operation: 'storage', mode, method, path, body })) }),
|
|
93
100
|
functions: {
|
|
94
101
|
invoke: async (id, params) => {
|
|
@@ -213,7 +220,7 @@ function isReadOperation(operation) {
|
|
|
213
220
|
return false;
|
|
214
221
|
}
|
|
215
222
|
}
|
|
216
|
-
function createBrokerTransport({ context, principal, token, getRuntime, fetcher, log }) {
|
|
223
|
+
function createBrokerTransport({ context, principal, token, authenticationToken, getRuntime, fetcher, log }) {
|
|
217
224
|
const send = async (input, signal, stream = false) => {
|
|
218
225
|
// The one choke point for every broker operation: an external caller never acts as a user.
|
|
219
226
|
if (input.mode === 'user' && principal.kind === 'external')
|
|
@@ -241,6 +248,7 @@ function createBrokerTransport({ context, principal, token, getRuntime, fetcher,
|
|
|
241
248
|
'Content-Type': 'application/json',
|
|
242
249
|
[ARTIFACT_RUNTIME_TOKEN_HEADER]: runtime.sessionToken,
|
|
243
250
|
[ARTIFACT_REQUEST_CONTEXT_HEADER]: token,
|
|
251
|
+
...(authenticationToken !== undefined ? { 'x-sequence-artifact-authentication': authenticationToken } : {}),
|
|
244
252
|
},
|
|
245
253
|
body,
|
|
246
254
|
cache: 'no-store',
|
|
@@ -7,6 +7,17 @@ export type { ArtifactProcessRunSharingAuthoritiesQuery, ArtifactProcessRunShari
|
|
|
7
7
|
/** A run of a process declared under `server.capabilities.processes.start`. */
|
|
8
8
|
export interface ArtifactProcessStart {
|
|
9
9
|
process: string;
|
|
10
|
+
/** Select a registered immutable version. Omit to start the process's current active version. */
|
|
11
|
+
version?: string;
|
|
12
|
+
/**
|
|
13
|
+
* Require the resolved immutable target to match this version and bundle hash before Atlas
|
|
14
|
+
* creates the run. Drift returns 409 PROCESS_IDENTITY_MISMATCH; this assertion does not select
|
|
15
|
+
* a version. Use `version` separately to start a captured target after active-version promotion.
|
|
16
|
+
*/
|
|
17
|
+
expectedProcess?: {
|
|
18
|
+
version: string;
|
|
19
|
+
bundleHash: string;
|
|
20
|
+
};
|
|
10
21
|
/** Idempotency key: starting the same run id again returns the existing run. */
|
|
11
22
|
runId?: string;
|
|
12
23
|
/**
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { type ArtifactGatewayPublicKeys, type ArtifactRequestContext } from './request-context.js';
|
|
2
|
+
export declare const ARTIFACT_REQUEST_AUTHENTICATION_HEADER = "x-sequence-artifact-authentication";
|
|
3
|
+
export declare const ARTIFACT_REQUEST_AUTHENTICATION_MAX_LENGTH = 4096;
|
|
4
|
+
export declare class ArtifactRequestHeaderError extends Error {
|
|
5
|
+
constructor();
|
|
6
|
+
}
|
|
7
|
+
/** Internal forwarding validation; the signed proof is verified by the receiving broker. */
|
|
8
|
+
export declare function validatedArtifactAuthenticationHeader({ token, contextToken, external }: {
|
|
9
|
+
token: unknown;
|
|
10
|
+
contextToken: unknown;
|
|
11
|
+
external?: boolean;
|
|
12
|
+
}): string | undefined;
|
|
13
|
+
/** Internal transport guard: trusted appended headers share the same 32 KiB transport budget. */
|
|
14
|
+
export declare function assertArtifactRequestHeaderBudget({ headers }: {
|
|
15
|
+
headers: Readonly<Record<string, string | string[]>>;
|
|
16
|
+
}): void;
|
|
17
|
+
/** Add only the gateway-controlled value after all browser-owned private headers were stripped. */
|
|
18
|
+
export declare function appendArtifactAuthenticationHeader({ headers, token, contextToken }: {
|
|
19
|
+
headers: Record<string, string | string[]>;
|
|
20
|
+
token: unknown;
|
|
21
|
+
contextToken: unknown;
|
|
22
|
+
}): void;
|
|
23
|
+
/** Cloud transport identity headers may come only from the platform's own credential provider. */
|
|
24
|
+
export declare function stripArtifactCloudHeaders({ headers }: {
|
|
25
|
+
headers: Record<string, string | string[]>;
|
|
26
|
+
}): void;
|
|
27
|
+
/**
|
|
28
|
+
* Verifies actual authentication separately from actor attribution. An absent proof keeps legacy
|
|
29
|
+
* contexts usable, but supplies no human authority. A present invalid proof always fails closed.
|
|
30
|
+
* The exact signed context bytes bind even bridge, deployment and service-account fields that
|
|
31
|
+
* are not repeated in this small proof. No application-authored context can supply this evidence.
|
|
32
|
+
*/
|
|
33
|
+
export declare function verifyArtifactRequestAuthentication({ token, requestContextToken, context, publicKeys, now }: {
|
|
34
|
+
token: string | null | undefined;
|
|
35
|
+
requestContextToken: string;
|
|
36
|
+
context: ArtifactRequestContext;
|
|
37
|
+
publicKeys: ArtifactGatewayPublicKeys;
|
|
38
|
+
now?: number;
|
|
39
|
+
}): Readonly<{
|
|
40
|
+
authenticationMethod: 'auth0_user';
|
|
41
|
+
sub: string;
|
|
42
|
+
}> | undefined;
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { validateHeaderValue } from 'node:http';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import { ArtifactContextError, artifactRequestContextSchema, verifyArtifactRequestContext } from './request-context.js';
|
|
5
|
+
import { ARTIFACT_EXTERNAL_CONTEXT_KID_PREFIX, verifySignedContext } from './internal/signed-token.js';
|
|
6
|
+
export const ARTIFACT_REQUEST_AUTHENTICATION_HEADER = 'x-sequence-artifact-authentication';
|
|
7
|
+
export const ARTIFACT_REQUEST_AUTHENTICATION_MAX_LENGTH = 4096;
|
|
8
|
+
export class ArtifactRequestHeaderError extends Error {
|
|
9
|
+
constructor() { super('Artifact request headers exceed the transport limit'); this.name = 'ArtifactRequestHeaderError'; }
|
|
10
|
+
}
|
|
11
|
+
/** Internal forwarding validation; the signed proof is verified by the receiving broker. */
|
|
12
|
+
export function validatedArtifactAuthenticationHeader({ token, contextToken, external = false }) {
|
|
13
|
+
if (token === undefined)
|
|
14
|
+
return undefined;
|
|
15
|
+
if (typeof token !== 'string' || !token || token.length > ARTIFACT_REQUEST_AUTHENTICATION_MAX_LENGTH ||
|
|
16
|
+
typeof contextToken !== 'string' || !contextToken || external)
|
|
17
|
+
throw new Error('Invalid Artifact request authentication');
|
|
18
|
+
validateHeaderValue(ARTIFACT_REQUEST_AUTHENTICATION_HEADER, token);
|
|
19
|
+
return token;
|
|
20
|
+
}
|
|
21
|
+
/** Internal transport guard: trusted appended headers share the same 32 KiB transport budget. */
|
|
22
|
+
export function assertArtifactRequestHeaderBudget({ headers }) {
|
|
23
|
+
let bytes = 0;
|
|
24
|
+
const entries = Object.entries(headers);
|
|
25
|
+
if (entries.length > 128)
|
|
26
|
+
throw new ArtifactRequestHeaderError();
|
|
27
|
+
for (const [name, value] of entries) {
|
|
28
|
+
// Node may emit array values as repeated lines. Counting each name/CRLF is conservative
|
|
29
|
+
// even for cookie values, which Node joins instead of repeating.
|
|
30
|
+
for (const item of Array.isArray(value) ? value : [value]) {
|
|
31
|
+
bytes += Buffer.byteLength(name) + Buffer.byteLength(item) + 4;
|
|
32
|
+
if (bytes > 32 * 1024)
|
|
33
|
+
throw new ArtifactRequestHeaderError();
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/** Add only the gateway-controlled value after all browser-owned private headers were stripped. */
|
|
38
|
+
export function appendArtifactAuthenticationHeader({ headers, token, contextToken }) {
|
|
39
|
+
const authentication = validatedArtifactAuthenticationHeader({ token, contextToken });
|
|
40
|
+
if (authentication !== undefined)
|
|
41
|
+
headers[ARTIFACT_REQUEST_AUTHENTICATION_HEADER] = authentication;
|
|
42
|
+
assertArtifactRequestHeaderBudget({ headers });
|
|
43
|
+
}
|
|
44
|
+
/** Cloud transport identity headers may come only from the platform's own credential provider. */
|
|
45
|
+
export function stripArtifactCloudHeaders({ headers }) {
|
|
46
|
+
for (const name of Object.keys(headers)) {
|
|
47
|
+
if (name === 'x-serverless-authorization' || name.startsWith('x-goog-'))
|
|
48
|
+
delete headers[name];
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/** Internal signed format. Authentication is deliberately absent from the public v1 context. */
|
|
52
|
+
const authenticationProofSchema = z.object({
|
|
53
|
+
purpose: z.literal('artifact-request-authentication'), v: z.literal(1),
|
|
54
|
+
iss: z.literal('atlas-artifact-gateway'), aud: z.string().min(1).max(300),
|
|
55
|
+
authenticationMethod: z.literal('auth0_user'), sub: z.string().min(1).max(255),
|
|
56
|
+
contextJti: z.uuid(), contextSha256: z.string().regex(/^[a-f0-9]{64}$/),
|
|
57
|
+
projectId: z.string().min(1).max(255), environment: z.string().min(1).max(255),
|
|
58
|
+
method: z.enum(['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS']), pathSha256: z.string().regex(/^[a-f0-9]{64}$/),
|
|
59
|
+
iat: z.number().int().nonnegative(), exp: z.number().int().positive(),
|
|
60
|
+
}).strict();
|
|
61
|
+
/**
|
|
62
|
+
* Verifies actual authentication separately from actor attribution. An absent proof keeps legacy
|
|
63
|
+
* contexts usable, but supplies no human authority. A present invalid proof always fails closed.
|
|
64
|
+
* The exact signed context bytes bind even bridge, deployment and service-account fields that
|
|
65
|
+
* are not repeated in this small proof. No application-authored context can supply this evidence.
|
|
66
|
+
*/
|
|
67
|
+
export function verifyArtifactRequestAuthentication({ token, requestContextToken, context, publicKeys, now = Math.floor(Date.now() / 1000) }) {
|
|
68
|
+
if (token === null || token === undefined)
|
|
69
|
+
return undefined;
|
|
70
|
+
try {
|
|
71
|
+
if (!token || token.length > ARTIFACT_REQUEST_AUTHENTICATION_MAX_LENGTH)
|
|
72
|
+
throw new ArtifactContextError();
|
|
73
|
+
const signedContext = verifyArtifactRequestContext({ token: requestContextToken, publicKeys,
|
|
74
|
+
projectId: context.projectId, environment: context.environment, method: context.method, path: context.path, now });
|
|
75
|
+
if (JSON.stringify(signedContext) !== JSON.stringify(artifactRequestContextSchema.parse(context)))
|
|
76
|
+
throw new ArtifactContextError();
|
|
77
|
+
const contextSignature = verifySignedContext({ token: requestContextToken, publicKeys });
|
|
78
|
+
const verified = verifySignedContext({ token, publicKeys });
|
|
79
|
+
if (verified.kid.startsWith(ARTIFACT_EXTERNAL_CONTEXT_KID_PREFIX) || verified.kid !== contextSignature.kid)
|
|
80
|
+
throw new ArtifactContextError();
|
|
81
|
+
const proof = authenticationProofSchema.parse(verified.payload);
|
|
82
|
+
if (proof.contextSha256 !== createHash('sha256').update(requestContextToken).digest('hex') ||
|
|
83
|
+
proof.contextJti !== signedContext.jti || proof.sub !== signedContext.sub || proof.aud !== signedContext.aud ||
|
|
84
|
+
proof.projectId !== signedContext.projectId || proof.environment !== signedContext.environment ||
|
|
85
|
+
proof.method !== signedContext.method || proof.pathSha256 !== createHash('sha256').update(signedContext.path).digest('hex') ||
|
|
86
|
+
proof.iat !== signedContext.iat || proof.exp !== signedContext.exp)
|
|
87
|
+
throw new ArtifactContextError();
|
|
88
|
+
return Object.freeze({ authenticationMethod: proof.authenticationMethod, sub: proof.sub });
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
throw new ArtifactContextError();
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -195,3 +195,4 @@ export declare function verifyArtifactRequestContext({ token, publicKeys, projec
|
|
|
195
195
|
export declare function getArtifactBrowserBootstrap(context: ArtifactRequestContext | RequestContextV2External): NextArtifactBridgeBootstrap & {
|
|
196
196
|
initialContext: NextArtifactInitialContext;
|
|
197
197
|
};
|
|
198
|
+
export { ARTIFACT_REQUEST_AUTHENTICATION_HEADER, verifyArtifactRequestAuthentication } from './request-authentication.js';
|
package/dist/server.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import 'server-only';
|
|
2
2
|
export { createArtifactServerClient } from './server/client.js';
|
|
3
|
+
export type { ArtifactServerAgents, ArtifactCompletedAgentRun, ArtifactCompletedAgentSourceCapture, ArtifactCompletedAgentConversationCapture, ArtifactAgentConversationSourceCapture, ArtifactExecutionIdentity, ArtifactCreateAgentJob, ArtifactCreateWorkflowJob, ArtifactCreatedAgentJob, ArtifactCreatedWorkflowJob, ArtifactCompletedWorkflowSourceCapture } from './server/agent-responses.js';
|
|
3
4
|
export { ArtifactPrincipalError, ArtifactServerError, INSUFFICIENT_USER_AUTHENTICATION, stepUpResponse } from './server/errors.js';
|
|
4
5
|
export type { ArtifactAtlasRefusal } from './server/errors.js';
|
|
5
6
|
export type { ArtifactAuditEvent, ArtifactOrmOperations, ArtifactOrmOptions, ArtifactProcessAdvance, ArtifactProcessAdvanceResult, ArtifactProcessArtifactRef, ArtifactProcessAssign, ArtifactProcessAssignResult, ArtifactProcessDefinition, ArtifactProcessDescribeQuery, ArtifactProcessEdge, ArtifactProcessHumanNode, ArtifactProcessNode, ArtifactProcessNodeBase, ArtifactProcessNodeState, ArtifactProcessPrincipal, ArtifactProcessPrincipalSearch, ArtifactProcessPrincipals, ArtifactProcessRun, ArtifactProcessRunRecord, ArtifactProcessRunSnapshot, ArtifactProcessRunSummary, ArtifactProcessRunsPage, ArtifactProcessRunsPageQuery, ArtifactProcessRunsQuery, ArtifactProcessRunViewerChange, ArtifactProcessRunViewers, ArtifactProcessStart, ArtifactProcessStepRecord, ArtifactProcessShareRun, ArtifactProcessShareRunResult, ArtifactProcessRunSharingAuthoritiesQuery, ArtifactProcessRunSharingAuthoritiesPage, ArtifactProcessRunSharingAuthority, ArtifactProcessRunSharingDelegate, ArtifactProcessRevokeRunSharingAuthority, ArtifactProcessRevokeRunSharingAuthorityResult, ArtifactProcessTask, ArtifactProcessTasksQuery, ArtifactServerAudit, ArtifactServerClient, ArtifactServerClientOptions, ArtifactServerContext, ArtifactServerFetchOptions, ArtifactServerFlags, ArtifactServerGroups, ArtifactServerProcesses, ArtifactGroupMembership, ArtifactGroupMembershipsPage, ArtifactGroupMembershipsQuery, } from './server/client.js';
|
|
@@ -7,7 +8,7 @@ export { ARTIFACT_AUDIT_ACTIONS, ARTIFACT_AUDIT_RESOURCES_PER_EVENT, ARTIFACT_FL
|
|
|
7
8
|
export type { ArtifactAuditAction, ArtifactProcessPrincipalType, ArtifactProcessRunStatus, ArtifactProcessTaskView, } from './server/broker-contract.js';
|
|
8
9
|
export { getArtifactRequestContext, readArtifactRuntimeConfig } from './server/runtime-config.js';
|
|
9
10
|
export type { ArtifactRuntimeConfig, ArtifactRequestHeaders } from './server/runtime-config.js';
|
|
10
|
-
export { ArtifactContextError, getArtifactBrowserBootstrap } from './server/request-context.js';
|
|
11
|
+
export { ArtifactContextError, getArtifactBrowserBootstrap, ARTIFACT_REQUEST_AUTHENTICATION_HEADER, verifyArtifactRequestAuthentication } from './server/request-context.js';
|
|
11
12
|
export type { ArtifactContextErrorReason, ArtifactRequestContext, RequestContextV2External } from './server/request-context.js';
|
|
12
13
|
export { verifyArtifactExternalContext } from './server/external-context.js';
|
|
13
14
|
export type { ArtifactExternalAccept } from './server/external-context.js';
|
package/dist/server.js
CHANGED
|
@@ -3,6 +3,6 @@ export { createArtifactServerClient } from './server/client.js';
|
|
|
3
3
|
export { ArtifactPrincipalError, ArtifactServerError, INSUFFICIENT_USER_AUTHENTICATION, stepUpResponse } from './server/errors.js';
|
|
4
4
|
export { ARTIFACT_AUDIT_ACTIONS, ARTIFACT_AUDIT_RESOURCES_PER_EVENT, ARTIFACT_FLAGS_PER_REQUEST, ARTIFACT_GROUP_MEMBERSHIPS_LIMIT, ARTIFACT_GROUP_MEMBERSHIPS_MAX_PAGE, ARTIFACT_PROCESS_PRINCIPAL_SEARCH_LIMIT, ARTIFACT_PROCESS_PRINCIPAL_TYPES, ARTIFACT_PROCESS_PRINCIPALS_PER_CALL, ARTIFACT_PROCESS_RUN_STATUSES, ARTIFACT_PROCESS_RUNS_LIMIT, ARTIFACT_PROCESS_RUN_ENTITIES_LIMIT, ARTIFACT_PROCESS_SHARE_NODES_LIMIT, ARTIFACT_PROCESS_SHARING_AUTHORITIES_LIMIT, ARTIFACT_PROCESS_TASK_VIEWS, ARTIFACT_PROCESS_TASKS_LIMIT, } from './server/broker-contract.js';
|
|
5
5
|
export { getArtifactRequestContext, readArtifactRuntimeConfig } from './server/runtime-config.js';
|
|
6
|
-
export { ArtifactContextError, getArtifactBrowserBootstrap } from './server/request-context.js';
|
|
6
|
+
export { ArtifactContextError, getArtifactBrowserBootstrap, ARTIFACT_REQUEST_AUTHENTICATION_HEADER, verifyArtifactRequestAuthentication } from './server/request-context.js';
|
|
7
7
|
export { verifyArtifactExternalContext } from './server/external-context.js';
|
|
8
8
|
export { externalEvidence } from './server/principal.js';
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
},
|
|
11
11
|
"dependencies": {},
|
|
12
12
|
"devDependencies": {
|
|
13
|
-
"@sequenceholdings/artifact-studio": "^0.
|
|
13
|
+
"@sequenceholdings/artifact-studio": "^0.9.0",
|
|
14
14
|
"@sequenceholdings/atlas-ui": "^3.1.1",
|
|
15
15
|
"@tailwindcss/postcss": "^4.2.2",
|
|
16
16
|
"@tailwindcss/vite": "^4.2.2",
|
package/dist/tunnel/broker.d.ts
CHANGED
|
@@ -30,6 +30,8 @@ export interface GatewayAuthorization {
|
|
|
30
30
|
publicOrigin: string;
|
|
31
31
|
/** Signed, audience-scoped context minted by Atlas, never a user bearer. */
|
|
32
32
|
requestContext?: string;
|
|
33
|
+
/** Generated from the authenticated gateway session, never forwarded from a browser. */
|
|
34
|
+
requestAuthentication?: string;
|
|
33
35
|
}
|
|
34
36
|
export interface TunnelBrokerOptions {
|
|
35
37
|
nodeId: string;
|
|
@@ -52,6 +54,10 @@ export declare class ArtifactTunnelBroker {
|
|
|
52
54
|
private readonly pendingConnections;
|
|
53
55
|
private readonly ownerTimers;
|
|
54
56
|
private closing;
|
|
57
|
+
private draining;
|
|
58
|
+
private readonly drainWaiters;
|
|
59
|
+
private readonly pendingReceipts;
|
|
60
|
+
private relayClosed?;
|
|
55
61
|
constructor(options: TunnelBrokerOptions);
|
|
56
62
|
handleTunnelUpgrade(request: IncomingMessage, socket: Duplex, head: Buffer): Promise<void>;
|
|
57
63
|
handleHttp(request: IncomingMessage, response: ServerResponse, sessionId: string): Promise<void>;
|
|
@@ -59,6 +65,7 @@ export declare class ArtifactTunnelBroker {
|
|
|
59
65
|
handleExternalHttp(input: TunnelExternalRequest): Promise<void>;
|
|
60
66
|
handleWebSocketUpgrade(request: IncomingMessage, socket: Duplex, head: Buffer, sessionId: string): Promise<void>;
|
|
61
67
|
private authorize;
|
|
68
|
+
private requestHeaders;
|
|
62
69
|
/**
|
|
63
70
|
* The live tunnel for an external request: the session's current lease, for the granted project
|
|
64
71
|
* and environment, still valid. Null otherwise. No browser owner is compared: Atlas's grant
|
|
@@ -79,7 +86,13 @@ export declare class ArtifactTunnelBroker {
|
|
|
79
86
|
private writeExternalChunk;
|
|
80
87
|
private finish;
|
|
81
88
|
private abort;
|
|
82
|
-
/**
|
|
89
|
+
/** Stop new upgrades and remote-owner admission while admitted HTTP handlers finish. */
|
|
90
|
+
beginDrain(): void;
|
|
91
|
+
private notifyDrained;
|
|
92
|
+
/** The gateway first joins admitted handlers and response accounting. Its outer deadline
|
|
93
|
+
* bounds finite streams; lease validation and revocation remain active until cleanup. */
|
|
94
|
+
drain(): Promise<void>;
|
|
95
|
+
/** Immediate security revocation, never a graceful drain. */
|
|
83
96
|
disconnect(sessionId: string): void;
|
|
84
97
|
close(): void;
|
|
85
98
|
}
|
package/dist/tunnel/broker.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { WebSocketServer } from 'ws';
|
|
2
2
|
import { TunnelPeer, tunnelLimits } from './peer.js';
|
|
3
3
|
import { assertSafeRequest, encodeTunnelFrame, isSharedCacheDirective, normalizePublicOrigin, sanitizeRequestHeaders, sanitizeResponseHeaders, SHARED_CACHE_REFUSAL, TUNNEL_PROTOCOL } from './protocol.js';
|
|
4
|
+
import { appendArtifactAuthenticationHeader } from '../server/request-authentication.js';
|
|
4
5
|
import { forwardWebSocketMessages, MessageBudget, permittedCloseCode, sendWebSocket, WebSocketMessage, writeChunk } from './io.js';
|
|
5
6
|
import { declaredLength, isExternalFailure } from './external.js';
|
|
6
7
|
const UPGRADE_REASONS = { 403: 'Forbidden', 429: 'Too Many Requests', 503: 'Service Unavailable' };
|
|
@@ -65,6 +66,10 @@ export class ArtifactTunnelBroker {
|
|
|
65
66
|
pendingConnections = new Map();
|
|
66
67
|
ownerTimers = new Set();
|
|
67
68
|
closing = false;
|
|
69
|
+
draining = false;
|
|
70
|
+
drainWaiters = new Set();
|
|
71
|
+
pendingReceipts = new Set();
|
|
72
|
+
relayClosed;
|
|
68
73
|
constructor(options) {
|
|
69
74
|
this.options = options;
|
|
70
75
|
if (!options.nodeId || !options.routing || !options.authorizeTunnel || !options.authorizeRequest || !options.validateLease) {
|
|
@@ -80,7 +85,7 @@ export class ArtifactTunnelBroker {
|
|
|
80
85
|
let lease = null;
|
|
81
86
|
let claimed = false;
|
|
82
87
|
try {
|
|
83
|
-
if (this.closing) {
|
|
88
|
+
if (this.draining || this.closing) {
|
|
84
89
|
rejectUpgrade(socket, 503);
|
|
85
90
|
return;
|
|
86
91
|
}
|
|
@@ -99,19 +104,28 @@ export class ArtifactTunnelBroker {
|
|
|
99
104
|
rejectUpgrade(socket, 403);
|
|
100
105
|
return;
|
|
101
106
|
}
|
|
107
|
+
if (this.draining || this.closing) {
|
|
108
|
+
rejectUpgrade(socket, 503);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
102
111
|
claimed = await this.options.routing.claim(lease, this.options.nodeId);
|
|
103
112
|
const owner = await this.options.routing.lookup(lease.sessionId);
|
|
104
113
|
if (!claimed || owner?.nodeId !== this.options.nodeId || owner.epoch !== lease.epoch || socket.destroyed) {
|
|
105
114
|
rejectUpgrade(socket, 403);
|
|
106
115
|
return;
|
|
107
116
|
}
|
|
117
|
+
if (this.draining || this.closing) {
|
|
118
|
+
await this.options.routing.release(lease.sessionId, lease.epoch, this.options.nodeId).catch(() => undefined);
|
|
119
|
+
rejectUpgrade(socket, 503);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
108
122
|
const authorizedLease = lease;
|
|
109
123
|
this.tunnels.handleUpgrade(request, socket, head, (websocket) => {
|
|
110
124
|
const ready = () => websocket.send(encodeTunnelFrame({ type: 'ready', id: authorizedLease.sessionId, epoch: authorizedLease.epoch }));
|
|
111
125
|
websocket.once('close', () => this.options.onTunnelClosed?.(authorizedLease));
|
|
112
126
|
if (this.options.relay) {
|
|
113
127
|
void this.options.relay.attach(authorizedLease, websocket).then(() => {
|
|
114
|
-
if (this.closing) {
|
|
128
|
+
if (this.draining || this.closing) {
|
|
115
129
|
websocket.terminate();
|
|
116
130
|
return;
|
|
117
131
|
}
|
|
@@ -283,7 +297,15 @@ export class ArtifactTunnelBroker {
|
|
|
283
297
|
async handleWebSocketUpgrade(request, socket, head, sessionId) {
|
|
284
298
|
socket.pause();
|
|
285
299
|
try {
|
|
300
|
+
if (this.draining || this.closing) {
|
|
301
|
+
rejectUpgrade(socket, 503);
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
286
304
|
const authorized = await this.authorize(request, sessionId);
|
|
305
|
+
if (this.draining || this.closing) {
|
|
306
|
+
rejectUpgrade(socket, 503);
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
287
309
|
if (!authorized) {
|
|
288
310
|
rejectUpgrade(socket, 403);
|
|
289
311
|
return;
|
|
@@ -317,12 +339,9 @@ export class ArtifactTunnelBroker {
|
|
|
317
339
|
return null;
|
|
318
340
|
if (!['GET', 'HEAD', 'OPTIONS'].includes(request.method ?? 'GET') && request.headers.origin !== origin)
|
|
319
341
|
return null;
|
|
320
|
-
const headers =
|
|
321
|
-
if (
|
|
322
|
-
|
|
323
|
-
return null;
|
|
324
|
-
headers['x-sequence-artifact-context'] = authorization.requestContext;
|
|
325
|
-
}
|
|
342
|
+
const headers = this.requestHeaders({ request, origin, authorization });
|
|
343
|
+
if (!headers)
|
|
344
|
+
return null;
|
|
326
345
|
let connection = this.connections.get(sessionId);
|
|
327
346
|
if (this.options.relay && this.options.resolveLease) {
|
|
328
347
|
const lease = await this.options.resolveLease(sessionId);
|
|
@@ -341,6 +360,21 @@ export class ArtifactTunnelBroker {
|
|
|
341
360
|
return null;
|
|
342
361
|
return { connection, headers };
|
|
343
362
|
}
|
|
363
|
+
requestHeaders({ request, origin, authorization }) {
|
|
364
|
+
const headers = sanitizeRequestHeaders(request.headers, origin);
|
|
365
|
+
if (authorization.requestContext) {
|
|
366
|
+
if (/[\r\n]/.test(authorization.requestContext))
|
|
367
|
+
return null;
|
|
368
|
+
headers['x-sequence-artifact-context'] = authorization.requestContext;
|
|
369
|
+
}
|
|
370
|
+
try {
|
|
371
|
+
appendArtifactAuthenticationHeader({ headers, token: authorization.requestAuthentication, contextToken: authorization.requestContext });
|
|
372
|
+
}
|
|
373
|
+
catch {
|
|
374
|
+
return null;
|
|
375
|
+
}
|
|
376
|
+
return headers;
|
|
377
|
+
}
|
|
344
378
|
/**
|
|
345
379
|
* The live tunnel for an external request: the session's current lease, for the granted project
|
|
346
380
|
* and environment, still valid. Null otherwise. No browser owner is compared: Atlas's grant
|
|
@@ -459,7 +493,22 @@ export class ArtifactTunnelBroker {
|
|
|
459
493
|
return;
|
|
460
494
|
}
|
|
461
495
|
if (frame.type === 'end') {
|
|
496
|
+
if (!this.options.relay) {
|
|
497
|
+
this.finish(connection, frame.id);
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
// The existing cancel frame releases an already-completed stream. Redis owners that
|
|
501
|
+
// negotiated receipts retain the route until this proves the final response was consumed.
|
|
502
|
+
const receipt = connection.peer.send({ type: 'cancel', id: frame.id }).catch(() => connection.peer.close());
|
|
503
|
+
this.pendingReceipts.add(receipt);
|
|
462
504
|
this.finish(connection, frame.id);
|
|
505
|
+
try {
|
|
506
|
+
await receipt;
|
|
507
|
+
}
|
|
508
|
+
finally {
|
|
509
|
+
this.pendingReceipts.delete(receipt);
|
|
510
|
+
this.notifyDrained();
|
|
511
|
+
}
|
|
463
512
|
return;
|
|
464
513
|
}
|
|
465
514
|
if (frame.type === 'cancel') {
|
|
@@ -575,6 +624,7 @@ export class ArtifactTunnelBroker {
|
|
|
575
624
|
stream.socket.close(status ? 1011 : 1000);
|
|
576
625
|
else if (stream.upgrade)
|
|
577
626
|
rejectUpgrade(stream.upgrade.socket, status ?? 503);
|
|
627
|
+
this.notifyDrained();
|
|
578
628
|
}
|
|
579
629
|
abort(connection, id, status = 502) {
|
|
580
630
|
if (!connection.streams.has(id))
|
|
@@ -582,20 +632,54 @@ export class ArtifactTunnelBroker {
|
|
|
582
632
|
this.finish(connection, id, status);
|
|
583
633
|
void connection.peer.send({ type: 'cancel', id }).catch(() => undefined);
|
|
584
634
|
}
|
|
585
|
-
/**
|
|
635
|
+
/** Stop new upgrades and remote-owner admission while admitted HTTP handlers finish. */
|
|
636
|
+
beginDrain() {
|
|
637
|
+
if (this.draining)
|
|
638
|
+
return;
|
|
639
|
+
this.draining = true;
|
|
640
|
+
this.options.relay?.beginDrain?.();
|
|
641
|
+
for (const connection of this.connections.values()) {
|
|
642
|
+
for (const [id, stream] of connection.streams)
|
|
643
|
+
if (stream.upgrade)
|
|
644
|
+
this.abort(connection, id, 503);
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
notifyDrained() {
|
|
648
|
+
if (!this.drainWaiters.size || this.pendingReceipts.size)
|
|
649
|
+
return;
|
|
650
|
+
for (const connection of this.connections.values())
|
|
651
|
+
if (connection.streams.size)
|
|
652
|
+
return;
|
|
653
|
+
for (const resolve of this.drainWaiters)
|
|
654
|
+
resolve();
|
|
655
|
+
this.drainWaiters.clear();
|
|
656
|
+
}
|
|
657
|
+
/** The gateway first joins admitted handlers and response accounting. Its outer deadline
|
|
658
|
+
* bounds finite streams; lease validation and revocation remain active until cleanup. */
|
|
659
|
+
async drain() {
|
|
660
|
+
this.beginDrain();
|
|
661
|
+
await new Promise((resolve) => { this.drainWaiters.add(resolve); this.notifyDrained(); });
|
|
662
|
+
await this.options.relay?.drain?.();
|
|
663
|
+
this.close();
|
|
664
|
+
await this.relayClosed;
|
|
665
|
+
}
|
|
666
|
+
/** Immediate security revocation, never a graceful drain. */
|
|
586
667
|
disconnect(sessionId) {
|
|
587
668
|
this.connections.get(sessionId)?.peer.close();
|
|
588
669
|
this.options.relay?.disconnect(sessionId);
|
|
589
670
|
}
|
|
590
671
|
close() {
|
|
672
|
+
if (this.closing)
|
|
673
|
+
return;
|
|
591
674
|
this.closing = true;
|
|
675
|
+
this.draining = true;
|
|
592
676
|
for (const timer of this.ownerTimers)
|
|
593
677
|
clearInterval(timer);
|
|
594
678
|
this.ownerTimers.clear();
|
|
595
679
|
for (const connection of this.connections.values())
|
|
596
680
|
connection.peer.close();
|
|
597
681
|
this.tunnels.close();
|
|
598
|
-
|
|
682
|
+
this.relayClosed = this.options.relay?.close().catch(() => undefined);
|
|
599
683
|
}
|
|
600
684
|
}
|
|
601
685
|
/** For one-process local development/tests only. Production must provide a shared
|
package/dist/tunnel/client.js
CHANGED
|
@@ -6,6 +6,7 @@ import { forwardWebSocketMessages, MessageBudget, permittedCloseCode, sendWebSoc
|
|
|
6
6
|
import { declaredLength } from './external.js';
|
|
7
7
|
import { isLoopbackHostname } from '../loopback.js';
|
|
8
8
|
import { ARTIFACT_EXTERNAL_CONTEXT_KID_PREFIX } from '../server/internal/signed-token.js';
|
|
9
|
+
import { ARTIFACT_REQUEST_AUTHENTICATION_HEADER, assertArtifactRequestHeaderBudget, validatedArtifactAuthenticationHeader } from '../server/request-authentication.js';
|
|
9
10
|
/** One connection/epoch only. The caller obtains a fresh lease to reconnect;
|
|
10
11
|
* no HTTP request or mutation is ever retained or replayed. */
|
|
11
12
|
export async function connectLocalTunnel(options) {
|
|
@@ -59,6 +60,11 @@ export async function connectLocalTunnel(options) {
|
|
|
59
60
|
const context = frame.headers['x-sequence-artifact-context'];
|
|
60
61
|
if (typeof context === 'string')
|
|
61
62
|
headers['x-sequence-artifact-context'] = context;
|
|
63
|
+
// This frame came from the authenticated gateway connection, not a browser header map.
|
|
64
|
+
const authentication = validatedArtifactAuthenticationHeader({ token: frame.headers[ARTIFACT_REQUEST_AUTHENTICATION_HEADER], contextToken: context });
|
|
65
|
+
if (authentication !== undefined)
|
|
66
|
+
headers[ARTIFACT_REQUEST_AUTHENTICATION_HEADER] = authentication;
|
|
67
|
+
assertArtifactRequestHeaderBudget({ headers });
|
|
62
68
|
const timer = setTimeout(() => { void fail(frame.id); }, limits.requestTimeoutMs);
|
|
63
69
|
timer.unref();
|
|
64
70
|
const forward = { message: new WebSocketMessage(limits.maxWebSocketMessageBytes, messageBudget), bytes: 0, timer };
|
|
@@ -40,12 +40,16 @@ export type Remote = {
|
|
|
40
40
|
nodeId: string;
|
|
41
41
|
connection: string;
|
|
42
42
|
lastSeen: number;
|
|
43
|
+
receipts?: boolean;
|
|
43
44
|
};
|
|
44
45
|
export type Attached = {
|
|
45
46
|
lease: TunnelLease;
|
|
46
47
|
socket: TunnelSocket;
|
|
47
48
|
remotes: Map<string, Remote>;
|
|
48
|
-
streams: Map<string, Remote
|
|
49
|
+
streams: Map<string, Remote & {
|
|
50
|
+
websocket: boolean;
|
|
51
|
+
ended: boolean;
|
|
52
|
+
}>;
|
|
49
53
|
timer: NodeJS.Timeout;
|
|
50
54
|
queuedBytes: number;
|
|
51
55
|
receiving: Promise<void>;
|
package/dist/tunnel/redis.d.ts
CHANGED
|
@@ -12,6 +12,10 @@ export declare class RedisTunnelRelay implements TunnelRelay {
|
|
|
12
12
|
private readonly options;
|
|
13
13
|
private started;
|
|
14
14
|
private closed;
|
|
15
|
+
private draining;
|
|
16
|
+
private drainSealed;
|
|
17
|
+
private drainWork;
|
|
18
|
+
private readonly drainWaiters;
|
|
15
19
|
private readonly attached;
|
|
16
20
|
private readonly connected;
|
|
17
21
|
private readonly frames;
|
|
@@ -52,5 +56,13 @@ export declare class RedisTunnelRelay implements TunnelRelay {
|
|
|
52
56
|
private closeOwner;
|
|
53
57
|
private closeConnection;
|
|
54
58
|
disconnect(sessionId: string): void;
|
|
59
|
+
/** Reject new remote requests, but allow same-node work already admitted by the HTTP
|
|
60
|
+
* gateway until drain() seals it. Security checks and ownership timers stay live. */
|
|
61
|
+
beginDrain(): void;
|
|
62
|
+
private notifyDrained;
|
|
63
|
+
/** Updated requesters acknowledge consumed final responses with cancel. Keep those bounded
|
|
64
|
+
* routes even before drain starts to cover an end-vs-shutdown race. Legacy requesters retain
|
|
65
|
+
* the previous publish-only completion behavior; they never acquire a new receipt wait. */
|
|
66
|
+
drain(): Promise<void>;
|
|
55
67
|
close(): Promise<void>;
|
|
56
68
|
}
|