@sequenceholdings/artifact-studio 0.9.1 → 0.9.2

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.
@@ -1,4 +1,4 @@
1
- /** Fail an artifact deploy whose source still points at another environment. */
1
+ /** Validate explicit artifact manifest bindings against the selected environment. */
2
2
  export declare function assertArtifactEnvironmentBindings({ directory, environment, token, }: {
3
3
  directory: string;
4
4
  environment: {
@@ -1,10 +1,11 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
1
3
  import { getJsonOr404 } from './api.js';
2
4
  import { knownEnvUrls } from './config.js';
3
5
  import { assertNoCrossEnvironmentBindings, canonicalDeploymentOrigin } from './env-bindings.js';
4
- import { readArtifactStudioSource } from './project.js';
5
- /** Fail an artifact deploy whose source still points at another environment. */
6
+ /** Validate explicit artifact manifest bindings against the selected environment. */
6
7
  export async function assertArtifactEnvironmentBindings({ directory, environment, token, }) {
7
- const source = await readArtifactStudioSource(directory);
8
+ const manifest = await readFile(join(directory, 'artifact.bundle.yml'), 'utf8');
8
9
  const deployOrigin = canonicalDeploymentOrigin(environment.url);
9
10
  if (!deployOrigin)
10
11
  throw new Error(`Invalid deployment URL '${environment.url}'.`);
@@ -17,7 +18,7 @@ export async function assertArtifactEnvironmentBindings({ directory, environment
17
18
  deployOrigin,
18
19
  origins,
19
20
  workspaces: [],
20
- files: source.files,
21
+ files: [{ path: 'artifact.bundle.yml', content: manifest }],
21
22
  workspaceExists: async (workspaceId) => {
22
23
  const workspace = await getJsonOr404({
23
24
  baseUrl: environment.url,
@@ -0,0 +1,8 @@
1
+ import { type Plugin } from 'vite';
2
+ import { type SourceBoundary } from './source-boundary.js';
3
+ export declare function guardStylesheetInputsPlugin(boundary: SourceBoundary): Plugin;
4
+ /**
5
+ * Reject `new URL('<path>', import.meta.url)` assets outside the boundary. A
6
+ * template literal is a glob over its static directory, which must stay inside.
7
+ */
8
+ export declare function guardImportMetaUrlPlugin(boundary: SourceBoundary): Plugin;
@@ -0,0 +1,178 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { readFile } from 'node:fs/promises';
3
+ import { dirname, isAbsolute, join, resolve } from 'node:path';
4
+ import { createIdResolver } from 'vite';
5
+ import { isBareImport } from './source-boundary.js';
6
+ /**
7
+ * Vite and Tailwind resolve some build inputs with internal resolvers that
8
+ * never reach a plugin's `resolveId`: stylesheet `@import`, CSS `url()` and
9
+ * `image-set()`, and `new URL('<literal>', import.meta.url)`. Tailwind also
10
+ * runs JavaScript named by `@plugin` and `@config`. The build runs on the
11
+ * caller's machine (a laptop, CI, or an agent sandbox holding the caller's
12
+ * token), so these guards check every such reference against the source
13
+ * boundary before Vite or Tailwind acts on it.
14
+ */
15
+ /** The stylesheet requests Vite and Tailwind process (Vite's own CSS language test). */
16
+ const STYLESHEET_REQUEST_RE = /\.(?:css|less|sass|scss|styl|stylus|pcss|postcss|sss)(?:$|\?)|[?&]lang\.css\b/;
17
+ /** Tailwind at-rules that load and run JavaScript at build time. */
18
+ const BUILD_CODE_AT_RULE_RE = /@(?:plugin|config)(?![\w-])/i;
19
+ /** A CSS string: either quote, with the other quote and escapes allowed inside. */
20
+ const CSS_STRING = String.raw `"((?:[^"\\]|\\.)*)"|'((?:[^'\\]|\\.)*)'`;
21
+ /** `@import` / `@reference` targets: a string, or a string or bare token in `url()`. */
22
+ const STYLESHEET_IMPORT_RE = new RegExp(String.raw `@(?:import|reference)\s*(?:url\(\s*)?(?:${CSS_STRING}|([^'"\s;)]+))`, 'gi');
23
+ /** Tailwind `@source` paths (`@source inline(...)` names classes, not files). */
24
+ const SOURCE_DIRECTIVE_RE = new RegExp(String.raw `@source\s+(?:not\s+)?(?:${CSS_STRING})`, 'gi');
25
+ /** Mirrors Vite's `cssUrlRE`, so every `url()` Vite rewrites is checked. */
26
+ const CSS_URL_RE = /(?<=^|[^\w\-\u0080-￿])url\((\s*('[^']+'|"[^"]+")\s*|(?:\\.|[^'")\\])+)\)/g;
27
+ const IMAGE_SET_RE = /image-set\(([^)]*(?:\([^)]*\)[^)]*)*)\)/gi;
28
+ const QUOTED_RE = new RegExp(CSS_STRING, 'g');
29
+ /** Mirrors Vite's `assetImportMetaUrlRE`. */
30
+ const IMPORT_META_URL_RE = /\bnew\s+URL\s*\(\s*('[^']+'|"[^"]+"|`[^`]+`)\s*,\s*import\.meta\.url\s*(?:,\s*)?\)/g;
31
+ const NON_FILE_URL_RE = /^(?:[a-z][a-z\d+.-]*:|\/\/|#)/i;
32
+ function pathOf(id) {
33
+ if (id.startsWith('\0'))
34
+ return null;
35
+ const filePath = id.split('?')[0];
36
+ return isAbsolute(filePath) ? filePath : null;
37
+ }
38
+ /**
39
+ * Where Vite would read a referenced asset from: relative to the referencing
40
+ * file, root-relative then absolute for a leading `/`. Null for a URL that is
41
+ * not a local file or that does not exist (Vite leaves those unresolved).
42
+ */
43
+ function referencedFile({ reference, importer, root }) {
44
+ let target = reference.trim().replace(/^['"]|['"]$/g, '').split(/[?#]/)[0];
45
+ try {
46
+ target = decodeURI(target);
47
+ }
48
+ catch {
49
+ // Vite keeps an undecodable URL as written.
50
+ }
51
+ if (!target || NON_FILE_URL_RE.test(target))
52
+ return null;
53
+ const candidates = target.startsWith('/') ? [join(root, target), target] : [resolve(dirname(importer), target)];
54
+ return candidates.find((candidate) => existsSync(candidate)) ?? null;
55
+ }
56
+ /**
57
+ * Reject Tailwind JavaScript loading and out-of-boundary references in every
58
+ * stylesheet the build processes, following `@import` into nested stylesheets
59
+ * because Tailwind and Vite read those without another plugin pass. Must run
60
+ * before Tailwind: list it ahead of `@tailwindcss/vite`.
61
+ */
62
+ /** The first capture group a match filled: the string body or the bare token. */
63
+ function captured(match) {
64
+ return match.slice(1).find((group) => group !== undefined);
65
+ }
66
+ export function guardStylesheetInputsPlugin(boundary) {
67
+ // Files already scanned or being scanned. Recording a file before awaiting
68
+ // its imports makes an import cycle end instead of awaiting an ancestor.
69
+ const started = new Set();
70
+ // Resolve package stylesheets as Vite's CSS @import does (the `style`
71
+ // condition): a CSS-only package such as tw-animate-css has no JS entry.
72
+ let resolveStylesheet;
73
+ async function checkStylesheet({ file, code, resolveStylesheetImport }) {
74
+ if (!boundary.isTrusted(file) && BUILD_CODE_AT_RULE_RE.test(code)) {
75
+ throw new Error(`Artifact stylesheet "${file}" uses Tailwind @plugin or @config, which runs JavaScript on the build machine. ` +
76
+ 'Configure Tailwind in CSS (@theme, @utility, @custom-variant) instead.');
77
+ }
78
+ const assertPath = (reference, kind) => {
79
+ const filePath = referencedFile({ reference, importer: file, root: boundary.root });
80
+ if (filePath)
81
+ boundary.assertBuildInput({ filePath, importer: file, bareSpecifier: false, reference: kind });
82
+ return filePath;
83
+ };
84
+ const nested = [];
85
+ for (const match of code.matchAll(STYLESHEET_IMPORT_RE)) {
86
+ const specifier = captured(match);
87
+ if (!specifier || NON_FILE_URL_RE.test(specifier))
88
+ continue;
89
+ // Check both the literal path and what the CSS resolver finds (which adds
90
+ // `.css` to an extensionless path and follows a package's `style` entry):
91
+ // Tailwind and Vite load whichever exists.
92
+ const literal = assertPath(specifier, 'stylesheet import');
93
+ const resolved = await resolveStylesheetImport(specifier, file);
94
+ if (resolved) {
95
+ boundary.assertBuildInput({ filePath: resolved, importer: file, bareSpecifier: isBareImport(specifier), reference: 'stylesheet import' });
96
+ }
97
+ // Tailwind and Vite parse any imported file as CSS, whatever its extension.
98
+ for (const filePath of new Set([literal, resolved]))
99
+ if (filePath)
100
+ nested.push(filePath);
101
+ }
102
+ for (const match of code.matchAll(SOURCE_DIRECTIVE_RE)) {
103
+ const path = captured(match);
104
+ if (path)
105
+ boundary.assertBuildInput({ filePath: resolve(dirname(file), path), importer: file, bareSpecifier: false, reference: '@source path' });
106
+ }
107
+ for (const [, reference] of code.matchAll(CSS_URL_RE)) {
108
+ if (reference)
109
+ assertPath(reference, 'stylesheet url()');
110
+ }
111
+ for (const [, body] of code.matchAll(IMAGE_SET_RE)) {
112
+ for (const quoted of (body ?? '').matchAll(QUOTED_RE)) {
113
+ const reference = captured(quoted);
114
+ if (reference)
115
+ assertPath(reference, 'stylesheet image-set()');
116
+ }
117
+ }
118
+ await Promise.all(nested.map((nestedFile) => checkFile(nestedFile, resolveStylesheetImport)));
119
+ }
120
+ async function checkFile(file, resolveStylesheetImport) {
121
+ // A file another traversal already started is checked there, and a failure
122
+ // there fails the build.
123
+ if (started.has(file))
124
+ return;
125
+ started.add(file);
126
+ await checkStylesheet({ file, code: await readFile(file, 'utf8'), resolveStylesheetImport });
127
+ }
128
+ return {
129
+ name: 'guard-artifact-stylesheet-inputs',
130
+ enforce: 'pre',
131
+ configResolved(config) {
132
+ resolveStylesheet = createIdResolver(config, {
133
+ extensions: ['.css'], mainFields: ['style'], conditions: ['style', 'production'], tryIndex: false, preferRelative: true,
134
+ });
135
+ },
136
+ async transform(code, id) {
137
+ const file = pathOf(id);
138
+ if (!file || !STYLESHEET_REQUEST_RE.test(id) || /[?&]raw\b/.test(id))
139
+ return null;
140
+ const environment = this.environment;
141
+ const resolveStylesheetImport = async (specifier, importer) => {
142
+ // An import the CSS resolver cannot find is checked by its literal path;
143
+ // anything else unresolvable fails in Vite or Tailwind as well.
144
+ const resolved = await resolveStylesheet?.(environment, specifier, importer).catch(() => undefined);
145
+ return resolved ? pathOf(resolved) : null;
146
+ };
147
+ await checkStylesheet({ file, code, resolveStylesheetImport });
148
+ return null;
149
+ },
150
+ };
151
+ }
152
+ /**
153
+ * Reject `new URL('<path>', import.meta.url)` assets outside the boundary. A
154
+ * template literal is a glob over its static directory, which must stay inside.
155
+ */
156
+ export function guardImportMetaUrlPlugin(boundary) {
157
+ return {
158
+ name: 'guard-artifact-import-meta-url',
159
+ enforce: 'pre',
160
+ transform(code, id) {
161
+ const file = pathOf(id);
162
+ if (!file || STYLESHEET_REQUEST_RE.test(id) || !code.includes('import.meta.url'))
163
+ return null;
164
+ for (const [, literal] of code.matchAll(IMPORT_META_URL_RE)) {
165
+ const body = literal.slice(1, -1);
166
+ const isTemplate = literal.startsWith('`') && body.includes('${');
167
+ const reference = isTemplate ? body.slice(0, body.lastIndexOf('/', body.indexOf('${')) + 1) || '.' : body;
168
+ if (NON_FILE_URL_RE.test(reference))
169
+ continue;
170
+ const target = reference.startsWith('/')
171
+ ? [join(boundary.root, reference), reference].find((candidate) => existsSync(candidate)) ?? join(boundary.root, reference)
172
+ : resolve(dirname(file), reference);
173
+ boundary.assertBuildInput({ filePath: target, importer: file, bareSpecifier: false, reference: 'new URL() asset' });
174
+ }
175
+ return null;
176
+ },
177
+ };
178
+ }
package/dist/build.js CHANGED
@@ -15,7 +15,8 @@ import { isNextArtifactManifest } from './manifest.js';
15
15
  import { normalizeArtifactPath } from './paths.js';
16
16
  import { resolveArtifactStudioPeerVersions, } from './peer-versions.js';
17
17
  import { ARTIFACT_RUNTIME_CHUNK_DIR, assertSafeRuntimeChunkFileName } from './runtime-chunks.js';
18
- import { rejectOutsideSourcePlugin } from './source-boundary.js';
18
+ import { guardImportMetaUrlPlugin, guardStylesheetInputsPlugin } from './build-input-guards.js';
19
+ import { createSourceBoundary, rejectOutsideSourcePlugin } from './source-boundary.js';
19
20
  import { collectCandidateTokenCompatibilityDiagnostics, collectDeprecatedTokenImportDiagnostics, } from './token-contract.js';
20
21
  /** Keep in sync with `atlas/src/server/services/artifact-studio/limits.ts`. */
21
22
  const MAX_DEPLOY_SOURCEMAP_BYTES = 10 * 1024 * 1024;
@@ -126,16 +127,24 @@ export async function buildArtifactStudioProject(rootDir, { candidateTokenCompat
126
127
  // Give Vite and the source boundary the same physical root.
127
128
  const root = await realpath(source.root);
128
129
  const transformedCssModules = new Map();
130
+ const boundary = createSourceBoundary({
131
+ root,
132
+ trustedExternalRoots: [...Object.values(peerResolveAlias).map((target) => dirname(target)), dirname(artifactSdkEntry)],
133
+ });
129
134
  const result = await viteBuild({
130
135
  root,
131
136
  configFile: false,
132
137
  logLevel: 'silent',
138
+ // An inline PostCSS config stops Vite from loading postcss.config.* (or
139
+ // .postcssrc) from the source tree, which would run repository code on the
140
+ // build machine. Tailwind runs as the Vite plugin below and needs none.
141
+ css: { postcss: { plugins: [] } },
133
142
  plugins: [
134
- rejectOutsideSourcePlugin({
135
- root,
136
- trustedExternalRoots: [...Object.values(peerResolveAlias).map((target) => dirname(target)), dirname(artifactSdkEntry)],
137
- }),
143
+ rejectOutsideSourcePlugin(boundary),
138
144
  rejectIgnoredSourcePlugin(root, source.ignorePatterns),
145
+ // Before Tailwind, which reads @import / @plugin / @config itself.
146
+ guardStylesheetInputsPlugin(boundary),
147
+ guardImportMetaUrlPlugin(boundary),
139
148
  artifactSdkVirtualModule(),
140
149
  atlasUiCssResolverPlugin({
141
150
  atlasUiDir: selectedAtlasUiDir,
package/dist/cli.d.ts CHANGED
@@ -39,5 +39,6 @@ export interface ProvidedToken {
39
39
  authMode: SourceAuthMode;
40
40
  token: string;
41
41
  }
42
+ export declare function setPackageInstallUnavailable(guidance: string | null): void;
42
43
  export declare function setTokenProvider(provider: ((options: TokenProviderOptions) => Promise<ProvidedToken | null>) | null): void;
43
44
  export {};
package/dist/cli.js CHANGED
@@ -64,6 +64,7 @@ const USAGE = `usage:
64
64
  select the target environment. Next.js build/plan/deploy accept --source-env
65
65
  to read a Git Service repository from a separately authenticated environment.
66
66
  Log in to both environments; source read and target deploy permissions apply.
67
+ Source identity must be adopted and belong to the destination tenant.
67
68
  The target must support verified cross-environment builds. Source credentials
68
69
  are used only to fetch source, never passed to the build or stored with it.
69
70
 
@@ -397,6 +398,19 @@ async function showCommand(args) {
397
398
  }
398
399
  return 0;
399
400
  }
401
+ function observedEvidenceLine(evidence) {
402
+ if ('atlasUi' in evidence) {
403
+ return `${evidence.channel} / ${evidence.integrityReceipt?.status ?? 'no receipt'} / ${evidence.toolchain.selection} toolchain ` +
404
+ `(atlas-ui ${evidence.atlasUi.contentHash.slice(0, 12)})`;
405
+ }
406
+ const toolchain = evidence.toolchain;
407
+ const parts = [
408
+ `Next.js ${toolchain?.next ?? '?'} on Node ${toolchain?.node ?? '?'}`,
409
+ evidence.builderImage ? `builder ${evidence.builderImage.replace(/^sha256:/, '').slice(0, 12)}` : undefined,
410
+ evidence.lockfileSha256 ? `lockfile ${evidence.lockfileSha256.slice(0, 12)}` : undefined,
411
+ ];
412
+ return parts.filter(Boolean).join(' / ');
413
+ }
400
414
  function logActiveDeploymentDetails(deployment) {
401
415
  console.log(` version: ${deployment.version}`);
402
416
  console.log(` sourceHash: ${deployment.sourceHash}`);
@@ -408,10 +422,8 @@ function logActiveDeploymentDetails(deployment) {
408
422
  if (deployment.cliVersion || deployment.atlasUiVersion) {
409
423
  console.log(` peers: artifact-studio@${deployment.cliVersion ?? '?'} atlas-ui@${deployment.atlasUiVersion ?? '?'}`);
410
424
  }
411
- if (deployment.observedBuildEvidence) {
412
- console.log(` observed: ${deployment.observedBuildEvidence.channel} / ${deployment.observedBuildEvidence.integrityReceipt?.status ?? 'no receipt'} / ${deployment.observedBuildEvidence.toolchain.selection} toolchain ` +
413
- `(atlas-ui ${deployment.observedBuildEvidence.atlasUi.contentHash.slice(0, 12)})`);
414
- }
425
+ if (deployment.observedBuildEvidence)
426
+ console.log(` observed: ${observedEvidenceLine(deployment.observedBuildEvidence)}`);
415
427
  if (deployment.deployMessage)
416
428
  console.log(` message: ${deployment.deployMessage}`);
417
429
  if (deployment.createdAt)
@@ -435,7 +447,10 @@ async function validateCommand(args) {
435
447
  return 0;
436
448
  }
437
449
  async function buildPreparedArtifactSource(source) {
438
- await prepareArtifactBuildRoot(source.dir, { remote: source.remote });
450
+ await prepareArtifactBuildRoot(source.dir, {
451
+ remote: source.remote,
452
+ ...(packageInstallUnavailable ? { installUnavailable: packageInstallUnavailable } : {}),
453
+ });
439
454
  return buildArtifactStudioProjectIsolated(source.dir);
440
455
  }
441
456
  function reportBuildDiagnostics(result) {
@@ -1066,6 +1081,16 @@ function assertSourceEnvironmentSupportsManifest(args) {
1066
1081
  throw new Error('--source-env is supported for Next.js repository builds only.');
1067
1082
  }
1068
1083
  let tokenProvider = null;
1084
+ /**
1085
+ * Embedder guidance for a host that cannot reach a package registry, such as an
1086
+ * agent sandbox. When set, a remote build whose package.json declares
1087
+ * production dependencies fails with it instead of attempting an install.
1088
+ * Next.js applications build in Atlas and are unaffected.
1089
+ */
1090
+ let packageInstallUnavailable = null;
1091
+ export function setPackageInstallUnavailable(guidance) {
1092
+ packageInstallUnavailable = guidance;
1093
+ }
1069
1094
  export function setTokenProvider(provider) {
1070
1095
  tokenProvider = provider;
1071
1096
  }
@@ -1,10 +1,7 @@
1
1
  import type { BindingSourceFile, NamedOrigin } from './env-bindings.js';
2
2
  export declare function originsDeclaredInFiles(files: readonly BindingSourceFile[]): NamedOrigin[];
3
3
  export declare function declaredTargets(content: string): Record<string, string>;
4
- export declare function contentContainsOrigin(content: string, origin: string, skippedLines: ReadonlySet<number>): boolean;
5
- /** Manifest text with the root `targets` map removed, so its URLs are declarations. */
6
- export declare function contentOutsideTargets(content: string): string;
7
4
  export declare function collectWorkspaceSlotIds(files: readonly BindingSourceFile[]): string[];
5
+ /** These are schema-defined capability maps, not arbitrary keys containing "workspace". */
8
6
  export declare function workspaceSlotIds(file: BindingSourceFile): string[];
9
- export declare function scannable(file: BindingSourceFile): boolean;
10
7
  export declare function basename(path: string): string;
@@ -0,0 +1,56 @@
1
+ import yaml from 'js-yaml';
2
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
3
+ /** Only schema-defined artifact manifest fields declare bindings; source text is never inspected. */
4
+ function manifestDocument(file) {
5
+ if (file.encoding === 'base64' || basename(file.path) !== 'artifact.bundle.yml')
6
+ return undefined;
7
+ return object(yaml.load(file.content));
8
+ }
9
+ function object(value) {
10
+ return isRecord(value) ? value : undefined;
11
+ }
12
+ function isRecord(value) {
13
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
14
+ }
15
+ export function originsDeclaredInFiles(files) {
16
+ const origins = [];
17
+ for (const file of files) {
18
+ if (!manifestDocument(file))
19
+ continue;
20
+ for (const [name, url] of Object.entries(declaredTargets(file.content))) {
21
+ try {
22
+ origins.push({ name, origin: new URL(url).origin.toLowerCase() });
23
+ }
24
+ catch {
25
+ // An invalid selected target is reported by the binding validator.
26
+ }
27
+ }
28
+ }
29
+ return origins;
30
+ }
31
+ export function declaredTargets(content) {
32
+ const targets = object(object(yaml.load(content))?.targets);
33
+ const urls = {};
34
+ for (const [name, value] of Object.entries(targets ?? {})) {
35
+ const url = object(value)?.url;
36
+ if (typeof url === 'string')
37
+ urls[name] = url;
38
+ }
39
+ return urls;
40
+ }
41
+ export function collectWorkspaceSlotIds(files) {
42
+ return [...new Set(files.flatMap(workspaceSlotIds))];
43
+ }
44
+ /** These are schema-defined capability maps, not arbitrary keys containing "workspace". */
45
+ export function workspaceSlotIds(file) {
46
+ const manifest = manifestDocument(file);
47
+ if (!manifest)
48
+ return [];
49
+ const browser = object(object(manifest.capabilities)?.storage);
50
+ const server = object(object(object(manifest.server)?.capabilities)?.storage);
51
+ return [...new Set([...Object.keys(browser ?? {}), ...Object.keys(server ?? {})]
52
+ .filter((id) => UUID.test(id)).map((id) => id.toLowerCase()))];
53
+ }
54
+ export function basename(path) {
55
+ return path.split('/').at(-1) ?? path;
56
+ }
@@ -1,4 +1,4 @@
1
- import { basename, collectWorkspaceSlotIds, contentContainsOrigin, declaredTargets, contentOutsideTargets, originsDeclaredInFiles, scannable, workspaceSlotIds, } from './env-binding-scan.js';
1
+ import { basename, collectWorkspaceSlotIds, declaredTargets, originsDeclaredInFiles, workspaceSlotIds, } from './env-binding-manifest.js';
2
2
  export class CrossEnvironmentBindingError extends Error {
3
3
  deployEnvironment;
4
4
  bindings;
@@ -19,7 +19,7 @@ export function canonicalDeploymentOrigin(url) {
19
19
  }
20
20
  export function formatCrossEnvironmentBindings({ deployEnvironment, bindings, }) {
21
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')}`;
22
+ return `Refusing to deploy to ${deployEnvironment} because declared manifest bindings do not match the selected environment.\n${lines.join('\n')}`;
23
23
  }
24
24
  export function findCrossEnvironmentBindings(input) {
25
25
  const deployOrigin = canonicalDeploymentOrigin(input.deployOrigin);
@@ -35,7 +35,6 @@ export function findCrossEnvironmentBindings(input) {
35
35
  const present = workspaceIdsPresent(input);
36
36
  return dedupe([
37
37
  ...selectedTargetFindings({ ...input, deployOrigin, origins }),
38
- ...foreignOriginFindings({ ...input, deployOrigin, origins }),
39
38
  ...unresolvedWorkspaceFindings(input),
40
39
  ...foreignWorkspaceFindings({ ...input, present }),
41
40
  ]);
@@ -93,35 +92,6 @@ function selectedTargetFindings({ deployEnvironment, deployOrigin, origins, file
93
92
  }
94
93
  return findings;
95
94
  }
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
95
  function unresolvedWorkspaceFindings(input) {
126
96
  const seen = new Set();
127
97
  const findings = [];
@@ -142,8 +112,6 @@ function foreignWorkspaceFindings({ deployEnvironment, files, present, }) {
142
112
  const findings = [];
143
113
  const reported = new Set();
144
114
  for (const file of files) {
145
- if (!scannable(file))
146
- continue;
147
115
  for (const workspaceId of workspaceSlotIds(file)) {
148
116
  if (present.has(workspaceId) || reported.has(`${file.path}:${workspaceId}`))
149
117
  continue;
@@ -1,3 +1,14 @@
1
+ /** Read-only server provenance preflight, never a permission grant or an activation token. */
2
+ export declare function checkRepositoryDeploymentIdentity({ baseUrl, token, repoId, commit }: {
3
+ baseUrl: string;
4
+ token: string;
5
+ repoId: string;
6
+ commit: string;
7
+ }): Promise<{
8
+ tenantId: string;
9
+ repositoryId: string;
10
+ generation: number;
11
+ }>;
1
12
  /** Resolve `namespace/name` to a repo id + default branch (FGA-filtered list, paginated). */
2
13
  export declare function resolveRepo({ baseUrl, token, namespace, name, }: {
3
14
  baseUrl: string;
@@ -1,6 +1,16 @@
1
1
  import { lstat, mkdir, realpath, unlink, writeFile } from 'node:fs/promises';
2
2
  import { dirname, resolve, sep } from 'node:path';
3
3
  import { getJson, getJsonOr404 } from './api.js';
4
+ import { RepositoryIdentitySchema } from './repository-identity.js';
5
+ /** Read-only server provenance preflight, never a permission grant or an activation token. */
6
+ export async function checkRepositoryDeploymentIdentity({ baseUrl, token, repoId, commit }) {
7
+ const result = await getJson({ baseUrl, token,
8
+ path: `/api/git-service/repos/${encodeURIComponent(repoId)}/deployment-admission?commit=${encodeURIComponent(commit)}` });
9
+ const identity = RepositoryIdentitySchema.parse(result.identity);
10
+ if (identity.repositoryId !== repoId || result.commit !== commit)
11
+ throw new Error('Deployment preflight returned a different repository or commit');
12
+ return identity;
13
+ }
4
14
  const REPO_PAGE_SIZE = 200;
5
15
  /**
6
16
  * Encode a `/`-delimited git path for a `[...path]` / `[...ref]` catch-all
@@ -86,6 +86,9 @@ export declare const artifactNextBuildConfigSchema: z.ZodPrefault<z.ZodObject<{
86
86
  url: z.ZodString;
87
87
  sha256: z.ZodString;
88
88
  }, z.core.$strict>>>;
89
+ source_includes: z.ZodDefault<z.ZodArray<z.ZodObject<{
90
+ path: z.ZodString;
91
+ }, z.core.$strict>>>;
89
92
  }, z.core.$strict>>;
90
93
  export declare const artifactNextServerSchema: z.ZodPrefault<z.ZodObject<{
91
94
  capabilities: z.ZodPrefault<z.ZodObject<{
@@ -280,6 +283,9 @@ export declare const artifactStudioNextManifestSchema: z.ZodObject<{
280
283
  url: z.ZodString;
281
284
  sha256: z.ZodString;
282
285
  }, z.core.$strict>>>;
286
+ source_includes: z.ZodDefault<z.ZodArray<z.ZodObject<{
287
+ path: z.ZodString;
288
+ }, z.core.$strict>>>;
283
289
  }, z.core.$strict>>;
284
290
  server: z.ZodPrefault<z.ZodObject<{
285
291
  capabilities: z.ZodPrefault<z.ZodObject<{
@@ -1,5 +1,6 @@
1
1
  import { ontologyContractSchema } from '@sequenceholdings/orm/canonical';
2
2
  import { z } from 'zod';
3
+ import { artifactNextSourceIncludesSchema } from './next-source-includes.js';
3
4
  import { capabilityPinsSchema, customRolesBlockSchema, validateCapabilityPins } from './manifest-custom-roles.js';
4
5
  import { artifactNextBrowserDataSchema, validateBrowserDataAdmission } from './manifest-next-browser-data.js';
5
6
  import { isManagedServerInvokePath } from './server/managed-server-path.js';
@@ -77,6 +78,8 @@ export const artifactNextPublicFontSchema = z.strictObject({
77
78
  export const artifactNextBuildConfigSchema = z.strictObject({
78
79
  public_env: z.record(publicEnvironmentName, z.string().max(8192)).refine((values) => Object.keys(values).length <= 64, 'At most 64 public build variables are allowed').default({}),
79
80
  public_fonts: z.array(artifactNextPublicFontSchema).max(8).refine((fonts) => new Set(fonts.map((font) => font.path)).size === fonts.length, 'Public font destination paths must be unique').default([]),
81
+ /** Repository directories compiled alongside this bundle; see next-source-includes.ts. */
82
+ source_includes: artifactNextSourceIncludesSchema,
80
83
  }).prefault({});
81
84
  const hostname = z.string().max(253).regex(/^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z](?:[a-z0-9-]{0,61}[a-z0-9])?$/, 'Egress entries must be exact lowercase DNS hostnames without schemes, ports, paths, IPs, or wildcards').refine((host) => !host.endsWith('.localhost') && !host.endsWith('.local') && !host.endsWith('.internal'), 'Egress cannot target local or platform-internal hostnames');
82
85
  const managedServerInvokePath = z.string().refine(isManagedServerInvokePath, 'Lattice accept paths are exact origin-relative paths with no query or traversal');
@@ -3,6 +3,7 @@ import { type ArtifactStudioStaticManifest } from './manifest-static.js';
3
3
  import { type ArtifactStudioNextManifest } from './manifest-next.js';
4
4
  export * from './manifest-static.js';
5
5
  export * from './manifest-next.js';
6
+ export * from './next-source-includes.js';
6
7
  /** Versioned parsing keeps schema-v1 stripping/default behavior unchanged. */
7
8
  export declare const artifactStudioManifestSchema: z.ZodUnion<readonly [z.ZodObject<{
8
9
  ontology: z.ZodOptional<z.ZodObject<{
@@ -161,6 +162,9 @@ export declare const artifactStudioManifestSchema: z.ZodUnion<readonly [z.ZodObj
161
162
  url: z.ZodString;
162
163
  sha256: z.ZodString;
163
164
  }, z.core.$strict>>>;
165
+ source_includes: z.ZodDefault<z.ZodArray<z.ZodObject<{
166
+ path: z.ZodString;
167
+ }, z.core.$strict>>>;
164
168
  }, z.core.$strict>>;
165
169
  server: z.ZodPrefault<z.ZodObject<{
166
170
  capabilities: z.ZodPrefault<z.ZodObject<{
package/dist/manifest.js CHANGED
@@ -3,6 +3,7 @@ import { artifactStudioStaticManifestSchema } from './manifest-static.js';
3
3
  import { artifactStudioNextManifestSchema } from './manifest-next.js';
4
4
  export * from './manifest-static.js';
5
5
  export * from './manifest-next.js';
6
+ export * from './next-source-includes.js';
6
7
  /** Versioned parsing keeps schema-v1 stripping/default behavior unchanged. */
7
8
  export const artifactStudioManifestSchema = z.union([
8
9
  artifactStudioStaticManifestSchema,
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Source includes: repository directories a Next artifact compiles alongside
3
+ * its own bundle, such as a generated ORM client several apps share.
4
+ *
5
+ * The bundle directory stays the build boundary. An include is read from the
6
+ * same immutable commit as the bundle, with the same blob, exclusion and size
7
+ * checks, and placed at a fixed mount inside the app root, so it becomes
8
+ * ordinary bundle source: hashed into the source hash, compiled from within
9
+ * the app (no external directories), and invisible to anything not declared.
10
+ * Apps import it through a tsconfig path alias to the mount; local
11
+ * development writes the same mount. Never accepted from a local upload,
12
+ * whose bytes outside the bundle no dirty bit covers.
13
+ */
14
+ import { z } from 'zod';
15
+ /** Where includes are placed, relative to app_root. Reserved when any include is declared. */
16
+ export declare const SOURCE_INCLUDE_MOUNT = ".artifact-includes";
17
+ export declare const MAX_SOURCE_INCLUDES = 4;
18
+ export declare const artifactNextSourceIncludeSchema: z.ZodObject<{
19
+ path: z.ZodString;
20
+ }, z.core.$strict>;
21
+ export type ArtifactNextSourceInclude = z.infer<typeof artifactNextSourceIncludeSchema>;
22
+ export declare const artifactNextSourceIncludesSchema: z.ZodDefault<z.ZodArray<z.ZodObject<{
23
+ path: z.ZodString;
24
+ }, z.core.$strict>>>;
25
+ export declare function sourceIncludeMountRoot({ appRoot }: {
26
+ appRoot: string;
27
+ }): string;
28
+ export interface PlannedSourceInclude {
29
+ /** Path in the repository tree at the build commit. */
30
+ readonly repositoryPath: string;
31
+ /** Path in the bundle's source snapshot, under the reserved mount. */
32
+ readonly sourcePath: string;
33
+ }
34
+ /**
35
+ * Which repository files each include contributes and where they mount.
36
+ * `paths` are repository-relative file paths at the build commit; `sourceRoot`
37
+ * is the bundle directory. Throws for an include that overlaps the bundle or
38
+ * contributes nothing.
39
+ */
40
+ export declare function planSourceIncludes({ includes, sourceRoot, appRoot, paths }: {
41
+ includes: readonly ArtifactNextSourceInclude[];
42
+ sourceRoot: string;
43
+ appRoot: string;
44
+ paths: readonly string[];
45
+ }): PlannedSourceInclude[];
46
+ /** A bundle that declares includes may not commit its own files under the mount they own. */
47
+ export declare function assertNoReservedSourcePaths({ appRoot, paths }: {
48
+ appRoot: string;
49
+ paths: readonly string[];
50
+ }): void;