@norskvideo/ctl-sdk 0.1.35 → 0.1.37

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/http-proxy.d.ts CHANGED
@@ -25,6 +25,10 @@ export type ProxyHttpOptions = ForwardResponseOptions & {
25
25
  timeoutMs?: number;
26
26
  };
27
27
  /** Proxy one HTTP request to `targetUrl`, streaming the response body back.
28
+ * Forwards the request's headers as they arrive: this runs INSIDE an instance
29
+ * (a product backend fronting its own studio), where the ingress has already
30
+ * blanked the client-supplied identity headers. The runner's product proxy,
31
+ * which sits at the trust boundary, strips them itself in proxy-middleware.ts.
28
32
  * Never throws into express: an unreachable/booting upstream surfaces as a
29
33
  * 502 the caller retries. */
30
34
  export declare function proxyHttp(req: Request, res: Response, targetUrl: string, opts?: ProxyHttpOptions): Promise<void>;
package/http-proxy.js CHANGED
@@ -93,6 +93,10 @@ export async function forwardUpstreamResponse(res, upstream, opts = {}) {
93
93
  }
94
94
  const DEFAULT_PROXY_TIMEOUT_MS = 30_000;
95
95
  /** Proxy one HTTP request to `targetUrl`, streaming the response body back.
96
+ * Forwards the request's headers as they arrive: this runs INSIDE an instance
97
+ * (a product backend fronting its own studio), where the ingress has already
98
+ * blanked the client-supplied identity headers. The runner's product proxy,
99
+ * which sits at the trust boundary, strips them itself in proxy-middleware.ts.
96
100
  * Never throws into express: an unreachable/booting upstream surfaces as a
97
101
  * 502 the caller retries. */
