@norskvideo/ctl-sdk 0.1.37 → 0.1.39

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
@@ -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";
@@ -15,6 +16,7 @@ export * from "./migration-client.js";
15
16
  export * from "./openapi-router.js";
16
17
  export * from "./product-health-monitor.js";
17
18
  export * from "./product-hub.js";
19
+ export * from "./product-pep.js";
18
20
  export * from "./product-reach.js";
19
21
  export * from "./product-service.js";
20
22
  export * from "./product-template-materials.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";
@@ -17,6 +18,7 @@ export * from "./migration-client.js";
17
18
  export * from "./openapi-router.js";
18
19
  export * from "./product-health-monitor.js";
19
20
  export * from "./product-hub.js";
21
+ export * from "./product-pep.js";
20
22
  export * from "./product-reach.js";
21
23
  export * from "./product-service.js";
22
24
  export * from "./product-template-materials.js";
@@ -0,0 +1,44 @@
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 mode ctl promises this control plane, and the reason it may enforce at
22
+ * all (RFC 0001's 2026-09-21 amendment, condition 3).
23
+ *
24
+ * Declared beside the credentials because it travels with them and means
25
+ * nothing apart from them: a secret without this is a gate ctl has not asked
26
+ * to be live, and this without a secret is a promise ctl cannot keep. Spelled
27
+ * once, here, because ctl writes it and the product reads it -- two spellings
28
+ * is how one side starts sending a word the other stopped honouring.
29
+ */
30
+ export declare const PRODUCT_ACCESS_ENV = "NORSK_PRODUCT_ACCESS";
31
+ /**
32
+ * The audience credentials ctl injected at launch, or `undefined` when it
33
+ * injected none.
34
+ *
35
+ * `undefined` is a state products must handle rather than a fault: RFC 0001
36
+ * keeps old ctl launching new product releases, so a product built against
37
+ * this will run on a runner that has never heard of it. Returning an empty
38
+ * secret instead would hand that product a verifier that accepts everything,
39
+ * which is the opposite of what the absence means.
40
+ */
41
+ export declare function audienceFromEnvironment(env?: Record<string, string | undefined>): {
42
+ audience: string;
43
+ secret: string;
44
+ } | undefined;
@@ -0,0 +1,47 @@
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 mode ctl promises this control plane, and the reason it may enforce at
22
+ * all (RFC 0001's 2026-09-21 amendment, condition 3).
23
+ *
24
+ * Declared beside the credentials because it travels with them and means
25
+ * nothing apart from them: a secret without this is a gate ctl has not asked
26
+ * to be live, and this without a secret is a promise ctl cannot keep. Spelled
27
+ * once, here, because ctl writes it and the product reads it -- two spellings
28
+ * is how one side starts sending a word the other stopped honouring.
29
+ */
30
+ export const PRODUCT_ACCESS_ENV = "NORSK_PRODUCT_ACCESS";
31
+ /**
32
+ * The audience credentials ctl injected at launch, or `undefined` when it
33
+ * injected none.
34
+ *
35
+ * `undefined` is a state products must handle rather than a fault: RFC 0001
36
+ * keeps old ctl launching new product releases, so a product built against
37
+ * this will run on a runner that has never heard of it. Returning an empty
38
+ * secret instead would hand that product a verifier that accepts everything,
39
+ * which is the opposite of what the absence means.
40
+ */
41
+ export function audienceFromEnvironment(env = process.env) {
42
+ const audience = env[AUDIENCE_ENV];
43
+ const secret = env[AUDIENCE_SECRET_ENV];
44
+ if (!audience || !secret)
45
+ return undefined;
46
+ return { audience, secret };
47
+ }
@@ -44,4 +44,4 @@ export declare function waitForReady(baseUrl: string, opts?: {
44
44
  * Soft warnings (future) would return via the unused return-value slot
45
45
  * here. For now both detections are hard fails.
46
46
  */
47
- export declare function probeConfigScreen(baseUrl: string, configScreenUrl: string): Promise<void>;
47
+ export declare function probeConfigScreen(baseUrl: string, configScreenUrl: string, headers?: Record<string, string>): Promise<void>;
package/manifest-fetch.js CHANGED
@@ -96,12 +96,12 @@ export async function waitForReady(baseUrl, opts = {}) {
96
96
  * Soft warnings (future) would return via the unused return-value slot
97
97
  * here. For now both detections are hard fails.
98
98
  */
99
- export async function probeConfigScreen(baseUrl, configScreenUrl) {
99
+ export async function probeConfigScreen(baseUrl, configScreenUrl, headers = {}) {
100
100
  const path = configScreenUrl.startsWith("/") ? configScreenUrl : `/${configScreenUrl}`;
101
101
  const url = `${baseUrl}${path}`;
102
102
  let response;
103
103
  try {
104
- response = await fetch(url, { redirect: "follow" });
104
+ response = await fetch(url, { redirect: "follow", headers });
105
105
  }
106
106
  catch (e) {
107
107
  throw new ProductError("CONFIG_SCREEN_UNREACHABLE", `configScreenUrl '${path}' at ${baseUrl} unreachable: ${String(e)}`);
@@ -15,6 +15,7 @@ export declare const ManifestSchema: z.ZodObject<{
15
15
  version: z.ZodString;
16
16
  minRunnerVersion: z.ZodOptional<z.ZodString>;
17
17
  requires: z.ZodOptional<z.ZodArray<z.ZodString>>;
18
+ accessModes: z.ZodOptional<z.ZodArray<z.ZodString>>;
18
19
  api: z.ZodObject<{
19
20
  basePath: z.ZodString;
20
21
  proxyPaths: z.ZodArray<z.ZodString>;
@@ -99,6 +99,9 @@ export const ManifestSchema = z.object({
99
99
  requires: z.array(z.string().min(1)).optional().meta({
100
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
101
  }),
102
+ accessModes: z.array(z.string().min(1)).optional().meta({
103
+ description: "The NORSK_PRODUCT_ACCESS values this product's control plane understands. The runner sets that variable only when the mode it is in appears here; absent, it sets nothing and the control plane runs with its gate dormant. This is the POSITIVE signal RFC 0001's 2026-09-21 amendment requires before a product may decline to serve: a product must never infer that it was promised enforcement from a missing variable, because a developer running it directly and an older runner launching it are indistinguishable from the inside. Declared on the product manifest rather than a product template because the control plane is started by `docker run` at `product add`, before any template for it exists -- templates are fetched FROM it -- and because this manifest is served by the very image that enforces, so the two cannot drift. A list rather than a boolean: the runner must never send a word this image predates.",
104
+ }),
102
105
  api: z.object({
103
106
  basePath: z.string(),
104
107
  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.37",
3
+ "version": "0.1.39",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {
@@ -0,0 +1,75 @@
1
+ /**
2
+ * The policy enforcement point a product control plane mounts — RFC 0003 D2b
3
+ * and D4a, the product half of what ctl asserts at `/products/<name>`.
4
+ *
5
+ * ctl's own gate on that route is coarse by construction: the route is not a
6
+ * contract operation, so ctl can only read the METHOD, and a product serving a
7
+ * state change behind a GET is not protected by it. This is where a product
8
+ * says what its own routes are worth — D5 keeps that judgement with the product,
9
+ * because only the product knows that `/configure` is builder's work and
10
+ * `/docs` is anyone's.
11
+ *
12
+ * **Two decisions, kept apart.** What rung a person must hold, and whether a
13
+ * machine may call at all, are different questions. Collapsing them means either
14
+ * minting a machine credential strong enough to pass a human's gate — a
15
+ * builder-equivalent nobody reviews — or exempting a path, which stops being
16
+ * true the moment the path moves. `allowWorkloads` is therefore its own opt-in,
17
+ * defaulting to false so a product that has not thought about machines refuses
18
+ * them.
19
+ *
20
+ * **Why enforcement waits for a positive signal.** RFC 0001's 2026-09-21
21
+ * amendment, condition 3: a product may only decline to serve when it KNOWS it
22
+ * was promised enforcement, from a signal the launcher sends — never from the
23
+ * absence of one. From inside the container, a developer running the backend
24
+ * directly and an older ctl launching it are indistinguishable if you reason
25
+ * from a missing variable, and reasoning from absence is the defect RFC 0003
26
+ * has shipped most often. So the gate is dormant until ctl says otherwise.
27
+ */
28
+ import { type AudienceAssertion, type PlatformRole } from "@norskvideo/ctl-foundation";
29
+ import type { Request, RequestHandler } from "express";
30
+ /** What this control plane is doing about access, right now. `dormant` is the
31
+ * shape of every product before ctl promises it anything. */
32
+ export type ProductPepMode = "dormant" | "enforced" | "permissive";
33
+ export type PepDenyReason =
34
+ /** Told to enforce, given nothing to verify with. Never served. */
35
+ "ingress-identity-missing"
36
+ /** Nothing proved ctl sent this, so the identity headers are just headers. */
37
+ | "no-assertion"
38
+ /** Authenticated, but does not hold the rung this route asks for. */
39
+ | "insufficient-role"
40
+ /** Authenticated as a machine on a route that admits only people. */
41
+ | "workload-not-admitted";
42
+ export interface PepDecision {
43
+ allow: boolean;
44
+ reason?: PepDenyReason;
45
+ sub?: string;
46
+ roles?: readonly string[];
47
+ /** Whether this was served despite a refusal, because the mode is permissive. */
48
+ unenforced?: boolean;
49
+ }
50
+ export interface ProductPepOptions {
51
+ env?: Record<string, string | undefined>;
52
+ /** Every decision, allow and refuse alike. A record that only explains its
53
+ * refusals leaves the allows to be inferred — and under `permissive` the
54
+ * record is the entire point of the exercise. */
55
+ onDecision?: (decision: PepDecision) => void;
56
+ }
57
+ export interface GateOptions {
58
+ /** The weakest rung a PERSON must hold. Omit to require only that ctl
59
+ * asserted somebody. */
60
+ role?: PlatformRole;
61
+ /** Whether a machine principal may call this route at all — ctl's own
62
+ * config-screen probe and template fetches are the ones that matter.
63
+ * Defaults to false: a route that has not said yes refuses them. */
64
+ allowWorkloads?: boolean;
65
+ }
66
+ export interface ProductPep {
67
+ readonly mode: ProductPepMode;
68
+ /** One line an operator can read without having watched the boot — the
69
+ * amendment's "never silent". */
70
+ describe(): string;
71
+ gate(options?: GateOptions): RequestHandler;
72
+ /** What ctl asserted about this request, once a gate has admitted it. */
73
+ identityOf(req: Request): AudienceAssertion | undefined;
74
+ }
75
+ export declare function createProductPep(options?: ProductPepOptions): ProductPep;
package/product-pep.js ADDED
@@ -0,0 +1,115 @@
1
+ /**
2
+ * The policy enforcement point a product control plane mounts — RFC 0003 D2b
3
+ * and D4a, the product half of what ctl asserts at `/products/<name>`.
4
+ *
5
+ * ctl's own gate on that route is coarse by construction: the route is not a
6
+ * contract operation, so ctl can only read the METHOD, and a product serving a
7
+ * state change behind a GET is not protected by it. This is where a product
8
+ * says what its own routes are worth — D5 keeps that judgement with the product,
9
+ * because only the product knows that `/configure` is builder's work and
10
+ * `/docs` is anyone's.
11
+ *
12
+ * **Two decisions, kept apart.** What rung a person must hold, and whether a
13
+ * machine may call at all, are different questions. Collapsing them means either
14
+ * minting a machine credential strong enough to pass a human's gate — a
15
+ * builder-equivalent nobody reviews — or exempting a path, which stops being
16
+ * true the moment the path moves. `allowWorkloads` is therefore its own opt-in,
17
+ * defaulting to false so a product that has not thought about machines refuses
18
+ * them.
19
+ *
20
+ * **Why enforcement waits for a positive signal.** RFC 0001's 2026-09-21
21
+ * amendment, condition 3: a product may only decline to serve when it KNOWS it
22
+ * was promised enforcement, from a signal the launcher sends — never from the
23
+ * absence of one. From inside the container, a developer running the backend
24
+ * directly and an older ctl launching it are indistinguishable if you reason
25
+ * from a missing variable, and reasoning from absence is the defect RFC 0003
26
+ * has shipped most often. So the gate is dormant until ctl says otherwise.
27
+ */
28
+ import { isWorkloadPrincipal, roleSatisfies, verifyIngressAssertion, } from "@norskvideo/ctl-foundation";
29
+ import { AUDIENCE_ENV, AUDIENCE_SECRET_ENV, PRODUCT_ACCESS_ENV } from "./ingress-identity.js";
30
+ const IDENTITY = Symbol("norsk.ingress.identity");
31
+ function refuse(res, status, reason, message) {
32
+ // The SDK's route error shape: a stable label plus what happened.
33
+ res.status(status).json({ error: reason, message });
34
+ }
35
+ export function createProductPep(options = {}) {
36
+ const env = options.env ?? process.env;
37
+ const declared = env[PRODUCT_ACCESS_ENV];
38
+ const audience = env[AUDIENCE_ENV];
39
+ const secret = env[AUDIENCE_SECRET_ENV];
40
+ // An unrecognised word is treated as `enforced`, never as dormant: ctl only
41
+ // ever sends a mode this product named (membership, not copy), so a value we
42
+ // do not know means something is wrong, and the safe reading of "something is
43
+ // wrong" about a security control is not "carry on unguarded".
44
+ const mode = declared === undefined ? "dormant" : declared === "permissive" ? "permissive" : "enforced";
45
+ const record = (decision) => {
46
+ options.onDecision?.(decision);
47
+ return decision;
48
+ };
49
+ function describe() {
50
+ if (mode === "dormant") {
51
+ return `access control is DORMANT: ${PRODUCT_ACCESS_ENV} is not set, so this control plane was promised no enforcement and serves every caller. This is the shape of a product run directly, or launched by a runner that does not gate it.`;
52
+ }
53
+ if (audience === undefined || secret === undefined) {
54
+ return `access control is ${mode.toUpperCase()} but ${AUDIENCE_ENV}/${AUDIENCE_SECRET_ENV} are not both set, so nothing can be verified and every gated route refuses.`;
55
+ }
56
+ return `access control is ${mode.toUpperCase()} for ${audience}.`;
57
+ }
58
+ return {
59
+ mode,
60
+ describe,
61
+ identityOf: (req) => req[IDENTITY],
62
+ gate(gateOptions = {}) {
63
+ return (req, res, next) => {
64
+ if (mode === "dormant") {
65
+ record({ allow: true });
66
+ next();
67
+ return;
68
+ }
69
+ // Promised enforcement, handed nothing to enforce with. Refused in both
70
+ // modes -- `permissive` relaxes a verdict the policy reached, and this
71
+ // is the absence of any means to reach one.
72
+ if (audience === undefined || secret === undefined) {
73
+ record({ allow: false, reason: "ingress-identity-missing" });
74
+ refuse(res, 503, "ingress-identity-missing", describe());
75
+ return;
76
+ }
77
+ const served = (decision, status, message) => {
78
+ if (mode === "permissive") {
79
+ record({ ...decision, unenforced: true });
80
+ next();
81
+ return;
82
+ }
83
+ record(decision);
84
+ refuse(res, status, decision.reason, message);
85
+ };
86
+ const verified = verifyIngressAssertion(req.headers, { audience, secret });
87
+ if (verified.status !== "ok") {
88
+ served({ allow: false, reason: "no-assertion" }, 401, `this request carries no identity this control plane can trust: ${verified.error.message}`);
89
+ return;
90
+ }
91
+ const assertion = verified.value;
92
+ req[IDENTITY] = assertion;
93
+ if (isWorkloadPrincipal(assertion.sub)) {
94
+ // A machine holds no human rung, so the rung is not the question and
95
+ // must not be consulted -- comparing would refuse every machine on a
96
+ // route that deliberately admits them.
97
+ if (gateOptions.allowWorkloads !== true) {
98
+ served({ allow: false, reason: "workload-not-admitted", sub: assertion.sub }, 403, `${assertion.sub} is a machine, and this route admits only people`);
99
+ return;
100
+ }
101
+ record({ allow: true, sub: assertion.sub, roles: assertion.roles });
102
+ next();
103
+ return;
104
+ }
105
+ const required = gateOptions.role;
106
+ if (required !== undefined && !assertion.roles.some((held) => roleSatisfies(held, required))) {
107
+ served({ allow: false, reason: "insufficient-role", sub: assertion.sub, roles: assertion.roles }, 403, `this route needs ${required}`);
108
+ return;
109
+ }
110
+ record({ allow: true, sub: assertion.sub, roles: assertion.roles });
111
+ next();
112
+ };
113
+ },
114
+ };
115
+ }
@@ -1,5 +1,6 @@
1
1
  import { type ProductPlacement } from "./docker-runner.js";
2
2
  import { type LicenseStager } from "./license-registration.js";
3
+ import type { Manifest } from "./manifest-schema.js";
3
4
  import { type ProductReach } from "./product-reach.js";
4
5
  import type { ProductTemplateSource } from "./product-template-record.js";
5
6
  import type { ProductLicense, ProductRegistration, ProductSpec } from "./product-types.js";
@@ -125,6 +126,47 @@ export interface ProductServiceOptions {
125
126
  * whose manifest `requires` any other is refused at add and reload.
126
127
  * Default: none. */
127
128
  knownCapabilities?: readonly string[];
129
+ /**
130
+ * The ingress identity to run this product's control plane with (RFC 0003
131
+ * D2b): the audience it is, and the secret the daemon stamps on requests to
132
+ * it and to no other audience.
133
+ *
134
+ * Deliberately NOT part of `ProductSpec`. The spec is persisted on the
135
+ * registration and serialised by `GET /api/v1/products`, so a secret living
136
+ * there would leak; this is re-derived from the host's own custody on every
137
+ * path that runs a container. Hosts that mint nothing return undefined and
138
+ * the product is launched exactly as before — the absence is a state
139
+ * products must handle (RFC 0001), not a fault.
140
+ *
141
+ * The manifest comes too, because the host also decides here whether to tell
142
+ * this control plane to ENFORCE, and that has to be read off what the product
143
+ * declared (`accessModes`). On `add` it is the manifest just fetched from the
144
+ * container; on the relaunch paths it is the stored one, so a product that
145
+ * starts declaring a mode is taken at its word from the reload that stores it
146
+ * rather than the run that precedes it. `undefined` where no manifest is known
147
+ * yet.
148
+ */
149
+ identityEnvFor?: (productName: string, manifest: Manifest | undefined) => Record<string, string> | undefined;
150
+ /** Drop whatever `identityEnvFor` minted, once the registration holding it
151
+ * has gone. */
152
+ forgetIdentity?: (productName: string) => void;
153
+ /**
154
+ * What the daemon asserts when it dials a REGISTERED product's own routes —
155
+ * the config-screen probe and the product-template fetches (RFC 0003 D7's
156
+ * in-stack service→service row).
157
+ *
158
+ * These calls carried nothing, which is fine while no product gates anything
159
+ * and fatal the moment one does: `probeConfigScreen` treats a non-2xx as a
160
+ * hard rejection, so a product gating its own config screen would fail
161
+ * `product add` outright.
162
+ *
163
+ * Deliberately NOT the operator who triggered the add — this is ctl calling
164
+ * in its own name, so the `sub` is a workload's and it carries no human rung.
165
+ * `/manifest.json` is not covered and cannot be: readiness and the manifest
166
+ * fetch happen during the anonymous first run, before the name is known, so
167
+ * there is no secret to assert with.
168
+ */
169
+ probeHeadersFor?: (productName: string) => Record<string, string> | undefined;
128
170
  }
129
171
  export declare class ProductService {
130
172
  /**
@@ -150,7 +192,16 @@ export declare class ProductService {
150
192
  private readonly containerOps;
151
193
  private readonly reach;
152
194
  private readonly knownCapabilities;
195
+ private readonly identityEnvFor?;
196
+ private readonly forgetIdentity?;
197
+ private readonly probeHeadersFor?;
153
198
  constructor(opts: ProductServiceOptions);
199
+ /** The environment a (re)launch of `productName`'s control plane runs with:
200
+ * the spec's deploy-time settings, with the freshly minted ingress identity
201
+ * last. The ordering is load-bearing — an operator who got a reserved key
202
+ * past the API boundary would otherwise hand themselves a forged identity,
203
+ * and the identity is the daemon's decision, never theirs. */
204
+ private runEnvFor;
154
205
  /** The address to record for a freshly started container: its own, when
155
206
  * this daemon cannot dial the host loopback; otherwise nothing. */
156
207
  private reachHostFor;
@@ -12,11 +12,11 @@ import { checkRequiredCapabilities } from "./runner-capabilities.js";
12
12
  * declared; we treat it as a path on the product's HTTP surface and
13
13
  * resolve against the product's base URL. Returns the raw tar bytes,
14
14
  * or throws ProductError on any non-2xx / network failure. */
15
- async function fetchProductTemplateBytes(baseUrl, url) {
15
+ async function fetchProductTemplateBytes(baseUrl, url, headers = {}) {
16
16
  const full = `${baseUrl.replace(/\/$/, "")}/${url.replace(/^\//, "")}`;
17
17
  let response;
18
18
  try {
19
- response = await fetch(full);
19
+ response = await fetch(full, { headers });
20
20
  }
21
21
  catch (e) {
22
22
  throw new ProductError("PRODUCT_TEMPLATE_FETCH_FAILED", `fetch ${full} threw: ${String(e)}`);
@@ -60,6 +60,9 @@ export class ProductService {
60
60
  containerOps;
61
61
  reach;
62
62
  knownCapabilities;
63
+ identityEnvFor;
64
+ forgetIdentity;
65
+ probeHeadersFor;
63
66
  constructor(opts) {
64
67
  this.store = opts.store;
65
68
  this.importProductTemplateBytes = opts.importProductTemplateBytes;
@@ -69,6 +72,20 @@ export class ProductService {
69
72
  this.containerOps = opts.containerOps ?? defaultProductContainerOps;
70
73
  this.reach = opts.reach ?? detectProductReach({});
71
74
  this.knownCapabilities = opts.knownCapabilities ?? [];
75
+ this.identityEnvFor = opts.identityEnvFor;
76
+ this.forgetIdentity = opts.forgetIdentity;
77
+ this.probeHeadersFor = opts.probeHeadersFor;
78
+ }
79
+ /** The environment a (re)launch of `productName`'s control plane runs with:
80
+ * the spec's deploy-time settings, with the freshly minted ingress identity
81
+ * last. The ordering is load-bearing — an operator who got a reserved key
82
+ * past the API boundary would otherwise hand themselves a forged identity,
83
+ * and the identity is the daemon's decision, never theirs. */
84
+ runEnvFor(productName, specEnv, manifest) {
85
+ const identity = this.identityEnvFor?.(productName, manifest);
86
+ if (identity === undefined)
87
+ return specEnv;
88
+ return { ...specEnv, ...identity };
72
89
  }
73
90
  /** The address to record for a freshly started container: its own, when
74
91
  * this daemon cannot dial the host loopback; otherwise nothing. */
@@ -213,6 +230,32 @@ export class ProductService {
213
230
  warnings.push(...check.warnings);
214
231
  }
215
232
  }
233
+ // RFC 0003 D2b. A control plane can only be handed its own ingress
234
+ // identity once we know WHICH product it is, and the name comes from the
235
+ // manifest the container itself serves — so the first run is necessarily
236
+ // anonymous and the container is recreated with the identity here, after
237
+ // every refusal above and before anything is registered. An add that is
238
+ // going to be rejected therefore never mints, and never starts a second
239
+ // container. Dev products run no container of ours, so there is nowhere to
240
+ // inject the pair; they assert nothing.
241
+ if (containerId !== undefined && spec.kind === "container") {
242
+ const identity = this.identityEnvFor?.(manifest.name, manifest);
243
+ if (identity !== undefined) {
244
+ await this.containerOps.remove(containerId);
245
+ const placement = await this.containerOps.run(spec.image, { ...spec.env, ...identity });
246
+ containerId = placement.containerId;
247
+ port = placement.hostPort;
248
+ reachHost = await this.reachHostFor(containerId);
249
+ baseUrl = specBaseUrl(spec, port, reachHost);
250
+ try {
251
+ await this.containerOps.waitForReady(baseUrl);
252
+ }
253
+ catch (e) {
254
+ await this.containerOps.remove(containerId);
255
+ throw e;
256
+ }
257
+ }
258
+ }
216
259
  // Now that the manifest name is known and unique, give the container a
217
260
  // stable, human-readable name (replacing docker's random alias). Tracked
218
261
  // by id, so a rename failure is harmless — hence best-effort.
@@ -224,7 +267,7 @@ export class ProductService {
224
267
  // because iframed product UIs need the built bundle to function.
225
268
  if (manifest.ui?.configScreenUrl) {
226
269
  try {
227
- await probeConfigScreen(baseUrl, manifest.ui.configScreenUrl);
270
+ await probeConfigScreen(baseUrl, manifest.ui.configScreenUrl, this.probeHeadersFor?.(manifest.name) ?? {});
228
271
  }
229
272
  catch (e) {
230
273
  if (containerId)
@@ -260,7 +303,7 @@ export class ProductService {
260
303
  if (manifest.defaultProductTemplates.length > 0 && this.importProductTemplateBytes) {
261
304
  for (const entry of manifest.defaultProductTemplates) {
262
305
  try {
263
- const bytes = await fetchProductTemplateBytes(baseUrl, entry.url);
306
+ const bytes = await fetchProductTemplateBytes(baseUrl, entry.url, this.probeHeadersFor?.(manifest.name) ?? {});
264
307
  await this.importProductTemplateBytes({
265
308
  name: entry.name,
266
309
  bytes,
@@ -291,6 +334,11 @@ export class ProductService {
291
334
  if (target.containerId)
292
335
  await this.containerOps.remove(target.containerId);
293
336
  await this.store.update((products) => products.filter((p) => p.name !== name));
337
+ // RFC 0003 D2b. The container that held this product's ingress secret has
338
+ // gone; the store's copy would otherwise outlive it as a live credential
339
+ // for a product nothing routes to. Safe because every launch mints afresh,
340
+ // so a re-added product of the same name is stamped anew.
341
+ this.forgetIdentity?.(name);
294
342
  logger.info(`Product '${name}' removed`);
295
343
  }
296
344
  /** Bring a registration up to date with what its image now serves. For a
@@ -322,7 +370,7 @@ export class ProductService {
322
370
  // Already gone (crashed / --rm reaped) — nothing to stop.
323
371
  }
324
372
  }
325
- const { containerId, hostPort } = await this.containerOps.run(target.spec.image, target.spec.env);
373
+ const { containerId, hostPort } = await this.containerOps.run(target.spec.image, this.runEnvFor(name, target.spec.env, target.manifest));
326
374
  const reachHost = await this.reachHostFor(containerId);
327
375
  await this.store.update((products) => products.map((p) => (p.name === name ? withPlacement(p, containerId, hostPort, reachHost) : p)));
328
376
  await this.containerOps.waitForReady(specBaseUrl(target.spec, hostPort, reachHost));
@@ -349,7 +397,7 @@ export class ProductService {
349
397
  if (this.refreshProductTemplateBytes) {
350
398
  for (const entry of manifest.defaultProductTemplates) {
351
399
  try {
352
- const bytes = await fetchProductTemplateBytes(baseUrl, entry.url);
400
+ const bytes = await fetchProductTemplateBytes(baseUrl, entry.url, this.probeHeadersFor?.(manifest.name) ?? {});
353
401
  await this.refreshProductTemplateBytes({
354
402
  name: entry.name,
355
403
  bytes,
@@ -416,7 +464,7 @@ export class ProductService {
416
464
  return;
417
465
  }
418
466
  try {
419
- const { containerId, hostPort } = await this.containerOps.run(reg.spec.image, reg.spec.env);
467
+ const { containerId, hostPort } = await this.containerOps.run(reg.spec.image, this.runEnvFor(reg.name, reg.spec.env, reg.manifest));
420
468
  const reachHost = await this.reachHostFor(containerId);
421
469
  await this.containerOps.waitForReady(specBaseUrl(reg.spec, hostPort, reachHost));
422
470
  await this.containerOps.rename(containerId, productContainerName(reg.name));
@@ -465,7 +513,7 @@ export class ProductService {
465
513
  // Already gone (crashed / --rm reaped) — nothing to stop.
466
514
  }
467
515
  }
468
- const { containerId, hostPort } = await this.containerOps.run(target.spec.image, target.spec.env);
516
+ const { containerId, hostPort } = await this.containerOps.run(target.spec.image, this.runEnvFor(name, target.spec.env, target.manifest));
469
517
  const reachHost = await this.reachHostFor(containerId);
470
518
  await this.store.update((products) => products.map((p) => (p.name === name ? withPlacement(p, containerId, hostPort, reachHost) : p)));
471
519
  await this.containerOps.waitForReady(specBaseUrl(target.spec, hostPort, reachHost));
@@ -4,5 +4,28 @@ interface ProductRegistry {
4
4
  list(): ProductRegistration[];
5
5
  }
6
6
  export declare function isOauth2SessionCookieName(name: string): boolean;
7
- export declare function createProductProxyMiddleware(svc: ProductRegistry): (req: Request, res: ExpressResponse, next: NextFunction) => Promise<void>;
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 {};
@@ -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 {