@norskvideo/ctl-sdk 0.1.0

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 (69) hide show
  1. package/base.css +53 -0
  2. package/browser.d.ts +8 -0
  3. package/browser.js +11 -0
  4. package/capabilities-router.d.ts +9 -0
  5. package/capabilities-router.js +15 -0
  6. package/cjs-interop.d.ts +33 -0
  7. package/cjs-interop.js +61 -0
  8. package/components/ProductIframe.d.ts +37 -0
  9. package/components/ProductIframe.js +119 -0
  10. package/components/ProductTemplateBuildForm.d.ts +40 -0
  11. package/components/ProductTemplateBuildForm.js +81 -0
  12. package/components/index.d.ts +3 -0
  13. package/components/index.js +3 -0
  14. package/components/ui-primitives.d.ts +22 -0
  15. package/components/ui-primitives.js +13 -0
  16. package/dev-url.d.ts +1 -0
  17. package/dev-url.js +14 -0
  18. package/docker-runner.d.ts +20 -0
  19. package/docker-runner.js +54 -0
  20. package/fonts/Geist-LICENSE.txt +92 -0
  21. package/fonts/Geist.woff2 +0 -0
  22. package/fonts/GeistMono.woff2 +0 -0
  23. package/fonts/README.md +21 -0
  24. package/fonts/STUDIO-FONT-SYNC.md +86 -0
  25. package/index.d.ts +15 -0
  26. package/index.js +17 -0
  27. package/license-registration.d.ts +52 -0
  28. package/license-registration.js +38 -0
  29. package/license-stager.d.ts +30 -0
  30. package/license-stager.js +118 -0
  31. package/license-v2.d.ts +107 -0
  32. package/license-v2.js +205 -0
  33. package/manifest-fetch.d.ts +29 -0
  34. package/manifest-fetch.js +100 -0
  35. package/manifest-router.d.ts +11 -0
  36. package/manifest-router.js +16 -0
  37. package/manifest-schema.d.ts +113 -0
  38. package/manifest-schema.js +135 -0
  39. package/manifest-seed.d.ts +28 -0
  40. package/manifest-seed.js +40 -0
  41. package/openapi-router.d.ts +12 -0
  42. package/openapi-router.js +21 -0
  43. package/package.json +46 -0
  44. package/parsing.d.ts +9 -0
  45. package/parsing.js +83 -0
  46. package/product-error.d.ts +4 -0
  47. package/product-error.js +8 -0
  48. package/product-health-monitor.d.ts +72 -0
  49. package/product-health-monitor.js +136 -0
  50. package/product-service.d.ts +118 -0
  51. package/product-service.js +340 -0
  52. package/product-template-error.d.ts +10 -0
  53. package/product-template-error.js +14 -0
  54. package/product-template-materials.d.ts +17 -0
  55. package/product-template-materials.js +51 -0
  56. package/product-template-parsing.d.ts +14 -0
  57. package/product-template-parsing.js +66 -0
  58. package/product-template-record.d.ts +45 -0
  59. package/product-template-record.js +1 -0
  60. package/product-types.d.ts +31 -0
  61. package/product-types.js +1 -0
  62. package/proxy-middleware.d.ts +7 -0
  63. package/proxy-middleware.js +112 -0
  64. package/runtime.d.ts +2 -0
  65. package/runtime.js +21 -0
  66. package/validate.d.ts +3 -0
  67. package/validate.js +22 -0
  68. package/workflow.d.ts +60 -0
  69. package/workflow.js +57 -0
