@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.
- package/base.css +53 -0
- package/browser.d.ts +8 -0
- package/browser.js +11 -0
- package/capabilities-router.d.ts +9 -0
- package/capabilities-router.js +15 -0
- package/cjs-interop.d.ts +33 -0
- package/cjs-interop.js +61 -0
- package/components/ProductIframe.d.ts +37 -0
- package/components/ProductIframe.js +119 -0
- package/components/ProductTemplateBuildForm.d.ts +40 -0
- package/components/ProductTemplateBuildForm.js +81 -0
- package/components/index.d.ts +3 -0
- package/components/index.js +3 -0
- package/components/ui-primitives.d.ts +22 -0
- package/components/ui-primitives.js +13 -0
- package/dev-url.d.ts +1 -0
- package/dev-url.js +14 -0
- package/docker-runner.d.ts +20 -0
- package/docker-runner.js +54 -0
- package/fonts/Geist-LICENSE.txt +92 -0
- package/fonts/Geist.woff2 +0 -0
- package/fonts/GeistMono.woff2 +0 -0
- package/fonts/README.md +21 -0
- package/fonts/STUDIO-FONT-SYNC.md +86 -0
- package/index.d.ts +15 -0
- package/index.js +17 -0
- package/license-registration.d.ts +52 -0
- package/license-registration.js +38 -0
- package/license-stager.d.ts +30 -0
- package/license-stager.js +118 -0
- package/license-v2.d.ts +107 -0
- package/license-v2.js +205 -0
- package/manifest-fetch.d.ts +29 -0
- package/manifest-fetch.js +100 -0
- package/manifest-router.d.ts +11 -0
- package/manifest-router.js +16 -0
- package/manifest-schema.d.ts +113 -0
- package/manifest-schema.js +135 -0
- package/manifest-seed.d.ts +28 -0
- package/manifest-seed.js +40 -0
- package/openapi-router.d.ts +12 -0
- package/openapi-router.js +21 -0
- package/package.json +46 -0
- package/parsing.d.ts +9 -0
- package/parsing.js +83 -0
- package/product-error.d.ts +4 -0
- package/product-error.js +8 -0
- package/product-health-monitor.d.ts +72 -0
- package/product-health-monitor.js +136 -0
- package/product-service.d.ts +118 -0
- package/product-service.js +340 -0
- package/product-template-error.d.ts +10 -0
- package/product-template-error.js +14 -0
- package/product-template-materials.d.ts +17 -0
- package/product-template-materials.js +51 -0
- package/product-template-parsing.d.ts +14 -0
- package/product-template-parsing.js +66 -0
- package/product-template-record.d.ts +45 -0
- package/product-template-record.js +1 -0
- package/product-types.d.ts +31 -0
- package/product-types.js +1 -0
- package/proxy-middleware.d.ts +7 -0
- package/proxy-middleware.js +112 -0
- package/runtime.d.ts +2 -0
- package/runtime.js +21 -0
- package/validate.d.ts +3 -0
- package/validate.js +22 -0
- package/workflow.d.ts +60 -0
- 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>;
|