uc-config 0.1.0

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/dist/schema.js ADDED
@@ -0,0 +1,75 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { Ajv2020 } from "ajv/dist/2020.js";
3
+ import addFormats from "ajv-formats";
4
+ import { isObject } from "./util.js";
5
+ export const spec = JSON.parse(readFileSync(new URL("../vendor/core-api.json", import.meta.url), "utf8"));
6
+ const ajv = new Ajv2020({
7
+ strict: false,
8
+ allErrors: true,
9
+ validateFormats: false,
10
+ });
11
+ addFormats(ajv);
12
+ // Authoring-time secret references are legal where a native string is expected.
13
+ // Resolved values are validated again immediately before writes.
14
+ function symbolicSchema(value) {
15
+ if (Array.isArray(value))
16
+ return value.map(symbolicSchema);
17
+ if (!value || typeof value !== "object")
18
+ return value;
19
+ const node = Object.fromEntries(Object.entries(value).map(([k, v]) => [k, symbolicSchema(v)]));
20
+ if (value.type === "string")
21
+ return {
22
+ anyOf: [
23
+ node,
24
+ {
25
+ type: "object",
26
+ properties: {
27
+ $secret: { type: "string", pattern: "^(env|keychain):.+$" },
28
+ version: { type: "string" },
29
+ },
30
+ required: ["$secret"],
31
+ additionalProperties: false,
32
+ },
33
+ ],
34
+ };
35
+ return node;
36
+ }
37
+ ajv.addSchema({ ...symbolicSchema(spec), $id: "uc" }, "uc");
38
+ export function validateRequest(path, method, data) {
39
+ const schema = spec.paths[path]?.[method.toLowerCase()]?.requestBody?.content?.["application/json"]?.schema;
40
+ if (!schema)
41
+ return;
42
+ const id = `uc#/paths/${path.replace(/~/g, "~0").replace(/\//g, "~1")}/${method.toLowerCase()}/requestBody/content/application~1json/schema`;
43
+ const validate = ajv.getSchema(id) ?? ajv.compile({ $ref: id });
44
+ if (!validate(data))
45
+ throw new Error(`Invalid ${method} ${path}: ${ajv.errorsText(validate.errors, { dataVar: "body" })}`);
46
+ }
47
+ export function writable(path, method, value) {
48
+ const root = spec.paths[path]?.[method]?.requestBody?.content?.["application/json"]
49
+ ?.schema;
50
+ function properties(schema) {
51
+ if (!schema)
52
+ return {};
53
+ if (schema.$ref)
54
+ return properties(schema.$ref
55
+ .split("/")
56
+ .slice(1)
57
+ .reduce((a, k) => a[k], spec));
58
+ return {
59
+ ...schema.properties,
60
+ ...Object.assign({}, ...(schema.allOf ?? []).map(properties)),
61
+ };
62
+ }
63
+ function pick(schema, v) {
64
+ const props = properties(schema);
65
+ return Object.fromEntries(Object.entries(v)
66
+ .filter(([k]) => k in props)
67
+ .map(([k, x]) => [
68
+ k,
69
+ isObject(x) && Object.keys(properties(props[k])).length
70
+ ? pick(props[k], x)
71
+ : x,
72
+ ]));
73
+ }
74
+ return pick(root, value);
75
+ }
@@ -0,0 +1,2 @@
1
+ export declare const curlFetch: typeof fetch;
2
+ export declare const localFetch: typeof fetch;
@@ -0,0 +1,101 @@
1
+ import { spawn } from "node:child_process";
2
+ import { mkdtemp, writeFile, readFile, rm } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ // curl is a fallback for macOS environments where Node lacks local-network access.
6
+ // Credentials go through stdin, never argv. Request bodies live in a private temporary directory.
7
+ export const curlFetch = async (input, init) => {
8
+ const request = new Request(input, init);
9
+ const dir = await mkdtemp(join(tmpdir(), "uc-config-http-"));
10
+ const quote = (s) => `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\r/g, "\\r").replace(/\n/g, "\\n")}"`;
11
+ try {
12
+ const lines = [
13
+ "silent",
14
+ "show-error",
15
+ "globoff",
16
+ "max-time = 180",
17
+ `url = ${quote(request.url)}`,
18
+ `request = ${quote(request.method)}`,
19
+ `dump-header = ${quote(join(dir, "headers"))}`,
20
+ ];
21
+ request.headers.forEach((v, k) => lines.push(`header = ${quote(`${k}: ${v}`)}`));
22
+ if (request.body) {
23
+ await writeFile(join(dir, "body"), Buffer.from(await request.arrayBuffer()), { mode: 0o600 });
24
+ lines.push(`data-binary = ${quote("@" + join(dir, "body"))}`);
25
+ }
26
+ const bytes = await new Promise((resolve, reject) => {
27
+ const child = spawn("curl", ["--config", "-"], {
28
+ stdio: ["pipe", "pipe", "pipe"],
29
+ });
30
+ const chunks = [];
31
+ let size = 0;
32
+ const abort = () => {
33
+ child.kill();
34
+ reject(new Error("curl request aborted"));
35
+ };
36
+ if (request.signal.aborted) {
37
+ abort();
38
+ return;
39
+ }
40
+ request.signal.addEventListener("abort", abort, { once: true });
41
+ child.stdout.on("data", (chunk) => {
42
+ size += chunk.length;
43
+ if (size > 200 * 1024 * 1024) {
44
+ child.kill();
45
+ reject(new Error("Response too large"));
46
+ }
47
+ else
48
+ chunks.push(chunk);
49
+ });
50
+ // Do not print curl stderr: upstream diagnostics can include private URLs.
51
+ child.stderr.resume();
52
+ child.on("error", () => reject(new Error("curl could not start")));
53
+ child.on("close", (code) => {
54
+ request.signal.removeEventListener("abort", abort);
55
+ code === 0
56
+ ? resolve(Buffer.concat(chunks))
57
+ : reject(new Error(`curl transport failed (${code})`));
58
+ });
59
+ child.stdin.on("error", () => { });
60
+ child.stdin.end(lines.join("\n") + "\n");
61
+ });
62
+ const blocks = (await readFile(join(dir, "headers"), "utf8"))
63
+ .trim()
64
+ .split(/\r?\n\r?\n/);
65
+ const last = blocks.at(-1);
66
+ const responseLines = last.split(/\r?\n/);
67
+ const status = Number(responseLines.shift()?.split(" ")[1]);
68
+ const headers = new Headers();
69
+ for (const line of responseLines) {
70
+ const colon = line.indexOf(":");
71
+ if (colon > 0)
72
+ headers.append(line.slice(0, colon), line.slice(colon + 1).trim());
73
+ }
74
+ return new Response(request.method === "HEAD" || [204, 205, 304].includes(status)
75
+ ? null
76
+ : new Uint8Array(bytes), { status, headers });
77
+ }
78
+ finally {
79
+ await rm(dir, { recursive: true, force: true });
80
+ }
81
+ };
82
+ const curlOrigins = new Set();
83
+ export const localFetch = async (input, init) => {
84
+ const origin = new URL(input instanceof Request ? input.url : String(input))
85
+ .origin;
86
+ if (process.env.UC_HTTP_TRANSPORT === "curl" || curlOrigins.has(origin))
87
+ return curlFetch(input, init);
88
+ try {
89
+ return await fetch(input, init);
90
+ }
91
+ catch (error) {
92
+ const code = error.cause?.code;
93
+ // Only retry failures known to occur before connection establishment, never a timeout/reset.
94
+ if (process.platform === "darwin" &&
95
+ ["EHOSTUNREACH", "ENETUNREACH", "EACCES", "EPERM"].includes(code ?? "")) {
96
+ curlOrigins.add(origin);
97
+ return curlFetch(input, init);
98
+ }
99
+ throw error;
100
+ }
101
+ };
package/dist/util.d.ts ADDED
@@ -0,0 +1,18 @@
1
+ import type { Json, ObjectValue, State } from "./model.js";
2
+ export declare function canonical(value: unknown): string;
3
+ export declare const hash: (v: unknown) => string;
4
+ export declare const equal: (a: unknown, b: unknown) => boolean;
5
+ export declare const isObject: (v: unknown) => v is ObjectValue;
6
+ export declare function assertJson(v: unknown, path?: string, seen?: Set<object>): asserts v is Json;
7
+ export declare function project(live: ObjectValue, shape: ObjectValue): ObjectValue;
8
+ export declare function merge(a: ObjectValue, b: ObjectValue): ObjectValue;
9
+ export declare function leaves(v: ObjectValue, path?: string[]): Array<[string[], Json]>;
10
+ export declare const get: (v: unknown, path: string[]) => unknown;
11
+ export declare function set(v: ObjectValue, path: string[], value: Json): void;
12
+ export declare function readJson<T>(file: string): Promise<T>;
13
+ export declare function saveJson(file: string, value: unknown): Promise<void>;
14
+ export declare function withLock<T>(file: string, fn: () => Promise<T>): Promise<T>;
15
+ export declare function resolveRefs<T>(value: T, state: State): T;
16
+ export declare function references(value: unknown): string[];
17
+ export declare function redact(value: unknown): unknown;
18
+ export declare function secretFields(v: unknown, path?: string[]): string[];
package/dist/util.js ADDED
@@ -0,0 +1,140 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { mkdir, readFile, rename, writeFile, open, unlink, } from "node:fs/promises";
3
+ import { dirname } from "node:path";
4
+ export function canonical(value) {
5
+ if (value === undefined)
6
+ return "undefined";
7
+ if (Array.isArray(value))
8
+ return `[${value.map(canonical).join(",")}]`;
9
+ if (value && typeof value === "object")
10
+ return `{${Object.entries(value)
11
+ .sort(([a], [b]) => a.localeCompare(b))
12
+ .map(([k, v]) => `${JSON.stringify(k)}:${canonical(v)}`)
13
+ .join(",")}}`;
14
+ return JSON.stringify(value);
15
+ }
16
+ export const hash = (v) => createHash("sha256").update(canonical(v)).digest("hex");
17
+ export const equal = (a, b) => canonical(a) === canonical(b);
18
+ export const isObject = (v) => !!v && typeof v === "object" && !Array.isArray(v);
19
+ export function assertJson(v, path = "config", seen = new Set()) {
20
+ if (v === null ||
21
+ typeof v === "string" ||
22
+ typeof v === "boolean" ||
23
+ (typeof v === "number" && Number.isFinite(v)))
24
+ return;
25
+ if (typeof v !== "object" || seen.has(v))
26
+ throw new Error(`${path}: expected serializable JSON (no undefined, functions, cycles or non-finite numbers)`);
27
+ if (!Array.isArray(v) &&
28
+ Object.getPrototypeOf(v) !== Object.prototype &&
29
+ Object.getPrototypeOf(v) !== null)
30
+ throw new Error(`${path}: expected plain object`);
31
+ seen.add(v);
32
+ for (const [k, x] of Object.entries(v)) {
33
+ if (["__proto__", "constructor", "prototype"].includes(k))
34
+ throw new Error(`${path}: forbidden key ${k}`);
35
+ assertJson(x, `${path}.${k}`, seen);
36
+ }
37
+ seen.delete(v);
38
+ }
39
+ export function project(live, shape) {
40
+ const value = (actual, wanted) => {
41
+ if (isObject(wanted) && !("$secret" in wanted) && isObject(actual))
42
+ return project(actual, wanted);
43
+ if (Array.isArray(wanted) && Array.isArray(actual))
44
+ return actual.map((item, i) => wanted[i] === undefined ? item : value(item, wanted[i]));
45
+ return actual;
46
+ };
47
+ return Object.fromEntries(Object.entries(shape)
48
+ .filter(([k]) => k in live)
49
+ .map(([k, v]) => [k, value(live[k], v)]));
50
+ }
51
+ export function merge(a, b) {
52
+ const out = structuredClone(a);
53
+ for (const [k, v] of Object.entries(b))
54
+ out[k] = isObject(v) && isObject(out[k]) ? merge(out[k], v) : v;
55
+ return out;
56
+ }
57
+ export function leaves(v, path = []) {
58
+ return Object.entries(v).flatMap(([k, x]) => isObject(x) && Object.keys(x).length && !("$secret" in x)
59
+ ? leaves(x, [...path, k])
60
+ : [[[...path, k], x]]);
61
+ }
62
+ export const get = (v, path) => path.reduce((a, k) => (isObject(a) ? a[k] : undefined), v);
63
+ export function set(v, path, value) {
64
+ const [key, ...rest] = path;
65
+ if (!key)
66
+ throw new Error("Empty path");
67
+ if (!rest.length)
68
+ v[key] = value;
69
+ else {
70
+ if (!isObject(v[key]))
71
+ v[key] = {};
72
+ set(v[key], rest, value);
73
+ }
74
+ }
75
+ export async function readJson(file) {
76
+ return JSON.parse(await readFile(file, "utf8"));
77
+ }
78
+ export async function saveJson(file, value) {
79
+ await mkdir(dirname(file), { recursive: true, mode: 0o700 });
80
+ const tmp = `${file}.${randomUUID()}.tmp`;
81
+ await writeFile(tmp, JSON.stringify(value, null, 2) + "\n", { mode: 0o600 });
82
+ await rename(tmp, file);
83
+ }
84
+ export async function withLock(file, fn) {
85
+ await mkdir(dirname(file), { recursive: true, mode: 0o700 });
86
+ const handle = await open(file, "wx", 0o600).catch(() => {
87
+ throw new Error(`Target locked: ${file}. If its process has exited, inspect the journal before removing this lock.`);
88
+ });
89
+ try {
90
+ await handle.writeFile(JSON.stringify({ pid: process.pid, createdAt: new Date().toISOString() }));
91
+ return await fn();
92
+ }
93
+ finally {
94
+ await handle.close();
95
+ await unlink(file);
96
+ }
97
+ }
98
+ export function resolveRefs(value, state) {
99
+ if (Array.isArray(value))
100
+ return value.map((x) => resolveRefs(x, state));
101
+ if (isObject(value)) {
102
+ if (typeof value.$ref === "string") {
103
+ const b = state.bindings[value.$ref];
104
+ if (!b)
105
+ throw new Error(`Unresolved reference: ${value.$ref}`);
106
+ return b.id;
107
+ }
108
+ return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, resolveRefs(v, state)]));
109
+ }
110
+ return value;
111
+ }
112
+ export function references(value) {
113
+ if (Array.isArray(value))
114
+ return value.flatMap(references);
115
+ if (isObject(value))
116
+ return typeof value.$ref === "string"
117
+ ? [value.$ref]
118
+ : Object.values(value).flatMap(references);
119
+ return [];
120
+ }
121
+ const sensitive = /password|token|secret|api[_-]?key|authorization|cookie|passkey|(^|_)pin$/i;
122
+ export function redact(value) {
123
+ if (Array.isArray(value))
124
+ return value.map(redact);
125
+ if (!isObject(value))
126
+ return value;
127
+ return Object.fromEntries(Object.entries(value).map(([k, v]) => [
128
+ k,
129
+ sensitive.test(k) ? "[REDACTED]" : redact(v),
130
+ ]));
131
+ }
132
+ export function secretFields(v, path = []) {
133
+ if (!isObject(v) && !Array.isArray(v))
134
+ return [];
135
+ if (isObject(v) && "$secret" in v)
136
+ return [];
137
+ return Object.entries(v).flatMap(([k, x]) => sensitive.test(k) && !(isObject(x) && "$secret" in x)
138
+ ? [[...path, k].join(".")]
139
+ : secretFields(x, [...path, k]));
140
+ }