package/license-v2.js ADDED
@@ -0,0 +1,205 @@
1
+ import crypto from "node:crypto";
2
+ /**
3
+ * Norsk Licensing V2 (per-product licenses).
4
+ *
5
+ * A V2 license file is a detached-signature envelope
6
+ * `{format, payload: base64, signature: base64}` where the signature is
7
+ * RSASSA-PKCS1-v1.5/SHA-256 by the Norsk root key over the exact payload
8
+ * bytes. The payload grants a list of per-product entitlements
9
+ * (product name + imageRef + versionConstraint + expiry).
10
+ *
11
+ * Anything that is NOT a V2 envelope — today's V1 license JSON, the
12
+ * "aws"/"gcp" marketplace sentinels, license archives — classifies as
13
+ * `legacy`. Classification is not acceptance: registration rejects `legacy`
14
+ * (see `notV2EnvelopeMessage`), while other readers — the CI expiry
15
+ * classifier, for one — still legitimately read V1 files. The kind survives
16
+ * precisely so "not an envelope at all" stays distinguishable from "envelope
17
+ * that failed verification"; the two send an operator to different remedies.
18
+ *
19
+ * One trust anchor is embedded: the production root key. A license may
20
+ * self-declare `nonProduction: true` (still production-signed) which warrants a
21
+ * "not for production" warning — it is not a separate trust anchor.
22
+ */
23
+ export const LICENSE_V2_FORMAT = "norsk-license-v2";
24
+ /**
25
+ * Wording for a readable file that is not a V2 envelope. Split rather than
26
+ * frozen because each door that rejects one needs its own middle clause while
27
+ * the diagnosis and the remedy must not drift between them: `add` says "at
28
+ * registration" (which also tells the operator their existing registrations
29
+ * still launch), renewal will say something else.
30
+ *
31
+ * Leads with "not a V2 envelope" deliberately — the same message fires for a
32
+ * path typo pointing at an HTML page or a truncated download, and those must
33
+ * not be misdiagnosed as "you have a V1 license".
34
+ */
35
+ export function notV2EnvelopeMessage(doorClause) {
36
+ return `this file is not a V2 license envelope (${doorClause}) — contact Norsk support for a reissued license`;
37
+ }
38
+ const PROD_ROOT_PUBLIC_KEY_PEM = `-----BEGIN PUBLIC KEY-----
39
+ MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA02CiAbYusGEEH+tmyD7l
40
+ 7su7nrIaU5HozRgMHUDpGGm6qkIstp5pItMop1s0hPYdXTArgCHhdqh77uuj5szv
41
+ 9AmH/hb0amvI+OQmsaVRFRM/YgFlwICDlMP1JLiLZP4RcOrSRwW0m8qY5/KiOqZQ
42
+ GG2KcOftzILklGYSxqAXxQu1UpAUZIRpZCuJ5wP3zQw7JASvLU0k4lO7yG2tcbLE
43
+ GpFw4c1naB0X16QxjC6lTFIYzJqIfJFWRERiJXekJidd27TZUWlFpXSxrgs15glk
44
+ 0/c61JwraAEAOOrWXY+NXhS4SLoZdsFZOsCtSGyrmx9mMK8Jttr5MgwHuQRCMUL+
45
+ lFlOPPYYqaGGWbpnirSVKmUhxz//U0AC+abI8OSXsgAMqIB6qMCp/204feIdDjXb
46
+ XJWxIF4wpPVTNurMZI0Rk0SYTKYpJPRuIdvSmquzOzRVhOzk36wgVNSivTDqAeGu
47
+ +4Jvb7yz87dv0hr4Fgp/OyYJ6bPRgg2c5sLGsOtlmn1XGC/hVYKQMRz/jq7l+so1
48
+ dq3LLYQUwcg+ykVPCBUr1J+dA/pq5iwp8xDRPzNFOUPmWBFLVhB4GxXhhs1NToBM
49
+ iytt46G/OmI2uxfkesdj+WnYURWjyUNVe/DZpfxqz3hXv0Q6dM8cJoRY5ye5c8eA
50
+ BAx1vUsggiexa49aSFttEKECAwEAAQ==
51
+ -----END PUBLIC KEY-----`;
52
+ export function parseLicenseContents(contents) {
53
+ let envelope;
54
+ try {
55
+ envelope = JSON.parse(contents);
56
+ }
57
+ catch {
58
+ return { kind: "legacy" };
59
+ }
60
+ if (typeof envelope !== "object" || envelope === null || envelope.format !== LICENSE_V2_FORMAT) {
61
+ return { kind: "legacy" };
62
+ }
63
+ const { payload, signature } = envelope;
64
+ if (typeof payload !== "string" || typeof signature !== "string") {
65
+ return { kind: "invalid-v2", reason: "malformed envelope" };
66
+ }
67
+ const payloadBytes = Buffer.from(payload, "base64");
68
+ const signatureBytes = Buffer.from(signature, "base64");
69
+ if (!crypto.verify("sha256", payloadBytes, PROD_ROOT_PUBLIC_KEY_PEM, signatureBytes)) {
70
+ return { kind: "invalid-v2", reason: "license signature is invalid" };
71
+ }
72
+ let license;
73
+ try {
74
+ license = JSON.parse(payloadBytes.toString("utf-8"));
75
+ }
76
+ catch {
77
+ return { kind: "invalid-v2", reason: "license payload is not valid JSON" };
78
+ }
79
+ if (license.version !== 2 || !Array.isArray(license.products)) {
80
+ return { kind: "invalid-v2", reason: "license payload is not a V2 license" };
81
+ }
82
+ return { kind: "v2", license };
83
+ }
84
+ /** How a product entry's image resolves, for previewing before an add. The
85
+ * licence carries a repo + versionConstraint but no tag: an exact version or
86
+ * sha256 digest is fixed (not editable); a caret range / * / no constraint
87
+ * defaults to `latest` and the tag is free to edit (a caret still has to stay
88
+ * in band, enforced at add time). */
89
+ export function describeProductImage(entry) {
90
+ const c = entry.versionConstraint;
91
+ if (c?.startsWith("sha256:"))
92
+ return { image: `${entry.imageRef}@${c}`, tag: c, editable: false };
93
+ if (c && c !== "*" && !c.startsWith("^"))
94
+ return { image: `${entry.imageRef}:${c}`, tag: c, editable: false };
95
+ return { image: `${entry.imageRef}:latest`, tag: "latest", editable: true };
96
+ }
97
+ /** Split `repo[:tag][@digest]`; a ':' only separates the tag when it appears
98
+ * after the last '/' (registry hosts can carry ports). */
99
+ export function parseImageRef(ref) {
100
+ const at = ref.indexOf("@");
101
+ const digest = at === -1 ? undefined : ref.slice(at + 1);
102
+ const repoAndTag = at === -1 ? ref : ref.slice(0, at);
103
+ const colon = repoAndTag.lastIndexOf(":");
104
+ if (colon > repoAndTag.lastIndexOf("/")) {
105
+ return { repo: repoAndTag.slice(0, colon), tag: repoAndTag.slice(colon + 1), digest };
106
+ }
107
+ return { repo: repoAndTag, digest };
108
+ }
109
+ function parseVersion(s) {
110
+ const segments = s.split(".");
111
+ // strict integer segments only; anything else (e.g. "1.2.3-beta") fails closed
112
+ if (segments.length === 0 || segments.some((p) => !/^\d+$/.test(p)))
113
+ return undefined;
114
+ return segments.map((p) => Number.parseInt(p, 10));
115
+ }
116
+ /** Same semantics as the engine's matcher: `*` (or absent) accepts any tag;
117
+ * `sha256:...` pins the digest; `^x[.y[.z]]` is a caret semver range over
118
+ * the tag; anything else is an exact tag match. Unparseable tags against a
119
+ * semver constraint fail closed. */
120
+ export function imageRefMatches(entry, presented) {
121
+ const parsed = parseImageRef(presented);
122
+ if (parsed.repo !== entry.imageRef)
123
+ return false;
124
+ const constraint = entry.versionConstraint ?? "*";
125
+ if (constraint === "*")
126
+ return true;
127
+ if (constraint.startsWith("sha256:"))
128
+ return parsed.digest === constraint;
129
+ if (constraint.startsWith("^")) {
130
+ if (parsed.tag === undefined)
131
+ return false;
132
+ const range = parseVersion(constraint.slice(1));
133
+ const tag = parseVersion(parsed.tag.replace(/^v/, ""));
134
+ if (!range || !tag)
135
+ return false;
136
+ const pad = (v) => [v[0] ?? 0, v[1] ?? 0, v[2] ?? 0];
137
+ const [tMaj, tMin, tPat] = pad(tag);
138
+ const [rMaj, rMin, rPat] = pad(range);
139
+ if (tMaj !== rMaj)
140
+ return false;
141
+ if (tMin !== rMin)
142
+ return tMin > rMin;
143
+ return tPat >= rPat;
144
+ }
145
+ return parsed.tag === constraint;
146
+ }
147
+ /** Does this V2 license entitle `productName`, optionally pinned to the
148
+ * container image being registered? */
149
+ export function checkProductEntitlement(parsed, productName, imageRef, now = new Date()) {
150
+ const { license } = parsed;
151
+ const warnings = [];
152
+ if (license.nonProduction === true) {
153
+ warnings.push("license is marked NON-PRODUCTION — not valid for production deployments");
154
+ }
155
+ // License-level expiry gates the whole license, exactly as the engine
156
+ // rejects an expired license at boot — a future per-entry expiry must not
157
+ // mask a past license-level expiry.
158
+ const licenseExpiry = parseExpiry(license.expiry);
159
+ if (licenseExpiry === "invalid") {
160
+ return { ok: false, reason: `license has an unparseable expiry '${license.expiry}'` };
161
+ }
162
+ if (licenseExpiry !== undefined && licenseExpiry.getTime() <= now.getTime()) {
163
+ return { ok: false, reason: `license expired at ${license.expiry}` };
164
+ }
165
+ const candidates = license.products.filter((p) => p.product === productName);
166
+ if (candidates.length === 0) {
167
+ const listed = license.products.map((p) => p.product).join(", ") || "none";
168
+ return { ok: false, reason: `license has no entry for product '${productName}' (licensed products: ${listed})` };
169
+ }
170
+ // A license may carry several entries for one product (e.g. a renewal beside
171
+ // an old entry). Accept if ANY entry authorises this image and is unexpired;
172
+ // surface the first entry's reason only when none do.
173
+ const checkEntry = (entry) => {
174
+ const entryExpiry = parseExpiry(entry.expiresAt);
175
+ if (entryExpiry === "invalid") {
176
+ return { ok: false, reason: `license entry for '${productName}' has an unparseable expiry '${entry.expiresAt}'` };
177
+ }
178
+ if (entryExpiry !== undefined && entryExpiry.getTime() <= now.getTime()) {
179
+ return { ok: false, reason: `license entry for '${productName}' expired at ${entry.expiresAt}` };
180
+ }
181
+ if (imageRef !== undefined && !imageRefMatches(entry, imageRef)) {
182
+ return {
183
+ ok: false,
184
+ reason: `image '${imageRef}' does not satisfy the licensed imageRef '${entry.imageRef}'` +
185
+ ` (versionConstraint: ${entry.versionConstraint ?? "*"})`,
186
+ };
187
+ }
188
+ return { ok: true };
189
+ };
190
+ const accepted = candidates.find((e) => checkEntry(e).ok);
191
+ if (accepted) {
192
+ return { ok: true, entry: accepted, warnings };
193
+ }
194
+ // None matched — report the first candidate's specific failure.
195
+ const firstFailure = checkEntry(candidates[0]);
196
+ return { ok: false, reason: firstFailure.ok ? "no matching entry" : firstFailure.reason };
197
+ }
198
+ /** Parse an ISO expiry: `undefined` for absent, `"invalid"` for unparseable
199
+ * (callers fail closed), else the Date. */
200
+ function parseExpiry(value) {
201
+ if (value === undefined)
202
+ return undefined;
203
+ const d = new Date(value);
204
+ return Number.isNaN(d.getTime()) ? "invalid" : d;
205
+ }
@@ -0,0 +1,29 @@
1
+ import { type Manifest } from "./manifest-schema.js";
2
+ import type { ProductSpec } from "./product-types.js";
3
+ /**
4
+ * Fast liveness probe for a registered product. A dev-mode product is
5
+ * externally owned, so the only way to know it's actually up is to ask it.
6
+ * GETs `/manifest.json` with a short timeout; any non-2xx, network error, or
7
+ * timeout reads as "not running". Deliberately cheap (single request, 3s cap)
8
+ * because it runs on the product-list render path.
9
+ */
10
+ export declare function isDevUrlAlive(baseUrl: string): Promise<boolean>;
11
+ export declare function specBaseUrl(spec: ProductSpec, port?: number): string;
12
+ export declare function fetchManifest(baseUrl: string): Promise<Manifest>;
13
+ export declare function waitForReady(baseUrl: string): Promise<void>;
14
+ /**
15
+ * Probe the product's configScreenUrl after registration. Two hard
16
+ * rejections:
17
+ * - CONFIG_SCREEN_UNREACHABLE: non-2xx after one redirect hop. Catches
18
+ * typo'd URLs and manifests that lie about what's exposed.
19
+ * - CONFIG_SCREEN_DEV_SERVER: response body has vite-dev fingerprints
20
+ * (`/@vite/client`, `src="/src/...`). Iframed product UIs need the
21
+ * built bundle — vite-dev paths are absolute on the product's origin
22
+ * and resolve against the *runner's* origin once iframed, so the
23
+ * wrong scripts load. Operators should register the backend port
24
+ * that serves the production build.
25
+ *
26
+ * Soft warnings (future) would return via the unused return-value slot
27
+ * here. For now both detections are hard fails.
28
+ */
29
+ export declare function probeConfigScreen(baseUrl: string, configScreenUrl: string): Promise<void>;
@@ -0,0 +1,100 @@
1
+ import { ManifestSchema } from "./manifest-schema.js";
2
+ import { ProductError } from "./product-error.js";
3
+ const READINESS_TIMEOUT_MS = 60_000;
4
+ const READINESS_INTERVAL_MS = 250;
5
+ const LIVENESS_TIMEOUT_MS = 3_000;
6
+ /**
7
+ * Fast liveness probe for a registered product. A dev-mode product is
8
+ * externally owned, so the only way to know it's actually up is to ask it.
9
+ * GETs `/manifest.json` with a short timeout; any non-2xx, network error, or
10
+ * timeout reads as "not running". Deliberately cheap (single request, 3s cap)
11
+ * because it runs on the product-list render path.
12
+ */
13
+ export async function isDevUrlAlive(baseUrl) {
14
+ try {
15
+ const r = await fetch(`${baseUrl}/manifest.json`, { signal: AbortSignal.timeout(LIVENESS_TIMEOUT_MS) });
16
+ return r.ok;
17
+ }
18
+ catch {
19
+ return false;
20
+ }
21
+ }
22
+ export function specBaseUrl(spec, port) {
23
+ if (spec.kind === "dev")
24
+ return spec.url.replace(/\/$/, "");
25
+ if (port === undefined)
26
+ throw new Error("container spec requires port");
27
+ return `http://127.0.0.1:${port}`;
28
+ }
29
+ export async function fetchManifest(baseUrl) {
30
+ let response;
31
+ try {
32
+ response = await fetch(`${baseUrl}/manifest.json`);
33
+ }
34
+ catch (e) {
35
+ throw new ProductError("MANIFEST_FETCH_FAILED", `manifest fetch failed: ${String(e)}`);
36
+ }
37
+ if (!response.ok) {
38
+ throw new ProductError("MANIFEST_FETCH_FAILED", `manifest.json returned ${response.status}`);
39
+ }
40
+ const json = await response.json().catch(() => null);
41
+ const parsed = ManifestSchema.safeParse(json);
42
+ if (!parsed.success) {
43
+ throw new ProductError("MANIFEST_INVALID", `manifest validation failed: ${parsed.error.message}`);
44
+ }
45
+ return parsed.data;
46
+ }
47
+ export async function waitForReady(baseUrl) {
48
+ const deadline = Date.now() + READINESS_TIMEOUT_MS;
49
+ while (Date.now() < deadline) {
50
+ try {
51
+ const r = await fetch(`${baseUrl}/manifest.json`);
52
+ if (r.ok)
53
+ return;
54
+ }
55
+ catch {
56
+ // not ready yet
57
+ }
58
+ await new Promise((resolve) => setTimeout(resolve, READINESS_INTERVAL_MS));
59
+ }
60
+ throw new ProductError("READINESS_TIMEOUT", `product did not become ready within ${READINESS_TIMEOUT_MS}ms`);
61
+ }
62
+ /**
63
+ * Probe the product's configScreenUrl after registration. Two hard
64
+ * rejections:
65
+ * - CONFIG_SCREEN_UNREACHABLE: non-2xx after one redirect hop. Catches
66
+ * typo'd URLs and manifests that lie about what's exposed.
67
+ * - CONFIG_SCREEN_DEV_SERVER: response body has vite-dev fingerprints
68
+ * (`/@vite/client`, `src="/src/...`). Iframed product UIs need the
69
+ * built bundle — vite-dev paths are absolute on the product's origin
70
+ * and resolve against the *runner's* origin once iframed, so the
71
+ * wrong scripts load. Operators should register the backend port
72
+ * that serves the production build.
73
+ *
74
+ * Soft warnings (future) would return via the unused return-value slot
75
+ * here. For now both detections are hard fails.
76
+ */
77
+ export async function probeConfigScreen(baseUrl, configScreenUrl) {
78
+ const path = configScreenUrl.startsWith("/") ? configScreenUrl : `/${configScreenUrl}`;
79
+ const url = `${baseUrl}${path}`;
80
+ let response;
81
+ try {
82
+ response = await fetch(url, { redirect: "follow" });
83
+ }
84
+ catch (e) {
85
+ throw new ProductError("CONFIG_SCREEN_UNREACHABLE", `configScreenUrl '${path}' at ${baseUrl} unreachable: ${String(e)}`);
86
+ }
87
+ if (!response.ok) {
88
+ throw new ProductError("CONFIG_SCREEN_UNREACHABLE", `configScreenUrl '${path}' returned ${response.status}`);
89
+ }
90
+ const contentType = response.headers.get("content-type") ?? "";
91
+ if (contentType.toLowerCase().includes("text/html")) {
92
+ const body = await response.text().catch(() => "");
93
+ if (body.includes("/@vite/client") || /\bsrc="\/src\//.test(body)) {
94
+ throw new ProductError("CONFIG_SCREEN_DEV_SERVER", `configScreenUrl '${path}' at ${baseUrl} appears to be served by a ` +
95
+ `vite dev server. Iframed product UIs need the built bundle — ` +
96
+ `register the backend port that serves the production build, not ` +
97
+ `the vite dev port.`);
98
+ }
99
+ }
100
+ }
@@ -0,0 +1,11 @@
1
+ import { Router } from "express";
2
+ import type { Manifest } from "./manifest-schema.js";
3
+ /**
4
+ * Serves a product's manifest at `GET /manifest.json`, relative to wherever the
5
+ * router is mounted.
6
+ *
7
+ * Unlike `createOpenapiRouter`, the manifest is NOT cached: the forked
8
+ * per-product code rebuilt it on every request, and this preserves that
9
+ * behaviour. Building a manifest is cheap, so per-request cost is negligible.
10
+ */
11
+ export declare function createManifestRouter(buildManifest: () => Manifest): Router;
@@ -0,0 +1,16 @@
1
+ import { Router } from "express";
2
+ /**
3
+ * Serves a product's manifest at `GET /manifest.json`, relative to wherever the
4
+ * router is mounted.
5
+ *
6
+ * Unlike `createOpenapiRouter`, the manifest is NOT cached: the forked
7
+ * per-product code rebuilt it on every request, and this preserves that
8
+ * behaviour. Building a manifest is cheap, so per-request cost is negligible.
9
+ */
10
+ export function createManifestRouter(buildManifest) {
11
+ const router = Router();
12
+ router.get("/manifest.json", (_req, res) => {
13
+ res.json(buildManifest());
14
+ });
15
+ return router;
16
+ }
@@ -0,0 +1,113 @@
1
+ import { z } from "zod";
2
+ /** Per-entry shape inside `defaultProductTemplates`. The runner fetches each URL
3
+ * (resolved relative to the product's base) at registration time and
4
+ * stores the returned product-template tar under `name`. Lets a product ship
5
+ * pre-canned product templates so operators get a launchable instance without
6
+ * visiting the configure screen — "add product, run it". */
7
+ declare const DefaultProductTemplateSchema: z.ZodObject<{
8
+ name: z.ZodString;
9
+ url: z.ZodString;
10
+ }, z.core.$strip>;
11
+ export type DefaultProductTemplate = z.infer<typeof DefaultProductTemplateSchema>;
12
+ export declare const ManifestSchema: z.ZodObject<{
13
+ manifestSchemaVersion: z.ZodLiteral<1>;
14
+ name: z.ZodString;
15
+ version: z.ZodString;
16
+ minRunnerVersion: z.ZodOptional<z.ZodString>;
17
+ api: z.ZodObject<{
18
+ basePath: z.ZodString;
19
+ proxyPaths: z.ZodArray<z.ZodString>;
20
+ openapiFragmentPath: z.ZodString;
21
+ mcpPath: z.ZodOptional<z.ZodString>;
22
+ healthCheckPath: z.ZodDefault<z.ZodString>;
23
+ productMcpPath: z.ZodOptional<z.ZodString>;
24
+ }, z.core.$strip>;
25
+ ui: z.ZodObject<{
26
+ configScreenUrl: z.ZodString;
27
+ instanceConfigScreenUrl: z.ZodOptional<z.ZodString>;
28
+ sidebarEntries: z.ZodDefault<z.ZodArray<z.ZodObject<{
29
+ label: z.ZodString;
30
+ icon: z.ZodOptional<z.ZodString>;
31
+ route: z.ZodString;
32
+ }, z.core.$strip>>>;
33
+ dashboardWidgets: z.ZodDefault<z.ZodArray<z.ZodObject<{
34
+ id: z.ZodString;
35
+ title: z.ZodString;
36
+ url: z.ZodString;
37
+ }, z.core.$strip>>>;
38
+ productTemplateActions: z.ZodDefault<z.ZodArray<z.ZodObject<{
39
+ label: z.ZodString;
40
+ configScreenUrl: z.ZodString;
41
+ }, z.core.$strip>>>;
42
+ }, z.core.$strip>;
43
+ cli: z.ZodObject<{
44
+ subcommandTree: z.ZodDefault<z.ZodArray<z.ZodObject<{
45
+ name: z.ZodString;
46
+ description: z.ZodString;
47
+ flags: z.ZodDefault<z.ZodArray<z.ZodObject<{
48
+ name: z.ZodString;
49
+ description: z.ZodString;
50
+ required: z.ZodOptional<z.ZodBoolean>;
51
+ type: z.ZodOptional<z.ZodEnum<{
52
+ string: "string";
53
+ number: "number";
54
+ boolean: "boolean";
55
+ }>>;
56
+ }, z.core.$strip>>>;
57
+ subcommands: z.ZodDefault<z.ZodArray<z.ZodObject<{
58
+ name: z.ZodString;
59
+ description: z.ZodString;
60
+ flags: z.ZodDefault<z.ZodArray<z.ZodObject<{
61
+ name: z.ZodString;
62
+ description: z.ZodString;
63
+ required: z.ZodOptional<z.ZodBoolean>;
64
+ type: z.ZodOptional<z.ZodEnum<{
65
+ string: "string";
66
+ number: "number";
67
+ boolean: "boolean";
68
+ }>>;
69
+ }, z.core.$strip>>>;
70
+ request: z.ZodOptional<z.ZodObject<{
71
+ method: z.ZodEnum<{
72
+ GET: "GET";
73
+ POST: "POST";
74
+ PUT: "PUT";
75
+ DELETE: "DELETE";
76
+ }>;
77
+ path: z.ZodString;
78
+ }, z.core.$strip>>;
79
+ }, z.core.$strip>>>;
80
+ request: z.ZodOptional<z.ZodObject<{
81
+ method: z.ZodEnum<{
82
+ GET: "GET";
83
+ POST: "POST";
84
+ PUT: "PUT";
85
+ DELETE: "DELETE";
86
+ }>;
87
+ path: z.ZodString;
88
+ }, z.core.$strip>>;
89
+ }, z.core.$strip>>>;
90
+ }, z.core.$strip>;
91
+ targets: z.ZodArray<z.ZodEnum<{
92
+ "norsk-ctl": "norsk-ctl";
93
+ "docker-compose": "docker-compose";
94
+ }>>;
95
+ components: z.ZodDefault<z.ZodArray<z.ZodObject<{
96
+ name: z.ZodString;
97
+ version: z.ZodString;
98
+ description: z.ZodString;
99
+ }, z.core.$strip>>>;
100
+ runtime: z.ZodDefault<z.ZodObject<{
101
+ sharedWorkingDirectory: z.ZodDefault<z.ZodBoolean>;
102
+ }, z.core.$strip>>;
103
+ defaultProductTemplates: z.ZodDefault<z.ZodArray<z.ZodObject<{
104
+ name: z.ZodString;
105
+ url: z.ZodString;
106
+ }, z.core.$strip>>>;
107
+ export: z.ZodOptional<z.ZodObject<{
108
+ include: z.ZodDefault<z.ZodArray<z.ZodString>>;
109
+ exclude: z.ZodDefault<z.ZodArray<z.ZodString>>;
110
+ }, z.core.$strip>>;
111
+ }, z.core.$strip>;
112
+ export type Manifest = z.infer<typeof ManifestSchema>;
113
+ export {};
@@ -0,0 +1,135 @@
1
+ import { z } from "zod";
2
+ const TargetSchema = z.enum(["norsk-ctl", "docker-compose"]);
3
+ const SidebarEntrySchema = z.object({
4
+ label: z.string(),
5
+ icon: z.string().optional(),
6
+ route: z.string(),
7
+ });
8
+ const DashboardWidgetSchema = z.object({
9
+ id: z.string(),
10
+ title: z.string(),
11
+ url: z.string(),
12
+ });
13
+ /** A product-specific entry point for building a particular kind of product
14
+ * template (e.g. Studio's "Add dev template" / "Add examples"). The runner
15
+ * renders one button per action on the product's hub; clicking opens the build
16
+ * form at the action's `configScreenUrl`, so the product owns what each one
17
+ * configures. */
18
+ const ProductTemplateActionSchema = z.object({
19
+ label: z.string(),
20
+ configScreenUrl: z.string().meta({
21
+ description: "Config screen URL (on the product's HTTP surface) opened to build this kind of product template.",
22
+ }),
23
+ });
24
+ const ComponentDescSchema = z.object({
25
+ name: z.string(),
26
+ version: z.string(),
27
+ description: z.string(),
28
+ });
29
+ const CliFlagSchema = z.object({
30
+ name: z.string(),
31
+ description: z.string(),
32
+ required: z.boolean().optional(),
33
+ type: z.enum(["string", "number", "boolean"]).optional(),
34
+ });
35
+ /** How a CLI leaf forwards to the product's HTTP surface. The runner registers
36
+ * a yargs command that issues `method` against `/products/<name><path>` (the
37
+ * product proxy resolves the rest), mapping flags to a JSON body for
38
+ * POST/PUT or a query string for GET/DELETE. Absent on grouping commands
39
+ * that only namespace subcommands. */
40
+ const CliRequestSchema = z.object({
41
+ method: z.enum(["GET", "POST", "PUT", "DELETE"]),
42
+ path: z.string().meta({
43
+ description: "Instance-relative path on the product's HTTP surface (e.g. /api/plugins/create). The runner prefixes /products/<name>.",
44
+ }),
45
+ });
46
+ const CliLeafSchema = z.object({
47
+ name: z.string(),
48
+ description: z.string(),
49
+ flags: z.array(CliFlagSchema).default([]),
50
+ request: CliRequestSchema.optional(),
51
+ });
52
+ const CliCommandSchema = z.object({
53
+ name: z.string(),
54
+ description: z.string(),
55
+ flags: z.array(CliFlagSchema).default([]),
56
+ subcommands: z.array(CliLeafSchema).default([]),
57
+ request: CliRequestSchema.optional(),
58
+ });
59
+ /** Runtime hints from the product manifest. Drives launch-time validation
60
+ * on instances launched from a product template — e.g. whether two instances
61
+ * may share a working directory. Optional in v1 manifests; defaults are safe. */
62
+ const RuntimeHintsSchema = z
63
+ .object({
64
+ sharedWorkingDirectory: z.boolean().default(false),
65
+ })
66
+ .default({ sharedWorkingDirectory: false });
67
+ /** Per-entry shape inside `defaultProductTemplates`. The runner fetches each URL
68
+ * (resolved relative to the product's base) at registration time and
69
+ * stores the returned product-template tar under `name`. Lets a product ship
70
+ * pre-canned product templates so operators get a launchable instance without
71
+ * visiting the configure screen — "add product, run it". */
72
+ const DefaultProductTemplateSchema = z.object({
73
+ name: z
74
+ .string()
75
+ .regex(/^[a-z0-9][a-z0-9-]{0,62}$/, "product template name must be lowercase letters, digits, hyphens; 1-63 chars"),
76
+ url: z.string().meta({
77
+ description: "Path on the product's HTTP surface that returns the product-template tar. Resolved against the product's base URL — relative paths only (no scheme/host).",
78
+ }),
79
+ });
80
+ /** Save-as-product-template file selection. When the runner exports a running
81
+ * instance's working directory into a new product template, it packs only files
82
+ * matching `include` and not matching `exclude` (glob patterns, relative to the
83
+ * working directory). Lets a product keep the built assets worth sharing
84
+ * (workflow, compiled dashboards/components) and drop dev cruft (recordings,
85
+ * node_modules) without the runner knowing the product's layout. */
86
+ const ExportSchema = z.object({
87
+ include: z.array(z.string()).default([]),
88
+ exclude: z.array(z.string()).default([]),
89
+ });
90
+ export const ManifestSchema = z.object({
91
+ manifestSchemaVersion: z.literal(1),
92
+ name: z.string(),
93
+ version: z.string(),
94
+ minRunnerVersion: z.string().optional(),
95
+ api: z.object({
96
+ basePath: z.string(),
97
+ proxyPaths: z.array(z.string()),
98
+ openapiFragmentPath: z.string(),
99
+ mcpPath: z.string().optional().meta({
100
+ description: "Instance-relative path on the product's HTTP surface where it serves its MCP endpoint. Lets the proxy discover the MCP from product registration. Omit for products without an MCP.",
101
+ }),
102
+ healthCheckPath: z.string().default("/healthz").meta({
103
+ description: "Path on the product's HTTP surface the runner polls for liveness. The daemon's health monitor GETs it on an interval and auto-restarts the container after repeated failures. Defaults to /healthz, which the SDK product scaffold serves.",
104
+ }),
105
+ productMcpPath: z.string().optional().meta({
106
+ description: "Path on the product control-plane's HTTP surface where it serves an always-on, instance-independent MCP endpoint (e.g. plugin scaffolding). Proxied as `<product>_<tool>`. Distinct from the per-instance `mcpPath`. Omit for products without one.",
107
+ }),
108
+ }),
109
+ ui: z.object({
110
+ configScreenUrl: z.string(),
111
+ // Optional rich instance-launch config screen. When present, the runner's
112
+ // launch flow probes it (a GET carrying product-template context); the
113
+ // product returns 200 to have the runner iframe it in place of the
114
+ // auto-generated parameter form, or 204/404 to fall back to that form. Lets
115
+ // a product present a per-template launch UI (e.g. probe's wall layout
116
+ // designer) only where it applies, leaving other templates on the default
117
+ // form.
118
+ instanceConfigScreenUrl: z.string().optional().meta({
119
+ description: "Optional URL (on the product's HTTP surface) of a rich instance-launch config screen. Probed per product template: 200 = iframe it, 204/404 = use the default parameter form.",
120
+ }),
121
+ sidebarEntries: z.array(SidebarEntrySchema).default([]),
122
+ dashboardWidgets: z.array(DashboardWidgetSchema).default([]),
123
+ productTemplateActions: z.array(ProductTemplateActionSchema).default([]),
124
+ }),
125
+ cli: z.object({
126
+ subcommandTree: z.array(CliCommandSchema).default([]),
127
+ }),
128
+ targets: z.array(TargetSchema),
129
+ components: z.array(ComponentDescSchema).default([]),
130
+ runtime: RuntimeHintsSchema,
131
+ defaultProductTemplates: z.array(DefaultProductTemplateSchema).default([]).meta({
132
+ description: "Product templates the runner auto-fetches at product-registration time. Each entry is GET'd against the product's base URL and stored under its `name` via the runner's product-template store. Lets a product offer 'zero-config' launch paths — operators add the product and immediately have a launchable product template without visiting configure.",
133
+ }),
134
+ export: ExportSchema.optional(),
135
+ });
@@ -0,0 +1,28 @@
1
+ import { type Result } from "@norskvideo/ctl-foundation";
2
+ import { z } from "zod";
3
+ export declare const SourceSchema: z.ZodEnum<{
4
+ inherited: "inherited";
5
+ "nightly-baseline": "nightly-baseline";
6
+ }>;
7
+ export type Source = z.infer<typeof SourceSchema>;
8
+ export declare const ChannelEntrySchema: z.ZodObject<{
9
+ media: z.ZodString;
10
+ studio: z.ZodString;
11
+ source: z.ZodEnum<{
12
+ inherited: "inherited";
13
+ "nightly-baseline": "nightly-baseline";
14
+ }>;
15
+ ratifiedAt: z.ZodOptional<z.ZodString>;
16
+ }, z.core.$strict>;
17
+ export type ChannelEntry = z.infer<typeof ChannelEntrySchema>;
18
+ export declare const SeedSchema: z.ZodRecord<z.ZodString, z.ZodObject<{
19
+ media: z.ZodString;
20
+ studio: z.ZodString;
21
+ source: z.ZodEnum<{
22
+ inherited: "inherited";
23
+ "nightly-baseline": "nightly-baseline";
24
+ }>;
25
+ ratifiedAt: z.ZodOptional<z.ZodString>;
26
+ }, z.core.$strict>>;
27
+ export type ManifestSeed = z.infer<typeof SeedSchema>;
28
+ export declare function parseManifestSeed(raw: unknown): Result<ManifestSeed, string>;