@foldrun/core 2.6.0 → 2.7.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/src/capture.js +39 -1
- package/dist/src/egress.d.ts +16 -0
- package/dist/src/run-container.d.ts +1 -1
- package/dist/src/run-container.js +30 -0
- package/dist/src/runner.js +74 -3
- package/dist/src/secret-files.js +23 -2
- package/dist/src/secrets.d.ts +62 -2
- package/dist/src/secrets.js +206 -20
- package/package.json +1 -1
- package/src/capture.ts +33 -1
- package/src/egress.ts +15 -1
- package/src/run-container.ts +30 -0
- package/src/runner.ts +71 -3
- package/src/secret-files.ts +22 -2
- package/src/secrets.ts +237 -23
package/src/secrets.ts
CHANGED
|
@@ -88,7 +88,7 @@ function secretsFile(tenant: string, workspace?: string) {
|
|
|
88
88
|
/** How the stored value is used at run time — "oauth2"/"service-account"
|
|
89
89
|
* are refreshed into live tokens, "file"/"ssh"/"api" are materialised into
|
|
90
90
|
* the run's scratch. Plain values have no kind. */
|
|
91
|
-
export type SecretKind = "oauth2" | "service-account" | "file" | "ssh" | "api";
|
|
91
|
+
export type SecretKind = "oauth2" | "service-account" | "file" | "ssh" | "api" | "client-cert" | "identity";
|
|
92
92
|
|
|
93
93
|
interface StoredSecret {
|
|
94
94
|
iv: string;
|
|
@@ -236,6 +236,11 @@ export function setSecretSites(tenant: string, name: string, sites: string[], wo
|
|
|
236
236
|
if (clean.length && (rec.kind === "file" || rec.kind === "ssh" || rec.kind === "api")) {
|
|
237
237
|
throw new Error(`${name} is a ${rec.kind} secret, unpacked inside the sandbox — only a value sent in a request can be bound to sites`);
|
|
238
238
|
}
|
|
239
|
+
// A client certificate is presented by the egress proxy to the exact
|
|
240
|
+
// hosts named — never to a whole domain on a wildcard.
|
|
241
|
+
if (rec.kind === "client-cert" && clean.some((s) => s.startsWith("*."))) {
|
|
242
|
+
throw new Error(`${name} is a client certificate — bind it to exact hosts, not a wildcard`);
|
|
243
|
+
}
|
|
239
244
|
if (clean.length) rec.sites = clean;
|
|
240
245
|
else delete rec.sites;
|
|
241
246
|
return all;
|
|
@@ -545,7 +550,9 @@ export function rotateMasterKey(
|
|
|
545
550
|
export interface OAuth2Config {
|
|
546
551
|
token_url: string;
|
|
547
552
|
client_id: string;
|
|
548
|
-
|
|
553
|
+
/** Absent for a public client (an app with no secret, connected with
|
|
554
|
+
* PKCE): its refresh sends client_id and refresh_token only. */
|
|
555
|
+
client_secret?: string;
|
|
549
556
|
/** Required for the refresh grant; absent for client_credentials. */
|
|
550
557
|
refresh_token?: string;
|
|
551
558
|
/** "refresh_token" (default — user-consented) or "client_credentials"
|
|
@@ -573,9 +580,14 @@ export function setOAuth2Secret(
|
|
|
573
580
|
config: OAuth2Config,
|
|
574
581
|
workspace?: string,
|
|
575
582
|
) {
|
|
576
|
-
for (const field of ["token_url", "client_id"
|
|
583
|
+
for (const field of ["token_url", "client_id"] as const) {
|
|
577
584
|
if (!config[field]?.trim()) throw new Error(`oauth2 secret needs ${field}`);
|
|
578
585
|
}
|
|
586
|
+
// A public client (PKCE, no secret) refreshes with its client id alone;
|
|
587
|
+
// client credentials are nothing without the secret.
|
|
588
|
+
if (config.grant_type === "client_credentials" && !config.client_secret?.trim()) {
|
|
589
|
+
throw new Error("oauth2 secret needs client_secret for client_credentials");
|
|
590
|
+
}
|
|
579
591
|
if (config.grant_type !== "client_credentials" && !config.refresh_token?.trim()) {
|
|
580
592
|
throw new Error("oauth2 secret needs refresh_token (or grant_type: client_credentials)");
|
|
581
593
|
}
|
|
@@ -597,11 +609,67 @@ export function isOAuth2Value(value: string): boolean {
|
|
|
597
609
|
const tokenCache = new Map<string, { token: string; expiresAt: number }>();
|
|
598
610
|
const inFlight = new Map<string, Promise<string>>();
|
|
599
611
|
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
612
|
+
/**
|
|
613
|
+
* The cache key for a live token: SHA-256 over the kind, the account and the
|
|
614
|
+
* WHOLE stored config — credential material included (client secret, refresh
|
|
615
|
+
* token, private key, subject, scopes, token URL). A caller can only hit an
|
|
616
|
+
* entry it could have minted itself. Both maps are process-wide, so a key of
|
|
617
|
+
* names alone (`sa:${name}:${issuer}`, `${name}:${client_id}:…`) once let a
|
|
618
|
+
* second account with the same names be handed the first one's token
|
|
619
|
+
* (Codex debate, 22 Oct 2026).
|
|
620
|
+
*/
|
|
621
|
+
export function tokenCacheKey(kind: "oauth2" | "sa", storedValue: string, tenant?: string): string {
|
|
622
|
+
return crypto.createHash("sha256").update(JSON.stringify([kind, tenant ?? "", storedValue])).digest("hex");
|
|
623
|
+
}
|
|
603
624
|
|
|
604
|
-
|
|
625
|
+
/**
|
|
626
|
+
* A provider that rotates refresh tokens (RFC 9700 §2.2.2) answered a refresh
|
|
627
|
+
* with a new one: write it back where the old one is stored — the workspace
|
|
628
|
+
* copy, else the account's — but only while that copy still holds the value
|
|
629
|
+
* this refresh used (compare-and-set), so two refreshes racing cannot put an
|
|
630
|
+
* older token over a newer one. Returns whether a copy was replaced.
|
|
631
|
+
*/
|
|
632
|
+
export function replaceOAuth2RefreshToken(tenant: string, name: string, workspace: string | undefined, usedValue: string, refreshToken: string): boolean {
|
|
633
|
+
if (!isOAuth2Value(usedValue)) return false;
|
|
634
|
+
const config = JSON.parse(usedValue.slice(OAUTH2_PREFIX.length)) as OAuth2Config;
|
|
635
|
+
const next = OAUTH2_PREFIX + JSON.stringify({ ...config, refresh_token: refreshToken });
|
|
636
|
+
for (const scope of workspace ? [workspace, undefined] : [undefined]) {
|
|
637
|
+
let replaced = false;
|
|
638
|
+
mutateVaultFile(secretsFile(tenant, scope), (all) => {
|
|
639
|
+
const rec = all[name];
|
|
640
|
+
if (rec && decrypt(tenant, rec) === usedValue) {
|
|
641
|
+
all[name] = { ...rec, ...encryptValue(tenant, next), updatedAt: new Date().toISOString() };
|
|
642
|
+
replaced = true;
|
|
643
|
+
}
|
|
644
|
+
return all;
|
|
645
|
+
});
|
|
646
|
+
if (replaced) return true;
|
|
647
|
+
}
|
|
648
|
+
return false;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
/** An oauth2 refresh's outcome: the access token and, when the provider
|
|
652
|
+
* rotated it, the new refresh token — kept with the cache entry so every
|
|
653
|
+
* copy of the old credential (another secret name, another scope) that is
|
|
654
|
+
* served this token also gets the new refresh token written back. */
|
|
655
|
+
interface OAuthLive { token: string; expiresAt: number; rotated?: string }
|
|
656
|
+
const oauthCache = new Map<string, OAuthLive>();
|
|
657
|
+
const oauthFlight = new Map<string, Promise<OAuthLive>>();
|
|
658
|
+
|
|
659
|
+
/** Cache an access token, sweeping expired entries as it goes: a rotating
|
|
660
|
+
* provider changes the stored config — and so the key — on every refresh,
|
|
661
|
+
* and expired entries would otherwise pile up for the life of the process. */
|
|
662
|
+
function cacheOAuth(key: string, live: OAuthLive): void {
|
|
663
|
+
const now = Date.now();
|
|
664
|
+
for (const [k, v] of oauthCache) if (v.expiresAt <= now) oauthCache.delete(k);
|
|
665
|
+
oauthCache.set(key, live);
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
async function exchange(config: OAuth2Config, cacheKey: string): Promise<OAuthLive> {
|
|
669
|
+
const cached = oauthCache.get(cacheKey);
|
|
670
|
+
if (cached && cached.expiresAt > Date.now()) return cached;
|
|
671
|
+
|
|
672
|
+
const pending = oauthFlight.get(cacheKey);
|
|
605
673
|
if (pending) return pending;
|
|
606
674
|
|
|
607
675
|
const flight = (async () => {
|
|
@@ -609,7 +677,7 @@ async function exchange(config: OAuth2Config, cacheKey: string): Promise<string>
|
|
|
609
677
|
grant_type: config.grant_type ?? "refresh_token",
|
|
610
678
|
...(config.grant_type === "client_credentials" ? {} : { refresh_token: config.refresh_token! }),
|
|
611
679
|
client_id: config.client_id,
|
|
612
|
-
client_secret: config.client_secret,
|
|
680
|
+
...(config.client_secret ? { client_secret: config.client_secret } : {}),
|
|
613
681
|
...(config.extra ?? {}),
|
|
614
682
|
});
|
|
615
683
|
// A network blip is retried; a provider's answer is not. "fetch failed"
|
|
@@ -639,6 +707,7 @@ async function exchange(config: OAuth2Config, cacheKey: string): Promise<string>
|
|
|
639
707
|
if (!res) throw lastNetwork instanceof Error ? lastNetwork : new Error(String(lastNetwork));
|
|
640
708
|
const payload = (await res.json().catch(() => ({}))) as {
|
|
641
709
|
access_token?: string;
|
|
710
|
+
refresh_token?: string;
|
|
642
711
|
expires_in?: number;
|
|
643
712
|
error?: string;
|
|
644
713
|
error_description?: string;
|
|
@@ -649,15 +718,21 @@ async function exchange(config: OAuth2Config, cacheKey: string): Promise<string>
|
|
|
649
718
|
);
|
|
650
719
|
}
|
|
651
720
|
const ttl = Math.max(60, (payload.expires_in ?? 3600) - 60) * 1000;
|
|
652
|
-
|
|
653
|
-
|
|
721
|
+
// A rotated refresh token replaces the old one, which the provider may
|
|
722
|
+
// now refuse; dropping it broke the next refresh for rotating providers.
|
|
723
|
+
const rotated = payload.refresh_token && config.grant_type !== "client_credentials" && payload.refresh_token !== config.refresh_token
|
|
724
|
+
? payload.refresh_token
|
|
725
|
+
: undefined;
|
|
726
|
+
const live: OAuthLive = { token: payload.access_token, expiresAt: Date.now() + ttl, ...(rotated ? { rotated } : {}) };
|
|
727
|
+
cacheOAuth(cacheKey, live);
|
|
728
|
+
return live;
|
|
654
729
|
})();
|
|
655
730
|
|
|
656
|
-
|
|
731
|
+
oauthFlight.set(cacheKey, flight);
|
|
657
732
|
try {
|
|
658
733
|
return await flight;
|
|
659
734
|
} finally {
|
|
660
|
-
|
|
735
|
+
oauthFlight.delete(cacheKey);
|
|
661
736
|
}
|
|
662
737
|
}
|
|
663
738
|
|
|
@@ -685,13 +760,21 @@ export async function materializeSecrets(
|
|
|
685
760
|
throw new Error(`secret ${name}: stored oauth2 config is unreadable`);
|
|
686
761
|
}
|
|
687
762
|
try {
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
763
|
+
const live = await exchange(config, tokenCacheKey("oauth2", value, ctx?.tenant));
|
|
764
|
+
out[name] = live.token;
|
|
765
|
+
if (live.rotated) {
|
|
766
|
+
// This caller's copy takes the new refresh token (compare-and-set:
|
|
767
|
+
// a copy already updated, or changed since, is left alone), and the
|
|
768
|
+
// access token is cached under the config as it is now stored, so
|
|
769
|
+
// the next run does not refresh again.
|
|
770
|
+
const next = OAUTH2_PREFIX + JSON.stringify({ ...config, refresh_token: live.rotated });
|
|
771
|
+
try {
|
|
772
|
+
if (ctx) replaceOAuth2RefreshToken(ctx.tenant, name, ctx.workspace, value, live.rotated);
|
|
773
|
+
} catch {
|
|
774
|
+
// The access token is good either way; the next refresh says if not.
|
|
775
|
+
}
|
|
776
|
+
cacheOAuth(tokenCacheKey("oauth2", next, ctx?.tenant), { token: live.token, expiresAt: live.expiresAt });
|
|
777
|
+
}
|
|
695
778
|
} catch (err) {
|
|
696
779
|
const message = err instanceof Error ? err.message : String(err);
|
|
697
780
|
const link = ctx ? await platform.noteOAuthRefresh(ctx, name, message).catch(() => undefined) : undefined;
|
|
@@ -706,7 +789,7 @@ export async function materializeSecrets(
|
|
|
706
789
|
throw new Error(`secret ${name}: stored service account config is unreadable`);
|
|
707
790
|
}
|
|
708
791
|
try {
|
|
709
|
-
out[name] = await exchangeServiceAccount(config,
|
|
792
|
+
out[name] = await exchangeServiceAccount(config, tokenCacheKey("sa", value, ctx?.tenant));
|
|
710
793
|
} catch (err) {
|
|
711
794
|
throw new Error(`secret ${name}: ${err instanceof Error ? err.message : String(err)}`);
|
|
712
795
|
}
|
|
@@ -737,6 +820,10 @@ export interface ServiceAccountConfig {
|
|
|
737
820
|
scope: string; // space-separated
|
|
738
821
|
/** JWT audience — usually the same as token_url. */
|
|
739
822
|
audience?: string;
|
|
823
|
+
/** The JWT bearer grant's subject (RFC 7523 §3): the user the account acts
|
|
824
|
+
* as, where the provider allows it — Google's domain-wide delegation, once
|
|
825
|
+
* a Workspace admin grants it. Absent, the account acts as itself. */
|
|
826
|
+
subject?: string;
|
|
740
827
|
}
|
|
741
828
|
|
|
742
829
|
export function setServiceAccountSecret(
|
|
@@ -751,7 +838,12 @@ export function setServiceAccountSecret(
|
|
|
751
838
|
if (!/BEGIN [A-Z ]{0,40}PRIVATE KEY/.test(config.private_key)) {
|
|
752
839
|
throw new Error("private_key must be a PEM key (from the service account JSON)");
|
|
753
840
|
}
|
|
754
|
-
|
|
841
|
+
if (config.subject !== undefined) {
|
|
842
|
+
if (typeof config.subject !== "string" || !config.subject.trim()) throw new Error("subject is the user to act as — leave it out to act as the account itself");
|
|
843
|
+
if (config.subject.length > 320 || /[\u0000-\u001f\u007f\s]/.test(config.subject.trim())) throw new Error("subject is one identifier, no spaces or control characters");
|
|
844
|
+
}
|
|
845
|
+
const stored: ServiceAccountConfig = { ...config, ...(config.subject !== undefined ? { subject: config.subject.trim() } : {}) };
|
|
846
|
+
setSecret(tenant, name, SERVICE_ACCOUNT_PREFIX + JSON.stringify(stored), workspace, "service-account");
|
|
755
847
|
}
|
|
756
848
|
|
|
757
849
|
function base64url(input: Buffer | string): string {
|
|
@@ -770,7 +862,9 @@ async function exchangeServiceAccount(config: ServiceAccountConfig, cacheKey: st
|
|
|
770
862
|
const claim = base64url(
|
|
771
863
|
JSON.stringify({
|
|
772
864
|
iss: config.issuer,
|
|
773
|
-
|
|
865
|
+
// The subject is part of the stored config, so of the cache key: two
|
|
866
|
+
// subjects never share a token (tokenCacheKey).
|
|
867
|
+
sub: config.subject || config.issuer,
|
|
774
868
|
scope: config.scope,
|
|
775
869
|
aud: config.audience ?? config.token_url,
|
|
776
870
|
iat: now,
|
|
@@ -898,14 +992,134 @@ export function sshConfigOf(value: string): SshConfig {
|
|
|
898
992
|
// never appears in the bash command at all.
|
|
899
993
|
|
|
900
994
|
const API_PREFIX = "@api ";
|
|
995
|
+
const CLIENT_CERT_PREFIX = "@client-cert ";
|
|
996
|
+
|
|
997
|
+
/** A client certificate for mutual TLS: presented by the egress proxy, on its
|
|
998
|
+
* own connection, to the exact hosts the secret is bound to — the sandbox
|
|
999
|
+
* never holds the key. `export` puts it in the sandbox as files instead,
|
|
1000
|
+
* only when asked for (a step with no egress proxy). */
|
|
1001
|
+
export interface ClientCertConfig {
|
|
1002
|
+
/** PEM certificate, chain included. */
|
|
1003
|
+
cert: string;
|
|
1004
|
+
/** PEM private key. */
|
|
1005
|
+
key: string;
|
|
1006
|
+
passphrase?: string;
|
|
1007
|
+
export?: boolean;
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
export function setClientCertSecret(tenant: string, name: string, config: ClientCertConfig, workspace?: string) {
|
|
1011
|
+
if (!/-----BEGIN CERTIFICATE-----/.test(config.cert ?? "")) throw new Error("cert must be a PEM certificate");
|
|
1012
|
+
if (!/-----BEGIN [A-Z ]{0,40}PRIVATE KEY-----/.test(config.key ?? "")) throw new Error("key must be a PEM private key");
|
|
1013
|
+
// The pair must match: a certificate presented with another key's proof
|
|
1014
|
+
// fails at the server, after the request has gone.
|
|
1015
|
+
let matches = false;
|
|
1016
|
+
try {
|
|
1017
|
+
const key = crypto.createPrivateKey({ key: config.key, ...(config.passphrase ? { passphrase: config.passphrase } : {}) });
|
|
1018
|
+
matches = new crypto.X509Certificate(config.cert).checkPrivateKey(key);
|
|
1019
|
+
} catch (err) {
|
|
1020
|
+
throw new Error(`the certificate or key cannot be read: ${err instanceof Error ? err.message : String(err)}`);
|
|
1021
|
+
}
|
|
1022
|
+
if (!matches) throw new Error("the key does not belong to the certificate");
|
|
1023
|
+
|
|
1024
|
+
const stored: ClientCertConfig = {
|
|
1025
|
+
cert: config.cert,
|
|
1026
|
+
key: config.key,
|
|
1027
|
+
...(config.passphrase ? { passphrase: config.passphrase } : {}),
|
|
1028
|
+
...(config.export ? { export: true } : {}),
|
|
1029
|
+
};
|
|
1030
|
+
if (!SECRET_NAME.test(name)) throw new Error(`secret name "${name}" must be UPPER_SNAKE_CASE`);
|
|
1031
|
+
const sealed = encryptValue(tenant, CLIENT_CERT_PREFIX + JSON.stringify(stored));
|
|
1032
|
+
// Written under the vault's own lock, with the binding checked in the same
|
|
1033
|
+
// step: a value set over a secret keeps its site binding, and a wildcard
|
|
1034
|
+
// one would present the certificate to a whole domain.
|
|
1035
|
+
mutateVaultFile(secretsFile(tenant, workspace), (all) => {
|
|
1036
|
+
const sites = all[name]?.sites;
|
|
1037
|
+
if (sites?.some((site) => site.startsWith("*."))) {
|
|
1038
|
+
throw new Error(`${name} is bound to ${sites.join(", ")} — a client certificate is bound to exact hosts; unbind the wildcard first`);
|
|
1039
|
+
}
|
|
1040
|
+
all[name] = { ...sealed, updatedAt: new Date().toISOString(), kind: "client-cert", ...(sites?.length ? { sites } : {}) };
|
|
1041
|
+
return all;
|
|
1042
|
+
});
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
const IDENTITY_PREFIX = "@identity ";
|
|
1046
|
+
|
|
1047
|
+
/** A keyless cloud identity: no credential at all, only the audience the
|
|
1048
|
+
* platform's signed tokens are for. A step that declares it gets a token
|
|
1049
|
+
* file the sandbox driver keeps fresh through the egress proxy. */
|
|
1050
|
+
export interface IdentityConfig {
|
|
1051
|
+
audience: string;
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
export function setIdentitySecret(tenant: string, name: string, config: IdentityConfig, workspace?: string) {
|
|
1055
|
+
const audience = typeof config.audience === "string" ? config.audience.trim() : "";
|
|
1056
|
+
if (!audience) throw new Error("an identity needs an audience — the value your cloud's trust expects in aud");
|
|
1057
|
+
if (audience.length > 600 || /[\u0000-\u001f\u007f]/.test(audience)) throw new Error("audience is one value, at most 600 characters, no control characters");
|
|
1058
|
+
setSecret(tenant, name, IDENTITY_PREFIX + JSON.stringify({ audience }), workspace, "identity");
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
export function isIdentityValue(value: string): boolean {
|
|
1062
|
+
return value.startsWith(IDENTITY_PREFIX);
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
export function identityOf(value: string): IdentityConfig {
|
|
1066
|
+
return JSON.parse(value.slice(IDENTITY_PREFIX.length));
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
export function isClientCertValue(value: string): boolean {
|
|
1070
|
+
return value.startsWith(CLIENT_CERT_PREFIX);
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
/** An environment without the client certificates that were not saved with
|
|
1074
|
+
* `export`: those are the egress proxy's to present, never a program's. */
|
|
1075
|
+
export function withoutHeldCerts(env: Record<string, string>): Record<string, string> {
|
|
1076
|
+
return Object.fromEntries(Object.entries(env).filter(([, v]) => typeof v !== "string" || !isClientCertValue(v) || clientCertOf(v).export === true));
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
export function clientCertOf(value: string): ClientCertConfig {
|
|
1080
|
+
return JSON.parse(value.slice(CLIENT_CERT_PREFIX.length));
|
|
1081
|
+
}
|
|
901
1082
|
|
|
902
1083
|
export interface ApiConfig {
|
|
903
1084
|
base_url?: string;
|
|
904
1085
|
headers: Record<string, string>;
|
|
1086
|
+
/** Username and password for HTTP Basic (RFC 7617): stored as the
|
|
1087
|
+
* `Authorization: Basic …` header they make, never kept apart. */
|
|
1088
|
+
basic?: { username: string; password: string };
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
/** `Authorization: Basic …` from a username and password (RFC 7617 §2):
|
|
1092
|
+
* UTF-8, no colon in the username, no control characters in either. */
|
|
1093
|
+
export function basicAuthorization(username: string, password: string): string {
|
|
1094
|
+
if (typeof username !== "string" || typeof password !== "string") throw new Error("basic needs username and password");
|
|
1095
|
+
if (!username) throw new Error("basic needs a username");
|
|
1096
|
+
if (username.includes(":")) throw new Error("a Basic username cannot contain a colon (RFC 7617)");
|
|
1097
|
+
if (/[\u0000-\u001f\u007f]/.test(username) || /[\u0000-\u001f\u007f]/.test(password)) throw new Error("Basic credentials cannot contain control characters");
|
|
1098
|
+
return `Basic ${Buffer.from(`${username}:${password}`, "utf8").toString("base64")}`;
|
|
905
1099
|
}
|
|
906
1100
|
|
|
907
1101
|
export function setApiSecret(tenant: string, name: string, config: ApiConfig, workspace?: string) {
|
|
908
|
-
|
|
1102
|
+
let all = { ...(config.headers ?? {}) };
|
|
1103
|
+
if (config.basic) {
|
|
1104
|
+
if (Object.keys(all).some((h) => h.toLowerCase() === "authorization")) {
|
|
1105
|
+
throw new Error("give either basic or an Authorization header, not both");
|
|
1106
|
+
}
|
|
1107
|
+
// Credentials in the clear over http are leaked ones: https, or loopback.
|
|
1108
|
+
const base = config.base_url?.trim();
|
|
1109
|
+
if (base) {
|
|
1110
|
+
let u: URL | null = null;
|
|
1111
|
+
try {
|
|
1112
|
+
u = new URL(base);
|
|
1113
|
+
} catch {
|
|
1114
|
+
u = null;
|
|
1115
|
+
}
|
|
1116
|
+
const loopback = u?.protocol === "http:" && (u.hostname === "localhost" || u.hostname === "127.0.0.1");
|
|
1117
|
+
if (!u || (u.protocol !== "https:" && !loopback)) throw new Error("Basic credentials need an https base_url");
|
|
1118
|
+
}
|
|
1119
|
+
all = { ...all, Authorization: basicAuthorization(config.basic.username, config.basic.password) };
|
|
1120
|
+
}
|
|
1121
|
+
config = { ...(config.base_url ? { base_url: config.base_url } : {}), headers: all };
|
|
1122
|
+
const headers = Object.entries(all);
|
|
909
1123
|
if (headers.length === 0) throw new Error("api secret needs at least one header");
|
|
910
1124
|
for (const [h, v] of headers) {
|
|
911
1125
|
if (!/^[A-Za-z0-9-]+$/.test(h)) throw new Error(`"${h}" is not a valid header name`);
|