railcode 0.1.13 → 0.1.16

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.
@@ -0,0 +1,695 @@
1
+ // Pure helpers for the app manifest (`manifest.yaml`) — local strict parsing so
2
+ // `railcode manifest validate` and `railcode deploy` fail fast with named
3
+ // errors before any upload. The SERVER parse is authoritative (PyYAML +
4
+ // org-resource existence checks); this parser is dependency-free and pinned to
5
+ // the server's accept/reject decisions for the manifest grammar — a shallow
6
+ // mapping with string lists and one nested map:
7
+ //
8
+ // run_as: app # or "user"
9
+ // saved_queries:
10
+ // - my_orders
11
+ // connectors:
12
+ // stripe:
13
+ // - GET /v1/balance
14
+ // - GET /v1/customers/*
15
+ // llm: true
16
+ // adhoc_sql:
17
+ // - warehouse
18
+ //
19
+ // Also supported, matching PyYAML: flow lists (`saved_queries: [a, b]`), a flow
20
+ // map for connectors (`connectors: {stripe: [GET /v1/balance]}`), a whole-
21
+ // document flow mapping (`{run_as: app, llm: true}`), quotes with YAML escape
22
+ // decoding (`''` in single quotes, C-style `\n`/`\uXXXX`/… in double quotes),
23
+ // comments, blank lines, a leading `---` document-start marker, a trailing
24
+ // `...` document-end marker, and duplicate mapping keys (the last value wins —
25
+ // though a superseded value must still be well-formed YAML, as on the server).
26
+ // Bare scalars that YAML resolves to non-strings (`123`, `true`, `null`, …) are
27
+ // rejected where the manifest wants a name, exactly like the server. YAML
28
+ // anchors/aliases are the one known divergence: the server expands them, this
29
+ // parser rejects them with a named error. The shared battery in
30
+ // test/manifest-fixtures.json (also run by
31
+ // backend/tests/test_manifest_parse_fixtures.py) keeps the two parsers pinned.
32
+ export const APP_MANIFEST_NAME = "manifest.yaml";
33
+ const ALLOWED_KEYS = ["run_as", "saved_queries", "connectors", "llm", "adhoc_sql"];
34
+ const HTTP_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"];
35
+ const SAVED_QUERY_NAME = /^[a-z0-9_]{1,80}$/;
36
+ // PyYAML's YAML 1.1 plain-scalar resolver: a bare (unquoted) scalar matching one
37
+ // of these resolves to a non-string (bool/null/int/float/timestamp), and the
38
+ // server rejects a non-string wherever the manifest wants a name. The quirks are
39
+ // deliberate — e.g. "1e3" IS a string, because PyYAML's float exponent requires
40
+ // an explicit sign.
41
+ const YAML_TRUE = /^(?:yes|Yes|YES|true|True|TRUE|on|On|ON)$/;
42
+ const YAML_FALSE = /^(?:no|No|NO|false|False|FALSE|off|Off|OFF)$/;
43
+ const YAML_NON_STRING = [
44
+ YAML_TRUE,
45
+ YAML_FALSE,
46
+ /^(?:~|null|Null|NULL)$/,
47
+ /^[-+]?(?:0b[0-1_]+|0[0-7_]+|(?:0|[1-9][0-9_]*)|0x[0-9a-fA-F_]+|[1-9][0-9_]*(?::[0-5]?[0-9])+)$/,
48
+ /^(?:[-+]?[0-9][0-9_]*\.[0-9_]*(?:[eE][-+][0-9]+)?|\.[0-9_]+(?:[eE][-+][0-9]+)?|[-+]?[0-9][0-9_]*(?::[0-5]?[0-9])+\.[0-9_]*|[-+]?\.(?:inf|Inf|INF)|\.(?:nan|NaN|NAN))$/,
49
+ /^\d{4}-\d\d-\d\d$|^\d{4}-\d\d?-\d\d?(?:[Tt]|[ \t]+)\d\d?:\d\d:\d\d(?:\.\d*)?(?:[ \t]*(?:Z|[-+]\d\d?(?::\d\d)?))?$/,
50
+ ];
51
+ function isYamlNonString(value) {
52
+ return YAML_NON_STRING.some((re) => re.test(value));
53
+ }
54
+ export class ManifestParseError extends Error {
55
+ line;
56
+ constructor(message, line) {
57
+ super(line > 0 ? `${message} (line ${line})` : message);
58
+ this.line = line;
59
+ }
60
+ }
61
+ function significantLines(source) {
62
+ const out = [];
63
+ source.split(/\r?\n/).forEach((raw, i) => {
64
+ const withoutComment = stripComment(raw);
65
+ if (!withoutComment.trim())
66
+ return;
67
+ const indent = withoutComment.length - withoutComment.trimStart().length;
68
+ if (withoutComment.slice(0, indent).includes("\t")) {
69
+ throw new ManifestParseError("Use spaces for indentation, not tabs", i + 1);
70
+ }
71
+ out.push({ indent, text: withoutComment.trim(), num: i + 1 });
72
+ });
73
+ return out;
74
+ }
75
+ function stripComment(line) {
76
+ // A "#" starts a comment at line start or after whitespace — good enough for
77
+ // this grammar (no scalar we accept legitimately contains " #").
78
+ let inSingle = false;
79
+ let inDouble = false;
80
+ for (let i = 0; i < line.length; i += 1) {
81
+ const ch = line[i];
82
+ if (ch === "\\" && inDouble)
83
+ i += 1; // \" does not close a double-quoted scalar
84
+ else if (ch === "'" && !inDouble)
85
+ inSingle = !inSingle;
86
+ else if (ch === '"' && !inSingle)
87
+ inDouble = !inDouble;
88
+ else if (ch === "#" && !inSingle && !inDouble && (i === 0 || /\s/.test(line[i - 1]))) {
89
+ return line.slice(0, i);
90
+ }
91
+ }
92
+ return line;
93
+ }
94
+ // PyYAML's double-quote escape table (scanner.ESCAPE_REPLACEMENTS) — an escape
95
+ // outside this set (plus \x/\u/\U) is a YAML error, as on the server.
96
+ const DOUBLE_QUOTE_ESCAPES = {
97
+ "0": "\0",
98
+ a: "\x07",
99
+ b: "\b",
100
+ t: "\t",
101
+ "\t": "\t",
102
+ n: "\n",
103
+ v: "\v",
104
+ f: "\f",
105
+ r: "\r",
106
+ e: "\x1b",
107
+ " ": " ",
108
+ '"': '"',
109
+ "/": "/",
110
+ "\\": "\\",
111
+ N: "\u0085",
112
+ _: "\u00a0",
113
+ L: "\u2028",
114
+ P: "\u2029",
115
+ };
116
+ function unquote(value, num) {
117
+ const v = value.trim();
118
+ if (v.startsWith("'"))
119
+ return decodeSingleQuoted(v, num);
120
+ if (v.startsWith('"'))
121
+ return decodeDoubleQuoted(v, num);
122
+ return v;
123
+ }
124
+ // YAML single-quoted scalar: the ONLY escape is '' → a literal '; a backslash
125
+ // is a plain character.
126
+ function decodeSingleQuoted(v, num) {
127
+ let out = "";
128
+ let i = 1;
129
+ while (i < v.length) {
130
+ const ch = v[i];
131
+ if (ch === "'") {
132
+ if (v[i + 1] === "'") {
133
+ out += "'";
134
+ i += 2;
135
+ continue;
136
+ }
137
+ if (i !== v.length - 1) {
138
+ throw new ManifestParseError(`Unexpected content after closing quote in "${v}"`, num);
139
+ }
140
+ return out;
141
+ }
142
+ out += ch;
143
+ i += 1;
144
+ }
145
+ throw new ManifestParseError(`Unterminated quote in "${v}"`, num);
146
+ }
147
+ // YAML double-quoted scalar: C-style escapes, decoded the way PyYAML does.
148
+ function decodeDoubleQuoted(v, num) {
149
+ let out = "";
150
+ let i = 1;
151
+ while (i < v.length) {
152
+ const ch = v[i];
153
+ if (ch === '"') {
154
+ if (i !== v.length - 1) {
155
+ throw new ManifestParseError(`Unexpected content after closing quote in "${v}"`, num);
156
+ }
157
+ return out;
158
+ }
159
+ if (ch === "\\") {
160
+ const esc = v[i + 1];
161
+ if (esc === undefined)
162
+ break; // dangling backslash → unterminated
163
+ if (esc === "x" || esc === "u" || esc === "U") {
164
+ const len = esc === "x" ? 2 : esc === "u" ? 4 : 8;
165
+ const hex = v.slice(i + 2, i + 2 + len);
166
+ if (hex.length !== len || !/^[0-9a-fA-F]+$/.test(hex)) {
167
+ throw new ManifestParseError(`Invalid \\${esc} escape in "${v}"`, num);
168
+ }
169
+ out += String.fromCodePoint(parseInt(hex, 16));
170
+ i += 2 + len;
171
+ continue;
172
+ }
173
+ const mapped = DOUBLE_QUOTE_ESCAPES[esc];
174
+ if (mapped === undefined) {
175
+ throw new ManifestParseError(`Unknown escape "\\${esc}" in "${v}"`, num);
176
+ }
177
+ out += mapped;
178
+ i += 2;
179
+ continue;
180
+ }
181
+ out += ch;
182
+ i += 1;
183
+ }
184
+ throw new ManifestParseError(`Unterminated quote in "${v}"`, num);
185
+ }
186
+ function scalar(raw, num) {
187
+ const v = raw.trim();
188
+ if (v.startsWith("&") || v.startsWith("*")) {
189
+ throw new ManifestParseError(`YAML anchors/aliases are not supported here ("${v}") — write the value out literally`, num);
190
+ }
191
+ return { value: unquote(v, num), quoted: v.startsWith('"') || v.startsWith("'") };
192
+ }
193
+ // Split a flow body on top-level commas — commas inside [], {} or quotes don't
194
+ // separate entries. A trailing comma is allowed (YAML flow grammar); an empty
195
+ // entry anywhere else is a YAML error, as on the server.
196
+ function splitFlowEntries(inner, num, what) {
197
+ const parts = [];
198
+ let depth = 0;
199
+ let quote = null;
200
+ let current = "";
201
+ for (let i = 0; i < inner.length; i += 1) {
202
+ const ch = inner[i];
203
+ if (quote !== null) {
204
+ if (ch === "\\" && quote === '"') {
205
+ current += ch + (inner[i + 1] ?? "");
206
+ i += 1;
207
+ continue;
208
+ }
209
+ if (ch === quote)
210
+ quote = null;
211
+ }
212
+ else if (ch === '"' || ch === "'") {
213
+ quote = ch;
214
+ }
215
+ else if (ch === "[" || ch === "{") {
216
+ depth += 1;
217
+ }
218
+ else if (ch === "]" || ch === "}") {
219
+ depth -= 1;
220
+ if (depth < 0) {
221
+ throw new ManifestParseError(`Unbalanced "${ch}" in ${what}`, num);
222
+ }
223
+ }
224
+ else if (ch === "," && depth === 0) {
225
+ parts.push(current);
226
+ current = "";
227
+ continue;
228
+ }
229
+ current += ch;
230
+ }
231
+ parts.push(current);
232
+ if (parts.length > 1 && parts[parts.length - 1].trim() === "")
233
+ parts.pop();
234
+ for (const part of parts) {
235
+ if (!part.trim())
236
+ throw new ManifestParseError(`Empty entry in ${what}`, num);
237
+ }
238
+ return parts.map((p) => p.trim());
239
+ }
240
+ // A flow collection ("[...]"/"{...}") must balance its delimiters, terminate
241
+ // its quotes, and consume the whole value — trailing content after the final
242
+ // closing delimiter is a YAML error. This is the well-formedness the server's
243
+ // loader enforces even on values a later duplicate key supersedes.
244
+ function checkFlowBalanced(value, num) {
245
+ const closers = [];
246
+ let quote = null;
247
+ for (let i = 0; i < value.length; i += 1) {
248
+ const ch = value[i];
249
+ if (quote !== null) {
250
+ if (ch === "\\" && quote === '"')
251
+ i += 1;
252
+ else if (ch === quote)
253
+ quote = null;
254
+ continue;
255
+ }
256
+ if (ch === '"' || ch === "'")
257
+ quote = ch;
258
+ else if (ch === "[")
259
+ closers.push("]");
260
+ else if (ch === "{")
261
+ closers.push("}");
262
+ else if (ch === "]" || ch === "}") {
263
+ if (closers.pop() !== ch) {
264
+ throw new ManifestParseError(`Unbalanced "${ch}" in flow collection`, num);
265
+ }
266
+ if (closers.length === 0) {
267
+ const rest = value.slice(i + 1).trim();
268
+ if (rest) {
269
+ throw new ManifestParseError(`Unexpected content after flow collection: "${rest}"`, num);
270
+ }
271
+ }
272
+ }
273
+ }
274
+ if (quote !== null) {
275
+ throw new ManifestParseError(`Unterminated quote in "${value}"`, num);
276
+ }
277
+ if (closers.length > 0) {
278
+ throw new ManifestParseError(`Unterminated flow collection in "${value}" (missing "${closers[closers.length - 1]}")`, num);
279
+ }
280
+ }
281
+ // Syntactic (not semantic) validation of one collected value: a flow
282
+ // collection must balance, a quoted scalar must terminate. Applied to EVERY
283
+ // entry — including ones a later duplicate key supersedes — because the
284
+ // server's yaml.safe_load parses the whole document before last-wins applies.
285
+ function checkValueSyntax(value, num) {
286
+ if (!value)
287
+ return;
288
+ if (value.startsWith("[") || value.startsWith("{"))
289
+ checkFlowBalanced(value, num);
290
+ else if (value.startsWith('"') || value.startsWith("'"))
291
+ unquote(value, num);
292
+ }
293
+ function checkEntrySyntax(entry) {
294
+ checkValueSyntax(entry.inline, entry.keyLine.num);
295
+ for (const child of entry.children) {
296
+ let v = child.text;
297
+ if (v.startsWith("- ")) {
298
+ v = v.slice(2).trim();
299
+ }
300
+ else {
301
+ const m = v.match(CONNECTOR_BLOCK_ENTRY);
302
+ if (m)
303
+ v = (m[2] ?? "").trim();
304
+ }
305
+ checkValueSyntax(v, child.num);
306
+ }
307
+ }
308
+ function parseFlowList(value, num) {
309
+ const inner = value.slice(1, -1).trim();
310
+ if (!inner)
311
+ return [];
312
+ return splitFlowEntries(inner, num, "flow list").map((part) => scalar(part, num));
313
+ }
314
+ // One string-list entry (saved_queries / adhoc_sql): must be a YAML *string* —
315
+ // a bare number/bool/null is a different type and the server rejects it.
316
+ function listString(s, key, num) {
317
+ if (!s.quoted && isYamlNonString(s.value)) {
318
+ throw new ManifestParseError(`"${s.value}" under "${key}" is not a YAML string — quote it ("${s.value}") if you mean the name`, num);
319
+ }
320
+ const value = s.value.trim();
321
+ if (!value)
322
+ throw new ManifestParseError(`Empty list item under "${key}"`, num);
323
+ return value;
324
+ }
325
+ function parseBool(s, num) {
326
+ // A quoted scalar is a STRING even if it spells a boolean — as on the server.
327
+ if (!s.quoted) {
328
+ if (YAML_TRUE.test(s.value))
329
+ return true;
330
+ if (YAML_FALSE.test(s.value))
331
+ return false;
332
+ }
333
+ throw new ManifestParseError(`"llm" must be true or false (got "${s.value}")`, num);
334
+ }
335
+ // Strip the optional document markers: one leading "---" and one trailing "..."
336
+ // are no-ops. Anything more is a multi-document stream, which the server
337
+ // (yaml.safe_load) refuses too — including a trailing "---", which starts an
338
+ // empty second document.
339
+ function documentLines(source) {
340
+ let lines = significantLines(source);
341
+ if (lines.length > 0 && lines[0].indent === 0 && lines[0].text === "---") {
342
+ lines = lines.slice(1);
343
+ }
344
+ const last = lines[lines.length - 1];
345
+ if (last !== undefined && last.indent === 0 && last.text === "...") {
346
+ lines = lines.slice(0, -1);
347
+ }
348
+ for (const line of lines) {
349
+ if (line.indent === 0 && (line.text === "---" || line.text === "...")) {
350
+ throw new ManifestParseError("manifest.yaml must be a single YAML document (unexpected document marker)", line.num);
351
+ }
352
+ }
353
+ return lines;
354
+ }
355
+ // A block-context key: the colon must be followed by whitespace or end the
356
+ // line ("llm:true" is a plain SCALAR in block YAML, not a mapping). In flow
357
+ // context the colon may also sit directly before a flow opener — PyYAML
358
+ // accepts "{stripe:[GET /x]}" but reads "{llm:true}" as the key "llm:true".
359
+ const BLOCK_KEY = /^([A-Za-z_][A-Za-z0-9_]*)\s*:(?:\s+(.*))?$/;
360
+ const FLOW_KEY = /^([A-Za-z_][A-Za-z0-9_]*)\s*:(?=$|[\s[{])\s*(.*)$/;
361
+ export function parseManifestYaml(source) {
362
+ const lines = documentLines(source);
363
+ // Pass 1: split the top level into key entries. The document is either a
364
+ // block mapping or one flow mapping ("{...}", possibly spanning lines) —
365
+ // both are dicts to the server. A duplicate key is legal YAML: the last
366
+ // value wins (as on the server), and a superseded value gets syntactic
367
+ // checks only — the server never validates it semantically either.
368
+ const first = lines[0];
369
+ let entries;
370
+ if (first !== undefined && first.indent === 0 && first.text.startsWith("{")) {
371
+ entries = collectRootFlowEntries(lines);
372
+ }
373
+ else if (first !== undefined && first.indent === 0 && first.text.startsWith("[")) {
374
+ throw new ManifestParseError("manifest.yaml must be a mapping of keys — see the manifest docs", first.num);
375
+ }
376
+ else {
377
+ entries = collectBlockEntries(lines);
378
+ }
379
+ // Pass 2: parse each (winning) entry.
380
+ const doc = {
381
+ run_as: "user",
382
+ saved_queries: [],
383
+ connectors: {},
384
+ llm: false,
385
+ adhoc_sql: [],
386
+ };
387
+ for (const [key, entry] of entries) {
388
+ if (key === "run_as") {
389
+ const value = inlineScalar(entry, key).value;
390
+ if (value !== "app" && value !== "user") {
391
+ throw new ManifestParseError(`"run_as" must be "app" or "user" (got "${value}")`, entry.keyLine.num);
392
+ }
393
+ doc.run_as = value;
394
+ }
395
+ else if (key === "llm") {
396
+ doc.llm = parseBool(inlineScalar(entry, key), entry.keyLine.num);
397
+ }
398
+ else if (key === "connectors") {
399
+ doc.connectors = parseConnectors(entry);
400
+ }
401
+ else {
402
+ // saved_queries / adhoc_sql: a list of names.
403
+ const items = parseStringList(entry, key);
404
+ if (key === "saved_queries")
405
+ doc.saved_queries = items;
406
+ else
407
+ doc.adhoc_sql = items;
408
+ }
409
+ }
410
+ validateDoc(doc, lines);
411
+ return doc;
412
+ }
413
+ function collectBlockEntries(lines) {
414
+ const entries = new Map();
415
+ let i = 0;
416
+ while (i < lines.length) {
417
+ const line = lines[i];
418
+ if (line.indent !== 0) {
419
+ throw new ManifestParseError(`Unexpected indentation before "${line.text}"`, line.num);
420
+ }
421
+ const match = line.text.match(BLOCK_KEY);
422
+ if (!match) {
423
+ throw new ManifestParseError(`Expected "key: value" or "key:" at "${line.text}"`, line.num);
424
+ }
425
+ const key = match[1];
426
+ if (!ALLOWED_KEYS.includes(key)) {
427
+ throw new ManifestParseError(`Unknown manifest key "${key}" — allowed keys: ${ALLOWED_KEYS.join(", ")}`, line.num);
428
+ }
429
+ i += 1;
430
+ const children = [];
431
+ while (i < lines.length && lines[i].indent > 0) {
432
+ children.push(lines[i]);
433
+ i += 1;
434
+ }
435
+ const entry = { keyLine: line, inline: (match[2] ?? "").trim(), children };
436
+ checkEntrySyntax(entry);
437
+ entries.set(key, entry);
438
+ }
439
+ return entries;
440
+ }
441
+ // The whole document is one flow mapping, e.g. `{run_as: app, llm: true}` —
442
+ // PyYAML reads it as a dict, so the server accepts it. Flow collections may
443
+ // span lines, so the lines are joined before scanning.
444
+ function collectRootFlowEntries(lines) {
445
+ const keyLine = lines[0];
446
+ const joined = lines.map((l) => l.text).join(" ");
447
+ checkFlowBalanced(joined, keyLine.num); // also rejects content after the "}"
448
+ const entries = new Map();
449
+ const inner = joined.slice(1, joined.lastIndexOf("}")).trim();
450
+ if (!inner)
451
+ return entries;
452
+ for (const part of splitFlowEntries(inner, keyLine.num, "the manifest flow mapping")) {
453
+ // A part that isn't "key: value" is a lone scalar key with a null value
454
+ // (e.g. "{run_as}" or the no-space "{llm:true}") — name it like the server.
455
+ const match = part.match(FLOW_KEY);
456
+ const key = match ? match[1] : part;
457
+ if (!ALLOWED_KEYS.includes(key)) {
458
+ throw new ManifestParseError(`Unknown manifest key "${key}" — allowed keys: ${ALLOWED_KEYS.join(", ")}`, keyLine.num);
459
+ }
460
+ entries.set(key, { keyLine, inline: match ? match[2].trim() : "", children: [] });
461
+ }
462
+ return entries;
463
+ }
464
+ function requireNoChildren(entry, key) {
465
+ const extra = entry.children[0];
466
+ if (extra !== undefined) {
467
+ throw new ManifestParseError(`Unexpected indented block under "${key}"`, extra.num);
468
+ }
469
+ }
470
+ function inlineScalar(entry, key) {
471
+ requireNoChildren(entry, key);
472
+ return scalar(entry.inline, entry.keyLine.num);
473
+ }
474
+ function parseStringList(entry, key) {
475
+ const { keyLine, inline, children } = entry;
476
+ if (inline) {
477
+ requireNoChildren(entry, key);
478
+ if (inline.startsWith("[") && inline.endsWith("]")) {
479
+ return parseFlowList(inline, keyLine.num).map((s) => listString(s, key, keyLine.num));
480
+ }
481
+ throw new ManifestParseError(`"${key}" must be a list of names — e.g.\n${key}:\n - some_name`, keyLine.num);
482
+ }
483
+ if (children.length === 0) {
484
+ throw new ManifestParseError(`"${key}" must list at least one name`, keyLine.num);
485
+ }
486
+ const itemIndent = children[0].indent;
487
+ return children.map((item) => {
488
+ if (item.indent !== itemIndent) {
489
+ throw new ManifestParseError(`Inconsistent indentation in the "${key}" list — every "- " item must line up`, item.num);
490
+ }
491
+ if (!item.text.startsWith("- ") && item.text !== "-") {
492
+ throw new ManifestParseError(`Expected a "- name" list item under "${key}" (got "${item.text}")`, item.num);
493
+ }
494
+ return listString(scalar(item.text.slice(1), item.num), key, item.num);
495
+ });
496
+ }
497
+ // A connector name entry. In block context the colon must be followed by
498
+ // whitespace or end the line; in flow context PyYAML also allows the colon to
499
+ // sit directly before a flow opener ("{stripe:[GET /x]}").
500
+ const CONNECTOR_BLOCK_ENTRY = /^("[^"]*"|'[^']*'|[^"':\s][^":]*?)\s*:(?:\s+(.*))?$/;
501
+ const CONNECTOR_FLOW_ENTRY = /^("[^"]*"|'[^']*'|[^"':\s][^":]*?)\s*:(?=$|[\s[{])\s*(.*)$/;
502
+ function connectorName(raw, num) {
503
+ const s = scalar(raw, num);
504
+ const name = s.value.trim();
505
+ if (!name)
506
+ throw new ManifestParseError("Connector names must be non-empty", num);
507
+ if (!s.quoted && isYamlNonString(name)) {
508
+ throw new ManifestParseError(`Connector names under "connectors" must be strings (got "${name}")`, num);
509
+ }
510
+ return name;
511
+ }
512
+ function parseConnectors(entry) {
513
+ const { keyLine, inline, children } = entry;
514
+ if (inline) {
515
+ requireNoChildren(entry, "connectors");
516
+ if (inline.startsWith("{") && inline.endsWith("}")) {
517
+ return parseConnectorsFlowMap(inline, keyLine.num);
518
+ }
519
+ throw new ManifestParseError('"connectors" takes an indented block (or a {flow map}): connector names, each with a list of endpoints', keyLine.num);
520
+ }
521
+ if (children.length === 0) {
522
+ throw new ManifestParseError('"connectors" must map at least one connector to its endpoints', keyLine.num);
523
+ }
524
+ const connectors = {};
525
+ const nameIndent = children[0].indent;
526
+ let i = 0;
527
+ while (i < children.length) {
528
+ const nameLine = children[i];
529
+ if (nameLine.indent !== nameIndent) {
530
+ throw new ManifestParseError('Inconsistent indentation under "connectors" — every connector name must line up', nameLine.num);
531
+ }
532
+ const match = nameLine.text.match(CONNECTOR_BLOCK_ENTRY);
533
+ if (!match) {
534
+ throw new ManifestParseError(`Expected a connector name (e.g. "stripe:") under "connectors" (got "${nameLine.text}")`, nameLine.num);
535
+ }
536
+ const name = connectorName(match[1], nameLine.num);
537
+ const inlineValue = (match[2] ?? "").trim();
538
+ i += 1;
539
+ let endpoints = [];
540
+ if (inlineValue) {
541
+ const extra = children[i];
542
+ if (extra !== undefined && extra.indent > nameIndent) {
543
+ throw new ManifestParseError(`Unexpected indented block under "${name}" after an inline value`, extra.num);
544
+ }
545
+ if (!(inlineValue.startsWith("[") && inlineValue.endsWith("]"))) {
546
+ throw new ManifestParseError(`"${name}" must list its endpoints as "- <METHOD> </path>" lines or a [flow list]`, nameLine.num);
547
+ }
548
+ endpoints = parseFlowList(inlineValue, nameLine.num);
549
+ }
550
+ else {
551
+ const first = children[i];
552
+ const endpointIndent = first !== undefined && first.indent > nameIndent ? first.indent : 0;
553
+ while (i < children.length && children[i].indent > nameIndent) {
554
+ const item = children[i];
555
+ if (item.indent !== endpointIndent) {
556
+ throw new ManifestParseError(`Inconsistent indentation in the endpoint list under "${name}"`, item.num);
557
+ }
558
+ if (!item.text.startsWith("- ")) {
559
+ throw new ManifestParseError(`Expected a "- <METHOD> </path>" endpoint under "${name}" (got "${item.text}")`, item.num);
560
+ }
561
+ endpoints.push(scalar(item.text.slice(1), item.num));
562
+ i += 1;
563
+ }
564
+ }
565
+ if (endpoints.length === 0) {
566
+ throw new ManifestParseError(`"${name}" must list at least one "<METHOD> </path>" endpoint`, nameLine.num);
567
+ }
568
+ // A repeated connector name is legal YAML — the last mapping wins.
569
+ connectors[name] = endpoints.map((e) => normalizeEndpoint(name, e.value, nameLine.num));
570
+ }
571
+ return connectors;
572
+ }
573
+ function parseConnectorsFlowMap(value, num) {
574
+ const connectors = {};
575
+ const inner = value.slice(1, -1).trim();
576
+ if (!inner)
577
+ return connectors; // {} is a valid empty mapping, as on the server
578
+ for (const part of splitFlowEntries(inner, num, '"connectors" flow map')) {
579
+ const match = part.match(CONNECTOR_FLOW_ENTRY);
580
+ if (!match) {
581
+ throw new ManifestParseError(`Expected "connector: [endpoints]" in the "connectors" flow map (got "${part}")`, num);
582
+ }
583
+ const name = connectorName(match[1], num);
584
+ const endpointsValue = match[2].trim();
585
+ if (!(endpointsValue.startsWith("[") && endpointsValue.endsWith("]"))) {
586
+ throw new ManifestParseError(`"${name}" must list its endpoints as a [flow list] in the "connectors" flow map`, num);
587
+ }
588
+ const endpoints = parseFlowList(endpointsValue, num);
589
+ if (endpoints.length === 0) {
590
+ throw new ManifestParseError(`"${name}" must list at least one "<METHOD> </path>" endpoint`, num);
591
+ }
592
+ connectors[name] = endpoints.map((e) => normalizeEndpoint(name, e.value, num));
593
+ }
594
+ return connectors;
595
+ }
596
+ function normalizeEndpoint(connector, entry, num) {
597
+ const parts = entry.split(/\s+/);
598
+ if (parts.length !== 2) {
599
+ throw new ManifestParseError(`"${entry}" under "${connector}" is not a valid endpoint — use "<METHOD> </path>", e.g. "GET /v1/balance"`, num);
600
+ }
601
+ const method = parts[0].toUpperCase();
602
+ const path = parts[1];
603
+ if (!HTTP_METHODS.includes(method)) {
604
+ throw new ManifestParseError(`"${parts[0]}" is not an HTTP method — use one of: ${HTTP_METHODS.join(", ")}`, num);
605
+ }
606
+ if (!path.startsWith("/")) {
607
+ throw new ManifestParseError(`"${path}" must start with "/" — e.g. "GET /v1/balance"`, num);
608
+ }
609
+ if (path.includes("*") && (!path.endsWith("/*") || path.slice(0, -1).includes("*"))) {
610
+ throw new ManifestParseError(`"${path}" has an unsupported wildcard — only a trailing "/*" segment is allowed (no mid-path globs)`, num);
611
+ }
612
+ const segments = path.replace(/\*$/, "").split("/");
613
+ if (segments.some((seg) => seg === "." || seg === "..")) {
614
+ throw new ManifestParseError(`"${path}" must not contain "." or ".." path segments`, num);
615
+ }
616
+ return `${method} ${path}`;
617
+ }
618
+ function validateDoc(doc, lines) {
619
+ const lineOf = (token) => lines.find((l) => l.text.includes(token))?.num ?? 0;
620
+ for (const name of doc.saved_queries) {
621
+ if (!SAVED_QUERY_NAME.test(name)) {
622
+ throw new ManifestParseError(`"${name}" is not a valid saved-query name — 1-80 lowercase letters, digits or underscores`, lineOf(name));
623
+ }
624
+ }
625
+ doc.saved_queries = [...new Set(doc.saved_queries)].sort();
626
+ doc.adhoc_sql = [...new Set(doc.adhoc_sql)].sort();
627
+ for (const name of Object.keys(doc.connectors)) {
628
+ doc.connectors[name] = [...new Set(doc.connectors[name])].sort();
629
+ }
630
+ }
631
+ export function documentOperations(doc) {
632
+ const ops = [];
633
+ for (const name of doc.saved_queries) {
634
+ ops.push({ type: "saved_query", id: name, label: `saved query "${name}"` });
635
+ }
636
+ for (const [connector, endpoints] of Object.entries(doc.connectors)) {
637
+ for (const endpoint of endpoints) {
638
+ ops.push({
639
+ type: "sc_endpoint",
640
+ id: `${connector}:${endpoint}`,
641
+ label: `"${endpoint}" on "${connector}"`,
642
+ });
643
+ }
644
+ }
645
+ if (doc.llm)
646
+ ops.push({ type: "llm", id: "*", label: "LLM access" });
647
+ for (const name of doc.adhoc_sql) {
648
+ ops.push({ type: "connector", id: name, label: `ad-hoc SQL on "${name}"` });
649
+ }
650
+ return ops;
651
+ }
652
+ export function renderManifestSummary(doc) {
653
+ const lines = [];
654
+ lines.push(doc.run_as === "app"
655
+ ? "run_as: app — runs with its ratified authority (diffs need ratification)"
656
+ : "run_as: user — runs as each caller; no borrowed authority, nothing to ratify");
657
+ const ops = documentOperations(doc);
658
+ if (ops.length === 0) {
659
+ lines.push("operations: none declared");
660
+ }
661
+ else {
662
+ lines.push(`operations (${ops.length}):`);
663
+ for (const op of ops)
664
+ lines.push(` - ${op.label}`);
665
+ }
666
+ if (doc.adhoc_sql.length > 0) {
667
+ lines.push("warning: adhoc_sql grants ad-hoc SQL authority — scarce by design; prefer saved queries");
668
+ }
669
+ return lines.join("\n");
670
+ }
671
+ export function renderManifestDeployOutcome(m) {
672
+ const lines = [];
673
+ for (const warning of m.warnings)
674
+ lines.push(`warning: ${warning}`);
675
+ switch (m.action) {
676
+ case "none":
677
+ break; // no manifest, nothing to say
678
+ case "unchanged":
679
+ lines.push("Manifest unchanged.");
680
+ break;
681
+ case "ratified":
682
+ lines.push(m.added.length > 0
683
+ ? `Manifest ratified (you hold: ${m.added.join(", ")}).`
684
+ : "Manifest ratified.");
685
+ if (m.removed.length > 0)
686
+ lines.push(`Removed: ${m.removed.join(", ")}.`);
687
+ break;
688
+ case "removed":
689
+ lines.push("Manifest removed — the app is back to pass-through (runs as each caller).");
690
+ if (m.removed.length > 0)
691
+ lines.push(`Shed: ${m.removed.join(", ")}.`);
692
+ break;
693
+ }
694
+ return lines;
695
+ }