@kici-dev/orchestrator 0.14.2 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/dist/__test-helpers__/peer-mutual-auth.d.ts +42 -0
  2. package/dist/agent/host-roster.d.ts +100 -37
  3. package/dist/app.d.ts +4 -2
  4. package/dist/cli/commands/host.d.ts +3 -0
  5. package/dist/cli/commands/source-manifest.d.ts +3 -3
  6. package/dist/cli/commands/source.d.ts +23 -0
  7. package/dist/cli/join.d.ts +2 -2
  8. package/dist/cli/kici-admin.d.ts +5 -0
  9. package/dist/cli/local-github-ingress-url.d.ts +6 -0
  10. package/dist/cli/root-option-routing.d.ts +46 -0
  11. package/dist/cli/webhook-url-notes.d.ts +15 -0
  12. package/dist/cli.js +1429 -885
  13. package/dist/cluster/coordinator-credential.d.ts +21 -0
  14. package/dist/cluster/index.d.ts +4 -4
  15. package/dist/cluster/join-client.d.ts +23 -21
  16. package/dist/cluster/join-handler.d.ts +24 -16
  17. package/dist/cluster/join-protocol-v2.d.ts +84 -0
  18. package/dist/cluster/join-token.d.ts +78 -56
  19. package/dist/cluster/peer-auth-coordinator.d.ts +16 -2
  20. package/dist/cluster/peer-client.d.ts +72 -4
  21. package/dist/cluster/peer-crypto.d.ts +60 -3
  22. package/dist/cluster/peer-discovery.d.ts +67 -0
  23. package/dist/cluster/peer-handler.d.ts +17 -5
  24. package/dist/cluster/peer-registry.d.ts +15 -1
  25. package/dist/config/schema.d.ts +14 -0
  26. package/dist/config/types.d.ts +4 -0
  27. package/dist/config.d.ts +9 -0
  28. package/dist/db/migrations/157_host_roster_identity_source.d.ts +21 -0
  29. package/dist/db/types.d.ts +16 -2
  30. package/dist/github-app-name-refresher/github-app-name-refresher.d.ts +25 -13
  31. package/dist/index.d.ts +1 -1
  32. package/dist/index.js +225 -81
  33. package/dist/providers/github/manifest.d.ts +58 -7
  34. package/dist/routes/admin-sources.d.ts +26 -23
  35. package/dist/routes/admin.d.ts +14 -17
  36. package/dist/routes/cluster-join.d.ts +13 -0
  37. package/dist/routes/github-webhook.d.ts +13 -11
  38. package/dist/scaler/bare-metal-backend.d.ts +42 -1
  39. package/dist/scaler/types.d.ts +7 -5
  40. package/dist/server.js +9040 -7631
  41. package/dist/sources/source-manager.d.ts +0 -24
  42. package/dist/sources/webhook-url-resolvers.d.ts +60 -0
  43. package/dist/standalone.js +9944 -8818
  44. package/dist/ws/dashboard-fleet-handler.d.ts +4 -3
  45. package/dist/ws/dashboard-fleet-write-handler.d.ts +7 -1
  46. package/dist/ws/platform-client.d.ts +18 -6
  47. package/installer-image-digests.json +3 -3
  48. package/package.json +4 -4
  49. package/sbom.spdx.json +53 -53
package/dist/index.js CHANGED
@@ -6,12 +6,12 @@ import { createLogger, createS3Client, sha256, toErrorMessage } from "@kici-dev/
6
6
  import * as fs from "node:fs";
7
7
  import { mkdtempSync, promises, writeFileSync } from "node:fs";
8
8
  import { dirname, join } from "node:path";
9
- import { createHash, createHmac, hkdfSync, randomBytes, randomUUID } from "node:crypto";
9
+ import { createHash, createHmac, randomBytes, randomUUID } from "node:crypto";
10
10
  import { chmod, mkdir, readFile, unlink, writeFile } from "node:fs/promises";
11
11
  import { Kysely, PostgresDialect, sql } from "kysely";
12
12
  import pg from "pg";
13
+ import { DEFAULT_APPROVAL_EXPIRY_HOURS, DEFAULT_APPROVAL_EXPIRY_SECONDS, ForkPolicy, HoldScope, HoldType, OrchRole, TriggerSource, approvalExpiryHoursOf, approvalExpirySecondsOf, persistedHoldTypeSpellings } from "@kici-dev/engine";
13
14
  import { z } from "zod";
14
- import { DEFAULT_APPROVAL_EXPIRY_HOURS, DEFAULT_APPROVAL_EXPIRY_SECONDS, ForkPolicy, HoldScope, HoldType, TriggerSource, approvalExpiryHoursOf, approvalExpirySecondsOf, persistedHoldTypeSpellings } from "@kici-dev/engine";
15
15
  import { platform, tmpdir } from "node:os";
16
16
  import { createInterface } from "node:readline";
17
17
  import { Octokit } from "@octokit/rest";
@@ -1626,12 +1626,27 @@ createLogger({ prefix: "coordinator-credential" });
1626
1626
  * A coordinator without a join token also wires a `selfIssue` callback. When a
1627
1627
  * decision finds no credential and no token, the callback issues the
1628
1628
  * coordinator its own credential, once per storm, under the same mutex.
