@norskvideo/ctl-test-harness 0.1.63 → 0.1.65

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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/source-pump.js +58 -19
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@norskvideo/ctl-test-harness",
3
- "version": "0.1.63",
3
+ "version": "0.1.65",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {
package/source-pump.js CHANGED
@@ -65,21 +65,52 @@ function secretForDaemonPort(port) {
65
65
  const storeDir = storeDirForDaemonPort(port);
66
66
  return storeDir === undefined ? undefined : readProxySecret(storeDir);
67
67
  }
68
+ /** The secret to stamp on the NEXT request, resolved per request rather than
69
+ * once per call.
70
+ *
71
+ * The ingress guard issues a fresh `crypto.randomUUID()` and REWRITES
72
+ * `<store>/proxy-secret` on every proxy configure, and the proxy is recreated
73
+ * mid-run whenever its mounts or ports stop matching the expected config. So a
74
+ * secret read once and reused for a 120s poll goes stale the moment that
75
+ * happens: the POST is admitted, a recreation follows, and every later GET is
76
+ * 307'd until the window runs out. (commentary's integration, red from
77
+ * 2026-09-24 — the earlier fix covered only the never-sent-one case.)
78
+ *
79
+ * A caller-supplied `proxySecret` is returned as given: it belongs to a daemon
80
+ * this process did not start, so this process has no store to re-read, and
81
+ * keeping it fresh is the caller's. */
82
+ function currentSecret(opts) {
83
+ return opts.proxySecret ?? secretForDaemonPort(opts.daemonPort);
84
+ }
68
85
  /** A 307/308 off `/api/*` is the daemon's guard, not a route that moved: the
69
86
  * API itself never redirects. */
70
87
  function isGuardRedirect(res) {
71
88
  return res.status === 307 || res.status === 308;
72
89
  }
73
- function guardRedirectError(method, res, sentSecret) {
90
+ /** Why the guard refused, in the words of what this process can actually see.
91
+ *
92
+ * `sentSecret` is the secret that WAS stamped on the refused request, not the
93
+ * caller's `proxySecret` option. Reporting the option instead made every
94
+ * refusal claim "no secret was sent / this process started no daemon on that
95
+ * port" — including the ones where a secret came from the registry and was
96
+ * simply stale, which is the case that actually had commentary red. The
97
+ * wrong half of the message is the half an operator acts on. */
98
+ function guardRedirectError(method, res, sentSecret, storeKnown) {
74
99
  const where = res.headers.get("location");
75
- return new Error(`${method} /api/sources was refused by the daemon's ingress guard (${res.status} -> ${where ?? "the ingress port"}). ` +
76
- (sentSecret
77
- ? "The x-norsk-proxy secret that was sent is not the one this daemon holds — it is re-issued on start, so a " +
78
- "secret read before the daemon booted is stale (read it after `ready`)."
100
+ const why = sentSecret
101
+ ? "The x-norsk-proxy secret that was sent is not the one this daemon holds. The ingress re-issues it and rewrites " +
102
+ "<store>/proxy-secret on every proxy configure — including a mid-run recreation — so a secret cached for the " +
103
+ "length of a poll goes stale."
104
+ : storeKnown
105
+ ? "No x-norsk-proxy secret was sent: this process started the daemon on that port, but its store holds no " +
106
+ "proxy-secret yet. The ingress writes it as the proxy comes up, so either the proxy has not come up or this " +
107
+ "daemon is `test-unguarded`."
79
108
  : "No x-norsk-proxy secret was sent, and RFC 0003 D10b removed the loopback bypass, so a local caller needs " +
80
109
  "one too. This process started no daemon on that port, so there was no store to read one from: either start " +
81
110
  "it through the harness's startDaemon, or read the secret yourself with `readProxySecret(storeDir)` and pass " +
82
- "it as `proxySecret`.") +
111
+ "it as `proxySecret`.";
112
+ return new Error(`${method} /api/sources was refused by the daemon's ingress guard (${res.status} -> ${where ?? "the ingress port"}). ` +
113
+ `${why}` +
83
114
  " Following this redirect instead is what turns a missing secret into an unrelated-looking ConnectionRefused or " +
84
115
  "a `Failed to parse JSON` on nginx's HTML.");
85
116
  }
@@ -90,12 +121,14 @@ export async function startCameraSource(opts) {
90
121
  const asset = opts.asset ?? { preset: opts.preset ?? "camera1" };
91
122
  const name = opts.name ?? defaultSourceName(asset);
92
123
  const fetchImpl = opts.fetch ?? ((url, init) => fetch(url, init));
93
- // Resolved here, not at the call site: every caller has the port and almost
94
- // none has the store dir, which is exactly why the explicit option went
95
- // unpassed. Read now rather than when the daemon was registered — the guard
96
- // issues the secret as the proxy comes up, after startDaemon returns.
97
- const secret = opts.proxySecret ?? secretForDaemonPort(opts.daemonPort);
98
- const headers = secret ? { "x-norsk-proxy": secret } : {};
124
+ // Resolved per request, not at the call site: every caller has the port and
125
+ // almost none has the store dir, which is exactly why the explicit option
126
+ // went unpassed — and the secret is re-issued under us, so one read per call
127
+ // is not enough (see currentSecret).
128
+ const stamp = () => {
129
+ const secret = currentSecret(opts);
130
+ return { secret, headers: secret ? { "x-norsk-proxy": secret } : {} };
131
+ };
99
132
  const base = `http://localhost:${opts.daemonPort}`;
100
133
  const body = {
101
134
  instanceId: opts.instanceId,
@@ -105,9 +138,10 @@ export async function startCameraSource(opts) {
105
138
  };
106
139
  if (opts.streamId !== undefined)
107
140
  body.streamId = opts.streamId;
141
+ const post = stamp();
108
142
  const startRes = await fetchImpl(`${base}/api/sources`, {
109
143
  method: "POST",
110
- headers: { ...headers, "Content-Type": "application/json" },
144
+ headers: { ...post.headers, "Content-Type": "application/json" },
111
145
  body: JSON.stringify(body),
112
146
  // MANUAL. The guard answers an unsecreted /api/* with a 307 to the ingress
113
147
  // port, and a followed redirect hides which call was refused: on a runner
@@ -118,14 +152,16 @@ export async function startCameraSource(opts) {
118
152
  redirect: "manual",
119
153
  signal: AbortSignal.timeout(10_000),
120
154
  });
121
- if (isGuardRedirect(startRes))
122
- throw guardRedirectError("POST", startRes, opts.proxySecret);
155
+ if (isGuardRedirect(startRes)) {
156
+ throw guardRedirectError("POST", startRes, post.secret, storeDirForDaemonPort(opts.daemonPort) !== undefined);
157
+ }
123
158
  if (!startRes.ok) {
124
159
  throw new Error(`POST /api/sources failed (${startRes.status}): ${await startRes.text()}`);
125
160
  }
126
161
  await pollUntil(async () => {
162
+ const poll = stamp();
127
163
  const r = await fetchImpl(`${base}/api/sources?instanceId=${encodeURIComponent(opts.instanceId)}`, {
128
- headers,
164
+ headers: poll.headers,
129
165
  redirect: "manual",
130
166
  signal: AbortSignal.timeout(5000),
131
167
  });
@@ -133,8 +169,9 @@ export async function startCameraSource(opts) {
133
169
  // condition, and pollUntil carries the last error into its timeout
134
170
  // message. Returning false spends the whole window and then says only
135
171
  // that the source never ran.
136
- if (isGuardRedirect(r))
137
- throw guardRedirectError("GET", r, opts.proxySecret);
172
+ if (isGuardRedirect(r)) {
173
+ throw guardRedirectError("GET", r, poll.secret, storeDirForDaemonPort(opts.daemonPort) !== undefined);
174
+ }
138
175
  if (!r.ok)
139
176
  return false;
140
177
  // GET /api/sources returns a bare array of SampleSource — not { sources: [...] }.
@@ -151,7 +188,9 @@ export async function startCameraSource(opts) {
151
188
  async stop() {
152
189
  await fetchImpl(`${base}/api/sources/${encodeURIComponent(opts.instanceId)}/${encodeURIComponent(name)}`, {
153
190
  method: "DELETE",
154
- headers,
191
+ // Stamped now, not at launch: teardown is the furthest a cached secret
192
+ // ever gets from the one the ingress currently holds.
193
+ headers: stamp().headers,
155
194
  // Swallowed either way (stopAll sweeps the instance), but not chased:
156
195
  // following the guard's redirect to a port this process may not reach
157
196
  // spends the full 10s timeout on every source at teardown.