@sequenceholdings/artifact-studio 0.4.0 → 0.5.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 +35 -1
- package/VERSION-PIN.md +9 -0
- package/dist/active-deploy-policy.d.ts +9 -3
- package/dist/active-deploy-policy.js +20 -8
- package/dist/artifact-env-bindings.d.ts +9 -0
- package/dist/artifact-env-bindings.js +32 -0
- package/dist/build.js +6 -0
- package/dist/cli.js +61 -12
- package/dist/deployment-validation.d.ts +2 -0
- package/dist/deployment-validation.js +2 -0
- package/dist/dev-link.js +28 -2
- package/dist/env-binding-scan.d.ts +10 -0
- package/dist/env-binding-scan.js +179 -0
- package/dist/env-bindings.d.ts +52 -0
- package/dist/env-bindings.js +180 -0
- package/dist/gateway/control.d.ts +33 -0
- package/dist/gateway/control.js +29 -0
- package/dist/gateway/hosted-proxy.d.ts +5 -0
- package/dist/gateway/hosted-proxy.js +4 -1
- package/dist/gateway/lattice-invoke.d.ts +48 -0
- package/dist/gateway/lattice-invoke.js +245 -0
- package/dist/gateway/lattice-upstream.d.ts +38 -0
- package/dist/gateway/lattice-upstream.js +136 -0
- package/dist/gateway/server.js +2 -3
- package/dist/gateway/session.d.ts +3 -1
- package/dist/gateway/session.js +10 -3
- package/dist/manifest-next.d.ts +55 -0
- package/dist/manifest-next.js +35 -2
- package/dist/manifest-static.d.ts +35 -0
- package/dist/manifest-static.js +12 -0
- package/dist/manifest.d.ts +48 -0
- package/dist/next-build.d.ts +2 -1
- package/dist/next-build.js +12 -7
- package/dist/offline/cache.d.ts +4 -0
- package/dist/offline/cache.js +7 -0
- package/dist/offline/connector.d.ts +26 -0
- package/dist/offline/connector.js +118 -0
- package/dist/offline/crypto.d.ts +5 -0
- package/dist/offline/crypto.js +24 -0
- package/dist/offline/index.d.ts +8 -0
- package/dist/offline/index.js +7 -0
- package/dist/offline/indexed-db.d.ts +10 -0
- package/dist/offline/indexed-db.js +104 -0
- package/dist/offline/outbox.d.ts +7 -0
- package/dist/offline/outbox.js +46 -0
- package/dist/offline/replay.d.ts +25 -0
- package/dist/offline/replay.js +192 -0
- package/dist/offline/secure-store.d.ts +6 -0
- package/dist/offline/secure-store.js +51 -0
- package/dist/offline/types.d.ts +162 -0
- package/dist/offline/types.js +1 -0
- package/dist/orm-json-scalars.d.ts +21 -0
- package/dist/orm-json-scalars.js +21 -0
- package/dist/runtime-contracts.d.ts +7 -7
- package/dist/sdk-orm.d.ts +7 -1
- package/dist/sdk-orm.js +12 -5
- package/dist/sdk.d.ts +126 -0
- package/dist/server/broker-contract.d.ts +191 -5
- package/dist/server/broker-contract.js +142 -1
- package/dist/server/client.d.ts +60 -12
- package/dist/server/client.js +143 -20
- package/dist/server/errors.d.ts +26 -0
- package/dist/server/errors.js +29 -0
- package/dist/server/managed-server-path.d.ts +6 -0
- package/dist/server/managed-server-path.js +14 -0
- package/dist/server/processes.d.ts +290 -0
- package/dist/server/processes.js +56 -0
- package/dist/server/request-context.d.ts +6 -0
- package/dist/server/request-context.js +10 -1
- package/dist/server.d.ts +5 -1
- package/dist/server.js +1 -0
- package/dist/source-resolver.d.ts +9 -2
- package/dist/source-resolver.js +55 -8
- package/dist/templates/nextjs/AGENTS.md +10 -0
- package/dist/templates/nextjs/CLAUDE.md +1 -0
- package/dist/templates/nextjs/README.md +88 -0
- package/dist/templates/react-vite/AGENTS.md +8 -88
- package/dist/templates/react-vite/CLAUDE.md +0 -6
- package/dist/templates/react-vite/package.json +1 -1
- package/dist/templates/react-vite/src/App.tsx +37 -14
- package/dist/templates/react-vite/src/styles.css +10 -0
- package/package.json +7 -3
- package/templates/nextjs/AGENTS.md +10 -0
- package/templates/nextjs/CLAUDE.md +1 -0
- package/templates/nextjs/README.md +88 -0
- package/templates/react-vite/AGENTS.md +8 -88
- package/templates/react-vite/CLAUDE.md +0 -6
- package/templates/react-vite/package.json +1 -1
- package/templates/react-vite/src/App.tsx +37 -14
- package/templates/react-vite/src/styles.css +10 -0
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
export interface NamedOrigin {
|
|
2
|
+
readonly name: string;
|
|
3
|
+
readonly origin: string;
|
|
4
|
+
}
|
|
5
|
+
export interface NamedWorkspace {
|
|
6
|
+
readonly name: string;
|
|
7
|
+
readonly workspaceName: string;
|
|
8
|
+
/** UUID of that workspace in the deploy environment, when the name resolved. */
|
|
9
|
+
readonly workspaceId?: string;
|
|
10
|
+
/** This deploy creates the workspace, so a missing remote id is not a foreign binding. */
|
|
11
|
+
readonly provisioned?: boolean;
|
|
12
|
+
}
|
|
13
|
+
export interface BindingSourceFile {
|
|
14
|
+
readonly path: string;
|
|
15
|
+
readonly content: string;
|
|
16
|
+
readonly encoding?: 'utf8' | 'base64';
|
|
17
|
+
}
|
|
18
|
+
export interface CrossEnvironmentBinding {
|
|
19
|
+
readonly path: string;
|
|
20
|
+
readonly kind: 'api_origin' | 'workspace';
|
|
21
|
+
readonly value: string;
|
|
22
|
+
readonly environment?: string;
|
|
23
|
+
readonly message: string;
|
|
24
|
+
}
|
|
25
|
+
export interface BindingCheckInput {
|
|
26
|
+
readonly deployEnvironment: string;
|
|
27
|
+
readonly deployOrigin: string;
|
|
28
|
+
readonly origins: readonly NamedOrigin[];
|
|
29
|
+
readonly workspaces: readonly NamedWorkspace[];
|
|
30
|
+
/** Workspace UUIDs that exist in the deploy environment. */
|
|
31
|
+
readonly workspaceIdsInDeployEnvironment: ReadonlySet<string>;
|
|
32
|
+
readonly files: readonly BindingSourceFile[];
|
|
33
|
+
}
|
|
34
|
+
export declare class CrossEnvironmentBindingError extends Error {
|
|
35
|
+
readonly deployEnvironment: string;
|
|
36
|
+
readonly bindings: readonly CrossEnvironmentBinding[];
|
|
37
|
+
constructor(deployEnvironment: string, bindings: readonly CrossEnvironmentBinding[]);
|
|
38
|
+
}
|
|
39
|
+
export declare function canonicalDeploymentOrigin(url: string): string | undefined;
|
|
40
|
+
export declare function formatCrossEnvironmentBindings({ deployEnvironment, bindings, }: {
|
|
41
|
+
deployEnvironment: string;
|
|
42
|
+
bindings: readonly CrossEnvironmentBinding[];
|
|
43
|
+
}): string;
|
|
44
|
+
export declare function findCrossEnvironmentBindings(input: BindingCheckInput): CrossEnvironmentBinding[];
|
|
45
|
+
export declare function assertNoCrossEnvironmentBindings({ deployEnvironment, deployOrigin, origins, workspaces, files, workspaceExists, }: {
|
|
46
|
+
deployEnvironment: string;
|
|
47
|
+
deployOrigin: string;
|
|
48
|
+
origins: readonly NamedOrigin[];
|
|
49
|
+
workspaces: readonly NamedWorkspace[];
|
|
50
|
+
files: readonly BindingSourceFile[];
|
|
51
|
+
workspaceExists: (workspaceId: string) => Promise<boolean>;
|
|
52
|
+
}): Promise<void>;
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { basename, collectWorkspaceSlotIds, contentContainsOrigin, declaredTargets, contentOutsideTargets, originsDeclaredInFiles, scannable, workspaceSlotIds, } from './env-binding-scan.js';
|
|
2
|
+
export class CrossEnvironmentBindingError extends Error {
|
|
3
|
+
deployEnvironment;
|
|
4
|
+
bindings;
|
|
5
|
+
constructor(deployEnvironment, bindings) {
|
|
6
|
+
super(formatCrossEnvironmentBindings({ deployEnvironment, bindings }));
|
|
7
|
+
this.deployEnvironment = deployEnvironment;
|
|
8
|
+
this.bindings = bindings;
|
|
9
|
+
this.name = 'CrossEnvironmentBindingError';
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
export function canonicalDeploymentOrigin(url) {
|
|
13
|
+
try {
|
|
14
|
+
return new URL(url).origin.toLowerCase();
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return undefined;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
export function formatCrossEnvironmentBindings({ deployEnvironment, bindings, }) {
|
|
21
|
+
const lines = bindings.map((binding) => ` ${binding.path}: ${binding.message}`);
|
|
22
|
+
return `Refusing to deploy to ${deployEnvironment} because the source binds another environment.\n${lines.join('\n')}`;
|
|
23
|
+
}
|
|
24
|
+
export function findCrossEnvironmentBindings(input) {
|
|
25
|
+
const deployOrigin = canonicalDeploymentOrigin(input.deployOrigin);
|
|
26
|
+
if (!deployOrigin) {
|
|
27
|
+
return [{
|
|
28
|
+
path: 'environment',
|
|
29
|
+
kind: 'api_origin',
|
|
30
|
+
value: input.deployOrigin,
|
|
31
|
+
message: `Invalid deployment URL '${input.deployOrigin}'.`,
|
|
32
|
+
}];
|
|
33
|
+
}
|
|
34
|
+
const origins = mergeOrigins(input.origins, originsDeclaredInFiles(input.files));
|
|
35
|
+
const present = workspaceIdsPresent(input);
|
|
36
|
+
return dedupe([
|
|
37
|
+
...selectedTargetFindings({ ...input, deployOrigin, origins }),
|
|
38
|
+
...foreignOriginFindings({ ...input, deployOrigin, origins }),
|
|
39
|
+
...unresolvedWorkspaceFindings(input),
|
|
40
|
+
...foreignWorkspaceFindings({ ...input, present }),
|
|
41
|
+
]);
|
|
42
|
+
}
|
|
43
|
+
export async function assertNoCrossEnvironmentBindings({ deployEnvironment, deployOrigin, origins, workspaces, files, workspaceExists, }) {
|
|
44
|
+
const present = new Set();
|
|
45
|
+
for (const workspace of workspaces) {
|
|
46
|
+
if (workspace.workspaceId)
|
|
47
|
+
present.add(workspace.workspaceId.toLowerCase());
|
|
48
|
+
}
|
|
49
|
+
for (const workspaceId of collectWorkspaceSlotIds(files)) {
|
|
50
|
+
if (present.has(workspaceId))
|
|
51
|
+
continue;
|
|
52
|
+
if (await workspaceExists(workspaceId))
|
|
53
|
+
present.add(workspaceId);
|
|
54
|
+
}
|
|
55
|
+
const bindings = findCrossEnvironmentBindings({
|
|
56
|
+
deployEnvironment,
|
|
57
|
+
deployOrigin,
|
|
58
|
+
origins,
|
|
59
|
+
workspaces,
|
|
60
|
+
workspaceIdsInDeployEnvironment: present,
|
|
61
|
+
files,
|
|
62
|
+
});
|
|
63
|
+
if (bindings.length > 0)
|
|
64
|
+
throw new CrossEnvironmentBindingError(deployEnvironment, bindings);
|
|
65
|
+
}
|
|
66
|
+
function mergeOrigins(catalog, declared) {
|
|
67
|
+
return [...catalog, ...declared].map((origin) => ({
|
|
68
|
+
name: origin.name,
|
|
69
|
+
origin: canonicalDeploymentOrigin(origin.origin) ?? origin.origin.toLowerCase(),
|
|
70
|
+
}));
|
|
71
|
+
}
|
|
72
|
+
function selectedTargetFindings({ deployEnvironment, deployOrigin, origins, files, }) {
|
|
73
|
+
const findings = [];
|
|
74
|
+
for (const file of files) {
|
|
75
|
+
if (file.encoding === 'base64' || basename(file.path) !== 'artifact.bundle.yml')
|
|
76
|
+
continue;
|
|
77
|
+
const selected = declaredTargets(file.content)[deployEnvironment];
|
|
78
|
+
if (selected === undefined)
|
|
79
|
+
continue;
|
|
80
|
+
const origin = canonicalDeploymentOrigin(selected);
|
|
81
|
+
if (origin === deployOrigin)
|
|
82
|
+
continue;
|
|
83
|
+
const owner = origins.find((entry) => entry.origin === origin && entry.name !== deployEnvironment);
|
|
84
|
+
findings.push({
|
|
85
|
+
path: file.path,
|
|
86
|
+
kind: 'api_origin',
|
|
87
|
+
value: selected,
|
|
88
|
+
...(owner ? { environment: owner.name } : {}),
|
|
89
|
+
message: owner
|
|
90
|
+
? `targets.${deployEnvironment}.url is ${origin}, which is the ${owner.name} API origin`
|
|
91
|
+
: `targets.${deployEnvironment}.url is ${selected}, not the ${deployEnvironment} API origin ${deployOrigin}`,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
return findings;
|
|
95
|
+
}
|
|
96
|
+
function foreignOriginFindings({ deployOrigin, origins, files, }) {
|
|
97
|
+
const foreign = new Map();
|
|
98
|
+
for (const origin of origins) {
|
|
99
|
+
if (origin.origin === deployOrigin)
|
|
100
|
+
continue;
|
|
101
|
+
const names = foreign.get(origin.origin) ?? [];
|
|
102
|
+
if (!names.includes(origin.name))
|
|
103
|
+
names.push(origin.name);
|
|
104
|
+
foreign.set(origin.origin, names);
|
|
105
|
+
}
|
|
106
|
+
const findings = [];
|
|
107
|
+
for (const file of files) {
|
|
108
|
+
if (!scannable(file))
|
|
109
|
+
continue;
|
|
110
|
+
const content = basename(file.path) === 'artifact.bundle.yml' ? contentOutsideTargets(file.content) : file.content;
|
|
111
|
+
for (const [origin, names] of foreign) {
|
|
112
|
+
if (!contentContainsOrigin(content, origin, new Set()))
|
|
113
|
+
continue;
|
|
114
|
+
findings.push({
|
|
115
|
+
path: file.path,
|
|
116
|
+
kind: 'api_origin',
|
|
117
|
+
value: origin,
|
|
118
|
+
environment: names[0],
|
|
119
|
+
message: `API origin ${origin} belongs to ${names.join(', ')}`,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return findings;
|
|
124
|
+
}
|
|
125
|
+
function unresolvedWorkspaceFindings(input) {
|
|
126
|
+
const seen = new Set();
|
|
127
|
+
const findings = [];
|
|
128
|
+
for (const workspace of input.workspaces) {
|
|
129
|
+
if (workspace.workspaceId || workspace.provisioned || seen.has(workspace.workspaceName))
|
|
130
|
+
continue;
|
|
131
|
+
seen.add(workspace.workspaceName);
|
|
132
|
+
findings.push({
|
|
133
|
+
path: 'sequence.app.yml',
|
|
134
|
+
kind: 'workspace',
|
|
135
|
+
value: workspace.workspaceName,
|
|
136
|
+
message: `Workspace "${workspace.workspaceName}" (${workspace.name}) does not resolve in ${input.deployEnvironment}`,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
return findings;
|
|
140
|
+
}
|
|
141
|
+
function foreignWorkspaceFindings({ deployEnvironment, files, present, }) {
|
|
142
|
+
const findings = [];
|
|
143
|
+
const reported = new Set();
|
|
144
|
+
for (const file of files) {
|
|
145
|
+
if (!scannable(file))
|
|
146
|
+
continue;
|
|
147
|
+
for (const workspaceId of workspaceSlotIds(file)) {
|
|
148
|
+
if (present.has(workspaceId) || reported.has(`${file.path}:${workspaceId}`))
|
|
149
|
+
continue;
|
|
150
|
+
reported.add(`${file.path}:${workspaceId}`);
|
|
151
|
+
findings.push({
|
|
152
|
+
path: file.path,
|
|
153
|
+
kind: 'workspace',
|
|
154
|
+
value: workspaceId,
|
|
155
|
+
message: `Workspace ${workspaceId} does not resolve in ${deployEnvironment}`,
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return findings;
|
|
160
|
+
}
|
|
161
|
+
function workspaceIdsPresent(input) {
|
|
162
|
+
const present = new Set();
|
|
163
|
+
for (const workspaceId of input.workspaceIdsInDeployEnvironment)
|
|
164
|
+
present.add(workspaceId.toLowerCase());
|
|
165
|
+
for (const workspace of input.workspaces) {
|
|
166
|
+
if (workspace.workspaceId)
|
|
167
|
+
present.add(workspace.workspaceId.toLowerCase());
|
|
168
|
+
}
|
|
169
|
+
return present;
|
|
170
|
+
}
|
|
171
|
+
function dedupe(bindings) {
|
|
172
|
+
const seen = new Set();
|
|
173
|
+
return bindings.filter((binding) => {
|
|
174
|
+
const key = `${binding.kind}\0${binding.path}\0${binding.value}`;
|
|
175
|
+
if (seen.has(key))
|
|
176
|
+
return false;
|
|
177
|
+
seen.add(key);
|
|
178
|
+
return true;
|
|
179
|
+
});
|
|
180
|
+
}
|
|
@@ -64,6 +64,24 @@ export declare const gatewayTargetSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
|
64
64
|
}, z.core.$strip>>;
|
|
65
65
|
}, z.core.$strip>], "kind">;
|
|
66
66
|
export type GatewayTarget = z.infer<typeof gatewayTargetSchema>;
|
|
67
|
+
declare const latticeInvokeGrantSchema: z.ZodObject<{
|
|
68
|
+
projectId: z.ZodString;
|
|
69
|
+
deploymentId: z.ZodString;
|
|
70
|
+
frameworkBuildId: z.ZodString;
|
|
71
|
+
upstreamUrl: z.ZodString;
|
|
72
|
+
upstreamAudience: z.ZodOptional<z.ZodString>;
|
|
73
|
+
publicOrigin: z.ZodString;
|
|
74
|
+
environment: z.ZodString;
|
|
75
|
+
principalSub: z.ZodString;
|
|
76
|
+
path: z.ZodString;
|
|
77
|
+
serviceAccountId: z.ZodNullable<z.ZodString>;
|
|
78
|
+
resources: z.ZodObject<{
|
|
79
|
+
concurrency: z.ZodNumber;
|
|
80
|
+
max_instances: z.ZodNumber;
|
|
81
|
+
timeout_seconds: z.ZodNumber;
|
|
82
|
+
}, z.core.$strict>;
|
|
83
|
+
}, z.core.$strict>;
|
|
84
|
+
export type LatticeInvokeGrant = z.infer<typeof latticeInvokeGrantSchema>;
|
|
67
85
|
export declare class GatewayControlError extends Error {
|
|
68
86
|
readonly status: number;
|
|
69
87
|
constructor(status: number);
|
|
@@ -86,9 +104,24 @@ export declare class ArtifactGatewayControl {
|
|
|
86
104
|
target: GatewayTarget;
|
|
87
105
|
}>;
|
|
88
106
|
authorize(session: GatewaySession): Promise<GatewayTarget>;
|
|
107
|
+
/** SHA-256 both sides so a wrong bearer still takes constant time. */
|
|
108
|
+
tokenMatches(presented: string): boolean;
|
|
109
|
+
latticeInvoke(body: {
|
|
110
|
+
server: string;
|
|
111
|
+
path: string;
|
|
112
|
+
principalSub: string;
|
|
113
|
+
lattice: {
|
|
114
|
+
runId: string;
|
|
115
|
+
nodeId: string;
|
|
116
|
+
stepId: string;
|
|
117
|
+
processId: string;
|
|
118
|
+
};
|
|
119
|
+
requestId: string;
|
|
120
|
+
}): Promise<LatticeInvokeGrant>;
|
|
89
121
|
tunnel({ token, sessionId, epoch }: {
|
|
90
122
|
token: string;
|
|
91
123
|
sessionId: string;
|
|
92
124
|
epoch: number;
|
|
93
125
|
}): Promise<TunnelLease>;
|
|
94
126
|
}
|
|
127
|
+
export {};
|
package/dist/gateway/control.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createHash, timingSafeEqual } from 'node:crypto';
|
|
1
2
|
import { z } from 'zod';
|
|
2
3
|
import { normalizePublicOrigin } from '../tunnel/protocol.js';
|
|
3
4
|
import { artifactRequestContextSchema } from '../server/request-context.js';
|
|
@@ -20,6 +21,23 @@ export const gatewayTargetSchema = z.discriminatedUnion('kind', [
|
|
|
20
21
|
]);
|
|
21
22
|
const bootstrapSchema = z.object({ parentOrigin: z.string().transform(normalizePublicOrigin), channelId: id, nonce: z.string().min(16).max(255) });
|
|
22
23
|
const usageResultSchema = z.object({ rejected: z.number().int().nonnegative() });
|
|
24
|
+
const latticeInvokeGrantSchema = z.object({
|
|
25
|
+
projectId: id,
|
|
26
|
+
deploymentId: id,
|
|
27
|
+
frameworkBuildId: artifactNextFrameworkBuildIdSchema,
|
|
28
|
+
upstreamUrl: z.string().url(),
|
|
29
|
+
upstreamAudience: z.string().url().optional(),
|
|
30
|
+
publicOrigin: z.string().min(1).max(2048),
|
|
31
|
+
environment: id,
|
|
32
|
+
principalSub: id,
|
|
33
|
+
path: z.string().min(1).max(512),
|
|
34
|
+
serviceAccountId: id.nullable(),
|
|
35
|
+
resources: z.object({
|
|
36
|
+
concurrency: z.number().int().min(1).max(80),
|
|
37
|
+
max_instances: z.number().int().min(1).max(100),
|
|
38
|
+
timeout_seconds: z.number().int().min(1).max(300),
|
|
39
|
+
}).strict(),
|
|
40
|
+
}).strict();
|
|
23
41
|
export class GatewayControlError extends Error {
|
|
24
42
|
status;
|
|
25
43
|
constructor(status) {
|
|
@@ -106,6 +124,17 @@ export class ArtifactGatewayControl {
|
|
|
106
124
|
throw new GatewayControlError(403);
|
|
107
125
|
return target;
|
|
108
126
|
}
|
|
127
|
+
/** SHA-256 both sides so a wrong bearer still takes constant time. */
|
|
128
|
+
tokenMatches(presented) {
|
|
129
|
+
if (presented.length === 0 || presented.length > 1024)
|
|
130
|
+
return false;
|
|
131
|
+
const actual = createHash('sha256').update(presented).digest();
|
|
132
|
+
const expected = createHash('sha256').update(this.options.token).digest();
|
|
133
|
+
return timingSafeEqual(actual, expected);
|
|
134
|
+
}
|
|
135
|
+
async latticeInvoke(body) {
|
|
136
|
+
return latticeInvokeGrantSchema.parse(await this.call('lattice-invoke', body));
|
|
137
|
+
}
|
|
109
138
|
async tunnel({ token, sessionId, epoch }) {
|
|
110
139
|
const value = await this.call('tunnel', { token, sessionId, epoch });
|
|
111
140
|
const parsed = z.object({ sessionId: id, epoch: z.number().int().positive(), ownerId: id, projectId: id, environment: id, expiresAt: z.number().int().positive(), publicOrigin: z.string().transform(normalizePublicOrigin) }).parse(value);
|
|
@@ -32,5 +32,10 @@ export interface HostedArtifactProxyOptions {
|
|
|
32
32
|
timeoutMs?: number;
|
|
33
33
|
};
|
|
34
34
|
}
|
|
35
|
+
export declare function hostedIdentityToken(input: {
|
|
36
|
+
audience: string;
|
|
37
|
+
signal: AbortSignal;
|
|
38
|
+
}): Promise<string>;
|
|
39
|
+
export declare function cloudRunOrigin(value: string): URL;
|
|
35
40
|
/** HTTP-only streaming hop. Authentication, CSRF and release admission happen before calling this. */
|
|
36
41
|
export declare function proxyHostedArtifact(options: HostedArtifactProxyOptions): Promise<void>;
|
|
@@ -32,7 +32,10 @@ async function defaultIdentityTokenProvider({ audience }) {
|
|
|
32
32
|
throw error;
|
|
33
33
|
}
|
|
34
34
|
}
|
|
35
|
-
function
|
|
35
|
+
export function hostedIdentityToken(input) {
|
|
36
|
+
return defaultIdentityTokenProvider(input);
|
|
37
|
+
}
|
|
38
|
+
export function cloudRunOrigin(value) {
|
|
36
39
|
const url = new URL(value);
|
|
37
40
|
if (!/^https:\/\/[a-z0-9.-]+\.run\.app\/?$/.test(value) || url.protocol !== 'https:' ||
|
|
38
41
|
url.username || url.password || url.port || url.pathname !== '/' || url.search || url.hash ||
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
|
+
import { type ArtifactRequestAdmission } from './admission.js';
|
|
3
|
+
import { type LatticeInvokeGrant } from './control.js';
|
|
4
|
+
import { invokeHostedArtifact } from './lattice-upstream.js';
|
|
5
|
+
import type { GatewayUsageReporter } from './usage.js';
|
|
6
|
+
/** Lattice invoke, then the generic refusal for every other `/_sequence/` path. */
|
|
7
|
+
export declare function routeSequenceRequest(input: {
|
|
8
|
+
request: IncomingMessage;
|
|
9
|
+
response: ServerResponse;
|
|
10
|
+
control: Parameters<typeof handleLatticeInvoke>[0]['control'];
|
|
11
|
+
privateKey: string;
|
|
12
|
+
keyId: string;
|
|
13
|
+
admission?: Pick<ArtifactRequestAdmission, 'acquire'>;
|
|
14
|
+
usage?: Pick<GatewayUsageReporter, 'record'>;
|
|
15
|
+
}): Promise<boolean>;
|
|
16
|
+
export declare function routeLatticeInvoke(input: {
|
|
17
|
+
request: IncomingMessage;
|
|
18
|
+
response: ServerResponse;
|
|
19
|
+
control: Parameters<typeof handleLatticeInvoke>[0]['control'];
|
|
20
|
+
privateKey: string;
|
|
21
|
+
keyId: string;
|
|
22
|
+
admission?: Pick<ArtifactRequestAdmission, 'acquire'>;
|
|
23
|
+
usage?: Pick<GatewayUsageReporter, 'record'>;
|
|
24
|
+
}): Promise<boolean>;
|
|
25
|
+
export declare function handleLatticeInvoke(input: {
|
|
26
|
+
request: IncomingMessage;
|
|
27
|
+
response: ServerResponse;
|
|
28
|
+
control: {
|
|
29
|
+
tokenMatches: (presented: string) => boolean;
|
|
30
|
+
latticeInvoke: (body: {
|
|
31
|
+
server: string;
|
|
32
|
+
path: string;
|
|
33
|
+
principalSub: string;
|
|
34
|
+
lattice: {
|
|
35
|
+
runId: string;
|
|
36
|
+
nodeId: string;
|
|
37
|
+
stepId: string;
|
|
38
|
+
processId: string;
|
|
39
|
+
};
|
|
40
|
+
requestId: string;
|
|
41
|
+
}) => Promise<LatticeInvokeGrant>;
|
|
42
|
+
};
|
|
43
|
+
privateKey: string;
|
|
44
|
+
keyId: string;
|
|
45
|
+
admission?: Pick<ArtifactRequestAdmission, 'acquire'>;
|
|
46
|
+
usage?: Pick<GatewayUsageReporter, 'record'>;
|
|
47
|
+
invoke?: typeof invokeHostedArtifact;
|
|
48
|
+
}): Promise<void>;
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import { randomBytes, randomUUID } from 'node:crypto';
|
|
2
|
+
import { performance } from 'node:perf_hooks';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import { isManagedServerInvokePath } from '../server/managed-server-path.js';
|
|
5
|
+
import { ARTIFACT_CONTEXT_MAX_AGE_SECONDS, ARTIFACT_LATTICE_CONTEXT_MAX_AGE_SECONDS } from '../server/request-context.js';
|
|
6
|
+
import { normalizePublicOrigin } from '../tunnel/protocol.js';
|
|
7
|
+
import { ArtifactAdmissionError } from './admission.js';
|
|
8
|
+
import { GatewayControlError } from './control.js';
|
|
9
|
+
import { invokeHostedArtifact } from './lattice-upstream.js';
|
|
10
|
+
import { signGatewayContext } from './session.js';
|
|
11
|
+
const requestSchema = z.object({
|
|
12
|
+
server: z.string().min(1).max(255),
|
|
13
|
+
path: z.string().refine(isManagedServerInvokePath, 'Invalid managed server path'),
|
|
14
|
+
principalSub: z.string().min(1).max(255),
|
|
15
|
+
body: z.unknown().optional(),
|
|
16
|
+
lattice: z.object({
|
|
17
|
+
runId: z.string().min(1).max(255),
|
|
18
|
+
nodeId: z.string().min(1).max(255),
|
|
19
|
+
stepId: z.string().min(1).max(255),
|
|
20
|
+
processId: z.string().min(1).max(255),
|
|
21
|
+
}).strict(),
|
|
22
|
+
requestId: z.string().min(1).max(255),
|
|
23
|
+
timeoutMs: z.number().int().positive().max(300_000),
|
|
24
|
+
}).strict();
|
|
25
|
+
const BODY_CAP = Math.floor(1.1 * 1024 * 1024);
|
|
26
|
+
/** Lattice invoke, then the generic refusal for every other `/_sequence/` path. */
|
|
27
|
+
export async function routeSequenceRequest(input) {
|
|
28
|
+
if (await routeLatticeInvoke(input))
|
|
29
|
+
return true;
|
|
30
|
+
if (input.request.url?.startsWith('/_sequence/')) {
|
|
31
|
+
write(input.response, 404, { error: 'not_found' });
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
export async function routeLatticeInvoke(input) {
|
|
37
|
+
if ((input.request.url ?? '').split('?')[0] !== '/_sequence/lattice-invoke')
|
|
38
|
+
return false;
|
|
39
|
+
if (input.request.method !== 'POST') {
|
|
40
|
+
write(input.response, 404, { error: 'not_found' });
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
await handleLatticeInvoke(input);
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
46
|
+
export async function handleLatticeInvoke(input) {
|
|
47
|
+
const startedAt = new Date().toISOString();
|
|
48
|
+
const startedMs = performance.now();
|
|
49
|
+
const presented = bearer(input.request);
|
|
50
|
+
if (!presented || !input.control.tokenMatches(presented)) {
|
|
51
|
+
write(input.response, 403, { error: 'lattice_invoke_denied' });
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
let parsed;
|
|
55
|
+
try {
|
|
56
|
+
parsed = requestSchema.parse(JSON.parse((await readBody(input.request)).toString('utf8')));
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
write(input.response, 400, { error: 'invalid_lattice_invoke' });
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
let grant;
|
|
63
|
+
try {
|
|
64
|
+
grant = await input.control.latticeInvoke({
|
|
65
|
+
server: parsed.server,
|
|
66
|
+
path: parsed.path,
|
|
67
|
+
principalSub: parsed.principalSub,
|
|
68
|
+
lattice: parsed.lattice,
|
|
69
|
+
requestId: parsed.requestId,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
catch (error) {
|
|
73
|
+
const status = error instanceof GatewayControlError ? error.status : 502;
|
|
74
|
+
if (status === 503) {
|
|
75
|
+
write(input.response, 503, { error: 'lattice_invoke_unavailable' });
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
if (status === 401 || status === 403 || status === 404) {
|
|
79
|
+
write(input.response, 403, { error: 'lattice_invoke_denied' });
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
write(input.response, 502, { error: 'lattice_invoke_failed' });
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
if (grant.principalSub !== parsed.principalSub || grant.path !== parsed.path) {
|
|
86
|
+
write(input.response, 403, { error: 'lattice_invoke_denied' });
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
const observed = await invokeAdmittedLattice({ ...input, grant, parsed });
|
|
90
|
+
input.usage?.record(latticeUsageEvent({ grant, requestId: parsed.requestId, startedAt, startedMs, ...observed }));
|
|
91
|
+
}
|
|
92
|
+
/** The browser proxy records on response finish. This path returns first, so it records the upstream result itself. */
|
|
93
|
+
async function invokeAdmittedLattice(input) {
|
|
94
|
+
const signal = callerAbortSignal(input);
|
|
95
|
+
let release = () => { };
|
|
96
|
+
try {
|
|
97
|
+
const admitted = await acquireLatticeAdmission({ admission: input.admission, grant: input.grant });
|
|
98
|
+
release = admitted.bind(input.response, () => input.response.destroy());
|
|
99
|
+
const session = syntheticSession(input.grant, input.parsed.lattice);
|
|
100
|
+
const requestContext = signGatewayContext({
|
|
101
|
+
session,
|
|
102
|
+
method: 'POST',
|
|
103
|
+
path: input.grant.path,
|
|
104
|
+
privateKey: input.privateKey,
|
|
105
|
+
keyId: input.keyId,
|
|
106
|
+
serviceAccountId: input.grant.serviceAccountId,
|
|
107
|
+
requestId: input.parsed.requestId,
|
|
108
|
+
ttlSeconds: Math.min(ARTIFACT_LATTICE_CONTEXT_MAX_AGE_SECONDS, Math.max(ARTIFACT_CONTEXT_MAX_AGE_SECONDS, Math.ceil(input.parsed.timeoutMs / 1000))),
|
|
109
|
+
});
|
|
110
|
+
const result = await (input.invoke ?? invokeHostedArtifact)({
|
|
111
|
+
upstreamUrl: input.grant.upstreamUrl,
|
|
112
|
+
...(input.grant.upstreamAudience ? { upstreamAudience: input.grant.upstreamAudience } : {}),
|
|
113
|
+
path: input.grant.path,
|
|
114
|
+
body: input.parsed.body ?? {},
|
|
115
|
+
requestContext,
|
|
116
|
+
timeoutMs: input.parsed.timeoutMs,
|
|
117
|
+
signal,
|
|
118
|
+
});
|
|
119
|
+
write(input.response, 200, { status: result.status, body: result.body });
|
|
120
|
+
return latticeOutcome({ aborted: signal.aborted, status: result.status });
|
|
121
|
+
}
|
|
122
|
+
catch (error) {
|
|
123
|
+
if (error instanceof ArtifactAdmissionError) {
|
|
124
|
+
writeAdmissionFailure(input.response, error);
|
|
125
|
+
return { outcome: 'error', httpStatus: error.status };
|
|
126
|
+
}
|
|
127
|
+
if (signal.aborted || input.response.writableEnded)
|
|
128
|
+
return { outcome: 'cancelled', httpStatus: 499 };
|
|
129
|
+
write(input.response, 502, { error: 'lattice_invoke_failed' });
|
|
130
|
+
return { outcome: 'error', httpStatus: 502 };
|
|
131
|
+
}
|
|
132
|
+
finally {
|
|
133
|
+
release();
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
function latticeOutcome({ aborted, status }) {
|
|
137
|
+
const httpStatus = status >= 100 && status <= 599 ? status : 502;
|
|
138
|
+
if (aborted)
|
|
139
|
+
return { outcome: 'cancelled', httpStatus };
|
|
140
|
+
return { outcome: httpStatus >= 400 ? 'error' : 'success', httpStatus };
|
|
141
|
+
}
|
|
142
|
+
function latticeUsageEvent(input) {
|
|
143
|
+
return {
|
|
144
|
+
// One id per gateway call. Retries of the same Lattice step reuse requestId,
|
|
145
|
+
// and a later attempt with a different outcome would be rejected as a
|
|
146
|
+
// conflicting duplicate. Delivery retries resend this same object.
|
|
147
|
+
eventId: `gateway:${randomUUID()}`,
|
|
148
|
+
requestId: input.requestId,
|
|
149
|
+
actorId: input.grant.principalSub,
|
|
150
|
+
projectId: input.grant.projectId,
|
|
151
|
+
deploymentId: input.grant.deploymentId,
|
|
152
|
+
environment: input.grant.environment,
|
|
153
|
+
startedAt: input.startedAt,
|
|
154
|
+
endedAt: new Date().toISOString(),
|
|
155
|
+
measurements: { requestCount: 1, durationMs: Math.max(0, Math.round(performance.now() - input.startedMs)) },
|
|
156
|
+
outcome: input.outcome,
|
|
157
|
+
httpStatus: input.httpStatus,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
function writeAdmissionFailure(response, error) {
|
|
161
|
+
const capacity = error.status === 429;
|
|
162
|
+
write(response, capacity ? 429 : 503, {
|
|
163
|
+
error: capacity ? 'lattice_invoke_capacity' : 'lattice_invoke_unavailable',
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
/** Same per-app lease the browser proxy acquires. Missing admission fails closed. */
|
|
167
|
+
async function acquireLatticeAdmission(input) {
|
|
168
|
+
if (!input.admission)
|
|
169
|
+
throw new ArtifactAdmissionError(503, 'admission_unavailable');
|
|
170
|
+
return input.admission.acquire({
|
|
171
|
+
environment: input.grant.environment,
|
|
172
|
+
projectId: input.grant.projectId,
|
|
173
|
+
deploymentId: input.grant.deploymentId,
|
|
174
|
+
resources: input.grant.resources,
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
/** The worker closing its connection must abort the Cloud Run POST still in flight. */
|
|
178
|
+
function callerAbortSignal(input) {
|
|
179
|
+
const abort = new AbortController();
|
|
180
|
+
const stop = () => {
|
|
181
|
+
if (!input.response.writableEnded)
|
|
182
|
+
abort.abort();
|
|
183
|
+
};
|
|
184
|
+
if (typeof input.request.on === 'function')
|
|
185
|
+
input.request.on('aborted', stop);
|
|
186
|
+
if (typeof input.response.on === 'function')
|
|
187
|
+
input.response.on('close', stop);
|
|
188
|
+
return abort.signal;
|
|
189
|
+
}
|
|
190
|
+
function syntheticSession(grant, lattice) {
|
|
191
|
+
const publicOrigin = normalizePublicOrigin(grant.publicOrigin);
|
|
192
|
+
return {
|
|
193
|
+
version: 1,
|
|
194
|
+
kind: 'hosted',
|
|
195
|
+
sessionId: null,
|
|
196
|
+
deploymentId: grant.deploymentId,
|
|
197
|
+
frameworkBuildId: grant.frameworkBuildId,
|
|
198
|
+
projectId: grant.projectId,
|
|
199
|
+
ownerId: grant.principalSub,
|
|
200
|
+
environment: grant.environment,
|
|
201
|
+
publicOrigin,
|
|
202
|
+
expiresAt: Date.now() + 60_000,
|
|
203
|
+
published: true,
|
|
204
|
+
bridge: {
|
|
205
|
+
parentOrigin: publicOrigin,
|
|
206
|
+
channelId: 'lattice-invoke',
|
|
207
|
+
nonce: randomBytes(16).toString('hex'),
|
|
208
|
+
},
|
|
209
|
+
lattice,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
function bearer(request) {
|
|
213
|
+
const header = request.headers.authorization;
|
|
214
|
+
if (typeof header !== 'string' || !header.startsWith('Bearer '))
|
|
215
|
+
return null;
|
|
216
|
+
return header.slice('Bearer '.length);
|
|
217
|
+
}
|
|
218
|
+
function readBody(request) {
|
|
219
|
+
return new Promise((resolve, reject) => {
|
|
220
|
+
const chunks = [];
|
|
221
|
+
let length = 0;
|
|
222
|
+
request.on('data', (chunk) => {
|
|
223
|
+
length += chunk.byteLength;
|
|
224
|
+
if (length > BODY_CAP) {
|
|
225
|
+
reject(new Error('body too large'));
|
|
226
|
+
request.destroy();
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
chunks.push(chunk);
|
|
230
|
+
});
|
|
231
|
+
request.on('end', () => resolve(Buffer.concat(chunks)));
|
|
232
|
+
request.on('error', reject);
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
function write(response, status, body) {
|
|
236
|
+
if (response.headersSent)
|
|
237
|
+
return;
|
|
238
|
+
response.writeHead(status, {
|
|
239
|
+
...(status >= 400 ? { connection: 'close' } : {}),
|
|
240
|
+
'content-type': 'application/json',
|
|
241
|
+
'cache-control': 'no-store',
|
|
242
|
+
'x-content-type-options': 'nosniff',
|
|
243
|
+
});
|
|
244
|
+
response.end(JSON.stringify(body));
|
|
245
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { request as httpsRequest } from 'node:https';
|
|
2
|
+
export interface HostedArtifactInvocation {
|
|
3
|
+
upstreamUrl: string;
|
|
4
|
+
upstreamAudience?: string;
|
|
5
|
+
path: string;
|
|
6
|
+
body: unknown;
|
|
7
|
+
requestContext: string;
|
|
8
|
+
timeoutMs: number;
|
|
9
|
+
/** Wall-clock cancellation from the caller, combined with `timeoutMs`. */
|
|
10
|
+
signal?: AbortSignal;
|
|
11
|
+
identityTokenProvider?: (input: {
|
|
12
|
+
audience: string;
|
|
13
|
+
signal: AbortSignal;
|
|
14
|
+
}) => Promise<string>;
|
|
15
|
+
/** Tests supply the socket. Production uses `https.request`. */
|
|
16
|
+
dial?: typeof httpsRequest;
|
|
17
|
+
/** Tests supply the hop. Production dials the validated Cloud Run origin. */
|
|
18
|
+
dispatch?: (input: {
|
|
19
|
+
origin: URL;
|
|
20
|
+
path: string;
|
|
21
|
+
headers: Record<string, string>;
|
|
22
|
+
body: Buffer;
|
|
23
|
+
timeoutMs: number;
|
|
24
|
+
signal: AbortSignal;
|
|
25
|
+
}) => Promise<{
|
|
26
|
+
status: number;
|
|
27
|
+
contentType?: string;
|
|
28
|
+
body: Buffer;
|
|
29
|
+
}>;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Buffered POST into a hosted Next.js app. The destination must already be a
|
|
33
|
+
* Cloud Run origin Atlas resolved; this rejects anything else before dialing.
|
|
34
|
+
*/
|
|
35
|
+
export declare function invokeHostedArtifact(input: HostedArtifactInvocation): Promise<{
|
|
36
|
+
status: number;
|
|
37
|
+
body: unknown;
|
|
38
|
+
}>;
|