youiyoux 0.1.12 → 0.1.13
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/chunks/{chunk-VXR7FMM7.js → chunk-EV62TUDV.js} +332 -215
- package/dist/youi.js +1 -1
- package/dist/youiyoux.js +1 -1
- package/package.json +4 -4
|
@@ -1307,9 +1307,10 @@ function recordWorkflowText(input) {
|
|
|
1307
1307
|
return `${RECORD_HEADER}
|
|
1308
1308
|
# Sends where each product here stands with its System to your team's hosted hub
|
|
1309
1309
|
# (youiyoux hub serve): youiyoux record, redacted as the shareable report is, with no code.
|
|
1310
|
-
# Set
|
|
1311
|
-
#
|
|
1312
|
-
#
|
|
1310
|
+
# Set the repository secret YOUIYOUX_HUB_URL (the hub's https address). The record proves this
|
|
1311
|
+
# repository with GitHub's OIDC token (id-token: write), so a hub that takes GitHub's needs no
|
|
1312
|
+
# other secret; a hub on a shared ingest token also needs YOUIYOUX_HUB_TOKEN.
|
|
1313
|
+
# Pinned to youiyoux ${input.version}; change it here. Only GitHub's own actions run here.
|
|
1313
1314
|
name: youiyoux record
|
|
1314
1315
|
on:
|
|
1315
1316
|
push:
|
|
@@ -1317,6 +1318,7 @@ on:
|
|
|
1317
1318
|
workflow_dispatch:
|
|
1318
1319
|
permissions:
|
|
1319
1320
|
contents: read
|
|
1321
|
+
id-token: write
|
|
1320
1322
|
concurrency:
|
|
1321
1323
|
group: youiyoux-record
|
|
1322
1324
|
cancel-in-progress: true
|
|
@@ -1346,7 +1348,7 @@ async function installRecordWorkflow(dir, version, force) {
|
|
|
1346
1348
|
const file = join8(top, RECORD_WORKFLOW);
|
|
1347
1349
|
const before = await readTextOrNull(file);
|
|
1348
1350
|
if (before !== null && !before.startsWith(RECORD_HEADER) && !force) throw new Error(`${file} exists and was not written by youiyoux; pass --force to replace it`);
|
|
1349
|
-
return write(file, recordWorkflowText({ branch, version, install: await installStep(top) }), before, `sends this repository's record (redacted, no code) to the hub in YOUIYOUX_HUB_URL on each push to ${branch}; set YOUIYOUX_HUB_URL and YOUIYOUX_HUB_TOKEN
|
|
1351
|
+
return write(file, recordWorkflowText({ branch, version, install: await installStep(top) }), before, `sends this repository's record (redacted, no code) to the hub in YOUIYOUX_HUB_URL on each push to ${branch}, proving the repository with GitHub's OIDC token; set YOUIYOUX_HUB_URL as a repository secret (and YOUIYOUX_HUB_TOKEN for a hub on a shared token)`);
|
|
1350
1352
|
}
|
|
1351
1353
|
|
|
1352
1354
|
// ../cli/src/check-repo.ts
|
|
@@ -2888,14 +2890,308 @@ function renderUpgrade(r, showDiff) {
|
|
|
2888
2890
|
// ../cli/src/record.ts
|
|
2889
2891
|
import { execFile as execFile5 } from "node:child_process";
|
|
2890
2892
|
import { readFileSync as readFileSync2 } from "node:fs";
|
|
2891
|
-
import { join as
|
|
2893
|
+
import { join as join20 } from "node:path";
|
|
2892
2894
|
import { promisify as promisify5 } from "node:util";
|
|
2893
2895
|
|
|
2896
|
+
// ../cli/src/org-hub.ts
|
|
2897
|
+
import { createHash, createHmac, timingSafeEqual } from "node:crypto";
|
|
2898
|
+
import { readFile as readFile6 } from "node:fs/promises";
|
|
2899
|
+
import { createServer } from "node:http";
|
|
2900
|
+
import { extname, join as join17, normalize, sep as sep3 } from "node:path";
|
|
2901
|
+
|
|
2902
|
+
// ../cli/src/github-oidc.ts
|
|
2903
|
+
import { createPublicKey, verify } from "node:crypto";
|
|
2904
|
+
var GITHUB_ISSUER = "https://token.actions.githubusercontent.com";
|
|
2905
|
+
var JWKS_URL = `${GITHUB_ISSUER}/.well-known/jwks`;
|
|
2906
|
+
var LEEWAY_SECONDS = 60;
|
|
2907
|
+
var JWKS_TTL_MS = 60 * 60 * 1e3;
|
|
2908
|
+
var JWKS_RETRY_MS = 60 * 1e3;
|
|
2909
|
+
var audienceOf = (hub) => hub.trim().replace(/\/+$/, "");
|
|
2910
|
+
async function requestGithubToken(audience, env = process.env, fetcher = fetch) {
|
|
2911
|
+
const url = env.ACTIONS_ID_TOKEN_REQUEST_URL;
|
|
2912
|
+
const bearer2 = env.ACTIONS_ID_TOKEN_REQUEST_TOKEN;
|
|
2913
|
+
if (!url || !bearer2) return null;
|
|
2914
|
+
const request = new URL(url);
|
|
2915
|
+
request.searchParams.set("audience", audience);
|
|
2916
|
+
const response = await fetcher(request, { headers: { authorization: `bearer ${bearer2}` }, signal: AbortSignal.timeout(15e3) });
|
|
2917
|
+
if (!response.ok) throw new Error(`GitHub Actions did not give an OIDC token (${response.status}): does the workflow have permissions: id-token: write?`);
|
|
2918
|
+
const { value } = await response.json();
|
|
2919
|
+
if (typeof value !== "string") throw new Error("GitHub Actions answered without an OIDC token");
|
|
2920
|
+
return value;
|
|
2921
|
+
}
|
|
2922
|
+
var decode = (part) => JSON.parse(Buffer.from(part, "base64url").toString("utf8"));
|
|
2923
|
+
var DEFAULT_BRANCHES = ["main", "master"];
|
|
2924
|
+
function githubVerifier(options) {
|
|
2925
|
+
const listed = options.owners.map((o) => o.trim().toLowerCase()).filter(Boolean);
|
|
2926
|
+
const ownerIds = new Set(listed.filter((o) => /^\d+$/.test(o)));
|
|
2927
|
+
const ownerNames = new Set(listed.filter((o) => !/^\d+$/.test(o)));
|
|
2928
|
+
const branches = new Set(options.branches && options.branches.length > 0 ? options.branches : DEFAULT_BRANCHES);
|
|
2929
|
+
const issuer = options.issuer ?? GITHUB_ISSUER;
|
|
2930
|
+
const audience = audienceOf(options.audience);
|
|
2931
|
+
const now = options.now ?? (() => /* @__PURE__ */ new Date());
|
|
2932
|
+
let cached = null;
|
|
2933
|
+
let lastFetch = 0;
|
|
2934
|
+
const fetchKeys = options.jwks ?? (async () => await (await fetch(JWKS_URL, { signal: AbortSignal.timeout(15e3) })).json());
|
|
2935
|
+
const keyFor = async (kid) => {
|
|
2936
|
+
const find = (jwks) => jwks.keys.find((k) => k.kid === kid && k.kty === "RSA") ?? null;
|
|
2937
|
+
const fresh = cached && Date.now() - cached.at < JWKS_TTL_MS ? find(cached.keys) : null;
|
|
2938
|
+
if (fresh) return fresh;
|
|
2939
|
+
if (cached && Date.now() - lastFetch < JWKS_RETRY_MS) return find(cached.keys);
|
|
2940
|
+
lastFetch = Date.now();
|
|
2941
|
+
cached = { keys: await fetchKeys(), at: Date.now() };
|
|
2942
|
+
return find(cached.keys);
|
|
2943
|
+
};
|
|
2944
|
+
return async (token) => {
|
|
2945
|
+
const parts = token.split(".");
|
|
2946
|
+
if (parts.length !== 3) throw new Error("not a JSON Web Token");
|
|
2947
|
+
const [head, body, signature] = parts;
|
|
2948
|
+
let header;
|
|
2949
|
+
let claims;
|
|
2950
|
+
try {
|
|
2951
|
+
header = decode(head);
|
|
2952
|
+
claims = decode(body);
|
|
2953
|
+
} catch {
|
|
2954
|
+
throw new Error("not a JSON Web Token");
|
|
2955
|
+
}
|
|
2956
|
+
if (header.alg !== "RS256") throw new Error(`signed with ${String(header.alg)}, not GitHub's RS256`);
|
|
2957
|
+
const jwk = await keyFor(typeof header.kid === "string" ? header.kid : void 0);
|
|
2958
|
+
if (!jwk) throw new Error("not signed by a key GitHub publishes");
|
|
2959
|
+
if (!verify("RSA-SHA256", Buffer.from(`${head}.${body}`), createPublicKey({ key: { kty: "RSA", n: String(jwk.n), e: String(jwk.e) }, format: "jwk" }), Buffer.from(signature, "base64url"))) throw new Error("its signature is not GitHub's");
|
|
2960
|
+
if (claims.iss !== issuer) throw new Error(`issued by ${String(claims.iss)}, not GitHub Actions`);
|
|
2961
|
+
const audiences = Array.isArray(claims.aud) ? claims.aud : [claims.aud];
|
|
2962
|
+
if (!audiences.some((a) => typeof a === "string" && audienceOf(a) === audience)) throw new Error(`issued for ${audiences.map(String).join(", ")}, not this hub (${audience})`);
|
|
2963
|
+
const seconds = now().getTime() / 1e3;
|
|
2964
|
+
if (typeof claims.exp !== "number" || claims.exp < seconds - LEEWAY_SECONDS) throw new Error("expired");
|
|
2965
|
+
if (typeof claims.nbf === "number" && claims.nbf > seconds + LEEWAY_SECONDS) throw new Error("not valid yet");
|
|
2966
|
+
const { repository, repository_owner: owner, repository_owner_id: ownerId, sha, ref, event_name: event } = claims;
|
|
2967
|
+
if (typeof repository !== "string" || typeof owner !== "string" || typeof sha !== "string" || typeof ref !== "string") throw new Error("missing its repository, owner, commit or ref");
|
|
2968
|
+
if (!(typeof ownerId === "string" && ownerIds.has(ownerId)) && !ownerNames.has(owner.toLowerCase())) throw new Error(`from ${owner}${typeof ownerId === "string" ? ` (id ${ownerId})` : ""}, which this hub does not take records from`);
|
|
2969
|
+
if (!ref.startsWith("refs/heads/") || !branches.has(ref.slice("refs/heads/".length))) throw new Error(`from ${ref}: this hub takes records from ${[...branches].join(" and ")} only (YOUIYOUX_HUB_BRANCHES)`);
|
|
2970
|
+
return { repository, repository_owner: owner, sha, ref, ...typeof event === "string" ? { event_name: event } : {} };
|
|
2971
|
+
};
|
|
2972
|
+
}
|
|
2973
|
+
function recordMismatch(claims, record) {
|
|
2974
|
+
if (claims.repository.toLowerCase() !== record.repo.toLowerCase()) return `the record names ${record.repo}, but it was sent from ${claims.repository}`;
|
|
2975
|
+
if (record.commit !== claims.sha) return `the record names commit ${record.commit ?? "none"}, but it was sent from ${claims.sha}`;
|
|
2976
|
+
if (!claims.ref.startsWith("refs/heads/")) return `it was sent from ${claims.ref}, not a branch`;
|
|
2977
|
+
if (claims.event_name !== void 0 && claims.event_name.startsWith("pull_request")) return `it was sent from a ${claims.event_name} run, not a push to the branch`;
|
|
2978
|
+
const branch = claims.ref.slice("refs/heads/".length);
|
|
2979
|
+
if (record.ref !== branch) return `the record names ${record.ref ?? "no branch"}, but it was sent from ${branch}`;
|
|
2980
|
+
return null;
|
|
2981
|
+
}
|
|
2982
|
+
|
|
2983
|
+
// ../cli/src/org-hub.ts
|
|
2984
|
+
var VIEW_KEY_ENV = "YOUIYOUX_HUB_VIEW_KEY";
|
|
2985
|
+
var GITHUB_OWNERS_ENV = "YOUIYOUX_HUB_GITHUB_OWNERS";
|
|
2986
|
+
var AUDIENCE_ENV = "YOUIYOUX_HUB_AUDIENCE";
|
|
2987
|
+
var GITHUB_TOKEN_HEADER = "x-youi-github-oidc";
|
|
2988
|
+
var FUTURE_MS = 5 * 60 * 1e3;
|
|
2989
|
+
var ORG_META = "youi-org";
|
|
2990
|
+
var COOKIE = "youi_hub_session";
|
|
2991
|
+
var MAX_RECORD = 2 * 1024 * 1024;
|
|
2992
|
+
var MIN_SECRET = 24;
|
|
2993
|
+
var SESSION_DAYS = 30;
|
|
2994
|
+
function secretsProblem(ingest, view, github = false) {
|
|
2995
|
+
const missing = [!github && !ingest && "YOUIYOUX_HUB_TOKEN", !view && VIEW_KEY_ENV].filter(Boolean);
|
|
2996
|
+
if (missing.length > 0) return `set ${missing.join(" and ")} in the environment: a random string of ${MIN_SECRET} characters or more${missing.length > 1 ? " each" : ""} (openssl rand -base64 32)${github ? "" : `, the token for CI and the view key for people; or ${GITHUB_OWNERS_ENV} and ${AUDIENCE_ENV}, so GitHub Actions proves each record's repository and no CI token is shared`}`;
|
|
2997
|
+
if (ingest !== void 0 && ingest.length < MIN_SECRET || view.length < MIN_SECRET) return `${ingest !== void 0 ? "YOUIYOUX_HUB_TOKEN and " : ""}${VIEW_KEY_ENV} must be ${MIN_SECRET} characters or more (openssl rand -base64 32)`;
|
|
2998
|
+
if (ingest === view) return `YOUIYOUX_HUB_TOKEN and ${VIEW_KEY_ENV} must differ: whoever can read the hub must not be able to write to it`;
|
|
2999
|
+
return null;
|
|
3000
|
+
}
|
|
3001
|
+
var digest = (text) => createHash("sha256").update(text).digest();
|
|
3002
|
+
var same = (given, expected) => timingSafeEqual(digest(given), digest(expected));
|
|
3003
|
+
var bearer = (req) => /^Bearer (.+)$/.exec(req.headers.authorization ?? "")?.[1] ?? null;
|
|
3004
|
+
var cookie = (req, name) => {
|
|
3005
|
+
for (const part of (req.headers.cookie ?? "").split(";")) {
|
|
3006
|
+
const [key2, ...value] = part.trim().split("=");
|
|
3007
|
+
if (key2 === name) return value.join("=");
|
|
3008
|
+
}
|
|
3009
|
+
return null;
|
|
3010
|
+
};
|
|
3011
|
+
var HEADERS = {
|
|
3012
|
+
"X-Content-Type-Options": "nosniff",
|
|
3013
|
+
"Referrer-Policy": "no-referrer",
|
|
3014
|
+
"X-Frame-Options": "DENY",
|
|
3015
|
+
"X-Robots-Tag": "noindex, nofollow",
|
|
3016
|
+
"Content-Security-Policy": csp([])
|
|
3017
|
+
};
|
|
3018
|
+
function csp(inline) {
|
|
3019
|
+
const hashes = inline.map((script) => ` 'sha256-${createHash("sha256").update(script).digest("base64")}'`).join("");
|
|
3020
|
+
return `default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; font-src 'self' data:; script-src 'self'${hashes}; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'`;
|
|
3021
|
+
}
|
|
3022
|
+
function send(res, status, body, extra = {}) {
|
|
3023
|
+
res.writeHead(status, { ...HEADERS, "Content-Type": "application/json", "Cache-Control": "no-store", ...extra });
|
|
3024
|
+
res.end(JSON.stringify(body));
|
|
3025
|
+
}
|
|
3026
|
+
async function readBody(req, limit) {
|
|
3027
|
+
const chunks = [];
|
|
3028
|
+
let size = 0;
|
|
3029
|
+
for await (const chunk2 of req) {
|
|
3030
|
+
size += chunk2.length;
|
|
3031
|
+
if (size > limit) throw Object.assign(new Error(`a record is at most ${limit / 1024 / 1024} MB`), { status: 413 });
|
|
3032
|
+
chunks.push(chunk2);
|
|
3033
|
+
}
|
|
3034
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
3035
|
+
}
|
|
3036
|
+
function unredacted(record) {
|
|
3037
|
+
const found = /* @__PURE__ */ new Set();
|
|
3038
|
+
const walk = (node) => {
|
|
3039
|
+
if (typeof node === "string") {
|
|
3040
|
+
if (/(?:^|[\s"'`(=,:;])(?:~\/|\/(?:Users|home|root)\/)|[A-Za-z]:\\Users\\/.test(node)) found.add("a path outside the repository");
|
|
3041
|
+
if (/\b[a-z][a-z0-9+.-]*:\/\/[^\s/@:"'<>]+(?::[^\s/@"'<>]*)?@/i.test(node) && !node.includes("[redacted]@")) found.add("a URL with credentials");
|
|
3042
|
+
} else if (Array.isArray(node)) node.forEach(walk);
|
|
3043
|
+
else if (node && typeof node === "object") Object.values(node).forEach(walk);
|
|
3044
|
+
};
|
|
3045
|
+
walk(record.products);
|
|
3046
|
+
return [...found];
|
|
3047
|
+
}
|
|
3048
|
+
var failed = (p) => "error" in p;
|
|
3049
|
+
function orgView(latest, now) {
|
|
3050
|
+
const systems = /* @__PURE__ */ new Map();
|
|
3051
|
+
let products = 0;
|
|
3052
|
+
let unreadable = 0;
|
|
3053
|
+
for (const { record } of latest) {
|
|
3054
|
+
for (const product2 of record.products) {
|
|
3055
|
+
products++;
|
|
3056
|
+
if (failed(product2)) {
|
|
3057
|
+
unreadable++;
|
|
3058
|
+
continue;
|
|
3059
|
+
}
|
|
3060
|
+
const system = systems.get(product2.system.id) ?? { name: product2.system.name, latest: product2.system.version, versions: /* @__PURE__ */ new Map() };
|
|
3061
|
+
if (compareVersions(product2.system.version, system.latest) > 0) system.latest = product2.system.version;
|
|
3062
|
+
const version = product2.lock?.systemVersion ?? null;
|
|
3063
|
+
system.versions.set(version, [...system.versions.get(version) ?? [], { repo: record.repo, id: product2.id }]);
|
|
3064
|
+
systems.set(product2.system.id, system);
|
|
3065
|
+
}
|
|
3066
|
+
}
|
|
3067
|
+
const statuses = latest.flatMap(({ record }) => record.products.filter((p) => !failed(p)));
|
|
3068
|
+
const behind = statuses.filter((p) => p.lock === null || p.lock.systemVersion !== systems.get(p.system.id).latest).length;
|
|
3069
|
+
return {
|
|
3070
|
+
schema: "youi.org/v1",
|
|
3071
|
+
generatedAt: now.toISOString(),
|
|
3072
|
+
repos: latest.map(({ record, receivedAt }) => ({ repo: record.repo, commit: record.commit, ref: record.ref, generatedAt: record.generatedAt, receivedAt, products: record.products })),
|
|
3073
|
+
systems: [...systems].sort(([a], [b]) => a.localeCompare(b)).map(([id, s]) => ({
|
|
3074
|
+
id,
|
|
3075
|
+
name: s.name,
|
|
3076
|
+
latest: s.latest,
|
|
3077
|
+
versions: [...s.versions].sort(([a], [b]) => a === null ? 1 : b === null ? -1 : compareVersions(b, a)).map(([version, list]) => ({ version, products: list }))
|
|
3078
|
+
})),
|
|
3079
|
+
totals: { repos: latest.length, products, unreadable, behind, failing: statuses.filter((p) => p.check.errors > 0 || p.check.incomplete).length }
|
|
3080
|
+
};
|
|
3081
|
+
}
|
|
3082
|
+
var TYPES = { ".js": "text/javascript; charset=utf-8", ".css": "text/css; charset=utf-8", ".svg": "image/svg+xml", ".woff2": "font/woff2", ".png": "image/png", ".webp": "image/webp", ".json": "application/json" };
|
|
3083
|
+
async function startOrgHub(options) {
|
|
3084
|
+
const problem = secretsProblem(options.github ? void 0 : options.ingestToken, options.viewKey, Boolean(options.github));
|
|
3085
|
+
if (problem) throw new Error(problem);
|
|
3086
|
+
const host = options.host ?? "127.0.0.1";
|
|
3087
|
+
const local = ["127.0.0.1", "localhost", "::1"].includes(host);
|
|
3088
|
+
const secure = options.secureCookies ?? !local;
|
|
3089
|
+
const now = options.now ?? (() => /* @__PURE__ */ new Date());
|
|
3090
|
+
const session = createHmac("sha256", options.viewKey).update("youiyoux hub viewer").digest("base64url");
|
|
3091
|
+
const page = options.dist ? (await readFile6(join17(options.dist, "index.html"), "utf8")).replace("</head>", ` <meta name="${ORG_META}" content="/api/org" />
|
|
3092
|
+
</head>`) : null;
|
|
3093
|
+
const pagePolicy = csp(page ? [...page.matchAll(/<script>([\s\S]*?)<\/script>/g)].map((m) => m[1]) : []);
|
|
3094
|
+
const viewer = (req) => {
|
|
3095
|
+
const given = cookie(req, COOKIE) ?? null;
|
|
3096
|
+
const key2 = bearer(req);
|
|
3097
|
+
return given !== null && same(given, session) || key2 !== null && same(key2, options.viewKey);
|
|
3098
|
+
};
|
|
3099
|
+
const cookieHeader = (value, maxAge) => `${COOKIE}=${value}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${maxAge}${secure ? "; Secure" : ""}`;
|
|
3100
|
+
const serveFile = async (res, path) => {
|
|
3101
|
+
const root = join17(options.dist, "assets");
|
|
3102
|
+
const file = normalize(join17(options.dist, path));
|
|
3103
|
+
if (!(file.startsWith(`${root}${sep3}`) || file === join17(options.dist, "favicon.svg"))) return send(res, 404, { error: "not found" });
|
|
3104
|
+
const body = await readFile6(file).catch(() => null);
|
|
3105
|
+
if (!body) return send(res, 404, { error: "not found" });
|
|
3106
|
+
res.writeHead(200, { ...HEADERS, "Content-Type": TYPES[extname(file)] ?? "application/octet-stream", "Cache-Control": path.startsWith("/assets/") ? "public, max-age=31536000, immutable" : "no-cache" });
|
|
3107
|
+
res.end(body);
|
|
3108
|
+
};
|
|
3109
|
+
const server = createServer((req, res) => {
|
|
3110
|
+
void (async () => {
|
|
3111
|
+
const path = (req.url ?? "/").split("?")[0];
|
|
3112
|
+
const method = req.method ?? "GET";
|
|
3113
|
+
try {
|
|
3114
|
+
if (path === "/api/health" && method === "GET") return send(res, 200, { ok: true });
|
|
3115
|
+
if (path === "/api/records" && method === "POST") {
|
|
3116
|
+
const oidc = req.headers[GITHUB_TOKEN_HEADER];
|
|
3117
|
+
let sender = null;
|
|
3118
|
+
if (options.github) {
|
|
3119
|
+
if (typeof oidc !== "string") return send(res, 401, { error: "this hub takes records from GitHub Actions with its OIDC token: give the workflow permissions: id-token: write (youiyoux hooks install --record does)" });
|
|
3120
|
+
try {
|
|
3121
|
+
sender = await options.github(oidc);
|
|
3122
|
+
} catch (error) {
|
|
3123
|
+
return send(res, 401, { error: `the GitHub Actions token was refused: ${error instanceof Error ? error.message : String(error)}` });
|
|
3124
|
+
}
|
|
3125
|
+
} else {
|
|
3126
|
+
const token = bearer(req);
|
|
3127
|
+
if (token === null || !same(token, options.ingestToken)) return send(res, 401, { error: "this hub takes records only with its ingest token (YOUIYOUX_HUB_TOKEN)" });
|
|
3128
|
+
}
|
|
3129
|
+
if (!(req.headers["content-type"] ?? "").startsWith("application/json")) return send(res, 415, { error: "a record is JSON (content-type: application/json)" });
|
|
3130
|
+
let parsed;
|
|
3131
|
+
try {
|
|
3132
|
+
parsed = JSON.parse(await readBody(req, MAX_RECORD));
|
|
3133
|
+
} catch (error) {
|
|
3134
|
+
const status = error.status;
|
|
3135
|
+
return send(res, status ?? 400, { error: status ? error.message : "a record is JSON" });
|
|
3136
|
+
}
|
|
3137
|
+
const record = CiRecord.safeParse(parsed);
|
|
3138
|
+
if (!record.success) return send(res, 422, { error: `not a youi.record/v1: ${record.error.issues.slice(0, 3).map((i) => `${i.path.join(".") || "record"}: ${i.message}`).join("; ")}` });
|
|
3139
|
+
if (sender) {
|
|
3140
|
+
const mismatch = recordMismatch(sender, record.data);
|
|
3141
|
+
if (mismatch) return send(res, 403, { error: `${mismatch}: a repository's CI sends only its own record` });
|
|
3142
|
+
}
|
|
3143
|
+
const received = now();
|
|
3144
|
+
if (Date.parse(record.data.generatedAt) > received.getTime() + FUTURE_MS) return send(res, 422, { error: `the record is dated ${record.data.generatedAt}, after the hub's clock (${received.toISOString()}) by more than five minutes: it would stay its repository's latest, so it was not kept` });
|
|
3145
|
+
const leaks = unredacted(record.data);
|
|
3146
|
+
if (leaks.length > 0) return send(res, 422, { error: `the record holds ${leaks.join(" and ")}, so it was not kept: youiyoux record redacts these` });
|
|
3147
|
+
await options.store.putRecord(record.data, received.toISOString());
|
|
3148
|
+
return send(res, 201, { kept: record.data.repo, products: record.data.products.length });
|
|
3149
|
+
}
|
|
3150
|
+
if (path === "/api/session" && method === "POST") {
|
|
3151
|
+
let key2;
|
|
3152
|
+
try {
|
|
3153
|
+
key2 = JSON.parse(await readBody(req, 4096)).key;
|
|
3154
|
+
} catch {
|
|
3155
|
+
return send(res, 400, { error: 'send { "key": "\u2026" }' });
|
|
3156
|
+
}
|
|
3157
|
+
if (typeof key2 !== "string" || !same(key2, options.viewKey)) return send(res, 401, { error: "that is not this hub's view key" });
|
|
3158
|
+
return send(res, 200, { ok: true }, { "Set-Cookie": cookieHeader(session, SESSION_DAYS * 86400) });
|
|
3159
|
+
}
|
|
3160
|
+
if (path === "/api/session" && method === "DELETE") return send(res, 200, { ok: true }, { "Set-Cookie": cookieHeader("", 0) });
|
|
3161
|
+
if (path === "/api/org" && method === "GET") {
|
|
3162
|
+
if (!viewer(req)) return send(res, 401, { error: "sign in with the hub's view key" });
|
|
3163
|
+
return send(res, 200, orgView(await options.store.latestRecords(), now()));
|
|
3164
|
+
}
|
|
3165
|
+
if (path.startsWith("/api/")) return send(res, 404, { error: "this hub is read-only: it shows the organisation, and takes records from CI" });
|
|
3166
|
+
if (method !== "GET" && method !== "HEAD") return send(res, 405, { error: "read-only" });
|
|
3167
|
+
if (!page) return send(res, 404, { error: "the hub's page is not built here; the API answers at /api/org" });
|
|
3168
|
+
if (path.startsWith("/assets/") || path === "/favicon.svg") return await serveFile(res, path);
|
|
3169
|
+
res.writeHead(200, { ...HEADERS, "Content-Security-Policy": pagePolicy, "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-cache" });
|
|
3170
|
+
res.end(page);
|
|
3171
|
+
} catch (error) {
|
|
3172
|
+
process.stderr.write(`youiyoux hub: ${method} ${path} failed: ${error instanceof Error ? error.message : String(error)}
|
|
3173
|
+
`);
|
|
3174
|
+
send(res, 500, { error: "the hub could not read its store; its log says why" });
|
|
3175
|
+
}
|
|
3176
|
+
})();
|
|
3177
|
+
});
|
|
3178
|
+
await new Promise((done, fail) => {
|
|
3179
|
+
server.once("error", fail);
|
|
3180
|
+
server.listen(options.port ?? 0, host, done);
|
|
3181
|
+
});
|
|
3182
|
+
const address = server.address();
|
|
3183
|
+
const port = typeof address === "object" && address ? address.port : 0;
|
|
3184
|
+
return {
|
|
3185
|
+
url: `http://${host.includes(":") ? `[${host}]` : host}:${port}`,
|
|
3186
|
+
close: () => new Promise((done) => server.close(() => done()))
|
|
3187
|
+
};
|
|
3188
|
+
}
|
|
3189
|
+
|
|
2894
3190
|
// ../cli/src/report/safe.ts
|
|
2895
3191
|
import { realpathSync } from "node:fs";
|
|
2896
|
-
import { readFile as
|
|
3192
|
+
import { readFile as readFile7 } from "node:fs/promises";
|
|
2897
3193
|
import { homedir as homedir2, userInfo as userInfo2 } from "node:os";
|
|
2898
|
-
import { join as
|
|
3194
|
+
import { join as join18 } from "node:path";
|
|
2899
3195
|
|
|
2900
3196
|
// ../cli/src/report/filters.ts
|
|
2901
3197
|
var SHOWCASE_GLOBS = ["**/{__stories__,.storybook,storybook,storybook-static,example,examples,demo,demos,playground,showcase,showcases}/**", "**/*.stories.*", "**/*.story.*"];
|
|
@@ -3032,7 +3328,7 @@ async function dotenvValues(root) {
|
|
|
3032
3328
|
const files = await findFiles(root, [".env", ".env.*", "**/.env", "**/.env.*"]).catch(() => []);
|
|
3033
3329
|
const values = [];
|
|
3034
3330
|
for (const file of files) {
|
|
3035
|
-
const text = await
|
|
3331
|
+
const text = await readFile7(join18(root, file), "utf8").catch(() => "");
|
|
3036
3332
|
for (const line of text.split(/\r?\n/)) {
|
|
3037
3333
|
const match = /^\s*(?:export\s+)?[A-Za-z_][A-Za-z0-9_.-]*\s*=\s*(.*)$/.exec(line);
|
|
3038
3334
|
if (!match) continue;
|
|
@@ -3151,7 +3447,7 @@ var Redactor = class _Redactor {
|
|
|
3151
3447
|
|
|
3152
3448
|
// ../cli/src/status.ts
|
|
3153
3449
|
import { realpath as realpath5 } from "node:fs/promises";
|
|
3154
|
-
import { dirname as dirname9, join as
|
|
3450
|
+
import { dirname as dirname9, join as join19, relative as relative8, resolve as resolve10 } from "node:path";
|
|
3155
3451
|
var placeOf = (cwd, root) => {
|
|
3156
3452
|
const rel = relative8(cwd, root) || ".";
|
|
3157
3453
|
return rel.split(/[\\/]/).filter((part) => part === "..").length > 1 ? resolve10(root) : rel;
|
|
@@ -3210,7 +3506,7 @@ async function statusTargets(cwd, named2) {
|
|
|
3210
3506
|
if (product2) return { roots: [product2], workspace: null };
|
|
3211
3507
|
const at = findUp(cwd, WORKSPACE_FILE);
|
|
3212
3508
|
if (at) {
|
|
3213
|
-
const file =
|
|
3509
|
+
const file = join19(at, WORKSPACE_FILE);
|
|
3214
3510
|
const workspace = await readDefinition(file, WorkspaceConfig);
|
|
3215
3511
|
return { roots: workspace.projects.map((p) => resolve10(at, p)), workspace: file };
|
|
3216
3512
|
}
|
|
@@ -3232,12 +3528,12 @@ async function workspaceStatus(cwd, named2) {
|
|
|
3232
3528
|
projects.push(await projectStatus(await loadProject(root), cwd));
|
|
3233
3529
|
} catch (error) {
|
|
3234
3530
|
const label = placeOf(cwd, root);
|
|
3235
|
-
projects.push({ root: label, error: error instanceof Error ? error.message : String(error), next: await readTextOrNull(
|
|
3531
|
+
projects.push({ root: label, error: error instanceof Error ? error.message : String(error), next: await readTextOrNull(join19(root, PROJECT_FILE)) === null ? `youiyoux adopt or youiyoux init --design <dir> in ${label}` : `fix ${join19(label, PROJECT_FILE)}` });
|
|
3236
3532
|
}
|
|
3237
3533
|
}
|
|
3238
3534
|
return { schema: "youi.status/v1", generatedAt: (/* @__PURE__ */ new Date()).toISOString(), workspace: workspace ? relative8(cwd, workspace) || WORKSPACE_FILE : null, projects };
|
|
3239
3535
|
}
|
|
3240
|
-
var
|
|
3536
|
+
var failed2 = (p) => "error" in p;
|
|
3241
3537
|
function projectLines(s) {
|
|
3242
3538
|
const row = (label, text) => ` ${padEnd(label, 11)} ${text}`;
|
|
3243
3539
|
const synced = s.lock ? `synced ${s.lock.syncedAt.slice(0, 10)}${s.lock.systemVersion !== s.system.version ? ` at v${s.lock.systemVersion}` : ""}` : "never synced";
|
|
@@ -3261,11 +3557,11 @@ function projectLines(s) {
|
|
|
3261
3557
|
function renderStatus(report) {
|
|
3262
3558
|
if (report.projects.length === 0) return `${style.warn("!")} no product here: no ${PROJECT_FILE} upward, no ${WORKSPACE_FILE}, and none in this repository below here
|
|
3263
3559
|
Next: youiyoux adopt makes this product's own UI its design system, or youiyoux init --design <dir> follows one`;
|
|
3264
|
-
const blocks = report.projects.map((p) =>
|
|
3560
|
+
const blocks = report.projects.map((p) => failed2(p) ? [`${style.bold(p.root)} ${style.error("\u2716")} ${p.error}`, `${style.bold("Next:")} ${p.next}`] : projectLines(p));
|
|
3265
3561
|
const head = report.projects.length > 1 ? [`${style.bold("workspace")} ${style.dim(`\xB7 ${plural(report.projects.length, "product")}${report.workspace ? ` from ${report.workspace}` : " in this repository"}`)}`, ""] : [];
|
|
3266
3562
|
return [...head, ...blocks.flatMap((b, i) => i === 0 ? b : ["", ...b])].join("\n");
|
|
3267
3563
|
}
|
|
3268
|
-
var statusExitCode = (report) => report.projects.some((p) =>
|
|
3564
|
+
var statusExitCode = (report) => report.projects.some((p) => failed2(p) || p.check.incomplete || p.check.errors > 0 || p.sync.state === "conflict") ? 1 : 0;
|
|
3269
3565
|
|
|
3270
3566
|
// ../cli/src/record.ts
|
|
3271
3567
|
var run6 = promisify5(execFile5);
|
|
@@ -3273,7 +3569,7 @@ var git3 = async (cwd, args) => run6("git", args, { cwd }).then((r) => r.stdout.
|
|
|
3273
3569
|
var HUB_URL_ENV = "YOUIYOUX_HUB_URL";
|
|
3274
3570
|
var HUB_TOKEN_ENV = "YOUIYOUX_HUB_TOKEN";
|
|
3275
3571
|
function toolVersion(root = TOOL_ROOT) {
|
|
3276
|
-
for (const file of [
|
|
3572
|
+
for (const file of [join20(root, "packages/youiyoux/package.json"), join20(root, "../package.json")]) {
|
|
3277
3573
|
try {
|
|
3278
3574
|
const pkg = JSON.parse(readFileSync2(file, "utf8"));
|
|
3279
3575
|
if (pkg.name === "youiyoux" && pkg.version) return pkg.version;
|
|
@@ -3308,9 +3604,10 @@ function recordsUrl(hub) {
|
|
|
3308
3604
|
if (url.username || url.password) throw new Error(`${HUB_URL_ENV} carries a user name or password: the token goes in ${HUB_TOKEN_ENV}`);
|
|
3309
3605
|
return new URL(`${url.pathname.replace(/\/+$/, "")}/api/records`, url.origin);
|
|
3310
3606
|
}
|
|
3311
|
-
async function sendRecord(record, hub,
|
|
3607
|
+
async function sendRecord(record, hub, credentials, fetcher = fetch) {
|
|
3312
3608
|
const url = recordsUrl(hub);
|
|
3313
|
-
const
|
|
3609
|
+
const headers = { "content-type": "application/json", ...credentials.token ? { authorization: `Bearer ${credentials.token}` } : {}, ...credentials.github ? { [GITHUB_TOKEN_HEADER]: credentials.github } : {} };
|
|
3610
|
+
const response = await fetcher(url, { method: "POST", headers, body: JSON.stringify(record), redirect: "error", signal: AbortSignal.timeout(3e4) }).catch((error) => {
|
|
3314
3611
|
throw new Error(`could not reach the hub at ${url.origin}: ${error instanceof Error ? error.message : String(error)}`);
|
|
3315
3612
|
});
|
|
3316
3613
|
if (response.ok) return url.origin;
|
|
@@ -3324,196 +3621,6 @@ function renderRecord(record, sentTo) {
|
|
|
3324
3621
|
return [head, style.dim(" redacted as the shareable report is: repo-relative paths, no home, username, env values or credentials; no code")].join("\n");
|
|
3325
3622
|
}
|
|
3326
3623
|
|
|
3327
|
-
// ../cli/src/org-hub.ts
|
|
3328
|
-
import { createHash, createHmac, timingSafeEqual } from "node:crypto";
|
|
3329
|
-
import { readFile as readFile7 } from "node:fs/promises";
|
|
3330
|
-
import { createServer } from "node:http";
|
|
3331
|
-
import { extname, join as join20, normalize, sep as sep3 } from "node:path";
|
|
3332
|
-
var VIEW_KEY_ENV = "YOUIYOUX_HUB_VIEW_KEY";
|
|
3333
|
-
var ORG_META = "youi-org";
|
|
3334
|
-
var COOKIE = "youi_hub_session";
|
|
3335
|
-
var MAX_RECORD = 2 * 1024 * 1024;
|
|
3336
|
-
var MIN_SECRET = 24;
|
|
3337
|
-
var SESSION_DAYS = 30;
|
|
3338
|
-
function secretsProblem(ingest, view) {
|
|
3339
|
-
const missing = [!ingest && "YOUIYOUX_HUB_TOKEN", !view && VIEW_KEY_ENV].filter(Boolean);
|
|
3340
|
-
if (missing.length > 0) return `set ${missing.join(" and ")} in the environment: a random string of ${MIN_SECRET} characters or more each (openssl rand -base64 32), the token for CI and the view key for people`;
|
|
3341
|
-
if (ingest.length < MIN_SECRET || view.length < MIN_SECRET) return `YOUIYOUX_HUB_TOKEN and ${VIEW_KEY_ENV} must be ${MIN_SECRET} characters or more (openssl rand -base64 32)`;
|
|
3342
|
-
if (ingest === view) return `YOUIYOUX_HUB_TOKEN and ${VIEW_KEY_ENV} must differ: whoever can read the hub must not be able to write to it`;
|
|
3343
|
-
return null;
|
|
3344
|
-
}
|
|
3345
|
-
var digest = (text) => createHash("sha256").update(text).digest();
|
|
3346
|
-
var same = (given, expected) => timingSafeEqual(digest(given), digest(expected));
|
|
3347
|
-
var bearer = (req) => /^Bearer (.+)$/.exec(req.headers.authorization ?? "")?.[1] ?? null;
|
|
3348
|
-
var cookie = (req, name) => {
|
|
3349
|
-
for (const part of (req.headers.cookie ?? "").split(";")) {
|
|
3350
|
-
const [key2, ...value] = part.trim().split("=");
|
|
3351
|
-
if (key2 === name) return value.join("=");
|
|
3352
|
-
}
|
|
3353
|
-
return null;
|
|
3354
|
-
};
|
|
3355
|
-
var HEADERS = {
|
|
3356
|
-
"X-Content-Type-Options": "nosniff",
|
|
3357
|
-
"Referrer-Policy": "no-referrer",
|
|
3358
|
-
"X-Frame-Options": "DENY",
|
|
3359
|
-
"X-Robots-Tag": "noindex, nofollow",
|
|
3360
|
-
"Content-Security-Policy": csp([])
|
|
3361
|
-
};
|
|
3362
|
-
function csp(inline) {
|
|
3363
|
-
const hashes = inline.map((script) => ` 'sha256-${createHash("sha256").update(script).digest("base64")}'`).join("");
|
|
3364
|
-
return `default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; font-src 'self' data:; script-src 'self'${hashes}; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'`;
|
|
3365
|
-
}
|
|
3366
|
-
function send(res, status, body, extra = {}) {
|
|
3367
|
-
res.writeHead(status, { ...HEADERS, "Content-Type": "application/json", "Cache-Control": "no-store", ...extra });
|
|
3368
|
-
res.end(JSON.stringify(body));
|
|
3369
|
-
}
|
|
3370
|
-
async function readBody(req, limit) {
|
|
3371
|
-
const chunks = [];
|
|
3372
|
-
let size = 0;
|
|
3373
|
-
for await (const chunk2 of req) {
|
|
3374
|
-
size += chunk2.length;
|
|
3375
|
-
if (size > limit) throw Object.assign(new Error(`a record is at most ${limit / 1024 / 1024} MB`), { status: 413 });
|
|
3376
|
-
chunks.push(chunk2);
|
|
3377
|
-
}
|
|
3378
|
-
return Buffer.concat(chunks).toString("utf8");
|
|
3379
|
-
}
|
|
3380
|
-
function unredacted(record) {
|
|
3381
|
-
const found = /* @__PURE__ */ new Set();
|
|
3382
|
-
const walk = (node) => {
|
|
3383
|
-
if (typeof node === "string") {
|
|
3384
|
-
if (/(?:^|[\s"'`(=,:;])(?:~\/|\/(?:Users|home|root)\/)|[A-Za-z]:\\Users\\/.test(node)) found.add("a path outside the repository");
|
|
3385
|
-
if (/\b[a-z][a-z0-9+.-]*:\/\/[^\s/@:"'<>]+(?::[^\s/@"'<>]*)?@/i.test(node) && !node.includes("[redacted]@")) found.add("a URL with credentials");
|
|
3386
|
-
} else if (Array.isArray(node)) node.forEach(walk);
|
|
3387
|
-
else if (node && typeof node === "object") Object.values(node).forEach(walk);
|
|
3388
|
-
};
|
|
3389
|
-
walk(record.products);
|
|
3390
|
-
return [...found];
|
|
3391
|
-
}
|
|
3392
|
-
var failed2 = (p) => "error" in p;
|
|
3393
|
-
function orgView(latest, now) {
|
|
3394
|
-
const systems = /* @__PURE__ */ new Map();
|
|
3395
|
-
let products = 0;
|
|
3396
|
-
let unreadable = 0;
|
|
3397
|
-
for (const { record } of latest) {
|
|
3398
|
-
for (const product2 of record.products) {
|
|
3399
|
-
products++;
|
|
3400
|
-
if (failed2(product2)) {
|
|
3401
|
-
unreadable++;
|
|
3402
|
-
continue;
|
|
3403
|
-
}
|
|
3404
|
-
const system = systems.get(product2.system.id) ?? { name: product2.system.name, latest: product2.system.version, versions: /* @__PURE__ */ new Map() };
|
|
3405
|
-
if (compareVersions(product2.system.version, system.latest) > 0) system.latest = product2.system.version;
|
|
3406
|
-
const version = product2.lock?.systemVersion ?? null;
|
|
3407
|
-
system.versions.set(version, [...system.versions.get(version) ?? [], { repo: record.repo, id: product2.id }]);
|
|
3408
|
-
systems.set(product2.system.id, system);
|
|
3409
|
-
}
|
|
3410
|
-
}
|
|
3411
|
-
const statuses = latest.flatMap(({ record }) => record.products.filter((p) => !failed2(p)));
|
|
3412
|
-
const behind = statuses.filter((p) => p.lock === null || p.lock.systemVersion !== systems.get(p.system.id).latest).length;
|
|
3413
|
-
return {
|
|
3414
|
-
schema: "youi.org/v1",
|
|
3415
|
-
generatedAt: now.toISOString(),
|
|
3416
|
-
repos: latest.map(({ record, receivedAt }) => ({ repo: record.repo, commit: record.commit, ref: record.ref, generatedAt: record.generatedAt, receivedAt, products: record.products })),
|
|
3417
|
-
systems: [...systems].sort(([a], [b]) => a.localeCompare(b)).map(([id, s]) => ({
|
|
3418
|
-
id,
|
|
3419
|
-
name: s.name,
|
|
3420
|
-
latest: s.latest,
|
|
3421
|
-
versions: [...s.versions].sort(([a], [b]) => a === null ? 1 : b === null ? -1 : compareVersions(b, a)).map(([version, list]) => ({ version, products: list }))
|
|
3422
|
-
})),
|
|
3423
|
-
totals: { repos: latest.length, products, unreadable, behind, failing: statuses.filter((p) => p.check.errors > 0 || p.check.incomplete).length }
|
|
3424
|
-
};
|
|
3425
|
-
}
|
|
3426
|
-
var TYPES = { ".js": "text/javascript; charset=utf-8", ".css": "text/css; charset=utf-8", ".svg": "image/svg+xml", ".woff2": "font/woff2", ".png": "image/png", ".webp": "image/webp", ".json": "application/json" };
|
|
3427
|
-
async function startOrgHub(options) {
|
|
3428
|
-
const problem = secretsProblem(options.ingestToken, options.viewKey);
|
|
3429
|
-
if (problem) throw new Error(problem);
|
|
3430
|
-
const host = options.host ?? "127.0.0.1";
|
|
3431
|
-
const local = ["127.0.0.1", "localhost", "::1"].includes(host);
|
|
3432
|
-
const secure = options.secureCookies ?? !local;
|
|
3433
|
-
const now = options.now ?? (() => /* @__PURE__ */ new Date());
|
|
3434
|
-
const session = createHmac("sha256", options.viewKey).update("youiyoux hub viewer").digest("base64url");
|
|
3435
|
-
const page = options.dist ? (await readFile7(join20(options.dist, "index.html"), "utf8")).replace("</head>", ` <meta name="${ORG_META}" content="/api/org" />
|
|
3436
|
-
</head>`) : null;
|
|
3437
|
-
const pagePolicy = csp(page ? [...page.matchAll(/<script>([\s\S]*?)<\/script>/g)].map((m) => m[1]) : []);
|
|
3438
|
-
const viewer = (req) => {
|
|
3439
|
-
const given = cookie(req, COOKIE) ?? null;
|
|
3440
|
-
const key2 = bearer(req);
|
|
3441
|
-
return given !== null && same(given, session) || key2 !== null && same(key2, options.viewKey);
|
|
3442
|
-
};
|
|
3443
|
-
const cookieHeader = (value, maxAge) => `${COOKIE}=${value}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${maxAge}${secure ? "; Secure" : ""}`;
|
|
3444
|
-
const serveFile = async (res, path) => {
|
|
3445
|
-
const root = join20(options.dist, "assets");
|
|
3446
|
-
const file = normalize(join20(options.dist, path));
|
|
3447
|
-
if (!(file.startsWith(`${root}${sep3}`) || file === join20(options.dist, "favicon.svg"))) return send(res, 404, { error: "not found" });
|
|
3448
|
-
const body = await readFile7(file).catch(() => null);
|
|
3449
|
-
if (!body) return send(res, 404, { error: "not found" });
|
|
3450
|
-
res.writeHead(200, { ...HEADERS, "Content-Type": TYPES[extname(file)] ?? "application/octet-stream", "Cache-Control": path.startsWith("/assets/") ? "public, max-age=31536000, immutable" : "no-cache" });
|
|
3451
|
-
res.end(body);
|
|
3452
|
-
};
|
|
3453
|
-
const server = createServer((req, res) => {
|
|
3454
|
-
void (async () => {
|
|
3455
|
-
const path = (req.url ?? "/").split("?")[0];
|
|
3456
|
-
const method = req.method ?? "GET";
|
|
3457
|
-
try {
|
|
3458
|
-
if (path === "/api/health" && method === "GET") return send(res, 200, { ok: true });
|
|
3459
|
-
if (path === "/api/records" && method === "POST") {
|
|
3460
|
-
const token = bearer(req);
|
|
3461
|
-
if (token === null || !same(token, options.ingestToken)) return send(res, 401, { error: "this hub takes records only with its ingest token (YOUIYOUX_HUB_TOKEN)" });
|
|
3462
|
-
if (!(req.headers["content-type"] ?? "").startsWith("application/json")) return send(res, 415, { error: "a record is JSON (content-type: application/json)" });
|
|
3463
|
-
let parsed;
|
|
3464
|
-
try {
|
|
3465
|
-
parsed = JSON.parse(await readBody(req, MAX_RECORD));
|
|
3466
|
-
} catch (error) {
|
|
3467
|
-
const status = error.status;
|
|
3468
|
-
return send(res, status ?? 400, { error: status ? error.message : "a record is JSON" });
|
|
3469
|
-
}
|
|
3470
|
-
const record = CiRecord.safeParse(parsed);
|
|
3471
|
-
if (!record.success) return send(res, 422, { error: `not a youi.record/v1: ${record.error.issues.slice(0, 3).map((i) => `${i.path.join(".") || "record"}: ${i.message}`).join("; ")}` });
|
|
3472
|
-
const leaks = unredacted(record.data);
|
|
3473
|
-
if (leaks.length > 0) return send(res, 422, { error: `the record holds ${leaks.join(" and ")}, so it was not kept: youiyoux record redacts these` });
|
|
3474
|
-
await options.store.putRecord(record.data, now().toISOString());
|
|
3475
|
-
return send(res, 201, { kept: record.data.repo, products: record.data.products.length });
|
|
3476
|
-
}
|
|
3477
|
-
if (path === "/api/session" && method === "POST") {
|
|
3478
|
-
let key2;
|
|
3479
|
-
try {
|
|
3480
|
-
key2 = JSON.parse(await readBody(req, 4096)).key;
|
|
3481
|
-
} catch {
|
|
3482
|
-
return send(res, 400, { error: 'send { "key": "\u2026" }' });
|
|
3483
|
-
}
|
|
3484
|
-
if (typeof key2 !== "string" || !same(key2, options.viewKey)) return send(res, 401, { error: "that is not this hub's view key" });
|
|
3485
|
-
return send(res, 200, { ok: true }, { "Set-Cookie": cookieHeader(session, SESSION_DAYS * 86400) });
|
|
3486
|
-
}
|
|
3487
|
-
if (path === "/api/session" && method === "DELETE") return send(res, 200, { ok: true }, { "Set-Cookie": cookieHeader("", 0) });
|
|
3488
|
-
if (path === "/api/org" && method === "GET") {
|
|
3489
|
-
if (!viewer(req)) return send(res, 401, { error: "sign in with the hub's view key" });
|
|
3490
|
-
return send(res, 200, orgView(await options.store.latestRecords(), now()));
|
|
3491
|
-
}
|
|
3492
|
-
if (path.startsWith("/api/")) return send(res, 404, { error: "this hub is read-only: it shows the organisation, and takes records from CI" });
|
|
3493
|
-
if (method !== "GET" && method !== "HEAD") return send(res, 405, { error: "read-only" });
|
|
3494
|
-
if (!page) return send(res, 404, { error: "the hub's page is not built here; the API answers at /api/org" });
|
|
3495
|
-
if (path.startsWith("/assets/") || path === "/favicon.svg") return await serveFile(res, path);
|
|
3496
|
-
res.writeHead(200, { ...HEADERS, "Content-Security-Policy": pagePolicy, "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-cache" });
|
|
3497
|
-
res.end(page);
|
|
3498
|
-
} catch (error) {
|
|
3499
|
-
process.stderr.write(`youiyoux hub: ${method} ${path} failed: ${error instanceof Error ? error.message : String(error)}
|
|
3500
|
-
`);
|
|
3501
|
-
send(res, 500, { error: "the hub could not read its store; its log says why" });
|
|
3502
|
-
}
|
|
3503
|
-
})();
|
|
3504
|
-
});
|
|
3505
|
-
await new Promise((done, fail) => {
|
|
3506
|
-
server.once("error", fail);
|
|
3507
|
-
server.listen(options.port ?? 0, host, done);
|
|
3508
|
-
});
|
|
3509
|
-
const address = server.address();
|
|
3510
|
-
const port = typeof address === "object" && address ? address.port : 0;
|
|
3511
|
-
return {
|
|
3512
|
-
url: `http://${host.includes(":") ? `[${host}]` : host}:${port}`,
|
|
3513
|
-
close: () => new Promise((done) => server.close(() => done()))
|
|
3514
|
-
};
|
|
3515
|
-
}
|
|
3516
|
-
|
|
3517
3624
|
// ../cli/src/workspace-hub.ts
|
|
3518
3625
|
import { randomBytes as randomBytes2 } from "node:crypto";
|
|
3519
3626
|
import { createServer as createServer2 } from "node:http";
|
|
@@ -5522,11 +5629,14 @@ ${style.bold("In a product repo")}
|
|
|
5522
5629
|
record [--send [--hub url]] [--repo owner/name] [--out file] [--json]
|
|
5523
5630
|
this repository's record for the team's hosted hub (youi.record/v1): each
|
|
5524
5631
|
product's status, redacted as the shareable report is, no code; --send
|
|
5525
|
-
posts it to YOUIYOUX_HUB_URL
|
|
5632
|
+
posts it to YOUIYOUX_HUB_URL, proved by GitHub Actions' OIDC token
|
|
5633
|
+
(id-token: write), else with YOUIYOUX_HUB_TOKEN (environment only)
|
|
5526
5634
|
hub serve [--port n] [--host h] [--db file]
|
|
5527
5635
|
the team's hosted hub, read-only: CI sends records, people sign in with
|
|
5528
5636
|
YOUIYOUX_HUB_VIEW_KEY and see every repository and each System's adoption
|
|
5529
|
-
by version.
|
|
5637
|
+
by version. YOUIYOUX_HUB_GITHUB_OWNERS and YOUIYOUX_HUB_AUDIENCE: GitHub
|
|
5638
|
+
Actions proves each record's repository (else YOUIYOUX_HUB_TOKEN, shared).
|
|
5639
|
+
Records are kept in Postgres (DATABASE_URL) or a SQLite file
|
|
5530
5640
|
|
|
5531
5641
|
${style.bold("System")} ${style.dim("approved elements: build only with these")}
|
|
5532
5642
|
show <component> the contract: import, legal prop values, guidance, examples
|
|
@@ -5976,13 +6086,14 @@ async function cmdUpgrade(values) {
|
|
|
5976
6086
|
}
|
|
5977
6087
|
async function cmdRecord(values) {
|
|
5978
6088
|
const hub = values.hub ?? process.env[HUB_URL_ENV];
|
|
5979
|
-
const token = process.env[HUB_TOKEN_ENV];
|
|
6089
|
+
const token = process.env[HUB_TOKEN_ENV] || null;
|
|
5980
6090
|
if (values.send && !hub) throw new UsageError(`youiyoux record --send needs the hub's address: set ${HUB_URL_ENV} (or pass --hub <url>)`);
|
|
5981
|
-
|
|
6091
|
+
const github = values.send ? await requestGithubToken(audienceOf(hub)) : null;
|
|
6092
|
+
if (values.send && !token && !github) throw new UsageError(`youiyoux record --send needs a way to prove where the record comes from: in GitHub Actions, permissions: id-token: write; elsewhere, the hub's ingest token in ${HUB_TOKEN_ENV} (from the environment only, never a flag)`);
|
|
5982
6093
|
const record = await buildRecord(values.project ?? process.cwd(), values.repo ? { repo: values.repo } : {});
|
|
5983
6094
|
if (values.out) await writeFileAtomic(resolve15(values.out), `${JSON.stringify(record, null, 2)}
|
|
5984
6095
|
`);
|
|
5985
|
-
const sentTo = values.send ? await sendRecord(record, hub, token) : null;
|
|
6096
|
+
const sentTo = values.send ? await sendRecord(record, hub, { token, github }) : null;
|
|
5986
6097
|
if (values.json) json3(record);
|
|
5987
6098
|
else print([renderRecord(record, sentTo), ...values.out ? [style.dim(` written to ${values.out}`)] : [], ...!values.send && !values.out ? [style.dim(` youiyoux record --send posts it to the hub in ${HUB_URL_ENV}; --out <file> writes it; --json prints it`)] : []].join("\n"));
|
|
5988
6099
|
return 0;
|
|
@@ -5993,7 +6104,12 @@ function builtHub() {
|
|
|
5993
6104
|
}
|
|
5994
6105
|
async function cmdHubServe(values, positionals) {
|
|
5995
6106
|
if (positionals[0] !== "serve" || positionals.length > 1) throw new UsageError("youiyoux hub serve [--port n] [--host h] [--db file] (the team's hosted hub: read-only, fed by youiyoux record in CI)");
|
|
5996
|
-
const
|
|
6107
|
+
const owners = (process.env[GITHUB_OWNERS_ENV] ?? "").split(",").map((o) => o.trim()).filter(Boolean);
|
|
6108
|
+
const audience = process.env[AUDIENCE_ENV];
|
|
6109
|
+
if (owners.length > 0 && !audience) throw new UsageError(`${GITHUB_OWNERS_ENV} needs ${AUDIENCE_ENV}: the hub's address, exactly as CI's ${HUB_URL_ENV} names it (GitHub's token is issued for it)`);
|
|
6110
|
+
const branches = (process.env.YOUIYOUX_HUB_BRANCHES ?? "").split(",").map((b) => b.trim()).filter(Boolean);
|
|
6111
|
+
const github = owners.length > 0 ? githubVerifier({ owners, audience, branches }) : void 0;
|
|
6112
|
+
const problem = secretsProblem(github ? void 0 : process.env[HUB_TOKEN_ENV], process.env[VIEW_KEY_ENV], Boolean(github));
|
|
5997
6113
|
if (problem) throw new UsageError(problem);
|
|
5998
6114
|
const port = Number(values.port ?? process.env.PORT ?? 8080);
|
|
5999
6115
|
if (!Number.isInteger(port) || port < 0 || port > 65535) throw new UsageError(`--port ${JSON.stringify(values.port ?? process.env.PORT)}: expected a port number`);
|
|
@@ -6001,8 +6117,9 @@ async function cmdHubServe(values, positionals) {
|
|
|
6001
6117
|
const path = resolve15(values.db ?? process.env.YOUI_HUB_DB ?? join28(process.env.XDG_DATA_HOME || join28(homedir4(), ".local/share"), "youiyoux/hub.db"));
|
|
6002
6118
|
const store = await openStore({ path });
|
|
6003
6119
|
const dist = builtHub();
|
|
6004
|
-
const hub = await startOrgHub({ store, ingestToken: process.env[HUB_TOKEN_ENV], viewKey: process.env[VIEW_KEY_ENV], dist, host: values.host ?? "127.0.0.1", port });
|
|
6005
|
-
|
|
6120
|
+
const hub = await startOrgHub({ store, ...github ? { github } : { ingestToken: process.env[HUB_TOKEN_ENV] }, viewKey: process.env[VIEW_KEY_ENV], dist, host: values.host ?? "127.0.0.1", port });
|
|
6121
|
+
const senders = github ? `CI sends records from GitHub Actions in ${owners.join(", ")}, on ${(branches.length > 0 ? branches : DEFAULT_BRANCHES).join(" or ")}, each proving its repository (OIDC, for ${audienceOf(audience)})${process.env[HUB_TOKEN_ENV] ? `; ${HUB_TOKEN_ENV} is not used` : ""}` : `CI sends records with ${HUB_TOKEN_ENV}, which can name any repository (${GITHUB_OWNERS_ENV} makes GitHub Actions prove each one's)`;
|
|
6122
|
+
print([`${style.ok("youiyoux hub")} ${hub.url} ${style.dim(`read-only \xB7 records kept in ${where2({ path })}`)}`, ...dist ? [] : [style.warn("! the hub's page is not built here (pnpm --filter web build): the API answers at /api/org")], style.dim(` ${senders}; people sign in with ${VIEW_KEY_ENV}. Put it behind https; --host 0.0.0.0 to listen beyond this machine`)].join("\n"));
|
|
6006
6123
|
await new Promise((done) => {
|
|
6007
6124
|
const stop = () => done();
|
|
6008
6125
|
process.once("SIGINT", stop);
|
package/dist/youi.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { createRequire as __youiRequire } from "node:module"; import { dirname as __youiDir } from "node:path"; import { fileURLToPath as __youiPath } from "node:url"; const require = __youiRequire(import.meta.url); const __youiDirname = __youiDir(__youiPath(import.meta.url));
|
|
3
3
|
import {
|
|
4
4
|
main
|
|
5
|
-
} from "./chunks/chunk-
|
|
5
|
+
} from "./chunks/chunk-EV62TUDV.js";
|
|
6
6
|
import "./chunks/chunk-PCLXMWZ2.js";
|
|
7
7
|
import "./chunks/chunk-42YJX3W5.js";
|
|
8
8
|
import "./chunks/chunk-5I2VHSEN.js";
|
package/dist/youiyoux.js
CHANGED
|
@@ -3,7 +3,7 @@ import { createRequire as __youiRequire } from "node:module"; import { dirname a
|
|
|
3
3
|
import {
|
|
4
4
|
COMMAND_NAMES,
|
|
5
5
|
main
|
|
6
|
-
} from "./chunks/chunk-
|
|
6
|
+
} from "./chunks/chunk-EV62TUDV.js";
|
|
7
7
|
import "./chunks/chunk-PCLXMWZ2.js";
|
|
8
8
|
import "./chunks/chunk-42YJX3W5.js";
|
|
9
9
|
import "./chunks/chunk-5I2VHSEN.js";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "youiyoux",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.13",
|
|
4
4
|
"description": "Your agent rebuilds components you already have. youiyoux catches it: npx youiyoux turns any repo into a shareable report of near-duplicate components, hardcoded token values and off-library imports, with the fixes.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -46,9 +46,9 @@
|
|
|
46
46
|
"typescript": "6.0.3"
|
|
47
47
|
},
|
|
48
48
|
"devDependencies": {
|
|
49
|
-
"@youiyoux/
|
|
50
|
-
"@youiyoux/
|
|
51
|
-
"@youiyoux/
|
|
49
|
+
"@youiyoux/core": "0.1.13",
|
|
50
|
+
"@youiyoux/scanner": "0.1.13",
|
|
51
|
+
"@youiyoux/cli": "0.1.13"
|
|
52
52
|
},
|
|
53
53
|
"exports": {
|
|
54
54
|
"./package.json": "./package.json"
|