@kici-dev/orchestrator 0.1.20 → 0.1.22
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/agent/dispatcher.d.ts +44 -0
- package/dist/agent/host-roster-reaper.d.ts +2 -1
- package/dist/agent/host-roster.d.ts +54 -1
- package/dist/agent/registry.d.ts +22 -0
- package/dist/app.d.ts +5 -0
- package/dist/approvals/step-approval-bridge.d.ts +5 -0
- package/dist/cli/commands/source-manifest.d.ts +42 -0
- package/dist/cli/loopback-callback.d.ts +24 -0
- package/dist/cli/open-browser.d.ts +12 -0
- package/dist/cli/service/compose.d.ts +13 -0
- package/dist/cli/service/deploy-env.d.ts +31 -0
- package/dist/cli.js +1204 -256
- package/dist/cluster/coordinator.d.ts +5 -3
- package/dist/cluster/index.d.ts +2 -0
- package/dist/cluster/join-token.d.ts +27 -5
- package/dist/cluster/peer-auth-coordinator.d.ts +36 -0
- package/dist/cluster/peer-client.d.ts +34 -14
- package/dist/cluster/peer-credentials.d.ts +14 -2
- package/dist/cluster/peer-handler.d.ts +2 -2
- package/dist/cluster/rerouted-job-guard.d.ts +39 -0
- package/dist/config.d.ts +6 -0
- package/dist/dashboard/needs-edges.d.ts +4 -3
- package/dist/db/migrations/043_rerouted_to_peer.d.ts +4 -0
- package/dist/db/migrations/044_check_mode.d.ts +4 -0
- package/dist/db/migrations/045_host_properties.d.ts +16 -0
- package/dist/db/migrations/046_join_token_consumed_by_instance.d.ts +17 -0
- package/dist/db/migrations/047_needs_run_on.d.ts +4 -0
- package/dist/db/migrations/048_host_reboot_pending.d.ts +19 -0
- package/dist/db/migrations/049_held_runs_payload.d.ts +14 -0
- package/dist/db/migrations/050_sources_slug.d.ts +17 -0
- package/dist/db/types.d.ts +60 -5
- package/dist/deployment/deployment-identity.d.ts +9 -0
- package/dist/entry-helpers.d.ts +7 -0
- package/dist/environments/held-runs.d.ts +7 -1
- package/dist/github-app-name-refresher/github-app-name-refresher.d.ts +77 -0
- package/dist/index.d.ts +5 -1
- package/dist/index.js +1187 -33
- package/dist/lockfile-validate.d.ts +24 -0
- package/dist/orchestrator-core.d.ts +13 -1
- package/dist/pipeline/decorating-secret-resolver.d.ts +32 -0
- package/dist/pipeline/dispatch-matched-workflow.d.ts +127 -3
- package/dist/pipeline/install-secrets-resolver.d.ts +2 -2
- package/dist/pipeline/needs-scheduler.d.ts +22 -13
- package/dist/pipeline/processor.d.ts +2 -2
- package/dist/pipeline/test-pipeline.d.ts +37 -58
- package/dist/providers/github/manifest-form.d.ts +15 -0
- package/dist/providers/github/manifest.d.ts +103 -0
- package/dist/reporting/execution-tracker.d.ts +10 -1
- package/dist/routes/admin-sources.d.ts +18 -0
- package/dist/routes/admin.d.ts +8 -0
- package/dist/secrets/pg-secret-store.d.ts +9 -0
- package/dist/secrets/secret-resolver.d.ts +16 -1
- package/dist/server.js +4373 -2322
- package/dist/sources/source-store.d.ts +4 -0
- package/dist/sources/source-validator.d.ts +2 -0
- package/dist/stale-detector/reboot-deadline-sweep.d.ts +29 -0
- package/dist/stale-detector/stale-run-detector.d.ts +8 -0
- package/dist/standalone.js +3582 -1932
- package/dist/worker/in-memory-job-queue.d.ts +17 -0
- package/dist/worker/peer-outbox.d.ts +36 -0
- package/dist/worker/worker-outbox-relay.d.ts +13 -0
- package/dist/ws/agent-handler.d.ts +11 -6
- package/dist/ws/dashboard-fleet-handler.d.ts +33 -0
- package/dist/ws/dashboard-fleet-write-handler.d.ts +60 -0
- package/dist/ws/fleet-runs-on-all.d.ts +16 -0
- package/dist/ws/inventory-api.d.ts +17 -0
- package/dist/ws/platform-client.d.ts +13 -1
- package/dist/ws/test-relay-handlers.d.ts +4 -2
- package/installer-image-digests.json +3 -3
- package/package.json +4 -4
- package/sbom.spdx.json +77 -128
package/dist/index.js
CHANGED
|
@@ -3,12 +3,19 @@ import { CopyObjectCommand, DeleteObjectCommand, GetObjectCommand, HeadObjectCom
|
|
|
3
3
|
import { Upload } from "@aws-sdk/lib-storage";
|
|
4
4
|
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
|
5
5
|
import { createLogger, createS3Client, sha256 } from "@kici-dev/shared";
|
|
6
|
-
import
|
|
6
|
+
import * as fs from "node:fs";
|
|
7
|
+
import { mkdtempSync, promises, writeFileSync } from "node:fs";
|
|
7
8
|
import { dirname, join } from "node:path";
|
|
8
|
-
import { createHmac, randomBytes, randomUUID } from "node:crypto";
|
|
9
|
-
import "node:fs/promises";
|
|
9
|
+
import { createHmac, hkdfSync, randomBytes, randomUUID } from "node:crypto";
|
|
10
|
+
import { chmod, mkdir, readFile, unlink, writeFile } from "node:fs/promises";
|
|
10
11
|
import { Kysely, PostgresDialect, sql } from "kysely";
|
|
11
12
|
import pg from "pg";
|
|
13
|
+
import { platform, tmpdir } from "node:os";
|
|
14
|
+
import { createInterface } from "node:readline";
|
|
15
|
+
import { Octokit } from "@octokit/rest";
|
|
16
|
+
import { createAppAuth } from "@octokit/auth-app";
|
|
17
|
+
import { createServer } from "node:http";
|
|
18
|
+
import { exec } from "node:child_process";
|
|
12
19
|
import.meta.url;
|
|
13
20
|
//#endregion
|
|
14
21
|
//#region src/storage/s3.ts
|
|
@@ -558,7 +565,7 @@ function createCacheStorage(config) {
|
|
|
558
565
|
* downloads the source tarball, extracts it, and imports the workflow entry
|
|
559
566
|
* via the shared oxc-transform ESM loader hook.
|
|
560
567
|
*/
|
|
561
|
-
const logger$
|
|
568
|
+
const logger$3 = createLogger({ prefix: "source-cache" });
|
|
562
569
|
/** Cache key format: source/{contentHash}.tar.gz */
|
|
563
570
|
function sourceKey(contentHash) {
|
|
564
571
|
return `source/${contentHash}.tar.gz`;
|
|
@@ -571,7 +578,7 @@ var SourceCache = class {
|
|
|
571
578
|
async has(contentHash) {
|
|
572
579
|
const key = sourceKey(contentHash);
|
|
573
580
|
const exists = await this.storage.has(key);
|
|
574
|
-
logger$
|
|
581
|
+
logger$3.debug(`has(${contentHash}): ${exists}`);
|
|
575
582
|
return exists;
|
|
576
583
|
}
|
|
577
584
|
async get(contentHash) {
|
|
@@ -579,8 +586,8 @@ var SourceCache = class {
|
|
|
579
586
|
const data = await this.storage.get(key);
|
|
580
587
|
if (data) {
|
|
581
588
|
await this.storage.touch(key);
|
|
582
|
-
logger$
|
|
583
|
-
} else logger$
|
|
589
|
+
logger$3.debug(`get(${contentHash}): hit (${data.length} bytes)`);
|
|
590
|
+
} else logger$3.debug(`get(${contentHash}): miss`);
|
|
584
591
|
return data;
|
|
585
592
|
}
|
|
586
593
|
async getUrl(contentHash) {
|
|
@@ -588,8 +595,8 @@ var SourceCache = class {
|
|
|
588
595
|
const url = await this.storage.getUrl(key);
|
|
589
596
|
if (url) {
|
|
590
597
|
await this.storage.touch(key);
|
|
591
|
-
logger$
|
|
592
|
-
} else logger$
|
|
598
|
+
logger$3.debug(`getUrl(${contentHash}): hit`);
|
|
599
|
+
} else logger$3.debug(`getUrl(${contentHash}): miss`);
|
|
593
600
|
return url;
|
|
594
601
|
}
|
|
595
602
|
async getUploadUrl(contentHash) {
|
|
@@ -600,12 +607,12 @@ var SourceCache = class {
|
|
|
600
607
|
const key = sourceKey(contentHash);
|
|
601
608
|
await this.storage.put(key, tarball);
|
|
602
609
|
const size = typeof tarball === "string" ? Buffer.byteLength(tarball) : tarball.length;
|
|
603
|
-
logger$
|
|
610
|
+
logger$3.info(`store(${contentHash}): stored (${size} bytes)`);
|
|
604
611
|
}
|
|
605
612
|
async remove(contentHash) {
|
|
606
613
|
const key = sourceKey(contentHash);
|
|
607
614
|
const removed = await this.storage.delete(key);
|
|
608
|
-
logger$
|
|
615
|
+
logger$3.info(`remove(${contentHash}): ${removed ? "removed" : "not found"}`);
|
|
609
616
|
return removed;
|
|
610
617
|
}
|
|
611
618
|
};
|
|
@@ -620,7 +627,7 @@ var SourceCache = class {
|
|
|
620
627
|
*
|
|
621
628
|
* Cache key format: deps/{platform}-{arch}/{lockfileHash}.tar.gz
|
|
622
629
|
*/
|
|
623
|
-
const logger$
|
|
630
|
+
const logger$2 = createLogger({ prefix: "dep-cache" });
|
|
624
631
|
/** Default max tarball size: 500MB */
|
|
625
632
|
const DEFAULT_MAX_TARBALL_BYTES = 524288e3;
|
|
626
633
|
/** Build cache key for dependency tarball: deps/{platform}-{arch}/{lockfileHash}.tar.gz */
|
|
@@ -638,7 +645,7 @@ var DepCache = class {
|
|
|
638
645
|
async has(lockfileHash, platform, arch) {
|
|
639
646
|
const key = depKey(lockfileHash, platform, arch);
|
|
640
647
|
const exists = await this.storage.has(key);
|
|
641
|
-
logger$
|
|
648
|
+
logger$2.debug(`has(${lockfileHash}): ${exists}`, {
|
|
642
649
|
platform,
|
|
643
650
|
arch
|
|
644
651
|
});
|
|
@@ -653,11 +660,11 @@ var DepCache = class {
|
|
|
653
660
|
const url = await this.storage.getUrl(key);
|
|
654
661
|
if (url) {
|
|
655
662
|
await this.storage.touch(key);
|
|
656
|
-
logger$
|
|
663
|
+
logger$2.debug(`getUrl(${lockfileHash}): hit`, {
|
|
657
664
|
platform,
|
|
658
665
|
arch
|
|
659
666
|
});
|
|
660
|
-
} else logger$
|
|
667
|
+
} else logger$2.debug(`getUrl(${lockfileHash}): miss`, {
|
|
661
668
|
platform,
|
|
662
669
|
arch
|
|
663
670
|
});
|
|
@@ -672,7 +679,7 @@ var DepCache = class {
|
|
|
672
679
|
const key = depKey(lockfileHash, platform, arch);
|
|
673
680
|
const url = await this.storage.getUrl(key);
|
|
674
681
|
if (!url) {
|
|
675
|
-
logger$
|
|
682
|
+
logger$2.debug(`getUrlAndHash(${lockfileHash}): miss`, {
|
|
676
683
|
platform,
|
|
677
684
|
arch
|
|
678
685
|
});
|
|
@@ -681,7 +688,7 @@ var DepCache = class {
|
|
|
681
688
|
await this.storage.touch(key);
|
|
682
689
|
const hashKey = `deps/${platform}-${arch}/${lockfileHash}.hash`;
|
|
683
690
|
const hash = (await this.storage.get(hashKey))?.toString("utf-8") || void 0;
|
|
684
|
-
logger$
|
|
691
|
+
logger$2.debug(`getUrlAndHash(${lockfileHash}): hit`, {
|
|
685
692
|
platform,
|
|
686
693
|
arch,
|
|
687
694
|
hasHash: !!hash
|
|
@@ -706,7 +713,7 @@ var DepCache = class {
|
|
|
706
713
|
if (tarballData.length > this.maxTarballBytes) throw new Error(`Dep tarball exceeds max size: ${tarballData.length} bytes > ${this.maxTarballBytes} bytes limit`);
|
|
707
714
|
const key = depKey(lockfileHash, platform, arch);
|
|
708
715
|
await this.storage.put(key, tarballData);
|
|
709
|
-
logger$
|
|
716
|
+
logger$2.info(`store: ${tarballData.length} bytes`, {
|
|
710
717
|
lockfileHash,
|
|
711
718
|
platform,
|
|
712
719
|
arch
|
|
@@ -723,7 +730,7 @@ var DepCache = class {
|
|
|
723
730
|
async remove(lockfileHash, platform, arch) {
|
|
724
731
|
const key = depKey(lockfileHash, platform, arch);
|
|
725
732
|
const removed = await this.storage.delete(key);
|
|
726
|
-
logger$
|
|
733
|
+
logger$2.info(`remove(${lockfileHash}): ${removed ? "removed" : "not found"}`, {
|
|
727
734
|
platform,
|
|
728
735
|
arch
|
|
729
736
|
});
|
|
@@ -754,7 +761,7 @@ var DepCache = class {
|
|
|
754
761
|
* hash and size accounting outside the tarball's own (presigned, metadata-less)
|
|
755
762
|
* upload.
|
|
756
763
|
*/
|
|
757
|
-
const logger = createLogger({ prefix: "user-cache" });
|
|
764
|
+
const logger$1 = createLogger({ prefix: "user-cache" });
|
|
758
765
|
/**
|
|
759
766
|
* Cluster-wide default quota: 5 GiB. Serves as the fallback when an org has no
|
|
760
767
|
* per-org override in `org_settings.user_cache_quota_bytes`. The cluster-wide
|
|
@@ -798,7 +805,7 @@ var UserCache = class {
|
|
|
798
805
|
try {
|
|
799
806
|
limits = await this.orgLimitsReader(org);
|
|
800
807
|
} catch (err) {
|
|
801
|
-
logger.warn("user-cache org-limits lookup failed — using cluster defaults", {
|
|
808
|
+
logger$1.warn("user-cache org-limits lookup failed — using cluster defaults", {
|
|
802
809
|
org,
|
|
803
810
|
error: err instanceof Error ? err.message : String(err)
|
|
804
811
|
});
|
|
@@ -899,7 +906,7 @@ var UserCache = class {
|
|
|
899
906
|
const prefix = this.writePrefix(ref);
|
|
900
907
|
const final = this.finalKey(prefix, ref.key);
|
|
901
908
|
if (await this.storage.has(final)) {
|
|
902
|
-
logger.info("user-cache save skipped (immutable key exists)", { key: ref.key });
|
|
909
|
+
logger$1.info("user-cache save skipped (immutable key exists)", { key: ref.key });
|
|
903
910
|
return { skip: true };
|
|
904
911
|
}
|
|
905
912
|
const tempKey = `${prefix}.tmp-${randomUUID()}${TAR_SUFFIX}`;
|
|
@@ -921,7 +928,7 @@ var UserCache = class {
|
|
|
921
928
|
await this.storage.initMeta(final);
|
|
922
929
|
await this.storage.put(`${final}.hash`, ref.tarHash);
|
|
923
930
|
await this.storage.put(`${final}.size`, String(ref.sizeBytes));
|
|
924
|
-
logger.info("user-cache entry committed", {
|
|
931
|
+
logger$1.info("user-cache entry committed", {
|
|
925
932
|
key: ref.key,
|
|
926
933
|
sizeBytes: ref.sizeBytes
|
|
927
934
|
});
|
|
@@ -964,7 +971,7 @@ var UserCache = class {
|
|
|
964
971
|
await this.storage.delete(`${key}.hash`);
|
|
965
972
|
await this.storage.delete(`${key}.size`);
|
|
966
973
|
total -= size;
|
|
967
|
-
logger.info("user-cache eviction (org over quota)", {
|
|
974
|
+
logger$1.info("user-cache eviction (org over quota)", {
|
|
968
975
|
org: ref.org,
|
|
969
976
|
key,
|
|
970
977
|
freedBytes: size
|
|
@@ -996,14 +1003,24 @@ var PeerCredentialStore = class {
|
|
|
996
1003
|
this.db = db;
|
|
997
1004
|
}
|
|
998
1005
|
/**
|
|
999
|
-
* Save a new peer credential
|
|
1006
|
+
* Save a new peer credential, revoking any prior active credential for the
|
|
1007
|
+
* same instanceId (the `peer_credentials_active_uniq` partial unique index
|
|
1008
|
+
* permits exactly one active credential per instanceId).
|
|
1009
|
+
*
|
|
1010
|
+
* Returns `{ revokedCount }` — how many previously-active credentials this
|
|
1011
|
+
* save revoked. Because a coordinator's peer-clients share one
|
|
1012
|
+
* identity-scoped credential, a `revokedCount > 0` here means every sibling
|
|
1013
|
+
* peer-client of `instanceId` that was authenticating with the old
|
|
1014
|
+
* credential is now invalidated and will be rejected on its next proof. The
|
|
1015
|
+
* caller logs this so a revoke that cascades sibling rejections is visible in
|
|
1016
|
+
* the orchestrator logs.
|
|
1000
1017
|
*/
|
|
1001
1018
|
async save(opts) {
|
|
1002
1019
|
const expiryDays = opts.expiryDays ?? DEFAULT_EXPIRY_DAYS;
|
|
1003
1020
|
const expiresAt = new Date(Date.now() + expiryDays * 24 * 60 * 60 * 1e3);
|
|
1004
1021
|
const runOnce = async () => {
|
|
1005
|
-
|
|
1006
|
-
await trx.updateTable("peer_credentials").set({ revoked_at: /* @__PURE__ */ new Date() }).where("instance_id", "=", opts.instanceId).where("revoked_at", "is", null).
|
|
1022
|
+
return this.db.transaction().execute(async (trx) => {
|
|
1023
|
+
const revokeResult = await trx.updateTable("peer_credentials").set({ revoked_at: /* @__PURE__ */ new Date() }).where("instance_id", "=", opts.instanceId).where("revoked_at", "is", null).executeTakeFirst();
|
|
1007
1024
|
await trx.insertInto("peer_credentials").values({
|
|
1008
1025
|
instance_id: opts.instanceId,
|
|
1009
1026
|
credential_hash: opts.credentialHash,
|
|
@@ -1012,15 +1029,13 @@ var PeerCredentialStore = class {
|
|
|
1012
1029
|
source_token_hash: opts.sourceTokenHash ?? null,
|
|
1013
1030
|
expires_at: expiresAt
|
|
1014
1031
|
}).execute();
|
|
1032
|
+
return Number(revokeResult?.numUpdatedRows ?? 0n);
|
|
1015
1033
|
});
|
|
1016
1034
|
};
|
|
1017
1035
|
try {
|
|
1018
|
-
await runOnce();
|
|
1036
|
+
return { revokedCount: await runOnce() };
|
|
1019
1037
|
} catch (err) {
|
|
1020
|
-
if (err instanceof Error && "code" in err && err.code === "23505") {
|
|
1021
|
-
await runOnce();
|
|
1022
|
-
return;
|
|
1023
|
-
}
|
|
1038
|
+
if (err instanceof Error && "code" in err && err.code === "23505") return { revokedCount: await runOnce() };
|
|
1024
1039
|
throw err;
|
|
1025
1040
|
}
|
|
1026
1041
|
}
|
|
@@ -1087,6 +1102,38 @@ function mapRow(row) {
|
|
|
1087
1102
|
};
|
|
1088
1103
|
}
|
|
1089
1104
|
/**
|
|
1105
|
+
* Write credential data to a JSON file with restrictive permissions.
|
|
1106
|
+
*
|
|
1107
|
+
* Creates the parent directory (e.g., ~/.kici/) with 0700 if it doesn't exist.
|
|
1108
|
+
* The credential file is written with 0600 permissions (owner read/write only).
|
|
1109
|
+
*
|
|
1110
|
+
* @param filePath - Absolute path to the credential file
|
|
1111
|
+
* @param data - Credential data to persist
|
|
1112
|
+
*/
|
|
1113
|
+
async function writeCredentialFile(filePath, data) {
|
|
1114
|
+
await mkdir(dirname(filePath), {
|
|
1115
|
+
recursive: true,
|
|
1116
|
+
mode: 448
|
|
1117
|
+
});
|
|
1118
|
+
await writeFile(filePath, JSON.stringify(data, null, 2) + "\n", { mode: 384 });
|
|
1119
|
+
await chmod(filePath, 384);
|
|
1120
|
+
}
|
|
1121
|
+
/**
|
|
1122
|
+
* Read credential data from a JSON file.
|
|
1123
|
+
*
|
|
1124
|
+
* @param filePath - Absolute path to the credential file
|
|
1125
|
+
* @returns Parsed credential data, or null if the file doesn't exist
|
|
1126
|
+
*/
|
|
1127
|
+
async function readCredentialFile(filePath) {
|
|
1128
|
+
try {
|
|
1129
|
+
const content = await readFile(filePath, "utf-8");
|
|
1130
|
+
return JSON.parse(content);
|
|
1131
|
+
} catch (err) {
|
|
1132
|
+
if (err.code === "ENOENT") return null;
|
|
1133
|
+
throw err;
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
/**
|
|
1090
1137
|
* Construct a PeerCredentialStore bound to a Postgres URL, returning the
|
|
1091
1138
|
* store plus a disposer that closes the underlying pool. Lets tests
|
|
1092
1139
|
* exercise the real Kysely-backed store without importing pg/kysely.
|
|
@@ -1108,6 +1155,1113 @@ async function createPeerCredentialStoreFromUrl(databaseUrl, opts) {
|
|
|
1108
1155
|
};
|
|
1109
1156
|
}
|
|
1110
1157
|
//#endregion
|
|
1111
|
-
|
|
1158
|
+
//#region src/cluster/peer-auth-coordinator.ts
|
|
1159
|
+
/**
|
|
1160
|
+
* Per-orchestrator coordinator that owns the shared peer credential file.
|
|
1161
|
+
*
|
|
1162
|
+
* A single orchestrator runs N peer-clients (one per cluster peer) that all
|
|
1163
|
+
* share one identity-scoped credential file. Left uncoordinated, a reconnect
|
|
1164
|
+
* storm makes each sibling independently token-join (each join revokes the
|
|
1165
|
+
* prior credential, invalidating the others) and delete the shared file on any
|
|
1166
|
+
* rejection — a credential revocation cascade. This coordinator serializes all
|
|
1167
|
+
* file access through one in-process mutex so only one peer-client token-joins
|
|
1168
|
+
* per storm, and it never deletes a credential file a sibling has refreshed.
|
|
1169
|
+
*/
|
|
1170
|
+
const logger = createLogger({ prefix: "peer-auth-coordinator" });
|
|
1171
|
+
/** How long a waiting peer-client awaits an in-flight sibling token-join. */
|
|
1172
|
+
const DEFAULT_JOIN_WAIT_TIMEOUT_MS = 1e4;
|
|
1173
|
+
/** Max read→await→re-read cycles before a waiter gives up and joins/aborts. */
|
|
1174
|
+
const MAX_DECIDE_ITERATIONS = 3;
|
|
1175
|
+
function deferred() {
|
|
1176
|
+
let resolve;
|
|
1177
|
+
return {
|
|
1178
|
+
promise: new Promise((res) => {
|
|
1179
|
+
resolve = res;
|
|
1180
|
+
}),
|
|
1181
|
+
resolve
|
|
1182
|
+
};
|
|
1183
|
+
}
|
|
1184
|
+
var PeerAuthCoordinator = class {
|
|
1185
|
+
credentialFile;
|
|
1186
|
+
instanceId;
|
|
1187
|
+
joinToken;
|
|
1188
|
+
joinWaitTimeoutMs;
|
|
1189
|
+
/** Promise-chain mutex tail; every file op awaits the prior one. */
|
|
1190
|
+
lock = Promise.resolve();
|
|
1191
|
+
/** Set while one peer-client is mid token-join; siblings await it. */
|
|
1192
|
+
inFlightJoin = null;
|
|
1193
|
+
constructor(opts) {
|
|
1194
|
+
this.credentialFile = opts.credentialFile;
|
|
1195
|
+
this.instanceId = opts.instanceId;
|
|
1196
|
+
this.joinToken = opts.joinToken;
|
|
1197
|
+
this.joinWaitTimeoutMs = opts.joinWaitTimeoutMs ?? DEFAULT_JOIN_WAIT_TIMEOUT_MS;
|
|
1198
|
+
}
|
|
1199
|
+
/** Run `fn` exclusively against the credential file. */
|
|
1200
|
+
async withLock(fn) {
|
|
1201
|
+
const run = this.lock.then(fn, fn);
|
|
1202
|
+
this.lock = run.then(() => void 0, () => void 0);
|
|
1203
|
+
return run;
|
|
1204
|
+
}
|
|
1205
|
+
async readValidCredential() {
|
|
1206
|
+
const cred = await readCredentialFile(this.credentialFile);
|
|
1207
|
+
return cred && cred.instanceId === this.instanceId ? cred : null;
|
|
1208
|
+
}
|
|
1209
|
+
async decideAuth() {
|
|
1210
|
+
for (let i = 0; i < MAX_DECIDE_ITERATIONS; i++) {
|
|
1211
|
+
const decision = await this.withLock(async () => {
|
|
1212
|
+
const cred = await this.readValidCredential();
|
|
1213
|
+
if (cred) return {
|
|
1214
|
+
mode: "credential",
|
|
1215
|
+
credential: cred
|
|
1216
|
+
};
|
|
1217
|
+
if (this.inFlightJoin) return "await-join";
|
|
1218
|
+
if (this.joinToken) {
|
|
1219
|
+
const join = deferred();
|
|
1220
|
+
this.inFlightJoin = join;
|
|
1221
|
+
return {
|
|
1222
|
+
mode: "token-join",
|
|
1223
|
+
token: this.joinToken,
|
|
1224
|
+
complete: this.makeComplete(join)
|
|
1225
|
+
};
|
|
1226
|
+
}
|
|
1227
|
+
return { mode: "no-auth" };
|
|
1228
|
+
});
|
|
1229
|
+
if (decision !== "await-join") return decision;
|
|
1230
|
+
await this.awaitInFlightJoin();
|
|
1231
|
+
}
|
|
1232
|
+
if (this.joinToken) {
|
|
1233
|
+
const join = deferred();
|
|
1234
|
+
this.inFlightJoin = join;
|
|
1235
|
+
return {
|
|
1236
|
+
mode: "token-join",
|
|
1237
|
+
token: this.joinToken,
|
|
1238
|
+
complete: this.makeComplete(join)
|
|
1239
|
+
};
|
|
1240
|
+
}
|
|
1241
|
+
return { mode: "no-auth" };
|
|
1242
|
+
}
|
|
1243
|
+
makeComplete(join) {
|
|
1244
|
+
return (issued) => {
|
|
1245
|
+
this.withLock(async () => {
|
|
1246
|
+
if (issued) await writeCredentialFile(this.credentialFile, issued);
|
|
1247
|
+
if (this.inFlightJoin === join) this.inFlightJoin = null;
|
|
1248
|
+
join.resolve(issued);
|
|
1249
|
+
});
|
|
1250
|
+
};
|
|
1251
|
+
}
|
|
1252
|
+
async awaitInFlightJoin() {
|
|
1253
|
+
const join = this.inFlightJoin;
|
|
1254
|
+
if (!join) return;
|
|
1255
|
+
let timer;
|
|
1256
|
+
const timeout = new Promise((res) => {
|
|
1257
|
+
timer = setTimeout(() => {
|
|
1258
|
+
if (this.inFlightJoin === join) this.inFlightJoin = null;
|
|
1259
|
+
logger.warn("In-flight peer token-join timed out; waiter will retry", { instanceId: this.instanceId });
|
|
1260
|
+
res();
|
|
1261
|
+
}, this.joinWaitTimeoutMs);
|
|
1262
|
+
});
|
|
1263
|
+
await Promise.race([join.promise.then(() => void 0), timeout]);
|
|
1264
|
+
if (timer) clearTimeout(timer);
|
|
1265
|
+
}
|
|
1266
|
+
async reportRejection(provedCredential, reason) {
|
|
1267
|
+
return this.withLock(async () => {
|
|
1268
|
+
const cred = await this.readValidCredential();
|
|
1269
|
+
if (cred && cred.credential !== provedCredential) {
|
|
1270
|
+
logger.info("Credential refreshed by sibling; retrying credential auth", {
|
|
1271
|
+
instanceId: this.instanceId,
|
|
1272
|
+
reason
|
|
1273
|
+
});
|
|
1274
|
+
return "retry-credential";
|
|
1275
|
+
}
|
|
1276
|
+
try {
|
|
1277
|
+
await unlink(this.credentialFile);
|
|
1278
|
+
logger.warn("Deleted stale credential file after server rejection", {
|
|
1279
|
+
instanceId: this.instanceId,
|
|
1280
|
+
reason
|
|
1281
|
+
});
|
|
1282
|
+
} catch (err) {
|
|
1283
|
+
if (err.code !== "ENOENT") logger.warn("Failed to delete stale credential file", {
|
|
1284
|
+
instanceId: this.instanceId,
|
|
1285
|
+
path: this.credentialFile
|
|
1286
|
+
});
|
|
1287
|
+
}
|
|
1288
|
+
return "rejoin";
|
|
1289
|
+
});
|
|
1290
|
+
}
|
|
1291
|
+
};
|
|
1292
|
+
//#endregion
|
|
1293
|
+
//#region src/cluster/join-token.ts
|
|
1294
|
+
/**
|
|
1295
|
+
* Join token manager for zero-knowledge cluster bootstrap.
|
|
1296
|
+
*
|
|
1297
|
+
* Token format: kici_join_v1.<base64url(routing_json)>.<random_256bit_hex>
|
|
1298
|
+
* - Routing JSON: { orgId, routingKey, expiry } (cleartext for Platform relay routing)
|
|
1299
|
+
* - Secret: 32 random bytes as hex (used for key derivation)
|
|
1300
|
+
*
|
|
1301
|
+
* Key derivation:
|
|
1302
|
+
* - encryption_key = HKDF-SHA256(secret, salt="kici-join-encrypt", info="v1", length=32)
|
|
1303
|
+
* - validation_hash = SHA-256(secret) (stored in DB for lookup)
|
|
1304
|
+
*
|
|
1305
|
+
* Config bundle encryption: AES-256-GCM with random 12-byte IV.
|
|
1306
|
+
* Wire format: <12-byte IV><16-byte auth tag><ciphertext>
|
|
1307
|
+
*/
|
|
1308
|
+
const joinTokenLogger = createLogger({ prefix: "join-token" });
|
|
1309
|
+
const TOKEN_PREFIX = "kici_join_v1";
|
|
1310
|
+
const DEFAULT_EXPIRY_MS = 36e5;
|
|
1311
|
+
const TOKEN_ALREADY_USED_MESSAGE = "Join token has already been used. Generate a new token with: kici admin create-join-token";
|
|
1312
|
+
var JoinTokenManager = class {
|
|
1313
|
+
deps;
|
|
1314
|
+
constructor(deps) {
|
|
1315
|
+
this.deps = deps;
|
|
1316
|
+
}
|
|
1317
|
+
/**
|
|
1318
|
+
* Create a new join token.
|
|
1319
|
+
* Returns the full token string (only available at creation time).
|
|
1320
|
+
*/
|
|
1321
|
+
async createToken(opts) {
|
|
1322
|
+
const expiryMs = opts.expiryMs ?? DEFAULT_EXPIRY_MS;
|
|
1323
|
+
const expiry = Date.now() + expiryMs;
|
|
1324
|
+
const role = opts.role ?? "coordinator";
|
|
1325
|
+
const routing = {
|
|
1326
|
+
orgId: opts.orgId,
|
|
1327
|
+
routingKey: opts.routingKey,
|
|
1328
|
+
expiry,
|
|
1329
|
+
role
|
|
1330
|
+
};
|
|
1331
|
+
const secret = randomBytes(32);
|
|
1332
|
+
const token = `${TOKEN_PREFIX}.${Buffer.from(JSON.stringify(routing)).toString("base64url")}.${secret.toString("hex")}`;
|
|
1333
|
+
const { validationHash } = deriveKeys(secret);
|
|
1334
|
+
await this.deps.db.insertInto("join_tokens").values({
|
|
1335
|
+
id: randomUUID(),
|
|
1336
|
+
token_hash: validationHash,
|
|
1337
|
+
routing_info: JSON.stringify(routing),
|
|
1338
|
+
role,
|
|
1339
|
+
created_by: opts.createdBy,
|
|
1340
|
+
expires_at: new Date(expiry)
|
|
1341
|
+
}).execute();
|
|
1342
|
+
joinTokenLogger.info("Created join token", {
|
|
1343
|
+
orgId: opts.orgId,
|
|
1344
|
+
routingKey: opts.routingKey
|
|
1345
|
+
});
|
|
1346
|
+
return token;
|
|
1347
|
+
}
|
|
1348
|
+
/**
|
|
1349
|
+
* Atomically validate and consume a token in one DB round-trip.
|
|
1350
|
+
*
|
|
1351
|
+
* Single UPDATE with `WHERE token_hash = ? AND consumed_at IS NULL AND
|
|
1352
|
+
* expires_at > NOW()` — only one caller can win the claim across a
|
|
1353
|
+
* shared-DB multi-coordinator mesh. The winner gets `{ routing, keys }`;
|
|
1354
|
+
* every other concurrent caller gets `TOKEN_ALREADY_USED_MESSAGE` and is
|
|
1355
|
+
* expected to fall into the idempotent recovery branch in
|
|
1356
|
+
* `peer-handler.ts` (which serialises credential issuance via the
|
|
1357
|
+
* `peer_credentials_active_uniq` partial unique index).
|
|
1358
|
+
*
|
|
1359
|
+
* On a 0-row claim, a follow-up SELECT disambiguates not-found / expired
|
|
1360
|
+
* / already-used / reusable-by-same-instance so callers can branch on the
|
|
1361
|
+
* specific reason — the recovery path in peer-handler.ts keys on the
|
|
1362
|
+
* "already used" string specifically. The follow-up only fires on the
|
|
1363
|
+
* unhappy path.
|
|
1364
|
+
*
|
|
1365
|
+
* Self-healing reuse: a join token is re-consumable by the same joining
|
|
1366
|
+
* peer (`peerInstanceId`) until its `expires_at`. A peer that lost its
|
|
1367
|
+
* credential (transient outage / deleted credential file) re-presents the
|
|
1368
|
+
* still-valid join token already in its env; the coordinator accepts the
|
|
1369
|
+
* reuse and issues a fresh credential — no operator action, no cluster
|
|
1370
|
+
* redeploy. Reuse is bounded by BOTH `expires_at` AND the consuming
|
|
1371
|
+
* instanceId, so it never widens a leaked token's usefulness beyond the
|
|
1372
|
+
* instance that first consumed it.
|
|
1373
|
+
*/
|
|
1374
|
+
async validateAndConsumeToken(token, consumedBy, peerInstanceId) {
|
|
1375
|
+
const parsed = parseToken(token);
|
|
1376
|
+
const keys = deriveKeys(Buffer.from(parsed.secretHex, "hex"));
|
|
1377
|
+
const updateResult = await this.deps.db.updateTable("join_tokens").set({
|
|
1378
|
+
consumed_at: /* @__PURE__ */ new Date(),
|
|
1379
|
+
consumed_by: consumedBy,
|
|
1380
|
+
consumed_by_instance: peerInstanceId
|
|
1381
|
+
}).where("token_hash", "=", keys.validationHash).where("consumed_at", "is", null).where("expires_at", ">", /* @__PURE__ */ new Date()).executeTakeFirst();
|
|
1382
|
+
if (Number(updateResult?.numUpdatedRows ?? 0n) > 0) {
|
|
1383
|
+
joinTokenLogger.info("Consumed join token", {
|
|
1384
|
+
consumedBy,
|
|
1385
|
+
peerInstanceId
|
|
1386
|
+
});
|
|
1387
|
+
return {
|
|
1388
|
+
routing: parsed.routing,
|
|
1389
|
+
keys
|
|
1390
|
+
};
|
|
1391
|
+
}
|
|
1392
|
+
const row = await this.deps.db.selectFrom("join_tokens").selectAll().where("token_hash", "=", keys.validationHash).executeTakeFirst();
|
|
1393
|
+
if (!row) throw new Error("Invalid join token");
|
|
1394
|
+
if (new Date(row.expires_at).getTime() <= Date.now()) throw new Error("Join token has expired. Generate a new token with: kici admin create-join-token");
|
|
1395
|
+
if (row.consumed_at) {
|
|
1396
|
+
if (row.consumed_by_instance === peerInstanceId) {
|
|
1397
|
+
joinTokenLogger.info("Re-validated join token for returning instance", {
|
|
1398
|
+
consumedBy,
|
|
1399
|
+
peerInstanceId
|
|
1400
|
+
});
|
|
1401
|
+
return {
|
|
1402
|
+
routing: parsed.routing,
|
|
1403
|
+
keys
|
|
1404
|
+
};
|
|
1405
|
+
}
|
|
1406
|
+
throw new Error(TOKEN_ALREADY_USED_MESSAGE);
|
|
1407
|
+
}
|
|
1408
|
+
throw new Error("Invalid join token");
|
|
1409
|
+
}
|
|
1410
|
+
};
|
|
1411
|
+
/**
|
|
1412
|
+
* Build a JoinTokenManager backed by its own connection pool to the given
|
|
1413
|
+
* orchestrator database URL. Mirrors `createPeerCredentialStoreFromUrl`;
|
|
1414
|
+
* consumed by E2E tests that need to exercise token validation/reuse against
|
|
1415
|
+
* the real cluster DB.
|
|
1416
|
+
*/
|
|
1417
|
+
function createJoinTokenManagerFromUrl(databaseUrl, opts) {
|
|
1418
|
+
const db = new Kysely({ dialect: new PostgresDialect({ pool: new pg.Pool({
|
|
1419
|
+
connectionString: databaseUrl,
|
|
1420
|
+
max: opts?.maxConnections ?? 3
|
|
1421
|
+
}) }) });
|
|
1422
|
+
return {
|
|
1423
|
+
manager: new JoinTokenManager({ db }),
|
|
1424
|
+
dispose: async () => {
|
|
1425
|
+
await db.destroy();
|
|
1426
|
+
}
|
|
1427
|
+
};
|
|
1428
|
+
}
|
|
1429
|
+
/**
|
|
1430
|
+
* Parse a join token string into routing info and secret hex.
|
|
1431
|
+
*/
|
|
1432
|
+
function parseToken(token) {
|
|
1433
|
+
const parts = token.split(".");
|
|
1434
|
+
if (parts.length !== 3 || parts[0] !== TOKEN_PREFIX) throw new Error(`Invalid join token format. Expected: ${TOKEN_PREFIX}.<routing>.<secret>`);
|
|
1435
|
+
const routingJson = Buffer.from(parts[1], "base64url").toString("utf-8");
|
|
1436
|
+
const routing = JSON.parse(routingJson);
|
|
1437
|
+
if (!routing.orgId || !routing.routingKey || !routing.expiry) throw new Error("Invalid join token routing data");
|
|
1438
|
+
return {
|
|
1439
|
+
routing,
|
|
1440
|
+
secretHex: parts[2]
|
|
1441
|
+
};
|
|
1442
|
+
}
|
|
1443
|
+
/**
|
|
1444
|
+
* Derive encryption key and validation hash from a token secret.
|
|
1445
|
+
* - encryptionKey: HKDF-SHA256 with salt="kici-join-encrypt", info="v1"
|
|
1446
|
+
* - validationHash: SHA-256 of secret (stored in DB for lookup)
|
|
1447
|
+
*/
|
|
1448
|
+
function deriveKeys(secret) {
|
|
1449
|
+
const validationHash = sha256(secret);
|
|
1450
|
+
const derived = hkdfSync("sha256", secret, Buffer.from("kici-join-encrypt"), Buffer.from("v1"), 32);
|
|
1451
|
+
return {
|
|
1452
|
+
encryptionKey: Buffer.from(derived),
|
|
1453
|
+
validationHash
|
|
1454
|
+
};
|
|
1455
|
+
}
|
|
1456
|
+
//#endregion
|
|
1457
|
+
//#region src/cli/api-client.ts
|
|
1458
|
+
/**
|
|
1459
|
+
* HTTP client wrapper for the kici-admin CLI.
|
|
1460
|
+
*
|
|
1461
|
+
* Communicates with the orchestrator admin API via Bearer token authentication.
|
|
1462
|
+
* All methods are thin wrappers around fetch() that handle JSON serialization,
|
|
1463
|
+
* error formatting, and URL construction.
|
|
1464
|
+
*/
|
|
1465
|
+
var AdminApiClient = class {
|
|
1466
|
+
baseUrl;
|
|
1467
|
+
token;
|
|
1468
|
+
constructor(baseUrl, token) {
|
|
1469
|
+
this.baseUrl = baseUrl;
|
|
1470
|
+
this.token = token;
|
|
1471
|
+
}
|
|
1472
|
+
/**
|
|
1473
|
+
* Make an authenticated HTTP request to the admin API.
|
|
1474
|
+
*/
|
|
1475
|
+
async request(method, path, body) {
|
|
1476
|
+
const url = `${this.baseUrl}${path}`;
|
|
1477
|
+
const headers = {
|
|
1478
|
+
Authorization: `Bearer ${this.token}`,
|
|
1479
|
+
"Content-Type": "application/json"
|
|
1480
|
+
};
|
|
1481
|
+
const res = await fetch(url, {
|
|
1482
|
+
method,
|
|
1483
|
+
headers,
|
|
1484
|
+
body: body !== void 0 ? JSON.stringify(body) : void 0
|
|
1485
|
+
});
|
|
1486
|
+
if (!res.ok) {
|
|
1487
|
+
const text = await res.text();
|
|
1488
|
+
let errorBody;
|
|
1489
|
+
try {
|
|
1490
|
+
errorBody = JSON.parse(text).error ?? text;
|
|
1491
|
+
} catch {
|
|
1492
|
+
errorBody = text;
|
|
1493
|
+
}
|
|
1494
|
+
throw new Error(`HTTP ${res.status}: ${errorBody}`);
|
|
1495
|
+
}
|
|
1496
|
+
if (res.status === 204) return;
|
|
1497
|
+
return await res.json();
|
|
1498
|
+
}
|
|
1499
|
+
/**
|
|
1500
|
+
* Public GET request returning parsed JSON.
|
|
1501
|
+
*/
|
|
1502
|
+
async get(path) {
|
|
1503
|
+
return this.request("GET", path);
|
|
1504
|
+
}
|
|
1505
|
+
/**
|
|
1506
|
+
* Public POST request returning parsed JSON.
|
|
1507
|
+
*/
|
|
1508
|
+
async post(path, body) {
|
|
1509
|
+
return this.request("POST", path, body);
|
|
1510
|
+
}
|
|
1511
|
+
/**
|
|
1512
|
+
* Public PATCH request returning parsed JSON.
|
|
1513
|
+
*/
|
|
1514
|
+
async patch(path, body) {
|
|
1515
|
+
return this.request("PATCH", path, body);
|
|
1516
|
+
}
|
|
1517
|
+
/**
|
|
1518
|
+
* Public PUT request returning parsed JSON.
|
|
1519
|
+
*/
|
|
1520
|
+
async put(path, body) {
|
|
1521
|
+
return this.request("PUT", path, body);
|
|
1522
|
+
}
|
|
1523
|
+
/**
|
|
1524
|
+
* Public DELETE request returning parsed JSON.
|
|
1525
|
+
*/
|
|
1526
|
+
async delete(path) {
|
|
1527
|
+
return this.request("DELETE", path);
|
|
1528
|
+
}
|
|
1529
|
+
/**
|
|
1530
|
+
* Public GET request returning raw response text.
|
|
1531
|
+
*/
|
|
1532
|
+
async getText(path) {
|
|
1533
|
+
const url = `${this.baseUrl}${path}`;
|
|
1534
|
+
const res = await fetch(url, {
|
|
1535
|
+
method: "GET",
|
|
1536
|
+
headers: { Authorization: `Bearer ${this.token}` }
|
|
1537
|
+
});
|
|
1538
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);
|
|
1539
|
+
return res.text();
|
|
1540
|
+
}
|
|
1541
|
+
/** Enumerate the cluster topology for `debug-bundle --fleet --list` / `--pick`. */
|
|
1542
|
+
async getFleetTopology() {
|
|
1543
|
+
return this.get("/admin/fleet-topology");
|
|
1544
|
+
}
|
|
1545
|
+
/**
|
|
1546
|
+
* Drive the fleet fan-out and write the assembled ZIP to `outPath`. The
|
|
1547
|
+
* response is an octet-stream, so it is read as bytes rather than parsed JSON.
|
|
1548
|
+
*/
|
|
1549
|
+
async downloadFleetBundle(body, outPath) {
|
|
1550
|
+
const res = await fetch(`${this.baseUrl}/admin/fleet-bundle`, {
|
|
1551
|
+
method: "POST",
|
|
1552
|
+
headers: {
|
|
1553
|
+
Authorization: `Bearer ${this.token}`,
|
|
1554
|
+
"Content-Type": "application/json"
|
|
1555
|
+
},
|
|
1556
|
+
body: JSON.stringify(body)
|
|
1557
|
+
});
|
|
1558
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);
|
|
1559
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
1560
|
+
fs.writeFileSync(outPath, buf);
|
|
1561
|
+
}
|
|
1562
|
+
async listScopes(orgId) {
|
|
1563
|
+
return this.request("GET", `/api/v1/admin/secrets/scopes?orgId=${encodeURIComponent(orgId)}`);
|
|
1564
|
+
}
|
|
1565
|
+
async listKeys(orgId, scope) {
|
|
1566
|
+
const params = new URLSearchParams({
|
|
1567
|
+
orgId,
|
|
1568
|
+
scope
|
|
1569
|
+
});
|
|
1570
|
+
return this.request("GET", `/api/v1/admin/secrets/keys?${params}`);
|
|
1571
|
+
}
|
|
1572
|
+
async setSecret(orgId, scope, key, value) {
|
|
1573
|
+
return this.request("PUT", `/api/v1/admin/secrets/${encodeURIComponent(orgId)}/${encodeURIComponent(scope)}/${encodeURIComponent(key)}`, { value });
|
|
1574
|
+
}
|
|
1575
|
+
async deleteSecret(orgId, scope, key) {
|
|
1576
|
+
return this.request("DELETE", `/api/v1/admin/secrets/${encodeURIComponent(orgId)}/${encodeURIComponent(scope)}/${encodeURIComponent(key)}`);
|
|
1577
|
+
}
|
|
1578
|
+
async listVariables(orgId, environment) {
|
|
1579
|
+
const params = new URLSearchParams({ orgId });
|
|
1580
|
+
return this.request("GET", `/api/v1/admin/environments/${encodeURIComponent(environment)}/variables?${params}`);
|
|
1581
|
+
}
|
|
1582
|
+
async setVariable(orgId, environment, key, value, locked) {
|
|
1583
|
+
const params = new URLSearchParams({ orgId });
|
|
1584
|
+
return this.request("PUT", `/api/v1/admin/environments/${encodeURIComponent(environment)}/variables/${encodeURIComponent(key)}?${params}`, {
|
|
1585
|
+
value,
|
|
1586
|
+
locked
|
|
1587
|
+
});
|
|
1588
|
+
}
|
|
1589
|
+
async deleteVariable(orgId, environment, key) {
|
|
1590
|
+
const params = new URLSearchParams({ orgId });
|
|
1591
|
+
return this.request("DELETE", `/api/v1/admin/environments/${encodeURIComponent(environment)}/variables/${encodeURIComponent(key)}?${params}`);
|
|
1592
|
+
}
|
|
1593
|
+
async rotateKey() {
|
|
1594
|
+
return this.request("POST", "/api/v1/admin/rotate-key");
|
|
1595
|
+
}
|
|
1596
|
+
async createGenericSource(data) {
|
|
1597
|
+
return this.request("POST", "/api/v1/admin/generic-sources", data);
|
|
1598
|
+
}
|
|
1599
|
+
async listGenericSources(orgId, includeDeleted) {
|
|
1600
|
+
const params = new URLSearchParams({ orgId });
|
|
1601
|
+
if (includeDeleted) params.set("includeDeleted", "true");
|
|
1602
|
+
return this.request("GET", `/api/v1/admin/generic-sources?${params}`);
|
|
1603
|
+
}
|
|
1604
|
+
async getGenericSource(id) {
|
|
1605
|
+
return this.request("GET", `/api/v1/admin/generic-sources/${encodeURIComponent(id)}`);
|
|
1606
|
+
}
|
|
1607
|
+
async updateGenericSource(id, data) {
|
|
1608
|
+
return this.request("PATCH", `/api/v1/admin/generic-sources/${encodeURIComponent(id)}`, data);
|
|
1609
|
+
}
|
|
1610
|
+
async deleteGenericSource(id, hard) {
|
|
1611
|
+
const qs = hard ? "?hard=true" : "";
|
|
1612
|
+
return this.request("DELETE", `/api/v1/admin/generic-sources/${encodeURIComponent(id)}${qs}`);
|
|
1613
|
+
}
|
|
1614
|
+
async enableGenericSource(id) {
|
|
1615
|
+
return this.request("POST", `/api/v1/admin/generic-sources/${encodeURIComponent(id)}/enable`);
|
|
1616
|
+
}
|
|
1617
|
+
async disableGenericSource(id) {
|
|
1618
|
+
return this.request("POST", `/api/v1/admin/generic-sources/${encodeURIComponent(id)}/disable`);
|
|
1619
|
+
}
|
|
1620
|
+
async queryAudit(opts) {
|
|
1621
|
+
const params = new URLSearchParams();
|
|
1622
|
+
if (opts?.contextName) params.set("contextName", opts.contextName);
|
|
1623
|
+
if (opts?.routingKey) params.set("routingKey", opts.routingKey);
|
|
1624
|
+
if (opts?.action) params.set("action", opts.action);
|
|
1625
|
+
if (opts?.from) params.set("from", opts.from);
|
|
1626
|
+
if (opts?.to) params.set("to", opts.to);
|
|
1627
|
+
if (opts?.limit !== void 0) params.set("limit", String(opts.limit));
|
|
1628
|
+
if (opts?.offset !== void 0) params.set("offset", String(opts.offset));
|
|
1629
|
+
if (opts?.includeArchived) params.set("includeArchived", "true");
|
|
1630
|
+
const qs = params.toString();
|
|
1631
|
+
return (await this.request("GET", `/api/v1/admin/audit${qs ? `?${qs}` : ""}`)).entries;
|
|
1632
|
+
}
|
|
1633
|
+
async createToken(data) {
|
|
1634
|
+
return this.request("POST", "/api/v1/admin/tokens", data);
|
|
1635
|
+
}
|
|
1636
|
+
async listTokens() {
|
|
1637
|
+
return (await this.request("GET", "/api/v1/admin/tokens")).tokens;
|
|
1638
|
+
}
|
|
1639
|
+
async revokeToken(id) {
|
|
1640
|
+
return this.request("DELETE", `/api/v1/admin/tokens/${encodeURIComponent(id)}`);
|
|
1641
|
+
}
|
|
1642
|
+
async createAgentToken(opts) {
|
|
1643
|
+
return this.request("POST", "/api/v1/agent-tokens", opts);
|
|
1644
|
+
}
|
|
1645
|
+
async listAgentTokens(opts) {
|
|
1646
|
+
const qs = opts?.type ? `?type=${encodeURIComponent(opts.type)}` : "";
|
|
1647
|
+
return this.request("GET", `/api/v1/agent-tokens${qs}`);
|
|
1648
|
+
}
|
|
1649
|
+
/**
|
|
1650
|
+
* Revoke an agent token.
|
|
1651
|
+
*
|
|
1652
|
+
* The orchestrator both flips `agent_tokens.revoked_at` AND
|
|
1653
|
+
* synchronously closes every in-flight agent WS authenticated by
|
|
1654
|
+
* this token. The returned `kicked` count is the number
|
|
1655
|
+
* of WS connections that were closed on the wire — surface it to
|
|
1656
|
+
* the operator so they know the revocation actually propagated.
|
|
1657
|
+
*/
|
|
1658
|
+
async revokeAgentToken(id) {
|
|
1659
|
+
return this.request("DELETE", `/api/v1/agent-tokens/${encodeURIComponent(id)}`);
|
|
1660
|
+
}
|
|
1661
|
+
async configSeed(config, description) {
|
|
1662
|
+
return this.request("POST", "/admin/config/seed", {
|
|
1663
|
+
config,
|
|
1664
|
+
description
|
|
1665
|
+
});
|
|
1666
|
+
}
|
|
1667
|
+
async configGet(path) {
|
|
1668
|
+
const qs = path ? `?path=${encodeURIComponent(path)}` : "";
|
|
1669
|
+
return this.request("GET", `/admin/config${qs}`);
|
|
1670
|
+
}
|
|
1671
|
+
async configSet(path, value, description) {
|
|
1672
|
+
return this.request("PUT", "/admin/config", {
|
|
1673
|
+
path,
|
|
1674
|
+
value,
|
|
1675
|
+
description
|
|
1676
|
+
});
|
|
1677
|
+
}
|
|
1678
|
+
async configDelete(path, description) {
|
|
1679
|
+
return this.request("DELETE", "/admin/config", {
|
|
1680
|
+
path,
|
|
1681
|
+
description
|
|
1682
|
+
});
|
|
1683
|
+
}
|
|
1684
|
+
async configExport() {
|
|
1685
|
+
return this.request("GET", "/admin/config/export");
|
|
1686
|
+
}
|
|
1687
|
+
async configValidate(config, type) {
|
|
1688
|
+
return this.request("POST", "/admin/config/validate", {
|
|
1689
|
+
config,
|
|
1690
|
+
type
|
|
1691
|
+
});
|
|
1692
|
+
}
|
|
1693
|
+
async configDiff() {
|
|
1694
|
+
return this.request("GET", "/admin/config/diff");
|
|
1695
|
+
}
|
|
1696
|
+
async configHistory(limit) {
|
|
1697
|
+
const qs = limit !== void 0 ? `?limit=${limit}` : "";
|
|
1698
|
+
return this.request("GET", `/admin/config/history${qs}`);
|
|
1699
|
+
}
|
|
1700
|
+
async configRollback(version) {
|
|
1701
|
+
return this.request("POST", "/admin/config/rollback", { version });
|
|
1702
|
+
}
|
|
1703
|
+
async configReload(opts) {
|
|
1704
|
+
return this.request("POST", "/admin/config/reload", opts);
|
|
1705
|
+
}
|
|
1706
|
+
async createApiKey(opts) {
|
|
1707
|
+
return this.request("POST", "/api/v1/api-keys", opts);
|
|
1708
|
+
}
|
|
1709
|
+
async addRoutingKeyPermission(keyId, pattern) {
|
|
1710
|
+
return this.request("POST", `/api/v1/api-keys/${encodeURIComponent(keyId)}/routing-permissions`, { pattern });
|
|
1711
|
+
}
|
|
1712
|
+
async addBackend(params) {
|
|
1713
|
+
return this.request("POST", "/api/v1/admin/backends", params);
|
|
1714
|
+
}
|
|
1715
|
+
async removeBackend(name) {
|
|
1716
|
+
return this.request("DELETE", `/api/v1/admin/backends/${encodeURIComponent(name)}`);
|
|
1717
|
+
}
|
|
1718
|
+
async listBackends() {
|
|
1719
|
+
return this.request("GET", "/api/v1/admin/backends");
|
|
1720
|
+
}
|
|
1721
|
+
async getBackend(name) {
|
|
1722
|
+
return this.request("GET", `/api/v1/admin/backends/${encodeURIComponent(name)}`);
|
|
1723
|
+
}
|
|
1724
|
+
async testBackend(params) {
|
|
1725
|
+
return this.request("POST", "/api/v1/admin/backends/test", params);
|
|
1726
|
+
}
|
|
1727
|
+
async testNamedBackend(name) {
|
|
1728
|
+
return this.request("POST", `/api/v1/admin/backends/${encodeURIComponent(name)}/test`);
|
|
1729
|
+
}
|
|
1730
|
+
async syncBackend(name) {
|
|
1731
|
+
return this.request("POST", `/api/v1/admin/backends/${encodeURIComponent(name)}/sync`);
|
|
1732
|
+
}
|
|
1733
|
+
async syncAllBackends() {
|
|
1734
|
+
return this.request("POST", "/api/v1/admin/backends/sync");
|
|
1735
|
+
}
|
|
1736
|
+
/**
|
|
1737
|
+
* List execution runs. `status` accepts either a single status or a
|
|
1738
|
+
* comma-separated list (e.g. `success,failed`). `since` is an ISO-8601
|
|
1739
|
+
* timestamp; only runs with `created_at` strictly later than this value
|
|
1740
|
+
* are returned.
|
|
1741
|
+
*/
|
|
1742
|
+
async listRuns(opts) {
|
|
1743
|
+
const params = new URLSearchParams();
|
|
1744
|
+
if (opts?.status) params.set("status", opts.status);
|
|
1745
|
+
if (opts?.workflowName) params.set("workflowName", opts.workflowName);
|
|
1746
|
+
if (opts?.repo) params.set("repo", opts.repo);
|
|
1747
|
+
if (opts?.since) params.set("since", opts.since);
|
|
1748
|
+
if (opts?.limit) params.set("limit", String(opts.limit));
|
|
1749
|
+
if (opts?.offset) params.set("offset", String(opts.offset));
|
|
1750
|
+
const qs = params.toString();
|
|
1751
|
+
return this.request("GET", `/api/v1/admin/runs${qs ? `?${qs}` : ""}`);
|
|
1752
|
+
}
|
|
1753
|
+
/**
|
|
1754
|
+
* Count matching runs without returning the row list. Wraps the list
|
|
1755
|
+
* endpoint with `?count=true`, which skips the row query server-side.
|
|
1756
|
+
*/
|
|
1757
|
+
async countRuns(opts) {
|
|
1758
|
+
const params = new URLSearchParams();
|
|
1759
|
+
params.set("count", "true");
|
|
1760
|
+
if (opts?.status) params.set("status", opts.status);
|
|
1761
|
+
if (opts?.workflowName) params.set("workflowName", opts.workflowName);
|
|
1762
|
+
if (opts?.repo) params.set("repo", opts.repo);
|
|
1763
|
+
if (opts?.since) params.set("since", opts.since);
|
|
1764
|
+
return this.request("GET", `/api/v1/admin/runs?${params.toString()}`);
|
|
1765
|
+
}
|
|
1766
|
+
/**
|
|
1767
|
+
* Fetch the run header (no jobs, no steps). Use `getRunJobs` for the
|
|
1768
|
+
* jobs sub-resource.
|
|
1769
|
+
*/
|
|
1770
|
+
async getRun(runId) {
|
|
1771
|
+
return this.request("GET", `/api/v1/admin/runs/${encodeURIComponent(runId)}`);
|
|
1772
|
+
}
|
|
1773
|
+
/**
|
|
1774
|
+
* Fetch the jobs list for a run. When `includeSteps: true`, each job
|
|
1775
|
+
* entry embeds a `steps[]` array.
|
|
1776
|
+
*/
|
|
1777
|
+
async getRunJobs(runId, opts) {
|
|
1778
|
+
const qs = opts?.includeSteps ? "?includeSteps=true" : "";
|
|
1779
|
+
return this.request("GET", `/api/v1/admin/runs/${encodeURIComponent(runId)}/jobs${qs}`);
|
|
1780
|
+
}
|
|
1781
|
+
/**
|
|
1782
|
+
* Fetch the scrub status of the run's ephemeral key. Never returns the
|
|
1783
|
+
* key material itself — only `{ exists, createdAt }`.
|
|
1784
|
+
*/
|
|
1785
|
+
async getRunEphemeralKey(runId) {
|
|
1786
|
+
return this.request("GET", `/api/v1/admin/runs/${encodeURIComponent(runId)}/ephemeral-key`);
|
|
1787
|
+
}
|
|
1788
|
+
/**
|
|
1789
|
+
* List secret outputs for a run. Values are masked unless `reveal: true`
|
|
1790
|
+
* is passed AND the calling token has the `secret.reveal` permission.
|
|
1791
|
+
* Every reveal call writes a `secret-outputs.reveal` row to the
|
|
1792
|
+
* secret_audit_log table.
|
|
1793
|
+
*/
|
|
1794
|
+
async getRunSecretOutputs(runId, opts) {
|
|
1795
|
+
const params = new URLSearchParams();
|
|
1796
|
+
if (opts?.outputKey) params.set("outputKey", opts.outputKey);
|
|
1797
|
+
if (opts?.reveal) params.set("reveal", "true");
|
|
1798
|
+
const qs = params.toString();
|
|
1799
|
+
return this.request("GET", `/api/v1/admin/runs/${encodeURIComponent(runId)}/secret-outputs${qs ? `?${qs}` : ""}`);
|
|
1800
|
+
}
|
|
1801
|
+
async listEventLog(opts) {
|
|
1802
|
+
const params = new URLSearchParams();
|
|
1803
|
+
if (opts?.orgId) params.set("orgId", opts.orgId);
|
|
1804
|
+
if (opts?.routingKey) params.set("routingKey", opts.routingKey);
|
|
1805
|
+
if (opts?.event) params.set("event", opts.event);
|
|
1806
|
+
if (opts?.action) params.set("action", opts.action);
|
|
1807
|
+
if (opts?.status) params.set("status", opts.status);
|
|
1808
|
+
if (opts?.from) params.set("from", opts.from);
|
|
1809
|
+
if (opts?.to) params.set("to", opts.to);
|
|
1810
|
+
if (opts?.deliveryId) params.set("deliveryId", opts.deliveryId);
|
|
1811
|
+
if (opts?.limit !== void 0) params.set("limit", String(opts.limit));
|
|
1812
|
+
if (opts?.offset !== void 0) params.set("offset", String(opts.offset));
|
|
1813
|
+
if (opts?.includeArchived) params.set("includeArchived", "true");
|
|
1814
|
+
const qs = params.toString();
|
|
1815
|
+
return this.request("GET", `/api/v1/admin/event-log${qs ? `?${qs}` : ""}`);
|
|
1816
|
+
}
|
|
1817
|
+
async getEventLog(deliveryId, opts) {
|
|
1818
|
+
const params = new URLSearchParams({ orgId: opts.orgId });
|
|
1819
|
+
if (opts.includePayload) params.set("includePayload", "true");
|
|
1820
|
+
if (opts.routingKey) params.set("routingKey", opts.routingKey);
|
|
1821
|
+
return this.request("GET", `/api/v1/admin/event-log/${encodeURIComponent(deliveryId)}?${params}`);
|
|
1822
|
+
}
|
|
1823
|
+
async listAccessLog(opts) {
|
|
1824
|
+
const params = new URLSearchParams();
|
|
1825
|
+
if (opts?.orgId) params.set("orgId", opts.orgId);
|
|
1826
|
+
if (opts?.actorType) params.set("actorType", opts.actorType);
|
|
1827
|
+
if (opts?.actorId) params.set("actorId", opts.actorId);
|
|
1828
|
+
if (opts?.action) params.set("action", opts.action);
|
|
1829
|
+
if (opts?.source) params.set("source", opts.source);
|
|
1830
|
+
if (opts?.outcome) params.set("outcome", opts.outcome);
|
|
1831
|
+
if (opts?.targetType) params.set("targetType", opts.targetType);
|
|
1832
|
+
if (opts?.targetId) params.set("targetId", opts.targetId);
|
|
1833
|
+
if (opts?.from) params.set("from", opts.from);
|
|
1834
|
+
if (opts?.to) params.set("to", opts.to);
|
|
1835
|
+
if (opts?.q) params.set("q", opts.q);
|
|
1836
|
+
if (opts?.limit !== void 0) params.set("limit", String(opts.limit));
|
|
1837
|
+
if (opts?.cursor) params.set("cursor", opts.cursor);
|
|
1838
|
+
const qs = params.toString();
|
|
1839
|
+
return this.request("GET", `/api/v1/admin/access-log${qs ? `?${qs}` : ""}`);
|
|
1840
|
+
}
|
|
1841
|
+
async getAccessLogEntry(id, opts) {
|
|
1842
|
+
const params = new URLSearchParams();
|
|
1843
|
+
if (opts?.orgId) params.set("orgId", opts.orgId);
|
|
1844
|
+
const qs = params.toString();
|
|
1845
|
+
return this.request("GET", `/api/v1/admin/access-log/${encodeURIComponent(id)}${qs ? `?${qs}` : ""}`);
|
|
1846
|
+
}
|
|
1847
|
+
async diagnose() {
|
|
1848
|
+
return this.request("GET", "/admin/diagnose");
|
|
1849
|
+
}
|
|
1850
|
+
};
|
|
1851
|
+
//#endregion
|
|
1852
|
+
//#region src/providers/github/auth.ts
|
|
1853
|
+
/**
|
|
1854
|
+
* GitHub clone token provider and authentication utilities.
|
|
1855
|
+
*
|
|
1856
|
+
* Implements the CloneTokenProvider interface from @kici-dev/engine for GitHub.
|
|
1857
|
+
* Uses GitHub App installation tokens for repository access.
|
|
1858
|
+
*/
|
|
1859
|
+
/**
|
|
1860
|
+
* Create an Octokit instance authenticated as a GitHub App installation.
|
|
1861
|
+
*
|
|
1862
|
+
* Uses @octokit/auth-app to handle:
|
|
1863
|
+
* - JWT generation for app-level authentication
|
|
1864
|
+
* - Installation token creation and automatic refresh
|
|
1865
|
+
*
|
|
1866
|
+
* Each call creates a new Octokit instance scoped to the specific installation.
|
|
1867
|
+
* The auth-app strategy handles token caching internally per Octokit instance.
|
|
1868
|
+
*
|
|
1869
|
+
* @param config - GitHub App credentials (appId + privateKey)
|
|
1870
|
+
* @param installationId - GitHub App installation ID for the target account/org
|
|
1871
|
+
* @returns Octokit instance with auto-refreshing installation tokens
|
|
1872
|
+
*/
|
|
1873
|
+
function createInstallationOctokit(config, installationId) {
|
|
1874
|
+
return new Octokit({
|
|
1875
|
+
authStrategy: createAppAuth,
|
|
1876
|
+
auth: {
|
|
1877
|
+
appId: config.appId,
|
|
1878
|
+
privateKey: config.privateKey,
|
|
1879
|
+
installationId
|
|
1880
|
+
}
|
|
1881
|
+
});
|
|
1882
|
+
}
|
|
1883
|
+
//#endregion
|
|
1884
|
+
//#region src/providers/github/manifest.ts
|
|
1885
|
+
/**
|
|
1886
|
+
* GitHub App Manifest flow helpers. The manifest encodes KiCI's exact
|
|
1887
|
+
* permissions + events + webhook config so the operator never picks them by
|
|
1888
|
+
* hand — GitHub creates a correctly-configured App from this object in one
|
|
1889
|
+
* click. See docs.github.com "Registering a GitHub App from a manifest".
|
|
1890
|
+
*
|
|
1891
|
+
* The webhook secret is NOT part of the manifest: GitHub generates it during
|
|
1892
|
+
* registration and returns it on the conversion response, so both GitHub and
|
|
1893
|
+
* the Platform end up sharing the same secret with zero operator effort.
|
|
1894
|
+
*/
|
|
1895
|
+
/**
|
|
1896
|
+
* Validate a self-hosted webhook URL supplied via `source add github
|
|
1897
|
+
* --webhook-url`. Must be a well-formed absolute `https://` URL. Returns the URL
|
|
1898
|
+
* verbatim on success; throws a clear error otherwise. The validated URL is
|
|
1899
|
+
* baked into `manifest.hook_attributes.url` as-is — KiCI adds no ingress and
|
|
1900
|
+
* does not receive events at it; the operator owns delivery.
|
|
1901
|
+
*/
|
|
1902
|
+
function validateWebhookUrl(value) {
|
|
1903
|
+
let url;
|
|
1904
|
+
try {
|
|
1905
|
+
url = new URL(value);
|
|
1906
|
+
} catch {
|
|
1907
|
+
throw new Error(`--webhook-url must be a valid absolute URL: ${value}`);
|
|
1908
|
+
}
|
|
1909
|
+
if (url.protocol !== "https:") throw new Error(`--webhook-url must be an https:// URL (got ${url.protocol}//…)`);
|
|
1910
|
+
return value;
|
|
1911
|
+
}
|
|
1912
|
+
function buildGithubAppManifest(input) {
|
|
1913
|
+
return {
|
|
1914
|
+
name: input.name,
|
|
1915
|
+
url: "https://kici.dev",
|
|
1916
|
+
hook_attributes: {
|
|
1917
|
+
url: input.webhookUrl,
|
|
1918
|
+
active: true
|
|
1919
|
+
},
|
|
1920
|
+
redirect_url: input.redirectUrl,
|
|
1921
|
+
...input.setupUrl ? { setup_url: input.setupUrl } : {},
|
|
1922
|
+
public: false,
|
|
1923
|
+
default_permissions: {
|
|
1924
|
+
contents: "read",
|
|
1925
|
+
metadata: "read",
|
|
1926
|
+
pull_requests: "read",
|
|
1927
|
+
checks: "write",
|
|
1928
|
+
members: "read"
|
|
1929
|
+
},
|
|
1930
|
+
default_events: [
|
|
1931
|
+
"push",
|
|
1932
|
+
"pull_request",
|
|
1933
|
+
"check_run",
|
|
1934
|
+
"check_suite"
|
|
1935
|
+
]
|
|
1936
|
+
};
|
|
1937
|
+
}
|
|
1938
|
+
/**
|
|
1939
|
+
* Exchange the short-lived manifest `code` for the App's id, private key, and
|
|
1940
|
+
* webhook secret. Runs server-to-server directly against GitHub — the private
|
|
1941
|
+
* key never transits the Platform.
|
|
1942
|
+
*/
|
|
1943
|
+
async function convertManifestCode(code, deps = {}) {
|
|
1944
|
+
const { data } = await (deps.octokit ?? new Octokit()).request("POST /app-manifests/{code}/conversions", { code });
|
|
1945
|
+
const d = data;
|
|
1946
|
+
if (!d.webhook_secret) throw new Error("GitHub returned no webhook secret for the new App — cannot verify inbound events. Re-run the setup, or configure a webhook secret manually with source update.");
|
|
1947
|
+
return {
|
|
1948
|
+
appId: String(d.id),
|
|
1949
|
+
slug: d.slug,
|
|
1950
|
+
name: d.name,
|
|
1951
|
+
privateKey: d.pem,
|
|
1952
|
+
webhookSecret: d.webhook_secret,
|
|
1953
|
+
clientId: d.client_id,
|
|
1954
|
+
clientSecret: d.client_secret,
|
|
1955
|
+
htmlUrl: d.html_url
|
|
1956
|
+
};
|
|
1957
|
+
}
|
|
1958
|
+
function appOctokitFor(creds) {
|
|
1959
|
+
return new Octokit({
|
|
1960
|
+
authStrategy: createAppAuth,
|
|
1961
|
+
auth: {
|
|
1962
|
+
appId: creds.appId,
|
|
1963
|
+
privateKey: creds.privateKey
|
|
1964
|
+
}
|
|
1965
|
+
});
|
|
1966
|
+
}
|
|
1967
|
+
/**
|
|
1968
|
+
* Poll GitHub (as the App, via a JWT) until at least one installation exists,
|
|
1969
|
+
* returning the first installation's id + account login. Throws on timeout.
|
|
1970
|
+
*/
|
|
1971
|
+
async function waitForInstallation(creds, opts) {
|
|
1972
|
+
const now = opts.now ?? Date.now;
|
|
1973
|
+
const octokit = opts.appOctokit ?? appOctokitFor(creds);
|
|
1974
|
+
const deadline = now() + opts.timeoutMs;
|
|
1975
|
+
for (;;) {
|
|
1976
|
+
const { data } = await octokit.request("GET /app/installations", { per_page: 1 });
|
|
1977
|
+
const installs = data;
|
|
1978
|
+
if (Array.isArray(installs) && installs.length > 0) return {
|
|
1979
|
+
installationId: installs[0].id,
|
|
1980
|
+
accountLogin: installs[0].account?.login ?? ""
|
|
1981
|
+
};
|
|
1982
|
+
if (now() >= deadline) throw new Error("Timed out waiting for the GitHub App to be installed");
|
|
1983
|
+
await new Promise((r) => setTimeout(r, opts.pollMs));
|
|
1984
|
+
}
|
|
1985
|
+
}
|
|
1986
|
+
/**
|
|
1987
|
+
* Mint an installation token from the captured private key and confirm the App
|
|
1988
|
+
* can reach repos — proves the key works end-to-end (the same path the agent's
|
|
1989
|
+
* clone uses at runtime).
|
|
1990
|
+
*/
|
|
1991
|
+
async function verifyRepoAccess(creds, installationId, deps = {}) {
|
|
1992
|
+
const { data } = await (deps.octokit ?? createInstallationOctokit(creds, installationId)).request("GET /installation/repositories", { per_page: 1 });
|
|
1993
|
+
return { repoCount: data.total_count };
|
|
1994
|
+
}
|
|
1995
|
+
//#endregion
|
|
1996
|
+
//#region src/providers/github/manifest-form.ts
|
|
1997
|
+
/**
|
|
1998
|
+
* GitHub's create-from-manifest flow is an HTML form POST (the `manifest`
|
|
1999
|
+
* JSON cannot ride in a query string). These helpers render the auto-submitting
|
|
2000
|
+
* form and the headless code-display page. Pure string builders — no IO — so
|
|
2001
|
+
* both the CLI loopback server and the static marketing-site page can reuse the
|
|
2002
|
+
* same shapes.
|
|
2003
|
+
*/
|
|
2004
|
+
function escapeHtml(s) {
|
|
2005
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
2006
|
+
}
|
|
2007
|
+
function manifestCreateUrl(githubOrg) {
|
|
2008
|
+
return githubOrg ? `https://github.com/organizations/${githubOrg}/settings/apps/new` : "https://github.com/settings/apps/new";
|
|
2009
|
+
}
|
|
2010
|
+
function renderManifestFormHtml(opts) {
|
|
2011
|
+
return `<!doctype html><html><head><meta charset="utf-8"><title>Create your KiCI GitHub App</title></head>
|
|
2012
|
+
<body>
|
|
2013
|
+
<p>Redirecting you to GitHub to create your KiCI GitHub App…</p>
|
|
2014
|
+
<form id="f" method="post" action="${escapeHtml(`${opts.createUrl}?state=${encodeURIComponent(opts.state)}`)}">
|
|
2015
|
+
<input type="hidden" name="manifest" value="${escapeHtml(opts.manifestJson)}">
|
|
2016
|
+
<noscript><button type="submit">Continue to GitHub</button></noscript>
|
|
2017
|
+
</form>
|
|
2018
|
+
<script>document.getElementById('f').submit();<\/script>
|
|
2019
|
+
</body></html>`;
|
|
2020
|
+
}
|
|
2021
|
+
function renderCodeDisplayHtml(code) {
|
|
2022
|
+
return `<!doctype html><html><head><meta charset="utf-8"><title>KiCI — copy your setup code</title></head>
|
|
2023
|
+
<body>
|
|
2024
|
+
<p>App created. Copy this code back into the <code>kici-admin</code> prompt:</p>
|
|
2025
|
+
<pre id="c" style="font-size:1.2rem;padding:12px;border:1px solid #ccc">${escapeHtml(code)}</pre>
|
|
2026
|
+
<button onclick="navigator.clipboard.writeText(document.getElementById('c').textContent)">Copy</button>
|
|
2027
|
+
</body></html>`;
|
|
2028
|
+
}
|
|
2029
|
+
//#endregion
|
|
2030
|
+
//#region src/cli/loopback-callback.ts
|
|
2031
|
+
function startManifestLoopback(opts) {
|
|
2032
|
+
return new Promise((resolveServer) => {
|
|
2033
|
+
let outcome;
|
|
2034
|
+
const waiters = [];
|
|
2035
|
+
const resolveCode = (value) => {
|
|
2036
|
+
if (outcome) return;
|
|
2037
|
+
outcome = {
|
|
2038
|
+
ok: true,
|
|
2039
|
+
value
|
|
2040
|
+
};
|
|
2041
|
+
for (const w of waiters) w.resolve(value);
|
|
2042
|
+
};
|
|
2043
|
+
const rejectCode = (error) => {
|
|
2044
|
+
if (outcome) return;
|
|
2045
|
+
outcome = {
|
|
2046
|
+
ok: false,
|
|
2047
|
+
error
|
|
2048
|
+
};
|
|
2049
|
+
for (const w of waiters) w.reject(error);
|
|
2050
|
+
};
|
|
2051
|
+
const server = createServer((req, res) => {
|
|
2052
|
+
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
2053
|
+
if (url.pathname === "/cb") {
|
|
2054
|
+
const code = url.searchParams.get("code") ?? "";
|
|
2055
|
+
const state = url.searchParams.get("state") ?? "";
|
|
2056
|
+
res.writeHead(200, { "content-type": "text/html" });
|
|
2057
|
+
res.end(renderCodeDisplayHtml(code));
|
|
2058
|
+
if (state !== opts.state) rejectCode(/* @__PURE__ */ new Error("OAuth state mismatch — aborting"));
|
|
2059
|
+
else resolveCode({
|
|
2060
|
+
code,
|
|
2061
|
+
state
|
|
2062
|
+
});
|
|
2063
|
+
return;
|
|
2064
|
+
}
|
|
2065
|
+
res.writeHead(200, { "content-type": "text/html" });
|
|
2066
|
+
res.end(renderManifestFormHtml({
|
|
2067
|
+
createUrl: opts.createUrl,
|
|
2068
|
+
state: opts.state,
|
|
2069
|
+
manifestJson: opts.manifestJson
|
|
2070
|
+
}));
|
|
2071
|
+
});
|
|
2072
|
+
server.on("clientError", () => {});
|
|
2073
|
+
server.listen(0, "127.0.0.1", () => {
|
|
2074
|
+
const base = `http://127.0.0.1:${server.address().port}`;
|
|
2075
|
+
resolveServer({
|
|
2076
|
+
redirectUrl: `${base}/cb`,
|
|
2077
|
+
formUrl: `${base}/`,
|
|
2078
|
+
waitForCode: (timeoutMs) => new Promise((resolve, reject) => {
|
|
2079
|
+
if (outcome) {
|
|
2080
|
+
outcome.ok ? resolve(outcome.value) : reject(outcome.error);
|
|
2081
|
+
return;
|
|
2082
|
+
}
|
|
2083
|
+
const timer = setTimeout(() => reject(/* @__PURE__ */ new Error("Timed out waiting for GitHub callback")), timeoutMs);
|
|
2084
|
+
waiters.push({
|
|
2085
|
+
resolve: (v) => {
|
|
2086
|
+
clearTimeout(timer);
|
|
2087
|
+
resolve(v);
|
|
2088
|
+
},
|
|
2089
|
+
reject: (e) => {
|
|
2090
|
+
clearTimeout(timer);
|
|
2091
|
+
reject(e);
|
|
2092
|
+
}
|
|
2093
|
+
});
|
|
2094
|
+
}),
|
|
2095
|
+
close: () => server.close()
|
|
2096
|
+
});
|
|
2097
|
+
});
|
|
2098
|
+
});
|
|
2099
|
+
}
|
|
2100
|
+
//#endregion
|
|
2101
|
+
//#region src/cli/open-browser.ts
|
|
2102
|
+
/**
|
|
2103
|
+
* Open a URL in the operator's default browser, best-effort. Used by the GitHub
|
|
2104
|
+
* App manifest setup flow to launch the create / install pages. Failure is
|
|
2105
|
+
* non-fatal — the caller always prints the URL too, so a headless host (or a
|
|
2106
|
+
* blocked launcher) just falls back to copy-paste.
|
|
2107
|
+
*
|
|
2108
|
+
* `KICI_BROWSER_CMD` overrides the launcher: `none` suppresses it entirely
|
|
2109
|
+
* (E2E / headless capture), any other value is run with `{url}` substituted —
|
|
2110
|
+
* mirroring the `kici login` convention.
|
|
2111
|
+
*/
|
|
2112
|
+
function openBrowserBestEffort(url) {
|
|
2113
|
+
const override = process.env.KICI_BROWSER_CMD;
|
|
2114
|
+
if (override === "none") return;
|
|
2115
|
+
if (override) {
|
|
2116
|
+
exec(override.replace("{url}", url), () => {});
|
|
2117
|
+
return;
|
|
2118
|
+
}
|
|
2119
|
+
exec(platform() === "darwin" ? `open ${JSON.stringify(url)}` : platform() === "win32" ? `start "" ${JSON.stringify(url)}` : `xdg-open ${JSON.stringify(url)}`, () => {});
|
|
2120
|
+
}
|
|
2121
|
+
//#endregion
|
|
2122
|
+
//#region src/cli/commands/source-manifest.ts
|
|
2123
|
+
/**
|
|
2124
|
+
* `kici-admin source add github --manifest` — the one-click GitHub App setup
|
|
2125
|
+
* flow. Drives GitHub's App Manifest flow end-to-end: builds a pre-configured
|
|
2126
|
+
* manifest (KiCI's exact permissions/events/webhook URL), hands it to GitHub via
|
|
2127
|
+
* an auto-submitting form, catches the returned short-lived setup code over a
|
|
2128
|
+
* localhost loopback (or copy-paste in --no-browser mode), exchanges it for the
|
|
2129
|
+
* App's id + private key + webhook secret, then reuses the existing source
|
|
2130
|
+
* storage + Platform-registration path.
|
|
2131
|
+
*
|
|
2132
|
+
* The private-key-bearing conversion happens entirely here on the orchestrator
|
|
2133
|
+
* host — it never transits the Platform (sovereignty invariant).
|
|
2134
|
+
*/
|
|
2135
|
+
/** Static marketing-site page the CLI points at in headless paste-code mode. */
|
|
2136
|
+
const STATIC_CALLBACK_URL = "https://kici.dev/gh-manifest-callback";
|
|
2137
|
+
/** How long to wait for the operator to click "Create" on GitHub. */
|
|
2138
|
+
const CREATE_TIMEOUT_MS = 5 * 6e4;
|
|
2139
|
+
/** How long to wait for the operator to install the App on at least one repo. */
|
|
2140
|
+
const INSTALL_TIMEOUT_MS = 5 * 6e4;
|
|
2141
|
+
const INSTALL_POLL_MS = 3e3;
|
|
2142
|
+
function defaultReadLine(prompt) {
|
|
2143
|
+
const rl = createInterface({
|
|
2144
|
+
input: process.stdin,
|
|
2145
|
+
output: process.stdout
|
|
2146
|
+
});
|
|
2147
|
+
return new Promise((resolve) => {
|
|
2148
|
+
rl.question(prompt, (answer) => {
|
|
2149
|
+
rl.close();
|
|
2150
|
+
resolve(answer.trim());
|
|
2151
|
+
});
|
|
2152
|
+
});
|
|
2153
|
+
}
|
|
2154
|
+
function defaultWriteRecoveryFile(appId, pem) {
|
|
2155
|
+
const file = join(mkdtempSync(join(tmpdir(), "kici-gh-app-")), `app-${appId}.private-key.pem`);
|
|
2156
|
+
writeFileSync(file, pem, { mode: 384 });
|
|
2157
|
+
return file;
|
|
2158
|
+
}
|
|
2159
|
+
const realManifestSetupDeps = {
|
|
2160
|
+
startLoopback: startManifestLoopback,
|
|
2161
|
+
openBrowser: openBrowserBestEffort,
|
|
2162
|
+
readLine: defaultReadLine,
|
|
2163
|
+
convert: (code) => convertManifestCode(code),
|
|
2164
|
+
waitForInstallation,
|
|
2165
|
+
verifyRepoAccess,
|
|
2166
|
+
writeRecoveryFile: defaultWriteRecoveryFile
|
|
2167
|
+
};
|
|
2168
|
+
/** Resolve the org-scoped webhook URL via the pre-flight route; abort if null. */
|
|
2169
|
+
async function resolveWebhookUrl(client) {
|
|
2170
|
+
const pre = await client.get("/api/v1/admin/sources/github-webhook-url");
|
|
2171
|
+
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.`);
|
|
2172
|
+
return pre.webhookUrl;
|
|
2173
|
+
}
|
|
2174
|
+
/**
|
|
2175
|
+
* Catch the short-lived setup code: over the loopback (browser mode) or by
|
|
2176
|
+
* printing the static-site URL and reading the pasted code (headless mode).
|
|
2177
|
+
*/
|
|
2178
|
+
async function captureCode(opts, deps, manifestJson, state, redirectUrl, createUrl, loopback) {
|
|
2179
|
+
if (opts.noBrowser) {
|
|
2180
|
+
const url = `${STATIC_CALLBACK_URL}#m=${Buffer.from(manifestJson, "utf-8").toString("base64url")}&state=${encodeURIComponent(state)}&createUrl=${encodeURIComponent(createUrl)}`;
|
|
2181
|
+
console.log("Open this URL in a browser to create your App:");
|
|
2182
|
+
console.log(` ${url}`);
|
|
2183
|
+
console.log("After clicking \"Create\", copy the setup code shown on the page.");
|
|
2184
|
+
const code = await deps.readLine("Paste the setup code here: ");
|
|
2185
|
+
if (!code) throw new Error("No setup code was provided");
|
|
2186
|
+
return code;
|
|
2187
|
+
}
|
|
2188
|
+
console.log("→ Opening GitHub to create your App…");
|
|
2189
|
+
await deps.openBrowser(loopback.formUrl);
|
|
2190
|
+
console.log(` (if your browser did not open, visit ${loopback.formUrl} )`);
|
|
2191
|
+
const { code } = await loopback.waitForCode(CREATE_TIMEOUT_MS);
|
|
2192
|
+
return code;
|
|
2193
|
+
}
|
|
2194
|
+
/** Store credentials + register; on failure, save the PEM so the App is not orphaned. */
|
|
2195
|
+
async function storeAndRegister(opts, client, deps, creds) {
|
|
2196
|
+
try {
|
|
2197
|
+
return await client.post("/api/v1/admin/sources", {
|
|
2198
|
+
provider: "github",
|
|
2199
|
+
name: creds.name,
|
|
2200
|
+
slug: creds.slug,
|
|
2201
|
+
appId: creds.appId,
|
|
2202
|
+
privateKey: creds.privateKey,
|
|
2203
|
+
webhookSecret: creds.webhookSecret
|
|
2204
|
+
});
|
|
2205
|
+
} catch (err) {
|
|
2206
|
+
const file = deps.writeRecoveryFile(creds.appId, creds.privateKey);
|
|
2207
|
+
console.error("");
|
|
2208
|
+
console.error("⚠ The GitHub App was created but storing it on the orchestrator failed.");
|
|
2209
|
+
console.error(` App id: ${creds.appId}`);
|
|
2210
|
+
console.error(` Private key saved to: ${file}`);
|
|
2211
|
+
console.error(" Recover with:");
|
|
2212
|
+
console.error(` kici-admin source add github --name "${opts.name}" --app-id ${creds.appId} --private-key @${file} --webhook-secret ${creds.webhookSecret}`);
|
|
2213
|
+
throw err;
|
|
2214
|
+
}
|
|
2215
|
+
}
|
|
2216
|
+
async function runGithubManifestSetup(opts, client, deps = realManifestSetupDeps) {
|
|
2217
|
+
const webhookUrl = opts.webhookUrl ? validateWebhookUrl(opts.webhookUrl) : await resolveWebhookUrl(client);
|
|
2218
|
+
const state = randomBytes(16).toString("hex");
|
|
2219
|
+
const createUrl = manifestCreateUrl(opts.githubOrg);
|
|
2220
|
+
const loopback = await deps.startLoopback({
|
|
2221
|
+
state,
|
|
2222
|
+
manifestJson: "{}",
|
|
2223
|
+
createUrl
|
|
2224
|
+
});
|
|
2225
|
+
try {
|
|
2226
|
+
const manifest = buildGithubAppManifest({
|
|
2227
|
+
name: opts.name,
|
|
2228
|
+
webhookUrl,
|
|
2229
|
+
redirectUrl: opts.noBrowser ? STATIC_CALLBACK_URL : loopback.redirectUrl
|
|
2230
|
+
});
|
|
2231
|
+
const manifestJson = JSON.stringify(manifest);
|
|
2232
|
+
loopback.close();
|
|
2233
|
+
const lb = await deps.startLoopback({
|
|
2234
|
+
state,
|
|
2235
|
+
manifestJson,
|
|
2236
|
+
createUrl
|
|
2237
|
+
});
|
|
2238
|
+
try {
|
|
2239
|
+
const code = await captureCode(opts, deps, manifestJson, state, lb.redirectUrl, createUrl, lb);
|
|
2240
|
+
const creds = await deps.convert(code);
|
|
2241
|
+
console.log(`→ ✓ App created (id ${creds.appId}), credentials captured`);
|
|
2242
|
+
const stored = await storeAndRegister(opts, client, deps, creds);
|
|
2243
|
+
console.log(`→ ✓ Stored on orchestrator (encrypted), registered as ${stored.routingKey}`);
|
|
2244
|
+
const installUrl = `https://github.com/apps/${creds.slug}/installations/new`;
|
|
2245
|
+
console.log(`→ Install the App on your repos: ${installUrl}`);
|
|
2246
|
+
if (!opts.noBrowser) await deps.openBrowser(installUrl);
|
|
2247
|
+
const { installationId, accountLogin } = await deps.waitForInstallation(creds, {
|
|
2248
|
+
timeoutMs: INSTALL_TIMEOUT_MS,
|
|
2249
|
+
pollMs: INSTALL_POLL_MS
|
|
2250
|
+
});
|
|
2251
|
+
console.log(`→ ✓ Installation detected (account ${accountLogin})`);
|
|
2252
|
+
const { repoCount } = await deps.verifyRepoAccess(creds, installationId);
|
|
2253
|
+
console.log(`→ ✓ Credentials verified (${repoCount} repositories reachable)`);
|
|
2254
|
+
console.log("");
|
|
2255
|
+
console.log(`GitHub App "${opts.name}" is live.`);
|
|
2256
|
+
console.log(` Webhook: ${webhookUrl}`);
|
|
2257
|
+
} finally {
|
|
2258
|
+
lb.close();
|
|
2259
|
+
}
|
|
2260
|
+
} finally {
|
|
2261
|
+
loopback.close();
|
|
2262
|
+
}
|
|
2263
|
+
}
|
|
2264
|
+
//#endregion
|
|
2265
|
+
export { AdminApiClient, DEFAULT_USER_CACHE_QUOTA_BYTES, DEFAULT_USER_CACHE_TTL_MS, DepCache, JoinTokenManager, PeerAuthCoordinator, PeerCredentialStore, S3CacheStorage, SourceCache, UserCache, createCacheStorage, createJoinTokenManagerFromUrl, createPeerCredentialStoreFromUrl, runGithubManifestSetup };
|
|
1112
2266
|
|
|
1113
2267
|
//# sourceMappingURL=index.js.map
|