@norskvideo/ctl-sdk 0.1.37 → 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 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";
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";
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@norskvideo/ctl-sdk",
3
- "version": "0.1.37",
3
+ "version": "0.1.38",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {
@@ -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 {