1629
+ *
1630
+ * A coordinator also wires `corroborateRejection`. Any dialled endpoint can
1631
+ * send a rejection, so before it deletes the file the coordinator asks its own
1632
+ * cluster database: a credential the database holds as valid, or a database it
1633
+ * cannot read, keeps the file.
1629
1634
  */
1630
1635
  const logger = createLogger({ prefix: "peer-auth-coordinator" });
1631
1636
  /** How long a waiting peer-client awaits an in-flight sibling token-join. */
1632
1637
  const DEFAULT_JOIN_WAIT_TIMEOUT_MS = 1e4;
1633
1638
  /** Max read→await→re-read cycles before a waiter gives up and joins/aborts. */
1634
1639
  const MAX_DECIDE_ITERATIONS = 3;
1640
+ /** What `reportRejection` did with the credential file. */
1641
+ let RejectionAction = /* @__PURE__ */ function(RejectionAction) {
1642
+ /** A sibling refreshed the file: retry with the fresh credential. */
1643
+ RejectionAction["RetryCredential"] = "retry-credential";
1644
+ /** The file was deleted: the next decision token-joins or self-issues. */
1645
+ RejectionAction["Rejoin"] = "rejoin";
1646
+ /** This cluster's database holds the credential as valid (or is unreadable): the file stays. */
1647
+ RejectionAction["KeepCredential"] = "keep-credential";
1648
+ return RejectionAction;
1649
+ }({});
1635
1650
  function deferred() {
1636
1651
  let resolve;
1637
1652
  return {
@@ -1647,6 +1662,7 @@ var PeerAuthCoordinator = class {
1647
1662
  joinToken;
1648
1663
  joinWaitTimeoutMs;
1649
1664
  selfIssue;
1665
+ corroborateRejection;
1650
1666
  /** The issuance in progress; concurrent sibling clients share it. */
1651
1667
  selfIssueInFlight = null;
1652
1668
  /** An operator revoked this coordinator's credential: never issue again in this process. */
@@ -1661,6 +1677,7 @@ var PeerAuthCoordinator = class {
1661
1677
  this.joinToken = opts.joinToken;
1662
1678
  this.joinWaitTimeoutMs = opts.joinWaitTimeoutMs ?? DEFAULT_JOIN_WAIT_TIMEOUT_MS;
1663
1679
  this.selfIssue = opts.selfIssue;
1680
+ this.corroborateRejection = opts.corroborateRejection;
1664
1681
  }
1665
1682
  /** Run `fn` exclusively against the credential file. */
1666
1683
  async withLock(fn) {
@@ -1771,6 +1788,17 @@ var PeerAuthCoordinator = class {
1771
1788
  });
1772
1789
  return "retry-credential";
1773
1790
  }
1791
+ if (cred && this.corroborateRejection) {
1792
+ const verdict = await this.corroborateRejection(cred.credential);
1793
+ if (verdict !== "not-held") {
1794
+ logger.warn("A peer rejected a credential this cluster holds as valid; keeping the credential file", {
1795
+ instanceId: this.instanceId,
1796
+ reason,
1797
+ verdict
1798
+ });
1799
+ return "keep-credential";
1800
+ }
1801
+ }
1774
1802
  try {
1775
1803
  await unlink(this.credentialFile);
1776
1804
  logger.warn("Deleted stale credential file after server rejection", {
@@ -1790,24 +1818,49 @@ var PeerAuthCoordinator = class {
1790
1818
  //#endregion
1791
1819
  //#region src/cluster/join-token.ts
1792
1820
  /**
1793
- * Join token manager for zero-knowledge cluster bootstrap.
1821
+ * Join tokens: creation, lookup and claim.
1794
1822
  *
1795
1823
  * Token format: kici_join_v1.<base64url(routing_json)>.<random_256bit_hex>
1796
- * - Routing JSON: { orgId, routingKey, expiry } (cleartext for Platform relay routing)
1797
- * - Secret: 32 random bytes as hex (used for key derivation)
1798
- *
1799
- * Key derivation:
1800
- * - encryption_key = HKDF-SHA256(secret, salt="kici-join-encrypt", info="v1", length=32)
1801
- * - validation_hash = SHA-256(secret) (stored in DB for lookup)
1824
+ * - Routing JSON: { orgId, routingKey, expiry, role }. A lookup key only: the
1825
+ * row the token was stored as decides the role and routing a claim returns.
1826
+ * - Secret: 32 random bytes as hex. The database stores token_hash =
1827
+ * SHA-256(secret), never the secret.
1802
1828
  *
1803
- * Config bundle encryption: AES-256-GCM with random 12-byte IV.
1804
- * Wire format: <12-byte IV><16-byte auth tag><ciphertext>
1829
+ * A row is found by its hash (claimByHash, readByHash), or by its routing fields
1830
+ * plus a proof the caller checks against each candidate hash
1831
+ * (resolveLiveTokenByRouting).
1805
1832
  */
1806
1833
  const joinTokenLogger = createLogger({ prefix: "join-token" });
1807
1834
  const TOKEN_PREFIX = "kici_join_v1";
1808
1835
  const DEFAULT_EXPIRY_MS = 36e5;
1809
1836
  const TOKEN_ALREADY_USED_MESSAGE = "Join token has already been used. Create a new token with: kici-admin peer create-token";
1810
1837
  const TOKEN_EXPIRED_MESSAGE = "Join token has expired. Create a new token with: kici-admin peer create-token";
1838
+ const INVALID_JOIN_TOKEN_MESSAGE = "Invalid join token";
1839
+ const ResolvedJoinTokenStatus = z.enum([
1840
+ "live",
1841
+ "expired",
1842
+ "unknown"
1843
+ ]);
1844
+ const joinRoutingClaimSchema = z.object({
1845
+ orgId: z.string().min(1),
1846
+ routingKey: z.string().min(1),
1847
+ expiry: z.number().finite()
1848
+ }).passthrough();
1849
+ /** Read a `routing_info` value (object, or a JSON string) and the `role` column. */
1850
+ function routingFromRow(routingInfo, role) {
1851
+ const raw = typeof routingInfo === "string" ? JSON.parse(routingInfo) : routingInfo;
1852
+ const parsed = joinRoutingClaimSchema.parse(raw);
1853
+ const rowRole = OrchRole.parse(role);
1854
+ return {
1855
+ role: rowRole,
1856
+ routing: {
1857
+ orgId: parsed.orgId,
1858
+ routingKey: parsed.routingKey,
1859
+ expiry: parsed.expiry,
1860
+ role: rowRole
1861
+ }
1862
+ };
1863
+ }
1811
1864
  var JoinTokenManager = class {
1812
1865
  deps;
1813
1866
  constructor(deps) {
@@ -1819,8 +1872,8 @@ var JoinTokenManager = class {
1819
1872
  */
1820
1873
  async createToken(opts) {
1821
1874
  const expiryMs = opts.expiryMs ?? DEFAULT_EXPIRY_MS;
1822
- const expiry = Date.now() + expiryMs;
1823
- const role = opts.role ?? "coordinator";
1875
+ const expiry = Date.now() + Math.floor(expiryMs);
1876
+ const role = opts.role ?? OrchRole.enum.coordinator;
1824
1877
  const routing = {
1825
1878
  orgId: opts.orgId,
1826
1879
  routingKey: opts.routingKey,
@@ -1831,10 +1884,9 @@ var JoinTokenManager = class {
1831
1884
  const routingB64 = Buffer.from(JSON.stringify(routing)).toString("base64url");
1832
1885
  const secretHex = secret.toString("hex");
1833
1886
  const token = `${TOKEN_PREFIX}.${routingB64}.${secretHex}`;
1834
- const { validationHash } = deriveKeys(secret);
1835
1887
  await this.deps.db.insertInto("join_tokens").values({
1836
1888
  id: randomUUID(),
1837
- token_hash: validationHash,
1889
+ token_hash: tokenHashOf(secretHex),
1838
1890
  routing_info: JSON.stringify(routing),
1839
1891
  role,
1840
1892
  created_by: opts.createdBy,
@@ -1847,66 +1899,92 @@ var JoinTokenManager = class {
1847
1899
  return token;
1848
1900
  }
1849
1901
  /**
1850
- * Atomically validate and consume a token in one DB round-trip.
1851
- *
1852
- * Single UPDATE with `WHERE token_hash = ? AND consumed_at IS NULL AND
1853
- * expires_at > NOW()` — only one caller can win the claim across a
1854
- * shared-DB multi-coordinator mesh. The winner gets `{ routing, keys }`;
1855
- * every other concurrent caller gets `TOKEN_ALREADY_USED_MESSAGE` and is
1856
- * expected to fall into the idempotent recovery branch in
1857
- * `peer-handler.ts` (which serialises credential issuance via the
1858
- * `peer_credentials_active_uniq` partial unique index).
1902
+ * Peer token mode: claim the row the presented token's secret hashes to. Role and
1903
+ * routing come from the row (`claimByHash`), never from the token's routing part.
1859
1904
  *
1860
- * On a 0-row claim, a follow-up SELECT disambiguates not-found / expired
1861
- * / already-used / reusable-by-same-instance so callers can branch on the
1862
- * specific reason — the recovery path in peer-handler.ts keys on the
1863
- * "already used" string specifically. The follow-up only fires on the
1864
- * unhappy path.
1865
- *
1866
- * Self-healing reuse: a join token is re-consumable by the same joining
1867
- * peer (`peerInstanceId`) until its `expires_at`. A peer that lost its
1868
- * credential (transient outage / deleted credential file) re-presents the
1869
- * still-valid join token already in its env; the coordinator accepts the
1870
- * reuse and issues a fresh credential — no operator action, no cluster
1871
- * redeploy. Reuse is bounded by BOTH `expires_at` AND the consuming
1872
- * instanceId, so it never widens a leaked token's usefulness beyond the
1873
- * instance that first consumed it.
1905
+ * Self-healing reuse: a join token is re-consumable by the same joining peer
1906
+ * (`peerInstanceId`) until its `expires_at`. A peer that lost its credential
1907
+ * re-presents the still-valid join token already in its env, and the coordinator
1908
+ * issues a fresh credential without an operator action. Reuse is bounded by both
1909
+ * `expires_at` and the consuming instanceId, so it never widens a leaked token's
1910
+ * usefulness beyond the instance that first consumed it.
1874
1911
  */
1875
1912
  async validateAndConsumeToken(token, consumedBy, peerInstanceId) {
1876
- const parsed = parseToken(token);
1877
- const keys = deriveKeys(Buffer.from(parsed.secretHex, "hex"));
1878
- const updateResult = await this.deps.db.updateTable("join_tokens").set({
1913
+ return this.claimByHash(tokenHashOf(parseToken(token).secretHex), consumedBy, peerInstanceId);
1914
+ }
1915
+ /**
1916
+ * Find the row whose proof the caller accepts, among rows whose routing matches
1917
+ * the claimed routing. Consumption state is not checked here; `claimByHash` owns it.
1918
+ * At most JOIN_TOKEN_ROUTING_CANDIDATE_CAP rows, newest first, are offered.
1919
+ */
1920
+ async resolveLiveTokenByRouting(claimed, accepts) {
1921
+ const rows = await this.deps.db.selectFrom("join_tokens").select(["token_hash", "expires_at"]).where(sql`routing_info->>'orgId'`, "=", claimed.orgId).where(sql`routing_info->>'routingKey'`, "=", claimed.routingKey).where(sql`routing_info->>'expiry'`, "=", String(claimed.expiry)).orderBy("created_at", "desc").limit(16).execute();
1922
+ for (const row of rows) {
1923
+ if (!accepts(row.token_hash)) continue;
1924
+ return new Date(row.expires_at).getTime() > Date.now() ? {
1925
+ status: ResolvedJoinTokenStatus.enum.live,
1926
+ tokenHash: row.token_hash
1927
+ } : { status: ResolvedJoinTokenStatus.enum.expired };
1928
+ }
1929
+ return { status: ResolvedJoinTokenStatus.enum.unknown };
1930
+ }
1931
+ /**
1932
+ * Atomically consume the row with this hash, or re-validate it for the instance
1933
+ * that consumed it (self-healing reuse, bounded by expires_at). Routing and role
1934
+ * come from the row.
1935
+ *
1936
+ * One UPDATE ... WHERE consumed_at IS NULL wins the claim across a shared-DB
1937
+ * multi-coordinator mesh; every other concurrent caller gets
1938
+ * TOKEN_ALREADY_USED_MESSAGE, the message peer-handler's idempotent retry keys on.
1939
+ * On a 0-row claim, a follow-up SELECT tells not-found, expired, already-used and
1940
+ * reusable-by-the-same-instance apart.
1941
+ */
1942
+ async claimByHash(tokenHash, consumedBy, consumerInstanceId) {
1943
+ const claimed = await this.deps.db.updateTable("join_tokens").set({
1879
1944
  consumed_at: /* @__PURE__ */ new Date(),
1880
1945
  consumed_by: consumedBy,
1881
- consumed_by_instance: peerInstanceId
1882
- }).where("token_hash", "=", keys.validationHash).where("consumed_at", "is", null).where("expires_at", ">", /* @__PURE__ */ new Date()).executeTakeFirst();
1883
- if (Number(updateResult?.numUpdatedRows ?? 0n) > 0) {
1946
+ consumed_by_instance: consumerInstanceId
1947
+ }).where("token_hash", "=", tokenHash).where("consumed_at", "is", null).where("expires_at", ">", /* @__PURE__ */ new Date()).returning(["routing_info", "role"]).executeTakeFirst();
1948
+ if (claimed) {
1884
1949
  joinTokenLogger.info("Consumed join token", {
1885
1950
  consumedBy,
1886
- peerInstanceId
1951
+ consumerInstanceId,
1952
+ tokenFingerprint: tokenFingerprint(tokenHash)
1887
1953
  });
1888
1954
  return {
1889
- routing: parsed.routing,
1890
- keys
1955
+ tokenHash,
1956
+ ...routingFromRow(claimed.routing_info, claimed.role)
1891
1957
  };
1892
1958
  }
1893
- const row = await this.deps.db.selectFrom("join_tokens").selectAll().where("token_hash", "=", keys.validationHash).executeTakeFirst();
1894
- if (!row) throw new Error("Invalid join token");
1959
+ const row = await this.deps.db.selectFrom("join_tokens").select([
1960
+ "routing_info",
1961
+ "role",
1962
+ "expires_at",
1963
+ "consumed_at",
1964
+ "consumed_by_instance"
1965
+ ]).where("token_hash", "=", tokenHash).executeTakeFirst();
1966
+ if (!row) throw new Error(INVALID_JOIN_TOKEN_MESSAGE);
1895
1967
  if (new Date(row.expires_at).getTime() <= Date.now()) throw new Error(TOKEN_EXPIRED_MESSAGE);
1896
1968
  if (row.consumed_at) {
1897
- if (row.consumed_by_instance === peerInstanceId) {
1969
+ if (row.consumed_by_instance === consumerInstanceId) {
1898
1970
  joinTokenLogger.info("Re-validated join token for returning instance", {
1899
1971
  consumedBy,
1900
- peerInstanceId
1972
+ consumerInstanceId,
1973
+ tokenFingerprint: tokenFingerprint(tokenHash)
1901
1974
  });
1902
1975
  return {
1903
- routing: parsed.routing,
1904
- keys
1976
+ tokenHash,
1977
+ ...routingFromRow(row.routing_info, row.role)
1905
1978
  };
1906
1979
  }
1907
1980
  throw new Error(TOKEN_ALREADY_USED_MESSAGE);
1908
1981
  }
1909
- throw new Error("Invalid join token");
1982
+ throw new Error(INVALID_JOIN_TOKEN_MESSAGE);
1983
+ }
1984
+ /** Role and routing of the row with this hash, or null. */
1985
+ async readByHash(tokenHash) {
1986
+ const row = await this.deps.db.selectFrom("join_tokens").select(["routing_info", "role"]).where("token_hash", "=", tokenHash).executeTakeFirst();
1987
+ return row ? routingFromRow(row.routing_info, row.role) : null;
1910
1988
  }
1911
1989
  };
1912
1990
  /**
@@ -1939,21 +2017,17 @@ function parseToken(token) {
1939
2017
  if (!routing.orgId || !routing.routingKey || !routing.expiry) throw new Error("Invalid join token routing data");
1940
2018
  return {
1941
2019
  routing,
2020
+ routingB64: parts[1],
1942
2021
  secretHex: parts[2]
1943
2022
  };
1944
2023
  }
1945
- /**
1946
- * Derive encryption key and validation hash from a token secret.
1947
- * - encryptionKey: HKDF-SHA256 with salt="kici-join-encrypt", info="v1"
1948
- * - validationHash: SHA-256 of secret (stored in DB for lookup)
1949
- */
1950
- function deriveKeys(secret) {
1951
- const validationHash = sha256(secret);
1952
- const derived = hkdfSync("sha256", secret, Buffer.from("kici-join-encrypt"), Buffer.from("v1"), 32);
1953
- return {
1954
- encryptionKey: Buffer.from(derived),
1955
- validationHash
1956
- };
2024
+ /** Hex SHA-256 of a token secret: the value `join_tokens.token_hash` stores. */
2025
+ function tokenHashOf(secretHex) {
2026
+ return sha256(Buffer.from(secretHex, "hex"));
2027
+ }
2028
+ /** A log-safe tag for a token hash. Never log the hash itself: it is the join key root. */
2029
+ function tokenFingerprint(tokenHash) {
2030
+ return sha256(`kici-join-log:${tokenHash}`).slice(0, 12);
1957
2031
  }
1958
2032
  /**
1959
2033
  * Reason a run was held in the security queue. Persisted verbatim in
@@ -3154,6 +3228,28 @@ function validateWebhookUrl(value) {
3154
3228
  if (url.protocol !== "https:") throw new Error(`--webhook-url must be an https:// URL (got ${url.protocol}//…)`);
3155
3229
  return value;
3156
3230
  }
3231
+ /**
3232
+ * Every permission and event a KiCI GitHub App needs. The manifest requests
3233
+ * exactly these, and `kici-admin source refresh` reports an existing App that
3234
+ * lacks any of them.
3235
+ */
3236
+ const GITHUB_APP_REQUIREMENTS = {
3237
+ permissions: {
3238
+ contents: "read",
3239
+ metadata: "read",
3240
+ pull_requests: "read",
3241
+ checks: "write",
3242
+ members: "read",
3243
+ issues: "read"
3244
+ },
3245
+ events: [
3246
+ "push",
3247
+ "pull_request",
3248
+ "check_run",
3249
+ "check_suite",
3250
+ "issue_comment"
3251
+ ]
3252
+ };
3157
3253
  function buildGithubAppManifest(input) {
3158
3254
  return {
3159
3255
  name: input.name,
@@ -3165,19 +3261,8 @@ function buildGithubAppManifest(input) {
3165
3261
  redirect_url: input.redirectUrl,
3166
3262
  ...input.setupUrl ? { setup_url: input.setupUrl } : {},
3167
3263
  public: false,
3168
- default_permissions: {
3169
- contents: "read",
3170
- metadata: "read",
3171
- pull_requests: "read",
3172
- checks: "write",
3173
- members: "read"
3174
- },
3175
- default_events: [
3176
- "push",
3177
- "pull_request",
3178
- "check_run",
3179
- "check_suite"
3180
- ]
3264
+ default_permissions: { ...GITHUB_APP_REQUIREMENTS.permissions },
3265
+ default_events: [...GITHUB_APP_REQUIREMENTS.events]
3181
3266
  };
3182
3267
  }
3183
3268
  /**
@@ -3356,6 +3441,65 @@ function openBrowserBestEffort(url) {
3356
3441
  exec(cmd, () => {});
3357
3442
  }
3358
3443
  //#endregion
3444
+ //#region src/sources/webhook-url-resolvers.ts
3445
+ /**
3446
+ * Every webhook-URL decision the orchestrator makes for a GitHub-App source,
3447
+ * by operating mode. Pure functions: the entry points (`server.ts`,
3448
+ * `standalone.ts`) pass in the mode, the public base, the org and the Platform
3449
+ * values, so each rule is unit-tested here once.
3450
+ */
3451
+ /** Why a webhook URL could not be resolved. The CLI maps each to a reason and a hint. */
3452
+ const WebhookUrlNote = z.enum([
3453
+ "unsupported-provider",
3454
+ "no-public-url",
3455
+ "platform-no-public-url",
3456
+ "platform-unavailable",
3457
+ "platform-url-unknown",
3458
+ "org-not-identified",
3459
+ "resolver-unavailable",
3460
+ "resolve-failed"
3461
+ ]);
3462
+ //#endregion
3463
+ //#region src/cli/webhook-url-notes.ts
3464
+ /**
3465
+ * The text the CLI prints when the orchestrator returns no webhook URL. Shared
3466
+ * by `kici-admin source add` and the manifest setup flow, so each
3467
+ * `WebhookUrlNote` reads the same in both.
3468
+ */
3469
+ const DASHBOARD_HINT = "Find the webhook URL in the dashboard under Sources.";
3470
+ /**
3471
+ * Short reason shown in `(unavailable — <reason>)` when no webhook URL could be
3472
+ * resolved. Takes a plain string too, because an orchestrator of another
3473
+ * version can send a note this CLI does not know.
3474
+ */
3475
+ function webhookNoteReason(note) {
3476
+ switch (note) {
3477
+ case WebhookUrlNote.enum["no-public-url"]: return "KICI_WEBHOOK_PUBLIC_URL is not set on the orchestrator";
3478
+ case WebhookUrlNote.enum["platform-no-public-url"]: return "the Platform has no public webhook URL configured";
3479
+ case WebhookUrlNote.enum["platform-unavailable"]: return "could not reach the Platform";
3480
+ case WebhookUrlNote.enum["platform-url-unknown"]: return "the Platform did not send its GitHub webhook URL";
3481
+ case WebhookUrlNote.enum["org-not-identified"]: return "the orchestrator does not know its Platform org yet";
3482
+ case WebhookUrlNote.enum["unsupported-provider"]: return "this provider has no webhook URL to print";
3483
+ case WebhookUrlNote.enum["resolver-unavailable"]: return "this orchestrator cannot resolve webhook URLs";
3484
+ case WebhookUrlNote.enum["resolve-failed"]: return "resolving the webhook URL failed";
3485
+ default: return "webhook URL could not be determined";
3486
+ }
3487
+ }
3488
+ /** Actionable next-step hint paired with {@link webhookNoteReason}. */
3489
+ function webhookNoteHint(note) {
3490
+ switch (note) {
3491
+ case WebhookUrlNote.enum["no-public-url"]: return "Set KICI_WEBHOOK_PUBLIC_URL to the orchestrator's public base, then re-run; paste the printed URL into your GitHub App or repo webhook.";
3492
+ case WebhookUrlNote.enum["platform-unavailable"]: return "Retry once the orchestrator reconnects, or find the URL in the dashboard under Sources.";
3493
+ case WebhookUrlNote.enum["platform-url-unknown"]: return "Retry when the orchestrator is connected to the Platform, or pass --webhook-url with the URL the App must deliver to.";
3494
+ case WebhookUrlNote.enum["org-not-identified"]: return "Retry when the orchestrator is connected to the Platform, or pass --webhook-url.";
3495
+ case WebhookUrlNote.enum["unsupported-provider"]: return "Configure the webhook from the provider's own settings.";
3496
+ case WebhookUrlNote.enum["resolve-failed"]: return "Retry; if it fails again, find the webhook URL in the dashboard under Sources.";
3497
+ case WebhookUrlNote.enum["platform-no-public-url"]:
3498
+ case WebhookUrlNote.enum["resolver-unavailable"]:
3499
+ default: return DASHBOARD_HINT;
3500
+ }
3501
+ }
3502
+ //#endregion
3359
3503
  //#region src/cli/commands/source-manifest.ts
3360
3504
  /**
3361
3505
  * `kici-admin source add github --manifest` — the one-click GitHub App setup
@@ -3406,7 +3550,7 @@ const realManifestSetupDeps = {
3406
3550
  /** Resolve the org-scoped webhook URL via the pre-flight route; abort if null. */
3407
3551
  async function resolveWebhookUrl(client) {
3408
3552
  const pre = await client.get("/api/v1/admin/sources/github-webhook-url");
3409
- if (!pre.webhookUrl) throw new Error(`Cannot resolve the GitHub webhook URL${pre.webhookNote ? ` (${pre.webhookNote})` : ""}. The manifest flow needs it to configure the App. Ensure the orchestrator is connected to the Platform and has identified its org, then retry.`);
3553
+ if (!pre.webhookUrl) throw new Error(`Cannot resolve the webhook URL for the new GitHub App: ${webhookNoteReason(pre.webhookNote)}. ` + webhookNoteHint(pre.webhookNote));
3410
3554
  return pre.webhookUrl;
3411
3555
  }
3412
3556
  /**
@@ -3501,6 +3645,6 @@ async function runGithubManifestSetup(opts, client, deps = realManifestSetupDeps
3501
3645
  }
3502
3646
  }
3503
3647
  //#endregion
3504
- export { AdminApiClient, DEFAULT_TRUST_POLICY, DEFAULT_USER_CACHE_QUOTA_BYTES, DEFAULT_USER_CACHE_TTL_MS, DepCache, HeldRunStore, JoinTokenManager, PeerAuthCoordinator, PeerCredentialStore, S3CacheStorage, SecurityHoldReason, SourceCache, TrustPolicySource, TrustPolicyStore, UserCache, createCacheStorage, createHeldRunStoreFromUrl, createJoinTokenManagerFromUrl, createPeerCredentialStoreFromUrl, createTrustPolicyStoreFromUrl, runGithubManifestSetup };
3648
+ export { AdminApiClient, DEFAULT_TRUST_POLICY, DEFAULT_USER_CACHE_QUOTA_BYTES, DEFAULT_USER_CACHE_TTL_MS, DepCache, HeldRunStore, JoinTokenManager, PeerAuthCoordinator, PeerCredentialStore, RejectionAction, S3CacheStorage, SecurityHoldReason, SourceCache, TrustPolicySource, TrustPolicyStore, UserCache, createCacheStorage, createHeldRunStoreFromUrl, createJoinTokenManagerFromUrl, createPeerCredentialStoreFromUrl, createTrustPolicyStoreFromUrl, runGithubManifestSetup };
3505
3649
 
3506
3650
  //# sourceMappingURL=index.js.map
@@ -41,6 +41,22 @@ export interface GithubAppManifest {
41
41
  * does not receive events at it; the operator owns delivery.
42
42
  */
43
43
  export declare function validateWebhookUrl(value: string): string;
44
+ /**
45
+ * Every permission and event a KiCI GitHub App needs. The manifest requests
46
+ * exactly these, and `kici-admin source refresh` reports an existing App that
47
+ * lacks any of them.
48
+ */
49
+ export declare const GITHUB_APP_REQUIREMENTS: {
50
+ readonly permissions: {
51
+ readonly contents: 'read';
52
+ readonly metadata: 'read';
53
+ readonly pull_requests: 'read';
54
+ readonly checks: 'write';
55
+ readonly members: 'read';
56
+ readonly issues: 'read';
57
+ };
58
+ readonly events: readonly ["push", "pull_request", "check_run", "check_suite", "issue_comment"];
59
+ };
44
60
  export declare function buildGithubAppManifest(input: GithubManifestInput): GithubAppManifest;
45
61
  /** Credentials returned by GitHub's manifest-conversion endpoint. */
46
62
  export interface GithubAppCredentials {
@@ -62,21 +78,56 @@ export interface GithubAppCredentials {
62
78
  export declare function convertManifestCode(code: string, deps?: {
63
79
  octokit?: Pick<Octokit, 'request'>;
64
80
  }): Promise<GithubAppCredentials>;
81
+ /** What `GET /app` says about the App: identity plus its grant. */
82
+ export interface GithubAppIdentity {
83
+ name: string;
84
+ slug: string;
85
+ /** Events the App subscribes to. */
86
+ events: string[];
87
+ /** The App's permissions, name → access level. */
88
+ permissions: Record<string, string>;
89
+ }
65
90
  /**
66
91
  * Ask GitHub who this App is (`GET /app`, authenticated as the App via its
67
- * JWT) and return its authoritative display `name` + `slug`. This is the single
68
- * "fetch the App's identity from GitHub" helper, reused at source creation, by
69
- * the daily refresher, and by `kici-admin source refresh`.
92
+ * JWT) and return its authoritative display `name` + `slug`, plus the events
93
+ * and permissions the App holds, which the requirement gap check reads. This is
94
+ * the single "fetch the App's identity from GitHub" helper, reused at source
95
+ * creation, by the daily refresher, and by `kici-admin source refresh`.
70
96
  *
71
97
  * GitHub is the source of truth: a rename in the GitHub UI changes the value
72
98
  * `GET /app` returns, which is what keeps the dashboard name fresh.
73
99
  */
74
100
  export declare function fetchGithubAppIdentity(creds: Pick<GithubAppCredentials, 'appId' | 'privateKey'>, deps?: {
75
101
  appOctokit?: Pick<Octokit, 'request'>;
76
- }): Promise<{
77
- name: string;
78
- slug: string;
79
- }>;
102
+ }): Promise<GithubAppIdentity>;
103
+ /** One installation's grant, as the gap check reads it. */
104
+ export interface GithubAppInstallationGrant {
105
+ id: number;
106
+ /** Login of the account the App is installed on (an enterprise's slug). */
107
+ account: string;
108
+ /** `Organization`, `User`, or null for an enterprise installation. */
109
+ accountType: string | null;
110
+ permissions: Record<string, string>;
111
+ }
112
+ /** Every installation of the App (`GET /app/installations`, all pages), as the App. */
113
+ export declare function listGithubAppInstallations(creds: Pick<GithubAppCredentials, 'appId' | 'privateKey'>, deps?: {
114
+ appOctokit?: Pick<Octokit, 'request'>;
115
+ }): Promise<GithubAppInstallationGrant[]>;
116
+ /** Required events and permissions an App or its installations lack. */
117
+ export interface GithubAppRequirementGaps {
118
+ missingEvents: string[];
119
+ missingPermissions: string[];
120
+ /** Installations that have not accepted a permission the App itself holds. */
121
+ installationsPendingApproval: Array<{
122
+ installationId: number;
123
+ account: string;
124
+ missingPermissions: string[];
125
+ }>;
126
+ }
127
+ /** Compare an App and its installations with {@link GITHUB_APP_REQUIREMENTS}. */
128
+ export declare function findGithubAppRequirementGaps(app: Pick<GithubAppIdentity, 'events' | 'permissions'>, installations: GithubAppInstallationGrant[]): GithubAppRequirementGaps;
129
+ /** Whether any gap array is non-empty. */
130
+ export declare function hasRequirementGaps(gaps: GithubAppRequirementGaps): boolean;
80
131
  /**
81
132
  * Poll GitHub (as the App, via a JWT) until at least one installation exists,
82
133
  * returning the first installation's id + account login. Throws on timeout.
@@ -9,56 +9,59 @@
9
9
  * the admin auth middleware in admin.ts.
10
10
  */
11
11
  import { Hono } from 'hono';
12
- import { OrchestratorMode } from '@kici-dev/engine';
13
12
  import type { SourceStore } from '../sources/source-store.js';
13
+ import { type WebhookUrlResolution } from '../sources/webhook-url-resolvers.js';
14
14
  import type { Role } from '../secrets/rbac.js';
15
15
  import { type RedeliverWindowResult } from '../providers/github/deliveries.js';
16
- import { type FetchGithubAppIdentity } from '../github-app-name-refresher/github-app-name-refresher.js';
16
+ import { type FetchGithubAppIdentity, type FetchGithubAppInstallations } from '../github-app-name-refresher/github-app-name-refresher.js';
17
17
  interface SourceRouteDeps {
18
18
  sourceStore: SourceStore;
19
19
  /**
20
20
  * Resolve the public webhook URL for a freshly added source so the CLI can
21
- * print it. Platform/hybrid mode registers the source with the Platform and
22
- * reads the URL from the `source.register.ack`; independent mode returns a
23
- * null URL with an explanatory `webhookNote`. Omitted in deployments with no
21
+ * print it, by mode: platform and hybrid read the Platform's URL from the
22
+ * `source.register.ack` (hybrid falls back to its own URL); observed and
23
+ * independent return this orchestrator's own per-source URL. A null URL
24
+ * carries a `webhookNote` that says why. Omitted in deployments with no
24
25
  * resolver wired (the route then returns `webhookUrl: null`).
25
26
  */
26
27
  resolveSourceWebhookUrl?: (params: {
27
28
  routingKey: string;
28
29
  provider: string;
29
30
  sourceId: string;
30
- }) => Promise<{
31
- webhookUrl: string | null;
32
- webhookNote?: string;
33
- }>;
31
+ }) => Promise<WebhookUrlResolution>;
34
32
  /**
35
33
  * Resolve the org-scoped GitHub webhook URL for the manifest setup flow
36
34
  * BEFORE any App exists. The GitHub webhook URL is org-scoped
37
35
  * (`<base>/webhook/<orgId>/github`), not app-scoped, so it can be computed
38
36
  * up front and baked into the App manifest. Returns null + a note when the
39
- * orchestrator cannot yet resolve a public base or its org id.
37
+ * orchestrator cannot resolve it for its mode.
40
38
  */
41
- resolveGithubWebhookUrl?: () => Promise<{
42
- webhookUrl: string | null;
43
- webhookNote?: string;
44
- }>;
39
+ resolveGithubWebhookUrl?: () => Promise<WebhookUrlResolution>;
45
40
  /**
46
- * Fetch a GitHub App's authoritative `{ name, slug }` from GitHub. Injectable
47
- * for tests; defaults to the real `fetchGithubAppIdentity`. Used by the
48
- * `source refresh` route.
41
+ * Build this orchestrator's own direct-ingress URL for a listed GitHub
42
+ * source, or null: platform mode serves no direct ingress, and without a
43
+ * public base or a known org there is no URL to print.
44
+ */
45
+ resolveSourceIngressUrl?: (row: {
46
+ id: string;
47
+ customerId: string | null;
48
+ }) => string | null;
49
+ /**
50
+ * Fetch a GitHub App's authoritative `{ name, slug }`, events and permissions
51
+ * from GitHub. Injectable for tests; defaults to the real
52
+ * `fetchGithubAppIdentity`. Used by the `source refresh` route.
49
53
  */
50
54
  fetchAppIdentity?: FetchGithubAppIdentity;
55
+ /**
56
+ * List a GitHub App's installations. Injectable for tests; defaults to
57
+ * `listGithubAppInstallations`. Used by the `source refresh` route.
58
+ */
59
+ fetchAppInstallations?: FetchGithubAppInstallations;
51
60
  /**
52
61
  * Replay a window of GitHub webhook deliveries for one App. Injectable for
53
62
  * tests; defaults to the real `redeliverWindow`.
54
63
  */
55
64
  redeliverDeliveries?: RedeliverDeliveries;
56
- /**
57
- * Orchestrator operating mode. `observed` refuses GitHub-App source creation:
58
- * those sources are ingested through the Platform relay, and an observed
59
- * orchestrator never accepts a relay. Omitted (undefined) behaves as before.
60
- */
61
- mode?: OrchestratorMode;
62
65
  }
63
66
  /** Replay a delivery window for a GitHub App. Matches `redeliverWindow`. */
64
67
  export type RedeliverDeliveries = (app: {