98
102
  export async function proxyHttp(req, res, targetUrl, opts = {}) {
package/index.d.ts CHANGED
@@ -20,4 +20,5 @@ export * from "./product-service.js";
20
20
  export * from "./product-template-materials.js";
21
21
  export * from "./proxy-middleware.js";
22
22
  export * from "./route-error.js";
23
+ export * from "./runner-capabilities.js";
23
24
  export * from "./sse.js";
package/index.js CHANGED
@@ -22,4 +22,5 @@ export * from "./product-service.js";
22
22
  export * from "./product-template-materials.js";
23
23
  export * from "./proxy-middleware.js";
24
24
  export * from "./route-error.js";
25
+ export * from "./runner-capabilities.js";
25
26
  export * from "./sse.js";
@@ -14,6 +14,7 @@ export declare const ManifestSchema: z.ZodObject<{
14
14
  name: z.ZodString;
15
15
  version: z.ZodString;
16
16
  minRunnerVersion: z.ZodOptional<z.ZodString>;
17
+ requires: z.ZodOptional<z.ZodArray<z.ZodString>>;
17
18
  api: z.ZodObject<{
18
19
  basePath: z.ZodString;
19
20
  proxyPaths: z.ZodArray<z.ZodString>;
@@ -96,6 +96,9 @@ export const ManifestSchema = z.object({
96
96
  name: z.string(),
97
97
  version: z.string(),
98
98
  minRunnerVersion: z.string().optional(),
99
+ requires: z.array(z.string().min(1)).optional().meta({
100
+ description: "Named runner capabilities this product version cannot launch without (RFC 0001 §3.6). A runner that lacks any of them refuses the product as too old rather than mis-launching it. Usually absent — evolve the contract additively first; never a runner version number.",
101
+ }),
99
102
  api: z.object({
100
103
  basePath: z.string(),
101
104
  proxyPaths: z.array(z.string()),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@norskvideo/ctl-sdk",
3
- "version": "0.1.35",
3
+ "version": "0.1.37",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {
@@ -23,6 +23,10 @@
23
23
  "types": "./manifest-seed.d.ts",
24
24
  "default": "./manifest-seed.js"
25
25
  },
26
+ "./runner-capabilities": {
27
+ "types": "./runner-capabilities.d.ts",
28
+ "default": "./runner-capabilities.js"
29
+ },
26
30
  "./runtime": {
27
31
  "types": "./runtime.d.ts",
28
32
  "default": "./runtime.js"
@@ -1,4 +1,4 @@
1
1
  export declare class ProductError extends Error {
2
- code: "DEV_URL_INVALID" | "DEV_URL_NOT_LOCALHOST" | "PORT_EXHAUSTED" | "DOCKER_PULL_FAILED" | "DOCKER_RUN_FAILED" | "READINESS_TIMEOUT" | "MANIFEST_FETCH_FAILED" | "MANIFEST_INVALID" | "CONFIG_SCREEN_UNREACHABLE" | "CONFIG_SCREEN_DEV_SERVER" | "LICENSE_INVALID" | "NAME_CONFLICT" | "NOT_FOUND" | "NOT_RESTARTABLE" | "PRODUCT_TEMPLATE_FETCH_FAILED";
3
- constructor(code: "DEV_URL_INVALID" | "DEV_URL_NOT_LOCALHOST" | "PORT_EXHAUSTED" | "DOCKER_PULL_FAILED" | "DOCKER_RUN_FAILED" | "READINESS_TIMEOUT" | "MANIFEST_FETCH_FAILED" | "MANIFEST_INVALID" | "CONFIG_SCREEN_UNREACHABLE" | "CONFIG_SCREEN_DEV_SERVER" | "LICENSE_INVALID" | "NAME_CONFLICT" | "NOT_FOUND" | "NOT_RESTARTABLE" | "PRODUCT_TEMPLATE_FETCH_FAILED", message: string);
2
+ code: "DEV_URL_INVALID" | "DEV_URL_NOT_LOCALHOST" | "PORT_EXHAUSTED" | "DOCKER_PULL_FAILED" | "DOCKER_RUN_FAILED" | "READINESS_TIMEOUT" | "MANIFEST_FETCH_FAILED" | "MANIFEST_INVALID" | "CONFIG_SCREEN_UNREACHABLE" | "CONFIG_SCREEN_DEV_SERVER" | "LICENSE_INVALID" | "NAME_CONFLICT" | "NOT_FOUND" | "NOT_RESTARTABLE" | "RUNNER_TOO_OLD" | "PRODUCT_TEMPLATE_FETCH_FAILED";
3
+ constructor(code: "DEV_URL_INVALID" | "DEV_URL_NOT_LOCALHOST" | "PORT_EXHAUSTED" | "DOCKER_PULL_FAILED" | "DOCKER_RUN_FAILED" | "READINESS_TIMEOUT" | "MANIFEST_FETCH_FAILED" | "MANIFEST_INVALID" | "CONFIG_SCREEN_UNREACHABLE" | "CONFIG_SCREEN_DEV_SERVER" | "LICENSE_INVALID" | "NAME_CONFLICT" | "NOT_FOUND" | "NOT_RESTARTABLE" | "RUNNER_TOO_OLD" | "PRODUCT_TEMPLATE_FETCH_FAILED", message: string);
4
4
  }
@@ -121,6 +121,10 @@ export interface ProductServiceOptions {
121
121
  * in a container). Default: detected from the daemon's cgroup, overridable
122
122
  * with NORSK_CTL_PRODUCT_REACH. */
123
123
  reach?: ProductReach;
124
+ /** Named runner capabilities this host knows (RFC 0001 §3.6). A product
125
+ * whose manifest `requires` any other is refused at add and reload.
126
+ * Default: none. */
127
+ knownCapabilities?: readonly string[];
124
128
  }
125
129
  export declare class ProductService {
126
130
  /**
@@ -145,10 +149,12 @@ export declare class ProductService {
145
149
  private readonly isDevUrlAlive;
146
150
  private readonly containerOps;
147
151
  private readonly reach;
152
+ private readonly knownCapabilities;
148
153
  constructor(opts: ProductServiceOptions);
149
154
  /** The address to record for a freshly started container: its own, when
150
155
  * this daemon cannot dial the host loopback; otherwise nothing. */
151
156
  private reachHostFor;
157
+ private refuseUnknownCapabilities;
152
158
  list(): ProductRegistration[];
153
159
  /** Docker's default `--pull=missing` keeps whatever a moving tag resolved to
154
160
  * last time, so a rebuilt `:latest`/`:dev` never reached a new registration.
@@ -7,6 +7,7 @@ import { checkProductEntitlement, notV2EnvelopeMessage, parseLicenseContents } f
7
7
  import { fetchManifest, isDevUrlAlive, probeConfigScreen, productBaseUrl, specBaseUrl, waitForReady, } from "./manifest-fetch.js";
8
8
  import { ProductError } from "./product-error.js";
9
9
  import { detectProductReach } from "./product-reach.js";
10
+ import { checkRequiredCapabilities } from "./runner-capabilities.js";
10
11
  /** GET a product-template tar from a product. `url` is whatever the manifest
11
12
  * declared; we treat it as a path on the product's HTTP surface and
12
13
  * resolve against the product's base URL. Returns the raw tar bytes,
@@ -58,6 +59,7 @@ export class ProductService {
58
59
  isDevUrlAlive;
59
60
  containerOps;
60
61
  reach;
62
+ knownCapabilities;
61
63
  constructor(opts) {
62
64
  this.store = opts.store;
63
65
  this.importProductTemplateBytes = opts.importProductTemplateBytes;
@@ -66,6 +68,7 @@ export class ProductService {
66
68
  this.isDevUrlAlive = opts.isDevUrlAlive ?? isDevUrlAlive;
67
69
  this.containerOps = opts.containerOps ?? defaultProductContainerOps;
68
70
  this.reach = opts.reach ?? detectProductReach({});
71
+ this.knownCapabilities = opts.knownCapabilities ?? [];
69
72
  }
70
73
  /** The address to record for a freshly started container: its own, when
71
74
  * this daemon cannot dial the host loopback; otherwise nothing. */
@@ -74,6 +77,10 @@ export class ProductService {
74
77
  return undefined;
75
78
  return this.containerOps.address(containerId);
76
79
  }
80
+ refuseUnknownCapabilities(manifest) {
81
+ const check = checkRequiredCapabilities({ product: manifest.name, version: manifest.version, requires: manifest.requires }, this.knownCapabilities);
82
+ return check.status === "error" ? new ProductError("RUNNER_TOO_OLD", check.error.message) : undefined;
83
+ }
77
84
  list() {
78
85
  return this.store.read();
79
86
  }
@@ -137,6 +144,12 @@ export class ProductService {
137
144
  await this.containerOps.remove(containerId);
138
145
  throw e;
139
146
  });
147
+ const refusal = this.refuseUnknownCapabilities(manifest);
148
+ if (refusal) {
149
+ if (containerId)
150
+ await this.containerOps.remove(containerId);
151
+ throw refusal;
152
+ }
140
153
  if (existing.some((p) => p.name === manifest.name)) {
141
154
  if (containerId)
142
155
  await this.containerOps.remove(containerId);
@@ -319,6 +332,9 @@ export class ProductService {
319
332
  const current = this.store.read().find((p) => p.name === name) ?? target;
320
333
  const baseUrl = productBaseUrl(current);
321
334
  const manifest = await fetchManifest(baseUrl);
335
+ const refusal = this.refuseUnknownCapabilities(manifest);
336
+ if (refusal)
337
+ throw refusal;
322
338
  if (manifest.name !== target.name) {
323
339
  throw new ProductError("NAME_CONFLICT", `manifest now reports name '${manifest.name}', was '${target.name}' — use remove + add`);
324
340
  }
@@ -3,5 +3,6 @@ import type { ProductRegistration } from "./product-types.js";
3
3
  interface ProductRegistry {
4
4
  list(): ProductRegistration[];
5
5
  }
6
+ export declare function isOauth2SessionCookieName(name: string): boolean;
6
7
  export declare function createProductProxyMiddleware(svc: ProductRegistry): (req: Request, res: ExpressResponse, next: NextFunction) => Promise<void>;
7
8
  export {};
@@ -7,6 +7,41 @@ function targetBaseUrl(reg) {
7
7
  }
8
8
  return reg.spec.url.replace(/\/$/, "");
9
9
  }
10
+ // The session cookie oauth2-proxy sets at the ctl/mgr ingress (see
11
+ // packages/proxy/src/oauth2-config.ts), including the _0, _1... chunks it
12
+ // splits an oversized session into.
13
+ const OAUTH2_SESSION_COOKIE = /^__(Host|Secure)-[A-Za-z0-9_-]+_oauth2(_\d+)?$/;
14
+ export function isOauth2SessionCookieName(name) {
15
+ return OAUTH2_SESSION_COOKIE.test(name);
16
+ }
17
+ // No product control plane calls back into ctl/mgr, so the ingress secret
18
+ // (full admin), the caller's bearer token and the ingress session cookie
19
+ // have no business reaching one. The whole `x-norsk-*` space goes, per RFC
20
+ // 0003 Phase 0 item 4 — a header the ingress starts asserting later must not
21
+ // reach a product merely because this code was not updated with its name.
22
+ function buildProductForwardHeaders(req) {
23
+ const headers = buildForwardHeaders(req);
24
+ const norskHeaders = [];
25
+ headers.forEach((_value, name) => {
26
+ if (name.toLowerCase().startsWith("x-norsk-"))
27
+ norskHeaders.push(name);
28
+ });
29
+ for (const name of norskHeaders)
30
+ headers.delete(name);
31
+ headers.delete("authorization");
32
+ const cookie = headers.get("cookie");
33
+ if (cookie !== null) {
34
+ const kept = cookie
35
+ .split(";")
36
+ .map((c) => c.trim())
37
+ .filter((c) => c !== "" && !isOauth2SessionCookieName(c.split("=", 1)[0] ?? ""));
38
+ if (kept.length > 0)
39
+ headers.set("cookie", kept.join("; "));
40
+ else
41
+ headers.delete("cookie");
42
+ }
43
+ return headers;
44
+ }
10
45
  export function createProductProxyMiddleware(svc) {
11
46
  return async (req, res, next) => {
12
47
  const name = req.params.name;
@@ -24,7 +59,7 @@ export function createProductProxyMiddleware(svc) {
24
59
  return;
25
60
  }
26
61
  const targetUrl = `${base}${req.url}`;
27
- const headers = buildForwardHeaders(req);
62
+ const headers = buildProductForwardHeaders(req);
28
63
  const body = await readRequestBody(req);
29
64
  let upstream;
30
65
  try {
@@ -0,0 +1,10 @@
1
+ import { type Result } from "@norskvideo/ctl-foundation";
2
+ export interface CapabilityRefusal {
3
+ missing: string[];
4
+ message: string;
5
+ }
6
+ export declare function checkRequiredCapabilities(target: {
7
+ product: string;
8
+ version: string;
9
+ requires?: readonly string[];
10
+ }, known: readonly string[]): Result<void, CapabilityRefusal>;
@@ -0,0 +1,14 @@
1
+ import { err, ok } from "@norskvideo/ctl-foundation";
2
+ // RFC 0001 §3.6: a version whose `requires` names a capability this runner
3
+ // does not know is refused with an explicit "too old" stop rather than a
4
+ // silent mis-launch. Run it before pulling anything for that version.
5
+ export function checkRequiredCapabilities(target, known) {
6
+ const missing = (target.requires ?? []).filter((c) => !known.includes(c));
7
+ if (missing.length === 0)
8
+ return ok(undefined);
9
+ const needs = missing.map((c) => `'${c}'`).join(", ");
10
+ return err({
11
+ missing,
12
+ message: `your norsk-ctl can't launch ${target.product} ${target.version} — it needs ${needs}; run 'norsk-ctl upgrade'`,
13
+ });
14
+ }