@sequenceholdings/artifact-studio 0.1.6 → 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.
Files changed (49) hide show
  1. package/dist/api.js +66 -9
  2. package/dist/auth.js +1 -1
  3. package/dist/build-subprocess.d.ts +15 -0
  4. package/dist/build-subprocess.js +65 -0
  5. package/dist/build-worker.d.ts +1 -0
  6. package/dist/build-worker.js +28 -0
  7. package/dist/build.d.ts +11 -0
  8. package/dist/build.js +85 -14
  9. package/dist/cli.d.ts +1 -0
  10. package/dist/cli.js +486 -118
  11. package/dist/config.d.ts +1 -0
  12. package/dist/config.js +4 -1
  13. package/dist/dev-link.d.ts +60 -0
  14. package/dist/dev-link.js +155 -0
  15. package/dist/dev-lock.d.ts +5 -0
  16. package/dist/dev-lock.js +83 -0
  17. package/dist/git-clone.d.ts +41 -0
  18. package/dist/git-clone.js +178 -0
  19. package/dist/git-service-client.d.ts +32 -0
  20. package/dist/git-service-client.js +122 -0
  21. package/dist/hash.d.ts +12 -0
  22. package/dist/hash.js +13 -1
  23. package/dist/lockfile-origin.d.ts +6 -0
  24. package/dist/lockfile-origin.js +41 -0
  25. package/dist/manifest-custom-roles.d.ts +63 -0
  26. package/dist/manifest-custom-roles.js +194 -0
  27. package/dist/manifest.d.ts +31 -0
  28. package/dist/manifest.js +77 -0
  29. package/dist/prepare-build.d.ts +21 -0
  30. package/dist/prepare-build.js +94 -0
  31. package/dist/project.js +2 -1
  32. package/dist/sandbox-lint.d.ts +21 -0
  33. package/dist/sandbox-lint.js +95 -0
  34. package/dist/sanitize-remote-tree.d.ts +19 -0
  35. package/dist/sanitize-remote-tree.js +52 -0
  36. package/dist/sdk.d.ts +49 -0
  37. package/dist/source-resolver.d.ts +68 -0
  38. package/dist/source-resolver.js +202 -0
  39. package/dist/templates/react-vite/CLAUDE.md +18 -7
  40. package/dist/templates/react-vite/package.json +8 -7
  41. package/dist/trusted-install.d.ts +13 -0
  42. package/dist/trusted-install.js +186 -0
  43. package/dist/watch-loop.d.ts +26 -0
  44. package/dist/watch-loop.js +31 -0
  45. package/package.json +29 -9
  46. package/templates/react-vite/CLAUDE.md +18 -7
  47. package/templates/react-vite/package.json +8 -7
  48. package/dist/bin.d.ts +0 -2
  49. 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-v2\0');
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
+ }
@@ -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>;