@forwardimpact/libutil 0.1.87 → 0.1.89

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.
@@ -2,25 +2,16 @@
2
2
 
3
3
  import "@forwardimpact/libpreflight/node22";
4
4
 
5
- import { readFileSync } from "node:fs";
6
5
  import { spawn } from "node:child_process";
7
6
  import { createCli } from "@forwardimpact/libcli";
8
7
  import { createScriptConfig } from "@forwardimpact/libconfig";
9
8
  import { createStorage } from "@forwardimpact/libstorage";
10
9
  import { createLogger } from "@forwardimpact/libtelemetry";
11
10
  import { createBundleDownloader, execLine } from "@forwardimpact/libutil";
12
-
13
- // `bun build --compile` injects FIT_DOWNLOAD_BUNDLE_VERSION via --define,
14
- // eliminating the readFileSync branch in the compiled binary (which would
15
- // ENOENT against the bunfs virtual mount). Source execution falls through.
16
- const VERSION =
17
- process.env.FIT_DOWNLOAD_BUNDLE_VERSION ||
18
- JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"))
19
- .version;
11
+ import { createDefaultRuntime } from "@forwardimpact/libutil/runtime";
20
12
 
21
13
  const definition = {
22
14
  name: "fit-download-bundle",
23
- version: VERSION,
24
15
  description: "Download generated code bundle from remote storage",
25
16
  globalOptions: {
26
17
  help: { type: "boolean", short: "h", description: "Show this help" },
@@ -29,8 +20,12 @@ const definition = {
29
20
  },
30
21
  };
31
22
 
32
- const cli = createCli(definition);
33
- const logger = createLogger("generated");
23
+ const runtime = createDefaultRuntime();
24
+ const cli = createCli(definition, {
25
+ runtime,
26
+ packageJsonUrl: new URL("../package.json", import.meta.url),
27
+ });
28
+ const logger = createLogger("generated", runtime);
34
29
 
35
30
  /**
36
31
  * Downloads generated code bundle from remote storage.
@@ -42,7 +37,7 @@ async function main() {
42
37
  if (!parsed) process.exit(0);
43
38
 
44
39
  await createScriptConfig("download-bundle");
45
- const downloader = createBundleDownloader(createStorage, logger);
40
+ const downloader = createBundleDownloader(createStorage, logger, runtime);
46
41
  await downloader.download();
47
42
 
48
43
  // If additional arguments provided, execute them after download
@@ -1,22 +1,13 @@
1
1
  #!/usr/bin/env node
2
2
  import "@forwardimpact/libpreflight/node22";
3
3
 
4
- import { readFileSync } from "node:fs";
5
4
  import { createCli } from "@forwardimpact/libcli";
5
+ import { createDefaultRuntime } from "@forwardimpact/libutil/runtime";
6
6
  import { createLogger } from "@forwardimpact/libtelemetry";
7
7
  import { countTokens } from "@forwardimpact/libutil";
8
8
 
9
- // `bun build --compile` injects FIT_TIKTOKEN_VERSION via --define, eliminating
10
- // the readFileSync branch in the compiled binary (which would ENOENT against
11
- // the bunfs virtual mount). Source execution falls through to package.json.
12
- const VERSION =
13
- process.env.FIT_TIKTOKEN_VERSION ||
14
- JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"))
15
- .version;
16
-
17
9
  const definition = {
18
10
  name: "fit-tiktoken",
19
- version: VERSION,
20
11
  description: "Count tokens in text",
21
12
  usage: "fit-tiktoken <text>\n echo 'text' | fit-tiktoken",
22
13
  globalOptions: {
@@ -27,8 +18,12 @@ const definition = {
27
18
  examples: ['fit-tiktoken "hello world"', "echo 'hello world' | fit-tiktoken"],
28
19
  };
29
20
 
30
- const cli = createCli(definition);
31
- const logger = createLogger("tiktoken");
21
+ const runtime = createDefaultRuntime();
22
+ const cli = createCli(definition, {
23
+ runtime,
24
+ packageJsonUrl: new URL("../package.json", import.meta.url),
25
+ });
26
+ const logger = createLogger("tiktoken", runtime);
32
27
 
33
28
  /**
34
29
  * Counts tokens in the provided text
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forwardimpact/libutil",
3
- "version": "0.1.87",
3
+ "version": "0.1.89",
4
4
  "description": "Cross-cutting utilities: retry, hashing, token counting, and project discovery.",
5
5
  "keywords": [
6
6
  "util",
@@ -22,6 +22,8 @@
22
22
  "exports": {
23
23
  ".": "./src/index.js",
24
24
  "./runtime": "./src/runtime.js",
25
+ "./trusted-origins": "./src/trusted-origins.js",
26
+ "./completion-ticket": "./src/completion-ticket.js",
25
27
  "./git-client": "./src/git-client.js",
26
28
  "./gh-client": "./src/gh-client.js",
27
29
  "./bin/fit-download-bundle.js": "./bin/fit-download-bundle.js",
@@ -0,0 +1,163 @@
1
+ import { createHmac, timingSafeEqual } from "node:crypto";
2
+
3
+ import { isTrusted } from "./trusted-origins.js";
4
+
5
+ /** Ticket lifetime in milliseconds. 5 minutes covers IdP round-trip + redirect
6
+ * with margin and is well inside browser/proxy URL-lifetime norms. */
7
+ export const TICKET_TTL_MS = 5 * 60 * 1000;
8
+
9
+ /**
10
+ * Canonical-JSON encoding of the ticket payload. Keys are sorted alphabetically
11
+ * (`exp, idp_origin, link_token, surface_user_id`) so the wire bytes do not
12
+ * depend on input-object property iteration order — minting the same claims
13
+ * twice produces byte-identical payloads.
14
+ *
15
+ * @param {object} claims
16
+ * @param {number} claims.exp Absolute ms-since-epoch expiry.
17
+ * @param {string} claims.idpOrigin Normalised IdP origin (`new URL(…).origin`).
18
+ * @param {string} claims.linkToken Opaque link-token claim.
19
+ * @param {string} claims.surfaceUserId Caller surface user id.
20
+ * @returns {string} Canonical JSON.
21
+ */
22
+ function canonicalJson({ exp, idpOrigin, linkToken, surfaceUserId }) {
23
+ return JSON.stringify({
24
+ exp,
25
+ idp_origin: idpOrigin,
26
+ link_token: linkToken,
27
+ surface_user_id: surfaceUserId,
28
+ });
29
+ }
30
+
31
+ function b64urlEncode(bufOrStr) {
32
+ const buf =
33
+ typeof bufOrStr === "string" ? Buffer.from(bufOrStr, "utf8") : bufOrStr;
34
+ return buf
35
+ .toString("base64")
36
+ .replace(/\+/g, "-")
37
+ .replace(/\//g, "_")
38
+ .replace(/=+$/g, "");
39
+ }
40
+
41
+ function b64urlDecodeToBuffer(s) {
42
+ const pad = s.length % 4 === 0 ? "" : "=".repeat(4 - (s.length % 4));
43
+ return Buffer.from(s.replace(/-/g, "+").replace(/_/g, "/") + pad, "base64");
44
+ }
45
+
46
+ function sign(secret, payloadB64) {
47
+ return createHmac("sha256", secret).update(payloadB64).digest();
48
+ }
49
+
50
+ /**
51
+ * Mint a completion ticket for the bridge to verify after the IdP round-trip.
52
+ * Wire form: `<base64url(canonicalJson)>.<base64url(hmacSha256(secret, payload))>`.
53
+ *
54
+ * @param {object} args
55
+ * @param {string} args.linkToken Opaque link-token bound to a queued dispatch.
56
+ * @param {string} args.surfaceUserId Caller surface user id.
57
+ * @param {string} args.idpOrigin Normalised IdP origin (`new URL(…).origin`).
58
+ * @param {string} args.secret Shared HMAC secret. Must match the verifier's.
59
+ * @param {number} args.now Absolute ms-since-epoch.
60
+ * @returns {string} Wire-form ticket.
61
+ */
62
+ export function mintCompletionTicket({
63
+ linkToken,
64
+ surfaceUserId,
65
+ idpOrigin,
66
+ secret,
67
+ now,
68
+ }) {
69
+ const exp = now + TICKET_TTL_MS;
70
+ const payloadJson = canonicalJson({
71
+ exp,
72
+ idpOrigin,
73
+ linkToken,
74
+ surfaceUserId,
75
+ });
76
+ const payloadB64 = b64urlEncode(payloadJson);
77
+ const sig = sign(secret, payloadB64);
78
+ return `${payloadB64}.${b64urlEncode(sig)}`;
79
+ }
80
+
81
+ /**
82
+ * Verify a wire-form completion ticket. Returns `{ ok: true, claims }` on
83
+ * success or `{ ok: false, reason }` on failure. All failure reasons are
84
+ * caller-rendered as the same "Unable to verify completion" page —
85
+ * indistinguishability is intentional.
86
+ *
87
+ * Failure reasons: `malformed`, `bad_signature`, `expired`,
88
+ * `link_token_mismatch`, `untrusted_origin`.
89
+ *
90
+ * The `surface_user_id` claim is returned in `claims.surfaceUserId` for the
91
+ * handler to cross-check against the freshly-resolved `pending.surface_user_id`
92
+ * — see `services/bridge` step 8 in the plan. Folding that check into the
93
+ * verifier would require it to take a pending store, which the design
94
+ * explicitly avoids.
95
+ *
96
+ * @param {object} args
97
+ * @param {string} args.ticket Wire-form ticket.
98
+ * @param {{linkToken: string}} args.expected Link-token the handler resolved.
99
+ * @param {Set<string>} args.trustedOrigins Trusted-origin set from libutil.
100
+ * @param {string} args.secret Shared HMAC secret.
101
+ * @param {number} args.now Absolute ms-since-epoch.
102
+ * @returns {{ok: true, claims: {linkToken: string, surfaceUserId: string, idpOrigin: string, exp: number}} | {ok: false, reason: string}}
103
+ */
104
+ export function verifyCompletionTicket({
105
+ ticket,
106
+ expected,
107
+ trustedOrigins,
108
+ secret,
109
+ now,
110
+ }) {
111
+ if (typeof ticket !== "string" || !ticket.includes(".")) {
112
+ return { ok: false, reason: "malformed" };
113
+ }
114
+ const dot = ticket.indexOf(".");
115
+ if (dot === 0 || dot === ticket.length - 1) {
116
+ return { ok: false, reason: "malformed" };
117
+ }
118
+ const payloadB64 = ticket.slice(0, dot);
119
+ const sigB64 = ticket.slice(dot + 1);
120
+
121
+ const presentedSig = b64urlDecodeToBuffer(sigB64);
122
+ const expectedSig = sign(secret, payloadB64);
123
+ if (
124
+ presentedSig.length !== expectedSig.length ||
125
+ !timingSafeEqual(presentedSig, expectedSig)
126
+ ) {
127
+ return { ok: false, reason: "bad_signature" };
128
+ }
129
+
130
+ let claims;
131
+ try {
132
+ claims = JSON.parse(b64urlDecodeToBuffer(payloadB64).toString("utf8"));
133
+ } catch {
134
+ return { ok: false, reason: "malformed" };
135
+ }
136
+
137
+ const { exp, idp_origin, link_token, surface_user_id } = claims;
138
+ if (
139
+ typeof exp !== "number" ||
140
+ typeof idp_origin !== "string" ||
141
+ typeof link_token !== "string" ||
142
+ typeof surface_user_id !== "string"
143
+ ) {
144
+ return { ok: false, reason: "malformed" };
145
+ }
146
+
147
+ if (now >= exp) return { ok: false, reason: "expired" };
148
+ if (link_token !== expected.linkToken) {
149
+ return { ok: false, reason: "link_token_mismatch" };
150
+ }
151
+ if (!isTrusted(idp_origin, trustedOrigins)) {
152
+ return { ok: false, reason: "untrusted_origin" };
153
+ }
154
+ return {
155
+ ok: true,
156
+ claims: {
157
+ linkToken: link_token,
158
+ surfaceUserId: surface_user_id,
159
+ idpOrigin: idp_origin,
160
+ exp,
161
+ },
162
+ };
163
+ }
package/src/finder.js CHANGED
@@ -1,81 +1,76 @@
1
- import nodeFsSync from "node:fs";
2
- import nodeFsPromises from "node:fs/promises";
3
1
  import path from "path";
4
2
  import { createRequire } from "node:module";
3
+ import { LIBCLI_IS_COMPILED } from "@forwardimpact/libcli";
5
4
 
6
5
  const NOOP_LOGGER = { debug() {} };
7
6
 
8
- /**
9
- * Detect the new collaborator-config constructor form. The legacy positional
10
- * form passes an fs module as the first argument (which carries `readFile`);
11
- * the new form passes `{ fs, fsSync?, proc, logger? }`.
12
- * @param {*} arg - The first constructor argument.
13
- * @returns {boolean}
14
- */
15
- function isRuntimeConfig(arg) {
16
- return (
17
- arg != null &&
18
- typeof arg === "object" &&
19
- !Array.isArray(arg) &&
20
- (arg.proc !== undefined ||
21
- arg.fsSync !== undefined ||
22
- (arg.fs !== undefined && typeof arg.readFile !== "function"))
23
- );
24
- }
25
-
26
7
  /**
27
8
  * Finder class for project path resolution and symlink management.
28
9
  * Handles filesystem operations for linking generated code to packages.
29
10
  *
30
- * Two constructor forms are supported during the ambient-to-injected migration:
31
- *
32
- * - Collaborator config (canonical): `new Finder({ fs, fsSync?, proc, logger? })`.
33
- * The injected `fs` (async) and `fsSync` (sync, for existence checks) flow
34
- * through to every internal call — the spec-flagged dead-`fs` bug is fixed.
35
- * - Legacy positional (deprecated, one migration cycle):
36
- * `new Finder(fs, logger, process)`. Preserved byte-for-byte so existing
37
- * call sites stay green until their per-unit migration PRs convert them.
11
+ * Constructed with injected collaborators: `new Finder({ fs, fsSync?, proc,
12
+ * logger? })`. The injected `fs` (async) and `fsSync` (sync, for existence
13
+ * checks) flow through to every internal call.
38
14
  */
39
15
  export class Finder {
40
16
  #fs;
17
+ #fsSync;
41
18
  #existsSync;
42
19
  #logger;
43
20
  #proc;
21
+ #isCompiled;
44
22
 
45
23
  /**
46
- * @param {object} fsOrConfig - Either `{ fs, fsSync?, proc, logger? }` (new)
47
- * or the async fs module (legacy positional first argument).
48
- * @param {object} [logger] - Legacy positional logger.
49
- * @param {object} [proc] - Legacy positional process (cwd provider).
24
+ * @param {object} config - Injected collaborators.
25
+ * @param {object} config.fs - Async fs surface (mkdir, lstat, symlink, ).
26
+ * @param {object} [config.fsSync] - Sync fs surface for existence checks;
27
+ * falls back to `fs` when omitted.
28
+ * @param {object} config.proc - Process collaborator (cwd provider).
29
+ * @param {object} [config.logger] - Optional logger; defaults to a no-op.
30
+ * @param {boolean} [config.isCompiled] - Whether the host is a
31
+ * `bun build --compile` binary; defaults to libcli's `LIBCLI_IS_COMPILED`.
32
+ * Injectable so tests can exercise the compiled branch of
33
+ * {@link Finder#findProjectRoot} without a real binary.
50
34
  */
51
- constructor(fsOrConfig, logger, proc = global.process) {
52
- if (isRuntimeConfig(fsOrConfig)) {
53
- // Finder is the one module that legitimately bridges the sync and async
54
- // fs surfaces (existence checks vs. symlink ops), so it reads both fields
55
- // by property access rather than a single `{ fs, fsSync }` destructure
56
- // (which design Decision 7 reserves for consumer modules).
57
- const fs = fsOrConfig.fs;
58
- const fsSync = fsOrConfig.fsSync;
59
- const procArg = fsOrConfig.proc;
60
- if (!fs) throw new Error("fs is required");
61
- if (!procArg) throw new Error("proc is required");
62
- this.#fs = fs;
63
- const existsTarget = fsSync ?? fs;
64
- this.#existsSync = existsTarget.existsSync.bind(existsTarget);
65
- this.#proc = procArg;
66
- this.#logger = fsOrConfig.logger ?? NOOP_LOGGER;
67
- return;
68
- }
69
- // Legacy positional form: behavior identical to the pre-1370 Finder —
70
- // every fs operation routes through the module-level node:fs imports
71
- // (the historical dead-`fs` behavior callers depend on).
72
- if (!fsOrConfig) throw new Error("fs is required");
73
- if (!logger) throw new Error("logger is required");
74
- if (!proc) throw new Error("process is required");
75
- this.#fs = nodeFsPromises;
76
- this.#existsSync = (p) => nodeFsSync.existsSync(p);
77
- this.#logger = logger;
35
+ constructor(config = {}) {
36
+ // Finder is the one module that legitimately bridges the sync and async
37
+ // fs surfaces (existence checks vs. symlink ops), so it reads both fields
38
+ // by property access rather than a single `{ fs, fsSync }` destructure
39
+ // (which design Decision 7 reserves for consumer modules).
40
+ const fs = config.fs;
41
+ const fsSync = config.fsSync;
42
+ const proc = config.proc;
43
+ if (!fs) throw new Error("fs is required");
44
+ if (!proc) throw new Error("proc is required");
45
+ this.#fs = fs;
46
+ // Retain the raw collaborators so `withLogger` can rebuild an identically
47
+ // bound Finder with a different logger (private fields can't be copied
48
+ // onto a bare clone, and `#existsSync` is derived from `fsSync ?? fs`).
49
+ this.#fsSync = fsSync;
50
+ const existsTarget = fsSync ?? fs;
51
+ this.#existsSync = existsTarget.existsSync.bind(existsTarget);
78
52
  this.#proc = proc;
53
+ this.#logger = config.logger ?? NOOP_LOGGER;
54
+ this.#isCompiled = config.isCompiled ?? LIBCLI_IS_COMPILED;
55
+ }
56
+
57
+ /**
58
+ * Return a Finder over the same collaborators but with the given logger.
59
+ * The shared `runtime.finder` carries a no-op logger; a site that needs
60
+ * symlink debug logs (e.g. codegen) calls `runtime.finder.withLogger(logger)`
61
+ * instead of constructing its own Finder (Success Criterion 9 keeps `new
62
+ * Finder(...)` inside libutil).
63
+ * @param {object} logger - Logger with a `debug(scope, msg, data)` method.
64
+ * @returns {Finder} A logger-bound view sharing this Finder's fs/proc.
65
+ */
66
+ withLogger(logger) {
67
+ return new Finder({
68
+ fs: this.#fs,
69
+ fsSync: this.#fsSync,
70
+ proc: this.#proc,
71
+ logger,
72
+ isCompiled: this.#isCompiled,
73
+ });
79
74
  }
80
75
 
81
76
  /**
@@ -119,12 +114,28 @@ export class Finder {
119
114
  }
120
115
 
121
116
  /**
122
- * Find the project root directory.
123
- * @param {string} startPath - Starting directory path
117
+ * Find the project root a tool operates against, transparently handling
118
+ * compiled binaries.
119
+ *
120
+ * In a `bun build --compile` binary the entry module lives in the virtual
121
+ * `/$bunfs` root, so `import.meta.url`/`__dirname`-relative traversal is
122
+ * meaningless — the binary operates on whatever project tree it is launched
123
+ * from, so the working directory *is* the project root. In source/npx
124
+ * execution the working directory may sit anywhere inside the project, so we
125
+ * walk upward from `startPath` for the nearest `package.json`.
126
+ *
127
+ * Folding the compiled check in here keeps it out of every consumer: callers
128
+ * just ask the injected `runtime.finder` for the project root and get the
129
+ * right answer in both worlds.
130
+ *
131
+ * @param {string} [startPath] - Source-mode search origin; defaults to cwd.
124
132
  * @returns {string} Project root directory path
125
133
  */
126
134
  findProjectRoot(startPath) {
127
- const projectRoot = this.findUpward(startPath, "package.json", 5);
135
+ if (this.#isCompiled) return this.#proc.cwd();
136
+
137
+ const start = startPath ?? this.#proc.cwd();
138
+ const projectRoot = this.findUpward(start, "package.json", 5);
128
139
  if (projectRoot) {
129
140
  return path.dirname(projectRoot);
130
141
  }
@@ -194,10 +205,15 @@ export class Finder {
194
205
  // Ensure the target's parent directory exists before symlinking
195
206
  await this.#fs.mkdir(path.dirname(targetPath), { recursive: true });
196
207
 
197
- // Create the symlink
198
- await this.#fs.symlink(sourcePath, targetPath, "dir");
208
+ // Link relative to the symlink's own directory, not absolute. A relative
209
+ // target survives being moved or restored at a different path — e.g. the
210
+ // CI workspace cache restoring libraries/*/src/generated on a runner whose
211
+ // checkout root differs from where codegen first ran. An absolute target
212
+ // would dangle, forcing a full codegen re-run on every warm cache.
213
+ const relativeSource = path.relative(path.dirname(targetPath), sourcePath);
214
+ await this.#fs.symlink(relativeSource, targetPath, "dir");
199
215
  this.#logger.debug("Finder", "Created symlink", {
200
- source_path: sourcePath,
216
+ source_path: relativeSource,
201
217
  target_path: targetPath,
202
218
  });
203
219
  }
package/src/index.js CHANGED
@@ -101,13 +101,16 @@ export function createTokenizer() {
101
101
  * Used in containerized deployments to download pre-generated code bundles.
102
102
  * @param {Function} createStorage - Storage factory function from libstorage
103
103
  * @param {object} logger - Logger instance
104
+ * @param {import("./runtime.js").Runtime} runtime - Injected runtime bag; supplies
105
+ * the `fs`/`fsSync`/`proc` collaborators the Finder reads.
104
106
  * @returns {BundleDownloader} Configured BundleDownloader instance
105
107
  */
106
- export function createBundleDownloader(createStorage, logger) {
108
+ export function createBundleDownloader(createStorage, logger, runtime) {
107
109
  if (!createStorage) throw new Error("createStorage is required");
108
110
  if (!logger) throw new Error("logger is required");
111
+ if (!runtime) throw new Error("runtime is required");
109
112
 
110
- const finder = new Finder(fs, logger);
113
+ const finder = new Finder({ ...runtime, logger });
111
114
  const extractor = new TarExtractor(fs, path);
112
115
 
113
116
  return new BundleDownloader(createStorage, finder, logger, extractor);
package/src/runtime.js CHANGED
@@ -4,6 +4,7 @@ import nodeFsSync, {
4
4
  createWriteStream as nodeCreateWriteStream,
5
5
  } from "node:fs";
6
6
  import nodeFs from "node:fs/promises";
7
+ import { Writable } from "node:stream";
7
8
  import { Finder } from "./finder.js";
8
9
 
9
10
  /**
@@ -29,8 +30,9 @@ import { Finder } from "./finder.js";
29
30
  * `readFileSync`, `writeFileSync`, `mkdirSync`, `readdirSync`, `statSync`,
30
31
  * `openSync`, `readSync`, `closeSync`, `unlinkSync`.
31
32
  * @property {Object} proc
32
- * Process surface: `cwd()`, `env`, `argv`, `stdin`, `stdout.write`,
33
- * `stderr.write`, `exit(code)`, `kill(pid, signal)` (a negative `pid`
33
+ * Process surface: `cwd()`, `env`, `argv`, `stdin`, `stdout`/`stderr`
34
+ * (pipeline-grade `Writable`s — they support `.write(str)` and also serve as
35
+ * `pipeline()` sinks), `exit(code)`, `kill(pid, signal)` (a negative `pid`
34
36
  * signals the process group, e.g. for daemon teardown), `pid` (this
35
37
  * process's id — used to exclude self from process-group descendant scans),
36
38
  * `platform` (the `process.platform` string — `"darwin"`/`"win32"`/`"linux"`
@@ -93,8 +95,12 @@ export function createDefaultProc({ source = process, env = source.env } = {}) {
93
95
  }),
94
96
  argv: Object.freeze([...source.argv]),
95
97
  stdin: lineIterator(source.stdin),
96
- stdout: { write: (s) => source.stdout.write(s) },
97
- stderr: { write: (s) => source.stderr.write(s) },
98
+ // Pipeline-grade Writables: they support `.write(str)` like the old
99
+ // `{ write }` shim and also serve as `pipeline()` sinks. The wrapper
100
+ // forwards each chunk to the real stream; `.end()` finishes the wrapper
101
+ // without closing `source.stdout`/`stderr`.
102
+ stdout: forwardingWritable(source.stdout),
103
+ stderr: forwardingWritable(source.stderr),
98
104
  exit: (code) => source.exit(code),
99
105
  kill: (pid, signal) => source.kill(pid, signal),
100
106
  pid: source.pid,
@@ -111,6 +117,28 @@ export function createDefaultProc({ source = process, env = source.env } = {}) {
111
117
  return proc;
112
118
  }
113
119
 
120
+ /**
121
+ * Wrap an underlying writable (`process.stdout`/`stderr`) in a pipeline-grade
122
+ * `Writable` sink that forwards every chunk to it. Used so `runtime.proc.stdout`
123
+ * can be both `.write(str)`-ed and used as a `pipeline()` destination, without
124
+ * `.end()` on the wrapper closing the real stream.
125
+ * @param {object} target - The underlying writable to forward chunks to.
126
+ * @returns {import("node:stream").Writable}
127
+ */
128
+ function forwardingWritable(target) {
129
+ return new Writable({
130
+ write(chunk, _encoding, callback) {
131
+ // Forward and complete immediately. The underlying stream's
132
+ // backpressure signal is not propagated, which is fine for the CLI
133
+ // sinks this serves (a one-shot `librc logs()` tail); a future
134
+ // high-volume consumer that needs flow control should respect
135
+ // `target.write()`'s return value here.
136
+ target.write(chunk);
137
+ callback();
138
+ },
139
+ });
140
+ }
141
+
114
142
  /**
115
143
  * Adapt a readable stream into an `AsyncIterable<string>` of UTF-8 lines.
116
144
  * @param {object} stream - A Node readable stream (e.g. `process.stdin`).
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Parse a comma-separated list of trusted `https://…` origins into a Set of
3
+ * normalised origin strings (`new URL(s).origin`). Empty entries are dropped.
4
+ * `http://` and malformed entries are skipped at load with a logged warning.
5
+ * No globals read; caller hands in the raw string from its `libconfig`-loaded
6
+ * service config.
7
+ *
8
+ * @param {string|null|undefined} raw Comma-separated string of origins.
9
+ * @param {object} [options]
10
+ * @param {{warn?: (msg: string, meta?: object) => void}} [options.logger]
11
+ * Optional logger; warnings are emitted but never thrown.
12
+ * @returns {Set<string>} Set of normalised origin strings.
13
+ */
14
+ export function loadTrustedIdpOrigins(raw, { logger } = {}) {
15
+ const set = new Set();
16
+ const entries = String(raw ?? "")
17
+ .split(",")
18
+ .map((s) => s.trim())
19
+ .filter(Boolean);
20
+ for (const entry of entries) {
21
+ let url;
22
+ try {
23
+ url = new URL(entry);
24
+ } catch {
25
+ logger?.error?.("trusted-origins", "malformed entry; skipping", {
26
+ entry,
27
+ });
28
+ continue;
29
+ }
30
+ if (url.protocol !== "https:") {
31
+ logger?.error?.("trusted-origins", "non-TLS entry refused; skipping", {
32
+ entry,
33
+ });
34
+ continue;
35
+ }
36
+ set.add(url.origin);
37
+ }
38
+ return set;
39
+ }
40
+
41
+ /**
42
+ * Test whether `origin` (any URL string the caller has) belongs to `set`.
43
+ * Compared as `new URL(origin).origin` against the Set's normalised entries.
44
+ * Returns `false` on any URL parse error rather than throwing.
45
+ *
46
+ * @param {string} origin URL or origin string to test.
47
+ * @param {Set<string>} set Set produced by `loadTrustedIdpOrigins`.
48
+ * @returns {boolean} `true` if the URL's origin is in the set.
49
+ */
50
+ export function isTrusted(origin, set) {
51
+ try {
52
+ return set.has(new URL(origin).origin);
53
+ } catch {
54
+ return false;
55
+ }
56
+ }