@norskvideo/ctl-product-template-schema 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/index.d.ts ADDED
@@ -0,0 +1,221 @@
1
+ import { z } from "zod";
2
+ export declare const ProductTemplateTargetSchema: z.ZodEnum<{
3
+ "docker-compose": "docker-compose";
4
+ }>;
5
+ export type ProductTemplateTarget = z.infer<typeof ProductTemplateTargetSchema>;
6
+ export declare const PRODUCT_TEMPLATE_ADVANCED_NETWORK_MODES: readonly ["docker", "hybrid"];
7
+ export declare const ProductTemplateAdvancedSchema: z.ZodObject<{
8
+ networkMode: z.ZodOptional<z.ZodObject<{
9
+ default: z.ZodOptional<z.ZodEnum<{
10
+ docker: "docker";
11
+ hybrid: "hybrid";
12
+ }>>;
13
+ }, z.core.$strip>>;
14
+ containerUser: z.ZodOptional<z.ZodObject<{
15
+ default: z.ZodOptional<z.ZodString>;
16
+ }, z.core.$strip>>;
17
+ publicHost: z.ZodOptional<z.ZodObject<{
18
+ default: z.ZodOptional<z.ZodString>;
19
+ }, z.core.$strip>>;
20
+ hostPorts: z.ZodOptional<z.ZodObject<{
21
+ default: z.ZodOptional<z.ZodArray<z.ZodString>>;
22
+ }, z.core.$strip>>;
23
+ }, z.core.$strip>;
24
+ export declare const ProductTemplateAllocatedPortSchema: z.ZodObject<{
25
+ param: z.ZodString;
26
+ protocol: z.ZodEnum<{
27
+ tcp: "tcp";
28
+ udp: "udp";
29
+ }>;
30
+ service: z.ZodOptional<z.ZodString>;
31
+ preferred: z.ZodNumber;
32
+ label: z.ZodOptional<z.ZodString>;
33
+ }, z.core.$strip>;
34
+ export type ProductTemplateAllocatedPort = z.infer<typeof ProductTemplateAllocatedPortSchema>;
35
+ export declare const ProductTemplateManifestSchema: z.ZodObject<{
36
+ productTemplateSchemaVersion: z.ZodLiteral<1>;
37
+ productName: z.ZodString;
38
+ productVersion: z.ZodString;
39
+ target: z.ZodEnum<{
40
+ "docker-compose": "docker-compose";
41
+ }>;
42
+ generatedAt: z.ZodString;
43
+ ui: z.ZodOptional<z.ZodObject<{
44
+ runtimeScreenUrl: z.ZodString;
45
+ runtimeScreenLabel: z.ZodOptional<z.ZodString>;
46
+ runtimeScreenService: z.ZodOptional<z.ZodString>;
47
+ }, z.core.$strip>>;
48
+ debug: z.ZodOptional<z.ZodObject<{
49
+ studio: z.ZodOptional<z.ZodBoolean>;
50
+ visualiser: z.ZodOptional<z.ZodBoolean>;
51
+ }, z.core.$strip>>;
52
+ proxy: z.ZodOptional<z.ZodObject<{
53
+ expose: z.ZodDefault<z.ZodArray<z.ZodString>>;
54
+ }, z.core.$strip>>;
55
+ requiresWorkingDirectory: z.ZodOptional<z.ZodBoolean>;
56
+ advanced: z.ZodOptional<z.ZodObject<{
57
+ networkMode: z.ZodOptional<z.ZodObject<{
58
+ default: z.ZodOptional<z.ZodEnum<{
59
+ docker: "docker";
60
+ hybrid: "hybrid";
61
+ }>>;
62
+ }, z.core.$strip>>;
63
+ containerUser: z.ZodOptional<z.ZodObject<{
64
+ default: z.ZodOptional<z.ZodString>;
65
+ }, z.core.$strip>>;
66
+ publicHost: z.ZodOptional<z.ZodObject<{
67
+ default: z.ZodOptional<z.ZodString>;
68
+ }, z.core.$strip>>;
69
+ hostPorts: z.ZodOptional<z.ZodObject<{
70
+ default: z.ZodOptional<z.ZodArray<z.ZodString>>;
71
+ }, z.core.$strip>>;
72
+ }, z.core.$strip>>;
73
+ allocatedPorts: z.ZodOptional<z.ZodArray<z.ZodObject<{
74
+ param: z.ZodString;
75
+ protocol: z.ZodEnum<{
76
+ tcp: "tcp";
77
+ udp: "udp";
78
+ }>;
79
+ service: z.ZodOptional<z.ZodString>;
80
+ preferred: z.ZodNumber;
81
+ label: z.ZodOptional<z.ZodString>;
82
+ }, z.core.$strip>>>;
83
+ }, z.core.$strip>;
84
+ export type ProductTemplateManifest = z.infer<typeof ProductTemplateManifestSchema>;
85
+ export declare const STANDARD_ADVANCED_OVERRIDES: {
86
+ readonly containerUser: {};
87
+ readonly publicHost: {};
88
+ };
89
+ export declare const ProductTemplateParameterSchema: z.ZodObject<{
90
+ name: z.ZodString;
91
+ purpose: z.ZodString;
92
+ type: z.ZodDefault<z.ZodEnum<{
93
+ string: "string";
94
+ number: "number";
95
+ boolean: "boolean";
96
+ int: "int";
97
+ enum: "enum";
98
+ }>>;
99
+ default: z.ZodOptional<z.ZodString>;
100
+ secret: z.ZodOptional<z.ZodBoolean>;
101
+ required: z.ZodOptional<z.ZodBoolean>;
102
+ min: z.ZodOptional<z.ZodNumber>;
103
+ max: z.ZodOptional<z.ZodNumber>;
104
+ values: z.ZodOptional<z.ZodArray<z.ZodString>>;
105
+ }, z.core.$strip>;
106
+ export type ProductTemplateParameter = z.infer<typeof ProductTemplateParameterSchema>;
107
+ export declare const ParametersFileSchema: z.ZodObject<{
108
+ parameters: z.ZodDefault<z.ZodArray<z.ZodObject<{
109
+ name: z.ZodString;
110
+ purpose: z.ZodString;
111
+ type: z.ZodDefault<z.ZodEnum<{
112
+ string: "string";
113
+ number: "number";
114
+ boolean: "boolean";
115
+ int: "int";
116
+ enum: "enum";
117
+ }>>;
118
+ default: z.ZodOptional<z.ZodString>;
119
+ secret: z.ZodOptional<z.ZodBoolean>;
120
+ required: z.ZodOptional<z.ZodBoolean>;
121
+ min: z.ZodOptional<z.ZodNumber>;
122
+ max: z.ZodOptional<z.ZodNumber>;
123
+ values: z.ZodOptional<z.ZodArray<z.ZodString>>;
124
+ }, z.core.$strip>>>;
125
+ }, z.core.$strip>;
126
+ export type ParametersFile = z.infer<typeof ParametersFileSchema>;
127
+ declare const ProductTemplateFileSchema: z.ZodObject<{
128
+ path: z.ZodString;
129
+ content: z.ZodUnion<readonly [z.ZodString, z.ZodCustom<Uint8Array<ArrayBuffer>, Uint8Array<ArrayBuffer>>]>;
130
+ }, z.core.$strip>;
131
+ export type ProductTemplateFile = z.infer<typeof ProductTemplateFileSchema>;
132
+ export declare const ProductTemplateMaterialsSchema: z.ZodObject<{
133
+ manifest: z.ZodObject<{
134
+ productTemplateSchemaVersion: z.ZodLiteral<1>;
135
+ productName: z.ZodString;
136
+ productVersion: z.ZodString;
137
+ target: z.ZodEnum<{
138
+ "docker-compose": "docker-compose";
139
+ }>;
140
+ generatedAt: z.ZodString;
141
+ ui: z.ZodOptional<z.ZodObject<{
142
+ runtimeScreenUrl: z.ZodString;
143
+ runtimeScreenLabel: z.ZodOptional<z.ZodString>;
144
+ runtimeScreenService: z.ZodOptional<z.ZodString>;
145
+ }, z.core.$strip>>;
146
+ debug: z.ZodOptional<z.ZodObject<{
147
+ studio: z.ZodOptional<z.ZodBoolean>;
148
+ visualiser: z.ZodOptional<z.ZodBoolean>;
149
+ }, z.core.$strip>>;
150
+ proxy: z.ZodOptional<z.ZodObject<{
151
+ expose: z.ZodDefault<z.ZodArray<z.ZodString>>;
152
+ }, z.core.$strip>>;
153
+ requiresWorkingDirectory: z.ZodOptional<z.ZodBoolean>;
154
+ advanced: z.ZodOptional<z.ZodObject<{
155
+ networkMode: z.ZodOptional<z.ZodObject<{
156
+ default: z.ZodOptional<z.ZodEnum<{
157
+ docker: "docker";
158
+ hybrid: "hybrid";
159
+ }>>;
160
+ }, z.core.$strip>>;
161
+ containerUser: z.ZodOptional<z.ZodObject<{
162
+ default: z.ZodOptional<z.ZodString>;
163
+ }, z.core.$strip>>;
164
+ publicHost: z.ZodOptional<z.ZodObject<{
165
+ default: z.ZodOptional<z.ZodString>;
166
+ }, z.core.$strip>>;
167
+ hostPorts: z.ZodOptional<z.ZodObject<{
168
+ default: z.ZodOptional<z.ZodArray<z.ZodString>>;
169
+ }, z.core.$strip>>;
170
+ }, z.core.$strip>>;
171
+ allocatedPorts: z.ZodOptional<z.ZodArray<z.ZodObject<{
172
+ param: z.ZodString;
173
+ protocol: z.ZodEnum<{
174
+ tcp: "tcp";
175
+ udp: "udp";
176
+ }>;
177
+ service: z.ZodOptional<z.ZodString>;
178
+ preferred: z.ZodNumber;
179
+ label: z.ZodOptional<z.ZodString>;
180
+ }, z.core.$strip>>>;
181
+ }, z.core.$strip>;
182
+ composeYaml: z.ZodString;
183
+ parameters: z.ZodOptional<z.ZodObject<{
184
+ parameters: z.ZodDefault<z.ZodArray<z.ZodObject<{
185
+ name: z.ZodString;
186
+ purpose: z.ZodString;
187
+ type: z.ZodDefault<z.ZodEnum<{
188
+ string: "string";
189
+ number: "number";
190
+ boolean: "boolean";
191
+ int: "int";
192
+ enum: "enum";
193
+ }>>;
194
+ default: z.ZodOptional<z.ZodString>;
195
+ secret: z.ZodOptional<z.ZodBoolean>;
196
+ required: z.ZodOptional<z.ZodBoolean>;
197
+ min: z.ZodOptional<z.ZodNumber>;
198
+ max: z.ZodOptional<z.ZodNumber>;
199
+ values: z.ZodOptional<z.ZodArray<z.ZodString>>;
200
+ }, z.core.$strip>>>;
201
+ }, z.core.$strip>>;
202
+ workflow: z.ZodOptional<z.ZodString>;
203
+ components: z.ZodDefault<z.ZodArray<z.ZodObject<{
204
+ path: z.ZodString;
205
+ content: z.ZodUnion<readonly [z.ZodString, z.ZodCustom<Uint8Array<ArrayBuffer>, Uint8Array<ArrayBuffer>>]>;
206
+ }, z.core.$strip>>>;
207
+ dashboards: z.ZodDefault<z.ZodArray<z.ZodObject<{
208
+ path: z.ZodString;
209
+ content: z.ZodUnion<readonly [z.ZodString, z.ZodCustom<Uint8Array<ArrayBuffer>, Uint8Array<ArrayBuffer>>]>;
210
+ }, z.core.$strip>>>;
211
+ assets: z.ZodDefault<z.ZodArray<z.ZodObject<{
212
+ path: z.ZodString;
213
+ content: z.ZodUnion<readonly [z.ZodString, z.ZodCustom<Uint8Array<ArrayBuffer>, Uint8Array<ArrayBuffer>>]>;
214
+ }, z.core.$strip>>>;
215
+ workdirSeed: z.ZodDefault<z.ZodArray<z.ZodObject<{
216
+ path: z.ZodString;
217
+ content: z.ZodUnion<readonly [z.ZodString, z.ZodCustom<Uint8Array<ArrayBuffer>, Uint8Array<ArrayBuffer>>]>;
218
+ }, z.core.$strip>>>;
219
+ }, z.core.$strip>;
220
+ export type ProductTemplateMaterials = z.infer<typeof ProductTemplateMaterialsSchema>;
221
+ export {};
package/index.js ADDED
@@ -0,0 +1,218 @@
1
+ import { z } from "zod";
2
+ // ProductTemplate format v1. A product template is a tar file at the wire; the schemas
3
+ // below describe its parts before packing and how the runner validates
4
+ // the unpacked tar.
5
+ //
6
+ // Producers (norsk-probe and future products) build a `ProductTemplateMaterials`
7
+ // in-memory bundle and hand it to the tar packer. The runner validates the
8
+ // unpacked manifest.json against `ProductTemplateManifestSchema` and the
9
+ // unpacked parameters.yaml against `ParametersFileSchema`.
10
+ //
11
+ // Canonical docs:
12
+ // packages/norsk-ctl/docs/product-template-format.md (wire format)
13
+ // packages/norsk-ctl/docs/_planning/product-template-working-directory.md
14
+ // (runtime mount contract)
15
+ export const ProductTemplateTargetSchema = z
16
+ .enum(["docker-compose"])
17
+ .meta({ description: "Runner target. Currently always docker-compose; future targets (e.g. k8s) will extend." });
18
+ // `ui` and `proxy` are product-template-level (not product-level) because the user's
19
+ // config choices in the configure iframe can change which services end up in
20
+ // the compose stack — and therefore which paths need a UI surface or a proxy
21
+ // allowlist entry. Both are optional: product templates whose compose stack has no
22
+ // HTTP service to expose simply omit them.
23
+ const ProductTemplateUiSchema = z.object({
24
+ runtimeScreenUrl: z.string().meta({
25
+ description: "Path (relative to the proxied instance root) for the per-instance dashboard. Runner builds <runnerHost>/instance/<id><runtimeScreenUrl>. Must also appear in proxy.expose[].",
26
+ }),
27
+ runtimeScreenLabel: z
28
+ .string()
29
+ .optional()
30
+ .meta({ description: "Friendly button label. Default `Open <productName>`." }),
31
+ runtimeScreenService: z.string().optional().meta({
32
+ description: "Compose service that hosts the runtime screen. Omit for the conventional `studio`; a product that serves its dashboard from a different service names it here so the runner routes the catch-all there.",
33
+ }),
34
+ });
35
+ // Per-instance Debug menu toggles. The runner always offers raw Studio +
36
+ // Visualiser debug surfaces for an instance; a product opts a link out when
37
+ // its primary runtime screen already *is* that surface (e.g. Studio's main
38
+ // "Open" button is Studio itself — showing a second "Open Studio" under Debug
39
+ // is redundant). Each flag defaults to true (shown) when omitted.
40
+ const ProductTemplateDebugSchema = z.object({
41
+ studio: z.boolean().optional().meta({ description: "Show the Debug menu's 'Open Studio' link. Default true." }),
42
+ visualiser: z
43
+ .boolean()
44
+ .optional()
45
+ .meta({ description: "Show the Debug menu's 'Open Visualiser' link. Default true." }),
46
+ });
47
+ const ProductTemplateProxySchema = z.object({
48
+ expose: z.array(z.string()).default([]).meta({
49
+ 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.",
50
+ }),
51
+ });
52
+ // product-template-schema cannot import @norsk-ctl/shared (dependency direction), so
53
+ // this is a literal copy of the runner's network-mode enum. The runner's
54
+ // OpenAPI ProductTemplateAdvanced shape is generated from this zod (see
55
+ // backend/scripts/gen-product-template-oas.ts), so the two can't drift — but the
56
+ // runner's own ProductNetworkMode launch enum is still hand-authored; extend
57
+ // both if a new mode ever lands.
58
+ export const PRODUCT_TEMPLATE_ADVANCED_NETWORK_MODES = ["docker", "hybrid"];
59
+ // Per-key opt-in map of settings-backed runner knobs the operator may
60
+ // override per instance. Key presence puts the knob on the launch form;
61
+ // `default` pre-fills it (otherwise the runner's daemon-wide default
62
+ // applies). UI gating only for now — the launch API accepts the full launch
63
+ // config regardless of declarations. Per-key objects (not a key list)
64
+ // because opting in and supplying a default are independent choices.
65
+ export const ProductTemplateAdvancedSchema = z
66
+ .object({
67
+ networkMode: z.object({ default: z.enum(PRODUCT_TEMPLATE_ADVANCED_NETWORK_MODES).optional() }).optional(),
68
+ containerUser: z.object({ default: z.string().optional() }).optional(),
69
+ publicHost: z.object({ default: z.string().optional() }).optional(),
70
+ // Managed host-port bindings. `default` is `SPEC` / `SPEC@service` tokens
71
+ // (e.g. "5001/udp", "9000:1935@my-sidecar"; service omitted = media) —
72
+ // a suggestion the operator can change per instance, since a host port is
73
+ // exclusive and a second instance needs a different one. Hard pinning is
74
+ // a future product-template-pins concern, not this knob.
75
+ hostPorts: z.object({ default: z.array(z.string()).optional() }).optional(),
76
+ })
77
+ .meta({ id: "ProductTemplateAdvanced", outputId: "ProductTemplateAdvanced" });
78
+ // A host-port binding whose value rides a product-template parameter, opted in per
79
+ // launch by that parameter's value: absent/blank keeps the port CLOSED,
80
+ // "auto" allocates a free (port, protocol) starting at `preferred`, a number
81
+ // uses that port. A product template that wants the port open by default gives the
82
+ // parameter a default of "auto" (or a number); one that can't know whether
83
+ // the feature is wanted (e.g. MoQ preview) declares no default. When opened,
84
+ // the runner publishes the chosen port as a managed host binding AND exposes
85
+ // the number to compose interpolation under `param`, so the in-container
86
+ // listener, the published binding and any advertised URL all agree. Relaunch
87
+ // re-supplies the recorded number, so an opened instance keeps its port.
88
+ // Distinct from `advanced.hostPorts` (operator-typed extra bindings with no
89
+ // parameter linkage).
90
+ export const ProductTemplateAllocatedPortSchema = z
91
+ .object({
92
+ param: z.string().regex(/^[A-Z][A-Z0-9_]*$/, "env var name; uppercase, digits, underscores"),
93
+ protocol: z.enum(["tcp", "udp"]),
94
+ service: z.string().optional().meta({ description: "Compose service the binding targets; omitted = media" }),
95
+ preferred: z.number().int().min(1).max(65535).meta({ description: "Allocation starts here and walks upward" }),
96
+ label: z.string().optional().meta({ description: "Human label, e.g. 'MoQ preview (WebTransport)'" }),
97
+ })
98
+ .meta({ id: "ProductTemplateAllocatedPort", outputId: "ProductTemplateAllocatedPort" });
99
+ export const ProductTemplateManifestSchema = z
100
+ .object({
101
+ productTemplateSchemaVersion: z.literal(1),
102
+ productName: z.string(),
103
+ productVersion: z.string(),
104
+ target: ProductTemplateTargetSchema,
105
+ generatedAt: z
106
+ .string()
107
+ .meta({ description: "ISO-8601 timestamp at product-template generation time", example: "2026-05-07T12:00:00Z" }),
108
+ ui: ProductTemplateUiSchema.optional(),
109
+ debug: ProductTemplateDebugSchema.optional().meta({
110
+ 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').",
111
+ }),
112
+ proxy: ProductTemplateProxySchema.optional(),
113
+ requiresWorkingDirectory: z.boolean().optional().meta({
114
+ 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.",
115
+ }),
116
+ advanced: ProductTemplateAdvancedSchema.optional().meta({
117
+ description: "Settings-backed runner knobs the operator may override per instance. Key presence opts the knob into the launch form; `default` pre-fills it (else the runner's daemon-wide default applies).",
118
+ }),
119
+ allocatedPorts: z.array(ProductTemplateAllocatedPortSchema).optional().meta({
120
+ description: "Host-port bindings whose value rides a product-template parameter. Opt-in per launch via the param value: absent = closed, 'auto' = allocate a free port from `preferred`, number = use it. When opened the port is published as a managed host binding and surfaced to the containers via compose interpolation.",
121
+ }),
122
+ })
123
+ .meta({ id: "ProductTemplateManifest", outputId: "ProductTemplateManifest" });
124
+ // Container user and external URL are runner knobs every product wants
125
+ // overridable per instance — the runner only holds their default values.
126
+ // Spread this into a manifest's `advanced` (adding product-specific keys
127
+ // like networkMode on top) instead of hand-rolling the declarations:
128
+ // advanced: { ...STANDARD_ADVANCED_OVERRIDES, networkMode: {} }
129
+ // Still an explicit opt-in: a product that must withhold a knob simply
130
+ // doesn't spread it.
131
+ export const STANDARD_ADVANCED_OVERRIDES = {
132
+ containerUser: {},
133
+ publicHost: {},
134
+ };
135
+ export const ProductTemplateParameterSchema = z
136
+ .object({
137
+ name: z.string().regex(/^[A-Z][A-Z0-9_]*$/, "env var name; uppercase, digits, underscores"),
138
+ purpose: z.string(),
139
+ type: z.enum(["string", "int", "number", "boolean", "enum"]).default("string"),
140
+ default: z.string().optional().meta({ description: "Stringified default (even for numeric/boolean/enum types)" }),
141
+ secret: z.boolean().optional(),
142
+ /** Mandatory input. Only bites when there is no `default` to fall back
143
+ * on — the runner/composer blocks launch on an empty required value. */
144
+ required: z.boolean().optional(),
145
+ /** Inclusive bounds for `int`/`number` parameters. */
146
+ min: z.number().optional(),
147
+ max: z.number().optional(),
148
+ /** Allowed values for `enum` parameters (rendered as a dropdown). */
149
+ values: z.array(z.string()).optional(),
150
+ })
151
+ .superRefine((p, ctx) => {
152
+ if (p.type === "enum" && (!p.values || p.values.length === 0)) {
153
+ ctx.addIssue({ code: "custom", path: ["values"], message: "enum parameters require a non-empty `values` list" });
154
+ }
155
+ if (p.type !== "enum" && p.values !== undefined) {
156
+ ctx.addIssue({ code: "custom", path: ["values"], message: "`values` is only valid for enum parameters" });
157
+ }
158
+ if (p.min !== undefined && p.max !== undefined && p.min > p.max) {
159
+ ctx.addIssue({ code: "custom", path: ["min"], message: "`min` must be <= `max`" });
160
+ }
161
+ if ((p.min !== undefined || p.max !== undefined) && p.type !== "int" && p.type !== "number") {
162
+ ctx.addIssue({ code: "custom", path: ["min"], message: "`min`/`max` are only valid for int/number parameters" });
163
+ }
164
+ })
165
+ .meta({ id: "ProductTemplateParameter", outputId: "ProductTemplateParameter" });
166
+ // Wire shape of the optional parameters.yaml file inside a product-template tar.
167
+ // `parameters` defaults to [] so products with no override surface can omit
168
+ // the file entirely (or ship it empty) and the runner's safeParse still
169
+ // returns a valid empty-list value.
170
+ export const ParametersFileSchema = z
171
+ .object({
172
+ parameters: z.array(ProductTemplateParameterSchema).default([]),
173
+ })
174
+ .meta({ id: "ParametersFile" });
175
+ const ProductTemplateFileSchema = z.object({
176
+ path: z.string().meta({ description: "Relative path under the parent directory in the tar" }),
177
+ // `Uint8Array` is the canonical shape — binary-safe for media/graphics
178
+ // assets in workdir-seed/ etc. String accepted for the text-emitting
179
+ // producer call sites (probe's compose.yml render, manifest JSON,
180
+ // etc.) so callers don't have to TextEncoder by hand. The tar packer
181
+ // accepts both via the same union.
182
+ content: z.union([z.string(), z.instanceof(Uint8Array)]),
183
+ });
184
+ // In-memory bundle the producer builds; the tar packer turns this into bytes.
185
+ //
186
+ // `workflow`, `components` and `dashboards` are known top-level paths in the
187
+ // v1 tar format. The runner detects studio in the compose stack and mounts
188
+ // all three into the studio container automatically — products don't need
189
+ // to declare the mounts.
190
+ //
191
+ // workflow.yml → /data/studio-save-files/workflow.yml (RO)
192
+ // components/<name>/ → /usr/src/app/plugins/<name>/ (RO)
193
+ // dashboards/<name>/ → /data/studio-save-files/<name>/dashboards/ (RO)
194
+ //
195
+ // `assets` is free-form; the rule is "if it earns a role, it earns a known
196
+ // path" (workflow.yml was the first instance of this — promoted out of
197
+ // `assets/`).
198
+ export const ProductTemplateMaterialsSchema = z
199
+ .object({
200
+ manifest: ProductTemplateManifestSchema,
201
+ composeYaml: z.string(),
202
+ parameters: ParametersFileSchema.optional(),
203
+ workflow: z.string().optional().meta({ description: "Studio workflow YAML — packed at tar root as workflow.yml" }),
204
+ components: z.array(ProductTemplateFileSchema).default([]).meta({
205
+ description: "Compiled studio components — packed under components/ in the tar",
206
+ }),
207
+ dashboards: z.array(ProductTemplateFileSchema).default([]).meta({
208
+ description: "Per-workflow dashboard assets — packed under dashboards/<workflowName>/. Runner mounts each into /data/studio-save-files/<workflowName>/dashboards/ so Studio serves them at /dashboard/<workflowName>/.",
209
+ }),
210
+ assets: z
211
+ .array(ProductTemplateFileSchema)
212
+ .default([])
213
+ .meta({ description: "Free-form product files — packed under assets/ in the tar" }),
214
+ workdirSeed: z.array(ProductTemplateFileSchema).default([]).meta({
215
+ description: "Files copied recursively into the launch workdir at launch time. Packed under workdir-seed/ in the tar. Existing files in the launch workdir win on conflict (the seed only fills gaps), so re-launches and operator-edits-in-place are non-destructive. Lets a product template ship a 'starter pack' of demo workflows / dashboards / components that the runtime sees in /data on first launch.",
216
+ }),
217
+ })
218
+ .meta({ id: "ProductTemplateMaterials" });
package/package.json ADDED
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "@norskvideo/ctl-product-template-schema",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "exports": {
6
+ ".": {
7
+ "types": "./index.d.ts",
8
+ "default": "./index.js"
9
+ },
10
+ "./params": {
11
+ "types": "./params.d.ts",
12
+ "default": "./params.js"
13
+ }
14
+ },
15
+ "main": "./index.js",
16
+ "types": "./index.d.ts",
17
+ "dependencies": {
18
+ "zod": "^4.3.6"
19
+ },
20
+ "publishConfig": {
21
+ "access": "public"
22
+ }
23
+ }
package/params.d.ts ADDED
@@ -0,0 +1,20 @@
1
+ import type { ProductTemplateParameter } from "./index.js";
2
+ /** The subset of a parameter these helpers reason about. `type` is optional
3
+ * (a missing type is treated as `string`) so callers holding a looser shape
4
+ * than the parsed schema can use these directly. */
5
+ export type ParamSpec = {
6
+ type?: ProductTemplateParameter["type"];
7
+ required?: boolean;
8
+ default?: string;
9
+ min?: number;
10
+ max?: number;
11
+ values?: string[];
12
+ };
13
+ /** A required parameter only blocks launch when it has no `default` to fall
14
+ * back on — supplying a default is itself a way of satisfying it. */
15
+ export declare function isParamRequired(p: Pick<ProductTemplateParameter, "required" | "default">): boolean;
16
+ /** Validate an operator's raw (string) input for a parameter. Returns an
17
+ * error message, or `undefined` when the value is acceptable — including an
18
+ * empty value for a non-required parameter (the default / runner fills it).
19
+ * Boolean/number/enum values arrive as strings from form inputs. */
20
+ export declare function validateParamValue(p: ParamSpec, raw: string): string | undefined;
package/params.js ADDED
@@ -0,0 +1,42 @@
1
+ // Pure, dependency-free helpers for product-template parameter input — shared by
2
+ // every surface that lets an operator fill parameters (norsk-mgr's
3
+ // ProductionComposer, norsk-ctl's ProductTemplateLaunchForm). Kept out of
4
+ // index.ts so importers don't pull zod into a browser bundle; the only
5
+ // dependency is the `ProductTemplateParameter` *type*.
6
+ /** A required parameter only blocks launch when it has no `default` to fall
7
+ * back on — supplying a default is itself a way of satisfying it. */
8
+ export function isParamRequired(p) {
9
+ return p.required === true && p.default === undefined;
10
+ }
11
+ /** Validate an operator's raw (string) input for a parameter. Returns an
12
+ * error message, or `undefined` when the value is acceptable — including an
13
+ * empty value for a non-required parameter (the default / runner fills it).
14
+ * Boolean/number/enum values arrive as strings from form inputs. */
15
+ export function validateParamValue(p, raw) {
16
+ const v = raw.trim();
17
+ if (v === "")
18
+ return isParamRequired(p) ? "required" : undefined;
19
+ switch (p.type) {
20
+ case "int":
21
+ if (!/^-?\d+$/.test(v))
22
+ return "must be a whole number";
23
+ return rangeError(p, Number(v));
24
+ case "number":
25
+ if (!Number.isFinite(Number(v)))
26
+ return "must be a number";
27
+ return rangeError(p, Number(v));
28
+ case "boolean":
29
+ return /^(true|false)$/i.test(v) ? undefined : "must be true or false";
30
+ case "enum":
31
+ return p.values && !p.values.includes(v) ? `must be one of: ${p.values.join(", ")}` : undefined;
32
+ default:
33
+ return undefined; // string — anything goes
34
+ }
35
+ }
36
+ function rangeError(p, n) {
37
+ if (p.min !== undefined && n < p.min)
38
+ return `must be >= ${p.min}`;
39
+ if (p.max !== undefined && n > p.max)
40
+ return `must be <= ${p.max}`;
41
+ return undefined;
42
+ }