@sequenceholdings/studio-cli 0.1.9
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/README.md +258 -0
- package/dist/artifact/delegate.d.ts +25 -0
- package/dist/artifact/delegate.js +263 -0
- package/dist/atlas-client.d.ts +44 -0
- package/dist/atlas-client.js +173 -0
- package/dist/auth-cmds/commands.d.ts +15 -0
- package/dist/auth-cmds/commands.js +249 -0
- package/dist/auth.d.ts +26 -0
- package/dist/auth.js +171 -0
- package/dist/bin.d.ts +2 -0
- package/dist/bin.js +8 -0
- package/dist/cli-errors.d.ts +5 -0
- package/dist/cli-errors.js +78 -0
- package/dist/config.d.ts +44 -0
- package/dist/config.js +103 -0
- package/dist/env-flags.d.ts +8 -0
- package/dist/env-flags.js +47 -0
- package/dist/functions/bundle.d.ts +30 -0
- package/dist/functions/bundle.js +137 -0
- package/dist/functions/commands.d.ts +86 -0
- package/dist/functions/commands.js +999 -0
- package/dist/functions/egress-preview.d.ts +32 -0
- package/dist/functions/egress-preview.js +54 -0
- package/dist/functions/lockfile-origin.d.ts +16 -0
- package/dist/functions/lockfile-origin.js +45 -0
- package/dist/functions/manifest.d.ts +89 -0
- package/dist/functions/manifest.js +586 -0
- package/dist/functions/secret-reconcile.d.ts +79 -0
- package/dist/functions/secret-reconcile.js +86 -0
- package/dist/main.d.ts +14 -0
- package/dist/main.js +129 -0
- package/dist/orm/delegate.d.ts +8 -0
- package/dist/orm/delegate.js +61 -0
- package/dist/pat-hints.d.ts +17 -0
- package/dist/pat-hints.js +28 -0
- package/dist/preview.d.ts +89 -0
- package/dist/preview.js +291 -0
- package/dist/process/agent-loader.d.ts +24 -0
- package/dist/process/agent-loader.js +57 -0
- package/dist/process/build.d.ts +14 -0
- package/dist/process/build.js +368 -0
- package/dist/process/codegen.d.ts +18 -0
- package/dist/process/codegen.js +270 -0
- package/dist/process/commands.d.ts +47 -0
- package/dist/process/commands.js +786 -0
- package/dist/process/discover.d.ts +32 -0
- package/dist/process/discover.js +131 -0
- package/dist/process/lint.d.ts +39 -0
- package/dist/process/lint.js +485 -0
- package/dist/process/local-bundle.d.ts +17 -0
- package/dist/process/local-bundle.js +65 -0
- package/dist/process/plan-diff.d.ts +82 -0
- package/dist/process/plan-diff.js +333 -0
- package/dist/process/resolve-process-pin.d.ts +11 -0
- package/dist/process/resolve-process-pin.js +63 -0
- package/dist/process/simulate.d.ts +50 -0
- package/dist/process/simulate.js +328 -0
- package/dist/prompt.d.ts +35 -0
- package/dist/prompt.js +65 -0
- package/dist/repos/commands.d.ts +49 -0
- package/dist/repos/commands.js +548 -0
- package/dist/repos/git-clone.d.ts +10 -0
- package/dist/repos/git-clone.js +49 -0
- package/dist/secrets/commands.d.ts +24 -0
- package/dist/secrets/commands.js +704 -0
- package/dist/templates/process/example-process/process.ts +43 -0
- package/dist/templates/process/package.json +23 -0
- package/dist/templates/process/pnpm-workspace.yaml +21 -0
- package/dist/templates/process/tsconfig.json +17 -0
- package/package.json +78 -0
- package/templates/process/example-process/process.ts +43 -0
- package/templates/process/package.json +23 -0
- package/templates/process/pnpm-workspace.yaml +21 -0
- package/templates/process/tsconfig.json +17 -0
|
@@ -0,0 +1,586 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
/**
|
|
3
|
+
* CLI-side mirror of the server manifest schema at
|
|
4
|
+
* atlas/src/server/services/managed-functions/manifest.ts — same pattern
|
|
5
|
+
* as artifact-studio's duplicated manifest. Keep the two in sync.
|
|
6
|
+
*/
|
|
7
|
+
export const MF_MANIFEST_FILENAME = 'managed-function.yml';
|
|
8
|
+
const SECRET_NAME_RE = /^[A-Z][A-Z0-9_]*$/;
|
|
9
|
+
/** RFC 1035 label: alnum, optional inner dashes, max 63 chars. */
|
|
10
|
+
const DNS_LABEL_RE = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/;
|
|
11
|
+
/**
|
|
12
|
+
* CLI-side copy of the server's egress entry validation (see
|
|
13
|
+
* atlas/src/server/services/managed-functions/egress.ts). Returns the
|
|
14
|
+
* failure reason, or null when the entry is valid.
|
|
15
|
+
*/
|
|
16
|
+
export function egressEntryError(input) {
|
|
17
|
+
const raw = input.trim();
|
|
18
|
+
if (!raw)
|
|
19
|
+
return 'egress entry is empty';
|
|
20
|
+
if (looksLikeIpv4EntryCli(raw)) {
|
|
21
|
+
return egressIpError(raw);
|
|
22
|
+
}
|
|
23
|
+
return egressHostError(input);
|
|
24
|
+
}
|
|
25
|
+
const IPV4_OCTET_RE = /^(25[0-5]|2[0-4]\d|1?\d?\d)$/;
|
|
26
|
+
function parseIpv4Cli(ip) {
|
|
27
|
+
const parts = ip.split('.');
|
|
28
|
+
if (parts.length !== 4)
|
|
29
|
+
return null;
|
|
30
|
+
const octets = [];
|
|
31
|
+
for (const part of parts) {
|
|
32
|
+
if (!IPV4_OCTET_RE.test(part))
|
|
33
|
+
return null;
|
|
34
|
+
octets.push(Number(part));
|
|
35
|
+
}
|
|
36
|
+
return octets;
|
|
37
|
+
}
|
|
38
|
+
function looksLikeIpv4EntryCli(raw) {
|
|
39
|
+
if (raw.includes('://')) {
|
|
40
|
+
try {
|
|
41
|
+
return parseIpv4Cli(new URL(raw).hostname) !== null;
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
const head = raw.split('/')[0].split(':')[0];
|
|
48
|
+
return parseIpv4Cli(head) !== null || /^(\d{1,3}\.){3}\d{1,3}\/\d{1,2}$/.test(raw.split(':')[0]);
|
|
49
|
+
}
|
|
50
|
+
function ipv4ToIntCli(octets) {
|
|
51
|
+
return ((octets[0] << 24) | (octets[1] << 16) | (octets[2] << 8) | octets[3]) >>> 0;
|
|
52
|
+
}
|
|
53
|
+
function intToIpv4Cli(value) {
|
|
54
|
+
return [
|
|
55
|
+
(value >>> 24) & 0xff,
|
|
56
|
+
(value >>> 16) & 0xff,
|
|
57
|
+
(value >>> 8) & 0xff,
|
|
58
|
+
value & 0xff,
|
|
59
|
+
].join('.');
|
|
60
|
+
}
|
|
61
|
+
function parseCidrCli(cidr) {
|
|
62
|
+
const slash = cidr.lastIndexOf('/');
|
|
63
|
+
const ipPart = slash === -1 ? cidr : cidr.slice(0, slash);
|
|
64
|
+
const prefixPart = slash === -1 ? '32' : cidr.slice(slash + 1);
|
|
65
|
+
const octets = parseIpv4Cli(ipPart);
|
|
66
|
+
if (!octets)
|
|
67
|
+
return null;
|
|
68
|
+
const prefix = Number(prefixPart);
|
|
69
|
+
if (!Number.isInteger(prefix) || prefix < 0 || prefix > 32)
|
|
70
|
+
return null;
|
|
71
|
+
const mask = prefix === 0 ? 0 : (0xffffffff << (32 - prefix)) >>> 0;
|
|
72
|
+
const ip = ipv4ToIntCli(octets);
|
|
73
|
+
if ((ip & ~mask) !== 0)
|
|
74
|
+
return null;
|
|
75
|
+
return { network: ip & mask, mask, prefix };
|
|
76
|
+
}
|
|
77
|
+
function cidrContainsCli(outerCidr, innerCidr) {
|
|
78
|
+
const outer = parseCidrCli(outerCidr);
|
|
79
|
+
const inner = parseCidrCli(innerCidr);
|
|
80
|
+
if (!outer || !inner)
|
|
81
|
+
return false;
|
|
82
|
+
return inner.prefix >= outer.prefix && (inner.network & outer.mask) === outer.network;
|
|
83
|
+
}
|
|
84
|
+
const RESERVED_CIDRS_CLI = [
|
|
85
|
+
{ cidr: '10.0.0.0/8', reason: 'private (RFC1918)' },
|
|
86
|
+
{ cidr: '172.16.0.0/12', reason: 'private (RFC1918)' },
|
|
87
|
+
{ cidr: '192.168.0.0/16', reason: 'private (RFC1918)' },
|
|
88
|
+
{ cidr: '127.0.0.0/8', reason: 'loopback' },
|
|
89
|
+
{ cidr: '169.254.0.0/16', reason: 'link-local / metadata' },
|
|
90
|
+
{ cidr: '100.64.0.0/10', reason: 'carrier-grade NAT' },
|
|
91
|
+
{ cidr: '0.0.0.0/8', reason: 'reserved' },
|
|
92
|
+
{ cidr: '224.0.0.0/4', reason: 'multicast' },
|
|
93
|
+
{ cidr: '240.0.0.0/4', reason: 'reserved' },
|
|
94
|
+
];
|
|
95
|
+
function normalizeEgressIpCli(input) {
|
|
96
|
+
const error = egressIpErrorImpl(input);
|
|
97
|
+
if (error !== null)
|
|
98
|
+
return { error };
|
|
99
|
+
const raw = input.trim().toLowerCase();
|
|
100
|
+
let candidate;
|
|
101
|
+
if (raw.includes('://')) {
|
|
102
|
+
candidate = new URL(raw).hostname;
|
|
103
|
+
}
|
|
104
|
+
else {
|
|
105
|
+
const beforePath = raw.split('/');
|
|
106
|
+
const head = beforePath[0];
|
|
107
|
+
const bareHost = head.split(':')[0];
|
|
108
|
+
candidate =
|
|
109
|
+
beforePath.length > 1 && /^\d{1,2}$/.test(beforePath[1])
|
|
110
|
+
? `${bareHost}/${beforePath[1]}`
|
|
111
|
+
: bareHost;
|
|
112
|
+
}
|
|
113
|
+
const slash = candidate.indexOf('/');
|
|
114
|
+
const ipPart = slash === -1 ? candidate : candidate.slice(0, slash);
|
|
115
|
+
const prefix = slash === -1 ? 32 : Number(candidate.slice(slash + 1));
|
|
116
|
+
const normalized = parseCidrCli(`${ipPart}/${prefix}`);
|
|
117
|
+
if (!normalized)
|
|
118
|
+
return { error: `egress IP/CIDR is not a valid IPv4 network: ${input}` };
|
|
119
|
+
return { cidr: `${intToIpv4Cli(normalized.network)}/${prefix}` };
|
|
120
|
+
}
|
|
121
|
+
function egressIpErrorImpl(input) {
|
|
122
|
+
const raw = input.trim().toLowerCase();
|
|
123
|
+
let candidate;
|
|
124
|
+
if (raw.includes('://')) {
|
|
125
|
+
let url;
|
|
126
|
+
try {
|
|
127
|
+
url = new URL(raw);
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
return `egress entry is not a valid URL: ${input}`;
|
|
131
|
+
}
|
|
132
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
|
133
|
+
return `egress URLs must be http(s): ${input}`;
|
|
134
|
+
}
|
|
135
|
+
if (url.port !== '' && url.port !== '80' && url.port !== '443') {
|
|
136
|
+
return `egress ports other than 80/443 are not supported (the proxy only carries web ports): ${input}`;
|
|
137
|
+
}
|
|
138
|
+
candidate = url.hostname;
|
|
139
|
+
}
|
|
140
|
+
else {
|
|
141
|
+
const beforePath = raw.split('/');
|
|
142
|
+
const head = beforePath[0];
|
|
143
|
+
const [bareHost, port, ...rest] = head.split(':');
|
|
144
|
+
if (rest.length > 0)
|
|
145
|
+
return `egress IP contains unsupported characters: ${input}`;
|
|
146
|
+
if (port !== undefined && port !== '80' && port !== '443') {
|
|
147
|
+
return `egress ports other than 80/443 are not supported (the proxy only carries web ports): ${input}`;
|
|
148
|
+
}
|
|
149
|
+
if (beforePath.length > 1) {
|
|
150
|
+
// Anything after the slash must be a bare 1-2 digit prefix — reject
|
|
151
|
+
// `1.2.3.4/24:8443`, `1.2.3.4/24/x`, etc. rather than silently
|
|
152
|
+
// dropping the suffix and treating the entry as a /32 (which would
|
|
153
|
+
// also skip the port 80/443 rejection above). Mirrors the server.
|
|
154
|
+
if (beforePath.length > 2 || !/^\d{1,2}$/.test(beforePath[1])) {
|
|
155
|
+
return `egress IP/CIDR is not a valid IPv4 network: ${input}`;
|
|
156
|
+
}
|
|
157
|
+
candidate = `${bareHost}/${beforePath[1]}`;
|
|
158
|
+
}
|
|
159
|
+
else {
|
|
160
|
+
candidate = bareHost;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
const slash = candidate.indexOf('/');
|
|
164
|
+
const ipPart = slash === -1 ? candidate : candidate.slice(0, slash);
|
|
165
|
+
const prefix = slash === -1 ? 32 : Number(candidate.slice(slash + 1));
|
|
166
|
+
if (!Number.isInteger(prefix)) {
|
|
167
|
+
return `egress IP/CIDR is not a valid IPv4 network: ${input}`;
|
|
168
|
+
}
|
|
169
|
+
if (prefix < 24) {
|
|
170
|
+
return `egress CIDR prefix must be /24 or narrower (got /${prefix}): ${input}`;
|
|
171
|
+
}
|
|
172
|
+
const normalized = parseCidrCli(`${ipPart}/${prefix}`);
|
|
173
|
+
if (!normalized) {
|
|
174
|
+
return `egress IP/CIDR is not a valid IPv4 network: ${input}`;
|
|
175
|
+
}
|
|
176
|
+
const canonical = `${intToIpv4Cli(normalized.network)}/${prefix}`;
|
|
177
|
+
for (const reserved of RESERVED_CIDRS_CLI) {
|
|
178
|
+
if (cidrContainsCli(reserved.cidr, canonical)) {
|
|
179
|
+
return `egress IP/CIDR falls in ${reserved.reason} range: ${input}`;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
export function egressIpError(input) {
|
|
185
|
+
const result = normalizeEgressIpCli(input);
|
|
186
|
+
return 'error' in result ? result.error : null;
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* CLI-side copy of the server's normalizeEgressHost validation (see
|
|
190
|
+
* atlas/src/server/services/managed-functions/egress.ts) so authors get the
|
|
191
|
+
* same rejection locally at `functions deploy` time instead of a server 400.
|
|
192
|
+
* Returns the failure reason, or null when the entry is a valid egress host.
|
|
193
|
+
*/
|
|
194
|
+
export function egressHostError(input) {
|
|
195
|
+
const raw = input.trim().toLowerCase();
|
|
196
|
+
if (!raw)
|
|
197
|
+
return 'egress entry is empty';
|
|
198
|
+
let host;
|
|
199
|
+
if (raw.includes('://')) {
|
|
200
|
+
let url;
|
|
201
|
+
try {
|
|
202
|
+
url = new URL(raw);
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
return `egress entry is not a valid URL: ${input}`;
|
|
206
|
+
}
|
|
207
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
|
208
|
+
return `egress URLs must be http(s): ${input}`;
|
|
209
|
+
}
|
|
210
|
+
// The SWP gateway carries ports 80/443 only — any other port would
|
|
211
|
+
// validate here and then be unreachable at runtime. Fail loudly.
|
|
212
|
+
if (url.port !== '' && url.port !== '80' && url.port !== '443') {
|
|
213
|
+
return `egress ports other than 80/443 are not supported (the proxy only carries web ports): ${input}`;
|
|
214
|
+
}
|
|
215
|
+
host = url.hostname;
|
|
216
|
+
}
|
|
217
|
+
else {
|
|
218
|
+
const beforePath = raw.split('/')[0];
|
|
219
|
+
const [bareHost, port, ...rest] = beforePath.split(':');
|
|
220
|
+
if (rest.length > 0) {
|
|
221
|
+
return `egress host contains unsupported characters: ${input}`;
|
|
222
|
+
}
|
|
223
|
+
if (port !== undefined && port !== '80' && port !== '443') {
|
|
224
|
+
return `egress ports other than 80/443 are not supported (the proxy only carries web ports): ${input}`;
|
|
225
|
+
}
|
|
226
|
+
host = bareHost;
|
|
227
|
+
}
|
|
228
|
+
host = host.replace(/\.$/, '');
|
|
229
|
+
if (host.includes('*')) {
|
|
230
|
+
return `wildcard hosts are not supported — list each hostname explicitly: ${input}`;
|
|
231
|
+
}
|
|
232
|
+
if (!/^[a-z0-9.-]+$/.test(host)) {
|
|
233
|
+
return `egress host contains unsupported characters: ${input}`;
|
|
234
|
+
}
|
|
235
|
+
if (host.length > 253) {
|
|
236
|
+
return `egress host exceeds 253 characters: ${input}`;
|
|
237
|
+
}
|
|
238
|
+
const labels = host.split('.');
|
|
239
|
+
if (labels.length < 2) {
|
|
240
|
+
return `egress host must be a fully-qualified domain name (single-label hosts are not allowed): ${input}`;
|
|
241
|
+
}
|
|
242
|
+
for (const label of labels) {
|
|
243
|
+
if (!DNS_LABEL_RE.test(label)) {
|
|
244
|
+
return `egress host is not a valid DNS name: ${input}`;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
if (/^\d+$/.test(labels[labels.length - 1])) {
|
|
248
|
+
return `egress entry looks like an IPv4 address — use the IP/CIDR form directly: ${input}`;
|
|
249
|
+
}
|
|
250
|
+
return null;
|
|
251
|
+
}
|
|
252
|
+
export const MAX_EGRESS_IP_RANGES = 16;
|
|
253
|
+
/**
|
|
254
|
+
* Partition a validated manifest egress list into normalized, deduped host
|
|
255
|
+
* and IP/CIDR sets — mirrors the server's `manifestEgressEntries` split so
|
|
256
|
+
* CLI output and budgets match what the deploy pipeline enforces.
|
|
257
|
+
*/
|
|
258
|
+
function partitionEgressEntries(entries) {
|
|
259
|
+
const hosts = new Set();
|
|
260
|
+
const ipCidrs = new Set();
|
|
261
|
+
for (const entry of entries) {
|
|
262
|
+
if (egressEntryError(entry) !== null)
|
|
263
|
+
continue;
|
|
264
|
+
if (looksLikeIpv4EntryCli(entry.trim())) {
|
|
265
|
+
const normalized = normalizeEgressIpCli(entry);
|
|
266
|
+
if (!('error' in normalized))
|
|
267
|
+
ipCidrs.add(normalized.cidr);
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
const raw = entry.trim().toLowerCase();
|
|
271
|
+
const host = raw.includes('://')
|
|
272
|
+
? new URL(raw).hostname.replace(/\.$/, '')
|
|
273
|
+
: raw.split('/')[0].split(':')[0].replace(/\.$/, '');
|
|
274
|
+
hosts.add(host);
|
|
275
|
+
}
|
|
276
|
+
return { hosts, ipCidrs };
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* Normalized, deduped, sorted egress hosts from a validated manifest egress
|
|
280
|
+
* list — mirrors `manifestEgressHosts` on the server so CLI output matches
|
|
281
|
+
* what the deploy pipeline enforces.
|
|
282
|
+
*/
|
|
283
|
+
export function manifestEgressHosts(entries) {
|
|
284
|
+
return [...partitionEgressEntries(entries).hosts].sort();
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* Normalized, deduped, sorted egress IP/CIDR ranges from a validated manifest
|
|
288
|
+
* egress list — mirrors `manifestEgressIpRanges` on the server so CLI output
|
|
289
|
+
* matches the per-function routes the deploy pipeline creates.
|
|
290
|
+
*/
|
|
291
|
+
export function manifestEgressIpRanges(entries) {
|
|
292
|
+
return [...partitionEgressEntries(entries).ipCidrs].sort();
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* CLI-side copy of the server's SWP sessionMatcher budget check: the whole
|
|
296
|
+
* egress list compiles into ONE proxy rule matcher (2048-char GCP cap).
|
|
297
|
+
* Deduped after normalization — the rule builder compiles the deduped host
|
|
298
|
+
* set, so aliases of one host cost one matcher clause. IP/CIDR entries
|
|
299
|
+
* bypass the matcher and are capped separately (per-function route count).
|
|
300
|
+
*/
|
|
301
|
+
export function egressBudgetError(entries) {
|
|
302
|
+
const { hosts, ipCidrs } = partitionEgressEntries(entries);
|
|
303
|
+
let hostChars = 0;
|
|
304
|
+
for (const host of hosts) {
|
|
305
|
+
hostChars += host.length + 16;
|
|
306
|
+
}
|
|
307
|
+
if (ipCidrs.size > MAX_EGRESS_IP_RANGES) {
|
|
308
|
+
return `egress list may include at most ${MAX_EGRESS_IP_RANGES} IP/CIDR entries`;
|
|
309
|
+
}
|
|
310
|
+
if (140 + hostChars > 2048) {
|
|
311
|
+
return (`egress list is too large to compile into a single proxy rule ` +
|
|
312
|
+
`(combined host length exceeds the 2048-character matcher limit) — ` +
|
|
313
|
+
`remove entries or shorten hostnames`);
|
|
314
|
+
}
|
|
315
|
+
return null;
|
|
316
|
+
}
|
|
317
|
+
// --- Custom Roles & Capabilities (PER-75) --------------------------------
|
|
318
|
+
// CLI-side mirror of the host-agnostic vocabulary at
|
|
319
|
+
// atlas/src/server/services/custom-roles/manifest-block.ts and the
|
|
320
|
+
// function-specific gates at
|
|
321
|
+
// atlas/src/server/services/managed-functions/custom-roles.ts — same
|
|
322
|
+
// keep-in-sync pattern as the rest of this file, so `functions push`
|
|
323
|
+
// reports the exact errors the server would.
|
|
324
|
+
/** Key grammar excludes `_` so the FGA id `custom_role:function_<id>__<key>` can use `__` as delimiter. */
|
|
325
|
+
const CUSTOM_ROLE_KEY_RE = /^[a-z0-9-]+$/;
|
|
326
|
+
const customRoleKeySchema = z.string().min(1).max(64).regex(CUSTOM_ROLE_KEY_RE, 'Role and capability keys must match [a-z0-9-]+');
|
|
327
|
+
const MAX_CUSTOM_ROLE_DEFINITIONS = 50;
|
|
328
|
+
const MAX_CUSTOM_ROLE_CAPABILITIES = 100;
|
|
329
|
+
const customRoleDefinitionSchema = z.object({
|
|
330
|
+
description: z.string().max(500).optional(),
|
|
331
|
+
default: z.boolean().default(false),
|
|
332
|
+
inherits: z.array(customRoleKeySchema).default([]),
|
|
333
|
+
grants: z.array(customRoleKeySchema).default([]),
|
|
334
|
+
});
|
|
335
|
+
const customRolesBlockSchema = z.object({
|
|
336
|
+
definitions: z.record(customRoleKeySchema, customRoleDefinitionSchema).default({}),
|
|
337
|
+
capabilities: z
|
|
338
|
+
.record(customRoleKeySchema, z.object({ description: z.string().max(500).optional() }))
|
|
339
|
+
.default({}),
|
|
340
|
+
}).superRefine((block, ctx) => {
|
|
341
|
+
const roleKeys = Object.keys(block.definitions);
|
|
342
|
+
const capabilityKeys = new Set(Object.keys(block.capabilities));
|
|
343
|
+
if (roleKeys.length > MAX_CUSTOM_ROLE_DEFINITIONS) {
|
|
344
|
+
ctx.addIssue({
|
|
345
|
+
code: 'custom',
|
|
346
|
+
message: `At most ${MAX_CUSTOM_ROLE_DEFINITIONS} role definitions are allowed`,
|
|
347
|
+
path: ['definitions'],
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
if (capabilityKeys.size > MAX_CUSTOM_ROLE_CAPABILITIES) {
|
|
351
|
+
ctx.addIssue({
|
|
352
|
+
code: 'custom',
|
|
353
|
+
message: `At most ${MAX_CUSTOM_ROLE_CAPABILITIES} capabilities are allowed`,
|
|
354
|
+
path: ['capabilities'],
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
for (const key of roleKeys) {
|
|
358
|
+
if (capabilityKeys.has(key)) {
|
|
359
|
+
ctx.addIssue({
|
|
360
|
+
code: 'custom',
|
|
361
|
+
message: `Key "${key}" is declared as both a role and a capability`,
|
|
362
|
+
path: ['definitions', key],
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
const roleKeySet = new Set(roleKeys);
|
|
367
|
+
for (const [roleKey, definition] of Object.entries(block.definitions)) {
|
|
368
|
+
const seenInherits = new Set();
|
|
369
|
+
for (const inherited of definition.inherits) {
|
|
370
|
+
if (seenInherits.has(inherited)) {
|
|
371
|
+
ctx.addIssue({
|
|
372
|
+
code: 'custom',
|
|
373
|
+
message: `Role "${roleKey}" lists "${inherited}" in inherits more than once`,
|
|
374
|
+
path: ['definitions', roleKey, 'inherits'],
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
seenInherits.add(inherited);
|
|
378
|
+
if (!roleKeySet.has(inherited)) {
|
|
379
|
+
ctx.addIssue({
|
|
380
|
+
code: 'custom',
|
|
381
|
+
message: `Role "${roleKey}" inherits unknown role "${inherited}"`,
|
|
382
|
+
path: ['definitions', roleKey, 'inherits'],
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
const seenGrants = new Set();
|
|
387
|
+
for (const granted of definition.grants) {
|
|
388
|
+
if (seenGrants.has(granted)) {
|
|
389
|
+
ctx.addIssue({
|
|
390
|
+
code: 'custom',
|
|
391
|
+
message: `Role "${roleKey}" lists "${granted}" in grants more than once`,
|
|
392
|
+
path: ['definitions', roleKey, 'grants'],
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
seenGrants.add(granted);
|
|
396
|
+
if (!capabilityKeys.has(granted)) {
|
|
397
|
+
ctx.addIssue({
|
|
398
|
+
code: 'custom',
|
|
399
|
+
message: `Role "${roleKey}" grants unknown capability "${granted}"`,
|
|
400
|
+
path: ['definitions', roleKey, 'grants'],
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
// The inherits graph must be a DAG (tri-color DFS, first cycle reported).
|
|
406
|
+
const state = new Map();
|
|
407
|
+
const visit = (key, path) => {
|
|
408
|
+
if (state.get(key) === 'done')
|
|
409
|
+
return null;
|
|
410
|
+
if (state.get(key) === 'visiting')
|
|
411
|
+
return [...path, key];
|
|
412
|
+
state.set(key, 'visiting');
|
|
413
|
+
const definition = block.definitions[key];
|
|
414
|
+
for (const inherited of definition?.inherits ?? []) {
|
|
415
|
+
if (!roleKeySet.has(inherited))
|
|
416
|
+
continue;
|
|
417
|
+
const cycle = visit(inherited, [...path, key]);
|
|
418
|
+
if (cycle)
|
|
419
|
+
return cycle;
|
|
420
|
+
}
|
|
421
|
+
state.set(key, 'done');
|
|
422
|
+
return null;
|
|
423
|
+
};
|
|
424
|
+
for (const roleKey of roleKeys) {
|
|
425
|
+
const cycle = visit(roleKey, []);
|
|
426
|
+
if (cycle) {
|
|
427
|
+
ctx.addIssue({
|
|
428
|
+
code: 'custom',
|
|
429
|
+
message: `Role inheritance cycle: ${cycle.join(' -> ')}`,
|
|
430
|
+
path: ['definitions'],
|
|
431
|
+
});
|
|
432
|
+
break;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
});
|
|
436
|
+
const CUSTOM_CAPABILITY_PIN_RE = /^(?:org\/)?[a-z0-9-]+$/;
|
|
437
|
+
const MAX_CAPABILITY_PINS = 100;
|
|
438
|
+
const capabilityPinsSchema = z
|
|
439
|
+
.array(z.string().min(1).max(68).regex(CUSTOM_CAPABILITY_PIN_RE, 'Pins must be "org/<key>" or a bare "<key>" matching [a-z0-9-]+'))
|
|
440
|
+
.max(MAX_CAPABILITY_PINS)
|
|
441
|
+
.default([]);
|
|
442
|
+
function parseCapabilityPin(pin) {
|
|
443
|
+
return pin.startsWith('org/')
|
|
444
|
+
? { scope: 'org', key: pin.slice('org/'.length) }
|
|
445
|
+
: { scope: 'host', key: pin };
|
|
446
|
+
}
|
|
447
|
+
function validateCapabilityPins({ uses, roles, }) {
|
|
448
|
+
const errors = [];
|
|
449
|
+
const seen = new Set();
|
|
450
|
+
const roleKeys = new Set(Object.keys(roles?.definitions ?? {}));
|
|
451
|
+
for (const pin of uses) {
|
|
452
|
+
if (seen.has(pin))
|
|
453
|
+
errors.push(`Pin "${pin}" is listed more than once`);
|
|
454
|
+
seen.add(pin);
|
|
455
|
+
const parsed = parseCapabilityPin(pin);
|
|
456
|
+
if (parsed.scope === 'host' && roleKeys.has(parsed.key)) {
|
|
457
|
+
errors.push(`Pin "${pin}" references a role — roles are never pinned`);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
return errors;
|
|
461
|
+
}
|
|
462
|
+
const MAX_CAPABILITY_GATES = 20;
|
|
463
|
+
const capabilityGateSchema = z.object({
|
|
464
|
+
requires: z.string().min(1).max(68).regex(CUSTOM_CAPABILITY_PIN_RE, 'Gate requirements must be "org/<key>" or a bare "<key>" matching [a-z0-9-]+'),
|
|
465
|
+
when: z.object({
|
|
466
|
+
field: z.string().min(1).max(128),
|
|
467
|
+
equals: z.union([z.string().max(256), z.number(), z.boolean()]),
|
|
468
|
+
}).optional(),
|
|
469
|
+
});
|
|
470
|
+
const capabilityGatesSchema = z
|
|
471
|
+
.array(capabilityGateSchema)
|
|
472
|
+
.max(MAX_CAPABILITY_GATES)
|
|
473
|
+
.default([]);
|
|
474
|
+
function validateCapabilityGates({ gates, uses, roles, }) {
|
|
475
|
+
const errors = [];
|
|
476
|
+
const pinSet = new Set(uses);
|
|
477
|
+
const declaredCapabilities = new Set(Object.keys(roles?.capabilities ?? {}));
|
|
478
|
+
for (const gate of gates) {
|
|
479
|
+
const parsed = parseCapabilityPin(gate.requires);
|
|
480
|
+
const claimable = parsed.scope === 'org'
|
|
481
|
+
? pinSet.has(gate.requires)
|
|
482
|
+
: declaredCapabilities.has(parsed.key) || pinSet.has(parsed.key);
|
|
483
|
+
if (!claimable) {
|
|
484
|
+
errors.push(parsed.scope === 'org'
|
|
485
|
+
? `Gate requires "${gate.requires}" which is not pinned in capabilities.uses`
|
|
486
|
+
: `Gate requires "${gate.requires}" which is neither declared in capabilities.roles.capabilities nor pinned in capabilities.uses`);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
return errors;
|
|
490
|
+
}
|
|
491
|
+
export const managedFunctionManifestSchema = z.object({
|
|
492
|
+
schema_version: z.literal(1).default(1),
|
|
493
|
+
function: z.object({
|
|
494
|
+
id: z
|
|
495
|
+
.string()
|
|
496
|
+
.min(1)
|
|
497
|
+
.max(200)
|
|
498
|
+
.regex(/^[a-z][a-z0-9-]*$/, 'function id must be lowercase letters, digits, and dashes'),
|
|
499
|
+
title: z.string().min(1).max(255),
|
|
500
|
+
description: z.string().max(2000).optional(),
|
|
501
|
+
}),
|
|
502
|
+
runtime: z.literal('nodejs20').default('nodejs20'),
|
|
503
|
+
entrypoint: z.string().min(1).max(255).default('handler'),
|
|
504
|
+
limits: z
|
|
505
|
+
.object({
|
|
506
|
+
memory_mb: z.number().int().min(128).max(2048).default(256),
|
|
507
|
+
timeout_seconds: z.number().int().min(1).max(540).default(60),
|
|
508
|
+
max_instances: z.number().int().min(1).max(10).default(3),
|
|
509
|
+
invoke_rate_per_minute: z.number().int().min(1).max(600).default(60),
|
|
510
|
+
})
|
|
511
|
+
.default({
|
|
512
|
+
memory_mb: 256,
|
|
513
|
+
timeout_seconds: 60,
|
|
514
|
+
max_instances: 3,
|
|
515
|
+
invoke_rate_per_minute: 60,
|
|
516
|
+
}),
|
|
517
|
+
secrets: z
|
|
518
|
+
.array(z.string().regex(SECRET_NAME_RE, 'secret names must be UPPER_SNAKE_CASE'))
|
|
519
|
+
.max(32)
|
|
520
|
+
.default([]),
|
|
521
|
+
/**
|
|
522
|
+
* External hosts the function is allowed to reach (bare hostnames or
|
|
523
|
+
* http(s) URLs) — the single source of truth for the egress allowlist,
|
|
524
|
+
* enforced at the network layer. Empty/omitted = no egress at all.
|
|
525
|
+
*/
|
|
526
|
+
egress: z
|
|
527
|
+
.array(z.string().min(1).max(255).superRefine((value, ctx) => {
|
|
528
|
+
const error = egressEntryError(value);
|
|
529
|
+
if (error !== null) {
|
|
530
|
+
ctx.addIssue({ code: 'custom', message: error });
|
|
531
|
+
}
|
|
532
|
+
}))
|
|
533
|
+
.max(64)
|
|
534
|
+
.superRefine((entries, ctx) => {
|
|
535
|
+
const error = egressBudgetError(entries);
|
|
536
|
+
if (error !== null) {
|
|
537
|
+
ctx.addIssue({ code: 'custom', message: error });
|
|
538
|
+
}
|
|
539
|
+
})
|
|
540
|
+
.default([]),
|
|
541
|
+
input_schema: z.record(z.string(), z.unknown()).optional(),
|
|
542
|
+
output_schema: z.record(z.string(), z.unknown()).optional(),
|
|
543
|
+
/**
|
|
544
|
+
* Custom Roles & Capabilities (PER-75) — declared roles/capabilities,
|
|
545
|
+
* registry capability pins, and invoke gates. Enforced server-side at
|
|
546
|
+
* activation and invoke time; validated here so push fails fast.
|
|
547
|
+
*/
|
|
548
|
+
capabilities: z.object({
|
|
549
|
+
roles: customRolesBlockSchema.optional(),
|
|
550
|
+
uses: capabilityPinsSchema,
|
|
551
|
+
gates: capabilityGatesSchema,
|
|
552
|
+
/**
|
|
553
|
+
* ORM Data API consumer reach, grouped by namespace: the `tables` this
|
|
554
|
+
* function may read, the `actions` it may invoke, and whether raw `query`
|
|
555
|
+
* (arbitrary read SQL over the namespace) is allowed — all ON BEHALF OF the
|
|
556
|
+
* invoking user. The invoke proxy mints a short-lived token scoped to
|
|
557
|
+
* exactly these refs; the Data API re-resolves the user's claims per call,
|
|
558
|
+
* so declaring a table never widens what the user could see. Writes go
|
|
559
|
+
* through declared actions only — raw table writes don't exist.
|
|
560
|
+
*
|
|
561
|
+
* Mirrors atlas/src/server/services/managed-functions/manifest.ts — must
|
|
562
|
+
* stay in sync so `seq-studio` doesn't strip the block before deploy.
|
|
563
|
+
*/
|
|
564
|
+
data: z
|
|
565
|
+
.record(z.string().regex(/^[a-z][a-z0-9_]{0,40}$/, 'capabilities.data keys are ORM namespace names'), z.object({
|
|
566
|
+
tables: z.array(z.string().regex(/^[a-z_][a-z0-9_]*$/, 'table names')).max(64).default([]),
|
|
567
|
+
actions: z.array(z.string().regex(/^[a-z_][a-z0-9_]*$/, 'action names')).max(64).default([]),
|
|
568
|
+
query: z.boolean().default(false),
|
|
569
|
+
}))
|
|
570
|
+
.default({}),
|
|
571
|
+
}).default({ uses: [], gates: [], data: {} }),
|
|
572
|
+
}).superRefine((manifest, ctx) => {
|
|
573
|
+
for (const message of validateCapabilityPins({
|
|
574
|
+
uses: manifest.capabilities.uses,
|
|
575
|
+
roles: manifest.capabilities.roles,
|
|
576
|
+
})) {
|
|
577
|
+
ctx.addIssue({ code: 'custom', message, path: ['capabilities', 'uses'] });
|
|
578
|
+
}
|
|
579
|
+
for (const message of validateCapabilityGates({
|
|
580
|
+
gates: manifest.capabilities.gates,
|
|
581
|
+
uses: manifest.capabilities.uses,
|
|
582
|
+
roles: manifest.capabilities.roles,
|
|
583
|
+
})) {
|
|
584
|
+
ctx.addIssue({ code: 'custom', message, path: ['capabilities', 'gates'] });
|
|
585
|
+
}
|
|
586
|
+
});
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure secret classifier for deploy-time reconciliation.
|
|
3
|
+
*
|
|
4
|
+
* This module performs no IO. The caller fetches the inputs (manifest,
|
|
5
|
+
* parsed .env, server secret list, current function attachments) and
|
|
6
|
+
* passes them here to get a categorised view that drives the deploy
|
|
7
|
+
* preview and the reconcile step.
|
|
8
|
+
*/
|
|
9
|
+
export interface ServerSecretInfo {
|
|
10
|
+
/** Server-side managed-secret id. */
|
|
11
|
+
id: string;
|
|
12
|
+
/** Whether a default value has been stored in GCP Secret Manager. */
|
|
13
|
+
hasDefaultValue: boolean;
|
|
14
|
+
/** Total number of function attachments for this secret across the org. */
|
|
15
|
+
attachmentCount: number;
|
|
16
|
+
}
|
|
17
|
+
export type SecretCategory = 'UPLOAD_NEW' | 'OVERWRITE' | 'USE_EXISTING' | 'BLOCKED';
|
|
18
|
+
export interface ClassifiedSecret {
|
|
19
|
+
name: string;
|
|
20
|
+
category: SecretCategory;
|
|
21
|
+
/** Whether this secret is already attached to the target function. */
|
|
22
|
+
attachedToFn: boolean;
|
|
23
|
+
/**
|
|
24
|
+
* The server-side secret id — null when the secret does not yet exist
|
|
25
|
+
* on the server (UPLOAD_NEW). Required for USE_EXISTING attach-only path.
|
|
26
|
+
*/
|
|
27
|
+
secretId: string | null;
|
|
28
|
+
/** Total function attachments on the server (0 when secret doesn't exist). */
|
|
29
|
+
attachmentCount: number;
|
|
30
|
+
}
|
|
31
|
+
export interface SecretClassification {
|
|
32
|
+
/** All satisfiable secrets in manifest declaration order. */
|
|
33
|
+
secrets: ClassifiedSecret[];
|
|
34
|
+
/** Secrets that cannot be satisfied — deploy must abort if non-empty. */
|
|
35
|
+
blocked: ClassifiedSecret[];
|
|
36
|
+
}
|
|
37
|
+
/** Partial shape of POST /api/managed-secrets/apply response. */
|
|
38
|
+
export interface ApplySecretsResult {
|
|
39
|
+
summary: {
|
|
40
|
+
attempted: number;
|
|
41
|
+
succeeded: number;
|
|
42
|
+
failed: number;
|
|
43
|
+
};
|
|
44
|
+
succeeded: Array<{
|
|
45
|
+
secret: string;
|
|
46
|
+
function: string;
|
|
47
|
+
}>;
|
|
48
|
+
failed: Array<{
|
|
49
|
+
secret: string;
|
|
50
|
+
function: string;
|
|
51
|
+
reason: string;
|
|
52
|
+
}>;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Categorise each manifest-declared secret into one of four states:
|
|
56
|
+
*
|
|
57
|
+
* UPLOAD_NEW — in .env, no server default → create + set + attach via /apply
|
|
58
|
+
* OVERWRITE — in .env, server default exists → overwrite + attach if missing via /apply
|
|
59
|
+
* USE_EXISTING — not in .env, server default exists → attach only (no value change)
|
|
60
|
+
* BLOCKED — not in .env, no server default → unsatisfiable; deploy must abort
|
|
61
|
+
*/
|
|
62
|
+
export declare function classifySecrets({ manifestSecrets, envValues, serverInfoByName, attachedByEnvVar, }: {
|
|
63
|
+
manifestSecrets: string[];
|
|
64
|
+
envValues: Record<string, string>;
|
|
65
|
+
serverInfoByName: Map<string, ServerSecretInfo>;
|
|
66
|
+
/**
|
|
67
|
+
* Map from env var name to the secretId of the secret currently attached to
|
|
68
|
+
* this function. Using the id (not just the name) prevents mistaking a
|
|
69
|
+
* different secret that occupies the same env var as the correct attachment.
|
|
70
|
+
*/
|
|
71
|
+
attachedByEnvVar: Map<string, string>;
|
|
72
|
+
}): SecretClassification;
|
|
73
|
+
/**
|
|
74
|
+
* Build the preview lines for the secrets section of the deploy preview.
|
|
75
|
+
*
|
|
76
|
+
* @param log The LOG prefix string (e.g. '[seq-studio]').
|
|
77
|
+
* @param secrets Non-BLOCKED classified secrets.
|
|
78
|
+
*/
|
|
79
|
+
export declare function buildSecretPreviewLines(log: string, secrets: ClassifiedSecret[]): string[];
|