@kici-dev/orchestrator 0.14.1 → 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.
- package/dist/__test-helpers__/peer-mutual-auth.d.ts +42 -0
- package/dist/agent/host-roster.d.ts +100 -37
- package/dist/app.d.ts +4 -2
- package/dist/cli/commands/host.d.ts +3 -0
- package/dist/cli/commands/source-manifest.d.ts +3 -3
- package/dist/cli/commands/source.d.ts +23 -0
- package/dist/cli/join.d.ts +2 -2
- package/dist/cli/kici-admin.d.ts +5 -0
- package/dist/cli/local-github-ingress-url.d.ts +6 -0
- package/dist/cli/root-option-routing.d.ts +46 -0
- package/dist/cli/webhook-url-notes.d.ts +15 -0
- package/dist/cli.js +1429 -885
- package/dist/cluster/coordinator-credential.d.ts +21 -0
- package/dist/cluster/index.d.ts +4 -4
- package/dist/cluster/join-client.d.ts +23 -21
- package/dist/cluster/join-handler.d.ts +24 -16
- package/dist/cluster/join-protocol-v2.d.ts +84 -0
- package/dist/cluster/join-token.d.ts +78 -56
- package/dist/cluster/peer-auth-coordinator.d.ts +16 -2
- package/dist/cluster/peer-client.d.ts +72 -4
- package/dist/cluster/peer-crypto.d.ts +60 -3
- package/dist/cluster/peer-discovery.d.ts +67 -0
- package/dist/cluster/peer-handler.d.ts +17 -5
- package/dist/cluster/peer-registry.d.ts +15 -1
- package/dist/config/schema.d.ts +14 -0
- package/dist/config/types.d.ts +4 -0
- package/dist/config.d.ts +9 -0
- package/dist/db/migrations/157_host_roster_identity_source.d.ts +21 -0
- package/dist/db/types.d.ts +16 -2
- package/dist/github-app-name-refresher/github-app-name-refresher.d.ts +25 -13
- package/dist/index.d.ts +1 -1
- package/dist/index.js +225 -81
- package/dist/providers/github/manifest.d.ts +58 -7
- package/dist/routes/admin-sources.d.ts +26 -23
- package/dist/routes/admin.d.ts +14 -17
- package/dist/routes/cluster-join.d.ts +13 -0
- package/dist/routes/github-webhook.d.ts +13 -11
- package/dist/scaler/bare-metal-backend.d.ts +42 -1
- package/dist/scaler/types.d.ts +7 -5
- package/dist/server.js +9040 -7631
- package/dist/sources/source-manager.d.ts +0 -24
- package/dist/sources/webhook-url-resolvers.d.ts +60 -0
- package/dist/standalone.js +9944 -8818
- package/dist/ws/dashboard-fleet-handler.d.ts +4 -3
- package/dist/ws/dashboard-fleet-write-handler.d.ts +7 -1
- package/dist/ws/platform-client.d.ts +18 -6
- package/installer-image-digests.json +3 -3
- package/package.json +4 -4
- 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,
|
|
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
|
|
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 }
|
|
1797
|
-
*
|
|
1798
|
-
*
|
|
1799
|
-
*
|
|
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
|
-
*
|
|
1804
|
-
*
|
|
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 ??
|
|
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:
|
|
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
|
-
*
|
|
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
|
-
*
|
|
1861
|
-
*
|
|
1862
|
-
*
|
|
1863
|
-
*
|
|
1864
|
-
*
|
|
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
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
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:
|
|
1882
|
-
}).where("token_hash", "=",
|
|
1883
|
-
if (
|
|
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
|
-
|
|
1951
|
+
consumerInstanceId,
|
|
1952
|
+
tokenFingerprint: tokenFingerprint(tokenHash)
|
|
1887
1953
|
});
|
|
1888
1954
|
return {
|
|
1889
|
-
|
|
1890
|
-
|
|
1955
|
+
tokenHash,
|
|
1956
|
+
...routingFromRow(claimed.routing_info, claimed.role)
|
|
1891
1957
|
};
|
|
1892
1958
|
}
|
|
1893
|
-
const row = await this.deps.db.selectFrom("join_tokens").
|
|
1894
|
-
|
|
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 ===
|
|
1969
|
+
if (row.consumed_by_instance === consumerInstanceId) {
|
|
1898
1970
|
joinTokenLogger.info("Re-validated join token for returning instance", {
|
|
1899
1971
|
consumedBy,
|
|
1900
|
-
|
|
1972
|
+
consumerInstanceId,
|
|
1973
|
+
tokenFingerprint: tokenFingerprint(tokenHash)
|
|
1901
1974
|
});
|
|
1902
1975
|
return {
|
|
1903
|
-
|
|
1904
|
-
|
|
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(
|
|
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
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
*/
|
|
1950
|
-
function
|
|
1951
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
68
|
-
*
|
|
69
|
-
* the
|
|
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
|
-
|
|
78
|
-
|
|
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
|
|
22
|
-
*
|
|
23
|
-
*
|
|
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
|
|
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
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
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: {
|