@norskvideo/ctl-product-template-schema 0.1.15 → 0.1.17

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/index.d.ts CHANGED
@@ -3,6 +3,7 @@ export declare const ProductTemplateTargetSchema: z.ZodEnum<{
3
3
  "docker-compose": "docker-compose";
4
4
  }>;
5
5
  export type ProductTemplateTarget = z.infer<typeof ProductTemplateTargetSchema>;
6
+ export declare const RESERVED_INSTANCE_ROUTE_SEGMENTS: readonly ["studio", "media", "visualiser", "uvis", "status", "metrics"];
6
7
  export declare const PRODUCT_TEMPLATE_ADVANCED_NETWORK_MODES: readonly ["docker", "hybrid"];
7
8
  export declare const ProductTemplateAdvancedSchema: z.ZodObject<{
8
9
  networkMode: z.ZodOptional<z.ZodObject<{
@@ -123,7 +124,17 @@ export declare const ProductTemplateManifestSchema: z.ZodObject<{
123
124
  }, z.core.$strip>>;
124
125
  proxy: z.ZodOptional<z.ZodObject<{
125
126
  expose: z.ZodDefault<z.ZodArray<z.ZodString>>;
127
+ routes: z.ZodOptional<z.ZodArray<z.ZodObject<{
128
+ segment: z.ZodString;
129
+ service: z.ZodString;
130
+ port: z.ZodOptional<z.ZodNumber>;
131
+ websocket: z.ZodOptional<z.ZodBoolean>;
132
+ auth: z.ZodOptional<z.ZodBoolean>;
133
+ }, z.core.$strip>>>;
126
134
  }, z.core.$strip>>;
135
+ allowWorkflowUploads: z.ZodOptional<z.ZodBoolean>;
136
+ isStudio: z.ZodOptional<z.ZodBoolean>;
137
+ supportsCustomAssets: z.ZodOptional<z.ZodBoolean>;
127
138
  requiresWorkingDirectory: z.ZodOptional<z.ZodBoolean>;
128
139
  sideload: z.ZodOptional<z.ZodObject<{
129
140
  gpuInference: z.ZodOptional<z.ZodBoolean>;
@@ -291,7 +302,17 @@ export declare const ProductTemplateMaterialsSchema: z.ZodObject<{
291
302
  }, z.core.$strip>>;
292
303
  proxy: z.ZodOptional<z.ZodObject<{
293
304
  expose: z.ZodDefault<z.ZodArray<z.ZodString>>;
305
+ routes: z.ZodOptional<z.ZodArray<z.ZodObject<{
306
+ segment: z.ZodString;
307
+ service: z.ZodString;
308
+ port: z.ZodOptional<z.ZodNumber>;
309
+ websocket: z.ZodOptional<z.ZodBoolean>;
310
+ auth: z.ZodOptional<z.ZodBoolean>;
311
+ }, z.core.$strip>>>;
294
312
  }, z.core.$strip>>;
313
+ allowWorkflowUploads: z.ZodOptional<z.ZodBoolean>;
314
+ isStudio: z.ZodOptional<z.ZodBoolean>;
315
+ supportsCustomAssets: z.ZodOptional<z.ZodBoolean>;
295
316
  requiresWorkingDirectory: z.ZodOptional<z.ZodBoolean>;
296
317
  sideload: z.ZodOptional<z.ZodObject<{
297
318
  gpuInference: z.ZodOptional<z.ZodBoolean>;
package/index.js CHANGED
@@ -50,10 +50,48 @@ const ProductTemplateDebugSchema = z.object({
50
50
  .optional()
51
51
  .meta({ description: "Show the Debug menu's 'Open Visualiser' link. Default true." }),
52
52
  });
53
+ // Path segments the runner owns under /instance/<id>/ and a product may never
54
+ // claim. They are the six base routes instance-routes.ts always emits; a
55
+ // declared route colliding with one would be shadowed by nginx's more-specific
56
+ // match and silently never serve, so the schema rejects it instead.
57
+ export const RESERVED_INSTANCE_ROUTE_SEGMENTS = ["studio", "media", "visualiser", "uvis", "status", "metrics"];
58
+ // One additional HTTP service a product wants behind the runner's front door,
59
+ // beside the runtime-screen catch-all. WHY THIS EXISTS: a product's second UI
60
+ // otherwise needs a published host port, and a plain http host port is a secure
61
+ // context on localhost only — browser APIs like WebCodecs fail from any other
62
+ // machine. The catch-all is a single slot, so a product with two UIs used to
63
+ // have to reverse-proxy one behind the other itself, websocket bridging
64
+ // included.
65
+ const ProductTemplateRouteSchema = z.object({
66
+ segment: z
67
+ .string()
68
+ .regex(/^[a-z0-9][a-z0-9-]*$/, "one lowercase path segment: letters, digits and dashes")
69
+ .refine((s) => !RESERVED_INSTANCE_ROUTE_SEGMENTS.includes(s), {
70
+ message: `segment is reserved by the runner (${RESERVED_INSTANCE_ROUTE_SEGMENTS.join(", ")})`,
71
+ })
72
+ .meta({
73
+ description: "Single path segment under the instance root. The runner serves it at /instance/<id>/<segment>/. Must not be one of the runner's reserved segments (studio, media, visualiser, uvis, status, metrics).",
74
+ }),
75
+ service: z.string().min(1).meta({
76
+ description: "Compose service that serves this route. Must be a service in the template's own compose.yml; the runner dials it by its compose container name.",
77
+ }),
78
+ port: z.number().int().positive().optional().meta({
79
+ description: "Container port to dial. Omit for the conventional 8000.",
80
+ }),
81
+ websocket: z.boolean().optional().meta({
82
+ description: "Emit the Upgrade/Connection proxy headers so this route can carry websockets. Omit for a plain HTTP route. HTTP is always served; this only adds the upgrade path, so a route carrying both needs just this flag.",
83
+ }),
84
+ auth: z.boolean().optional().meta({
85
+ description: "Whether the route sits behind the runner's oauth2 auth_request. Omit for the default `true`. Set false only for a surface that must be reachable unauthenticated, as the media route is.",
86
+ }),
87
+ });
53
88
  const ProductTemplateProxySchema = z.object({
54
89
  expose: z.array(z.string()).default([]).meta({
55
90
  description: "Paths (or globs) the runner's nginx routes through to this instance. Anything not in the list returns 404 from the proxy. Glob-shaped strings (`/static/*`) are accepted as hints — current implementation treats the whole set as a signal to mount a catch-all on the instance root; per-path filtering can come later.",
56
91
  }),
92
+ routes: z.array(ProductTemplateRouteSchema).optional().meta({
93
+ description: "Extra named services to route under the instance root, beside the runtime-screen catch-all. Additive and optional: a template that omits it routes exactly as before, so an older runner ignoring the field still launches the product.",
94
+ }),
57
95
  });
58
96
  // product-template-schema cannot import @norsk-ctl/shared (dependency direction), so
59
97
  // this is a literal copy of the runner's network-mode enum — and the runner
@@ -218,6 +256,19 @@ export const ProductTemplateManifestSchema = z
218
256
  description: "Per-instance Debug menu toggles. Each of `studio`/`visualiser` defaults to shown; set false to hide a redundant link (e.g. Studio hides its own 'Open Studio').",
219
257
  }),
220
258
  proxy: ProductTemplateProxySchema.optional(),
259
+ allowWorkflowUploads: z.boolean().optional().meta({
260
+ description: "Whether the operator may upload workflow documents for this product. They land in the working directory's studio-save-files/, where Studio lists them for the operator to open — so this is for Norsk Studio itself (the editor), not for appliances built on it: an appliance (probe, playout) generates its one workflow from its config screen and has nothing to select. Absent = false. Assets are governed separately by `supportsCustomAssets`.",
261
+ }),
262
+ /** @deprecated Renamed to `allowWorkflowUploads`, which says what it
263
+ * gates. Read-side only: existing stored product templates carry it, and
264
+ * dropping it would silently un-set the flag on a real install. Nothing
265
+ * writes it. */
266
+ isStudio: z.boolean().optional().meta({
267
+ description: "DEPRECATED — renamed to `allowWorkflowUploads`. Accepted on read so product templates built before the rename keep working; nothing writes it.",
268
+ }),
269
+ supportsCustomAssets: z.boolean().optional().meta({
270
+ description: "Whether the operator may attach their own files (media, overlays, rundowns) to the working directory. Absent = true: every product gets a working directory, so the asset surface is universal and a product opts OUT rather than in. Assets land at operator-named workdir-relative paths and are also surfaced as NORSK_WORKDIR_ASSETS.",
271
+ }),
221
272
  requiresWorkingDirectory: z.boolean().optional().meta({
222
273
  description: "When true, the operator must choose a working directory at launch (a dev product template they own and keep), rather than getting the auto-assigned per-instance default. The runner's launch UI makes the working-directory field mandatory.",
223
274
  }),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@norskvideo/ctl-product-template-schema",
3
- "version": "0.1.15",
3
+ "version": "0.1.17",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {
@@ -18,6 +18,14 @@
18
18
  "./read": {
19
19
  "types": "./read.d.ts",
20
20
  "default": "./read.js"
21
+ },
22
+ "./workdir-fetch": {
23
+ "types": "./workdir-fetch.d.ts",
24
+ "default": "./workdir-fetch.js"
25
+ },
26
+ "./workdir-path": {
27
+ "types": "./workdir-path.d.ts",
28
+ "default": "./workdir-path.js"
21
29
  }
22
30
  },
23
31
  "main": "./index.js",
@@ -0,0 +1,30 @@
1
+ import { z } from "zod";
2
+ /** Reserved parameter carrying the JSON-encoded fetch list. Infra-injected —
3
+ * `invalidParameterNames` rejects operator entries in the NORSK_ namespace. */
4
+ export declare const WORKDIR_FETCH_PARAM = "NORSK_WORKDIR_FETCH";
5
+ export declare const WorkdirFetchEntrySchema: z.ZodObject<{
6
+ path: z.ZodString;
7
+ url: z.ZodString;
8
+ name: z.ZodOptional<z.ZodString>;
9
+ sha256: z.ZodOptional<z.ZodString>;
10
+ optional: z.ZodOptional<z.ZodBoolean>;
11
+ }, z.core.$strip>;
12
+ export type WorkdirFetchEntry = z.infer<typeof WorkdirFetchEntrySchema>;
13
+ export declare const WorkdirFetchListSchema: z.ZodArray<z.ZodObject<{
14
+ path: z.ZodString;
15
+ url: z.ZodString;
16
+ name: z.ZodOptional<z.ZodString>;
17
+ sha256: z.ZodOptional<z.ZodString>;
18
+ optional: z.ZodOptional<z.ZodBoolean>;
19
+ }, z.core.$strip>>;
20
+ export type WorkdirFetchList = z.infer<typeof WorkdirFetchListSchema>;
21
+ /** Parse the reserved parameter's value. Returns [] for an absent/blank
22
+ * value; a malformed one is an error the caller reports rather than a
23
+ * silent empty list — a typo'd list must not look like "no assets". */
24
+ export declare function parseWorkdirFetchParam(raw: string | undefined): WorkdirFetchList;
25
+ export declare function encodeWorkdirFetchParam(list: WorkdirFetchList): string;
26
+ export { assertSafeWorkdirPath, checkWorkdirPath, type WorkdirPathProblem, } from "./workdir-path.js";
27
+ /** Stack the tiers — product template < production template < launch — the
28
+ * same way the parameter tiers stack. Later layers win by `path`, and the
29
+ * winner keeps its position so the resolved order stays readable in the UI. */
30
+ export declare function mergeWorkdirFetchLayers(...layers: (WorkdirFetchList | undefined)[]): WorkdirFetchList;
@@ -0,0 +1,82 @@
1
+ // Working-directory fetch list — the per-JOB channel for operator content
2
+ // (assets, media, rundowns) that must land in an instance's working
3
+ // directory on an ephemeral cloud node.
4
+ //
5
+ // Design: norsk-mgr-deploy/docs/design/working-directory-and-assets.md.
6
+ // The list rides the job rather than the product template's tar, which is
7
+ // what keeps the tar immutable: `ProductTemplateRef.sha256` names the on-disk
8
+ // extract on the worker (`worker-<sha12>`) and a repeat import is skipped as
9
+ // NAME_CONFLICT, so mutating a template's bytes would silently give two nodes
10
+ // different content for the same production.
11
+ //
12
+ // Transport is the existing `product_template_parameters` map<string,string>
13
+ // under the reserved name below — no proto change. mgr builds and interpolates
14
+ // the list at launch (where ${production}/${role}/${index} are in scope) and
15
+ // the worker resolves it after seeding, before compose up.
16
+ //
17
+ // This module is on the browser side of the barrel (mgr's composer imports the
18
+ // schema to validate operator input), so: zod only, no node builtins.
19
+ import { z } from "zod";
20
+ import { checkWorkdirPath } from "./workdir-path.js";
21
+ /** Reserved parameter carrying the JSON-encoded fetch list. Infra-injected —
22
+ * `invalidParameterNames` rejects operator entries in the NORSK_ namespace. */
23
+ export const WORKDIR_FETCH_PARAM = "NORSK_WORKDIR_FETCH";
24
+ const SHA256 = z.string().regex(/^[0-9a-f]{64}$/, "lowercase-hex sha256");
25
+ export const WorkdirFetchEntrySchema = z
26
+ .object({
27
+ path: z.string().meta({
28
+ description: "Destination relative to the instance working directory. Must survive assertSafeWorkdirPath: no absolute paths, no '..', and nothing landing under a runner-managed read-only mount point.",
29
+ }),
30
+ url: z.string().meta({
31
+ description: "Where the bytes come from: https:// (plain fetch), s3:// (resolved with the worker's instance role — never credentials in a template), or an absolute mgr URL for operator-uploaded bytes. May carry interpolation tokens (production, runtime, index, role); mgr resolves them before this reaches the wire.",
32
+ }),
33
+ name: z.string().optional().meta({
34
+ description: "Display label for the manager UI. Never a lookup key: assets simply land in the working directory (mounted at /data), and a product may create or rename them at runtime, so no name→path map is meaningful at template time.",
35
+ }),
36
+ sha256: SHA256.optional().meta({
37
+ description: "Content hash. When present the worker verifies the download and caches it content-addressed, so N nodes of a count-launch share one fetch.",
38
+ }),
39
+ optional: z.boolean().optional().meta({
40
+ description: "When true a missing object (404 / NoSuchKey) is not a launch failure — the entry is skipped. For interpolated per-event URLs that may legitimately not exist yet.",
41
+ }),
42
+ })
43
+ .meta({ id: "WorkdirFetchEntry", outputId: "WorkdirFetchEntry" });
44
+ export const WorkdirFetchListSchema = z.array(WorkdirFetchEntrySchema);
45
+ /** Parse the reserved parameter's value. Returns [] for an absent/blank
46
+ * value; a malformed one is an error the caller reports rather than a
47
+ * silent empty list — a typo'd list must not look like "no assets". */
48
+ export function parseWorkdirFetchParam(raw) {
49
+ if (raw === undefined || raw.trim() === "")
50
+ return [];
51
+ let json;
52
+ try {
53
+ json = JSON.parse(raw);
54
+ }
55
+ catch (e) {
56
+ throw new Error(`${WORKDIR_FETCH_PARAM} is not valid JSON: ${e instanceof Error ? e.message : String(e)}`);
57
+ }
58
+ const parsed = WorkdirFetchListSchema.safeParse(json);
59
+ if (!parsed.success)
60
+ throw new Error(`${WORKDIR_FETCH_PARAM} is not a valid fetch list: ${parsed.error.message}`);
61
+ return parsed.data;
62
+ }
63
+ export function encodeWorkdirFetchParam(list) {
64
+ return JSON.stringify(list);
65
+ }
66
+ // Destination safety lives in ./workdir-path.ts — zod-free, so browsers can
67
+ // import the same rules the server enforces. Re-exported here because callers
68
+ // reach for the fetch-list contract as one module.
69
+ export { assertSafeWorkdirPath, checkWorkdirPath, } from "./workdir-path.js";
70
+ /** Stack the tiers — product template < production template < launch — the
71
+ * same way the parameter tiers stack. Later layers win by `path`, and the
72
+ * winner keeps its position so the resolved order stays readable in the UI. */
73
+ export function mergeWorkdirFetchLayers(...layers) {
74
+ const byPath = new Map();
75
+ for (const layer of layers) {
76
+ for (const entry of layer ?? []) {
77
+ const checked = checkWorkdirPath(entry.path);
78
+ byPath.set(checked.ok ? checked.path : entry.path, entry);
79
+ }
80
+ }
81
+ return [...byPath.values()];
82
+ }
@@ -0,0 +1,17 @@
1
+ export type WorkdirPathProblem = {
2
+ kind: "escape" | "shadowed";
3
+ message: string;
4
+ };
5
+ /** The normalised workdir-relative path, or why it is not usable. Pure string
6
+ * logic — no node:path — because this runs in the composer as well as on the
7
+ * worker, and both must agree. */
8
+ export declare function checkWorkdirPath(raw: string): {
9
+ ok: true;
10
+ path: string;
11
+ } | {
12
+ ok: false;
13
+ problem: WorkdirPathProblem;
14
+ };
15
+ /** checkWorkdirPath, throwing. For the worker, where a bad path is a launch
16
+ * failure rather than a form validation message. */
17
+ export declare function assertSafeWorkdirPath(raw: string): string;
@@ -0,0 +1,67 @@
1
+ // Destination safety for working-directory assets — where a file may and may
2
+ // not land inside an instance's working directory.
3
+ //
4
+ // Deliberately ZOD-FREE and dependency-free so browsers can import it: the
5
+ // manager's composer, the product-template forms and (soon) norsk-ctl's UI all
6
+ // validate destinations as the operator types, and none of them should pull a
7
+ // schema library into their bundle to do it. The server validates the same list
8
+ // with the zod schema in ./workdir-fetch.ts, which re-exports everything here —
9
+ // so form and server can never disagree about what a legal destination is.
10
+ // Two separate hazards, deliberately not conflated:
11
+ // escape — `../../etc/x` writes outside the workdir. A hole.
12
+ // shadowing — a file written UNDER one of the runner's read-only bind
13
+ // mounts is on disk and invisible to the container. Not a
14
+ // security problem, but a miserable one to debug, so it is
15
+ // refused at upload time rather than discovered at runtime.
16
+ /** Runner-managed read-only mount points inside the working directory
17
+ * (override-generator.ts). `workflow.yml` is a file mount; the other two are
18
+ * directory mounts, so anything beneath them is shadowed too. */
19
+ const READONLY_MOUNTS = {
20
+ file: ["studio-save-files/workflow.yml"],
21
+ dir: [".norsk"],
22
+ /** `studio-save-files/<anything>/dashboards` — the per-workflow dashboard mount. */
23
+ pattern: [/^studio-save-files\/[^/]+\/dashboards(\/|$)/],
24
+ };
25
+ /** The normalised workdir-relative path, or why it is not usable. Pure string
26
+ * logic — no node:path — because this runs in the composer as well as on the
27
+ * worker, and both must agree. */
28
+ export function checkWorkdirPath(raw) {
29
+ const escaped = (message) => ({ ok: false, problem: { kind: "escape", message } });
30
+ if (raw.includes("\\"))
31
+ return escaped(`'${raw}' must use '/' separators`);
32
+ if (raw.startsWith("/") || /^[a-zA-Z]:/.test(raw))
33
+ return escaped(`'${raw}' must be relative to the working directory`);
34
+ if (raw.startsWith("~"))
35
+ return escaped(`'${raw}' must not start with '~'`);
36
+ const segments = raw.split("/").filter((s) => s !== "" && s !== ".");
37
+ if (segments.length === 0)
38
+ return escaped(`'${raw}' names no file`);
39
+ if (segments.some((s) => s === ".."))
40
+ return escaped(`'${raw}' escapes the working directory`);
41
+ const path = segments.join("/");
42
+ const shadowed = (mount) => ({
43
+ ok: false,
44
+ problem: {
45
+ kind: "shadowed",
46
+ message: `'${path}' lands under the read-only mount '${mount}', where it would be on disk but invisible to the container`,
47
+ },
48
+ });
49
+ for (const file of READONLY_MOUNTS.file)
50
+ if (path === file)
51
+ return shadowed(file);
52
+ for (const dir of READONLY_MOUNTS.dir)
53
+ if (path === dir || path.startsWith(`${dir}/`))
54
+ return shadowed(dir);
55
+ for (const re of READONLY_MOUNTS.pattern)
56
+ if (re.test(path))
57
+ return shadowed(path.split("/").slice(0, 3).join("/"));
58
+ return { ok: true, path };
59
+ }
60
+ /** checkWorkdirPath, throwing. For the worker, where a bad path is a launch
61
+ * failure rather than a form validation message. */
62
+ export function assertSafeWorkdirPath(raw) {
63
+ const checked = checkWorkdirPath(raw);
64
+ if (!checked.ok)
65
+ throw new Error(checked.problem.message);
66
+ return checked.path;
67
+ }