@sequenceholdings/artifact-studio 0.1.5 → 0.1.9
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/dist/api.js +66 -9
- package/dist/auth.js +1 -1
- package/dist/build-subprocess.d.ts +15 -0
- package/dist/build-subprocess.js +65 -0
- package/dist/build-worker.d.ts +1 -0
- package/dist/build-worker.js +28 -0
- package/dist/build.d.ts +11 -0
- package/dist/build.js +85 -14
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +493 -121
- package/dist/config.d.ts +1 -0
- package/dist/config.js +4 -1
- package/dist/dev-link.d.ts +60 -0
- package/dist/dev-link.js +155 -0
- package/dist/dev-lock.d.ts +5 -0
- package/dist/dev-lock.js +83 -0
- package/dist/git-clone.d.ts +41 -0
- package/dist/git-clone.js +178 -0
- package/dist/git-service-client.d.ts +32 -0
- package/dist/git-service-client.js +122 -0
- package/dist/hash.d.ts +12 -0
- package/dist/hash.js +13 -1
- package/dist/lockfile-origin.d.ts +6 -0
- package/dist/lockfile-origin.js +41 -0
- package/dist/manifest-custom-roles.d.ts +63 -0
- package/dist/manifest-custom-roles.js +194 -0
- package/dist/manifest.d.ts +31 -0
- package/dist/manifest.js +77 -0
- package/dist/prepare-build.d.ts +21 -0
- package/dist/prepare-build.js +94 -0
- package/dist/project.js +2 -1
- package/dist/sandbox-lint.d.ts +21 -0
- package/dist/sandbox-lint.js +95 -0
- package/dist/sanitize-remote-tree.d.ts +19 -0
- package/dist/sanitize-remote-tree.js +52 -0
- package/dist/sdk.d.ts +49 -0
- package/dist/source-resolver.d.ts +68 -0
- package/dist/source-resolver.js +202 -0
- package/dist/templates/react-vite/CLAUDE.md +18 -7
- package/dist/templates/react-vite/package.json +8 -7
- package/dist/trusted-install.d.ts +13 -0
- package/dist/trusted-install.js +186 -0
- package/dist/watch-loop.d.ts +26 -0
- package/dist/watch-loop.js +31 -0
- package/package.json +29 -9
- package/templates/react-vite/CLAUDE.md +18 -7
- package/templates/react-vite/package.json +8 -7
- package/dist/bin.d.ts +0 -2
- package/dist/bin.js +0 -8
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { lstat, mkdir, realpath, unlink, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { dirname, resolve, sep } from 'node:path';
|
|
3
|
+
import { getJson, getJsonOr404 } from './api.js';
|
|
4
|
+
const REPO_PAGE_SIZE = 200;
|
|
5
|
+
/**
|
|
6
|
+
* Encode a `/`-delimited git path for a `[...path]` / `[...ref]` catch-all
|
|
7
|
+
* route: keep `/` as segment separators, percent-encode each segment so a
|
|
8
|
+
* branch like `feature/x` or a path with spaces survives the URL.
|
|
9
|
+
*/
|
|
10
|
+
function encodeSegments(path) {
|
|
11
|
+
return path
|
|
12
|
+
.split('/')
|
|
13
|
+
.map((segment) => encodeURIComponent(segment))
|
|
14
|
+
.join('/');
|
|
15
|
+
}
|
|
16
|
+
/** Resolve `namespace/name` to a repo id + default branch (FGA-filtered list, paginated). */
|
|
17
|
+
export async function resolveRepo({ baseUrl, token, namespace, name, }) {
|
|
18
|
+
const ns = await getJsonOr404({
|
|
19
|
+
baseUrl,
|
|
20
|
+
token,
|
|
21
|
+
path: `/api/git-service/namespaces/${encodeURIComponent(namespace)}`,
|
|
22
|
+
});
|
|
23
|
+
if (!ns)
|
|
24
|
+
throw new Error(`Git-service namespace "${namespace}" not found on ${baseUrl}.`);
|
|
25
|
+
for (let offset = 0;; offset += REPO_PAGE_SIZE) {
|
|
26
|
+
const page = await getJson({
|
|
27
|
+
baseUrl,
|
|
28
|
+
token,
|
|
29
|
+
path: `/api/git-service/repos?namespaceId=${encodeURIComponent(ns.id)}&limit=${REPO_PAGE_SIZE}&offset=${offset}`,
|
|
30
|
+
});
|
|
31
|
+
const match = page.items.find((repo) => repo.name === name);
|
|
32
|
+
if (match)
|
|
33
|
+
return { id: match.id, defaultBranch: match.defaultBranch };
|
|
34
|
+
// Stop when this page was the last one (or empty) — the list endpoint is
|
|
35
|
+
// FGA-filtered, so total counts only repos the caller can read.
|
|
36
|
+
if (page.items.length === 0 || offset + page.items.length >= page.total)
|
|
37
|
+
break;
|
|
38
|
+
}
|
|
39
|
+
throw new Error(`Repo "${namespace}/${name}" not found (or not readable) on ${baseUrl}.`);
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Resolve a ref (branch, tag, short name, or 40-hex SHA) to its commit SHA.
|
|
43
|
+
* Tries the refs endpoint first (cheap), then commit detail (which also
|
|
44
|
+
* verifies a raw SHA points at a real commit).
|
|
45
|
+
*/
|
|
46
|
+
export async function resolveCommitSha({ baseUrl, token, repoId, ref, }) {
|
|
47
|
+
const fromRef = await getJsonOr404({
|
|
48
|
+
baseUrl,
|
|
49
|
+
token,
|
|
50
|
+
path: `/api/git-service/repos/${repoId}/refs/${encodeSegments(ref)}`,
|
|
51
|
+
});
|
|
52
|
+
if (fromRef)
|
|
53
|
+
return fromRef.sha;
|
|
54
|
+
const fromCommit = await getJsonOr404({
|
|
55
|
+
baseUrl,
|
|
56
|
+
token,
|
|
57
|
+
path: `/api/git-service/repos/${repoId}/commits/${encodeSegments(ref)}`,
|
|
58
|
+
});
|
|
59
|
+
if (fromCommit)
|
|
60
|
+
return fromCommit.sha;
|
|
61
|
+
throw new Error(`Ref "${ref}" not found in repo ${repoId}.`);
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Materialize the full tree at `ref` into `destDir` (recursive tree listing +
|
|
65
|
+
* one content fetch per blob). Returns the number of files written.
|
|
66
|
+
*/
|
|
67
|
+
export async function materializeRepo({ baseUrl, token, repoId, ref, destDir, }) {
|
|
68
|
+
const tree = await getJson({
|
|
69
|
+
baseUrl,
|
|
70
|
+
token,
|
|
71
|
+
path: `/api/git-service/repos/${repoId}/tree?recursive=true&ref=${encodeURIComponent(ref)}`,
|
|
72
|
+
});
|
|
73
|
+
const blobs = tree.entries.filter((entry) => entry.type === 'blob');
|
|
74
|
+
const root = resolve(destDir);
|
|
75
|
+
// The destination itself must not be a pre-existing symlink — mkdir and
|
|
76
|
+
// realpath would follow it, every containment check below would pass
|
|
77
|
+
// relative to the link TARGET, and the whole tree would land outside the
|
|
78
|
+
// requested path.
|
|
79
|
+
const rootStat = await lstat(root).catch(() => null);
|
|
80
|
+
if (rootStat?.isSymbolicLink()) {
|
|
81
|
+
throw new Error(`Refusing to materialize into a symlinked destination: ${destDir}`);
|
|
82
|
+
}
|
|
83
|
+
const rootPrefix = root + sep;
|
|
84
|
+
await mkdir(root, { recursive: true });
|
|
85
|
+
// Symlink defense root: writeFile FOLLOWS symlinks, so the lexical check
|
|
86
|
+
// below doesn't stop a pre-existing symlink inside destDir (e.g. a pnpm
|
|
87
|
+
// node_modules tree pulled into with `repos pull --force`) from routing a
|
|
88
|
+
// write outside it. Each file's parent must realpath-resolve back inside
|
|
89
|
+
// the destination, and the file itself must not be a symlink.
|
|
90
|
+
const rootReal = await realpath(root);
|
|
91
|
+
const rootRealPrefix = rootReal + sep;
|
|
92
|
+
for (const blob of blobs) {
|
|
93
|
+
const dest = resolve(destDir, blob.path);
|
|
94
|
+
// Defense in depth: the service rejects `..` paths on write, but never let a
|
|
95
|
+
// crafted tree escape the destination directory on the way out.
|
|
96
|
+
if (dest !== root && !dest.startsWith(rootPrefix)) {
|
|
97
|
+
throw new Error(`Refusing to write outside destination: ${blob.path}`);
|
|
98
|
+
}
|
|
99
|
+
const file = await getJson({
|
|
100
|
+
baseUrl,
|
|
101
|
+
token,
|
|
102
|
+
path: `/api/git-service/repos/${repoId}/contents/${encodeSegments(blob.path)}?ref=${encodeURIComponent(ref)}`,
|
|
103
|
+
});
|
|
104
|
+
await mkdir(dirname(dest), { recursive: true });
|
|
105
|
+
const parentReal = await realpath(dirname(dest));
|
|
106
|
+
if (parentReal !== rootReal && !parentReal.startsWith(rootRealPrefix)) {
|
|
107
|
+
throw new Error(`Refusing to write through symlinked directory: ${blob.path}`);
|
|
108
|
+
}
|
|
109
|
+
const existing = await lstat(dest).catch(() => null);
|
|
110
|
+
if (existing?.isSymbolicLink()) {
|
|
111
|
+
throw new Error(`Refusing to write through symlink: ${blob.path}`);
|
|
112
|
+
}
|
|
113
|
+
// Replace rather than overwrite-in-place: unlinking first breaks any
|
|
114
|
+
// pre-existing hardlink (writeFile would otherwise update the shared
|
|
115
|
+
// inode — data reachable outside the destination) and closes the
|
|
116
|
+
// lstat→write window on the symlink check above.
|
|
117
|
+
if (existing)
|
|
118
|
+
await unlink(dest);
|
|
119
|
+
await writeFile(dest, Buffer.from(file.content, 'base64'));
|
|
120
|
+
}
|
|
121
|
+
return blobs.length;
|
|
122
|
+
}
|
package/dist/hash.d.ts
CHANGED
|
@@ -4,6 +4,18 @@ export interface HashableArtifactFile {
|
|
|
4
4
|
encoding?: string;
|
|
5
5
|
}
|
|
6
6
|
export declare function sha256Hex(content: string | Buffer): string;
|
|
7
|
+
/**
|
|
8
|
+
* Hash of a deployable source tree. `manifest` MUST be the RAW yaml-loaded
|
|
9
|
+
* `artifact.bundle.yml` object, never the zod-parsed manifest: parsing fills
|
|
10
|
+
* schema defaults, so hashing the parsed object changes the digest of
|
|
11
|
+
* byte-identical sources every time the manifest schema gains a defaulted
|
|
12
|
+
* field — and a CLI built against an older schema then disagrees with the
|
|
13
|
+
* server forever (`artifact plan` reported perpetual drift after
|
|
14
|
+
* `capabilities.uses` landed). The raw object only changes when the file
|
|
15
|
+
* bytes change. Must stay byte-identical to the Atlas server hasher
|
|
16
|
+
* (atlas/src/server/services/artifact-studio/hash.ts) — pinned by
|
|
17
|
+
* hash-parity.test.ts.
|
|
18
|
+
*/
|
|
7
19
|
export declare function computeSourceHash({ files, manifest, }: {
|
|
8
20
|
files: readonly HashableArtifactFile[];
|
|
9
21
|
manifest: unknown;
|
package/dist/hash.js
CHANGED
|
@@ -14,9 +14,21 @@ function stableJson(value) {
|
|
|
14
14
|
.map((key) => `${JSON.stringify(key)}:${stableJson(obj[key])}`)
|
|
15
15
|
.join(',')}}`;
|
|
16
16
|
}
|
|
17
|
+
/**
|
|
18
|
+
* Hash of a deployable source tree. `manifest` MUST be the RAW yaml-loaded
|
|
19
|
+
* `artifact.bundle.yml` object, never the zod-parsed manifest: parsing fills
|
|
20
|
+
* schema defaults, so hashing the parsed object changes the digest of
|
|
21
|
+
* byte-identical sources every time the manifest schema gains a defaulted
|
|
22
|
+
* field — and a CLI built against an older schema then disagrees with the
|
|
23
|
+
* server forever (`artifact plan` reported perpetual drift after
|
|
24
|
+
* `capabilities.uses` landed). The raw object only changes when the file
|
|
25
|
+
* bytes change. Must stay byte-identical to the Atlas server hasher
|
|
26
|
+
* (atlas/src/server/services/artifact-studio/hash.ts) — pinned by
|
|
27
|
+
* hash-parity.test.ts.
|
|
28
|
+
*/
|
|
17
29
|
export function computeSourceHash({ files, manifest, }) {
|
|
18
30
|
const h = createHash('sha256');
|
|
19
|
-
h.update('artifact-studio-source-
|
|
31
|
+
h.update('artifact-studio-source-v3\0');
|
|
20
32
|
h.update(stableJson(manifest));
|
|
21
33
|
h.update('\0');
|
|
22
34
|
const sorted = [...files].map((file) => ({
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI-side mirror of studio-cli/functions/lockfile-origin.ts and
|
|
3
|
+
* atlas/.../managed-functions/lockfile-origin.ts — keep the three in sync.
|
|
4
|
+
*/
|
|
5
|
+
export type LockfileOrigin = 'chainguard' | 'npm' | 'unknown';
|
|
6
|
+
export declare function classifyLockfileOrigin(lockfileText: string): LockfileOrigin;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { load as parseYaml } from 'js-yaml';
|
|
2
|
+
const CHAINGUARD_TARBALL_HOST = 'libraries.cgr.dev';
|
|
3
|
+
const NPM_TARBALL_HOST = 'registry.npmjs.org';
|
|
4
|
+
export function classifyLockfileOrigin(lockfileText) {
|
|
5
|
+
let parsed;
|
|
6
|
+
try {
|
|
7
|
+
const doc = parseYaml(lockfileText);
|
|
8
|
+
if (!doc || typeof doc !== 'object')
|
|
9
|
+
return 'unknown';
|
|
10
|
+
parsed = doc;
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
return 'unknown';
|
|
14
|
+
}
|
|
15
|
+
const packages = parsed.packages;
|
|
16
|
+
if (!packages || typeof packages !== 'object')
|
|
17
|
+
return 'unknown';
|
|
18
|
+
let sawChainguard = false;
|
|
19
|
+
let sawNpm = false;
|
|
20
|
+
for (const entry of Object.values(packages)) {
|
|
21
|
+
const tarball = entry?.resolution?.tarball;
|
|
22
|
+
if (typeof tarball !== 'string')
|
|
23
|
+
continue;
|
|
24
|
+
let host;
|
|
25
|
+
try {
|
|
26
|
+
host = new URL(tarball).host.toLowerCase();
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
if (host === NPM_TARBALL_HOST)
|
|
32
|
+
sawNpm = true;
|
|
33
|
+
else if (host === CHAINGUARD_TARBALL_HOST)
|
|
34
|
+
sawChainguard = true;
|
|
35
|
+
}
|
|
36
|
+
if (sawNpm)
|
|
37
|
+
return 'npm';
|
|
38
|
+
if (sawChainguard)
|
|
39
|
+
return 'chainguard';
|
|
40
|
+
return 'unknown';
|
|
41
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Custom Roles & Capabilities (PER-61) — the `capabilities.roles` manifest
|
|
3
|
+
* block. Kept in its own module (imported by `manifest.ts`). CLI-side mirror of
|
|
4
|
+
* atlas/src/server/services/artifact-studio/manifest-custom-roles.ts — same
|
|
5
|
+
* pattern as the duplicated manifest schema. Keep the two copies in sync.
|
|
6
|
+
*/
|
|
7
|
+
import { z } from 'zod';
|
|
8
|
+
/**
|
|
9
|
+
* Key grammar for roles and capabilities. Deliberately excludes `_` so the
|
|
10
|
+
* FGA object id `custom_role:artifact_<projectId>__<key>` can use `__` as an
|
|
11
|
+
* unambiguous delimiter.
|
|
12
|
+
*/
|
|
13
|
+
export declare const CUSTOM_ROLE_KEY_RE: RegExp;
|
|
14
|
+
/**
|
|
15
|
+
* Manifest-declared custom roles & capabilities. Roles and capabilities are
|
|
16
|
+
* both `custom_role` FGA objects; deploy reconciliation turns this block into
|
|
17
|
+
* the structural waterfall edges (role→capability / senior→junior `assignee`
|
|
18
|
+
* userset tuples) — nothing else. The host floor is enforced app-layer at the
|
|
19
|
+
* resolution points, never in the model. Runtime grants (user/group/org →
|
|
20
|
+
* role) are NEVER declared here — they are written through host-admin-gated
|
|
21
|
+
* endpoints.
|
|
22
|
+
*/
|
|
23
|
+
export declare const customRolesBlockSchema: z.ZodObject<{
|
|
24
|
+
definitions: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodObject<{
|
|
25
|
+
description: z.ZodOptional<z.ZodString>;
|
|
26
|
+
default: z.ZodDefault<z.ZodBoolean>;
|
|
27
|
+
inherits: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
|
28
|
+
grants: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
|
29
|
+
}, z.core.$strip>>>;
|
|
30
|
+
capabilities: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodObject<{
|
|
31
|
+
description: z.ZodOptional<z.ZodString>;
|
|
32
|
+
}, z.core.$strip>>>;
|
|
33
|
+
}, z.core.$strip>;
|
|
34
|
+
export type ArtifactStudioCustomRoles = z.infer<typeof customRolesBlockSchema>;
|
|
35
|
+
/** True when the block declares at least one role or capability. */
|
|
36
|
+
export declare function customRolesDeclared(block: ArtifactStudioCustomRoles | null | undefined): boolean;
|
|
37
|
+
/**
|
|
38
|
+
* Capability pins (PER-76) — the `capabilities.uses` attachment set. A pin
|
|
39
|
+
* references a REGISTRY capability the host's code checks: `org/<key>` for
|
|
40
|
+
* an org-scoped capability (must already exist — hosts never create org
|
|
41
|
+
* entities), or a bare `<key>` for a project-scoped one (declared in this
|
|
42
|
+
* manifest's `capabilities.roles.capabilities` record, or already published
|
|
43
|
+
* to the host's registry scope). Roles are never pinned — composition and
|
|
44
|
+
* grants are live runtime configuration owned by the registry.
|
|
45
|
+
*/
|
|
46
|
+
export declare const CUSTOM_CAPABILITY_PIN_RE: RegExp;
|
|
47
|
+
export declare const capabilityPinsSchema: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
|
48
|
+
export interface ParsedCapabilityPin {
|
|
49
|
+
scope: 'org' | 'project';
|
|
50
|
+
key: string;
|
|
51
|
+
}
|
|
52
|
+
/** Split a pin into its scope and key. Assumes the pin grammar validated. */
|
|
53
|
+
export declare function parseCapabilityPin(pin: string): ParsedCapabilityPin;
|
|
54
|
+
/**
|
|
55
|
+
* Cross-field pin validation, called from the manifest superRefine (the
|
|
56
|
+
* `uses` array and the roles block are siblings under `capabilities`).
|
|
57
|
+
* Purely structural — registry existence checks are server-side, at
|
|
58
|
+
* activation, where atomicity lives.
|
|
59
|
+
*/
|
|
60
|
+
export declare function validateCapabilityPins({ uses, roles, }: {
|
|
61
|
+
uses: readonly string[];
|
|
62
|
+
roles: ArtifactStudioCustomRoles | null | undefined;
|
|
63
|
+
}): string[];
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Custom Roles & Capabilities (PER-61) — the `capabilities.roles` manifest
|
|
3
|
+
* block. Kept in its own module (imported by `manifest.ts`). CLI-side mirror of
|
|
4
|
+
* atlas/src/server/services/artifact-studio/manifest-custom-roles.ts — same
|
|
5
|
+
* pattern as the duplicated manifest schema. Keep the two copies in sync.
|
|
6
|
+
*/
|
|
7
|
+
import { z } from 'zod';
|
|
8
|
+
/**
|
|
9
|
+
* Key grammar for roles and capabilities. Deliberately excludes `_` so the
|
|
10
|
+
* FGA object id `custom_role:artifact_<projectId>__<key>` can use `__` as an
|
|
11
|
+
* unambiguous delimiter.
|
|
12
|
+
*/
|
|
13
|
+
export const CUSTOM_ROLE_KEY_RE = /^[a-z0-9-]+$/;
|
|
14
|
+
const customRoleKeySchema = z.string().min(1).max(64).regex(CUSTOM_ROLE_KEY_RE, 'Role and capability keys must match [a-z0-9-]+');
|
|
15
|
+
const MAX_CUSTOM_ROLE_DEFINITIONS = 50;
|
|
16
|
+
const MAX_CUSTOM_ROLE_CAPABILITIES = 100;
|
|
17
|
+
const customRoleCapabilityDeclarationSchema = z.object({
|
|
18
|
+
description: z.string().max(500).optional(),
|
|
19
|
+
});
|
|
20
|
+
const customRoleDefinitionSchema = z.object({
|
|
21
|
+
description: z.string().max(500).optional(),
|
|
22
|
+
/**
|
|
23
|
+
* Grants this role to the host's entire runtime audience. Resolved at the
|
|
24
|
+
* app layer at claim time — every caller who passes the host floor
|
|
25
|
+
* (`end_user`) holds the default roles; no tuple is written for it.
|
|
26
|
+
*/
|
|
27
|
+
default: z.boolean().default(false),
|
|
28
|
+
/** Role keys whose grants this role also receives (privilege waterfall). */
|
|
29
|
+
inherits: z.array(customRoleKeySchema).default([]),
|
|
30
|
+
/** Capability keys this role grants directly. */
|
|
31
|
+
grants: z.array(customRoleKeySchema).default([]),
|
|
32
|
+
});
|
|
33
|
+
/**
|
|
34
|
+
* Manifest-declared custom roles & capabilities. Roles and capabilities are
|
|
35
|
+
* both `custom_role` FGA objects; deploy reconciliation turns this block into
|
|
36
|
+
* the structural waterfall edges (role→capability / senior→junior `assignee`
|
|
37
|
+
* userset tuples) — nothing else. The host floor is enforced app-layer at the
|
|
38
|
+
* resolution points, never in the model. Runtime grants (user/group/org →
|
|
39
|
+
* role) are NEVER declared here — they are written through host-admin-gated
|
|
40
|
+
* endpoints.
|
|
41
|
+
*/
|
|
42
|
+
export const customRolesBlockSchema = z.object({
|
|
43
|
+
/** Roles grantable to principals, keyed by role key. */
|
|
44
|
+
definitions: z.record(customRoleKeySchema, customRoleDefinitionSchema).default({}),
|
|
45
|
+
/** Leaf capability keys the artifact's code checks, keyed by capability key. */
|
|
46
|
+
capabilities: z.record(customRoleKeySchema, customRoleCapabilityDeclarationSchema).default({}),
|
|
47
|
+
}).superRefine((block, ctx) => {
|
|
48
|
+
const roleKeys = Object.keys(block.definitions);
|
|
49
|
+
const capabilityKeys = new Set(Object.keys(block.capabilities));
|
|
50
|
+
if (roleKeys.length > MAX_CUSTOM_ROLE_DEFINITIONS) {
|
|
51
|
+
ctx.addIssue({
|
|
52
|
+
code: 'custom',
|
|
53
|
+
message: `At most ${MAX_CUSTOM_ROLE_DEFINITIONS} role definitions are allowed`,
|
|
54
|
+
path: ['definitions'],
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
if (capabilityKeys.size > MAX_CUSTOM_ROLE_CAPABILITIES) {
|
|
58
|
+
ctx.addIssue({
|
|
59
|
+
code: 'custom',
|
|
60
|
+
message: `At most ${MAX_CUSTOM_ROLE_CAPABILITIES} capabilities are allowed`,
|
|
61
|
+
path: ['capabilities'],
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
// Roles and capabilities share one FGA object namespace on the host —
|
|
65
|
+
// a key present in both would collapse two objects into one.
|
|
66
|
+
for (const key of roleKeys) {
|
|
67
|
+
if (capabilityKeys.has(key)) {
|
|
68
|
+
ctx.addIssue({
|
|
69
|
+
code: 'custom',
|
|
70
|
+
message: `Key "${key}" is declared as both a role and a capability`,
|
|
71
|
+
path: ['definitions', key],
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
const roleKeySet = new Set(roleKeys);
|
|
76
|
+
for (const [roleKey, definition] of Object.entries(block.definitions)) {
|
|
77
|
+
const seenInherits = new Set();
|
|
78
|
+
for (const inherited of definition.inherits) {
|
|
79
|
+
if (seenInherits.has(inherited)) {
|
|
80
|
+
ctx.addIssue({
|
|
81
|
+
code: 'custom',
|
|
82
|
+
message: `Role "${roleKey}" lists "${inherited}" in inherits more than once`,
|
|
83
|
+
path: ['definitions', roleKey, 'inherits'],
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
seenInherits.add(inherited);
|
|
87
|
+
if (!roleKeySet.has(inherited)) {
|
|
88
|
+
ctx.addIssue({
|
|
89
|
+
code: 'custom',
|
|
90
|
+
message: `Role "${roleKey}" inherits unknown role "${inherited}"`,
|
|
91
|
+
path: ['definitions', roleKey, 'inherits'],
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
const seenGrants = new Set();
|
|
96
|
+
for (const granted of definition.grants) {
|
|
97
|
+
if (seenGrants.has(granted)) {
|
|
98
|
+
ctx.addIssue({
|
|
99
|
+
code: 'custom',
|
|
100
|
+
message: `Role "${roleKey}" lists "${granted}" in grants more than once`,
|
|
101
|
+
path: ['definitions', roleKey, 'grants'],
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
seenGrants.add(granted);
|
|
105
|
+
if (!capabilityKeys.has(granted)) {
|
|
106
|
+
ctx.addIssue({
|
|
107
|
+
code: 'custom',
|
|
108
|
+
message: `Role "${roleKey}" grants unknown capability "${granted}"`,
|
|
109
|
+
path: ['definitions', roleKey, 'grants'],
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
// The inherits graph must be a DAG: a cycle would make the FGA waterfall
|
|
115
|
+
// walk self-referential. DFS with tri-color marking; report the first
|
|
116
|
+
// cycle found.
|
|
117
|
+
const state = new Map();
|
|
118
|
+
const visit = (key, path) => {
|
|
119
|
+
if (state.get(key) === 'done')
|
|
120
|
+
return null;
|
|
121
|
+
if (state.get(key) === 'visiting')
|
|
122
|
+
return [...path, key];
|
|
123
|
+
state.set(key, 'visiting');
|
|
124
|
+
const definition = block.definitions[key];
|
|
125
|
+
for (const inherited of definition?.inherits ?? []) {
|
|
126
|
+
if (!roleKeySet.has(inherited))
|
|
127
|
+
continue;
|
|
128
|
+
const cycle = visit(inherited, [...path, key]);
|
|
129
|
+
if (cycle)
|
|
130
|
+
return cycle;
|
|
131
|
+
}
|
|
132
|
+
state.set(key, 'done');
|
|
133
|
+
return null;
|
|
134
|
+
};
|
|
135
|
+
for (const roleKey of roleKeys) {
|
|
136
|
+
const cycle = visit(roleKey, []);
|
|
137
|
+
if (cycle) {
|
|
138
|
+
ctx.addIssue({
|
|
139
|
+
code: 'custom',
|
|
140
|
+
message: `Role inheritance cycle: ${cycle.join(' -> ')}`,
|
|
141
|
+
path: ['definitions'],
|
|
142
|
+
});
|
|
143
|
+
break;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
/** True when the block declares at least one role or capability. */
|
|
148
|
+
export function customRolesDeclared(block) {
|
|
149
|
+
if (!block)
|
|
150
|
+
return false;
|
|
151
|
+
return Object.keys(block.definitions).length > 0 || Object.keys(block.capabilities).length > 0;
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Capability pins (PER-76) — the `capabilities.uses` attachment set. A pin
|
|
155
|
+
* references a REGISTRY capability the host's code checks: `org/<key>` for
|
|
156
|
+
* an org-scoped capability (must already exist — hosts never create org
|
|
157
|
+
* entities), or a bare `<key>` for a project-scoped one (declared in this
|
|
158
|
+
* manifest's `capabilities.roles.capabilities` record, or already published
|
|
159
|
+
* to the host's registry scope). Roles are never pinned — composition and
|
|
160
|
+
* grants are live runtime configuration owned by the registry.
|
|
161
|
+
*/
|
|
162
|
+
export const CUSTOM_CAPABILITY_PIN_RE = /^(?:org\/)?[a-z0-9-]+$/;
|
|
163
|
+
const MAX_CAPABILITY_PINS = 100;
|
|
164
|
+
export const capabilityPinsSchema = z
|
|
165
|
+
.array(z.string().min(1).max(68).regex(CUSTOM_CAPABILITY_PIN_RE, 'Pins must be "org/<key>" or a bare "<key>" matching [a-z0-9-]+'))
|
|
166
|
+
.max(MAX_CAPABILITY_PINS)
|
|
167
|
+
.default([]);
|
|
168
|
+
/** Split a pin into its scope and key. Assumes the pin grammar validated. */
|
|
169
|
+
export function parseCapabilityPin(pin) {
|
|
170
|
+
return pin.startsWith('org/')
|
|
171
|
+
? { scope: 'org', key: pin.slice('org/'.length) }
|
|
172
|
+
: { scope: 'project', key: pin };
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Cross-field pin validation, called from the manifest superRefine (the
|
|
176
|
+
* `uses` array and the roles block are siblings under `capabilities`).
|
|
177
|
+
* Purely structural — registry existence checks are server-side, at
|
|
178
|
+
* activation, where atomicity lives.
|
|
179
|
+
*/
|
|
180
|
+
export function validateCapabilityPins({ uses, roles, }) {
|
|
181
|
+
const errors = [];
|
|
182
|
+
const seen = new Set();
|
|
183
|
+
const roleKeys = new Set(Object.keys(roles?.definitions ?? {}));
|
|
184
|
+
for (const pin of uses) {
|
|
185
|
+
if (seen.has(pin))
|
|
186
|
+
errors.push(`Pin "${pin}" is listed more than once`);
|
|
187
|
+
seen.add(pin);
|
|
188
|
+
const parsed = parseCapabilityPin(pin);
|
|
189
|
+
if (parsed.scope === 'project' && roleKeys.has(parsed.key)) {
|
|
190
|
+
errors.push(`Pin "${pin}" references a role — roles are never pinned`);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return errors;
|
|
194
|
+
}
|
package/dist/manifest.d.ts
CHANGED
|
@@ -1,4 +1,14 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
+
export { CUSTOM_CAPABILITY_PIN_RE, CUSTOM_ROLE_KEY_RE, capabilityPinsSchema, customRolesBlockSchema, customRolesDeclared, parseCapabilityPin, validateCapabilityPins, type ArtifactStudioCustomRoles, type ParsedCapabilityPin, } from './manifest-custom-roles.js';
|
|
3
|
+
export declare const sidebarBadgeSchema: z.ZodObject<{
|
|
4
|
+
endpoint: z.ZodString;
|
|
5
|
+
tone: z.ZodOptional<z.ZodEnum<{
|
|
6
|
+
alert: "alert";
|
|
7
|
+
muted: "muted";
|
|
8
|
+
}>>;
|
|
9
|
+
}, z.core.$strip>;
|
|
10
|
+
export type SidebarBadgeManifest = z.infer<typeof sidebarBadgeSchema>;
|
|
11
|
+
export declare function apiCapabilityPathAllowed(pathname: string, allowed: readonly string[]): boolean;
|
|
2
12
|
export declare const artifactStudioManifestSchema: z.ZodObject<{
|
|
3
13
|
bundle: z.ZodObject<{
|
|
4
14
|
name: z.ZodString;
|
|
@@ -30,6 +40,20 @@ export declare const artifactStudioManifestSchema: z.ZodObject<{
|
|
|
30
40
|
functions: z.ZodDefault<z.ZodObject<{
|
|
31
41
|
invoke: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
|
32
42
|
}, z.core.$strip>>;
|
|
43
|
+
allow_downloads: z.ZodDefault<z.ZodBoolean>;
|
|
44
|
+
allow_popups: z.ZodDefault<z.ZodBoolean>;
|
|
45
|
+
roles: z.ZodOptional<z.ZodObject<{
|
|
46
|
+
definitions: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodObject<{
|
|
47
|
+
description: z.ZodOptional<z.ZodString>;
|
|
48
|
+
default: z.ZodDefault<z.ZodBoolean>;
|
|
49
|
+
inherits: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
|
50
|
+
grants: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
|
51
|
+
}, z.core.$strip>>>;
|
|
52
|
+
capabilities: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodObject<{
|
|
53
|
+
description: z.ZodOptional<z.ZodString>;
|
|
54
|
+
}, z.core.$strip>>>;
|
|
55
|
+
}, z.core.$strip>>;
|
|
56
|
+
uses: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
|
33
57
|
}, z.core.$strip>>;
|
|
34
58
|
targets: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodObject<{
|
|
35
59
|
url: z.ZodString;
|
|
@@ -39,6 +63,13 @@ export declare const artifactStudioManifestSchema: z.ZodObject<{
|
|
|
39
63
|
published: "published";
|
|
40
64
|
}>>;
|
|
41
65
|
}, z.core.$strip>>>;
|
|
66
|
+
sidebarBadge: z.ZodOptional<z.ZodObject<{
|
|
67
|
+
endpoint: z.ZodString;
|
|
68
|
+
tone: z.ZodOptional<z.ZodEnum<{
|
|
69
|
+
alert: "alert";
|
|
70
|
+
muted: "muted";
|
|
71
|
+
}>>;
|
|
72
|
+
}, z.core.$strip>>;
|
|
42
73
|
}, z.core.$strip>;
|
|
43
74
|
export type ArtifactStudioManifest = z.infer<typeof artifactStudioManifestSchema>;
|
|
44
75
|
export declare function parseArtifactStudioManifest(value: unknown): ArtifactStudioManifest;
|
package/dist/manifest.js
CHANGED
|
@@ -1,10 +1,44 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
+
import { capabilityPinsSchema, customRolesBlockSchema, validateCapabilityPins, } from './manifest-custom-roles.js';
|
|
2
3
|
import { normalizeArtifactPath } from './paths.js';
|
|
4
|
+
export { CUSTOM_CAPABILITY_PIN_RE, CUSTOM_ROLE_KEY_RE, capabilityPinsSchema, customRolesBlockSchema, customRolesDeclared, parseCapabilityPin, validateCapabilityPins, } from './manifest-custom-roles.js';
|
|
3
5
|
const apiCapabilityPathSchema = z.string().min(1).refine((path) => path.startsWith('/api/') && path !== '/api/' && path !== '/api/*', 'API capability paths must be scoped below /api/');
|
|
4
6
|
const apiCapabilitiesSchema = z.object({
|
|
5
7
|
read: z.array(apiCapabilityPathSchema).default([]),
|
|
6
8
|
write: z.array(apiCapabilityPathSchema).default([]),
|
|
7
9
|
}).default({ read: [], write: [] });
|
|
10
|
+
const sidebarBadgeToneSchema = z.enum(['alert', 'muted']);
|
|
11
|
+
export const sidebarBadgeSchema = z.object({
|
|
12
|
+
endpoint: apiCapabilityPathSchema,
|
|
13
|
+
tone: sidebarBadgeToneSchema.optional(),
|
|
14
|
+
});
|
|
15
|
+
function matchesApiCapabilityEntry(pathname, entry) {
|
|
16
|
+
if (!entry.startsWith('/api/'))
|
|
17
|
+
return false;
|
|
18
|
+
if (entry === pathname)
|
|
19
|
+
return true;
|
|
20
|
+
if (entry.includes('*') && (!entry.endsWith('/*') || hasMidPathWildcard(entry))) {
|
|
21
|
+
const pathParts = pathname.split('/');
|
|
22
|
+
const entryParts = entry.split('/');
|
|
23
|
+
return (pathParts.length === entryParts.length &&
|
|
24
|
+
entryParts.every((part, index) => part === '*' || part === pathParts[index]));
|
|
25
|
+
}
|
|
26
|
+
if (entry.endsWith('/*')) {
|
|
27
|
+
const prefix = entry.slice(0, -1);
|
|
28
|
+
return pathname.startsWith(prefix);
|
|
29
|
+
}
|
|
30
|
+
if (entry.endsWith('/')) {
|
|
31
|
+
return pathname.startsWith(entry);
|
|
32
|
+
}
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
function hasMidPathWildcard(entry) {
|
|
36
|
+
const parts = entry.split('/');
|
|
37
|
+
return parts.slice(1, -1).some((part) => part === '*');
|
|
38
|
+
}
|
|
39
|
+
export function apiCapabilityPathAllowed(pathname, allowed) {
|
|
40
|
+
return allowed.some((entry) => matchesApiCapabilityEntry(pathname, entry));
|
|
41
|
+
}
|
|
8
42
|
export const artifactStudioManifestSchema = z.object({
|
|
9
43
|
bundle: z.object({
|
|
10
44
|
name: z.string().min(1).max(255),
|
|
@@ -30,15 +64,58 @@ export const artifactStudioManifestSchema = z.object({
|
|
|
30
64
|
functions: z.object({
|
|
31
65
|
invoke: z.array(z.string().min(1)).default([]),
|
|
32
66
|
}).default({ invoke: [] }),
|
|
67
|
+
/** Opt-in: enables iframe `allow-downloads`. Default false. */
|
|
68
|
+
allow_downloads: z.boolean().default(false),
|
|
69
|
+
/**
|
|
70
|
+
* Opt-in: enables iframe `allow-popups allow-popups-to-escape-sandbox` so
|
|
71
|
+
* the artifact can open a new top-level (unsandboxed) browser tab — needed
|
|
72
|
+
* to render a PDF in the native viewer, which the sandbox CSP blocks
|
|
73
|
+
* in-frame. Default false; grant only to trusted-authored artifacts.
|
|
74
|
+
*/
|
|
75
|
+
allow_popups: z.boolean().default(false),
|
|
76
|
+
/**
|
|
77
|
+
* Custom Roles & Capabilities (PER-61): manifest-declared roles and the
|
|
78
|
+
* capability keys the artifact's code checks. Reconciled into
|
|
79
|
+
* `custom_role` FGA structural tuples when a deploy activates.
|
|
80
|
+
*/
|
|
81
|
+
roles: customRolesBlockSchema.optional(),
|
|
82
|
+
/**
|
|
83
|
+
* Capability pins (PER-76): registry capabilities this artifact's code
|
|
84
|
+
* checks — `org/<key>` or a bare project-scoped `<key>`. Reconciled into
|
|
85
|
+
* the host's attachment set at activation; existence is validated
|
|
86
|
+
* server-side there.
|
|
87
|
+
*/
|
|
88
|
+
uses: capabilityPinsSchema,
|
|
33
89
|
}).default({
|
|
34
90
|
data: { read: [], write: [] },
|
|
35
91
|
api: { read: [], write: [] },
|
|
36
92
|
functions: { invoke: [] },
|
|
93
|
+
allow_downloads: false,
|
|
94
|
+
allow_popups: false,
|
|
95
|
+
uses: [],
|
|
37
96
|
}),
|
|
38
97
|
targets: z.record(z.string().min(1), z.object({
|
|
39
98
|
url: z.string().url(),
|
|
40
99
|
visibility: z.enum(['private', 'shared', 'published']).default('private'),
|
|
41
100
|
})).default({}),
|
|
101
|
+
sidebarBadge: sidebarBadgeSchema.optional(),
|
|
102
|
+
}).superRefine((manifest, ctx) => {
|
|
103
|
+
for (const message of validateCapabilityPins({
|
|
104
|
+
uses: manifest.capabilities.uses,
|
|
105
|
+
roles: manifest.capabilities.roles,
|
|
106
|
+
})) {
|
|
107
|
+
ctx.addIssue({ code: 'custom', message, path: ['capabilities', 'uses'] });
|
|
108
|
+
}
|
|
109
|
+
if (!manifest.sidebarBadge)
|
|
110
|
+
return;
|
|
111
|
+
const endpoint = manifest.sidebarBadge.endpoint;
|
|
112
|
+
if (!apiCapabilityPathAllowed(endpoint, manifest.capabilities.api.read)) {
|
|
113
|
+
ctx.addIssue({
|
|
114
|
+
code: 'custom',
|
|
115
|
+
message: `sidebarBadge.endpoint ${endpoint} must be declared in capabilities.api.read`,
|
|
116
|
+
path: ['sidebarBadge', 'endpoint'],
|
|
117
|
+
});
|
|
118
|
+
}
|
|
42
119
|
});
|
|
43
120
|
export function parseArtifactStudioManifest(value) {
|
|
44
121
|
return artifactStudioManifestSchema.parse(value);
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** Chainguard registry routing for ephemeral remote builds. */
|
|
2
|
+
export declare const ARTIFACT_BUILD_NPMRC = "; Ephemeral install config for seq-studio artifact builds.\nregistry=https://libraries.cgr.dev/javascript/\n//libraries.cgr.dev/javascript/:always-auth=true\n//libraries.cgr.dev/javascript-upstream/:always-auth=true\nignore-scripts=true\nmanage-package-manager-versions=false\n";
|
|
3
|
+
/** Pull only Chainguard registry auth lines from the developer ~/.npmrc. */
|
|
4
|
+
export declare function extractChainguardAuthLines(userNpmrc: string): string[];
|
|
5
|
+
/** Merge trusted Chainguard auth from ~/.npmrc with pinned install settings. */
|
|
6
|
+
export declare function buildControlledArtifactNpmrc(userNpmrcPath?: string): string;
|
|
7
|
+
export interface PrepareBuildOptions {
|
|
8
|
+
/** Remote sources (--repo / --git-url) install when package.json exists. */
|
|
9
|
+
remote?: boolean;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Whether the build should run `pnpm install` before Vite bundles the tree.
|
|
13
|
+
* Only remote materializations (--repo / --git-url) are bare and need install.
|
|
14
|
+
*/
|
|
15
|
+
export declare function shouldInstallArtifactDependencies(dir: string, opts?: PrepareBuildOptions): boolean;
|
|
16
|
+
/**
|
|
17
|
+
* Install artifact runtime deps into the build root. Platform peers (react,
|
|
18
|
+
* atlas-ui, react-query, artifact-studio SDK) are still force-aliased at build
|
|
19
|
+
* time — this step resolves app-specific imports like recharts or reactflow.
|
|
20
|
+
*/
|
|
21
|
+
export declare function prepareArtifactBuildRoot(dir: string, opts?: PrepareBuildOptions): Promise<void>;
|