@norskvideo/ctl-product-template-schema 0.1.16 → 0.1.18
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 +6 -0
- package/index.js +13 -0
- package/package.json +10 -2
- package/workdir-fetch.d.ts +30 -0
- package/workdir-fetch.js +82 -0
- package/workdir-path.d.ts +17 -0
- package/workdir-path.js +67 -0
package/index.d.ts
CHANGED
|
@@ -132,6 +132,9 @@ export declare const ProductTemplateManifestSchema: z.ZodObject<{
|
|
|
132
132
|
auth: z.ZodOptional<z.ZodBoolean>;
|
|
133
133
|
}, z.core.$strip>>>;
|
|
134
134
|
}, z.core.$strip>>;
|
|
135
|
+
allowWorkflowUploads: z.ZodOptional<z.ZodBoolean>;
|
|
136
|
+
isStudio: z.ZodOptional<z.ZodBoolean>;
|
|
137
|
+
supportsCustomAssets: z.ZodOptional<z.ZodBoolean>;
|
|
135
138
|
requiresWorkingDirectory: z.ZodOptional<z.ZodBoolean>;
|
|
136
139
|
sideload: z.ZodOptional<z.ZodObject<{
|
|
137
140
|
gpuInference: z.ZodOptional<z.ZodBoolean>;
|
|
@@ -307,6 +310,9 @@ export declare const ProductTemplateMaterialsSchema: z.ZodObject<{
|
|
|
307
310
|
auth: z.ZodOptional<z.ZodBoolean>;
|
|
308
311
|
}, z.core.$strip>>>;
|
|
309
312
|
}, z.core.$strip>>;
|
|
313
|
+
allowWorkflowUploads: z.ZodOptional<z.ZodBoolean>;
|
|
314
|
+
isStudio: z.ZodOptional<z.ZodBoolean>;
|
|
315
|
+
supportsCustomAssets: z.ZodOptional<z.ZodBoolean>;
|
|
310
316
|
requiresWorkingDirectory: z.ZodOptional<z.ZodBoolean>;
|
|
311
317
|
sideload: z.ZodOptional<z.ZodObject<{
|
|
312
318
|
gpuInference: z.ZodOptional<z.ZodBoolean>;
|
package/index.js
CHANGED
|
@@ -256,6 +256,19 @@ export const ProductTemplateManifestSchema = z
|
|
|
256
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').",
|
|
257
257
|
}),
|
|
258
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
|
+
}),
|
|
259
272
|
requiresWorkingDirectory: z.boolean().optional().meta({
|
|
260
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.",
|
|
261
274
|
}),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@norskvideo/ctl-product-template-schema",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.18",
|
|
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",
|
|
@@ -25,7 +33,7 @@
|
|
|
25
33
|
"dependencies": {
|
|
26
34
|
"@norskvideo/ctl-foundation": "^0.1.0",
|
|
27
35
|
"yaml": "^2.8.0",
|
|
28
|
-
"zod": "
|
|
36
|
+
"zod": "4.4.3"
|
|
29
37
|
},
|
|
30
38
|
"publishConfig": {
|
|
31
39
|
"access": "public"
|
|
@@ -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;
|
package/workdir-fetch.js
ADDED
|
@@ -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;
|
package/workdir-path.js
ADDED
|
@@ -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
|
+
}
|