@sequenceholdings/artifact-studio 0.4.0 → 0.6.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 +35 -1
- package/VERSION-PIN.md +29 -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 +62 -13
- 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 +28 -0
- package/dist/offline/connector.js +122 -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/management-types.d.ts +25 -0
- package/dist/offline/management-types.js +1 -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/runtime-contracts.js +1 -1
- package/dist/sdk-orm.d.ts +7 -1
- package/dist/sdk-orm.js +12 -5
- package/dist/sdk.d.ts +134 -1
- package/dist/server/api-client.d.ts +26 -0
- package/dist/server/api-client.js +38 -0
- package/dist/server/broker-contract.d.ts +191 -5
- package/dist/server/broker-contract.js +142 -1
- package/dist/server/client.d.ts +82 -47
- package/dist/server/client.js +138 -59
- package/dist/server/connection-retry.d.ts +12 -0
- package/dist/server/connection-retry.js +40 -0
- package/dist/server/errors.d.ts +61 -0
- package/dist/server/errors.js +89 -0
- package/dist/server/external-context.d.ts +21 -0
- package/dist/server/external-context.js +19 -0
- package/dist/server/internal/signed-token.d.ts +15 -0
- package/dist/server/internal/signed-token.js +67 -0
- package/dist/server/internal/v1-context.d.ts +18 -0
- package/dist/server/internal/v1-context.js +26 -0
- package/dist/server/internal/verify-any.d.ts +22 -0
- package/dist/server/internal/verify-any.js +53 -0
- package/dist/server/logger.d.ts +1 -1
- package/dist/server/managed-server-path.d.ts +6 -0
- package/dist/server/managed-server-path.js +14 -0
- package/dist/server/principal.d.ts +48 -0
- package/dist/server/principal.js +39 -0
- package/dist/server/processes.d.ts +290 -0
- package/dist/server/processes.js +56 -0
- package/dist/server/request-context.d.ts +121 -3
- package/dist/server/request-context.js +111 -33
- package/dist/server/testing.d.ts +40 -0
- package/dist/server/testing.js +86 -0
- package/dist/server.d.ts +13 -4
- package/dist/server.js +6 -2
- 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/nextjs/package.json +3 -1
- package/dist/templates/nextjs/tests/server.test.mjs +25 -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 +15 -3
- package/templates/nextjs/AGENTS.md +10 -0
- package/templates/nextjs/CLAUDE.md +1 -0
- package/templates/nextjs/README.md +88 -0
- package/templates/nextjs/package.json +3 -1
- package/templates/nextjs/tests/server.test.mjs +25 -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,179 @@
|
|
|
1
|
+
import yaml from 'js-yaml';
|
|
2
|
+
const UUID_SOURCE = '[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}';
|
|
3
|
+
const SCANNED_EXTENSIONS = new Set(['.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs', '.json', '.yml', '.yaml']);
|
|
4
|
+
export function originsDeclaredInFiles(files) {
|
|
5
|
+
const origins = [];
|
|
6
|
+
for (const file of files) {
|
|
7
|
+
if (file.encoding === 'base64' || basename(file.path) !== 'artifact.bundle.yml')
|
|
8
|
+
continue;
|
|
9
|
+
for (const [name, url] of Object.entries(declaredTargets(file.content))) {
|
|
10
|
+
try {
|
|
11
|
+
origins.push({ name, origin: new URL(url).origin.toLowerCase() });
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
// A target that is not a URL is reported when the selected environment is checked.
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return origins;
|
|
19
|
+
}
|
|
20
|
+
export function declaredTargets(content) {
|
|
21
|
+
let parsed;
|
|
22
|
+
try {
|
|
23
|
+
parsed = yaml.load(content);
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return {};
|
|
27
|
+
}
|
|
28
|
+
if (!parsed || typeof parsed !== 'object' || !('targets' in parsed))
|
|
29
|
+
return {};
|
|
30
|
+
const targets = parsed.targets;
|
|
31
|
+
if (!targets || typeof targets !== 'object')
|
|
32
|
+
return {};
|
|
33
|
+
const urls = {};
|
|
34
|
+
for (const [name, value] of Object.entries(targets)) {
|
|
35
|
+
if (value && typeof value === 'object' && 'url' in value && typeof value.url === 'string')
|
|
36
|
+
urls[name] = value.url;
|
|
37
|
+
}
|
|
38
|
+
return urls;
|
|
39
|
+
}
|
|
40
|
+
export function contentContainsOrigin(content, origin, skippedLines) {
|
|
41
|
+
const haystack = content.toLowerCase();
|
|
42
|
+
const needle = origin.toLowerCase();
|
|
43
|
+
let from = 0;
|
|
44
|
+
while (from < haystack.length) {
|
|
45
|
+
const at = haystack.indexOf(needle, from);
|
|
46
|
+
if (at < 0)
|
|
47
|
+
return false;
|
|
48
|
+
const next = haystack[at + needle.length];
|
|
49
|
+
const line = haystack.slice(0, at).split('\n').length - 1;
|
|
50
|
+
if (!skippedLines.has(line) && (next === undefined || !/[a-z0-9.-]/.test(next)))
|
|
51
|
+
return true;
|
|
52
|
+
from = at + needle.length;
|
|
53
|
+
}
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
/** Manifest text with the root `targets` map removed, so its URLs are declarations. */
|
|
57
|
+
export function contentOutsideTargets(content) {
|
|
58
|
+
let parsed;
|
|
59
|
+
try {
|
|
60
|
+
parsed = yaml.load(content);
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return content;
|
|
64
|
+
}
|
|
65
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed) || !('targets' in parsed))
|
|
66
|
+
return content;
|
|
67
|
+
const rest = {};
|
|
68
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
69
|
+
if (key !== 'targets')
|
|
70
|
+
rest[key] = value;
|
|
71
|
+
}
|
|
72
|
+
return yaml.dump(rest, { lineWidth: -1 });
|
|
73
|
+
}
|
|
74
|
+
export function collectWorkspaceSlotIds(files) {
|
|
75
|
+
const ids = new Set();
|
|
76
|
+
for (const file of files) {
|
|
77
|
+
if (!scannable(file))
|
|
78
|
+
continue;
|
|
79
|
+
for (const workspaceId of workspaceSlotIds(file))
|
|
80
|
+
ids.add(workspaceId);
|
|
81
|
+
}
|
|
82
|
+
return [...ids];
|
|
83
|
+
}
|
|
84
|
+
export function workspaceSlotIds(file) {
|
|
85
|
+
const ids = new Set();
|
|
86
|
+
if (file.path.endsWith('.yml') || file.path.endsWith('.yaml'))
|
|
87
|
+
collectYamlWorkspaceIds(file.content, ids);
|
|
88
|
+
const marker = /workspace/gi;
|
|
89
|
+
let match;
|
|
90
|
+
while ((match = marker.exec(file.content))) {
|
|
91
|
+
const region = workspaceExpression(file.content, match.index);
|
|
92
|
+
for (const id of region.matchAll(new RegExp(UUID_SOURCE, 'gi')))
|
|
93
|
+
ids.add(id[0].toLowerCase());
|
|
94
|
+
}
|
|
95
|
+
return [...ids];
|
|
96
|
+
}
|
|
97
|
+
function workspaceExpression(content, start) {
|
|
98
|
+
let depth = 0;
|
|
99
|
+
let quote;
|
|
100
|
+
const limit = Math.min(content.length, start + 500);
|
|
101
|
+
for (let index = start; index < limit; index += 1) {
|
|
102
|
+
const char = content[index] ?? '';
|
|
103
|
+
const step = stepExpression({ char, depth, quote });
|
|
104
|
+
depth = step.depth;
|
|
105
|
+
quote = step.quote;
|
|
106
|
+
if (step.skip) {
|
|
107
|
+
index += 1;
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
if (step.done)
|
|
111
|
+
return content.slice(start, index);
|
|
112
|
+
if (char === '\n' && depth === 0 && expressionEnds(content.slice(index + 1))) {
|
|
113
|
+
return content.slice(start, index);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return content.slice(start, limit);
|
|
117
|
+
}
|
|
118
|
+
function stepExpression({ char, depth, quote }) {
|
|
119
|
+
if (quote) {
|
|
120
|
+
if (char === '\\')
|
|
121
|
+
return { depth, quote, skip: true };
|
|
122
|
+
return { depth, quote: char === quote ? undefined : quote };
|
|
123
|
+
}
|
|
124
|
+
if (char === '"' || char === "'" || char === '`')
|
|
125
|
+
return { depth, quote: char };
|
|
126
|
+
if (char === '(' || char === '[' || char === '{')
|
|
127
|
+
return { depth: depth + 1, quote };
|
|
128
|
+
if (char === ')' || char === ']' || char === '}')
|
|
129
|
+
return { depth: Math.max(0, depth - 1), quote };
|
|
130
|
+
if (char === ';' && depth === 0)
|
|
131
|
+
return { depth, quote, done: true };
|
|
132
|
+
return { depth, quote };
|
|
133
|
+
}
|
|
134
|
+
function expressionEnds(rest) {
|
|
135
|
+
const next = rest.split('\n')[0] ?? '';
|
|
136
|
+
return next.trim() === '' || (!/^[ \t]/.test(next) && !/^[)\]},.]/.test(next.trimStart()));
|
|
137
|
+
}
|
|
138
|
+
export function scannable(file) {
|
|
139
|
+
if (file.encoding === 'base64')
|
|
140
|
+
return false;
|
|
141
|
+
const name = basename(file.path);
|
|
142
|
+
if (name === 'pnpm-lock.yaml' || name === 'package-lock.json' || name === 'yarn.lock' || name.endsWith('.md'))
|
|
143
|
+
return false;
|
|
144
|
+
if (name === '.env')
|
|
145
|
+
return true;
|
|
146
|
+
const extension = name.includes('.') ? name.slice(name.lastIndexOf('.')) : '';
|
|
147
|
+
return SCANNED_EXTENSIONS.has(extension);
|
|
148
|
+
}
|
|
149
|
+
export function basename(path) {
|
|
150
|
+
const parts = path.split('/');
|
|
151
|
+
return parts[parts.length - 1] ?? path;
|
|
152
|
+
}
|
|
153
|
+
function collectYamlWorkspaceIds(content, ids) {
|
|
154
|
+
let parsed;
|
|
155
|
+
try {
|
|
156
|
+
parsed = yaml.load(content);
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
walkYaml(parsed, null, ids);
|
|
162
|
+
}
|
|
163
|
+
function walkYaml(node, parentKey, ids) {
|
|
164
|
+
if (!node || typeof node !== 'object')
|
|
165
|
+
return;
|
|
166
|
+
if (Array.isArray(node)) {
|
|
167
|
+
for (const item of node)
|
|
168
|
+
walkYaml(item, parentKey, ids);
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
for (const [key, value] of Object.entries(node)) {
|
|
172
|
+
if (parentKey === 'storage' && new RegExp(`^${UUID_SOURCE}$`, 'i').test(key))
|
|
173
|
+
ids.add(key.toLowerCase());
|
|
174
|
+
if (/workspace/i.test(key) && typeof value === 'string' && new RegExp(`^${UUID_SOURCE}$`, 'i').test(value)) {
|
|
175
|
+
ids.add(value.toLowerCase());
|
|
176
|
+
}
|
|
177
|
+
walkYaml(value, key, ids);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
@@ -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>;
|