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.
@@ -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 two repository secrets: YOUIYOUX_HUB_URL (the hub's https address) and
1311
- # YOUIYOUX_HUB_TOKEN (the hub's ingest token). Pinned to youiyoux ${input.version}; change it here.
1312
- # Only GitHub's own actions run here: the record step holds the token.
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 as repository secrets`);
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 join19 } from "node:path";
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 readFile6 } from "node:fs/promises";
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 join17 } from "node:path";
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 readFile6(join17(root, file), "utf8").catch(() => "");
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 join18, relative as relative8, resolve as resolve10 } from "node:path";
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 = join18(at, WORKSPACE_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(join18(root, PROJECT_FILE)) === null ? `youiyoux adopt or youiyoux init --design <dir> in ${label}` : `fix ${join18(label, PROJECT_FILE)}` });
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 failed = (p) => "error" in p;
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) => failed(p) ? [`${style.bold(p.root)} ${style.error("\u2716")} ${p.error}`, `${style.bold("Next:")} ${p.next}`] : projectLines(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) => failed(p) || p.check.incomplete || p.check.errors > 0 || p.sync.state === "conflict") ? 1 : 0;
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 [join19(root, "packages/youiyoux/package.json"), join19(root, "../package.json")]) {
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, token, fetcher = fetch) {
3607
+ async function sendRecord(record, hub, credentials, fetcher = fetch) {
3312
3608
  const url = recordsUrl(hub);
3313
- const response = await fetcher(url, { method: "POST", headers: { "content-type": "application/json", authorization: `Bearer ${token}` }, body: JSON.stringify(record), redirect: "error", signal: AbortSignal.timeout(3e4) }).catch((error) => {
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 with YOUIYOUX_HUB_TOKEN (environment only)
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. Records are kept in Postgres (DATABASE_URL) or a SQLite file
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
- if (values.send && !token) throw new UsageError(`youiyoux record --send needs the hub's ingest token in ${HUB_TOKEN_ENV} (from the environment only, never a flag)`);
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 problem = secretsProblem(process.env[HUB_TOKEN_ENV], process.env[VIEW_KEY_ENV]);
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
- 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(` CI sends records with ${HUB_TOKEN_ENV}; people sign in with ${VIEW_KEY_ENV}. Put it behind https; --host 0.0.0.0 to listen beyond this machine`)].join("\n"));
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-VXR7FMM7.js";
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-VXR7FMM7.js";
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.12",
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/cli": "0.1.12",
50
- "@youiyoux/core": "0.1.12",
51
- "@youiyoux/scanner": "0.1.12"
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"