@seekrit/mcp 0.6.1 → 0.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/index.js +1298 -44
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -8,6 +8,43 @@ import { homedir } from "node:os";
|
|
|
8
8
|
import { dirname, join, parse } from "node:path";
|
|
9
9
|
import { createInterface } from "node:readline";
|
|
10
10
|
import { Writable } from "node:stream";
|
|
11
|
+
//#region ../../packages/core/src/agent-policy.ts
|
|
12
|
+
/** A bare hostname: no scheme, no port, no path, no wildcard. */
|
|
13
|
+
const policyHostSchema = z.string().trim().min(1).max(253).toLowerCase().refine((h) => !/[:/\s*]/.test(h), { message: "host must be a bare hostname (no scheme, port, path, or wildcard)" }).refine((h) => /^[a-z0-9.-]+$/.test(h), { message: "host contains invalid characters" });
|
|
14
|
+
const policyMethodSchema = z.string().trim().toUpperCase().regex(/^[A-Z]{3,10}$/, "method must be an HTTP method name");
|
|
15
|
+
/**
|
|
16
|
+
* A path pattern. Must be absolute, because it is matched against a request
|
|
17
|
+
* path — a relative pattern is a mistake that would silently match nothing.
|
|
18
|
+
*/
|
|
19
|
+
const policyPathSchema = z.string().trim().min(1).max(512).startsWith("/", "path pattern must start with /").refine((p) => !p.includes("?"), { message: "path patterns match the path only, not the query" });
|
|
20
|
+
const policySecretNameSchema = z.string().trim().regex(/^[A-Za-z0-9_]+$/, "secret names are letters, digits, and underscores");
|
|
21
|
+
z.object({
|
|
22
|
+
host: policyHostSchema,
|
|
23
|
+
methods: z.array(policyMethodSchema).max(16).default([]),
|
|
24
|
+
paths: z.array(policyPathSchema).max(64).default([]),
|
|
25
|
+
allow: z.array(policySecretNameSchema).max(64).default([]),
|
|
26
|
+
label: z.string().trim().max(120).optional()
|
|
27
|
+
});
|
|
28
|
+
z.object({
|
|
29
|
+
/** `ap1.<body>.<signature>` — opaque to the server. */
|
|
30
|
+
bundle: z.string().min(16).max(256 * 1024) });
|
|
31
|
+
z.object({
|
|
32
|
+
name: z.string().trim().min(1).max(120),
|
|
33
|
+
slug: z.string().trim().min(1).max(64).regex(/^[a-z0-9][a-z0-9-]*$/, "slug is lowercase letters, digits, and dashes"),
|
|
34
|
+
/** The environment whose secrets this agent's policy may name. Optional. */
|
|
35
|
+
environmentId: z.string().trim().min(1).max(64).optional()
|
|
36
|
+
});
|
|
37
|
+
z.object({
|
|
38
|
+
name: z.string().trim().min(1).max(120).optional(),
|
|
39
|
+
enabled: z.boolean().optional(),
|
|
40
|
+
environmentId: z.string().trim().min(1).max(64).nullish()
|
|
41
|
+
});
|
|
42
|
+
z.object({
|
|
43
|
+
host: policyHostSchema,
|
|
44
|
+
method: policyMethodSchema,
|
|
45
|
+
path: z.string().trim().min(1).max(2048),
|
|
46
|
+
secret: policySecretNameSchema.optional()
|
|
47
|
+
});
|
|
11
48
|
/** All catalog keys as a runtime array (for iteration / zod enums). */
|
|
12
49
|
const ENTITLEMENT_KEYS = Object.keys({
|
|
13
50
|
"feature.kms": {
|
|
@@ -16,12 +53,24 @@ const ENTITLEMENT_KEYS = Object.keys({
|
|
|
16
53
|
description: "Client-side managed keys for application-layer encryption and signing.",
|
|
17
54
|
default: true
|
|
18
55
|
},
|
|
56
|
+
"feature.honey_tokens": {
|
|
57
|
+
kind: "feature",
|
|
58
|
+
label: "Honey tokens",
|
|
59
|
+
description: "Decoy credentials that alert the moment anyone tries to use them.",
|
|
60
|
+
default: true
|
|
61
|
+
},
|
|
19
62
|
"feature.leases": {
|
|
20
63
|
kind: "feature",
|
|
21
64
|
label: "Temporary access",
|
|
22
65
|
description: "Vault-style short-lived database and cloud credentials.",
|
|
23
66
|
default: true
|
|
24
67
|
},
|
|
68
|
+
"feature.rotation": {
|
|
69
|
+
kind: "feature",
|
|
70
|
+
label: "Secret rotation",
|
|
71
|
+
description: "Managed, scheduled rotation of stored credentials.",
|
|
72
|
+
default: true
|
|
73
|
+
},
|
|
25
74
|
"feature.log_sink": {
|
|
26
75
|
kind: "feature",
|
|
27
76
|
label: "Audit log export (SIEM)",
|
|
@@ -100,6 +149,12 @@ const ENTITLEMENT_KEYS = Object.keys({
|
|
|
100
149
|
description: "Maximum registered third-party sync destinations.",
|
|
101
150
|
default: null
|
|
102
151
|
},
|
|
152
|
+
"rotation.policies.max": {
|
|
153
|
+
kind: "limit",
|
|
154
|
+
label: "Rotation policies",
|
|
155
|
+
description: "Maximum secrets with managed rotation configured.",
|
|
156
|
+
default: null
|
|
157
|
+
},
|
|
103
158
|
members: {
|
|
104
159
|
kind: "metered",
|
|
105
160
|
label: "Members",
|
|
@@ -122,7 +177,7 @@ const PLAN_FAMILIES = {
|
|
|
122
177
|
id: "free",
|
|
123
178
|
name: "Free",
|
|
124
179
|
description: "Get started with the essentials.",
|
|
125
|
-
current:
|
|
180
|
+
current: 3,
|
|
126
181
|
hidden: false
|
|
127
182
|
},
|
|
128
183
|
team: {
|
|
@@ -512,7 +567,7 @@ const redisSha256VerifierSchema = z.string().regex(/^[0-9a-f]{64}$/, "must be a
|
|
|
512
567
|
*/
|
|
513
568
|
const awsRoleArnSchema = z.string().regex(/^arn:aws(?:-us-gov|-cn)?:iam::\d{12}:role\/[\w+=,.@/-]{1,512}$/, "must be an IAM role ARN (arn:aws:iam::<account>:role/<name>)");
|
|
514
569
|
/** An AWS region id, e.g. `us-east-1`, `eu-west-2`, `us-gov-west-1`. */
|
|
515
|
-
const awsRegionSchema = z.string().regex(/^[a-z]{2}(?:-[a-z]+)+-\d$/, "must be an AWS region id (e.g. us-east-1)");
|
|
570
|
+
const awsRegionSchema$1 = z.string().regex(/^[a-z]{2}(?:-[a-z]+)+-\d$/, "must be an AWS region id (e.g. us-east-1)");
|
|
516
571
|
/**
|
|
517
572
|
* An STS external id — the shared string a role's trust policy can require so a
|
|
518
573
|
* confused-deputy can't assume it. AWS allows a broad charset; we keep to the
|
|
@@ -592,7 +647,7 @@ const connectionSchema = z.object({
|
|
|
592
647
|
database: z.string().min(1)
|
|
593
648
|
});
|
|
594
649
|
/** A `{{name}}`/`{{verifier}}`/`{{valid_until}}` templated SQL statement. */
|
|
595
|
-
const statementSchema = z.string().min(1).max(4e3);
|
|
650
|
+
const statementSchema$1 = z.string().min(1).max(4e3);
|
|
596
651
|
/** A bare SQL identifier (schema name) — no quotes/whitespace/semicolons. */
|
|
597
652
|
const identifierSchema = z.string().regex(/^[A-Za-z_][A-Za-z0-9_]{0,62}$/, "must be an identifier");
|
|
598
653
|
const postgresTargetConfigSchema = z.object({
|
|
@@ -602,20 +657,20 @@ const postgresTargetConfigSchema = z.object({
|
|
|
602
657
|
schema: identifierSchema.optional(),
|
|
603
658
|
connection: connectionSchema,
|
|
604
659
|
provisionerUrl: z.url().optional(),
|
|
605
|
-
createStatements: z.array(statementSchema).max(16).optional(),
|
|
606
|
-
revokeStatements: z.array(statementSchema).max(16).optional()
|
|
660
|
+
createStatements: z.array(statementSchema$1).max(16).optional(),
|
|
661
|
+
revokeStatements: z.array(statementSchema$1).max(16).optional()
|
|
607
662
|
});
|
|
608
663
|
/** A MySQL account host part (`'name'@'<host>'`) — no quotes/whitespace. */
|
|
609
|
-
const mysqlHostSchema = z.string().regex(/^[A-Za-z0-9_.%:-]{1,255}$/, "must be a host pattern");
|
|
664
|
+
const mysqlHostSchema$1 = z.string().regex(/^[A-Za-z0-9_.%:-]{1,255}$/, "must be a host pattern");
|
|
610
665
|
const mysqlTargetConfigSchema = z.object({
|
|
611
666
|
provider: z.literal("mysql"),
|
|
612
667
|
executor: executorModeSchema,
|
|
613
668
|
accessLevel: mysqlAccessLevelSchema.optional(),
|
|
614
669
|
connection: connectionSchema,
|
|
615
|
-
userHost: mysqlHostSchema.optional(),
|
|
670
|
+
userHost: mysqlHostSchema$1.optional(),
|
|
616
671
|
provisionerUrl: z.url().optional(),
|
|
617
|
-
createStatements: z.array(statementSchema).max(16).optional(),
|
|
618
|
-
revokeStatements: z.array(statementSchema).max(16).optional()
|
|
672
|
+
createStatements: z.array(statementSchema$1).max(16).optional(),
|
|
673
|
+
revokeStatements: z.array(statementSchema$1).max(16).optional()
|
|
619
674
|
});
|
|
620
675
|
const redisConnectionSchema = z.object({
|
|
621
676
|
host: z.string().min(1),
|
|
@@ -629,8 +684,8 @@ const redisTargetConfigSchema = z.object({
|
|
|
629
684
|
accessLevel: redisAccessLevelSchema.optional(),
|
|
630
685
|
connection: redisConnectionSchema,
|
|
631
686
|
provisionerUrl: z.url().optional(),
|
|
632
|
-
createStatements: z.array(statementSchema).max(16).optional(),
|
|
633
|
-
revokeStatements: z.array(statementSchema).max(16).optional()
|
|
687
|
+
createStatements: z.array(statementSchema$1).max(16).optional(),
|
|
688
|
+
revokeStatements: z.array(statementSchema$1).max(16).optional()
|
|
634
689
|
});
|
|
635
690
|
const sshTargetConfigSchema = z.object({
|
|
636
691
|
provider: z.literal("ssh"),
|
|
@@ -649,7 +704,7 @@ const awsTargetConfigSchema = z.object({
|
|
|
649
704
|
provider: z.literal("aws"),
|
|
650
705
|
executor: z.literal("in_do"),
|
|
651
706
|
roleArn: awsRoleArnSchema,
|
|
652
|
-
region: awsRegionSchema,
|
|
707
|
+
region: awsRegionSchema$1,
|
|
653
708
|
externalId: awsExternalIdSchema.optional(),
|
|
654
709
|
sessionPolicy: z.string().min(1).max(4e3).optional(),
|
|
655
710
|
maxTtlSeconds: z.number().int().min(900).max(AWS_MAX_TTL_SECONDS).optional()
|
|
@@ -813,7 +868,9 @@ const NOTIFICATION_TYPES = [
|
|
|
813
868
|
"org_welcome",
|
|
814
869
|
"token_expiring",
|
|
815
870
|
"lease_expired",
|
|
816
|
-
"sync_failed"
|
|
871
|
+
"sync_failed",
|
|
872
|
+
"honey_token_tripped",
|
|
873
|
+
"secret_rotation_failed"
|
|
817
874
|
];
|
|
818
875
|
//#endregion
|
|
819
876
|
//#region ../../packages/core/src/schemas.ts
|
|
@@ -920,6 +977,14 @@ z.object({
|
|
|
920
977
|
environmentId: z.string().min(1).nullish(),
|
|
921
978
|
expiresAt: z.iso.datetime().nullish()
|
|
922
979
|
});
|
|
980
|
+
z.object({
|
|
981
|
+
name: nameSchema,
|
|
982
|
+
tokenId: z.string().regex(/^skt_[0-9A-Za-z]+$/),
|
|
983
|
+
/** SHA-256 hash (base64url) of the full token string. */
|
|
984
|
+
tokenHash: z.string().min(1),
|
|
985
|
+
/** Where the decoy was planted, as a reminder for whoever reads the alert. */
|
|
986
|
+
placement: z.string().max(200).nullish()
|
|
987
|
+
});
|
|
923
988
|
const kmsKeyPurposeSchema = z.enum(["encrypt", "sign"]);
|
|
924
989
|
const kmsKeySpecSchema = z.enum(["aes-256-gcm", "ecdsa-p256"]);
|
|
925
990
|
/** A wrapped key grant supplied by the client (server never sees plaintext material). */
|
|
@@ -1032,6 +1097,25 @@ z.object({
|
|
|
1032
1097
|
note: z.string().max(500).nullish(),
|
|
1033
1098
|
expiresAt: z.iso.datetime().nullish()
|
|
1034
1099
|
});
|
|
1100
|
+
z.object({
|
|
1101
|
+
code: z.string().min(4).max(40),
|
|
1102
|
+
family: planFamilySchema,
|
|
1103
|
+
version: z.number().int().positive().optional(),
|
|
1104
|
+
durationDays: z.number().int().positive().max(3650).nullish(),
|
|
1105
|
+
maxRedemptions: z.number().int().positive().nullish(),
|
|
1106
|
+
startsAt: z.iso.datetime().nullish(),
|
|
1107
|
+
endsAt: z.iso.datetime().nullish(),
|
|
1108
|
+
note: z.string().max(500).nullish()
|
|
1109
|
+
});
|
|
1110
|
+
z.object({
|
|
1111
|
+
maxRedemptions: z.number().int().positive().nullish(),
|
|
1112
|
+
startsAt: z.iso.datetime().nullish(),
|
|
1113
|
+
endsAt: z.iso.datetime().nullish(),
|
|
1114
|
+
note: z.string().max(500).nullish(),
|
|
1115
|
+
/** Kill switch. Disabling stops new redemptions; live grants are untouched. */
|
|
1116
|
+
disabled: z.boolean().optional()
|
|
1117
|
+
});
|
|
1118
|
+
z.object({ code: z.string().min(1).max(40) });
|
|
1035
1119
|
z.object({ family: planFamilySchema });
|
|
1036
1120
|
z.object({
|
|
1037
1121
|
sessionId: z.string().regex(/^skc_[0-9A-Za-z]+$/),
|
|
@@ -1049,7 +1133,117 @@ z.object({
|
|
|
1049
1133
|
action: z.string().optional(),
|
|
1050
1134
|
resourceType: z.string().optional()
|
|
1051
1135
|
});
|
|
1052
|
-
z.enum([
|
|
1136
|
+
z.enum([
|
|
1137
|
+
"generated",
|
|
1138
|
+
"postgres",
|
|
1139
|
+
"mysql",
|
|
1140
|
+
"redis"
|
|
1141
|
+
]);
|
|
1142
|
+
z.enum([
|
|
1143
|
+
"active",
|
|
1144
|
+
"paused",
|
|
1145
|
+
"failed"
|
|
1146
|
+
]);
|
|
1147
|
+
/** Statuses an admin may set directly (`failed` is only reached by the sweep). */
|
|
1148
|
+
const settableRotationStatusSchema = z.enum(["active", "paused"]);
|
|
1149
|
+
const rotationAlphabetSchema = z.enum([
|
|
1150
|
+
"alphanumeric",
|
|
1151
|
+
"hex",
|
|
1152
|
+
"base64url",
|
|
1153
|
+
"printable"
|
|
1154
|
+
]);
|
|
1155
|
+
const rotationIntervalSchema = z.number().int().min(300).max(3600 * 24 * 365);
|
|
1156
|
+
/**
|
|
1157
|
+
* A database user name we are willing to *re-key*. Deliberately more permissive
|
|
1158
|
+
* than the lease providers' name schemas — those name accounts seekrit creates,
|
|
1159
|
+
* whereas this names an account the customer's DBA created years ago, which may
|
|
1160
|
+
* be mixed-case or contain dots or dashes.
|
|
1161
|
+
*
|
|
1162
|
+
* It stays injection-safe for every place it is interpolated: a double-quoted
|
|
1163
|
+
* Postgres identifier, a single-quoted MySQL literal, and a bare Redis command
|
|
1164
|
+
* token. The charset excludes both quote characters, backslash, whitespace, and
|
|
1165
|
+
* `;`, so there is no way out of the surrounding quoting, and no whitespace to
|
|
1166
|
+
* split one Redis argument into two.
|
|
1167
|
+
*/
|
|
1168
|
+
const rotationUsernameSchema = z.string().regex(/^[A-Za-z0-9_$.-]{1,63}$/, "must be 1–63 chars of letters, digits, underscore, dollar, dot or dash");
|
|
1169
|
+
/** A `{{name}}`/`{{host}}`/`{{verifier}}` templated statement or command line. */
|
|
1170
|
+
const statementSchema = z.string().min(1).max(4e3);
|
|
1171
|
+
const passwordLengthSchema = z.number().int().min(16).max(256);
|
|
1172
|
+
/** A MySQL account host part (`'name'@'<host>'`) — no quotes/whitespace. */
|
|
1173
|
+
const mysqlHostSchema = z.string().regex(/^[A-Za-z0-9_.%:-]{1,255}$/, "must be a host pattern");
|
|
1174
|
+
const generatedRotationConfigSchema = z.object({
|
|
1175
|
+
kind: z.literal("generated"),
|
|
1176
|
+
length: passwordLengthSchema.optional(),
|
|
1177
|
+
alphabet: rotationAlphabetSchema.optional()
|
|
1178
|
+
});
|
|
1179
|
+
const postgresRotationConfigSchema = z.object({
|
|
1180
|
+
kind: z.literal("postgres"),
|
|
1181
|
+
username: rotationUsernameSchema,
|
|
1182
|
+
passwordLength: passwordLengthSchema.optional(),
|
|
1183
|
+
statements: z.array(statementSchema).max(16).optional()
|
|
1184
|
+
});
|
|
1185
|
+
const mysqlRotationConfigSchema = z.object({
|
|
1186
|
+
kind: z.literal("mysql"),
|
|
1187
|
+
username: rotationUsernameSchema,
|
|
1188
|
+
userHost: mysqlHostSchema.optional(),
|
|
1189
|
+
passwordLength: passwordLengthSchema.optional(),
|
|
1190
|
+
statements: z.array(statementSchema).max(16).optional()
|
|
1191
|
+
});
|
|
1192
|
+
const redisRotationConfigSchema = z.object({
|
|
1193
|
+
kind: z.literal("redis"),
|
|
1194
|
+
username: rotationUsernameSchema,
|
|
1195
|
+
passwordLength: passwordLengthSchema.optional(),
|
|
1196
|
+
statements: z.array(statementSchema).max(16).optional()
|
|
1197
|
+
});
|
|
1198
|
+
const rotationConfigSchema = z.discriminatedUnion("kind", [
|
|
1199
|
+
generatedRotationConfigSchema,
|
|
1200
|
+
postgresRotationConfigSchema,
|
|
1201
|
+
mysqlRotationConfigSchema,
|
|
1202
|
+
redisRotationConfigSchema
|
|
1203
|
+
]);
|
|
1204
|
+
z.object({
|
|
1205
|
+
environmentId: z.string().min(1),
|
|
1206
|
+
/** The secret whose value rotates. It must already exist. */
|
|
1207
|
+
secretName: secretNameSchema,
|
|
1208
|
+
config: rotationConfigSchema,
|
|
1209
|
+
intervalSeconds: rotationIntervalSchema,
|
|
1210
|
+
/**
|
|
1211
|
+
* The registered lease target supplying the connection, executor mode, and
|
|
1212
|
+
* wrapped admin credential. Required for every kind but `generated`.
|
|
1213
|
+
*/
|
|
1214
|
+
targetId: z.string().min(1).optional(),
|
|
1215
|
+
/** Environment DEK wrapped to the rotator public key (`wd1.` blob). */
|
|
1216
|
+
wrappedDek: z.string().min(1).optional(),
|
|
1217
|
+
/** Rotate once immediately instead of waiting for the first interval. */
|
|
1218
|
+
rotateNow: z.boolean().optional()
|
|
1219
|
+
});
|
|
1220
|
+
z.object({
|
|
1221
|
+
intervalSeconds: rotationIntervalSchema.optional(),
|
|
1222
|
+
config: rotationConfigSchema.optional(),
|
|
1223
|
+
/**
|
|
1224
|
+
* `paused` stops the sweep; `active` resumes it and clears the failure
|
|
1225
|
+
* streak, which is also how a `failed` policy is recovered.
|
|
1226
|
+
*/
|
|
1227
|
+
status: settableRotationStatusSchema.optional()
|
|
1228
|
+
}).refine((v) => v.intervalSeconds !== void 0 || v.config !== void 0 || v.status !== void 0, "provide at least one of intervalSeconds, config, or status");
|
|
1229
|
+
z.enum([
|
|
1230
|
+
"vercel",
|
|
1231
|
+
"cloudflare-workers",
|
|
1232
|
+
"cloudflare-pages",
|
|
1233
|
+
"cloudflare-secrets-store",
|
|
1234
|
+
"railway",
|
|
1235
|
+
"aws-secrets-manager",
|
|
1236
|
+
"aws-parameter-store",
|
|
1237
|
+
"render",
|
|
1238
|
+
"fly",
|
|
1239
|
+
"northflank",
|
|
1240
|
+
"digitalocean",
|
|
1241
|
+
"heroku",
|
|
1242
|
+
"netlify",
|
|
1243
|
+
"bunnyshell",
|
|
1244
|
+
"github-actions",
|
|
1245
|
+
"gcp-secret-manager"
|
|
1246
|
+
]);
|
|
1053
1247
|
/**
|
|
1054
1248
|
* Vercel account scope. The API token itself is never here — it is wrapped to
|
|
1055
1249
|
* the connection's public key and stored as ciphertext.
|
|
@@ -1063,7 +1257,274 @@ const vercelConnectionConfigSchema = z.object({
|
|
|
1063
1257
|
/** Vercel Team id (`team_…`). Omit for a personal account. */
|
|
1064
1258
|
teamId: z.string().trim().min(1).max(128).optional()
|
|
1065
1259
|
});
|
|
1066
|
-
|
|
1260
|
+
/**
|
|
1261
|
+
* A Cloudflare account id — 32 lowercase hex characters, found in the sidebar
|
|
1262
|
+
* of any account's dashboard. Every Cloudflare endpoint seekrit calls is
|
|
1263
|
+
* account-scoped, so this is the account half of "which account, which thing".
|
|
1264
|
+
*
|
|
1265
|
+
* Validated by shape because the alternative is a bare 400 from Cloudflare
|
|
1266
|
+
* hours later inside an alarm, with nobody watching. It does not catch pasting
|
|
1267
|
+
* a *zone* id, which has the same shape — only the API can tell those apart.
|
|
1268
|
+
*/
|
|
1269
|
+
const cloudflareAccountIdSchema = z.string().trim().regex(/^[0-9a-f]{32}$/, "must be a 32-character Cloudflare account ID (lowercase hex)");
|
|
1270
|
+
/**
|
|
1271
|
+
* Cloudflare account scope, shared by all three Cloudflare providers. The API
|
|
1272
|
+
* token is never here — it is wrapped to the connection's public key and
|
|
1273
|
+
* stored as ciphertext, exactly as Vercel's is.
|
|
1274
|
+
*
|
|
1275
|
+
* The three providers are deliberately separate kinds rather than one
|
|
1276
|
+
* `cloudflare` with a mode field: they target different APIs, take different
|
|
1277
|
+
* destinations, and fail in different ways. Splitting them keeps the
|
|
1278
|
+
* exhaustiveness guard in `connectorFor` meaningful.
|
|
1279
|
+
*/
|
|
1280
|
+
const cloudflareWorkersConnectionConfigSchema = z.object({
|
|
1281
|
+
provider: z.literal("cloudflare-workers"),
|
|
1282
|
+
accountId: cloudflareAccountIdSchema
|
|
1283
|
+
});
|
|
1284
|
+
const cloudflarePagesConnectionConfigSchema = z.object({
|
|
1285
|
+
provider: z.literal("cloudflare-pages"),
|
|
1286
|
+
accountId: cloudflareAccountIdSchema
|
|
1287
|
+
});
|
|
1288
|
+
const cloudflareSecretsStoreConnectionConfigSchema = z.object({
|
|
1289
|
+
provider: z.literal("cloudflare-secrets-store"),
|
|
1290
|
+
accountId: cloudflareAccountIdSchema
|
|
1291
|
+
});
|
|
1292
|
+
/**
|
|
1293
|
+
* A Railway id — every project, environment, and service is a UUID. Validated
|
|
1294
|
+
* by shape for the same reason Cloudflare's account id is: the alternative is a
|
|
1295
|
+
* bare GraphQL "Problem processing request" hours later inside an alarm, with
|
|
1296
|
+
* nobody watching.
|
|
1297
|
+
*/
|
|
1298
|
+
const railwayIdSchema = z.string().trim().regex(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i, "must be a Railway UUID");
|
|
1299
|
+
/**
|
|
1300
|
+
* Railway account scope. The token is never here — it is wrapped to the
|
|
1301
|
+
* connection's public key and stored as ciphertext, exactly as Vercel's is.
|
|
1302
|
+
*
|
|
1303
|
+
* There is no workspace/team id to carry: Railway ids are globally unique and
|
|
1304
|
+
* a destination names its project outright, so the token plus the destination
|
|
1305
|
+
* is the whole address.
|
|
1306
|
+
*/
|
|
1307
|
+
const railwayConnectionConfigSchema = z.object({
|
|
1308
|
+
provider: z.literal("railway"),
|
|
1309
|
+
tokenKind: z.enum(["account", "project"]).default("account")
|
|
1310
|
+
});
|
|
1311
|
+
/**
|
|
1312
|
+
* An AWS region id (`us-east-1`, `eu-central-1`, `us-gov-west-1`).
|
|
1313
|
+
*
|
|
1314
|
+
* Validated by shape rather than against a list, because AWS adds regions
|
|
1315
|
+
* faster than we ship. The endpoint host is built from this string, so a typo
|
|
1316
|
+
* would otherwise surface as a DNS failure inside an alarm with nobody
|
|
1317
|
+
* watching — which is a much worse place to learn about it than this form.
|
|
1318
|
+
*/
|
|
1319
|
+
const awsRegionSchema = z.string().trim().regex(/^[a-z]{2}(-[a-z]+)+-\d$/, "must be an AWS region ID, e.g. us-east-1");
|
|
1320
|
+
/**
|
|
1321
|
+
* The IAM access key id seekrit signs with.
|
|
1322
|
+
*
|
|
1323
|
+
* This lives in `config` — the *non-secret* half — on purpose: an access key id
|
|
1324
|
+
* is an identifier, not a credential. It appears in CloudTrail, in the IAM
|
|
1325
|
+
* console, and in the `Authorization` header of every signed request; only the
|
|
1326
|
+
* **secret access key** is secret, and that is what gets wrapped to the
|
|
1327
|
+
* connection's public key. Keeping the id here also lets the dashboard say
|
|
1328
|
+
* which key a connection is using, which is the first thing you want to know
|
|
1329
|
+
* when a connection starts failing after a key rotation.
|
|
1330
|
+
*
|
|
1331
|
+
* Long-lived IAM user keys only. `ASIA…` session credentials from STS expire
|
|
1332
|
+
* within hours, and a sync connection has to keep working unattended.
|
|
1333
|
+
*/
|
|
1334
|
+
const awsAccessKeyIdSchema = z.string().trim().regex(/^[A-Z0-9]{16,128}$/, "must be an AWS access key ID, e.g. AKIAIOSFODNN7EXAMPLE");
|
|
1335
|
+
/**
|
|
1336
|
+
* AWS account scope, shared by both AWS providers: which region to call and
|
|
1337
|
+
* which key to sign with. There is no account id — every endpoint seekrit calls
|
|
1338
|
+
* is reached through the regional host and authorizes off the signature, so the
|
|
1339
|
+
* account is whichever one the key belongs to.
|
|
1340
|
+
*
|
|
1341
|
+
* Two providers rather than one `aws` with a mode field, for the same reason
|
|
1342
|
+
* the three Cloudflare kinds are separate: different APIs, different
|
|
1343
|
+
* destinations, different IAM actions.
|
|
1344
|
+
*/
|
|
1345
|
+
const awsSecretsManagerConnectionConfigSchema = z.object({
|
|
1346
|
+
provider: z.literal("aws-secrets-manager"),
|
|
1347
|
+
region: awsRegionSchema,
|
|
1348
|
+
accessKeyId: awsAccessKeyIdSchema
|
|
1349
|
+
});
|
|
1350
|
+
const awsParameterStoreConnectionConfigSchema = z.object({
|
|
1351
|
+
provider: z.literal("aws-parameter-store"),
|
|
1352
|
+
region: awsRegionSchema,
|
|
1353
|
+
accessKeyId: awsAccessKeyIdSchema
|
|
1354
|
+
});
|
|
1355
|
+
/**
|
|
1356
|
+
* Render account scope — deliberately empty.
|
|
1357
|
+
*
|
|
1358
|
+
* Like Railway's, and unlike Vercel (which 403s team-owned resources without
|
|
1359
|
+
* `teamId`) or Cloudflare (whose every endpoint is account-scoped): a Render
|
|
1360
|
+
* API key is issued to a user, and every endpoint seekrit calls addresses its
|
|
1361
|
+
* resource by id — `srv-…`, `crn-…`, `evg-…`. There is nothing to scope, so
|
|
1362
|
+
* nothing is stored. The connection's `name` is what tells an operator which Render
|
|
1363
|
+
* workspace it belongs to.
|
|
1364
|
+
*/
|
|
1365
|
+
const renderConnectionConfigSchema = z.object({ provider: z.literal("render") });
|
|
1366
|
+
/**
|
|
1367
|
+
* Fly.io account scope — empty, as Render's is.
|
|
1368
|
+
*
|
|
1369
|
+
* Neither half of "which account, which thing" needs stating: Fly app names are
|
|
1370
|
+
* globally unique, so the destination names its app and that is the whole
|
|
1371
|
+
* address. Nor is there a token kind to declare the way Railway's `tokenKind`
|
|
1372
|
+
* is — Fly's two token shapes do take different auth schemes, but
|
|
1373
|
+
* `flyAuthorization` in the connector reads which one from the token itself.
|
|
1374
|
+
*/
|
|
1375
|
+
const flyConnectionConfigSchema = z.object({ provider: z.literal("fly") });
|
|
1376
|
+
/**
|
|
1377
|
+
* A Northflank id — projects and secret groups are both slugs derived from the
|
|
1378
|
+
* name they were created with (`default-project`, `example-secret-group`), and
|
|
1379
|
+
* both appear in the resource's URL. Validated against Northflank's own pattern
|
|
1380
|
+
* so the common slip — pasting the *display name*, spaces and all — fails here
|
|
1381
|
+
* rather than as a bare 404 inside an alarm with nobody watching.
|
|
1382
|
+
*/
|
|
1383
|
+
const northflankIdSchema = z.string().trim().min(3).max(100).regex(/^[a-zA-Z0-9]+(-[a-zA-Z0-9]+)*$/, "must be a Northflank ID — the slug in the resource's URL, not its display name");
|
|
1384
|
+
/**
|
|
1385
|
+
* Northflank account scope — deliberately empty.
|
|
1386
|
+
*
|
|
1387
|
+
* A Northflank API token is issued by exactly one team (or org-owned team) and
|
|
1388
|
+
* carries that scope itself; `GET /v1/auth` reports which. There is no team id
|
|
1389
|
+
* to disambiguate the way Vercel needs one, and no account id the way
|
|
1390
|
+
* Cloudflare does: the token plus the destination's project is the whole
|
|
1391
|
+
* address. The kind exists so the discriminated union stays uniform.
|
|
1392
|
+
*/
|
|
1393
|
+
const northflankConnectionConfigSchema = z.object({ provider: z.literal("northflank") });
|
|
1394
|
+
/**
|
|
1395
|
+
* DigitalOcean account scope — deliberately empty, as Render's and Fly's are.
|
|
1396
|
+
*
|
|
1397
|
+
* A DigitalOcean personal access token belongs to one account (or one team, if
|
|
1398
|
+
* it was issued inside one) and carries that scope itself, and every endpoint
|
|
1399
|
+
* this connector calls addresses its app by id. There is no team id to
|
|
1400
|
+
* disambiguate the way Vercel needs one: the token plus the destination's app
|
|
1401
|
+
* id is the whole address.
|
|
1402
|
+
*/
|
|
1403
|
+
const digitalOceanConnectionConfigSchema = z.object({ provider: z.literal("digitalocean") });
|
|
1404
|
+
/**
|
|
1405
|
+
* Heroku account scope — empty, as Fly's and Render's are.
|
|
1406
|
+
*
|
|
1407
|
+
* A Heroku API token carries its user's access to every app and team they can
|
|
1408
|
+
* reach, and app names are globally unique, so the destination's app is the
|
|
1409
|
+
* whole address. There is no team id to state: unlike Vercel, where a personal
|
|
1410
|
+
* token 403s a team-owned project without `teamId`, Heroku resolves
|
|
1411
|
+
* `/apps/{app_id_or_name}` against everything the token can see, team-owned or
|
|
1412
|
+
* not.
|
|
1413
|
+
*/
|
|
1414
|
+
const herokuConnectionConfigSchema = z.object({ provider: z.literal("heroku") });
|
|
1415
|
+
/**
|
|
1416
|
+
* Netlify team scope — the one thing a Netlify token cannot tell us itself.
|
|
1417
|
+
*
|
|
1418
|
+
* Every environment variable endpoint is account-scoped
|
|
1419
|
+
* (`/accounts/{account_id}/env`), and a personal access token belongs to a
|
|
1420
|
+
* *user*, who may sit in several teams. So unlike Fly's or Heroku's, this
|
|
1421
|
+
* config is not empty: the token says who you are, and this says which team's
|
|
1422
|
+
* variables to write.
|
|
1423
|
+
*
|
|
1424
|
+
* Netlify treats the team's id and its slug as interchangeable wherever
|
|
1425
|
+
* `{account_id}` appears, so both are accepted. The slug is the one an operator
|
|
1426
|
+
* can find without an API call — it is in the dashboard URL
|
|
1427
|
+
* (`app.netlify.com/teams/<slug>`) and under Team settings → General.
|
|
1428
|
+
*/
|
|
1429
|
+
const netlifyConnectionConfigSchema = z.object({
|
|
1430
|
+
provider: z.literal("netlify"),
|
|
1431
|
+
/** Netlify team slug (`acme`) or account id — `{account_id}` accepts either. */
|
|
1432
|
+
accountId: z.string().trim().min(1).max(128).regex(/^[A-Za-z0-9][A-Za-z0-9._-]*$/, "must be a Netlify team slug or account ID — no slashes or spaces")
|
|
1433
|
+
});
|
|
1434
|
+
/**
|
|
1435
|
+
* Bunnyshell account scope — empty, as Fly's, Heroku's, and Northflank's are.
|
|
1436
|
+
*
|
|
1437
|
+
* A Bunnyshell access token (from `environments.bunnyshell.com/access-token`)
|
|
1438
|
+
* belongs to a *user* and carries their access to every organization they are
|
|
1439
|
+
* in, exactly as a Heroku token does. Unlike Netlify's, that does not force an
|
|
1440
|
+
* organization onto the connection, because nothing here is addressed *through*
|
|
1441
|
+
* one: both variable collections name their parent by an opaque, globally
|
|
1442
|
+
* unique id (`environment` or `project`), so the token plus the destination's
|
|
1443
|
+
* id is the whole address. The API offers an `organization` filter, but it
|
|
1444
|
+
* narrows a listing — it is not part of an address.
|
|
1445
|
+
*/
|
|
1446
|
+
const bunnyshellConnectionConfigSchema = z.object({ provider: z.literal("bunnyshell") });
|
|
1447
|
+
/**
|
|
1448
|
+
* GitHub account scope — empty for github.com, which is the whole point.
|
|
1449
|
+
*
|
|
1450
|
+
* A GitHub token addresses everything by `{owner}/{repo}` or `{org}`, and those
|
|
1451
|
+
* are the destination's business, so there is no account half to state the way
|
|
1452
|
+
* Cloudflare and Netlify need one. `baseUrl` is the single exception, and it is
|
|
1453
|
+
* not an account scope at all: it names a **GitHub Enterprise Server** install,
|
|
1454
|
+
* whose API lives on the customer's own host rather than on `api.github.com`.
|
|
1455
|
+
*
|
|
1456
|
+
* Left unset for github.com and for Enterprise Cloud (which is `api.github.com`
|
|
1457
|
+
* with a different plan behind it). Set only for a self-hosted GHES appliance,
|
|
1458
|
+
* where the REST API is at `https://<host>/api/v3`.
|
|
1459
|
+
*/
|
|
1460
|
+
const githubActionsConnectionConfigSchema = z.object({
|
|
1461
|
+
provider: z.literal("github-actions"),
|
|
1462
|
+
/**
|
|
1463
|
+
* GitHub Enterprise Server API root, e.g. `https://github.acme.com/api/v3`.
|
|
1464
|
+
* Omit for github.com. Must be `https:` — this URL carries the token.
|
|
1465
|
+
*/
|
|
1466
|
+
baseUrl: z.string().trim().max(300).refine((value) => {
|
|
1467
|
+
let parsed;
|
|
1468
|
+
try {
|
|
1469
|
+
parsed = new URL(value);
|
|
1470
|
+
} catch {
|
|
1471
|
+
return false;
|
|
1472
|
+
}
|
|
1473
|
+
return parsed.protocol === "https:" && !parsed.username && !parsed.password;
|
|
1474
|
+
}, "must be an https:// URL — the GitHub Enterprise Server API root, e.g. https://github.acme.com/api/v3").optional()
|
|
1475
|
+
});
|
|
1476
|
+
/**
|
|
1477
|
+
* A Google Cloud project, as `projects/{project}` accepts one: either the
|
|
1478
|
+
* project **ID** (`acme-prod`, 6–30 characters, what the console shows) or the
|
|
1479
|
+
* project **number** (all digits). Both are accepted because both work, and
|
|
1480
|
+
* the id is the one an operator can read off their own dashboard.
|
|
1481
|
+
*
|
|
1482
|
+
* Validated by shape for the reason Cloudflare's account id is: every Secret
|
|
1483
|
+
* Manager URL is built from this string, and a typo would otherwise surface as
|
|
1484
|
+
* a 403 from Google hours later inside an alarm, with nobody watching.
|
|
1485
|
+
*/
|
|
1486
|
+
const gcpProjectSchema = z.string().trim().refine((value) => /^[a-z][a-z0-9-]{4,28}[a-z0-9]$/.test(value) || /^\d{1,20}$/.test(value), "must be a Google Cloud project ID (e.g. acme-prod) or project number");
|
|
1487
|
+
/**
|
|
1488
|
+
* Google Cloud project scope — which project's Secret Manager to write.
|
|
1489
|
+
*
|
|
1490
|
+
* The service account is *not* here, unlike AWS's access key id: a GCP
|
|
1491
|
+
* credential is a key JSON that names its own `client_email`, so the identity
|
|
1492
|
+
* arrives with the credential the way a Vercel token's does. What the
|
|
1493
|
+
* credential cannot say is which project to write, because a service account
|
|
1494
|
+
* can be granted access to secrets in projects other than its own — so that is
|
|
1495
|
+
* this field, exactly as Cloudflare's account id is.
|
|
1496
|
+
*
|
|
1497
|
+
* One project per connection. Syncing an environment into two projects means
|
|
1498
|
+
* two connections, which also keeps their key grants separate.
|
|
1499
|
+
*
|
|
1500
|
+
* Global secrets only: v1 addresses `secretmanager.googleapis.com`, not the
|
|
1501
|
+
* per-location `secretmanager.<location>.rep.googleapis.com` endpoints that
|
|
1502
|
+
* regional secrets live behind. Data residency is expressed instead through the
|
|
1503
|
+
* destination's user-managed replication.
|
|
1504
|
+
*/
|
|
1505
|
+
const gcpSecretManagerConnectionConfigSchema = z.object({
|
|
1506
|
+
provider: z.literal("gcp-secret-manager"),
|
|
1507
|
+
/** Project ID (`acme-prod`) or project number. */
|
|
1508
|
+
projectId: gcpProjectSchema
|
|
1509
|
+
});
|
|
1510
|
+
const syncConnectionConfigSchema = z.discriminatedUnion("provider", [
|
|
1511
|
+
vercelConnectionConfigSchema,
|
|
1512
|
+
cloudflareWorkersConnectionConfigSchema,
|
|
1513
|
+
cloudflarePagesConnectionConfigSchema,
|
|
1514
|
+
cloudflareSecretsStoreConnectionConfigSchema,
|
|
1515
|
+
railwayConnectionConfigSchema,
|
|
1516
|
+
awsSecretsManagerConnectionConfigSchema,
|
|
1517
|
+
awsParameterStoreConnectionConfigSchema,
|
|
1518
|
+
renderConnectionConfigSchema,
|
|
1519
|
+
flyConnectionConfigSchema,
|
|
1520
|
+
northflankConnectionConfigSchema,
|
|
1521
|
+
digitalOceanConnectionConfigSchema,
|
|
1522
|
+
herokuConnectionConfigSchema,
|
|
1523
|
+
netlifyConnectionConfigSchema,
|
|
1524
|
+
bunnyshellConnectionConfigSchema,
|
|
1525
|
+
githubActionsConnectionConfigSchema,
|
|
1526
|
+
gcpSecretManagerConnectionConfigSchema
|
|
1527
|
+
]);
|
|
1067
1528
|
const vercelDestinationSchema = z.object({
|
|
1068
1529
|
provider: z.literal("vercel"),
|
|
1069
1530
|
/** Vercel project id (`prj_…`) or project name. */
|
|
@@ -1080,7 +1541,693 @@ const vercelDestinationSchema = z.object({
|
|
|
1080
1541
|
*/
|
|
1081
1542
|
gitBranch: z.string().trim().min(1).max(255).optional()
|
|
1082
1543
|
});
|
|
1083
|
-
|
|
1544
|
+
/**
|
|
1545
|
+
* The Worker whose secrets a binding owns. Wrangler *environments* are not a
|
|
1546
|
+
* separate field because they are not a separate concept at the API: deploying
|
|
1547
|
+
* `my-api` with `--env staging` creates a Worker literally named
|
|
1548
|
+
* `my-api-staging`, so pointing at an environment means naming that script.
|
|
1549
|
+
*/
|
|
1550
|
+
const cloudflareWorkersDestinationSchema = z.object({
|
|
1551
|
+
provider: z.literal("cloudflare-workers"),
|
|
1552
|
+
/** Worker script name, as shown in the dashboard (`my-api`). */
|
|
1553
|
+
scriptName: z.string().trim().min(1).max(63).regex(/^[A-Za-z0-9_][A-Za-z0-9_-]*$/, "must be a Worker script name")
|
|
1554
|
+
});
|
|
1555
|
+
const cloudflarePagesDestinationSchema = z.object({
|
|
1556
|
+
provider: z.literal("cloudflare-pages"),
|
|
1557
|
+
/** Pages project name (`my-site`) — Pages has no separate project id. */
|
|
1558
|
+
projectName: z.string().trim().min(1).max(58).regex(/^[A-Za-z0-9][A-Za-z0-9-]*$/, "must be a Pages project name"),
|
|
1559
|
+
/** Which deployment configs receive these values. At least one. */
|
|
1560
|
+
environments: z.array(z.enum(["production", "preview"])).min(1)
|
|
1561
|
+
});
|
|
1562
|
+
const cloudflareSecretsStoreDestinationSchema = z.object({
|
|
1563
|
+
provider: z.literal("cloudflare-secrets-store"),
|
|
1564
|
+
/** Store id (32 hex). An account has exactly one store today. */
|
|
1565
|
+
storeId: z.string().trim().regex(/^[0-9a-f]{32}$/, "must be a 32-character Secrets Store ID (lowercase hex)"),
|
|
1566
|
+
/** Scopes applied to secrets this binding creates. At least one. */
|
|
1567
|
+
scopes: z.array(z.enum([
|
|
1568
|
+
"workers",
|
|
1569
|
+
"ai_gateway",
|
|
1570
|
+
"dex",
|
|
1571
|
+
"access",
|
|
1572
|
+
"containers",
|
|
1573
|
+
"websearch"
|
|
1574
|
+
])).min(1)
|
|
1575
|
+
});
|
|
1576
|
+
/**
|
|
1577
|
+
* Where inside Railway a binding writes.
|
|
1578
|
+
*
|
|
1579
|
+
* Railway variables are addressed by (project, environment, service) — the
|
|
1580
|
+
* environment here is *Railway's* (`production`, `pr-42`), not the seekrit
|
|
1581
|
+
* environment the binding reads from; a binding is precisely the mapping
|
|
1582
|
+
* between the two.
|
|
1583
|
+
*
|
|
1584
|
+
* Omitting `serviceId` targets the project's **shared** variables for that
|
|
1585
|
+
* environment, which services opt into with `${{shared.NAME}}`. That is a
|
|
1586
|
+
* genuinely different destination from any one service's variables, so it is an
|
|
1587
|
+
* absent field rather than a sentinel.
|
|
1588
|
+
*/
|
|
1589
|
+
const railwayDestinationSchema = z.object({
|
|
1590
|
+
provider: z.literal("railway"),
|
|
1591
|
+
/** Railway project id (a UUID, from the project's Settings page or URL). */
|
|
1592
|
+
projectId: railwayIdSchema,
|
|
1593
|
+
/** Railway environment id (a UUID) — the deployment environment to write. */
|
|
1594
|
+
environmentId: railwayIdSchema,
|
|
1595
|
+
/** Service to write. Omit to write the environment's shared variables. */
|
|
1596
|
+
serviceId: railwayIdSchema.optional(),
|
|
1597
|
+
/**
|
|
1598
|
+
* Suppress the redeploy Railway triggers when a variable changes.
|
|
1599
|
+
*
|
|
1600
|
+
* Left off (the default), a sync that changes a value redeploys the service,
|
|
1601
|
+
* which is what makes the new value actually reach the running process —
|
|
1602
|
+
* Railway applies variables at deploy time. Turn it on when deploys are
|
|
1603
|
+
* gated behind a release process and a secrets push must not start one; the
|
|
1604
|
+
* values then sit staged until the next deploy.
|
|
1605
|
+
*/
|
|
1606
|
+
skipDeploys: z.boolean().optional()
|
|
1607
|
+
});
|
|
1608
|
+
/**
|
|
1609
|
+
* A customer-managed KMS key to encrypt with, as a key id, ARN, or alias
|
|
1610
|
+
* (`alias/seekrit`). Omitted means the AWS-managed default for that service
|
|
1611
|
+
* (`aws/secretsmanager`, `aws/ssm`), which is what most accounts want.
|
|
1612
|
+
*
|
|
1613
|
+
* Deliberately loose: a KMS key can be named five different ways, half of them
|
|
1614
|
+
* cross-account ARNs, and rejecting a valid one here would be worse than
|
|
1615
|
+
* letting KMS give its own (very clear) error.
|
|
1616
|
+
*/
|
|
1617
|
+
const awsKmsKeyIdSchema = z.string().trim().min(1).max(2048);
|
|
1618
|
+
/**
|
|
1619
|
+
* Where in Secrets Manager a binding writes.
|
|
1620
|
+
*
|
|
1621
|
+
* `pathPrefix` exists rather than reusing {@link NameTransform}'s `prefix`
|
|
1622
|
+
* because the two answer different questions: a name transform produces a
|
|
1623
|
+
* *variable name* (`[A-Za-z0-9_]`, no slashes), while this produces a
|
|
1624
|
+
* *namespace* — `prod/storefront/` — and slashes are the whole point of it.
|
|
1625
|
+
*/
|
|
1626
|
+
const awsSecretsManagerDestinationSchema = z.object({
|
|
1627
|
+
provider: z.literal("aws-secrets-manager"),
|
|
1628
|
+
layout: z.enum(["secret-per-name", "json-bundle"]).default("secret-per-name"),
|
|
1629
|
+
/**
|
|
1630
|
+
* `secret-per-name` only: prepended to every secret's name, e.g.
|
|
1631
|
+
* `prod/storefront/`. Optional, but strongly advised in an account that
|
|
1632
|
+
* holds anything else — without it a binding writes at the root of a
|
|
1633
|
+
* namespace it does not own.
|
|
1634
|
+
*/
|
|
1635
|
+
pathPrefix: z.string().trim().max(400).regex(/^[A-Za-z0-9/_+=.@-]*$/, "may contain letters, digits, and / _ + = . @ -").optional(),
|
|
1636
|
+
/** `json-bundle` only: the one secret that holds every value, e.g. `prod/storefront/env`. */
|
|
1637
|
+
secretName: z.string().trim().min(1).max(512).regex(/^[A-Za-z0-9/_+=.@-]+$/, "may contain letters, digits, and / _ + = . @ -").optional(),
|
|
1638
|
+
kmsKeyId: awsKmsKeyIdSchema.optional()
|
|
1639
|
+
}).refine((d) => d.layout !== "json-bundle" || d.secretName !== void 0, {
|
|
1640
|
+
message: "a json-bundle destination needs the name of the secret to write",
|
|
1641
|
+
path: ["secretName"]
|
|
1642
|
+
});
|
|
1643
|
+
/**
|
|
1644
|
+
* The Parameter Store hierarchy a binding owns, e.g. `/prod/storefront/`.
|
|
1645
|
+
*
|
|
1646
|
+
* A path rather than a free-form prefix because that is what the API is built
|
|
1647
|
+
* around: `GetParametersByPath` is how an application reads a whole
|
|
1648
|
+
* environment in one call, and it only works on `/`-delimited names. Leading
|
|
1649
|
+
* and trailing slashes are required so the binding's names concatenate
|
|
1650
|
+
* unambiguously — `/prod/storefront/` + `DB_URL`.
|
|
1651
|
+
*/
|
|
1652
|
+
const awsParameterStoreDestinationSchema = z.object({
|
|
1653
|
+
provider: z.literal("aws-parameter-store"),
|
|
1654
|
+
/** Must start and end with `/`. `aws`/`ssm` are reserved by AWS as the first segment. */
|
|
1655
|
+
path: z.string().trim().max(1011).regex(/^\/([A-Za-z0-9_.-]+\/)*$/, "must be a parameter path like /prod/storefront/"),
|
|
1656
|
+
type: z.enum(["SecureString", "String"]).default("SecureString"),
|
|
1657
|
+
/**
|
|
1658
|
+
* Standard caps a value at 4KB and costs nothing; Advanced raises that to 8KB
|
|
1659
|
+
* and is billed per parameter per month. `Intelligent-Tiering` lets AWS pick,
|
|
1660
|
+
* upgrading only the parameters that need it.
|
|
1661
|
+
*/
|
|
1662
|
+
tier: z.enum([
|
|
1663
|
+
"Standard",
|
|
1664
|
+
"Advanced",
|
|
1665
|
+
"Intelligent-Tiering"
|
|
1666
|
+
]).default("Standard"),
|
|
1667
|
+
kmsKeyId: awsKmsKeyIdSchema.optional()
|
|
1668
|
+
});
|
|
1669
|
+
/**
|
|
1670
|
+
* Render resource ids are `<prefix>-<slug>`, and the two prefixes below are the
|
|
1671
|
+
* documented ones: `srv-` for every service type, `crn-` for cron jobs, `evg-`
|
|
1672
|
+
* for an environment group.
|
|
1673
|
+
*
|
|
1674
|
+
* The patterns reject the *other* kind's prefix rather than requiring their own.
|
|
1675
|
+
* The mistake worth catching is pasting an env-group id into the service field
|
|
1676
|
+
* (or the reverse) — which is otherwise a 404 hours later inside an alarm, with
|
|
1677
|
+
* nobody watching. Requiring the positive prefix would also reject a valid id
|
|
1678
|
+
* the day Render introduces a new resource prefix, which is not our call to
|
|
1679
|
+
* make.
|
|
1680
|
+
*/
|
|
1681
|
+
const renderServiceIdSchema = z.string().trim().regex(/^(?!evg-)[A-Za-z0-9_-]{1,64}$/, "must be a Render service ID (`srv-…` or `crn-…`), not an environment group");
|
|
1682
|
+
const renderEnvGroupIdSchema = z.string().trim().regex(/^(?!srv-|crn-)[A-Za-z0-9_-]{1,64}$/, "must be a Render environment group ID (`evg-…`), not a service");
|
|
1683
|
+
/** Environment variables set directly on one service. */
|
|
1684
|
+
const renderServiceDestinationSchema = z.object({
|
|
1685
|
+
provider: z.literal("render"),
|
|
1686
|
+
kind: z.literal("service"),
|
|
1687
|
+
/** Service id (`srv-…`, or `crn-…` for a cron job), from its dashboard URL. */
|
|
1688
|
+
serviceId: renderServiceIdSchema
|
|
1689
|
+
});
|
|
1690
|
+
/**
|
|
1691
|
+
* Environment variables in a shared environment group. Every service linked to
|
|
1692
|
+
* the group sees them, which is the point — and the reason a group binding is
|
|
1693
|
+
* worth thinking about twice: its blast radius is the link list, not one
|
|
1694
|
+
* service.
|
|
1695
|
+
*/
|
|
1696
|
+
const renderEnvGroupDestinationSchema = z.object({
|
|
1697
|
+
provider: z.literal("render"),
|
|
1698
|
+
kind: z.literal("env-group"),
|
|
1699
|
+
/** Environment group id (`evg-…`), from its dashboard URL. */
|
|
1700
|
+
envGroupId: renderEnvGroupIdSchema
|
|
1701
|
+
});
|
|
1702
|
+
const renderDestinationSchema = z.discriminatedUnion("kind", [renderServiceDestinationSchema, renderEnvGroupDestinationSchema]);
|
|
1703
|
+
/**
|
|
1704
|
+
* The Fly app whose secret set a binding owns.
|
|
1705
|
+
*
|
|
1706
|
+
* A Fly app has **one** secret set, shared by every Machine in every region —
|
|
1707
|
+
* there is no per-target split to state, the way Vercel and Pages have one.
|
|
1708
|
+
* Fly's convention is that staging and production are separate *apps*
|
|
1709
|
+
* (`storefront`, `storefront-staging`), so pointing at an environment means
|
|
1710
|
+
* naming that app, exactly as a Wrangler environment means naming its own
|
|
1711
|
+
* Worker.
|
|
1712
|
+
*
|
|
1713
|
+
* Values land **staged**: Fly injects secrets when a Machine boots, so already
|
|
1714
|
+
* running Machines keep what they started with until the app is deployed or its
|
|
1715
|
+
* Machines are updated (`fly secrets deploy -a <app>`), while Machines created
|
|
1716
|
+
* after the push get them straight away. The connector deliberately restarts
|
|
1717
|
+
* nothing — see the note in `apps/api/src/lib/sync/connectors/fly.ts`.
|
|
1718
|
+
*/
|
|
1719
|
+
const flyDestinationSchema = z.object({
|
|
1720
|
+
provider: z.literal("fly"),
|
|
1721
|
+
/** Fly app name, as `fly apps list` prints it. */
|
|
1722
|
+
appName: z.string().trim().min(1).max(63).regex(/^[a-z0-9][a-z0-9-]*$/, "must be a Fly app name (lowercase letters, numbers, and dashes)")
|
|
1723
|
+
});
|
|
1724
|
+
/**
|
|
1725
|
+
* Where inside Northflank a binding writes: one **secret group** in one
|
|
1726
|
+
* project.
|
|
1727
|
+
*
|
|
1728
|
+
* A secret group is Northflank's unit of injection — services and jobs in the
|
|
1729
|
+
* project inherit its variables, subject to the group's own restrictions and
|
|
1730
|
+
* priority. Those settings belong to the operator, not to seekrit: a binding
|
|
1731
|
+
* names an existing group and only ever writes its `variables` map, so
|
|
1732
|
+
* restrictions, priority, secret type, and any secret *files* stay as they were
|
|
1733
|
+
* configured.
|
|
1734
|
+
*
|
|
1735
|
+
* There is no environment field. Northflank has no per-group environment axis —
|
|
1736
|
+
* separate environments are separate projects (or separate groups restricted to
|
|
1737
|
+
* a stage), so the binding's seekrit environment maps to a group, one to one.
|
|
1738
|
+
*/
|
|
1739
|
+
const northflankDestinationSchema = z.object({
|
|
1740
|
+
provider: z.literal("northflank"),
|
|
1741
|
+
/** Project id — the slug in the project URL (`default-project`). */
|
|
1742
|
+
projectId: northflankIdSchema,
|
|
1743
|
+
/** Secret group id — the slug in the group's URL (`example-secret-group`). */
|
|
1744
|
+
secretGroupId: northflankIdSchema
|
|
1745
|
+
});
|
|
1746
|
+
/**
|
|
1747
|
+
* When App Platform makes a variable visible. DigitalOcean's enum also has
|
|
1748
|
+
* `UNSET`, which is not offered: it means "no scope stated", and a secrets
|
|
1749
|
+
* manager that writes a value should say when that value applies.
|
|
1750
|
+
*
|
|
1751
|
+
* The default here is `RUN_TIME` rather than DigitalOcean's own
|
|
1752
|
+
* `RUN_AND_BUILD_TIME`, and the difference is deliberate. A build-time variable
|
|
1753
|
+
* is visible to every build command, every buildpack, and anything they print;
|
|
1754
|
+
* a secret only the running process needs has no business being there. Binding
|
|
1755
|
+
* a value a build genuinely needs — a private registry token, a sourcemap
|
|
1756
|
+
* upload key — is a decision worth making explicitly.
|
|
1757
|
+
*/
|
|
1758
|
+
const DIGITALOCEAN_ENV_SCOPES = [
|
|
1759
|
+
"RUN_TIME",
|
|
1760
|
+
"BUILD_TIME",
|
|
1761
|
+
"RUN_AND_BUILD_TIME"
|
|
1762
|
+
];
|
|
1763
|
+
/**
|
|
1764
|
+
* A DigitalOcean app id — the UUID in the app's dashboard URL
|
|
1765
|
+
* (`cloud.digitalocean.com/apps/<id>`), and what `doctl apps list` prints.
|
|
1766
|
+
*
|
|
1767
|
+
* DigitalOcean's own spec types this as a bare string, but every app id it
|
|
1768
|
+
* issues is a UUID, and the slip worth catching is the one the API cannot tell
|
|
1769
|
+
* from a typo: pasting the app's *name* (`storefront`), which `GET /v2/apps/{id}`
|
|
1770
|
+
* answers with a flat 404 hours later inside an alarm, with nobody watching.
|
|
1771
|
+
*/
|
|
1772
|
+
const digitalOceanAppIdSchema = z.string().trim().regex(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i, "must be a DigitalOcean app ID (the UUID in the app's URL), not its name");
|
|
1773
|
+
/**
|
|
1774
|
+
* A component name, matching App Platform's own pattern for one. Names are
|
|
1775
|
+
* unique within an app, which is what makes a name — rather than an index into
|
|
1776
|
+
* `services` — the stable way to address a component's variables.
|
|
1777
|
+
*/
|
|
1778
|
+
const digitalOceanComponentNameSchema = z.string().trim().regex(/^[a-z][a-z0-9-]{0,30}[a-z0-9]$/, "must be an App Platform component name (lowercase letters, numbers, and dashes)");
|
|
1779
|
+
/**
|
|
1780
|
+
* Where inside a DigitalOcean app a binding writes.
|
|
1781
|
+
*
|
|
1782
|
+
* ## A push is a deployment
|
|
1783
|
+
*
|
|
1784
|
+
* App Platform has no per-variable endpoint. Environment variables live in the
|
|
1785
|
+
* app spec, and the only way to change one is to submit a new spec — which
|
|
1786
|
+
* starts a **new deployment** of the app. That is not a side effect this
|
|
1787
|
+
* connector chose and there is no flag to suppress it; it is what "set an
|
|
1788
|
+
* environment variable" means on this platform, in the control panel and in
|
|
1789
|
+
* `doctl` alike.
|
|
1790
|
+
*
|
|
1791
|
+
* The deployment reuses each component's current source (seekrit never sends
|
|
1792
|
+
* `update_all_source_versions`), so it redeploys the code already running
|
|
1793
|
+
* rather than pulling a newer commit or image. It is still a real deployment:
|
|
1794
|
+
* a build, a health check, and a rollout. Bind an environment here knowing that
|
|
1795
|
+
* changing a secret in it will roll the app.
|
|
1796
|
+
*
|
|
1797
|
+
* ## Values are written encrypted
|
|
1798
|
+
*
|
|
1799
|
+
* Everything seekrit writes goes in as `type: SECRET`, so App Platform encrypts
|
|
1800
|
+
* it at rest and hands it back as an opaque `EV[1:…]` blob rather than as
|
|
1801
|
+
* plaintext. That is also why this connector cannot tell whether a value it is
|
|
1802
|
+
* about to write is already there — see
|
|
1803
|
+
* `apps/api/src/lib/sync/connectors/digitalocean.ts`.
|
|
1804
|
+
*/
|
|
1805
|
+
const digitalOceanAppDestinationSchema = z.object({
|
|
1806
|
+
provider: z.literal("digitalocean"),
|
|
1807
|
+
kind: z.literal("app"),
|
|
1808
|
+
/** App id — the UUID in `cloud.digitalocean.com/apps/<id>`. */
|
|
1809
|
+
appId: digitalOceanAppIdSchema,
|
|
1810
|
+
scope: z.enum(DIGITALOCEAN_ENV_SCOPES).default("RUN_TIME")
|
|
1811
|
+
});
|
|
1812
|
+
/**
|
|
1813
|
+
* One component's own environment variables. Narrower than the app-level list:
|
|
1814
|
+
* only this service, worker, job, static site, or function sees them, and a key
|
|
1815
|
+
* here wins over the same key at app level.
|
|
1816
|
+
*/
|
|
1817
|
+
const digitalOceanComponentDestinationSchema = z.object({
|
|
1818
|
+
provider: z.literal("digitalocean"),
|
|
1819
|
+
kind: z.literal("component"),
|
|
1820
|
+
appId: digitalOceanAppIdSchema,
|
|
1821
|
+
/** Component name, as it appears in the app spec — not its type. */
|
|
1822
|
+
componentName: digitalOceanComponentNameSchema,
|
|
1823
|
+
scope: z.enum(DIGITALOCEAN_ENV_SCOPES).default("RUN_TIME")
|
|
1824
|
+
});
|
|
1825
|
+
const digitalOceanDestinationSchema = z.discriminatedUnion("kind", [digitalOceanAppDestinationSchema, digitalOceanComponentDestinationSchema]);
|
|
1826
|
+
/**
|
|
1827
|
+
* A Heroku app, named the way `/apps/{app_id_or_name}` names one: either the
|
|
1828
|
+
* app name or its UUID id. Both are accepted because both work, and the id is
|
|
1829
|
+
* the durable one — renaming an app in the dashboard breaks a binding that
|
|
1830
|
+
* holds its name, and does not break one that holds its id.
|
|
1831
|
+
*
|
|
1832
|
+
* The name pattern is Heroku's own (`^[a-z][a-z0-9-]{1,28}[a-z0-9]$`): 3–30
|
|
1833
|
+
* characters, starting with a letter and ending alphanumeric. Checking it here
|
|
1834
|
+
* turns the habitual slip — pasting `example.herokuapp.com`, or a name with
|
|
1835
|
+
* capitals — into a message at the form rather than a bare 404 from an alarm
|
|
1836
|
+
* with nobody watching.
|
|
1837
|
+
*/
|
|
1838
|
+
const herokuAppSchema = z.string().trim().refine((value) => /^[a-z][a-z0-9-]{1,28}[a-z0-9]$/.test(value) || /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value), "must be a Heroku app name (lowercase letters, numbers, and dashes) or its UUID");
|
|
1839
|
+
/**
|
|
1840
|
+
* The Heroku app whose config vars a binding owns.
|
|
1841
|
+
*
|
|
1842
|
+
* A Heroku app has **one** set of config vars, shared by every dyno and every
|
|
1843
|
+
* process type — there is no per-target split to state the way Vercel and Pages
|
|
1844
|
+
* have one. Heroku's convention is that staging and production are separate
|
|
1845
|
+
* *apps* (`storefront`, `storefront-staging`), so pointing at an environment
|
|
1846
|
+
* means naming that app, exactly as it does on Fly.
|
|
1847
|
+
*
|
|
1848
|
+
* Unlike Fly, values take effect **immediately**: setting config vars cuts a new
|
|
1849
|
+
* release and restarts the app's dynos, which is why a run sends exactly one
|
|
1850
|
+
* request — see the note in `apps/api/src/lib/sync/connectors/heroku.ts`.
|
|
1851
|
+
*/
|
|
1852
|
+
const herokuDestinationSchema = z.object({
|
|
1853
|
+
provider: z.literal("heroku"),
|
|
1854
|
+
/** App name as `heroku apps` prints it, or the app's UUID. */
|
|
1855
|
+
app: herokuAppSchema
|
|
1856
|
+
});
|
|
1857
|
+
/**
|
|
1858
|
+
* The deploy contexts a Netlify value can be set for.
|
|
1859
|
+
*
|
|
1860
|
+
* These are Netlify's own, minus two. `all` is missing deliberately: Netlify
|
|
1861
|
+
* requires a **secret** value to be set against explicit contexts, and its
|
|
1862
|
+
* `setEnvVarValue` endpoint is reported to fail outright on `context: "all"` —
|
|
1863
|
+
* so the union offers only contexts that work under both. Naming the contexts
|
|
1864
|
+
* you mean is what you want here anyway; a binding already exists to map one
|
|
1865
|
+
* seekrit environment onto one deploy context. `dev-server` (Preview Server) is
|
|
1866
|
+
* left out for want of anyone asking.
|
|
1867
|
+
*
|
|
1868
|
+
* `branch` is the odd one: it needs a branch name alongside it, which the
|
|
1869
|
+
* destination carries as {@link netlifyDestinationSchema}'s `branch`.
|
|
1870
|
+
*/
|
|
1871
|
+
const NETLIFY_CONTEXTS = [
|
|
1872
|
+
"production",
|
|
1873
|
+
"deploy-preview",
|
|
1874
|
+
"branch-deploy",
|
|
1875
|
+
"branch",
|
|
1876
|
+
"dev"
|
|
1877
|
+
];
|
|
1878
|
+
/**
|
|
1879
|
+
* A Netlify site, by its **API ID** — the UUID under Project configuration →
|
|
1880
|
+
* General → Project information.
|
|
1881
|
+
*
|
|
1882
|
+
* Netlify accepts a site's domain in place of its id where a site appears in a
|
|
1883
|
+
* *path* (`/sites/{site_id}`), but the environment variable endpoints take the
|
|
1884
|
+
* site as a `?site_id=` **query parameter** instead, and Netlify documents no
|
|
1885
|
+
* name resolution there. That asymmetry is why this is strict where the Heroku
|
|
1886
|
+
* and Fly destinations are permissive: a `site_id` Netlify does not resolve
|
|
1887
|
+
* does not 404 — the write lands on the *team*, as a shared variable inherited
|
|
1888
|
+
* by every site in it. Refusing anything but the UUID keeps a slip from turning
|
|
1889
|
+
* into a much wider blast radius than the operator asked for.
|
|
1890
|
+
*/
|
|
1891
|
+
const netlifySiteIdSchema = z.string().trim().regex(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i, "must be the site's API ID (a UUID), not its name or URL");
|
|
1892
|
+
/**
|
|
1893
|
+
* The Netlify site, and which of its deploy contexts a binding owns.
|
|
1894
|
+
*
|
|
1895
|
+
* Netlify keys a value by (site, context), so the destination is that pair.
|
|
1896
|
+
* Contexts are a list rather than a single value for the same reason Vercel's
|
|
1897
|
+
* `targets` is one: a binding is unique per (connection, environment), so an
|
|
1898
|
+
* environment that feeds both production and deploy previews has to say so in
|
|
1899
|
+
* one destination or not at all.
|
|
1900
|
+
*
|
|
1901
|
+
* A push writes **only** the contexts listed here. Other contexts of the same
|
|
1902
|
+
* variable — and every variable this binding does not manage — are left as they
|
|
1903
|
+
* are, which is what makes it safe to point at a site that already has
|
|
1904
|
+
* variables set by hand.
|
|
1905
|
+
*
|
|
1906
|
+
* `secret` marks what seekrit creates as a Netlify **secret**: write-only, and
|
|
1907
|
+
* unreadable afterwards through the UI, CLI, and API. On by default, since that
|
|
1908
|
+
* is the whole point of pushing from a secrets manager. It applies only to
|
|
1909
|
+
* variables seekrit *creates* — Netlify will not let a flag be added to an
|
|
1910
|
+
* existing variable, or removed from one ever — and it needs a plan that
|
|
1911
|
+
* includes Secrets Controller.
|
|
1912
|
+
*/
|
|
1913
|
+
const netlifyDestinationSchema = z.object({
|
|
1914
|
+
provider: z.literal("netlify"),
|
|
1915
|
+
/** Site API ID (a UUID), from Project configuration → General. */
|
|
1916
|
+
siteId: netlifySiteIdSchema,
|
|
1917
|
+
/** Which deploy contexts receive these values. At least one. */
|
|
1918
|
+
contexts: z.array(z.enum(NETLIFY_CONTEXTS)).min(1),
|
|
1919
|
+
/** Branch name, required when `contexts` includes `branch`; ignored otherwise. */
|
|
1920
|
+
branch: z.string().trim().min(1).max(255).optional(),
|
|
1921
|
+
/** Create variables as Netlify secrets (default true). */
|
|
1922
|
+
secret: z.boolean().optional()
|
|
1923
|
+
}).refine((d) => !d.contexts.includes("branch") || d.branch !== void 0, {
|
|
1924
|
+
message: "a branch context needs the branch name it applies to",
|
|
1925
|
+
path: ["branch"]
|
|
1926
|
+
});
|
|
1927
|
+
/**
|
|
1928
|
+
* A Bunnyshell resource id, as the platform hands it out.
|
|
1929
|
+
*
|
|
1930
|
+
* Deliberately loose. Bunnyshell documents no format for these — they are
|
|
1931
|
+
* opaque strings from `bns environments list` or the dashboard URL — so
|
|
1932
|
+
* asserting a shape here would be inventing a rule the platform never stated,
|
|
1933
|
+
* and the failure mode would be seekrit refusing an id that works.
|
|
1934
|
+
*
|
|
1935
|
+
* Being loose is affordable here in a way it is not on Netlify, where an
|
|
1936
|
+
* unresolved `site_id` silently widens a write to the whole team. Both
|
|
1937
|
+
* Bunnyshell variable collections name their parent in the **request body** of
|
|
1938
|
+
* a create, as a required relation: an id the platform cannot resolve is a 422
|
|
1939
|
+
* naming the field, not a write that lands somewhere broader. The listing side
|
|
1940
|
+
* is fenced separately — the connector re-checks every variable's own parent
|
|
1941
|
+
* before it touches it, so a filter that failed to bite cannot turn into an
|
|
1942
|
+
* edit of a neighbouring environment's variables.
|
|
1943
|
+
*/
|
|
1944
|
+
const bunnyshellIdSchema = z.string().trim().min(1).max(64).regex(/^[A-Za-z0-9][A-Za-z0-9._-]*$/, "must be a Bunnyshell ID — no slashes or spaces");
|
|
1945
|
+
/**
|
|
1946
|
+
* Variables on one Bunnyshell **environment** — the set every component in it
|
|
1947
|
+
* inherits, and the closest match to a seekrit environment.
|
|
1948
|
+
*
|
|
1949
|
+
* This is the destination for an environment that already exists and stays
|
|
1950
|
+
* around: a primary environment, or a long-lived ephemeral one.
|
|
1951
|
+
*/
|
|
1952
|
+
const bunnyshellEnvironmentDestinationSchema = z.object({
|
|
1953
|
+
provider: z.literal("bunnyshell"),
|
|
1954
|
+
kind: z.literal("environment"),
|
|
1955
|
+
/** Environment ID, as `bns environments list` prints it. */
|
|
1956
|
+
environmentId: bunnyshellIdSchema,
|
|
1957
|
+
/** Mark what seekrit creates as a Bunnyshell secret (default true). */
|
|
1958
|
+
secret: z.boolean().optional()
|
|
1959
|
+
});
|
|
1960
|
+
/**
|
|
1961
|
+
* Variables on a Bunnyshell **project** — inherited by every environment
|
|
1962
|
+
* created in it from then on.
|
|
1963
|
+
*
|
|
1964
|
+
* Worth thinking about twice, for the reason a Render environment group is:
|
|
1965
|
+
* the blast radius is the project, not one environment. It earns its place
|
|
1966
|
+
* anyway, because it is the only destination that reaches an environment which
|
|
1967
|
+
* *does not exist yet*. Bunnyshell's whole shape is ephemeral environments spun
|
|
1968
|
+
* up per branch or per pull request; pushing to the environment cannot seed one
|
|
1969
|
+
* that a webhook will create tomorrow, and pushing to the project can.
|
|
1970
|
+
*
|
|
1971
|
+
* An environment inherits the project's value at creation and may then be
|
|
1972
|
+
* overridden at its own scope — so a project binding does not fight an
|
|
1973
|
+
* environment binding pointed at the same name, it loses to it.
|
|
1974
|
+
*/
|
|
1975
|
+
const bunnyshellProjectDestinationSchema = z.object({
|
|
1976
|
+
provider: z.literal("bunnyshell"),
|
|
1977
|
+
kind: z.literal("project"),
|
|
1978
|
+
/** Project ID, as `bns projects list` prints it. */
|
|
1979
|
+
projectId: bunnyshellIdSchema,
|
|
1980
|
+
/** Mark what seekrit creates as a Bunnyshell secret (default true). */
|
|
1981
|
+
secret: z.boolean().optional()
|
|
1982
|
+
});
|
|
1983
|
+
/**
|
|
1984
|
+
* Where in Bunnyshell a binding writes.
|
|
1985
|
+
*
|
|
1986
|
+
* Split on `kind` rather than into two providers — the way Render's service and
|
|
1987
|
+
* environment group are, and unlike Cloudflare's three — because the two are the
|
|
1988
|
+
* same API twice over: `/v1/environment_variables` and `/v1/project_variables`
|
|
1989
|
+
* take the same fields, fail the same ways, and differ only in which parent they
|
|
1990
|
+
* name. One connector serves both, so one provider does too.
|
|
1991
|
+
*
|
|
1992
|
+
* `secret` is Bunnyshell's `isSecret`, and means less than Netlify's flag of the
|
|
1993
|
+
* same name: Bunnyshell encrypts every variable with an organization key whether
|
|
1994
|
+
* or not the flag is set, so this only decides whether the value is obscured in
|
|
1995
|
+
* the dashboard and stored encrypted in an exported definition. It is on by
|
|
1996
|
+
* default all the same — a value pushed from a secrets manager should not be
|
|
1997
|
+
* sitting in plain view of everyone with project access. It applies only to
|
|
1998
|
+
* variables seekrit **creates**: an update never sends the flag, so a variable
|
|
1999
|
+
* an operator deliberately un-secreted stays that way.
|
|
2000
|
+
*/
|
|
2001
|
+
const bunnyshellDestinationSchema = z.discriminatedUnion("kind", [bunnyshellEnvironmentDestinationSchema, bunnyshellProjectDestinationSchema]);
|
|
2002
|
+
/**
|
|
2003
|
+
* Which repositories in an organization can read an org-level secret.
|
|
2004
|
+
*
|
|
2005
|
+
* GitHub's own enum, unchanged. There is deliberately **no default**: `all` hands
|
|
2006
|
+
* the value to every repository in the organization — including ones added
|
|
2007
|
+
* tomorrow, and including forks' workflows to the extent the org allows them —
|
|
2008
|
+
* and that is not a blast radius a secrets manager should pick on an operator's
|
|
2009
|
+
* behalf. Naming it is the point.
|
|
2010
|
+
*/
|
|
2011
|
+
const GITHUB_ACTIONS_VISIBILITIES = [
|
|
2012
|
+
"all",
|
|
2013
|
+
"private",
|
|
2014
|
+
"selected"
|
|
2015
|
+
];
|
|
2016
|
+
/**
|
|
2017
|
+
* A GitHub account or organization login, matching GitHub's own rule:
|
|
2018
|
+
* alphanumeric with single internal hyphens, 39 characters at most.
|
|
2019
|
+
*
|
|
2020
|
+
* Checked here so the habitual slip — pasting a URL, or `owner/repo` into the
|
|
2021
|
+
* owner field — fails at the form rather than as a 404 from an alarm with nobody
|
|
2022
|
+
* watching.
|
|
2023
|
+
*/
|
|
2024
|
+
const githubOwnerSchema = z.string().trim().min(1).max(39).regex(/^[A-Za-z0-9](?:-?[A-Za-z0-9])*$/, "must be a GitHub user or organization login — not a URL or an owner/repo pair");
|
|
2025
|
+
/**
|
|
2026
|
+
* A repository name. GitHub's rules are looser than an owner's: letters,
|
|
2027
|
+
* numbers, hyphens, underscores, and periods, up to 100 characters. `.` and `..`
|
|
2028
|
+
* are refused outright — they would traverse the API path rather than name a
|
|
2029
|
+
* repository.
|
|
2030
|
+
*/
|
|
2031
|
+
const githubRepoSchema = z.string().trim().min(1).max(100).regex(/^[A-Za-z0-9._-]+$/, "must be a repository name alone (`api`), not `owner/repo` or a URL").refine((value) => value !== "." && value !== "..", "is not a repository name");
|
|
2032
|
+
/**
|
|
2033
|
+
* A deployment environment name.
|
|
2034
|
+
*
|
|
2035
|
+
* Deliberately permissive: GitHub allows spaces and most punctuation here, and
|
|
2036
|
+
* the dashboard shows names like `prod (eu-west)`. Only the two things that would
|
|
2037
|
+
* break the request are refused — an empty name, and the path separators that
|
|
2038
|
+
* would let a name escape its URL segment. Everything else is GitHub's to reject.
|
|
2039
|
+
*/
|
|
2040
|
+
const githubEnvironmentSchema = z.string().trim().min(1).max(255).refine((value) => !value.includes("/") && !value.includes("\\"), "cannot contain a slash — that is a path separator, not part of an environment name");
|
|
2041
|
+
/**
|
|
2042
|
+
* One repository's Actions secrets.
|
|
2043
|
+
*
|
|
2044
|
+
* Every workflow in the repository can read these, including one added by a pull
|
|
2045
|
+
* request from a collaborator with write access. That is GitHub's model, not a
|
|
2046
|
+
* choice this connector makes — but it is the reason `environment` exists below,
|
|
2047
|
+
* and the reason to prefer it for anything that touches production.
|
|
2048
|
+
*/
|
|
2049
|
+
const githubActionsRepoDestinationSchema = z.object({
|
|
2050
|
+
provider: z.literal("github-actions"),
|
|
2051
|
+
kind: z.literal("repo"),
|
|
2052
|
+
/** Repository owner — a user or organization login. */
|
|
2053
|
+
owner: githubOwnerSchema,
|
|
2054
|
+
/** Repository name, without the owner. */
|
|
2055
|
+
repo: githubRepoSchema
|
|
2056
|
+
});
|
|
2057
|
+
/**
|
|
2058
|
+
* One deployment environment's Actions secrets — the narrowest scope GitHub has.
|
|
2059
|
+
*
|
|
2060
|
+
* A job reads these only by declaring `environment: <name>`, which also subjects
|
|
2061
|
+
* it to that environment's protection rules: required reviewers, wait timers, and
|
|
2062
|
+
* the branch policy. That combination is the closest GitHub gets to "this secret
|
|
2063
|
+
* is for production, and reaching it requires approval", and it is the scope to
|
|
2064
|
+
* reach for by default.
|
|
2065
|
+
*
|
|
2066
|
+
* The environment must already exist. This connector will not create one: an
|
|
2067
|
+
* environment is a deployment gate, and silently creating an unprotected one
|
|
2068
|
+
* because a name was misspelled would quietly remove the protection the operator
|
|
2069
|
+
* was relying on.
|
|
2070
|
+
*/
|
|
2071
|
+
const githubActionsEnvironmentDestinationSchema = z.object({
|
|
2072
|
+
provider: z.literal("github-actions"),
|
|
2073
|
+
kind: z.literal("environment"),
|
|
2074
|
+
owner: githubOwnerSchema,
|
|
2075
|
+
repo: githubRepoSchema,
|
|
2076
|
+
/** Environment name, exactly as the repository's Settings → Environments shows it. */
|
|
2077
|
+
environment: githubEnvironmentSchema
|
|
2078
|
+
});
|
|
2079
|
+
/**
|
|
2080
|
+
* An organization's Actions secrets.
|
|
2081
|
+
*
|
|
2082
|
+
* The widest scope in the product, and the only destination on any provider that
|
|
2083
|
+
* can hand a value to repositories nobody named. Read {@link
|
|
2084
|
+
* GITHUB_ACTIONS_VISIBILITIES} before using it.
|
|
2085
|
+
*
|
|
2086
|
+
* `selectedRepositoryIds` takes numeric repository **ids**, not names, because
|
|
2087
|
+
* that is what GitHub's API takes. An id is visible at
|
|
2088
|
+
* `GET /repos/{owner}/{repo}` as `id`, and in the dashboard nowhere at all —
|
|
2089
|
+
* which is friction worth accepting rather than resolving names to ids here: name
|
|
2090
|
+
* resolution would mean this connector picking which repository an ambiguous
|
|
2091
|
+
* name meant, and getting that wrong widens a secret's reach silently.
|
|
2092
|
+
*/
|
|
2093
|
+
const githubActionsOrgDestinationSchema = z.object({
|
|
2094
|
+
provider: z.literal("github-actions"),
|
|
2095
|
+
kind: z.literal("org"),
|
|
2096
|
+
/** Organization login. */
|
|
2097
|
+
org: githubOwnerSchema,
|
|
2098
|
+
/** Which repositories may read these secrets. Stated, never defaulted. */
|
|
2099
|
+
visibility: z.enum(GITHUB_ACTIONS_VISIBILITIES),
|
|
2100
|
+
/** Numeric repository ids, required when `visibility` is `selected`. */
|
|
2101
|
+
selectedRepositoryIds: z.array(z.number().int().positive()).max(500).optional()
|
|
2102
|
+
}).refine((dest) => dest.visibility !== "selected" || dest.selectedRepositoryIds !== void 0 && dest.selectedRepositoryIds.length > 0, {
|
|
2103
|
+
message: "selected visibility needs at least one repository id",
|
|
2104
|
+
path: ["selectedRepositoryIds"]
|
|
2105
|
+
}).refine((dest) => dest.visibility === "selected" || dest.selectedRepositoryIds === void 0, {
|
|
2106
|
+
message: "repository ids only apply to selected visibility — remove them, or select it",
|
|
2107
|
+
path: ["selectedRepositoryIds"]
|
|
2108
|
+
});
|
|
2109
|
+
const githubActionsDestinationSchema = z.discriminatedUnion("kind", [
|
|
2110
|
+
githubActionsRepoDestinationSchema,
|
|
2111
|
+
githubActionsEnvironmentDestinationSchema,
|
|
2112
|
+
githubActionsOrgDestinationSchema
|
|
2113
|
+
]);
|
|
2114
|
+
/**
|
|
2115
|
+
* How a binding lays its secrets out in Secret Manager. The same two shapes the
|
|
2116
|
+
* AWS Secrets Manager destination offers, and for the same reasons:
|
|
2117
|
+
*
|
|
2118
|
+
* - `secret-per-name` — one GCP secret per seekrit secret. The direct
|
|
2119
|
+
* translation, and what Cloud Run's `--set-secrets` and GKE's Secret Manager
|
|
2120
|
+
* CSI driver mount one at a time.
|
|
2121
|
+
* - `json-bundle` — every value as one JSON object in a single secret. Costs one
|
|
2122
|
+
* active version instead of fifty, which is the whole billing unit here.
|
|
2123
|
+
*/
|
|
2124
|
+
const GCP_SECRET_MANAGER_LAYOUTS = ["secret-per-name", "json-bundle"];
|
|
2125
|
+
/**
|
|
2126
|
+
* Where Google keeps the copies of a secret. Chosen at creation and
|
|
2127
|
+
* **immutable** afterwards — changing it means deleting the secret and letting
|
|
2128
|
+
* the next run recreate it.
|
|
2129
|
+
*
|
|
2130
|
+
* - `automatic` — Google picks the locations. One billable replica, and what
|
|
2131
|
+
* you want unless a policy says otherwise.
|
|
2132
|
+
* - `user-managed` — the binding names the regions. This is how data residency
|
|
2133
|
+
* is expressed for global secrets, and each region is billed as its own
|
|
2134
|
+
* active version.
|
|
2135
|
+
*/
|
|
2136
|
+
const GCP_REPLICATION_POLICIES = ["automatic", "user-managed"];
|
|
2137
|
+
/**
|
|
2138
|
+
* A Secret Manager secret ID. Google's own rule, quoted from the API reference:
|
|
2139
|
+
* "a string with a maximum length of 255 characters and can contain uppercase
|
|
2140
|
+
* and lowercase letters, numerals, and the hyphen (`-`) and underscore (`_`)
|
|
2141
|
+
* characters."
|
|
2142
|
+
*
|
|
2143
|
+
* Notably **no slashes and no dots**, which is what makes this a different
|
|
2144
|
+
* field from AWS's `pathPrefix` rather than the same idea renamed: a Secret
|
|
2145
|
+
* Manager namespace is spelled `prod-storefront-DB_URL`, not
|
|
2146
|
+
* `prod/storefront/DB_URL`.
|
|
2147
|
+
*/
|
|
2148
|
+
const gcpSecretIdSchema = z.string().trim().min(1).max(255).regex(/^[A-Za-z0-9_-]+$/, "may contain letters, digits, hyphens, and underscores");
|
|
2149
|
+
/**
|
|
2150
|
+
* A GCP region for a user-managed replica (`us-east1`, `europe-west4`,
|
|
2151
|
+
* `northamerica-northeast1`). Validated by shape rather than against a list,
|
|
2152
|
+
* because Google adds regions faster than we ship — a name Secret Manager does
|
|
2153
|
+
* not know is refused by Google with a clear message at creation.
|
|
2154
|
+
*/
|
|
2155
|
+
const gcpLocationSchema = z.string().trim().regex(/^[a-z]+-[a-z]+\d+$/, "must be a GCP region ID, e.g. us-east1");
|
|
2156
|
+
/**
|
|
2157
|
+
* A Cloud KMS key, as its full resource name — the only form the API accepts:
|
|
2158
|
+
* `projects/p/locations/l/keyRings/r/cryptoKeys/k`.
|
|
2159
|
+
*
|
|
2160
|
+
* Stricter than AWS's `kmsKeyId` (which tolerates five spellings) because
|
|
2161
|
+
* Google tolerates exactly one, and because a key in the wrong *location* is
|
|
2162
|
+
* rejected at creation: an automatic-replication secret needs a `global` key,
|
|
2163
|
+
* and a user-managed replica needs one in its own region.
|
|
2164
|
+
*/
|
|
2165
|
+
const gcpKmsKeyNameSchema = z.string().trim().max(1024).regex(/^projects\/[^/]+\/locations\/[^/]+\/keyRings\/[^/]+\/cryptoKeys\/[^/]+$/, "must be a full Cloud KMS key name (projects/…/locations/…/keyRings/…/cryptoKeys/…)");
|
|
2166
|
+
/**
|
|
2167
|
+
* Where inside a project's Secret Manager a binding writes.
|
|
2168
|
+
*
|
|
2169
|
+
* ## Every push would otherwise cost a version
|
|
2170
|
+
*
|
|
2171
|
+
* Secret Manager has no "set the value" call — only `addVersion`, which appends.
|
|
2172
|
+
* A run pushes the whole environment (never a diff), so changing one secret in
|
|
2173
|
+
* an environment of fifty would leave fifty new versions behind, forty-nine of
|
|
2174
|
+
* them identical to their predecessors, each one billed for as long as it stays
|
|
2175
|
+
* active.
|
|
2176
|
+
*
|
|
2177
|
+
* So this connector writes a version only when the value actually changed,
|
|
2178
|
+
* decided from a keyed digest it keeps in the secret's own **annotations** — see
|
|
2179
|
+
* `apps/api/src/lib/sync/connectors/gcp-secret-manager.ts` for why it is keyed
|
|
2180
|
+
* and what that costs. `pruneVersions` is the other half of the bill: with it
|
|
2181
|
+
* on, the version a push supersedes is destroyed as soon as the new one lands,
|
|
2182
|
+
* so a secret keeps exactly one active version.
|
|
2183
|
+
*/
|
|
2184
|
+
const gcpSecretManagerDestinationSchema = z.object({
|
|
2185
|
+
provider: z.literal("gcp-secret-manager"),
|
|
2186
|
+
layout: z.enum(GCP_SECRET_MANAGER_LAYOUTS).default("secret-per-name"),
|
|
2187
|
+
/**
|
|
2188
|
+
* `secret-per-name` only: prepended to every secret ID, e.g.
|
|
2189
|
+
* `prod-storefront-`. Optional, but strongly advised in a project that holds
|
|
2190
|
+
* anything else — without it a binding writes at the root of a namespace it
|
|
2191
|
+
* does not own, and Secret Manager has no folders to hide behind.
|
|
2192
|
+
*/
|
|
2193
|
+
idPrefix: z.string().trim().max(200).regex(/^[A-Za-z0-9_-]*$/, "may contain letters, digits, hyphens, and underscores").optional(),
|
|
2194
|
+
/** `json-bundle` only: the one secret that holds every value, e.g. `prod-storefront-env`. */
|
|
2195
|
+
secretId: gcpSecretIdSchema.optional(),
|
|
2196
|
+
replication: z.enum(GCP_REPLICATION_POLICIES).default("automatic"),
|
|
2197
|
+
/** `user-managed` only: the regions to replicate to. At least one. */
|
|
2198
|
+
locations: z.array(gcpLocationSchema).min(1).max(16).optional(),
|
|
2199
|
+
/** Customer-managed encryption key. Omitted means Google-managed keys. */
|
|
2200
|
+
kmsKeyName: gcpKmsKeyNameSchema.optional(),
|
|
2201
|
+
/** Destroy the version each push supersedes, keeping one active version. */
|
|
2202
|
+
pruneVersions: z.boolean().optional()
|
|
2203
|
+
}).refine((d) => d.layout !== "json-bundle" || d.secretId !== void 0, {
|
|
2204
|
+
message: "a json-bundle destination needs the ID of the secret to write",
|
|
2205
|
+
path: ["secretId"]
|
|
2206
|
+
}).refine((d) => d.replication !== "user-managed" || (d.locations?.length ?? 0) > 0, {
|
|
2207
|
+
message: "user-managed replication needs at least one location",
|
|
2208
|
+
path: ["locations"]
|
|
2209
|
+
}).refine((d) => d.kmsKeyName === void 0 || d.replication === "automatic" || (d.locations?.length ?? 0) === 1, {
|
|
2210
|
+
message: "a customer-managed key covers one location — use automatic replication, or a single location",
|
|
2211
|
+
path: ["kmsKeyName"]
|
|
2212
|
+
});
|
|
2213
|
+
const syncDestinationSchema = z.discriminatedUnion("provider", [
|
|
2214
|
+
vercelDestinationSchema,
|
|
2215
|
+
cloudflareWorkersDestinationSchema,
|
|
2216
|
+
cloudflarePagesDestinationSchema,
|
|
2217
|
+
cloudflareSecretsStoreDestinationSchema,
|
|
2218
|
+
railwayDestinationSchema,
|
|
2219
|
+
awsSecretsManagerDestinationSchema,
|
|
2220
|
+
awsParameterStoreDestinationSchema,
|
|
2221
|
+
renderDestinationSchema,
|
|
2222
|
+
flyDestinationSchema,
|
|
2223
|
+
northflankDestinationSchema,
|
|
2224
|
+
digitalOceanDestinationSchema,
|
|
2225
|
+
herokuDestinationSchema,
|
|
2226
|
+
netlifyDestinationSchema,
|
|
2227
|
+
bunnyshellDestinationSchema,
|
|
2228
|
+
githubActionsDestinationSchema,
|
|
2229
|
+
gcpSecretManagerDestinationSchema
|
|
2230
|
+
]);
|
|
1084
2231
|
/**
|
|
1085
2232
|
* How seekrit secret names become destination key names. Applied in order:
|
|
1086
2233
|
* explicit `rename` (wins outright), then `prefix`/`suffix`, then `case`.
|
|
@@ -1458,6 +2605,43 @@ async function generateDataKey(material, ref) {
|
|
|
1458
2605
|
};
|
|
1459
2606
|
}
|
|
1460
2607
|
//#endregion
|
|
2608
|
+
//#region ../../packages/crypto/src/random.ts
|
|
2609
|
+
const ALPHABETS = {
|
|
2610
|
+
alphanumeric: "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz",
|
|
2611
|
+
hex: "0123456789abcdef",
|
|
2612
|
+
base64url: "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-_",
|
|
2613
|
+
/**
|
|
2614
|
+
* Alphanumerics plus punctuation chosen to survive being pasted anywhere a
|
|
2615
|
+
* secret goes: no quote of either kind, no backslash, backtick, `$`, or
|
|
2616
|
+
* whitespace, so the value can't break out of a shell word, a SQL literal, a
|
|
2617
|
+
* URL component, or a `.env` line.
|
|
2618
|
+
*/
|
|
2619
|
+
printable: "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-._~!*+="
|
|
2620
|
+
};
|
|
2621
|
+
/**
|
|
2622
|
+
* A cryptographically random string of `length` characters drawn uniformly from
|
|
2623
|
+
* `alphabet` (default `alphanumeric`, ≈5.95 bits/char — 32 chars ≈ 190 bits).
|
|
2624
|
+
*
|
|
2625
|
+
* Uses rejection sampling: bytes at or above the largest multiple of the
|
|
2626
|
+
* alphabet size are discarded rather than folded, so `% n` introduces no modulo
|
|
2627
|
+
* bias toward the low end of the alphabet.
|
|
2628
|
+
*/
|
|
2629
|
+
function generateSecretValue(length, alphabet = "alphanumeric") {
|
|
2630
|
+
if (!Number.isInteger(length) || length < 1) throw new RangeError("length must be a positive integer");
|
|
2631
|
+
const chars = ALPHABETS[alphabet];
|
|
2632
|
+
const n = chars.length;
|
|
2633
|
+
const limit = 256 - 256 % n;
|
|
2634
|
+
let out = "";
|
|
2635
|
+
while (out.length < length) {
|
|
2636
|
+
const bytes = crypto.getRandomValues(new Uint8Array(length - out.length));
|
|
2637
|
+
for (const byte of bytes) {
|
|
2638
|
+
if (byte < limit) out += chars[byte % n];
|
|
2639
|
+
if (out.length === length) break;
|
|
2640
|
+
}
|
|
2641
|
+
}
|
|
2642
|
+
return out;
|
|
2643
|
+
}
|
|
2644
|
+
//#endregion
|
|
1461
2645
|
//#region ../../packages/crypto/src/mysql.ts
|
|
1462
2646
|
/**
|
|
1463
2647
|
* Client-side construction of a MySQL/MariaDB `mysql_native_password`
|
|
@@ -1487,7 +2671,6 @@ async function generateDataKey(material, ref) {
|
|
|
1487
2671
|
* SCRAM helper does, with no hand-rolled hash primitive.
|
|
1488
2672
|
*/
|
|
1489
2673
|
const DEFAULT_PASSWORD_LENGTH$1 = 32;
|
|
1490
|
-
const PASSWORD_ALPHABET$1 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
|
|
1491
2674
|
async function sha1(data) {
|
|
1492
2675
|
return new Uint8Array(await crypto.subtle.digest("SHA-1", data));
|
|
1493
2676
|
}
|
|
@@ -1496,17 +2679,6 @@ function toUpperHex(bytes) {
|
|
|
1496
2679
|
for (const b of bytes) hex += b.toString(16).padStart(2, "0");
|
|
1497
2680
|
return hex.toUpperCase();
|
|
1498
2681
|
}
|
|
1499
|
-
function randomPassword$1(length) {
|
|
1500
|
-
let out = "";
|
|
1501
|
-
while (out.length < length) {
|
|
1502
|
-
const bytes = crypto.getRandomValues(new Uint8Array(length - out.length));
|
|
1503
|
-
for (const byte of bytes) {
|
|
1504
|
-
if (byte < 248) out += PASSWORD_ALPHABET$1[byte % 62];
|
|
1505
|
-
if (out.length === length) break;
|
|
1506
|
-
}
|
|
1507
|
-
}
|
|
1508
|
-
return out;
|
|
1509
|
-
}
|
|
1510
2682
|
/**
|
|
1511
2683
|
* Compute the `mysql_native_password` auth string `*<UPPER(HEX(SHA1(SHA1(pw))))>`
|
|
1512
2684
|
* for a known password. Pass the result straight to
|
|
@@ -1520,7 +2692,7 @@ async function mysqlNativePasswordVerifier(password) {
|
|
|
1520
2692
|
* — the client-side half of a Vault-style dynamic MySQL credential.
|
|
1521
2693
|
*/
|
|
1522
2694
|
async function generateMysqlCredential(options = {}) {
|
|
1523
|
-
const password =
|
|
2695
|
+
const password = generateSecretValue(options.length ?? DEFAULT_PASSWORD_LENGTH$1);
|
|
1524
2696
|
return {
|
|
1525
2697
|
password,
|
|
1526
2698
|
verifier: await mysqlNativePasswordVerifier(password)
|
|
@@ -1618,7 +2790,6 @@ const LOG = /* @__PURE__ */ new Uint8Array(256);
|
|
|
1618
2790
|
}
|
|
1619
2791
|
const SALT_LENGTH = 16;
|
|
1620
2792
|
const DEFAULT_PASSWORD_LENGTH = 32;
|
|
1621
|
-
const PASSWORD_ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
|
|
1622
2793
|
async function hmacSha256(key, message) {
|
|
1623
2794
|
const k = await crypto.subtle.importKey("raw", key, {
|
|
1624
2795
|
name: "HMAC",
|
|
@@ -1639,17 +2810,6 @@ async function saltPassword(password, salt, iterations) {
|
|
|
1639
2810
|
}, material, 256);
|
|
1640
2811
|
return new Uint8Array(bits);
|
|
1641
2812
|
}
|
|
1642
|
-
function randomPassword(length) {
|
|
1643
|
-
let out = "";
|
|
1644
|
-
while (out.length < length) {
|
|
1645
|
-
const bytes = crypto.getRandomValues(new Uint8Array(length - out.length));
|
|
1646
|
-
for (const byte of bytes) {
|
|
1647
|
-
if (byte < 248) out += PASSWORD_ALPHABET[byte % 62];
|
|
1648
|
-
if (out.length === length) break;
|
|
1649
|
-
}
|
|
1650
|
-
}
|
|
1651
|
-
return out;
|
|
1652
|
-
}
|
|
1653
2813
|
/**
|
|
1654
2814
|
* Compute the `SCRAM-SHA-256$<i>:<salt>$<StoredKey>:<ServerKey>` verifier for a
|
|
1655
2815
|
* known password. Pass the result straight to `CREATE ROLE … PASSWORD`.
|
|
@@ -1667,7 +2827,7 @@ async function scramSha256Verifier(password, options = {}) {
|
|
|
1667
2827
|
* client-side half of a Vault-style dynamic Postgres credential.
|
|
1668
2828
|
*/
|
|
1669
2829
|
async function generatePostgresCredential(options = {}) {
|
|
1670
|
-
const password =
|
|
2830
|
+
const password = generateSecretValue(options.length ?? DEFAULT_PASSWORD_LENGTH);
|
|
1671
2831
|
const iterations = options.iterations ?? 4096;
|
|
1672
2832
|
return {
|
|
1673
2833
|
password,
|
|
@@ -1796,7 +2956,7 @@ function isServiceToken(value) {
|
|
|
1796
2956
|
}
|
|
1797
2957
|
//#endregion
|
|
1798
2958
|
//#region ../cli/package.json
|
|
1799
|
-
var version$1 = "0.
|
|
2959
|
+
var version$1 = "0.42.0";
|
|
1800
2960
|
const PROJECT_FILE = "seekrit.json";
|
|
1801
2961
|
function globalConfigPath() {
|
|
1802
2962
|
return join(process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"), "seekrit", "config.json");
|
|
@@ -2157,6 +3317,54 @@ var SeekritClient = class {
|
|
|
2157
3317
|
deleteToken(orgId, tokenId) {
|
|
2158
3318
|
return this.request("DELETE", `/v1/orgs/${orgId}/tokens/${tokenId}/permanent`);
|
|
2159
3319
|
}
|
|
3320
|
+
listHoneyTokens(orgId) {
|
|
3321
|
+
return this.request("GET", `/v1/orgs/${orgId}/honey-tokens`);
|
|
3322
|
+
}
|
|
3323
|
+
createHoneyToken(orgId, input) {
|
|
3324
|
+
return this.request("POST", `/v1/orgs/${orgId}/honey-tokens`, input);
|
|
3325
|
+
}
|
|
3326
|
+
/** Delete a decoy outright — there is no access to revoke first. */
|
|
3327
|
+
deleteHoneyToken(orgId, honeyTokenId) {
|
|
3328
|
+
return this.request("DELETE", `/v1/orgs/${orgId}/honey-tokens/${honeyTokenId}`);
|
|
3329
|
+
}
|
|
3330
|
+
listAgents(orgId) {
|
|
3331
|
+
return this.request("GET", `/v1/orgs/${orgId}/agents`);
|
|
3332
|
+
}
|
|
3333
|
+
createAgent(orgId, input) {
|
|
3334
|
+
return this.request("POST", `/v1/orgs/${orgId}/agents`, input);
|
|
3335
|
+
}
|
|
3336
|
+
getAgent(orgId, agentId) {
|
|
3337
|
+
return this.request("GET", `/v1/orgs/${orgId}/agents/${agentId}`);
|
|
3338
|
+
}
|
|
3339
|
+
updateAgent(orgId, agentId, input) {
|
|
3340
|
+
return this.request("PATCH", `/v1/orgs/${orgId}/agents/${agentId}`, input);
|
|
3341
|
+
}
|
|
3342
|
+
deleteAgent(orgId, agentId) {
|
|
3343
|
+
return this.request("DELETE", `/v1/orgs/${orgId}/agents/${agentId}`);
|
|
3344
|
+
}
|
|
3345
|
+
/** Published versions, newest first. Append-only; nothing here is rewritten. */
|
|
3346
|
+
listAgentPolicies(orgId, agentId) {
|
|
3347
|
+
return this.request("GET", `/v1/orgs/${orgId}/agents/${agentId}/policies`);
|
|
3348
|
+
}
|
|
3349
|
+
/**
|
|
3350
|
+
* Publish a bundle signed in the browser.
|
|
3351
|
+
*
|
|
3352
|
+
* The signing happens client-side (`signAgentPolicy` in `@seekrit/core`) with
|
|
3353
|
+
* the publishing admin's own key, so the API receives an opaque envelope it
|
|
3354
|
+
* cannot forge. A version mismatch answers `409`: the version is inside the
|
|
3355
|
+
* signature, so a concurrent publish has to be re-signed, not patched.
|
|
3356
|
+
*/
|
|
3357
|
+
publishAgentPolicy(orgId, agentId, bundle) {
|
|
3358
|
+
return this.request("POST", `/v1/orgs/${orgId}/agents/${agentId}/policies`, { bundle });
|
|
3359
|
+
}
|
|
3360
|
+
/** Republish an earlier version's bundle as the newest version. */
|
|
3361
|
+
rollbackAgentPolicy(orgId, agentId, version) {
|
|
3362
|
+
return this.request("POST", `/v1/orgs/${orgId}/agents/${agentId}/policies/${version}/rollback`);
|
|
3363
|
+
}
|
|
3364
|
+
/** The caller's own signing thumbprint, for the trust-anchor snippet. */
|
|
3365
|
+
getMyPolicySigner(orgId) {
|
|
3366
|
+
return this.request("GET", `/v1/orgs/${orgId}/agents/signers/me`);
|
|
3367
|
+
}
|
|
2160
3368
|
/** Keys the caller can see: all org keys for admins, granted keys otherwise. */
|
|
2161
3369
|
listKmsKeys(orgId) {
|
|
2162
3370
|
return this.request("GET", `/v1/orgs/${orgId}/kms/keys`);
|
|
@@ -2267,6 +3475,39 @@ var SeekritClient = class {
|
|
|
2267
3475
|
revokeLease(orgId, leaseId) {
|
|
2268
3476
|
return this.request("DELETE", `/v1/orgs/${orgId}/leases/${leaseId}`);
|
|
2269
3477
|
}
|
|
3478
|
+
/**
|
|
3479
|
+
* The rotator public key (the broker DO's), plus the environments that have
|
|
3480
|
+
* already granted it. Wrap an environment's DEK to this key client-side before
|
|
3481
|
+
* configuring rotation — that wrap IS the grant, and the server can't make it.
|
|
3482
|
+
*/
|
|
3483
|
+
getRotatorKey(orgId) {
|
|
3484
|
+
return this.request("GET", `/v1/orgs/${orgId}/rotation/rotator-key`);
|
|
3485
|
+
}
|
|
3486
|
+
listRotations(orgId) {
|
|
3487
|
+
return this.request("GET", `/v1/orgs/${orgId}/rotation`);
|
|
3488
|
+
}
|
|
3489
|
+
getRotation(orgId, rotationId) {
|
|
3490
|
+
return this.request("GET", `/v1/orgs/${orgId}/rotation/${rotationId}`);
|
|
3491
|
+
}
|
|
3492
|
+
/**
|
|
3493
|
+
* Configure (or replace) a secret's rotation policy. `version` comes back only
|
|
3494
|
+
* when `rotateNow` was set — a rotated secret's new version number, never its
|
|
3495
|
+
* value.
|
|
3496
|
+
*/
|
|
3497
|
+
configureRotation(orgId, input) {
|
|
3498
|
+
return this.request("POST", `/v1/orgs/${orgId}/rotation`, input);
|
|
3499
|
+
}
|
|
3500
|
+
updateRotation(orgId, rotationId, input) {
|
|
3501
|
+
return this.request("PATCH", `/v1/orgs/${orgId}/rotation/${rotationId}`, input);
|
|
3502
|
+
}
|
|
3503
|
+
/** Rotate now. Returns the new version — the value stays where it belongs. */
|
|
3504
|
+
rotateSecretNow(orgId, rotationId) {
|
|
3505
|
+
return this.request("POST", `/v1/orgs/${orgId}/rotation/${rotationId}/rotate`);
|
|
3506
|
+
}
|
|
3507
|
+
/** Disable rotation. `rotatorRevoked` reports whether the broker's key grant went too. */
|
|
3508
|
+
disableRotation(orgId, rotationId) {
|
|
3509
|
+
return this.request("DELETE", `/v1/orgs/${orgId}/rotation/${rotationId}`);
|
|
3510
|
+
}
|
|
2270
3511
|
listAudit(orgId, query = {}) {
|
|
2271
3512
|
const params = new URLSearchParams();
|
|
2272
3513
|
if (query.cursor) params.set("cursor", query.cursor);
|
|
@@ -2323,6 +3564,19 @@ var SeekritClient = class {
|
|
|
2323
3564
|
cancelSubscription(orgId) {
|
|
2324
3565
|
return this.request("POST", `/v1/orgs/${orgId}/billing/cancel`);
|
|
2325
3566
|
}
|
|
3567
|
+
/**
|
|
3568
|
+
* Redeem a promo code, comping the org onto the plan the code grants.
|
|
3569
|
+
* Admin-only. Casing, spaces, and dashes are normalized server-side, so pass
|
|
3570
|
+
* the code as the user typed it. Returns the refreshed billing view.
|
|
3571
|
+
*
|
|
3572
|
+
* Every invalid code fails the same way regardless of why (unknown, expired,
|
|
3573
|
+
* fully redeemed, already used by this org) — the API deliberately won't
|
|
3574
|
+
* confirm that a code exists. Show the returned message as-is rather than
|
|
3575
|
+
* guessing at a more specific one.
|
|
3576
|
+
*/
|
|
3577
|
+
redeemPromoCode(orgId, input) {
|
|
3578
|
+
return this.request("POST", `/v1/orgs/${orgId}/billing/promo`, input);
|
|
3579
|
+
}
|
|
2326
3580
|
};
|
|
2327
3581
|
//#endregion
|
|
2328
3582
|
//#region ../cli/src/io.ts
|
|
@@ -3891,7 +5145,7 @@ async function runMcpServer(options = {}) {
|
|
|
3891
5145
|
* `seekrit mcp`. tsdown bundles the shared server source in at build time, so the
|
|
3892
5146
|
* published package is self-contained and needs no `@seekrit/cli` install.
|
|
3893
5147
|
*/
|
|
3894
|
-
runMcpServer({ version: "0.
|
|
5148
|
+
runMcpServer({ version: "0.7.0" }).catch((err) => {
|
|
3895
5149
|
const message = err instanceof Error ? err.message : String(err);
|
|
3896
5150
|
process.stderr.write(`seekrit-mcp: fatal: ${message}\n`);
|
|
3897
5151
|
process.exit(1);
|