@norskvideo/ctl-sdk 0.1.36 → 0.1.38
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 +2 -0
- package/index.js +2 -0
- package/ingress-identity.d.ts +33 -0
- package/ingress-identity.js +36 -0
- package/manifest-schema.d.ts +1 -0
- package/manifest-schema.js +3 -0
- package/package.json +5 -1
- package/product-error.d.ts +2 -2
- package/product-service.d.ts +6 -0
- package/product-service.js +16 -0
- package/proxy-middleware.d.ts +24 -1
- package/proxy-middleware.js +9 -2
- package/runner-capabilities.d.ts +10 -0
- package/runner-capabilities.js +14 -0
package/index.d.ts
CHANGED
|
@@ -5,6 +5,7 @@ export * from "./dev-url.js";
|
|
|
5
5
|
export * from "./docker-runner.js";
|
|
6
6
|
export * from "./docs-router.js";
|
|
7
7
|
export * from "./http-proxy.js";
|
|
8
|
+
export * from "./ingress-identity.js";
|
|
8
9
|
export * from "./license-registration.js";
|
|
9
10
|
export * from "./license-staged.js";
|
|
10
11
|
export * from "./license-stager.js";
|
|
@@ -20,4 +21,5 @@ export * from "./product-service.js";
|
|
|
20
21
|
export * from "./product-template-materials.js";
|
|
21
22
|
export * from "./proxy-middleware.js";
|
|
22
23
|
export * from "./route-error.js";
|
|
24
|
+
export * from "./runner-capabilities.js";
|
|
23
25
|
export * from "./sse.js";
|
package/index.js
CHANGED
|
@@ -7,6 +7,7 @@ export * from "./dev-url.js";
|
|
|
7
7
|
export * from "./docker-runner.js";
|
|
8
8
|
export * from "./docs-router.js";
|
|
9
9
|
export * from "./http-proxy.js";
|
|
10
|
+
export * from "./ingress-identity.js";
|
|
10
11
|
export * from "./license-registration.js";
|
|
11
12
|
export * from "./license-staged.js";
|
|
12
13
|
export * from "./license-stager.js";
|
|
@@ -22,4 +23,5 @@ export * from "./product-service.js";
|
|
|
22
23
|
export * from "./product-template-materials.js";
|
|
23
24
|
export * from "./proxy-middleware.js";
|
|
24
25
|
export * from "./route-error.js";
|
|
26
|
+
export * from "./runner-capabilities.js";
|
|
25
27
|
export * from "./sse.js";
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a product control plane needs to believe ctl's ingress (RFC 0003 D2b).
|
|
3
|
+
*
|
|
4
|
+
* A **library, not a policy decision point**. It answers "did the ingress
|
|
5
|
+
* really say this, and who did it name?"; what a role may then do is the
|
|
6
|
+
* product's own `authorize()` (D4a), and Studio's gating lives in the
|
|
7
|
+
* norsk-studio repo.
|
|
8
|
+
*
|
|
9
|
+
* The format itself lives in `@norskvideo/ctl-foundation` so the ingress
|
|
10
|
+
* renderer and the products that read it cannot drift apart; this module is
|
|
11
|
+
* the published door onto it, plus the one thing only the launched side knows
|
|
12
|
+
* — where ctl puts the audience's credentials.
|
|
13
|
+
*/
|
|
14
|
+
export { type AssertionRefusal, AUDIENCE_HEADER, AUDIENCE_SECRET_HEADER, type AudienceAssertion, type AudienceHeaders, CTL_AUDIENCE, CTL_READINESS_PROBE_SUB, type IngressAssertion, ingressAssertionHeaders, instanceAudience, PRINCIPAL_HEADER, productAudience, ROLES_HEADER, verifyIngressAssertion, } from "@norskvideo/ctl-foundation";
|
|
15
|
+
/** The audience this container is, e.g. `urn:norsk:instance:p-1`. */
|
|
16
|
+
export declare const AUDIENCE_ENV = "NORSK_INGRESS_AUDIENCE";
|
|
17
|
+
/** The secret the ingress stamps on this audience's locations, and on no
|
|
18
|
+
* other's. */
|
|
19
|
+
export declare const AUDIENCE_SECRET_ENV = "NORSK_INGRESS_AUDIENCE_SECRET";
|
|
20
|
+
/**
|
|
21
|
+
* The audience credentials ctl injected at launch, or `undefined` when it
|
|
22
|
+
* injected none.
|
|
23
|
+
*
|
|
24
|
+
* `undefined` is a state products must handle rather than a fault: RFC 0001
|
|
25
|
+
* keeps old ctl launching new product releases, so a product built against
|
|
26
|
+
* this will run on a runner that has never heard of it. Returning an empty
|
|
27
|
+
* secret instead would hand that product a verifier that accepts everything,
|
|
28
|
+
* which is the opposite of what the absence means.
|
|
29
|
+
*/
|
|
30
|
+
export declare function audienceFromEnvironment(env?: Record<string, string | undefined>): {
|
|
31
|
+
audience: string;
|
|
32
|
+
secret: string;
|
|
33
|
+
} | undefined;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a product control plane needs to believe ctl's ingress (RFC 0003 D2b).
|
|
3
|
+
*
|
|
4
|
+
* A **library, not a policy decision point**. It answers "did the ingress
|
|
5
|
+
* really say this, and who did it name?"; what a role may then do is the
|
|
6
|
+
* product's own `authorize()` (D4a), and Studio's gating lives in the
|
|
7
|
+
* norsk-studio repo.
|
|
8
|
+
*
|
|
9
|
+
* The format itself lives in `@norskvideo/ctl-foundation` so the ingress
|
|
10
|
+
* renderer and the products that read it cannot drift apart; this module is
|
|
11
|
+
* the published door onto it, plus the one thing only the launched side knows
|
|
12
|
+
* — where ctl puts the audience's credentials.
|
|
13
|
+
*/
|
|
14
|
+
export { AUDIENCE_HEADER, AUDIENCE_SECRET_HEADER, CTL_AUDIENCE, CTL_READINESS_PROBE_SUB, ingressAssertionHeaders, instanceAudience, PRINCIPAL_HEADER, productAudience, ROLES_HEADER, verifyIngressAssertion, } from "@norskvideo/ctl-foundation";
|
|
15
|
+
/** The audience this container is, e.g. `urn:norsk:instance:p-1`. */
|
|
16
|
+
export const AUDIENCE_ENV = "NORSK_INGRESS_AUDIENCE";
|
|
17
|
+
/** The secret the ingress stamps on this audience's locations, and on no
|
|
18
|
+
* other's. */
|
|
19
|
+
export const AUDIENCE_SECRET_ENV = "NORSK_INGRESS_AUDIENCE_SECRET";
|
|
20
|
+
/**
|
|
21
|
+
* The audience credentials ctl injected at launch, or `undefined` when it
|
|
22
|
+
* injected none.
|
|
23
|
+
*
|
|
24
|
+
* `undefined` is a state products must handle rather than a fault: RFC 0001
|
|
25
|
+
* keeps old ctl launching new product releases, so a product built against
|
|
26
|
+
* this will run on a runner that has never heard of it. Returning an empty
|
|
27
|
+
* secret instead would hand that product a verifier that accepts everything,
|
|
28
|
+
* which is the opposite of what the absence means.
|
|
29
|
+
*/
|
|
30
|
+
export function audienceFromEnvironment(env = process.env) {
|
|
31
|
+
const audience = env[AUDIENCE_ENV];
|
|
32
|
+
const secret = env[AUDIENCE_SECRET_ENV];
|
|
33
|
+
if (!audience || !secret)
|
|
34
|
+
return undefined;
|
|
35
|
+
return { audience, secret };
|
|
36
|
+
}
|
package/manifest-schema.d.ts
CHANGED
|
@@ -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>;
|
package/manifest-schema.js
CHANGED
|
@@ -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.
|
|
3
|
+
"version": "0.1.38",
|
|
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"
|
package/product-error.d.ts
CHANGED
|
@@ -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
|
}
|
package/product-service.d.ts
CHANGED
|
@@ -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.
|
package/product-service.js
CHANGED
|
@@ -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
|
}
|
package/proxy-middleware.d.ts
CHANGED
|
@@ -4,5 +4,28 @@ interface ProductRegistry {
|
|
|
4
4
|
list(): ProductRegistration[];
|
|
5
5
|
}
|
|
6
6
|
export declare function isOauth2SessionCookieName(name: string): boolean;
|
|
7
|
-
|
|
7
|
+
/** What the DAEMON asserts to a product control plane on this request (RFC
|
|
8
|
+
* 0003 D2b), or `undefined` when it has nothing to assert. */
|
|
9
|
+
export interface ProductAssertion {
|
|
10
|
+
audience: string;
|
|
11
|
+
secret: string;
|
|
12
|
+
sub: string;
|
|
13
|
+
roles: readonly string[];
|
|
14
|
+
}
|
|
15
|
+
export interface ProductProxyOptions {
|
|
16
|
+
/**
|
|
17
|
+
* Mint this request's identity for the product it is addressed to.
|
|
18
|
+
*
|
|
19
|
+
* **Why the daemon and not the ingress.** `/products/<name>` is proxied by
|
|
20
|
+
* the daemon, so the ingress has no location on a product's control plane to
|
|
21
|
+
* stamp — asserting there would mean routing products through nginx
|
|
22
|
+
* directly, which is a different topology. Phase 0 item 4 (E6) made this
|
|
23
|
+
* proxy strip the client's `x-norsk-*`, `Cookie` and `Authorization`, and
|
|
24
|
+
* that strip stays and runs first: what crosses here is not forwarded, it is
|
|
25
|
+
* minted from the decision the daemon has already made, and the secret is the
|
|
26
|
+
* product's own rather than `x-norsk-proxy` (root-equivalent at ctl, E2).
|
|
27
|
+
*/
|
|
28
|
+
assertIdentity?: (req: Request, productName: string) => Promise<ProductAssertion | undefined>;
|
|
29
|
+
}
|
|
30
|
+
export declare function createProductProxyMiddleware(svc: ProductRegistry, opts?: ProductProxyOptions): (req: Request, res: ExpressResponse, next: NextFunction) => Promise<void>;
|
|
8
31
|
export {};
|
package/proxy-middleware.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { logger } from "@norskvideo/ctl-foundation";
|
|
1
|
+
import { ingressAssertionHeaders, logger } from "@norskvideo/ctl-foundation";
|
|
2
2
|
import { buildForwardHeaders, forwardUpstreamResponse, readRequestBody } from "./http-proxy.js";
|
|
3
3
|
import { productBaseUrl } from "./manifest-fetch.js";
|
|
4
4
|
function targetBaseUrl(reg) {
|
|
@@ -42,7 +42,7 @@ function buildProductForwardHeaders(req) {
|
|
|
42
42
|
}
|
|
43
43
|
return headers;
|
|
44
44
|
}
|
|
45
|
-
export function createProductProxyMiddleware(svc) {
|
|
45
|
+
export function createProductProxyMiddleware(svc, opts = {}) {
|
|
46
46
|
return async (req, res, next) => {
|
|
47
47
|
const name = req.params.name;
|
|
48
48
|
if (typeof name !== "string" || !name)
|
|
@@ -59,7 +59,14 @@ export function createProductProxyMiddleware(svc) {
|
|
|
59
59
|
return;
|
|
60
60
|
}
|
|
61
61
|
const targetUrl = `${base}${req.url}`;
|
|
62
|
+
// Strip first, always. The assertion below then SETS its own values, so a
|
|
63
|
+
// client's copies are gone whether or not there is anything to assert.
|
|
62
64
|
const headers = buildProductForwardHeaders(req);
|
|
65
|
+
const asserted = await opts.assertIdentity?.(req, name);
|
|
66
|
+
if (asserted !== undefined) {
|
|
67
|
+
for (const [name, value] of Object.entries(ingressAssertionHeaders(asserted)))
|
|
68
|
+
headers.set(name, value);
|
|
69
|
+
}
|
|
63
70
|
const body = await readRequestBody(req);
|
|
64
71
|
let upstream;
|
|
65
72
|
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
|
+
}
|