@ggui-ai/registry-core 0.1.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +76 -0
  3. package/dist/impls/memory-bundle-storage.d.ts +12 -0
  4. package/dist/impls/memory-bundle-storage.d.ts.map +1 -0
  5. package/dist/impls/memory-bundle-storage.js +40 -0
  6. package/dist/impls/memory-registry-storage.d.ts +3 -0
  7. package/dist/impls/memory-registry-storage.d.ts.map +1 -0
  8. package/dist/impls/memory-registry-storage.js +154 -0
  9. package/dist/index.d.ts +26 -0
  10. package/dist/index.d.ts.map +1 -0
  11. package/dist/index.js +26 -0
  12. package/dist/interfaces/authn.d.ts +51 -0
  13. package/dist/interfaces/authn.d.ts.map +1 -0
  14. package/dist/interfaces/authn.js +1 -0
  15. package/dist/interfaces/bundle-storage.d.ts +79 -0
  16. package/dist/interfaces/bundle-storage.d.ts.map +1 -0
  17. package/dist/interfaces/bundle-storage.js +1 -0
  18. package/dist/interfaces/registry-storage.d.ts +204 -0
  19. package/dist/interfaces/registry-storage.d.ts.map +1 -0
  20. package/dist/interfaces/registry-storage.js +19 -0
  21. package/dist/ops/compile.d.ts +48 -0
  22. package/dist/ops/compile.d.ts.map +1 -0
  23. package/dist/ops/compile.js +163 -0
  24. package/dist/ops/conformance.d.ts +154 -0
  25. package/dist/ops/conformance.d.ts.map +1 -0
  26. package/dist/ops/conformance.js +487 -0
  27. package/dist/ops/list-versions.d.ts +58 -0
  28. package/dist/ops/list-versions.d.ts.map +1 -0
  29. package/dist/ops/list-versions.js +60 -0
  30. package/dist/ops/publish.d.ts +70 -0
  31. package/dist/ops/publish.d.ts.map +1 -0
  32. package/dist/ops/publish.js +417 -0
  33. package/dist/ops/read.d.ts +52 -0
  34. package/dist/ops/read.d.ts.map +1 -0
  35. package/dist/ops/read.js +60 -0
  36. package/dist/ops/register-author-key.d.ts +22 -0
  37. package/dist/ops/register-author-key.d.ts.map +1 -0
  38. package/dist/ops/register-author-key.js +171 -0
  39. package/dist/ops/search.d.ts +50 -0
  40. package/dist/ops/search.d.ts.map +1 -0
  41. package/dist/ops/search.js +106 -0
  42. package/dist/testing/bundle-storage-contract.d.ts +3 -0
  43. package/dist/testing/bundle-storage-contract.d.ts.map +1 -0
  44. package/dist/testing/bundle-storage-contract.js +176 -0
  45. package/dist/testing/index.d.ts +10 -0
  46. package/dist/testing/index.d.ts.map +1 -0
  47. package/dist/testing/index.js +9 -0
  48. package/dist/testing/registry-storage-contract.d.ts +3 -0
  49. package/dist/testing/registry-storage-contract.d.ts.map +1 -0
  50. package/dist/testing/registry-storage-contract.js +392 -0
  51. package/dist/types.d.ts +482 -0
  52. package/dist/types.d.ts.map +1 -0
  53. package/dist/types.js +110 -0
  54. package/dist/utils/base64.d.ts +15 -0
  55. package/dist/utils/base64.d.ts.map +1 -0
  56. package/dist/utils/base64.js +35 -0
  57. package/dist/utils/semver.d.ts +13 -0
  58. package/dist/utils/semver.d.ts.map +1 -0
  59. package/dist/utils/semver.js +69 -0
  60. package/package.json +85 -0
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Conformance check — a pure function shared by the registry server,
3
+ * the hosted registry, and the publish op so they all enforce the
4
+ * same gate.
5
+ *
6
+ * ## Gadget gates
7
+ *
8
+ * 1. **`manifest_invalid`** — manifest does not parse against the
9
+ * strict zod schema in `@ggui-ai/artifact-manifest`.
10
+ *
11
+ * 2. **`bundle_parse_error`** — submitted `bundle` text does not
12
+ * parse as ESM via `oxc-parser`'s `parseSync`.
13
+ *
14
+ * 3. **`disallowed_import`** — AST-walk over the bundle's
15
+ * `staticImports` + `dynamicImports` rejects any source not in the
16
+ * allowlist + verifies a matching named or default export exists.
17
+ * Dynamic imports with a non-literal specifier
18
+ * (`import(getEvilPkg())`) are rejected outright since the static
19
+ * gate cannot resolve the target.
20
+ *
21
+ * 4. **`missing_default_export`** — same walk; for every entry in
22
+ * the manifest's `exports[]` the bundle must carry either a
23
+ * `default` export or a named export matching that entry's
24
+ * `hook` / `component` name.
25
+ *
26
+ * ## Blueprint gates
27
+ *
28
+ * Blueprints carry TSX source instead of a pre-compiled bundle; the
29
+ * gate compiles + AST-walks the source so the registry rejects
30
+ * un-installable blueprints at publish time instead of at iframe load
31
+ * time.
32
+ *
33
+ * 5. **`blueprint_source_too_large`** — `manifest.source` exceeds
34
+ * {@link MAX_BLUEPRINT_SOURCE_BYTES}.
35
+ *
36
+ * 6. **`blueprint_compile_error`** — `oxc-parser` rejects the TSX
37
+ * source (syntax error, JSX-not-resolved, etc.). `oxc-parser`'s
38
+ * `lang: 'tsx'` validates JSX + TS syntax on its own, so this
39
+ * gate is parse-only — no separate compile step is needed here
40
+ * (the import-walk runs directly against `manifest.source`).
41
+ *
42
+ * 7. **`blueprint_disallowed_import`** — source imports a module
43
+ * outside the always-allowlist. Walks both `staticImports` and
44
+ * `dynamicImports`. Dynamic imports with a non-literal specifier
45
+ * (`import(getEvilPkg())`) are rejected outright since the static
46
+ * gate cannot resolve the target. Blueprints have NO `peerDeps`
47
+ * channel — the only legal imports are `react`, `react-dom`,
48
+ * `react/jsx-runtime`, `@ggui-ai/gadgets`.
49
+ *
50
+ * 8. **`blueprint_missing_default_export`** — source has no default
51
+ * export. The iframe runtime mounts the default export as the root
52
+ * component; a blueprint without one cannot render.
53
+ *
54
+ * 9. **`fixture_props_shape_mismatch`** — both `manifest.fixtureProps`
55
+ * and `manifest.contract.propsSpec` are present, and the fixture is
56
+ * missing a key marked `required: true` on the propsSpec (or the
57
+ * fixture is not a JSON object).
58
+ *
59
+ * Each gate short-circuits the next. Within the import walk, every
60
+ * disallowed import + the missing-export check are all collected
61
+ * together (one walk, every violation surfaced).
62
+ *
63
+ * The optional runtime-probe gate (`blueprint_runtime_probe_failed`) is
64
+ * defined in this file's {@link ConformanceErrorCode} union but executed
65
+ * by {@link BlueprintProbeRunner}-implementing packages — kept out of
66
+ * registry-core to keep this layer free of DOM-emulation deps.
67
+ */
68
+ import { type ArtifactManifest } from '@ggui-ai/artifact-manifest';
69
+ import { type StaticExport, type StaticImport } from 'oxc-parser';
70
+ /**
71
+ * Body shape of the conformance request. `bundle` is the UTF-8 text of
72
+ * the compiled gadget entry; required for `kind: "gadget"` manifests,
73
+ * ignored otherwise.
74
+ */
75
+ export interface ConformanceRequestPayload {
76
+ readonly manifest: unknown;
77
+ readonly bundle?: string;
78
+ }
79
+ /**
80
+ * Stable error-code enum. Strings are the wire contract — the publish
81
+ * CLI matches on these for human-readable rendering.
82
+ *
83
+ * Also exported as the `ConformanceFailureCode` alias for callers
84
+ * reading the `conformanceFailureCode` sub-discriminator field on the
85
+ * publish error body. Same union, two names — the `*FailureCode` name
86
+ * reads naturally on the publish/error side; the `*ErrorCode` name
87
+ * reads naturally on the standalone-conformance-check side.
88
+ */
89
+ export type ConformanceErrorCode = 'manifest_invalid' | 'bundle_parse_error' | 'disallowed_import' | 'missing_default_export' | 'blueprint_source_too_large' | 'blueprint_compile_error' | 'blueprint_disallowed_import' | 'blueprint_missing_default_export' | 'fixture_props_shape_mismatch' | 'blueprint_runtime_probe_failed';
90
+ export type ConformanceFailureCode = ConformanceErrorCode;
91
+ export interface ConformanceError {
92
+ readonly code: ConformanceErrorCode;
93
+ readonly message: string;
94
+ /**
95
+ * Zod `path` array for `manifest_invalid`, otherwise omitted. Zod's
96
+ * issue paths are `PropertyKey[]` (string | number | symbol); the
97
+ * symbol case is projected via `String(...)` in {@link zodIssueToError}.
98
+ */
99
+ readonly path?: readonly (string | number)[];
100
+ /**
101
+ * Per-code structured detail — `{ line, column }` for parse errors,
102
+ * `{ source }` for disallowed imports, etc.
103
+ */
104
+ readonly detail?: unknown;
105
+ }
106
+ export interface ConformanceResponseBody {
107
+ readonly ok: boolean;
108
+ readonly errors: readonly ConformanceError[];
109
+ }
110
+ /**
111
+ * Hard upper bound on blueprint TSX source size. Symmetric with the
112
+ * gadget bundle limit ({@link MAX_BUNDLE_BYTES} in `publish.ts`) so an
113
+ * operator with no `MAX_BUNDLE_BYTES` mental model still gets the same
114
+ * order of magnitude. Source size is measured in UTF-8 bytes, not
115
+ * JavaScript string length — multi-byte characters count for their
116
+ * encoded width.
117
+ */
118
+ export declare const MAX_BLUEPRINT_SOURCE_BYTES: number;
119
+ /**
120
+ * Run the conformance check synchronously. Pure function: no AWS, no
121
+ * network, no env lookups. Returns the locked response body shape.
122
+ * The OSS server, the cloud Lambda, and the publish op all call this.
123
+ *
124
+ * Static gates only — the optional runtime probe lives behind a
125
+ * {@link BlueprintProbeRunner} seam so registry-core stays free of
126
+ * DOM-emulation deps (happy-dom + react-dom/server land in the caller
127
+ * that wants the probe).
128
+ */
129
+ export declare function checkConformance(payload: ConformanceRequestPayload): ConformanceResponseBody;
130
+ /**
131
+ * Runtime-probe seam. Implemented by callers that opt into the
132
+ * sandboxed render check. registry-core does not provide a default
133
+ * impl because the canonical happy-dom-based runner pulls in
134
+ * `happy-dom` + `react-dom/server` — deps the lean conformance HTTP
135
+ * endpoint should not pay for.
136
+ *
137
+ * Publish flow wires this through `PublishArtifactDeps.blueprintProbe`
138
+ * (optional). When absent, the probe is skipped and only the static
139
+ * gates run.
140
+ */
141
+ export interface BlueprintProbeRunner {
142
+ /**
143
+ * Render the compiled blueprint with the manifest's fixtureProps in
144
+ * a sandboxed DOM. Resolve to `ok: true` on a clean render; resolve
145
+ * to `ok: false` with a single error carrying code
146
+ * `'blueprint_runtime_probe_failed'` on any thrown error during
147
+ * compile / mount / render.
148
+ */
149
+ probe(manifest: Extract<ArtifactManifest, {
150
+ kind: 'blueprint';
151
+ }>): Promise<ConformanceResponseBody>;
152
+ }
153
+ export type { StaticImport, StaticExport };
154
+ //# sourceMappingURL=conformance.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"conformance.d.ts","sourceRoot":"","sources":["../../src/ops/conformance.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkEG;AACH,OAAO,EAEL,KAAK,gBAAgB,EACtB,MAAM,4BAA4B,CAAC;AACpC,OAAO,EAGL,KAAK,YAAY,EACjB,KAAK,YAAY,EAClB,MAAM,YAAY,CAAC;AAGpB;;;;GAIG;AACH,MAAM,WAAW,yBAAyB;IACxC,QAAQ,CAAC,QAAQ,EAAE,OAAO,CAAC;IAC3B,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;CAC1B;AAED;;;;;;;;;GASG;AACH,MAAM,MAAM,oBAAoB,GAC5B,kBAAkB,GAClB,oBAAoB,GACpB,mBAAmB,GACnB,wBAAwB,GACxB,4BAA4B,GAC5B,yBAAyB,GACzB,6BAA6B,GAC7B,kCAAkC,GAClC,8BAA8B,GAC9B,gCAAgC,CAAC;AAErC,MAAM,MAAM,sBAAsB,GAAG,oBAAoB,CAAC;AAE1D,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,CAAC,IAAI,EAAE,oBAAoB,CAAC;IACpC,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB;;;;OAIG;IACH,QAAQ,CAAC,IAAI,CAAC,EAAE,SAAS,CAAC,MAAM,GAAG,MAAM,CAAC,EAAE,CAAC;IAC7C;;;OAGG;IACH,QAAQ,CAAC,MAAM,CAAC,EAAE,OAAO,CAAC;CAC3B;AAED,MAAM,WAAW,uBAAuB;IACtC,QAAQ,CAAC,EAAE,EAAE,OAAO,CAAC;IACrB,QAAQ,CAAC,MAAM,EAAE,SAAS,gBAAgB,EAAE,CAAC;CAC9C;AAkBD;;;;;;;GAOG;AACH,eAAO,MAAM,0BAA0B,QAAkB,CAAC;AAE1D;;;;;;;;;GASG;AACH,wBAAgB,gBAAgB,CAC9B,OAAO,EAAE,yBAAyB,GACjC,uBAAuB,CAgBzB;AAmbD;;;;;;;;;;GAUG;AACH,MAAM,WAAW,oBAAoB;IACnC;;;;;;OAMG;IACH,KAAK,CACH,QAAQ,EAAE,OAAO,CAAC,gBAAgB,EAAE;QAAE,IAAI,EAAE,WAAW,CAAA;KAAE,CAAC,GACzD,OAAO,CAAC,uBAAuB,CAAC,CAAC;CACrC;AAED,YAAY,EAAE,YAAY,EAAE,YAAY,EAAE,CAAC"}
@@ -0,0 +1,487 @@
1
+ /**
2
+ * Conformance check — a pure function shared by the registry server,
3
+ * the hosted registry, and the publish op so they all enforce the
4
+ * same gate.
5
+ *
6
+ * ## Gadget gates
7
+ *
8
+ * 1. **`manifest_invalid`** — manifest does not parse against the
9
+ * strict zod schema in `@ggui-ai/artifact-manifest`.
10
+ *
11
+ * 2. **`bundle_parse_error`** — submitted `bundle` text does not
12
+ * parse as ESM via `oxc-parser`'s `parseSync`.
13
+ *
14
+ * 3. **`disallowed_import`** — AST-walk over the bundle's
15
+ * `staticImports` + `dynamicImports` rejects any source not in the
16
+ * allowlist + verifies a matching named or default export exists.
17
+ * Dynamic imports with a non-literal specifier
18
+ * (`import(getEvilPkg())`) are rejected outright since the static
19
+ * gate cannot resolve the target.
20
+ *
21
+ * 4. **`missing_default_export`** — same walk; for every entry in
22
+ * the manifest's `exports[]` the bundle must carry either a
23
+ * `default` export or a named export matching that entry's
24
+ * `hook` / `component` name.
25
+ *
26
+ * ## Blueprint gates
27
+ *
28
+ * Blueprints carry TSX source instead of a pre-compiled bundle; the
29
+ * gate compiles + AST-walks the source so the registry rejects
30
+ * un-installable blueprints at publish time instead of at iframe load
31
+ * time.
32
+ *
33
+ * 5. **`blueprint_source_too_large`** — `manifest.source` exceeds
34
+ * {@link MAX_BLUEPRINT_SOURCE_BYTES}.
35
+ *
36
+ * 6. **`blueprint_compile_error`** — `oxc-parser` rejects the TSX
37
+ * source (syntax error, JSX-not-resolved, etc.). `oxc-parser`'s
38
+ * `lang: 'tsx'` validates JSX + TS syntax on its own, so this
39
+ * gate is parse-only — no separate compile step is needed here
40
+ * (the import-walk runs directly against `manifest.source`).
41
+ *
42
+ * 7. **`blueprint_disallowed_import`** — source imports a module
43
+ * outside the always-allowlist. Walks both `staticImports` and
44
+ * `dynamicImports`. Dynamic imports with a non-literal specifier
45
+ * (`import(getEvilPkg())`) are rejected outright since the static
46
+ * gate cannot resolve the target. Blueprints have NO `peerDeps`
47
+ * channel — the only legal imports are `react`, `react-dom`,
48
+ * `react/jsx-runtime`, `@ggui-ai/gadgets`.
49
+ *
50
+ * 8. **`blueprint_missing_default_export`** — source has no default
51
+ * export. The iframe runtime mounts the default export as the root
52
+ * component; a blueprint without one cannot render.
53
+ *
54
+ * 9. **`fixture_props_shape_mismatch`** — both `manifest.fixtureProps`
55
+ * and `manifest.contract.propsSpec` are present, and the fixture is
56
+ * missing a key marked `required: true` on the propsSpec (or the
57
+ * fixture is not a JSON object).
58
+ *
59
+ * Each gate short-circuits the next. Within the import walk, every
60
+ * disallowed import + the missing-export check are all collected
61
+ * together (one walk, every violation surfaced).
62
+ *
63
+ * The optional runtime-probe gate (`blueprint_runtime_probe_failed`) is
64
+ * defined in this file's {@link ConformanceErrorCode} union but executed
65
+ * by {@link BlueprintProbeRunner}-implementing packages — kept out of
66
+ * registry-core to keep this layer free of DOM-emulation deps.
67
+ */
68
+ import { safeParseArtifactManifest, } from '@ggui-ai/artifact-manifest';
69
+ import { parseSync, } from 'oxc-parser';
70
+ /**
71
+ * Always-permitted import sources for gadget bundles. Mirrors the
72
+ * runtime CSP the iframe enforces — these are the only modules the
73
+ * data-URL shim resolves besides the manifest's `peerDeps`.
74
+ *
75
+ * Kept as a `readonly Set` so the walker check is O(1) and the array
76
+ * cannot be mutated by callers. The exact contents are wire-stable;
77
+ * widening requires a follow-up + migration note.
78
+ */
79
+ const ALWAYS_ALLOWED_IMPORTS = new Set([
80
+ 'react',
81
+ 'react-dom',
82
+ 'react/jsx-runtime',
83
+ '@ggui-ai/gadgets',
84
+ ]);
85
+ /**
86
+ * Hard upper bound on blueprint TSX source size. Symmetric with the
87
+ * gadget bundle limit ({@link MAX_BUNDLE_BYTES} in `publish.ts`) so an
88
+ * operator with no `MAX_BUNDLE_BYTES` mental model still gets the same
89
+ * order of magnitude. Source size is measured in UTF-8 bytes, not
90
+ * JavaScript string length — multi-byte characters count for their
91
+ * encoded width.
92
+ */
93
+ export const MAX_BLUEPRINT_SOURCE_BYTES = 5 * 1024 * 1024;
94
+ /**
95
+ * Run the conformance check synchronously. Pure function: no AWS, no
96
+ * network, no env lookups. Returns the locked response body shape.
97
+ * The OSS server, the cloud Lambda, and the publish op all call this.
98
+ *
99
+ * Static gates only — the optional runtime probe lives behind a
100
+ * {@link BlueprintProbeRunner} seam so registry-core stays free of
101
+ * DOM-emulation deps (happy-dom + react-dom/server land in the caller
102
+ * that wants the probe).
103
+ */
104
+ export function checkConformance(payload) {
105
+ // ── Gate 1: manifest schema ────────────────────────────────────────
106
+ const manifestResult = safeParseArtifactManifest(payload.manifest);
107
+ if (!manifestResult.success) {
108
+ return {
109
+ ok: false,
110
+ errors: manifestResult.error.issues.map(zodIssueToError),
111
+ };
112
+ }
113
+ const manifest = manifestResult.data;
114
+ if (manifest.kind === 'blueprint') {
115
+ return checkBlueprintConformance(manifest);
116
+ }
117
+ return checkGadgetConformance(manifest, payload.bundle);
118
+ }
119
+ /**
120
+ * Gadget gate sequence — parses + import-walks the submitted bundle.
121
+ * Extracted from {@link checkConformance} so the blueprint branch can
122
+ * stand on its own without nesting.
123
+ */
124
+ function checkGadgetConformance(manifest, bundle) {
125
+ // ── Gate 2: bundle is parseable ESM ────────────────────────────────
126
+ if (typeof bundle !== 'string' || bundle.length === 0) {
127
+ return {
128
+ ok: false,
129
+ errors: [
130
+ {
131
+ code: 'bundle_parse_error',
132
+ message: 'gadget submission is missing the `bundle` field — bundle text is required for kind=gadget conformance.',
133
+ },
134
+ ],
135
+ };
136
+ }
137
+ const parsed = parseSync('bundle.js', bundle, {
138
+ lang: 'js',
139
+ sourceType: 'module',
140
+ });
141
+ if (parsed.errors.length > 0) {
142
+ const first = parsed.errors[0];
143
+ if (!first) {
144
+ return {
145
+ ok: false,
146
+ errors: [{ code: 'bundle_parse_error', message: 'unknown parse error' }],
147
+ };
148
+ }
149
+ const firstLabel = first.labels[0];
150
+ const offset = firstLabel?.start ?? 0;
151
+ const { line, column } = offsetToLineCol(bundle, offset);
152
+ return {
153
+ ok: false,
154
+ errors: [
155
+ {
156
+ code: 'bundle_parse_error',
157
+ message: first.message,
158
+ detail: { line, column, offset },
159
+ },
160
+ ],
161
+ };
162
+ }
163
+ // ── Gate 3: import allowlist + export check (combined walk) ────────
164
+ const peerDepKeys = manifest.peerDeps
165
+ ? new Set(Object.keys(manifest.peerDeps))
166
+ : new Set();
167
+ const errors = [];
168
+ for (const imp of parsed.module.staticImports) {
169
+ const source = imp.moduleRequest.value;
170
+ if (!isImportAllowed(source, peerDepKeys)) {
171
+ const { line, column } = offsetToLineCol(bundle, imp.start);
172
+ errors.push({
173
+ code: 'disallowed_import',
174
+ message: `import source \`${source}\` is not in the conformance allowlist. Allowed: \`react\`, \`react-dom\`, \`react/jsx-runtime\`, \`@ggui-ai/gadgets\`, plus any package declared in \`peerDeps\`.`,
175
+ detail: { source, line, column },
176
+ });
177
+ }
178
+ }
179
+ // Dynamic imports — same allow-list, plus a hard reject on non-literal
180
+ // expressions (`import(getEvilPkg())`) since the static gate cannot
181
+ // know the target. Pre-launch posture: a known bypass path that
182
+ // would otherwise sail through; rejected outright.
183
+ for (const dyn of parsed.module.dynamicImports) {
184
+ const dynError = checkDynamicImport(bundle, dyn, 'disallowed_import', (source) => isImportAllowed(source, peerDepKeys), 'Allowed: `react`, `react-dom`, `react/jsx-runtime`, `@ggui-ai/gadgets`, plus any package declared in `peerDeps`.');
185
+ if (dynError)
186
+ errors.push(dynError);
187
+ }
188
+ // A gadget package declares ≥1 export; the bundle MUST carry a
189
+ // matching named export (or `default`) for every one of them.
190
+ for (const exp of manifest.exports) {
191
+ const isHook = 'hook' in exp;
192
+ const exportName = isHook ? exp.hook : exp.component;
193
+ if (!hasMatchingExport(parsed.module.staticExports, exportName)) {
194
+ errors.push({
195
+ code: 'missing_default_export',
196
+ message: `bundle exports neither \`default\` nor a named export matching the manifest's ${isHook ? 'hook' : 'component'} "${exportName}". The publish CLI fails closed when the LLM cannot resolve the export entry.`,
197
+ detail: { expectedHook: exportName },
198
+ });
199
+ }
200
+ }
201
+ return { ok: errors.length === 0, errors };
202
+ }
203
+ /**
204
+ * Blueprint gate sequence — compile + import-walk + export + fixture
205
+ * shape. Static-only; the runtime probe lives behind the optional
206
+ * {@link BlueprintProbeRunner} seam.
207
+ */
208
+ function checkBlueprintConformance(manifest) {
209
+ // ── Gate 5: source size ──────────────────────────────────────────
210
+ const sourceBytes = utf8ByteLength(manifest.source);
211
+ if (sourceBytes > MAX_BLUEPRINT_SOURCE_BYTES) {
212
+ return {
213
+ ok: false,
214
+ errors: [
215
+ {
216
+ code: 'blueprint_source_too_large',
217
+ message: `blueprint source is ${sourceBytes} bytes; maximum is ${MAX_BLUEPRINT_SOURCE_BYTES} bytes (${MAX_BLUEPRINT_SOURCE_BYTES / (1024 * 1024)} MiB).`,
218
+ detail: { sourceBytes, maxBytes: MAX_BLUEPRINT_SOURCE_BYTES },
219
+ },
220
+ ],
221
+ };
222
+ }
223
+ // ── Gate 6: TSX parse + syntax validation ────────────────────────
224
+ // oxc-parser's `lang: 'tsx'` rejects JSX + TS syntax errors directly,
225
+ // so a separate esbuild compile is redundant work. Parsing the SOURCE
226
+ // (not a compiled output) is deliberate — author intent (including
227
+ // unused imports that signal mis-configured deps) is what the
228
+ // allow-list gates, not whatever a bundler would optimize away.
229
+ const parsed = parseSync('blueprint.tsx', manifest.source, {
230
+ lang: 'tsx',
231
+ sourceType: 'module',
232
+ });
233
+ if (parsed.errors.length > 0) {
234
+ const first = parsed.errors[0];
235
+ const offset = first?.labels[0]?.start ?? 0;
236
+ const { line, column } = offsetToLineCol(manifest.source, offset);
237
+ return {
238
+ ok: false,
239
+ errors: [
240
+ {
241
+ code: 'blueprint_compile_error',
242
+ message: first?.message ?? 'syntax error in blueprint source',
243
+ detail: { line, column, offset },
244
+ },
245
+ ],
246
+ };
247
+ }
248
+ const errors = [];
249
+ for (const imp of parsed.module.staticImports) {
250
+ const source = imp.moduleRequest.value;
251
+ if (!ALWAYS_ALLOWED_IMPORTS.has(source)) {
252
+ const { line, column } = offsetToLineCol(manifest.source, imp.start);
253
+ errors.push({
254
+ code: 'blueprint_disallowed_import',
255
+ message: `import source \`${source}\` is not allowed in a blueprint. Blueprints have no \`peerDeps\` channel; the only legal imports are \`react\`, \`react-dom\`, \`react/jsx-runtime\`, \`@ggui-ai/gadgets\`.`,
256
+ detail: { source, line, column },
257
+ });
258
+ }
259
+ }
260
+ // Dynamic imports — same allow-list, plus a hard reject on non-literal
261
+ // expressions (`import(getEvilPkg())`) since the static gate cannot
262
+ // know the target. Pre-launch posture: a known bypass path; rejected
263
+ // outright. Blueprints have no peerDeps channel, so the literal must
264
+ // resolve into ALWAYS_ALLOWED_IMPORTS.
265
+ for (const dyn of parsed.module.dynamicImports) {
266
+ const dynError = checkDynamicImport(manifest.source, dyn, 'blueprint_disallowed_import', (source) => ALWAYS_ALLOWED_IMPORTS.has(source), 'Blueprints have no `peerDeps` channel; the only legal imports are `react`, `react-dom`, `react/jsx-runtime`, `@ggui-ai/gadgets`.');
267
+ if (dynError)
268
+ errors.push(dynError);
269
+ }
270
+ // ── Gate 8: default export ───────────────────────────────────────
271
+ if (!hasDefaultExport(parsed.module.staticExports)) {
272
+ errors.push({
273
+ code: 'blueprint_missing_default_export',
274
+ message: 'blueprint source has no default export. The iframe runtime mounts the default export as the root component — a blueprint without one cannot render.',
275
+ });
276
+ }
277
+ // ── Gate 9: fixtureProps × contract.propsSpec shape ──────────────
278
+ if (manifest.fixtureProps !== undefined &&
279
+ manifest.contract?.propsSpec !== undefined) {
280
+ const shapeError = validateFixturePropsShape(manifest.fixtureProps, manifest.contract.propsSpec);
281
+ if (shapeError !== null) {
282
+ errors.push(shapeError);
283
+ }
284
+ }
285
+ return { ok: errors.length === 0, errors };
286
+ }
287
+ /**
288
+ * Allowlist check — short-circuits on the implicit four sources, then
289
+ * falls through to the manifest's `peerDeps` keys. Subpath imports of
290
+ * a `peerDeps` root are also allowed (e.g. `mapbox-gl/dist/mapbox-gl.css`
291
+ * when `mapbox-gl` is a peerDep).
292
+ */
293
+ function isImportAllowed(source, peerDepKeys) {
294
+ if (ALWAYS_ALLOWED_IMPORTS.has(source))
295
+ return true;
296
+ if (peerDepKeys.has(source))
297
+ return true;
298
+ const slashIdx = source.indexOf('/');
299
+ if (slashIdx > 0 && !source.startsWith('@')) {
300
+ const root = source.slice(0, slashIdx);
301
+ if (peerDepKeys.has(root))
302
+ return true;
303
+ }
304
+ if (source.startsWith('@')) {
305
+ const firstSlash = source.indexOf('/');
306
+ if (firstSlash > 0) {
307
+ const secondSlash = source.indexOf('/', firstSlash + 1);
308
+ if (secondSlash > 0) {
309
+ const root = source.slice(0, secondSlash);
310
+ if (peerDepKeys.has(root))
311
+ return true;
312
+ }
313
+ }
314
+ }
315
+ return false;
316
+ }
317
+ function hasMatchingExport(staticExports, expectedHook) {
318
+ for (const exp of staticExports) {
319
+ for (const entry of exp.entries) {
320
+ if (entry.exportName.kind === 'Default')
321
+ return true;
322
+ if (entry.exportName.kind === 'Name' && entry.exportName.name === expectedHook) {
323
+ return true;
324
+ }
325
+ }
326
+ }
327
+ return false;
328
+ }
329
+ function hasDefaultExport(staticExports) {
330
+ for (const exp of staticExports) {
331
+ for (const entry of exp.entries) {
332
+ if (entry.exportName.kind === 'Default')
333
+ return true;
334
+ // oxc-parser reports the alias form `export { X as default }`
335
+ // as `kind: 'Name'` with `name: 'default'`. Both shapes are
336
+ // semantically default exports — the iframe runtime mounts
337
+ // whichever the bundler emits as `module.exports.default` /
338
+ // `import().default`.
339
+ if (entry.exportName.kind === 'Name' && entry.exportName.name === 'default') {
340
+ return true;
341
+ }
342
+ }
343
+ }
344
+ return false;
345
+ }
346
+ /**
347
+ * Fixture shape check — verifies that every `properties[key]` marked
348
+ * `required: true` on the contract's propsSpec is present on the
349
+ * fixture, and that the fixture itself is a JSON object (not array,
350
+ * not null, not primitive).
351
+ *
352
+ * Deliberately narrow: full per-prop JSON-Schema validation would
353
+ * require materializing each `properties[k].schema` into a validator
354
+ * (ajv or zod-via-json-schema). At conformance time the goal is to
355
+ * catch "author shipped fixtureProps with the wrong top-level shape";
356
+ * deep type-mismatch within a fixture key is caught by the runtime
357
+ * probe, which actually renders the blueprint with the fixture.
358
+ */
359
+ function validateFixturePropsShape(fixtureProps, propsSpec) {
360
+ if (fixtureProps === null ||
361
+ typeof fixtureProps !== 'object' ||
362
+ Array.isArray(fixtureProps)) {
363
+ return {
364
+ code: 'fixture_props_shape_mismatch',
365
+ message: `fixtureProps must be a JSON object (got ${describeJsonType(fixtureProps)}). The runtime probe renders the blueprint with \`<Component {...fixtureProps} />\`; a non-object fixture cannot spread.`,
366
+ detail: { received: describeJsonType(fixtureProps) },
367
+ };
368
+ }
369
+ // After the `typeof === 'object' && !null && !Array.isArray` narrowing
370
+ // above, `fixtureProps` is `object`. `in` works on `object` — no
371
+ // `Record<string, unknown>` cast needed.
372
+ const missing = [];
373
+ for (const [key, entry] of Object.entries(propsSpec.properties)) {
374
+ if (entry.required === true && !(key in fixtureProps)) {
375
+ missing.push(key);
376
+ }
377
+ }
378
+ if (missing.length > 0) {
379
+ return {
380
+ code: 'fixture_props_shape_mismatch',
381
+ message: `fixtureProps is missing required ${missing.length === 1 ? 'key' : 'keys'} \`${missing.join('`, `')}\` declared on contract.propsSpec.properties. Either add the ${missing.length === 1 ? 'key' : 'keys'} to fixtureProps or relax \`required\` on the propsSpec.`,
382
+ detail: { missingKeys: missing },
383
+ };
384
+ }
385
+ return null;
386
+ }
387
+ function describeJsonType(v) {
388
+ if (v === null)
389
+ return 'null';
390
+ if (Array.isArray(v))
391
+ return 'array';
392
+ return typeof v;
393
+ }
394
+ /**
395
+ * Walk a single dynamic-import expression. The oxc-parser AST gives
396
+ * us only `start`/`end` byte offsets for the `moduleRequest`, so we
397
+ * extract the raw source slice and detect a literal string ourselves.
398
+ *
399
+ * Pre-launch posture: non-literal expressions (`import(getEvilPkg())`)
400
+ * are rejected outright. The static gate cannot know the resolved
401
+ * target and ignoring them would create a silent bypass of the
402
+ * allow-list. Symmetric across gadgets (allow-list = ALWAYS_ALLOWED +
403
+ * peerDeps) and blueprints (allow-list = ALWAYS_ALLOWED only).
404
+ */
405
+ function checkDynamicImport(source, dyn, code, isAllowed, allowedSuffix) {
406
+ const raw = source.slice(dyn.moduleRequest.start, dyn.moduleRequest.end);
407
+ const literal = parseStringLiteral(raw);
408
+ const { line, column } = offsetToLineCol(source, dyn.start);
409
+ if (literal === null) {
410
+ return {
411
+ code,
412
+ message: `dynamic import with a non-literal expression (\`import(${raw.length > 40 ? raw.slice(0, 37) + '...' : raw})\`) is not allowed — the static gate cannot verify the target against the allow-list. Inline the specifier as a string literal.`,
413
+ detail: { source: '<dynamic-expression>', expression: raw, line, column },
414
+ };
415
+ }
416
+ if (!isAllowed(literal)) {
417
+ return {
418
+ code,
419
+ message: `dynamic import source \`${literal}\` is not in the conformance allowlist. ${allowedSuffix}`,
420
+ detail: { source: literal, line, column },
421
+ };
422
+ }
423
+ return null;
424
+ }
425
+ /**
426
+ * Parse a JS string literal from its raw source slice. Returns the
427
+ * decoded value for single-quoted, double-quoted, or template-literal
428
+ * specifiers with NO interpolation (e.g. `` `react` ``). Returns
429
+ * `null` for any non-literal expression (identifier, call, template
430
+ * with `${...}`, etc.) — callers treat null as "non-literal, reject".
431
+ *
432
+ * Deliberately conservative: we do not decode escape sequences in the
433
+ * specifier because import specifiers in practice are bare package
434
+ * names. A specifier that requires escape decoding (`'react'`)
435
+ * is rare enough that surfacing it as "non-literal, reject" is the
436
+ * safer pre-launch default than risking a half-correct decoder.
437
+ */
438
+ function parseStringLiteral(raw) {
439
+ if (raw.length < 2)
440
+ return null;
441
+ const first = raw[0];
442
+ const last = raw[raw.length - 1];
443
+ if (first !== last)
444
+ return null;
445
+ if (first !== "'" && first !== '"' && first !== '`')
446
+ return null;
447
+ const inner = raw.slice(1, -1);
448
+ if (first === '`' && inner.includes('${'))
449
+ return null;
450
+ // Reject any escape sequence — bare package names don't need them,
451
+ // and a half-correct unescape would be a contract surface we'd have
452
+ // to maintain forever.
453
+ if (inner.includes('\\'))
454
+ return null;
455
+ // Reject unescaped quote of the matching kind (would mean we
456
+ // mis-identified the boundaries — defensive).
457
+ if (inner.includes(first))
458
+ return null;
459
+ return inner;
460
+ }
461
+ function zodIssueToError(issue) {
462
+ const path = issue.path.map((seg) => typeof seg === 'symbol' ? String(seg) : seg);
463
+ return {
464
+ code: 'manifest_invalid',
465
+ message: issue.message,
466
+ path,
467
+ detail: { zodCode: issue.code },
468
+ };
469
+ }
470
+ function offsetToLineCol(source, offset) {
471
+ const clamped = Math.max(0, Math.min(offset, source.length));
472
+ let line = 1;
473
+ let lineStart = 0;
474
+ for (let i = 0; i < clamped; i++) {
475
+ if (source.charCodeAt(i) === 10) {
476
+ line += 1;
477
+ lineStart = i + 1;
478
+ }
479
+ }
480
+ return { line, column: clamped - lineStart + 1 };
481
+ }
482
+ function utf8ByteLength(s) {
483
+ // `TextEncoder` is universally available in Node 18+ and edge runtimes.
484
+ // Avoids the `Buffer.byteLength` Node-only dep that registry-core would
485
+ // otherwise inherit.
486
+ return new TextEncoder().encode(s).length;
487
+ }