primitive-admin 1.1.0-alpha.81 → 1.1.0-alpha.83
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/assets/skill/skills/primitive-platform/SKILL.md +12 -0
- package/dist/src/commands/documents.js +455 -22
- package/dist/src/commands/documents.js.map +1 -1
- package/dist/src/commands/functions.js +889 -76
- package/dist/src/commands/functions.js.map +1 -1
- package/dist/src/commands/locks.js +15 -1
- package/dist/src/commands/locks.js.map +1 -1
- package/dist/src/commands/prompts.js +19 -2
- package/dist/src/commands/prompts.js.map +1 -1
- package/dist/src/commands/sync.d.ts +50 -0
- package/dist/src/commands/sync.js +587 -316
- package/dist/src/commands/sync.js.map +1 -1
- package/dist/src/commands/workflows.d.ts +1 -29
- package/dist/src/commands/workflows.js +8 -109
- package/dist/src/commands/workflows.js.map +1 -1
- package/dist/src/lib/api-client.d.ts +191 -2
- package/dist/src/lib/api-client.js +283 -7
- package/dist/src/lib/api-client.js.map +1 -1
- package/dist/src/lib/config-object-descriptor.js +12 -4
- package/dist/src/lib/config-object-descriptor.js.map +1 -1
- package/dist/src/lib/document-ingest-artifact.d.ts +120 -0
- package/dist/src/lib/document-ingest-artifact.js +505 -0
- package/dist/src/lib/document-ingest-artifact.js.map +1 -0
- package/dist/src/lib/document-ingest-input.d.ts +51 -0
- package/dist/src/lib/document-ingest-input.js +132 -0
- package/dist/src/lib/document-ingest-input.js.map +1 -0
- package/dist/src/lib/document-ingest.d.ts +78 -0
- package/dist/src/lib/document-ingest.js +231 -0
- package/dist/src/lib/document-ingest.js.map +1 -0
- package/dist/src/lib/env-resolver-core.d.ts +2 -2
- package/dist/src/lib/env-resolver-core.js +2 -2
- package/dist/src/lib/function-bundle.d.ts +6 -0
- package/dist/src/lib/function-bundle.js +8 -0
- package/dist/src/lib/function-bundle.js.map +1 -1
- package/dist/src/lib/function-collect.d.ts +1 -1
- package/dist/src/lib/function-collect.js +16 -0
- package/dist/src/lib/function-collect.js.map +1 -1
- package/dist/src/lib/function-db-types.d.ts +4 -0
- package/dist/src/lib/function-db-types.js +46 -2
- package/dist/src/lib/function-db-types.js.map +1 -1
- package/dist/src/lib/function-document-types.d.ts +83 -0
- package/dist/src/lib/function-document-types.js +137 -0
- package/dist/src/lib/function-document-types.js.map +1 -1
- package/dist/src/lib/function-grants-preflight.js +5 -0
- package/dist/src/lib/function-grants-preflight.js.map +1 -1
- package/dist/src/lib/function-log-lines.d.ts +76 -0
- package/dist/src/lib/function-log-lines.js +160 -0
- package/dist/src/lib/function-log-lines.js.map +1 -0
- package/dist/src/lib/function-log-row.d.ts +29 -0
- package/dist/src/lib/function-log-row.js +73 -0
- package/dist/src/lib/function-log-row.js.map +1 -0
- package/dist/src/lib/function-log-tail.d.ts +51 -2
- package/dist/src/lib/function-log-tail.js +160 -13
- package/dist/src/lib/function-log-tail.js.map +1 -1
- package/dist/src/lib/function-run.d.ts +289 -0
- package/dist/src/lib/function-run.js +389 -0
- package/dist/src/lib/function-run.js.map +1 -0
- package/dist/src/lib/function-schema-codegen.d.ts +17 -9
- package/dist/src/lib/function-schema-codegen.js +30 -29
- package/dist/src/lib/function-schema-codegen.js.map +1 -1
- package/dist/src/lib/function-sync.d.ts +226 -30
- package/dist/src/lib/function-sync.js +731 -77
- package/dist/src/lib/function-sync.js.map +1 -1
- package/dist/src/lib/function-versions.d.ts +122 -0
- package/dist/src/lib/function-versions.js +182 -0
- package/dist/src/lib/function-versions.js.map +1 -0
- package/dist/src/lib/generated-config-surfaces.d.ts +561 -91
- package/dist/src/lib/generated-config-surfaces.js +1858 -287
- package/dist/src/lib/generated-config-surfaces.js.map +1 -1
- package/dist/src/lib/generated-sdk-types.d.ts +1 -1
- package/dist/src/lib/generated-sdk-types.js +1 -1
- package/dist/src/lib/generated-sdk-types.js.map +1 -1
- package/dist/src/lib/log-inspection.d.ts +2 -0
- package/dist/src/lib/log-inspection.js +4 -0
- package/dist/src/lib/log-inspection.js.map +1 -1
- package/dist/src/lib/output.d.ts +15 -0
- package/dist/src/lib/output.js +28 -0
- package/dist/src/lib/output.js.map +1 -1
- package/dist/src/lib/prompt-schema-codegen.d.ts +94 -0
- package/dist/src/lib/prompt-schema-codegen.js +212 -0
- package/dist/src/lib/prompt-schema-codegen.js.map +1 -0
- package/dist/src/lib/snapshot-audit-source.d.ts +45 -0
- package/dist/src/lib/snapshot-audit-source.js +58 -0
- package/dist/src/lib/snapshot-audit-source.js.map +1 -0
- package/dist/src/lib/snapshot-audit-store.d.ts +52 -0
- package/dist/src/lib/snapshot-audit-store.js +196 -0
- package/dist/src/lib/snapshot-audit-store.js.map +1 -0
- package/dist/src/lib/snapshot-audit.d.ts +207 -0
- package/dist/src/lib/snapshot-audit.js +431 -0
- package/dist/src/lib/snapshot-audit.js.map +1 -0
- package/dist/src/lib/snapshot-build-rows.d.ts +60 -0
- package/dist/src/lib/snapshot-build-rows.js +87 -0
- package/dist/src/lib/snapshot-build-rows.js.map +1 -0
- package/dist/src/lib/snapshot-manifest-layout.d.ts +61 -0
- package/dist/src/lib/snapshot-manifest-layout.js +70 -0
- package/dist/src/lib/snapshot-manifest-layout.js.map +1 -0
- package/dist/src/lib/step-run-table.d.ts +43 -0
- package/dist/src/lib/step-run-table.js +129 -0
- package/dist/src/lib/step-run-table.js.map +1 -0
- package/dist/src/lib/swift-codegen/functionGenerator.d.ts +26 -13
- package/dist/src/lib/swift-codegen/functionGenerator.js +38 -27
- package/dist/src/lib/swift-codegen/functionGenerator.js.map +1 -1
- package/dist/src/lib/sync-selectors.d.ts +45 -2
- package/dist/src/lib/sync-selectors.js +66 -5
- package/dist/src/lib/sync-selectors.js.map +1 -1
- package/dist/src/lib/workflow-toml-validator.d.ts +9 -5
- package/dist/src/lib/workflow-toml-validator.js +18 -10
- package/dist/src/lib/workflow-toml-validator.js.map +1 -1
- package/package.json +2 -2
|
@@ -38,9 +38,16 @@ export const UPDATE_ONLY = ["update"];
|
|
|
38
38
|
* the definition never dispatches through it (#1976 decision of record); the
|
|
39
39
|
* guard asserts the export exists so a renamed handler fails the CLI unit suite
|
|
40
40
|
* instead of leaving a dangling reference (#2644 behavior 2b).
|
|
41
|
+
*
|
|
42
|
+
* `stage` is REQUIRED (#3375): a field handler that does not say when it runs
|
|
43
|
+
* makes the same empty promise the cross-field slot made before #3373 — it
|
|
44
|
+
* names an owner, and criterion 5 has nothing to check. A `preflight` handler
|
|
45
|
+
* is reached from `cli/src/commands/sync.ts#runConfigPushPreflight` before the
|
|
46
|
+
* first mutating call, which `findUnreachedPreflightRules` proves statically;
|
|
47
|
+
* an `apply` handler is everything else.
|
|
41
48
|
*/
|
|
42
|
-
export function handledBy(modulePath, exportName) {
|
|
43
|
-
return { handler: `${modulePath}#${exportName}
|
|
49
|
+
export function handledBy(modulePath, exportName, stage) {
|
|
50
|
+
return { handler: `${modulePath}#${exportName}`, stage };
|
|
44
51
|
}
|
|
45
52
|
/**
|
|
46
53
|
* Name the ONE export that enforces a cross-field rule, as
|
|
@@ -1395,6 +1402,616 @@ export function crossFieldRulesForStage(surface, stage) {
|
|
|
1395
1402
|
}
|
|
1396
1403
|
return (surface.crossFieldRules ?? []).filter((rule) => rule.stage === stage);
|
|
1397
1404
|
}
|
|
1405
|
+
// ── src/config-surface/preflight-reachability.ts ─────────────────────────
|
|
1406
|
+
/**
|
|
1407
|
+
* Is a rule declared `preflight` actually reached before `config push` mutates
|
|
1408
|
+
* anything? (issue #3375, project phase 3, intent §Success criteria 5.)
|
|
1409
|
+
*
|
|
1410
|
+
* #3373 gave a rule a `stage`; #3374 declared three surfaces' rules. Both left
|
|
1411
|
+
* the string a PROMISE. "This runs before the first mutating call" was
|
|
1412
|
+
* satisfied by typing `preflight` beside a rule that is not on the preflight
|
|
1413
|
+
* path at all, because the push preflight was an inline block inside the
|
|
1414
|
+
* command's `.action()` callback with no exported symbol — so "reached from the
|
|
1415
|
+
* preflight" had nothing to resolve against.
|
|
1416
|
+
*
|
|
1417
|
+
* #3375 extracts that block into `cli/src/commands/sync.ts#runConfigPushPreflight`
|
|
1418
|
+
* and this module answers the question statically, from the declared
|
|
1419
|
+
* `<module>#<export>`:
|
|
1420
|
+
*
|
|
1421
|
+
* - `reachableExports` walks the call graph from one entry symbol over module
|
|
1422
|
+
* SOURCE TEXT and returns what it reached — plus what it could NOT resolve,
|
|
1423
|
+
* so silence is never a pass.
|
|
1424
|
+
* - `findUnreachedPreflightRules` reports every `preflight` declaration,
|
|
1425
|
+
* cross-field or single-field, whose handler is missing from that set.
|
|
1426
|
+
* - `findMalformedFieldValidationStages` is the field-level twin of #3373's
|
|
1427
|
+
* `findMalformedRuleDeclarations`: `ConfigField["validation"]` gained a
|
|
1428
|
+
* `stage` here, and it must be well-formed to mean anything.
|
|
1429
|
+
*
|
|
1430
|
+
* Everything is a PURE function over values and over source text the caller
|
|
1431
|
+
* passes in: `cli/tests/unit/config-surface-preflight-staging-3375.test.ts`
|
|
1432
|
+
* does the filesystem walk, so this module stays vendorable (`types.ts`
|
|
1433
|
+
* §Purity), exactly the split `rules.ts` already uses.
|
|
1434
|
+
*
|
|
1435
|
+
* ── Two things this check is NOT ──────────────────────────────────────────
|
|
1436
|
+
*
|
|
1437
|
+
* It is NECESSARY, not sufficient, and there is deliberately no converse. A
|
|
1438
|
+
* handler reached on one surface's leg satisfies a `preflight` declaration on
|
|
1439
|
+
* another (the residual the intent accepts and #3379 records), and asking "is
|
|
1440
|
+
* this `apply` rule reached from the preflight?" would be wrong by
|
|
1441
|
+
* construction: `validateSigningSecretDeclaration` IS reached on the function
|
|
1442
|
+
* leg and IS correctly `apply` on the webhook surface, where no CLI call
|
|
1443
|
+
* exists.
|
|
1444
|
+
*
|
|
1445
|
+
* The walk is identifier-based over masked source, so it over-approximates in
|
|
1446
|
+
* the PASSING direction: a local variable shadowing an imported handler's name
|
|
1447
|
+
* reads as a call. That is the safe direction for a guard whose failure mode
|
|
1448
|
+
* must be "a promise you did not keep", not "a promise you did keep, reported
|
|
1449
|
+
* as broken".
|
|
1450
|
+
*/
|
|
1451
|
+
/** A field's `validation` object may carry these keys and no others (#3375). */
|
|
1452
|
+
export const FIELD_VALIDATION_KEYS = ["handler", "stage"];
|
|
1453
|
+
const PREFLIGHT_IDENTIFIER = /[A-Za-z_$][\w$]*/g;
|
|
1454
|
+
const PREFLIGHT_DECLARATION = /^(?:export\s+)?(?:default\s+)?(?:declare\s+)?(?:abstract\s+)?(?:async\s+)?(function|class|const|let|var|type|interface|enum)\s*\*?\s+([A-Za-z_$][\w$]*)/;
|
|
1455
|
+
/** The `<module>` and `<export>` halves, or null when the reference is malformed. */
|
|
1456
|
+
function preflightSplitSymbol(symbol) {
|
|
1457
|
+
if (typeof symbol !== "string")
|
|
1458
|
+
return null;
|
|
1459
|
+
const [module, name, ...rest] = symbol.split("#");
|
|
1460
|
+
if (!module || !name || rest.length)
|
|
1461
|
+
return null;
|
|
1462
|
+
return { module, name };
|
|
1463
|
+
}
|
|
1464
|
+
/** Index of the `}`/`)`/`]` closing the bracket at `open`, or -1. */
|
|
1465
|
+
function preflightMatchingBracket(masked, open) {
|
|
1466
|
+
const pairs = { "[": "]", "{": "}", "(": ")" };
|
|
1467
|
+
const stack = [];
|
|
1468
|
+
for (let i = open; i < masked.length; i++) {
|
|
1469
|
+
const ch = masked[i];
|
|
1470
|
+
if (pairs[ch]) {
|
|
1471
|
+
stack.push(pairs[ch]);
|
|
1472
|
+
continue;
|
|
1473
|
+
}
|
|
1474
|
+
if (ch === "]" || ch === "}" || ch === ")") {
|
|
1475
|
+
if (!stack.length || stack.pop() !== ch)
|
|
1476
|
+
return -1;
|
|
1477
|
+
if (!stack.length)
|
|
1478
|
+
return i;
|
|
1479
|
+
}
|
|
1480
|
+
}
|
|
1481
|
+
return -1;
|
|
1482
|
+
}
|
|
1483
|
+
/**
|
|
1484
|
+
* A keyword that can only OPEN a top-level declaration, never continue an
|
|
1485
|
+
* expression: seeing one at column zero ends the statement before it.
|
|
1486
|
+
*
|
|
1487
|
+
* `async` and `abstract` are spelled with what must follow them, because both
|
|
1488
|
+
* can legitimately continue an expression across a line break; `function` and
|
|
1489
|
+
* `class` alone cannot start an indented continuation in a formatted file, and
|
|
1490
|
+
* the column-zero test below is what rules out the expression forms.
|
|
1491
|
+
*/
|
|
1492
|
+
const PREFLIGHT_DECLARATION_OPENS = /^(?:export|import|declare|function|class|const|let|var|type|interface|enum|async\s+function|abstract\s+class)\b/;
|
|
1493
|
+
/**
|
|
1494
|
+
* End of a `const`/`let`/`var`/`type` statement that begins at `from`: the
|
|
1495
|
+
* first `;` outside any bracket, or the line break before the next top-level
|
|
1496
|
+
* declaration — whichever comes first.
|
|
1497
|
+
*
|
|
1498
|
+
* The second boundary is not optional. TypeScript's automatic semicolon
|
|
1499
|
+
* insertion makes `export const entry = () => 1` a complete statement, and a
|
|
1500
|
+
* scan that only ever stops at a `;` runs on through every following
|
|
1501
|
+
* declaration to the next one that has one — attributing an unrelated
|
|
1502
|
+
* apply-only function's body to this declaration, which is an
|
|
1503
|
+
* over-approximation in the PASSING direction: the staging guard would report
|
|
1504
|
+
* a handler as reached that the preflight never mentions. A depth-zero line
|
|
1505
|
+
* break followed by a declaration keyword at column zero is where a formatted
|
|
1506
|
+
* file puts the boundary, and an indented continuation line (`? x`, `: y`,
|
|
1507
|
+
* `| A`, `.then(…)`) never matches it.
|
|
1508
|
+
*
|
|
1509
|
+
* Falling back to the end of the module would let one missing semicolon
|
|
1510
|
+
* attribute the whole rest of the file to a single declaration — an
|
|
1511
|
+
* over-approximation so large the guard would pass on anything. The fallback is
|
|
1512
|
+
* the end of the line instead, which is what an unterminated statement looks
|
|
1513
|
+
* like in a formatted file.
|
|
1514
|
+
*/
|
|
1515
|
+
function preflightStatementEnd(masked, from) {
|
|
1516
|
+
let depth = 0;
|
|
1517
|
+
for (let i = from; i < masked.length; i++) {
|
|
1518
|
+
const ch = masked[i];
|
|
1519
|
+
if (ch === "{" || ch === "(" || ch === "[")
|
|
1520
|
+
depth++;
|
|
1521
|
+
else if (ch === "}" || ch === ")" || ch === "]")
|
|
1522
|
+
depth--;
|
|
1523
|
+
else if (ch === ";" && depth <= 0)
|
|
1524
|
+
return i;
|
|
1525
|
+
else if (ch === "\n" && depth <= 0) {
|
|
1526
|
+
const next = preflightNextSignificant(masked, i + 1);
|
|
1527
|
+
if (next === -1)
|
|
1528
|
+
break;
|
|
1529
|
+
// Column zero only: a continuation line is indented, and a comment ahead
|
|
1530
|
+
// of the next declaration is already blanked to whitespace.
|
|
1531
|
+
if (masked[next - 1] !== "\n")
|
|
1532
|
+
continue;
|
|
1533
|
+
if (PREFLIGHT_DECLARATION_OPENS.test(masked.slice(next, next + 40))) {
|
|
1534
|
+
return i;
|
|
1535
|
+
}
|
|
1536
|
+
}
|
|
1537
|
+
}
|
|
1538
|
+
const newline = masked.indexOf("\n", from);
|
|
1539
|
+
return newline === -1 ? masked.length : newline;
|
|
1540
|
+
}
|
|
1541
|
+
/** Index of the next non-whitespace character at or after `from`, or -1. */
|
|
1542
|
+
function preflightNextSignificant(masked, from) {
|
|
1543
|
+
for (let i = from; i < masked.length; i++) {
|
|
1544
|
+
if (!/\s/.test(masked[i]))
|
|
1545
|
+
return i;
|
|
1546
|
+
}
|
|
1547
|
+
return -1;
|
|
1548
|
+
}
|
|
1549
|
+
/** Index of the previous non-whitespace character before `before`, or -1. */
|
|
1550
|
+
function preflightPreviousSignificant(masked, before) {
|
|
1551
|
+
for (let i = before - 1; i >= 0; i--) {
|
|
1552
|
+
if (!/\s/.test(masked[i]))
|
|
1553
|
+
return i;
|
|
1554
|
+
}
|
|
1555
|
+
return -1;
|
|
1556
|
+
}
|
|
1557
|
+
/**
|
|
1558
|
+
* The character ending the token before `before` — `"` when that token is a
|
|
1559
|
+
* string literal.
|
|
1560
|
+
*
|
|
1561
|
+
* The masker blanks a literal's QUOTES along with its contents, so a
|
|
1562
|
+
* literal type is invisible in the masked view and the search above walks
|
|
1563
|
+
* straight past it to whatever preceded it in the annotation. That matters for
|
|
1564
|
+
* exactly one decision: `function typeOfValue(v): Kind | "null" | "unknown" {`
|
|
1565
|
+
* masks to `… Kind | | {`, whose previous significant
|
|
1566
|
+
* character is a `|` — a type continuation — so the body would be read as an
|
|
1567
|
+
* object type, skipped whole, and the NEXT function's body taken as this one's.
|
|
1568
|
+
* The literal table restores the token the mask erased.
|
|
1569
|
+
*/
|
|
1570
|
+
function preflightPreviousTokenEnd(masked, literals, before) {
|
|
1571
|
+
const previous = preflightPreviousSignificant(masked, before);
|
|
1572
|
+
const gapStart = previous + 1;
|
|
1573
|
+
for (const literal of literals) {
|
|
1574
|
+
if (literal.start >= gapStart && literal.end <= before)
|
|
1575
|
+
return '"';
|
|
1576
|
+
}
|
|
1577
|
+
return previous === -1 ? "" : masked[previous];
|
|
1578
|
+
}
|
|
1579
|
+
/**
|
|
1580
|
+
* A character that can only CONTINUE a type annotation, so a `{` right after it
|
|
1581
|
+
* opens an object type rather than a function body.
|
|
1582
|
+
*/
|
|
1583
|
+
const PREFLIGHT_TYPE_CONTINUES = new Set([
|
|
1584
|
+
":",
|
|
1585
|
+
"|",
|
|
1586
|
+
"&",
|
|
1587
|
+
",",
|
|
1588
|
+
"(",
|
|
1589
|
+
"[",
|
|
1590
|
+
"<",
|
|
1591
|
+
"=",
|
|
1592
|
+
]);
|
|
1593
|
+
/**
|
|
1594
|
+
* Index of the `{` that opens a function's BODY, given the offset just past its
|
|
1595
|
+
* name — or -1 when it has none (an overload signature, a `declare`).
|
|
1596
|
+
*
|
|
1597
|
+
* Not simply the next `{`. A parameter destructured into an inline object type
|
|
1598
|
+
* (`input: { configDir: string; … }`) and a return type naming one
|
|
1599
|
+
* (`Promise<{ parsedVars: … }>`) each open a brace first, and taking either
|
|
1600
|
+
* would end the declaration's span at the signature — after which every symbol
|
|
1601
|
+
* the body really calls is invisible, and a staging check whose walk reaches
|
|
1602
|
+
* nothing passes everything.
|
|
1603
|
+
*
|
|
1604
|
+
* So: match the parameter list, then read the return-type annotation off the
|
|
1605
|
+
* text. A `{` inside a generic argument, or one following a character that can
|
|
1606
|
+
* only continue a type, is skipped whole; the first other `{` is the body. A
|
|
1607
|
+
* `;` reached first means there is no body at all. A literal return type
|
|
1608
|
+
* (`: "ok" {`) is read through the lexer's literal table, because the mask
|
|
1609
|
+
* erased its quotes along with its contents.
|
|
1610
|
+
*/
|
|
1611
|
+
function preflightFunctionBody(masked, literals, afterName) {
|
|
1612
|
+
const paren = masked.indexOf("(", afterName);
|
|
1613
|
+
if (paren === -1)
|
|
1614
|
+
return -1;
|
|
1615
|
+
const closeParen = preflightMatchingBracket(masked, paren);
|
|
1616
|
+
if (closeParen === -1)
|
|
1617
|
+
return -1;
|
|
1618
|
+
let angle = 0;
|
|
1619
|
+
for (let i = closeParen + 1; i < masked.length; i++) {
|
|
1620
|
+
const ch = masked[i];
|
|
1621
|
+
if (ch === ";")
|
|
1622
|
+
return -1; // an overload signature: no body follows
|
|
1623
|
+
if (ch === "<") {
|
|
1624
|
+
angle++;
|
|
1625
|
+
continue;
|
|
1626
|
+
}
|
|
1627
|
+
if (ch === ">" && masked[i - 1] !== "=") {
|
|
1628
|
+
angle = Math.max(0, angle - 1);
|
|
1629
|
+
continue;
|
|
1630
|
+
}
|
|
1631
|
+
if (ch !== "{")
|
|
1632
|
+
continue;
|
|
1633
|
+
const character = preflightPreviousTokenEnd(masked, literals, i);
|
|
1634
|
+
if (angle === 0 && !PREFLIGHT_TYPE_CONTINUES.has(character))
|
|
1635
|
+
return i;
|
|
1636
|
+
const close = preflightMatchingBracket(masked, i);
|
|
1637
|
+
if (close === -1)
|
|
1638
|
+
return -1;
|
|
1639
|
+
i = close;
|
|
1640
|
+
}
|
|
1641
|
+
return -1;
|
|
1642
|
+
}
|
|
1643
|
+
/** Every top-level declaration of a masked module, with its span. */
|
|
1644
|
+
function preflightTopLevelDeclarations(masked, literals) {
|
|
1645
|
+
const out = new Map();
|
|
1646
|
+
/** Names whose recorded span is a signature with no body, so far. */
|
|
1647
|
+
const signatureOnly = new Set();
|
|
1648
|
+
let i = 0;
|
|
1649
|
+
let depth = 0;
|
|
1650
|
+
while (i < masked.length) {
|
|
1651
|
+
const ch = masked[i];
|
|
1652
|
+
const boundary = i === 0 || !/[\w$]/.test(masked[i - 1]);
|
|
1653
|
+
if (depth === 0 && /[A-Za-z_$]/.test(ch) && boundary) {
|
|
1654
|
+
const match = PREFLIGHT_DECLARATION.exec(masked.slice(i, i + 200));
|
|
1655
|
+
if (match) {
|
|
1656
|
+
const kind = match[1];
|
|
1657
|
+
const name = match[2];
|
|
1658
|
+
const afterName = i + match[0].length;
|
|
1659
|
+
let end;
|
|
1660
|
+
let hasBody = true;
|
|
1661
|
+
if (kind === "function" ||
|
|
1662
|
+
kind === "class" ||
|
|
1663
|
+
kind === "interface" ||
|
|
1664
|
+
kind === "enum") {
|
|
1665
|
+
const brace = kind === "function"
|
|
1666
|
+
? preflightFunctionBody(masked, literals, afterName)
|
|
1667
|
+
: masked.indexOf("{", afterName);
|
|
1668
|
+
const close = brace === -1 ? -1 : preflightMatchingBracket(masked, brace);
|
|
1669
|
+
hasBody = close !== -1;
|
|
1670
|
+
end = close === -1 ? preflightStatementEnd(masked, afterName) : close;
|
|
1671
|
+
}
|
|
1672
|
+
else {
|
|
1673
|
+
end = preflightStatementEnd(masked, afterName);
|
|
1674
|
+
}
|
|
1675
|
+
// The declaration with a BODY wins, whichever came first: a `declare`
|
|
1676
|
+
// or an overload signature ahead of the real one carries only types,
|
|
1677
|
+
// and letting it hold the name would hide every call the
|
|
1678
|
+
// implementation makes — the walk would report a genuinely reached
|
|
1679
|
+
// handler as unreached. Between two bodies the first still wins.
|
|
1680
|
+
if (!out.has(name) || (signatureOnly.has(name) && hasBody)) {
|
|
1681
|
+
out.set(name, { start: i, end });
|
|
1682
|
+
if (hasBody)
|
|
1683
|
+
signatureOnly.delete(name);
|
|
1684
|
+
else
|
|
1685
|
+
signatureOnly.add(name);
|
|
1686
|
+
}
|
|
1687
|
+
i = end + 1;
|
|
1688
|
+
continue;
|
|
1689
|
+
}
|
|
1690
|
+
}
|
|
1691
|
+
if (ch === "{" || ch === "(" || ch === "[")
|
|
1692
|
+
depth++;
|
|
1693
|
+
else if (ch === "}" || ch === ")" || ch === "]")
|
|
1694
|
+
depth = Math.max(0, depth - 1);
|
|
1695
|
+
i++;
|
|
1696
|
+
}
|
|
1697
|
+
return out;
|
|
1698
|
+
}
|
|
1699
|
+
/**
|
|
1700
|
+
* Read one module's imports, re-exports and declarations.
|
|
1701
|
+
*
|
|
1702
|
+
* The string CONTENTS are blanked in the masked view — that is what stops a
|
|
1703
|
+
* name inside a comment or a message from reading as a call — so a module
|
|
1704
|
+
* specifier is recovered from the lexer's literal table by position rather than
|
|
1705
|
+
* from the masked text.
|
|
1706
|
+
*/
|
|
1707
|
+
function preflightModuleFacts(source) {
|
|
1708
|
+
const { masked, strings } = lexRuleModuleSource(source);
|
|
1709
|
+
const literalAfter = (index) => {
|
|
1710
|
+
for (const literal of strings) {
|
|
1711
|
+
if (literal.start >= index)
|
|
1712
|
+
return literal.value;
|
|
1713
|
+
}
|
|
1714
|
+
return null;
|
|
1715
|
+
};
|
|
1716
|
+
const imports = new Map();
|
|
1717
|
+
const namespaces = new Map();
|
|
1718
|
+
const namedReExports = new Map();
|
|
1719
|
+
const starReExports = [];
|
|
1720
|
+
/**
|
|
1721
|
+
* `{ a, b as c, type D }` → the value bindings it introduces, as
|
|
1722
|
+
* (name on THIS side, name on the OTHER module's side) pairs. The same
|
|
1723
|
+
* shape reads an import clause and a re-export clause, which are mirrors.
|
|
1724
|
+
*/
|
|
1725
|
+
const readClause = (clause, onBinding) => {
|
|
1726
|
+
for (const piece of clause.split(",")) {
|
|
1727
|
+
const parts = piece.trim().split(/\s+/).filter(Boolean);
|
|
1728
|
+
if (!parts.length || parts[0] === "type")
|
|
1729
|
+
continue; // `{ type X }`
|
|
1730
|
+
const there = parts[0];
|
|
1731
|
+
const here = parts.length >= 3 && parts[1] === "as" ? parts[2] : there;
|
|
1732
|
+
onBinding(here, there);
|
|
1733
|
+
}
|
|
1734
|
+
};
|
|
1735
|
+
// Anchored at the start of a line, and stopping at a `;`: a static import is
|
|
1736
|
+
// always top-level, and an unanchored `\bimport\b` would let a dynamic
|
|
1737
|
+
// `await import(…)` deep inside a function run on to some unrelated `from`
|
|
1738
|
+
// and invent a binding out of whichever string literal came next.
|
|
1739
|
+
for (const match of masked.matchAll(/^import\b([^;]*?)\bfrom\b/gm)) {
|
|
1740
|
+
const clause = match[1];
|
|
1741
|
+
if (/^\s*type\b/.test(clause))
|
|
1742
|
+
continue; // `import type { … } from`
|
|
1743
|
+
const specifier = literalAfter((match.index ?? 0) + match[0].length);
|
|
1744
|
+
if (!specifier)
|
|
1745
|
+
continue;
|
|
1746
|
+
const namespace = /\*\s+as\s+([A-Za-z_$][\w$]*)/.exec(clause);
|
|
1747
|
+
if (namespace)
|
|
1748
|
+
namespaces.set(namespace[1], specifier);
|
|
1749
|
+
const named = /\{([^}]*)\}/.exec(clause);
|
|
1750
|
+
if (named) {
|
|
1751
|
+
readClause(named[1], (here, there) => {
|
|
1752
|
+
imports.set(here, { specifier, imported: there });
|
|
1753
|
+
});
|
|
1754
|
+
}
|
|
1755
|
+
}
|
|
1756
|
+
for (const match of masked.matchAll(/^export\s+(type\s+)?\{([^}]*)\}\s*from\b/gm)) {
|
|
1757
|
+
if (match[1])
|
|
1758
|
+
continue; // `export type { … } from` re-exports no value
|
|
1759
|
+
const specifier = literalAfter((match.index ?? 0) + match[0].length);
|
|
1760
|
+
if (!specifier)
|
|
1761
|
+
continue;
|
|
1762
|
+
readClause(match[2], (here, there) => {
|
|
1763
|
+
namedReExports.set(here, { specifier, local: there });
|
|
1764
|
+
});
|
|
1765
|
+
}
|
|
1766
|
+
for (const match of masked.matchAll(/^export\s*\*\s*from\b/gm)) {
|
|
1767
|
+
const specifier = literalAfter((match.index ?? 0) + match[0].length);
|
|
1768
|
+
if (specifier)
|
|
1769
|
+
starReExports.push(specifier);
|
|
1770
|
+
}
|
|
1771
|
+
return {
|
|
1772
|
+
masked,
|
|
1773
|
+
declarations: preflightTopLevelDeclarations(masked, strings),
|
|
1774
|
+
imports,
|
|
1775
|
+
namespaces,
|
|
1776
|
+
namedReExports,
|
|
1777
|
+
starReExports,
|
|
1778
|
+
};
|
|
1779
|
+
}
|
|
1780
|
+
/**
|
|
1781
|
+
* Walk the call graph from `entry` and return every symbol it reaches.
|
|
1782
|
+
*
|
|
1783
|
+
* An identifier inside a reached declaration's span is followed when it names
|
|
1784
|
+
* another top-level declaration of the same module, or a value import — which
|
|
1785
|
+
* resolves through the graph to whichever candidate module DECLARES the name,
|
|
1786
|
+
* following `export { x } from` and `export * from` on the way. A symbol is
|
|
1787
|
+
* recorded under the module that declares it, never under a barrel that merely
|
|
1788
|
+
* re-exports it: a declaration that names the barrel is reported unreached,
|
|
1789
|
+
* because the point of the string is to name the one implementation.
|
|
1790
|
+
*/
|
|
1791
|
+
export function reachableExports(entry, graph) {
|
|
1792
|
+
const reached = new Set();
|
|
1793
|
+
const unresolved = new Set();
|
|
1794
|
+
const facts = new Map();
|
|
1795
|
+
const queued = new Set();
|
|
1796
|
+
const queue = [];
|
|
1797
|
+
const factsFor = (module) => {
|
|
1798
|
+
const source = graph.modules.get(module);
|
|
1799
|
+
if (source === undefined)
|
|
1800
|
+
return null;
|
|
1801
|
+
let cached = facts.get(module);
|
|
1802
|
+
if (!cached) {
|
|
1803
|
+
cached = preflightModuleFacts(source);
|
|
1804
|
+
facts.set(module, cached);
|
|
1805
|
+
}
|
|
1806
|
+
return cached;
|
|
1807
|
+
};
|
|
1808
|
+
/** Candidate modules for a specifier, reporting a dangling relative one. */
|
|
1809
|
+
const candidates = (from, specifier) => {
|
|
1810
|
+
const resolved = graph.resolve(from, specifier);
|
|
1811
|
+
if (resolved === null)
|
|
1812
|
+
return []; // a package: not followed, not reported
|
|
1813
|
+
if (resolved.length === 0) {
|
|
1814
|
+
unresolved.add(`${from}: "${specifier}" resolves to no module in the graph`);
|
|
1815
|
+
}
|
|
1816
|
+
return resolved;
|
|
1817
|
+
};
|
|
1818
|
+
/**
|
|
1819
|
+
* Where `name` is really DECLARED, starting from `module` — following named
|
|
1820
|
+
* and star re-exports. `[]` when nothing in reach declares it.
|
|
1821
|
+
*/
|
|
1822
|
+
const declaringSymbols = (module, name, seen) => {
|
|
1823
|
+
const key = `${module}#${name}`;
|
|
1824
|
+
if (seen.has(key))
|
|
1825
|
+
return [];
|
|
1826
|
+
seen.add(key);
|
|
1827
|
+
const moduleFacts = factsFor(module);
|
|
1828
|
+
if (!moduleFacts)
|
|
1829
|
+
return [];
|
|
1830
|
+
if (moduleFacts.declarations.has(name))
|
|
1831
|
+
return [key];
|
|
1832
|
+
const out = [];
|
|
1833
|
+
const named = moduleFacts.namedReExports.get(name);
|
|
1834
|
+
if (named) {
|
|
1835
|
+
for (const candidate of candidates(module, named.specifier)) {
|
|
1836
|
+
out.push(...declaringSymbols(candidate, named.local, seen));
|
|
1837
|
+
}
|
|
1838
|
+
}
|
|
1839
|
+
for (const specifier of moduleFacts.starReExports) {
|
|
1840
|
+
for (const candidate of candidates(module, specifier)) {
|
|
1841
|
+
out.push(...declaringSymbols(candidate, name, seen));
|
|
1842
|
+
}
|
|
1843
|
+
}
|
|
1844
|
+
return out;
|
|
1845
|
+
};
|
|
1846
|
+
const enqueue = (symbol) => {
|
|
1847
|
+
if (queued.has(symbol))
|
|
1848
|
+
return;
|
|
1849
|
+
queued.add(symbol);
|
|
1850
|
+
queue.push(symbol);
|
|
1851
|
+
};
|
|
1852
|
+
/** Follow an imported binding to the symbol(s) it really names. */
|
|
1853
|
+
const followImport = (module, specifier, imported) => {
|
|
1854
|
+
for (const candidate of candidates(module, specifier)) {
|
|
1855
|
+
for (const symbol of declaringSymbols(candidate, imported, new Set())) {
|
|
1856
|
+
enqueue(symbol);
|
|
1857
|
+
}
|
|
1858
|
+
}
|
|
1859
|
+
};
|
|
1860
|
+
const parsedEntry = preflightSplitSymbol(entry);
|
|
1861
|
+
if (!parsedEntry) {
|
|
1862
|
+
return {
|
|
1863
|
+
reached,
|
|
1864
|
+
unresolved: [
|
|
1865
|
+
`"${String(entry)}" is not a "<module>#<export>" reference`,
|
|
1866
|
+
],
|
|
1867
|
+
};
|
|
1868
|
+
}
|
|
1869
|
+
const entrySymbols = declaringSymbols(parsedEntry.module, parsedEntry.name, new Set());
|
|
1870
|
+
if (entrySymbols.length === 0) {
|
|
1871
|
+
unresolved.add(`${entry}: no module in the graph declares or re-exports it — the ` +
|
|
1872
|
+
"entry point was renamed, un-exported, or never added to the graph");
|
|
1873
|
+
}
|
|
1874
|
+
for (const symbol of entrySymbols)
|
|
1875
|
+
enqueue(symbol);
|
|
1876
|
+
while (queue.length) {
|
|
1877
|
+
const symbol = queue.shift();
|
|
1878
|
+
const parsed = preflightSplitSymbol(symbol);
|
|
1879
|
+
const moduleFacts = factsFor(parsed.module);
|
|
1880
|
+
if (!moduleFacts)
|
|
1881
|
+
continue;
|
|
1882
|
+
const span = moduleFacts.declarations.get(parsed.name);
|
|
1883
|
+
if (!span)
|
|
1884
|
+
continue;
|
|
1885
|
+
reached.add(symbol);
|
|
1886
|
+
const body = moduleFacts.masked.slice(span.start, span.end);
|
|
1887
|
+
// `ns.member` — the namespace binding's own name says nothing, so the
|
|
1888
|
+
// member is read off the text rather than off the identifier stream.
|
|
1889
|
+
for (const [local, specifier] of moduleFacts.namespaces) {
|
|
1890
|
+
const member = new RegExp(`\\b${local}\\s*\\.\\s*([A-Za-z_$][\\w$]*)`, "g");
|
|
1891
|
+
for (const hit of body.matchAll(member)) {
|
|
1892
|
+
followImport(parsed.module, specifier, hit[1]);
|
|
1893
|
+
}
|
|
1894
|
+
}
|
|
1895
|
+
for (const hit of body.matchAll(PREFLIGHT_IDENTIFIER)) {
|
|
1896
|
+
const name = hit[0];
|
|
1897
|
+
if (name === parsed.name)
|
|
1898
|
+
continue;
|
|
1899
|
+
// A property access is not a reference to a top-level name; `ns.member`
|
|
1900
|
+
// above is the one case where it is, and it is handled there. Walked
|
|
1901
|
+
// backwards rather than by slicing, so a long declaration costs the
|
|
1902
|
+
// walk linear time and not quadratic.
|
|
1903
|
+
let back = (hit.index ?? 0) - 1;
|
|
1904
|
+
while (back >= 0 && /\s/.test(body[back]))
|
|
1905
|
+
back--;
|
|
1906
|
+
if (back >= 0 && body[back] === ".")
|
|
1907
|
+
continue;
|
|
1908
|
+
if (moduleFacts.declarations.has(name)) {
|
|
1909
|
+
enqueue(`${parsed.module}#${name}`);
|
|
1910
|
+
continue;
|
|
1911
|
+
}
|
|
1912
|
+
const imported = moduleFacts.imports.get(name);
|
|
1913
|
+
if (imported) {
|
|
1914
|
+
followImport(parsed.module, imported.specifier, imported.imported);
|
|
1915
|
+
}
|
|
1916
|
+
}
|
|
1917
|
+
}
|
|
1918
|
+
return { reached, unresolved: [...unresolved].sort() };
|
|
1919
|
+
}
|
|
1920
|
+
/** The handler's `<module>` half, or "" when the reference is malformed. */
|
|
1921
|
+
function preflightHandlerModule(handler) {
|
|
1922
|
+
const parsed = preflightSplitSymbol(handler);
|
|
1923
|
+
return parsed ? parsed.module : "";
|
|
1924
|
+
}
|
|
1925
|
+
/**
|
|
1926
|
+
* Every fault in one surface's FIELD validation declarations, each naming the
|
|
1927
|
+
* surface and the field. `[]` means every field is well-formed.
|
|
1928
|
+
*
|
|
1929
|
+
* Runs over every surface in the registry, migrated or not: field declarations
|
|
1930
|
+
* exist independently of the cross-field slot, so the
|
|
1931
|
+
* `CROSS_FIELD_RULES_UNMIGRATED` ratchet does not — and must not — stand
|
|
1932
|
+
* between a field-level `preflight` declaration and this check (DSO-3375-001).
|
|
1933
|
+
*/
|
|
1934
|
+
export function findMalformedFieldValidationStages(surface) {
|
|
1935
|
+
const out = [];
|
|
1936
|
+
for (const table of surface.tables ?? []) {
|
|
1937
|
+
for (const field of table.fields ?? []) {
|
|
1938
|
+
const validation = field.validation;
|
|
1939
|
+
if (validation === "passthrough")
|
|
1940
|
+
continue;
|
|
1941
|
+
const where = `${surface.label}.${field.field}`;
|
|
1942
|
+
if (!validation || typeof validation !== "object") {
|
|
1943
|
+
out.push(`${where}: \`validation\` must be "passthrough" or ` +
|
|
1944
|
+
"`{ handler, stage }`");
|
|
1945
|
+
continue;
|
|
1946
|
+
}
|
|
1947
|
+
const keys = Object.keys(validation).sort();
|
|
1948
|
+
const expected = [...FIELD_VALIDATION_KEYS].sort();
|
|
1949
|
+
if (keys.join(",") !== expected.join(",")) {
|
|
1950
|
+
out.push(`${where}: \`validation\` declares ${keys.join(", ") || "no keys"} — ` +
|
|
1951
|
+
`exactly ${expected.join(" and ")}, and nothing else: the slot ` +
|
|
1952
|
+
"classifies, it does not carry behavior knobs");
|
|
1953
|
+
}
|
|
1954
|
+
const handler = validation.handler;
|
|
1955
|
+
const modulePath = preflightHandlerModule(handler);
|
|
1956
|
+
if (!modulePath) {
|
|
1957
|
+
out.push(`${where}: handler ${JSON.stringify(handler)} is not a ` +
|
|
1958
|
+
'"<module>#<export>" reference');
|
|
1959
|
+
}
|
|
1960
|
+
const stage = validation.stage;
|
|
1961
|
+
if (!CROSS_FIELD_RULE_STAGES.includes(stage)) {
|
|
1962
|
+
out.push(`${where}: stage ${JSON.stringify(stage)} is not one of ` +
|
|
1963
|
+
CROSS_FIELD_RULE_STAGES.join(", ") +
|
|
1964
|
+
" — a field handler says when it runs");
|
|
1965
|
+
}
|
|
1966
|
+
else if (stage === "preflight" &&
|
|
1967
|
+
modulePath &&
|
|
1968
|
+
!modulePath.startsWith(PREFLIGHT_HANDLER_MODULE_PREFIX)) {
|
|
1969
|
+
out.push(`${where}: a \`preflight\` handler is vendored into the CLI, so its ` +
|
|
1970
|
+
`module lives under ${PREFLIGHT_HANDLER_MODULE_PREFIX} — ` +
|
|
1971
|
+
`"${modulePath}" does not`);
|
|
1972
|
+
}
|
|
1973
|
+
}
|
|
1974
|
+
}
|
|
1975
|
+
return out.sort();
|
|
1976
|
+
}
|
|
1977
|
+
/**
|
|
1978
|
+
* Every `preflight` declaration on this surface whose handler the walk did not
|
|
1979
|
+
* reach, each naming the surface, the rule (or field), the handler and the
|
|
1980
|
+
* entry point.
|
|
1981
|
+
*
|
|
1982
|
+
* The FIELD half runs on every surface. The CROSS-FIELD half runs only where a
|
|
1983
|
+
* surface has declared, because `crossFieldRulesForStage` throws for one that
|
|
1984
|
+
* has not — "not declared yet" is never "rule-free" (#3373), and asking it
|
|
1985
|
+
* here would turn an unmigrated surface into a crash rather than a skip.
|
|
1986
|
+
*/
|
|
1987
|
+
export function findUnreachedPreflightRules(surface, reached, entry) {
|
|
1988
|
+
const out = [];
|
|
1989
|
+
const unreached = (subject, handler) => `${surface.label}: ${subject} is declared \`preflight\`, but its handler ` +
|
|
1990
|
+
`${handler} is not reached from ${entry} — a preflight rule runs before ` +
|
|
1991
|
+
"the first mutating call, or it is not preflight";
|
|
1992
|
+
for (const table of surface.tables ?? []) {
|
|
1993
|
+
for (const field of table.fields ?? []) {
|
|
1994
|
+
const validation = field.validation;
|
|
1995
|
+
if (!validation || typeof validation !== "object")
|
|
1996
|
+
continue;
|
|
1997
|
+
const { handler, stage } = validation;
|
|
1998
|
+
if (stage !== "preflight")
|
|
1999
|
+
continue;
|
|
2000
|
+
const reference = String(handler);
|
|
2001
|
+
if (reached.has(reference))
|
|
2002
|
+
continue;
|
|
2003
|
+
out.push(unreached(`field \`${field.field}\``, reference));
|
|
2004
|
+
}
|
|
2005
|
+
}
|
|
2006
|
+
if (hasDeclaredCrossFieldRules(surface)) {
|
|
2007
|
+
for (const rule of crossFieldRulesForStage(surface, "preflight")) {
|
|
2008
|
+
if (reached.has(rule.handler))
|
|
2009
|
+
continue;
|
|
2010
|
+
out.push(unreached(`rule "${rule.id}"`, rule.handler));
|
|
2011
|
+
}
|
|
2012
|
+
}
|
|
2013
|
+
return out.sort();
|
|
2014
|
+
}
|
|
1398
2015
|
/**
|
|
1399
2016
|
* Protocol keys every config UPDATE accepts, whichever object it is.
|
|
1400
2017
|
*
|
|
@@ -2467,92 +3084,170 @@ export function parseFunctionManifest(value) {
|
|
|
2467
3084
|
errors: [],
|
|
2468
3085
|
};
|
|
2469
3086
|
}
|
|
2470
|
-
|
|
3087
|
+
// ── src/config-surface/function-retired-keys.ts ──────────────────────────
|
|
3088
|
+
/**
|
|
3089
|
+
* The runtime keys a `functions/<key>.toml` no longer decides anything with —
|
|
3090
|
+
* #3482, project `server-functions` phase 4.
|
|
3091
|
+
*
|
|
3092
|
+
* The sponsor settled it on 2026-09-15 (the intent, "Mode vocabulary?",
|
|
3093
|
+
* amended again): the config says NOTHING about how a function runs. A
|
|
3094
|
+
* function is a function; the caller picks the runtime at each call, cron
|
|
3095
|
+
* fires always start a task run, webhook deliveries always run as a request,
|
|
3096
|
+
* and a function that must not run under one runtime tests for it in code with
|
|
3097
|
+
* `assertRuntime`.
|
|
3098
|
+
*
|
|
3099
|
+
* ── Why these keys are accepted, not refused ──────────────────────────────
|
|
3100
|
+
*
|
|
3101
|
+
* Every tree pushed since #3454 that declares a cron trigger carries
|
|
3102
|
+
* `mode = …` ON THE ENTRY, because `cron-trigger-mode-required` demanded it;
|
|
3103
|
+
* plenty carry a top-level `mode` or the older `durable`. Removing the keys
|
|
3104
|
+
* from the accepted set outright would refuse those trees with an unknown-key
|
|
3105
|
+
* error one day after the platform insisted on them, which is the opposite of
|
|
3106
|
+
* principle 5 inside a non-breaking phase.
|
|
3107
|
+
*
|
|
3108
|
+
* So through phase 4 they are ACCEPTED and IGNORED, with one warning per key
|
|
3109
|
+
* naming it and the line to delete; from phase 5 (#3188) they are refused with
|
|
3110
|
+
* the rest of the retirement matrix. The value is not validated — there is
|
|
3111
|
+
* nothing left to validate it against, and refusing a bad value for a key
|
|
3112
|
+
* nobody reads would be a refusal about spelling alone.
|
|
3113
|
+
*
|
|
3114
|
+
* Pure: no models, no tenant context, no `env` (see `types.ts` §Purity). Both
|
|
3115
|
+
* the CLI preflight (vendored by `cli/scripts/gen-config-surfaces.mjs`) and the
|
|
3116
|
+
* admin config-version route read this one scanner, so the warning an author
|
|
3117
|
+
* sees from `config push` is the sentence the API answers with.
|
|
3118
|
+
*/
|
|
3119
|
+
/** The two `[function]` keys that no longer say anything (#3482). */
|
|
3120
|
+
export const RETIRED_FUNCTION_RUNTIME_KEYS = ["mode", "durable"];
|
|
3121
|
+
const RUNTIME_IS_THE_CALLERS = "a function is a function, and the caller picks the runtime at each call — " +
|
|
3122
|
+
"`invoke` runs it inside the request, `start` runs it as a task";
|
|
3123
|
+
const FROM_PHASE_5 = "Remove the line; from phase 5 it is refused.";
|
|
2471
3124
|
/**
|
|
2472
|
-
*
|
|
3125
|
+
* Every retired runtime key a `[function]` table still carries.
|
|
3126
|
+
*
|
|
3127
|
+
* Reads the table the way the author wrote it: the two top-level keys, and a
|
|
3128
|
+
* `mode` on each `[[function.triggers.cron]]` entry. A cron entry's sentence
|
|
3129
|
+
* says the extra thing its author needs to hear — that the schedule no longer
|
|
3130
|
+
* chooses, because every fire starts a task run (#3334's runner branch is
|
|
3131
|
+
* gone).
|
|
2473
3132
|
*
|
|
2474
|
-
*
|
|
2475
|
-
*
|
|
2476
|
-
* both keys, so an author who half-migrated a file hears which line to fix.
|
|
3133
|
+
* Order is the order an author reads their file in: the top-level keys first,
|
|
3134
|
+
* then the cron entries in declaration order.
|
|
2477
3135
|
*/
|
|
2478
|
-
export function
|
|
2479
|
-
const
|
|
2480
|
-
|
|
2481
|
-
|
|
2482
|
-
const
|
|
2483
|
-
|
|
2484
|
-
|
|
2485
|
-
|
|
2486
|
-
|
|
2487
|
-
|
|
2488
|
-
|
|
2489
|
-
|
|
2490
|
-
"task function returns a run id and survives restarts",
|
|
2491
|
-
};
|
|
2492
|
-
}
|
|
2493
|
-
fromMode = rawMode;
|
|
2494
|
-
}
|
|
2495
|
-
let fromDurable = null;
|
|
2496
|
-
if (hasDurable) {
|
|
2497
|
-
if (typeof rawDurable !== "boolean") {
|
|
2498
|
-
return {
|
|
2499
|
-
ok: false,
|
|
2500
|
-
error: `\`durable\` must be true or false (got ${describe(rawDurable)}); it is ` +
|
|
2501
|
-
'the accepted alias for `mode = "task"` / `mode = "request"`',
|
|
2502
|
-
};
|
|
2503
|
-
}
|
|
2504
|
-
fromDurable = rawDurable ? "task" : "request";
|
|
2505
|
-
}
|
|
2506
|
-
if (fromMode && fromDurable && fromMode !== fromDurable) {
|
|
2507
|
-
return {
|
|
2508
|
-
ok: false,
|
|
2509
|
-
error: `\`mode = "${fromMode}"\` and \`durable = ${String(rawDurable)}\` disagree: ` +
|
|
2510
|
-
'`durable` is the accepted alias for `mode = "task"` (true) and ' +
|
|
2511
|
-
'`mode = "request"` (false), so keep one key, or make the two agree',
|
|
2512
|
-
};
|
|
3136
|
+
export function ignoredFunctionKeys(functionTable) {
|
|
3137
|
+
const table = functionTable;
|
|
3138
|
+
if (!table || typeof table !== "object" || Array.isArray(table))
|
|
3139
|
+
return [];
|
|
3140
|
+
const found = [];
|
|
3141
|
+
for (const key of RETIRED_FUNCTION_RUNTIME_KEYS) {
|
|
3142
|
+
if (table[key] === undefined)
|
|
3143
|
+
continue;
|
|
3144
|
+
found.push({
|
|
3145
|
+
path: key,
|
|
3146
|
+
message: `\`${key}\` is ignored (#3482): ${RUNTIME_IS_THE_CALLERS}. ${FROM_PHASE_5}`,
|
|
3147
|
+
});
|
|
2513
3148
|
}
|
|
2514
|
-
const
|
|
2515
|
-
|
|
3149
|
+
const triggers = table.triggers;
|
|
3150
|
+
const rawCron = triggers && typeof triggers === "object" && !Array.isArray(triggers)
|
|
3151
|
+
? triggers.cron
|
|
3152
|
+
: undefined;
|
|
3153
|
+
const entries = Array.isArray(rawCron)
|
|
3154
|
+
? rawCron
|
|
3155
|
+
: rawCron && typeof rawCron === "object"
|
|
3156
|
+
? [rawCron]
|
|
3157
|
+
: [];
|
|
3158
|
+
entries.forEach((entry, index) => {
|
|
3159
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry))
|
|
3160
|
+
return;
|
|
3161
|
+
if (entry.mode === undefined)
|
|
3162
|
+
return;
|
|
3163
|
+
const rawName = entry.name;
|
|
3164
|
+
const name = typeof rawName === "string" && rawName ? rawName : `#${index + 1}`;
|
|
3165
|
+
found.push({
|
|
3166
|
+
path: `triggers.cron.${name}.mode`,
|
|
3167
|
+
message: `\`mode\` on cron trigger '${name}' is ignored (#3482): every cron ` +
|
|
3168
|
+
`fire starts a task run, and ${RUNTIME_IS_THE_CALLERS}. ${FROM_PHASE_5}`,
|
|
3169
|
+
});
|
|
3170
|
+
});
|
|
3171
|
+
return found;
|
|
2516
3172
|
}
|
|
2517
3173
|
/**
|
|
2518
|
-
* The
|
|
2519
|
-
*
|
|
2520
|
-
*
|
|
2521
|
-
*
|
|
3174
|
+
* The database types a `[function]` table's retired trigger block names —
|
|
3175
|
+
* #3483.
|
|
3176
|
+
*
|
|
3177
|
+
* Beside `ignoredFunctionKeys` because it answers the same question about the
|
|
3178
|
+
* same table, for the same reader: `config pull` writes a version's AUTHORED
|
|
3179
|
+
* bytes, and a version pushed before the removal carries the block. Without
|
|
3180
|
+
* this the pulled tree would be refused on its next push — a tree an author
|
|
3181
|
+
* cannot get back into a working state, which is the failure D3482-007 already
|
|
3182
|
+
* had to close for the runtime keys.
|
|
3183
|
+
*
|
|
3184
|
+
* This is NOT a refusal and shares nothing with one. The block is refused on
|
|
3185
|
+
* input by `normalizeTriggerDeclaration`; what a strip needs is the list of
|
|
3186
|
+
* entries it is taking out so it can say so, which is why an entry whose
|
|
3187
|
+
* `type` is unreadable still counts as one entry (the empty string). It is the
|
|
3188
|
+
* block's PRESENCE that goes, not its contents.
|
|
3189
|
+
*
|
|
3190
|
+
* TOML spells the block three ways — an array of tables, a single table, and
|
|
3191
|
+
* an inline array — and a scan that missed one would leave a key behind
|
|
3192
|
+
* (CR3482-002's lesson, applied to a whole table).
|
|
2522
3193
|
*/
|
|
2523
|
-
export function
|
|
2524
|
-
|
|
3194
|
+
export function retiredDatabaseTriggerTypes(functionTable) {
|
|
3195
|
+
const raw = retiredDatabaseTriggerValue(functionTable);
|
|
3196
|
+
if (raw === undefined || raw === null)
|
|
3197
|
+
return [];
|
|
3198
|
+
const entries = Array.isArray(raw) ? raw : [raw];
|
|
3199
|
+
return entries.map((entry) => {
|
|
3200
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry))
|
|
3201
|
+
return "";
|
|
3202
|
+
const type = entry.type;
|
|
3203
|
+
return typeof type === "string" ? type : String(type ?? "");
|
|
3204
|
+
});
|
|
2525
3205
|
}
|
|
2526
|
-
/**
|
|
2527
|
-
|
|
2528
|
-
|
|
3206
|
+
/**
|
|
3207
|
+
* Whether the block is THERE at all — the question a strip has to ask, which
|
|
3208
|
+
* the type list above cannot answer (CR3483-001).
|
|
3209
|
+
*
|
|
3210
|
+
* `database = []` names no type, so the list is empty and a strip that decided
|
|
3211
|
+
* by its length left the key in the pulled file. That file then fails its own
|
|
3212
|
+
* next push: `normalizeTriggerDeclaration` refuses the key by PRESENCE, of any
|
|
3213
|
+
* shape, an empty array included — so the two halves disagreed about what the
|
|
3214
|
+
* block is, and the tree an author pulled was a tree they could not push.
|
|
3215
|
+
* Presence is the one rule both sides read now, spelled the way
|
|
3216
|
+
* `normalizeTriggerDeclaration` spells it — `!== undefined` — so a value one
|
|
3217
|
+
* of them would call a block and the other would not cannot exist.
|
|
3218
|
+
*/
|
|
3219
|
+
export function hasRetiredDatabaseTriggerBlock(functionTable) {
|
|
3220
|
+
return retiredDatabaseTriggerValue(functionTable) !== undefined;
|
|
2529
3221
|
}
|
|
2530
|
-
|
|
2531
|
-
|
|
2532
|
-
|
|
2533
|
-
|
|
2534
|
-
|
|
2535
|
-
|
|
2536
|
-
|
|
2537
|
-
|
|
3222
|
+
/**
|
|
3223
|
+
* The raw `[function.triggers].database` value, or `undefined` when the block
|
|
3224
|
+
* is absent. One walk down to the key, so presence and contents can never be
|
|
3225
|
+
* read off two different traversals.
|
|
3226
|
+
*/
|
|
3227
|
+
function retiredDatabaseTriggerValue(functionTable) {
|
|
3228
|
+
const table = functionTable;
|
|
3229
|
+
if (!table || typeof table !== "object" || Array.isArray(table)) {
|
|
3230
|
+
return undefined;
|
|
3231
|
+
}
|
|
3232
|
+
const triggers = table.triggers;
|
|
3233
|
+
if (!triggers || typeof triggers !== "object" || Array.isArray(triggers)) {
|
|
3234
|
+
return undefined;
|
|
3235
|
+
}
|
|
3236
|
+
return triggers.database;
|
|
2538
3237
|
}
|
|
2539
3238
|
/**
|
|
2540
|
-
*
|
|
3239
|
+
* The same scan, rendered for one file — the shape `config push` and
|
|
3240
|
+
* `config diff` print and the admin route answers with.
|
|
2541
3241
|
*
|
|
2542
|
-
*
|
|
2543
|
-
*
|
|
2544
|
-
* half that was missing — an author who meets this at the push door could not
|
|
2545
|
-
* previously tell whether it was a limitation or a design, nor what to write.
|
|
3242
|
+
* `label` is what the reader can act on: `functions/<key>.toml` from the CLI,
|
|
3243
|
+
* and the function's key from the API, which has no file to name.
|
|
2546
3244
|
*/
|
|
2547
|
-
export
|
|
2548
|
-
|
|
2549
|
-
|
|
2550
|
-
"the request function, then hand the work to a task function with " +
|
|
2551
|
-
"ctx.functions.start — or remove [function.triggers.webhook]";
|
|
3245
|
+
export function ignoredFunctionKeyWarnings(label, functionTable) {
|
|
3246
|
+
return ignoredFunctionKeys(functionTable).map((ignored) => `${label}: ${ignored.message}`);
|
|
3247
|
+
}
|
|
2552
3248
|
export const EMPTY_TRIGGER_DECLARATION = {
|
|
2553
3249
|
webhook: null,
|
|
2554
3250
|
cron: [],
|
|
2555
|
-
databases: [],
|
|
2556
3251
|
};
|
|
2557
3252
|
/**
|
|
2558
3253
|
* The most cron entries one function may declare.
|
|
@@ -2570,22 +3265,6 @@ export const MAX_CRON_TRIGGERS_PER_FUNCTION = 10;
|
|
|
2570
3265
|
* collide with (the intent, "Key namespace?").
|
|
2571
3266
|
*/
|
|
2572
3267
|
const CRON_NAME_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/;
|
|
2573
|
-
/**
|
|
2574
|
-
* The most database types one function may watch (#3184, D-07).
|
|
2575
|
-
*
|
|
2576
|
-
* Per-OBJECT, like the cron cap above: a function watching more than a handful
|
|
2577
|
-
* of types is describing a fan-in the platform cannot bound per write, and
|
|
2578
|
-
* every extra type is another discovery read on somebody's write path.
|
|
2579
|
-
*/
|
|
2580
|
-
export const MAX_DATABASE_TRIGGERS_PER_FUNCTION = 5;
|
|
2581
|
-
/**
|
|
2582
|
-
* A database type key, matching the charset the grant grammar already uses for
|
|
2583
|
-
* the same value (`src/config-surface/function-grants.ts`). One rule for one
|
|
2584
|
-
* concept: `database:<type>/<model>:read` and `[[function.triggers.database]]`
|
|
2585
|
-
* name the same thing.
|
|
2586
|
-
*/
|
|
2587
|
-
const DATABASE_TYPE_PATTERN = /^[A-Za-z0-9_-]+$/;
|
|
2588
|
-
const DATABASE_KEYS = new Set(["type"]);
|
|
2589
3268
|
const WEBHOOK_KEYS = new Set([
|
|
2590
3269
|
"verificationScheme",
|
|
2591
3270
|
"signingSecret",
|
|
@@ -2599,18 +3278,67 @@ const WEBHOOK_KEYS = new Set([
|
|
|
2599
3278
|
const CRON_KEYS = new Set([
|
|
2600
3279
|
"name",
|
|
2601
3280
|
"cron",
|
|
3281
|
+
// #3482 — RETIRED and still accepted. `cron-trigger-mode-required` demanded
|
|
3282
|
+
// this key on every default-mode function since #3454, so every tree pushed
|
|
3283
|
+
// with a cron entry since then carries it; dropping it from the accepted set
|
|
3284
|
+
// would refuse those trees with an unknown-key error one day after the
|
|
3285
|
+
// platform insisted on it (D3482-006). Ignored, warned about at the push
|
|
3286
|
+
// door, and refused from phase 5.
|
|
3287
|
+
"mode",
|
|
2602
3288
|
"timezone",
|
|
2603
3289
|
"overlapPolicy",
|
|
2604
3290
|
"rootInput",
|
|
2605
3291
|
]);
|
|
2606
3292
|
const VALID_OVERLAP_POLICIES = ["skip", "allow"];
|
|
3293
|
+
/**
|
|
3294
|
+
* The rule ids this module owns — one per refusal, declared on the `function`
|
|
3295
|
+
* surface (#3374). The union below closes `refuseTrigger`'s id parameter, so a
|
|
3296
|
+
* twenty-seventh refusal cannot compile without a twenty-seventh id, and that
|
|
3297
|
+
* id fails `cli-unit-tests` until the surface declares it (#3373's anchor).
|
|
3298
|
+
*/
|
|
3299
|
+
export const TRIGGER_RULE_IDS = [
|
|
3300
|
+
"triggers-table",
|
|
3301
|
+
"trigger-kind",
|
|
3302
|
+
// #3483 — one refusal replaced the eight that shaped the block. There is no
|
|
3303
|
+
// shape left to get right; the block itself is what is refused.
|
|
3304
|
+
"database-trigger-removed",
|
|
3305
|
+
"webhook-trigger-single",
|
|
3306
|
+
"webhook-trigger-table",
|
|
3307
|
+
"webhook-trigger-keys",
|
|
3308
|
+
"webhook-trigger-scheme-required",
|
|
3309
|
+
"webhook-trigger-scheme-none",
|
|
3310
|
+
"webhook-trigger-number",
|
|
3311
|
+
"webhook-trigger-dedup-boolean",
|
|
3312
|
+
"webhook-trigger-verification-table",
|
|
3313
|
+
"cron-trigger-cap",
|
|
3314
|
+
"cron-trigger-entry",
|
|
3315
|
+
"cron-trigger-keys",
|
|
3316
|
+
"cron-trigger-name-required",
|
|
3317
|
+
"cron-trigger-name-charset",
|
|
3318
|
+
"cron-trigger-duplicate",
|
|
3319
|
+
"cron-trigger-expression-required",
|
|
3320
|
+
"cron-trigger-overlap-policy",
|
|
3321
|
+
];
|
|
3322
|
+
/**
|
|
3323
|
+
* A broken trigger rule. `ruleId` names which one, for a reader that wants to
|
|
3324
|
+
* branch without matching prose; every existing site reads `message` only.
|
|
3325
|
+
* Optional so `new TriggerDeclarationError(message)` — the CLI's own parse
|
|
3326
|
+
* refusal in `function-sync.ts` — keeps constructing.
|
|
3327
|
+
*/
|
|
2607
3328
|
export class TriggerDeclarationError extends Error {
|
|
3329
|
+
ruleId;
|
|
3330
|
+
constructor(message, ruleId) {
|
|
3331
|
+
super(message);
|
|
3332
|
+
this.ruleId = ruleId;
|
|
3333
|
+
}
|
|
3334
|
+
}
|
|
3335
|
+
/** The one `throw` in this module: every refusal names its rule here. */
|
|
3336
|
+
function refuseTrigger(ruleId, message) {
|
|
3337
|
+
throw new TriggerDeclarationError(message, ruleId);
|
|
2608
3338
|
}
|
|
2609
3339
|
/** Is anything at all declared? Used to skip work on the common case. */
|
|
2610
3340
|
export function hasDeclaredTriggers(declaration) {
|
|
2611
|
-
return
|
|
2612
|
-
declaration.cron.length > 0 ||
|
|
2613
|
-
declaration.databases.length > 0);
|
|
3341
|
+
return declaration.webhook !== null || declaration.cron.length > 0;
|
|
2614
3342
|
}
|
|
2615
3343
|
/**
|
|
2616
3344
|
* Normalize the `triggers` table of an already-parsed `[function]` table.
|
|
@@ -2621,114 +3349,72 @@ export function hasDeclaredTriggers(declaration) {
|
|
|
2621
3349
|
* push boundary can normalize a declaration it read from a stored row without
|
|
2622
3350
|
* re-parsing bytes.
|
|
2623
3351
|
*/
|
|
2624
|
-
export function normalizeTriggerDeclaration(functionTable
|
|
3352
|
+
export function normalizeTriggerDeclaration(functionTable) {
|
|
2625
3353
|
const triggers = functionTable?.triggers;
|
|
2626
3354
|
if (triggers === undefined || triggers === null) {
|
|
2627
3355
|
return EMPTY_TRIGGER_DECLARATION;
|
|
2628
3356
|
}
|
|
2629
3357
|
if (typeof triggers !== "object" || Array.isArray(triggers)) {
|
|
2630
|
-
|
|
3358
|
+
refuseTrigger("triggers-table", "[function.triggers] must be a table declaring `webhook` and/or `cron`");
|
|
3359
|
+
}
|
|
3360
|
+
// #3483 — checked BEFORE the unknown-kind rule, so an author who declares
|
|
3361
|
+
// the block hears why it went rather than that `database` is not a word the
|
|
3362
|
+
// platform knows. Any shape of the key is the same refusal: it is the
|
|
3363
|
+
// block's PRESENCE that is refused, and an author who wrote
|
|
3364
|
+
// `triggers.database = []` still has a line to delete.
|
|
3365
|
+
if (triggers.database !== undefined) {
|
|
3366
|
+
refuseTrigger("database-trigger-removed", DATABASE_TRIGGER_REMOVED);
|
|
2631
3367
|
}
|
|
2632
3368
|
for (const key of Object.keys(triggers)) {
|
|
2633
|
-
if (key !== "webhook" && key !== "cron"
|
|
2634
|
-
|
|
2635
|
-
"`webhook
|
|
3369
|
+
if (key !== "webhook" && key !== "cron") {
|
|
3370
|
+
refuseTrigger("trigger-kind", `unknown trigger kind '${key}' under [function.triggers]: the kinds are ` +
|
|
3371
|
+
"`webhook` and `cron`");
|
|
2636
3372
|
}
|
|
2637
3373
|
}
|
|
2638
|
-
const webhook = normalizeWebhook(triggers.webhook
|
|
2639
|
-
const cron = normalizeCron(triggers.cron
|
|
2640
|
-
|
|
2641
|
-
return { webhook, cron, databases };
|
|
3374
|
+
const webhook = normalizeWebhook(triggers.webhook);
|
|
3375
|
+
const cron = normalizeCron(triggers.cron);
|
|
3376
|
+
return { webhook, cron };
|
|
2642
3377
|
}
|
|
2643
3378
|
/**
|
|
2644
|
-
*
|
|
2645
|
-
*
|
|
2646
|
-
*
|
|
2647
|
-
*
|
|
2648
|
-
*
|
|
2649
|
-
*
|
|
3379
|
+
* The sentence an author who declared the block is owed — D3483-001.
|
|
3380
|
+
*
|
|
3381
|
+
* It names the pattern that replaces the trigger rather than only saying the
|
|
3382
|
+
* trigger is gone, because the author's problem is not "how do I spell this"
|
|
3383
|
+
* but "where does this work go now". The answer is: into the handler that
|
|
3384
|
+
* writes the row. Database access moves behind server functions (phase 6
|
|
3385
|
+
* retires the client's direct database paths), so the code that writes a
|
|
3386
|
+
* record is already a function — a trigger fired by that write has no caller
|
|
3387
|
+
* it cannot already see.
|
|
2650
3388
|
*/
|
|
2651
|
-
function
|
|
2652
|
-
|
|
2653
|
-
|
|
2654
|
-
|
|
2655
|
-
|
|
2656
|
-
|
|
2657
|
-
if (options.mode === "task") {
|
|
2658
|
-
throw new TriggerDeclarationError("database triggers require request mode: set `mode = \"request\"` (or " +
|
|
2659
|
-
"remove `mode = \"task\"`), or remove the [[function.triggers.database]] " +
|
|
2660
|
-
"entries");
|
|
2661
|
-
}
|
|
2662
|
-
if (entries.length > MAX_DATABASE_TRIGGERS_PER_FUNCTION) {
|
|
2663
|
-
throw new TriggerDeclarationError(`a function may watch at most ${MAX_DATABASE_TRIGGERS_PER_FUNCTION} database ` +
|
|
2664
|
-
`types, and this one declares ${entries.length}`);
|
|
2665
|
-
}
|
|
2666
|
-
const seen = new Set();
|
|
2667
|
-
const normalized = [];
|
|
2668
|
-
for (const entry of entries) {
|
|
2669
|
-
if (entry === null || typeof entry !== "object" || Array.isArray(entry)) {
|
|
2670
|
-
throw new TriggerDeclarationError("each [[function.triggers.database]] entry must be a table");
|
|
2671
|
-
}
|
|
2672
|
-
const table = entry;
|
|
2673
|
-
for (const key of Object.keys(table)) {
|
|
2674
|
-
if (!DATABASE_KEYS.has(key)) {
|
|
2675
|
-
throw new TriggerDeclarationError(`unknown key '${key}' in a [[function.triggers.database]] entry: the ` +
|
|
2676
|
-
`only accepted key is ${[...DATABASE_KEYS].join(", ")}`);
|
|
2677
|
-
}
|
|
2678
|
-
}
|
|
2679
|
-
const type = String(table.type ?? "").trim();
|
|
2680
|
-
if (!type) {
|
|
2681
|
-
throw new TriggerDeclarationError("each [[function.triggers.database]] entry needs a `type`: it names the " +
|
|
2682
|
-
"database type whose writes fire the function");
|
|
2683
|
-
}
|
|
2684
|
-
if (!DATABASE_TYPE_PATTERN.test(type)) {
|
|
2685
|
-
throw new TriggerDeclarationError(`database trigger type '${type}' must contain only letters, digits, ` +
|
|
2686
|
-
"'-' or '_'");
|
|
2687
|
-
}
|
|
2688
|
-
if (seen.has(type)) {
|
|
2689
|
-
throw new TriggerDeclarationError(`duplicate database trigger type '${type}': a function fires once per ` +
|
|
2690
|
-
"write request, so each type is declared once");
|
|
2691
|
-
}
|
|
2692
|
-
seen.add(type);
|
|
2693
|
-
normalized.push({ type });
|
|
2694
|
-
}
|
|
2695
|
-
// Sorted, like the cron entries: two files that differ only in the order
|
|
2696
|
-
// they list their blocks describe the same triggers.
|
|
2697
|
-
normalized.sort((a, b) => a.type.localeCompare(b.type));
|
|
2698
|
-
return normalized;
|
|
2699
|
-
}
|
|
2700
|
-
function normalizeWebhook(raw, options) {
|
|
3389
|
+
const DATABASE_TRIGGER_REMOVED = "[[function.triggers.database]] is no longer supported (#3483): database " +
|
|
3390
|
+
"access moves behind functions, so the code that writes the record is a " +
|
|
3391
|
+
"function — do the follow-up in the handler that writes (write the row, " +
|
|
3392
|
+
"then publish, notify or write again), or hand longer work to a task with " +
|
|
3393
|
+
"ctx.functions.start. Remove the block.";
|
|
3394
|
+
function normalizeWebhook(raw) {
|
|
2701
3395
|
if (raw === undefined || raw === null)
|
|
2702
3396
|
return null;
|
|
2703
3397
|
if (Array.isArray(raw)) {
|
|
2704
3398
|
// `[[function.triggers.webhook]]` — the shape that would declare two.
|
|
2705
|
-
|
|
3399
|
+
refuseTrigger("webhook-trigger-single", "[function.triggers.webhook] is a single table: one webhook trigger per " +
|
|
2706
3400
|
"function (the intent, \"Webhook trigger URL and scheme?\")");
|
|
2707
3401
|
}
|
|
2708
3402
|
if (typeof raw !== "object") {
|
|
2709
|
-
|
|
3403
|
+
refuseTrigger("webhook-trigger-table", "[function.triggers.webhook] must be a table");
|
|
2710
3404
|
}
|
|
2711
3405
|
const table = raw;
|
|
2712
3406
|
for (const key of Object.keys(table)) {
|
|
2713
3407
|
if (!WEBHOOK_KEYS.has(key)) {
|
|
2714
|
-
|
|
3408
|
+
refuseTrigger("webhook-trigger-keys", `unknown key '${key}' under [function.triggers.webhook]: accepted keys ` +
|
|
2715
3409
|
`are ${[...WEBHOOK_KEYS].join(", ")}`);
|
|
2716
3410
|
}
|
|
2717
3411
|
}
|
|
2718
|
-
// The intent forbids this permanently: a webhook fires the function inside
|
|
2719
|
-
// the receiver, and the provider is holding the connection open waiting for
|
|
2720
|
-
// its answer. A task run has no answer to give inside that request — it
|
|
2721
|
-
// returns a run id and finishes later — so the refusal says so and names
|
|
2722
|
-
// the pattern that does the same job (#3334).
|
|
2723
|
-
if (options.mode === "task") {
|
|
2724
|
-
throw new TriggerDeclarationError(WEBHOOK_ON_TASK_REFUSAL);
|
|
2725
|
-
}
|
|
2726
3412
|
const scheme = String(table.verificationScheme ?? "").trim();
|
|
2727
3413
|
if (!scheme) {
|
|
2728
|
-
|
|
3414
|
+
refuseTrigger("webhook-trigger-scheme-required", "[function.triggers.webhook] verificationScheme is required");
|
|
2729
3415
|
}
|
|
2730
3416
|
if (scheme === "none") {
|
|
2731
|
-
|
|
3417
|
+
refuseTrigger("webhook-trigger-scheme-none", "verification scheme 'none' is not allowed for function triggers: an " +
|
|
2732
3418
|
"unauthenticated caller could run the function");
|
|
2733
3419
|
}
|
|
2734
3420
|
const declaration = { verificationScheme: scheme };
|
|
@@ -2745,13 +3431,13 @@ function normalizeWebhook(raw, options) {
|
|
|
2745
3431
|
continue;
|
|
2746
3432
|
const value = Number(table[key]);
|
|
2747
3433
|
if (!Number.isFinite(value)) {
|
|
2748
|
-
|
|
3434
|
+
refuseTrigger("webhook-trigger-number", `[function.triggers.webhook] ${key} must be a number`);
|
|
2749
3435
|
}
|
|
2750
3436
|
declaration[key] = value;
|
|
2751
3437
|
}
|
|
2752
3438
|
if (table.deduplicationEnabled !== undefined) {
|
|
2753
3439
|
if (typeof table.deduplicationEnabled !== "boolean") {
|
|
2754
|
-
|
|
3440
|
+
refuseTrigger("webhook-trigger-dedup-boolean", "[function.triggers.webhook] deduplicationEnabled must be a boolean");
|
|
2755
3441
|
}
|
|
2756
3442
|
declaration.deduplicationEnabled = table.deduplicationEnabled;
|
|
2757
3443
|
}
|
|
@@ -2760,58 +3446,56 @@ function normalizeWebhook(raw, options) {
|
|
|
2760
3446
|
if (verification === null ||
|
|
2761
3447
|
typeof verification !== "object" ||
|
|
2762
3448
|
Array.isArray(verification)) {
|
|
2763
|
-
|
|
3449
|
+
refuseTrigger("webhook-trigger-verification-table", "[function.triggers.webhook.verification] must be a table");
|
|
2764
3450
|
}
|
|
2765
3451
|
declaration.verification = jsonSafe(verification);
|
|
2766
3452
|
}
|
|
2767
3453
|
return declaration;
|
|
2768
3454
|
}
|
|
2769
|
-
function normalizeCron(raw
|
|
3455
|
+
function normalizeCron(raw) {
|
|
2770
3456
|
if (raw === undefined || raw === null)
|
|
2771
3457
|
return [];
|
|
2772
3458
|
const entries = Array.isArray(raw) ? raw : [raw];
|
|
2773
3459
|
if (entries.length === 0)
|
|
2774
3460
|
return [];
|
|
2775
|
-
// No mode check: a cron entry is legal in EITHER mode (#3334). The intent
|
|
2776
|
-
// allows it ("Sync functions write a run row?": *HTTP and cron allow both*),
|
|
2777
|
-
// and the cron DO starts a durable run for a task target. #3181's refusal
|
|
2778
|
-
// here was release-scoped, written before task starts existed, and its own
|
|
2779
|
-
// comment asked for exactly this one-line removal.
|
|
2780
3461
|
if (entries.length > MAX_CRON_TRIGGERS_PER_FUNCTION) {
|
|
2781
|
-
|
|
3462
|
+
refuseTrigger("cron-trigger-cap", `a function may declare at most ${MAX_CRON_TRIGGERS_PER_FUNCTION} cron ` +
|
|
2782
3463
|
`trigger entries, and this one declares ${entries.length}`);
|
|
2783
3464
|
}
|
|
2784
3465
|
const seen = new Set();
|
|
2785
3466
|
const normalized = [];
|
|
2786
3467
|
for (const entry of entries) {
|
|
2787
3468
|
if (entry === null || typeof entry !== "object" || Array.isArray(entry)) {
|
|
2788
|
-
|
|
3469
|
+
refuseTrigger("cron-trigger-entry", "each [[function.triggers.cron]] entry must be a table");
|
|
2789
3470
|
}
|
|
2790
3471
|
const table = entry;
|
|
2791
3472
|
for (const key of Object.keys(table)) {
|
|
2792
3473
|
if (!CRON_KEYS.has(key)) {
|
|
2793
|
-
|
|
3474
|
+
refuseTrigger("cron-trigger-keys", `unknown key '${key}' in a [[function.triggers.cron]] entry: accepted ` +
|
|
2794
3475
|
`keys are ${[...CRON_KEYS].join(", ")}`);
|
|
2795
3476
|
}
|
|
2796
3477
|
}
|
|
2797
3478
|
const name = String(table.name ?? "").trim();
|
|
2798
3479
|
if (!name) {
|
|
2799
|
-
|
|
3480
|
+
refuseTrigger("cron-trigger-name-required", "each [[function.triggers.cron]] entry needs a `name`: it is required " +
|
|
2800
3481
|
"because it is half of the trigger's key, `<functionKey>:<name>`");
|
|
2801
3482
|
}
|
|
2802
3483
|
if (!CRON_NAME_PATTERN.test(name)) {
|
|
2803
|
-
|
|
3484
|
+
refuseTrigger("cron-trigger-name-charset", `cron trigger name '${name}' must start with a letter or digit and ` +
|
|
2804
3485
|
"contain only letters, digits, '-' or '_'");
|
|
2805
3486
|
}
|
|
2806
3487
|
if (seen.has(name)) {
|
|
2807
|
-
|
|
3488
|
+
refuseTrigger("cron-trigger-duplicate", `duplicate cron trigger name '${name}': names are the trigger's key ` +
|
|
2808
3489
|
"within the function, so each must appear once");
|
|
2809
3490
|
}
|
|
2810
3491
|
seen.add(name);
|
|
2811
3492
|
const cron = String(table.cron ?? "").trim();
|
|
2812
3493
|
if (!cron) {
|
|
2813
|
-
|
|
3494
|
+
refuseTrigger("cron-trigger-expression-required", `cron trigger '${name}' needs a \`cron\` expression`);
|
|
2814
3495
|
}
|
|
3496
|
+
// #3482 — no runner branch here any more. Every cron fire starts a task
|
|
3497
|
+
// run, so an entry's `mode` decides nothing; it is accepted (D3482-006),
|
|
3498
|
+
// reported as ignored by `ignoredFunctionKeys`, and dropped.
|
|
2815
3499
|
const declaration = { name, cron };
|
|
2816
3500
|
if (table.timezone !== undefined) {
|
|
2817
3501
|
declaration.timezone = String(table.timezone);
|
|
@@ -2819,7 +3503,7 @@ function normalizeCron(raw, options) {
|
|
|
2819
3503
|
if (table.overlapPolicy !== undefined) {
|
|
2820
3504
|
const policy = String(table.overlapPolicy);
|
|
2821
3505
|
if (!VALID_OVERLAP_POLICIES.includes(policy)) {
|
|
2822
|
-
|
|
3506
|
+
refuseTrigger("cron-trigger-overlap-policy", `cron trigger '${name}': overlapPolicy must be one of ` +
|
|
2823
3507
|
VALID_OVERLAP_POLICIES.join(", "));
|
|
2824
3508
|
}
|
|
2825
3509
|
declaration.overlapPolicy = policy;
|
|
@@ -2880,15 +3564,39 @@ export function parseStoredTriggerDeclaration(stored) {
|
|
|
2880
3564
|
return {
|
|
2881
3565
|
webhook: parsed?.webhook ?? null,
|
|
2882
3566
|
cron: Array.isArray(parsed?.cron) ? parsed.cron : [],
|
|
2883
|
-
//
|
|
2884
|
-
//
|
|
2885
|
-
|
|
3567
|
+
// #3483 — a stored `databases` key is DROPPED, not read. A version
|
|
3568
|
+
// pushed before the refusal existed still carries one in its immutable
|
|
3569
|
+
// JSON, and the row is never rewritten; answering nothing at all is what
|
|
3570
|
+
// makes that version inert at every reader at once.
|
|
3571
|
+
// `storedDatabaseTriggerTypes` below is how a caller that wants to SAY
|
|
3572
|
+
// something about it still can.
|
|
2886
3573
|
};
|
|
2887
3574
|
}
|
|
2888
3575
|
catch {
|
|
2889
3576
|
return EMPTY_TRIGGER_DECLARATION;
|
|
2890
3577
|
}
|
|
2891
3578
|
}
|
|
3579
|
+
/**
|
|
3580
|
+
* The database types a STORED declaration used to watch, for a reader that
|
|
3581
|
+
* wants to name them — #3483.
|
|
3582
|
+
*
|
|
3583
|
+
* Pure, and separate from the parse on purpose: the parse answers what the
|
|
3584
|
+
* version means NOW (nothing), and this answers what it said, which is only
|
|
3585
|
+
* ever used to write one log line. Nothing branches on it.
|
|
3586
|
+
*/
|
|
3587
|
+
export function storedDatabaseTriggerTypes(stored) {
|
|
3588
|
+
if (typeof stored !== "string" || !stored.trim())
|
|
3589
|
+
return [];
|
|
3590
|
+
try {
|
|
3591
|
+
const parsed = JSON.parse(stored);
|
|
3592
|
+
if (!Array.isArray(parsed?.databases))
|
|
3593
|
+
return [];
|
|
3594
|
+
return parsed.databases.map((entry) => String(entry?.type ?? ""));
|
|
3595
|
+
}
|
|
3596
|
+
catch {
|
|
3597
|
+
return [];
|
|
3598
|
+
}
|
|
3599
|
+
}
|
|
2892
3600
|
/** The `CronTrigger.triggerKey` an entry owns. */
|
|
2893
3601
|
export function cronTriggerKeyFor(functionKey, name) {
|
|
2894
3602
|
return `${functionKey}:${name}`;
|
|
@@ -3141,6 +3849,27 @@ export function requiresSigningSecret(scheme) {
|
|
|
3141
3849
|
/** The coded refusals, so a client can branch without matching prose. */
|
|
3142
3850
|
export const SIGNING_SECRET_MUST_BE_SECRET_REF = "SIGNING_SECRET_MUST_BE_SECRET_REF";
|
|
3143
3851
|
export const SIGNING_SECRET_NOT_SUPPORTED_FOR_SCHEME = "SIGNING_SECRET_NOT_SUPPORTED_FOR_SCHEME";
|
|
3852
|
+
/**
|
|
3853
|
+
* The rule ids this module owns, declared on the `function` surface as
|
|
3854
|
+
* `preflight` and on the `webhook` surface as `apply` (#3374) — one rule, two
|
|
3855
|
+
* surfaces, one handler. `whole-secret-reference` is generic on purpose:
|
|
3856
|
+
* `validateWholeSecretReference` is reused by the Google client secret and by
|
|
3857
|
+
* both webhook controllers under their own labels and codes.
|
|
3858
|
+
*/
|
|
3859
|
+
export const SIGNING_SECRET_RULE_IDS = [
|
|
3860
|
+
"signing-secret-required",
|
|
3861
|
+
"whole-secret-reference",
|
|
3862
|
+
"signing-secret-not-supported",
|
|
3863
|
+
];
|
|
3864
|
+
/** The one refusal constructor (#3373's anchor). */
|
|
3865
|
+
function refuseSigningSecret(ruleId, message, extra = {}) {
|
|
3866
|
+
const refusal = { ruleId, message };
|
|
3867
|
+
if (extra.code !== undefined)
|
|
3868
|
+
refusal.code = extra.code;
|
|
3869
|
+
if (extra.plain !== undefined)
|
|
3870
|
+
refusal.plain = extra.plain;
|
|
3871
|
+
return refusal;
|
|
3872
|
+
}
|
|
3144
3873
|
/** The reference-only message, shared by every field that carries one. */
|
|
3145
3874
|
export function wholeSecretReferenceMessage(label) {
|
|
3146
3875
|
return (`${label} must be a whole {{secrets.KEY}} reference to an app secret, not ` +
|
|
@@ -3159,7 +3888,7 @@ export function wholeSecretReferenceMessage(label) {
|
|
|
3159
3888
|
export function validateWholeSecretReference(label, value, code) {
|
|
3160
3889
|
if (typeof value === "string" && isWholeSecretReference(value))
|
|
3161
3890
|
return null;
|
|
3162
|
-
return
|
|
3891
|
+
return refuseSigningSecret("whole-secret-reference", wholeSecretReferenceMessage(label), { code });
|
|
3163
3892
|
}
|
|
3164
3893
|
/**
|
|
3165
3894
|
* The whole file-decidable `signingSecret` rule, in the order the standalone
|
|
@@ -3175,21 +3904,67 @@ export function validateSigningSecretDeclaration(input) {
|
|
|
3175
3904
|
const scheme = input.verificationScheme;
|
|
3176
3905
|
if (requiresSigningSecret(scheme)) {
|
|
3177
3906
|
if (!input.signingSecret) {
|
|
3178
|
-
return {
|
|
3179
|
-
message: `signingSecret is required for verification scheme '${scheme}'`,
|
|
3180
|
-
plain: true,
|
|
3181
|
-
};
|
|
3907
|
+
return refuseSigningSecret("signing-secret-required", `signingSecret is required for verification scheme '${scheme}'`, { plain: true });
|
|
3182
3908
|
}
|
|
3183
3909
|
return validateWholeSecretReference("signingSecret", input.signingSecret, SIGNING_SECRET_MUST_BE_SECRET_REF);
|
|
3184
3910
|
}
|
|
3185
3911
|
if (input.signingSecret !== undefined &&
|
|
3186
3912
|
input.signingSecret !== null &&
|
|
3187
3913
|
input.signingSecret !== "") {
|
|
3188
|
-
return {
|
|
3189
|
-
|
|
3190
|
-
|
|
3191
|
-
|
|
3192
|
-
|
|
3914
|
+
return refuseSigningSecret("signing-secret-not-supported", `signingSecret is not supported for verification scheme '${scheme}' — ` +
|
|
3915
|
+
"its key material comes from the scheme's config.", { code: SIGNING_SECRET_NOT_SUPPORTED_FOR_SCHEME });
|
|
3916
|
+
}
|
|
3917
|
+
return null;
|
|
3918
|
+
}
|
|
3919
|
+
// ── src/config-surface/workflow-rules.ts ─────────────────────────────────
|
|
3920
|
+
/**
|
|
3921
|
+
* The workflow rules two runners share (#3374, project `cli-server-rule-parity`
|
|
3922
|
+
* phase 2; intent §Success criteria 3).
|
|
3923
|
+
*
|
|
3924
|
+
* ── One predicate, two messages ──────────────────────────────────────────
|
|
3925
|
+
*
|
|
3926
|
+
* `runAs = "system"` beside a non-empty `accessRule` is dead config: a system
|
|
3927
|
+
* run can only be started by the system and never evaluates its access rule
|
|
3928
|
+
* (#1258, broadening #1172). The server refused it at save time
|
|
3929
|
+
* (`validateWorkflowIdentityConfig`) and the CLI refused it in the push
|
|
3930
|
+
* preflight (`validateWorkflowIdentity`), and the two carried the conjunction
|
|
3931
|
+
* byte-for-byte as separate copies kept in step by a comment. This module is
|
|
3932
|
+
* the one copy: it decides, and returns a STRUCTURED violation — the rule id
|
|
3933
|
+
* and the server's sentence — that each site renders in its own words. The
|
|
3934
|
+
* server pushes the sentence into its `string[]`; the CLI keeps its own hint,
|
|
3935
|
+
* which names the preflight remedy. Neither message changed.
|
|
3936
|
+
*
|
|
3937
|
+
* ── Why a `*-rules.ts` module ────────────────────────────────────────────
|
|
3938
|
+
*
|
|
3939
|
+
* The registry's unclaimed-export check (`findUnclaimedRuleModuleExports`)
|
|
3940
|
+
* keys on this name under `src/config-surface/`: every exported function here
|
|
3941
|
+
* is a rule, so one no surface declares fails `cli-unit-tests`. Shared
|
|
3942
|
+
* predicates that are not rules live elsewhere (`webhook-signing-secret.ts`).
|
|
3943
|
+
* The module is vendored into the CLI artifact like the rest of the
|
|
3944
|
+
* directory, which is what lets `workflow`'s declaration call the rule
|
|
3945
|
+
* `preflight`.
|
|
3946
|
+
*/
|
|
3947
|
+
/** The rule ids this module owns — the union type below anchors them (#3373). */
|
|
3948
|
+
export const WORKFLOW_RULE_IDS = ["system-access-rule"];
|
|
3949
|
+
/** The one refusal constructor: a new branch cannot compile without an id. */
|
|
3950
|
+
function refuseWorkflowRule(ruleId, message) {
|
|
3951
|
+
return { ruleId, message };
|
|
3952
|
+
}
|
|
3953
|
+
/**
|
|
3954
|
+
* `runAs = "system"` with a non-empty string `accessRule`, or null.
|
|
3955
|
+
*
|
|
3956
|
+
* Exactly the conjunction both sites carried: a default, unset, empty or
|
|
3957
|
+
* whitespace-only rule is no rule, and a non-string one (`accessRule = false`)
|
|
3958
|
+
* is malformed input for another check to name, not dead config. The message
|
|
3959
|
+
* is the server's save-time sentence; the CLI renders its own hint from the
|
|
3960
|
+
* same answer.
|
|
3961
|
+
*/
|
|
3962
|
+
export function checkWorkflowSystemAccessRule(input) {
|
|
3963
|
+
const { runAs, accessRule } = input;
|
|
3964
|
+
if (runAs === "system" &&
|
|
3965
|
+
typeof accessRule === "string" &&
|
|
3966
|
+
accessRule.trim() !== "") {
|
|
3967
|
+
return refuseWorkflowRule("system-access-rule", 'runAs:"system" workflows do not evaluate accessRule — remove the accessRule');
|
|
3193
3968
|
}
|
|
3194
3969
|
return null;
|
|
3195
3970
|
}
|
|
@@ -3210,7 +3985,20 @@ export function validateSigningSecretDeclaration(input) {
|
|
|
3210
3985
|
* (`cli/src/lib/workflow-payload.ts`, #1177), both deleted with this change.
|
|
3211
3986
|
*/
|
|
3212
3987
|
const HANDLERS = "src/workflows/config/workflow-field-handlers.ts";
|
|
3213
|
-
const handler = (name) => handledBy(HANDLERS, name);
|
|
3988
|
+
const handler = (name) => handledBy(HANDLERS, name, "apply");
|
|
3989
|
+
/**
|
|
3990
|
+
* The save-time rules the server runs and the CLI does not (#3374). They need
|
|
3991
|
+
* the runner's vocabulary (`isValidRunAs`, `GRANTABLE_SENSITIVE_CAPS`), which
|
|
3992
|
+
* this directory cannot import, so they are `apply`; the module carries the
|
|
3993
|
+
* rule-id anchor and `definition-store.ts` re-exports it.
|
|
3994
|
+
*/
|
|
3995
|
+
const WORKFLOW_SAVE_RULES = "src/workflows/config/workflow-save-rules.ts";
|
|
3996
|
+
const workflowApplyRule = (id, exportName, description) => ({
|
|
3997
|
+
id,
|
|
3998
|
+
description,
|
|
3999
|
+
stage: "apply",
|
|
4000
|
+
handler: enforcedBy(WORKFLOW_SAVE_RULES, exportName),
|
|
4001
|
+
});
|
|
3214
4002
|
/**
|
|
3215
4003
|
* The five per-app / per-user queue limits (#1177). All share one handler: a
|
|
3216
4004
|
* finite non-negative number, floored.
|
|
@@ -3524,7 +4312,464 @@ export const WORKFLOW_SURFACE = {
|
|
|
3524
4312
|
"tables), expanded into `steps` by expandWorkflowTomlData before push.",
|
|
3525
4313
|
},
|
|
3526
4314
|
},
|
|
4315
|
+
// ── Cross-field rules (#3374) ─────────────────────────────────────────────
|
|
4316
|
+
//
|
|
4317
|
+
// One rule the CLI's push preflight runs — through the vendored copy of
|
|
4318
|
+
// `workflow-rules.ts` — and seven the server runs at save time. What stays
|
|
4319
|
+
// UNDECLARED, on purpose: the step-body validation (`validateWorkflowDefinition`
|
|
4320
|
+
// and the `steps` structural sub-tree, which is not a field-table rule), and
|
|
4321
|
+
// the enable-time activation lint (`workflow-activation-lint.ts`, a transition
|
|
4322
|
+
// check rather than a write rule). `runAs`'s VALUE is checked twice today —
|
|
4323
|
+
// by its field handler and again inside the identity validator — which is
|
|
4324
|
+
// stated here as a finding, not fixed.
|
|
4325
|
+
crossFieldRules: [
|
|
4326
|
+
{
|
|
4327
|
+
id: "system-access-rule",
|
|
4328
|
+
description: 'A `runAs = "system"` workflow may not carry a non-empty `accessRule`: ' +
|
|
4329
|
+
"a system run never evaluates its rule, so any rule there is dead " +
|
|
4330
|
+
"config. The one predicate both runners share; each renders its own " +
|
|
4331
|
+
"message.",
|
|
4332
|
+
stage: "preflight",
|
|
4333
|
+
handler: enforcedBy("src/config-surface/workflow-rules.ts", "checkWorkflowSystemAccessRule"),
|
|
4334
|
+
},
|
|
4335
|
+
workflowApplyRule("run-as-value", "validateWorkflowIdentityConfig", '`runAs` must be "caller" or "system" when present — the runner has no ' +
|
|
4336
|
+
"third identity."),
|
|
4337
|
+
workflowApplyRule("capabilities-shape", "validateWorkflowIdentityConfig", "`capabilities` must be an array or set of capability strings."),
|
|
4338
|
+
workflowApplyRule("capability-grantable", "validateWorkflowIdentityConfig", "Every declared capability must be one the runner can grant; the " +
|
|
4339
|
+
"always-on data baseline is never declared."),
|
|
4340
|
+
workflowApplyRule("iterate-users-system-only", "validateWorkflowIdentityConfig", 'An `iterate-users` step may appear only in a `runAs = "system"` ' +
|
|
4341
|
+
"workflow — fanning out over every user is not a member-as-themselves " +
|
|
4342
|
+
"operation."),
|
|
4343
|
+
workflowApplyRule("access-rule-type", "validateWorkflowIdentityConfig", "On create, a supplied `accessRule` must be a string — a non-string is " +
|
|
4344
|
+
"malformed input, reported as such rather than as an omission."),
|
|
4345
|
+
workflowApplyRule("access-rule-required", "validateWorkflowIdentityConfig", "On create, a caller-mode workflow must state its `accessRule` (`\"true\"` " +
|
|
4346
|
+
"for any app member), so a locked workflow is a decision rather than a " +
|
|
4347
|
+
"default."),
|
|
4348
|
+
workflowApplyRule("sync-callable-step-kind", "validateSyncCallableSteps", "A `syncCallable = true` workflow may not carry a step kind that only " +
|
|
4349
|
+
"the durable runtime can execute (`delay`, `event.wait`)."),
|
|
4350
|
+
],
|
|
3527
4351
|
};
|
|
4352
|
+
const FIELD_RANGES = [
|
|
4353
|
+
{ name: "minute", min: 0, max: 59 },
|
|
4354
|
+
{ name: "hour", min: 0, max: 23 },
|
|
4355
|
+
{ name: "day-of-month", min: 1, max: 31 },
|
|
4356
|
+
{ name: "month", min: 1, max: 12 },
|
|
4357
|
+
{ name: "day-of-week", min: 0, max: 6 },
|
|
4358
|
+
];
|
|
4359
|
+
function parseField(field, min, max, fieldName) {
|
|
4360
|
+
const result = new Set();
|
|
4361
|
+
const parts = field.split(",");
|
|
4362
|
+
for (const partRaw of parts) {
|
|
4363
|
+
const part = partRaw.trim();
|
|
4364
|
+
if (!part) {
|
|
4365
|
+
throw new Error(`Invalid ${fieldName} field: empty segment`);
|
|
4366
|
+
}
|
|
4367
|
+
let step = 1;
|
|
4368
|
+
let rangePart = part;
|
|
4369
|
+
const slashIdx = part.indexOf("/");
|
|
4370
|
+
if (slashIdx !== -1) {
|
|
4371
|
+
rangePart = part.slice(0, slashIdx);
|
|
4372
|
+
const stepStr = part.slice(slashIdx + 1);
|
|
4373
|
+
const stepNum = Number(stepStr);
|
|
4374
|
+
if (!Number.isInteger(stepNum) || stepNum <= 0) {
|
|
4375
|
+
throw new Error(`Invalid ${fieldName} step: "${stepStr}"`);
|
|
4376
|
+
}
|
|
4377
|
+
step = stepNum;
|
|
4378
|
+
}
|
|
4379
|
+
let lo;
|
|
4380
|
+
let hi;
|
|
4381
|
+
if (rangePart === "*") {
|
|
4382
|
+
lo = min;
|
|
4383
|
+
hi = max;
|
|
4384
|
+
}
|
|
4385
|
+
else if (rangePart.includes("-")) {
|
|
4386
|
+
const [loStr, hiStr] = rangePart.split("-");
|
|
4387
|
+
lo = Number(loStr);
|
|
4388
|
+
hi = Number(hiStr);
|
|
4389
|
+
if (!Number.isInteger(lo) || !Number.isInteger(hi)) {
|
|
4390
|
+
throw new Error(`Invalid ${fieldName} range: "${rangePart}"`);
|
|
4391
|
+
}
|
|
4392
|
+
}
|
|
4393
|
+
else {
|
|
4394
|
+
const exact = Number(rangePart);
|
|
4395
|
+
if (!Number.isInteger(exact)) {
|
|
4396
|
+
throw new Error(`Invalid ${fieldName} value: "${rangePart}"`);
|
|
4397
|
+
}
|
|
4398
|
+
lo = exact;
|
|
4399
|
+
hi = exact;
|
|
4400
|
+
}
|
|
4401
|
+
// Normalize day-of-week 7 -> 0 (Sunday). We only do this for bare
|
|
4402
|
+
// values, not inside a range — `5-7` would otherwise become `5-0`
|
|
4403
|
+
// which then fails the `lo > hi` check with a confusing error. Users
|
|
4404
|
+
// who want Fri–Sun should write `5-0,0` or `5,6,0`; we reject 7 in
|
|
4405
|
+
// ranges outright with a clearer message.
|
|
4406
|
+
if (fieldName === "day-of-week") {
|
|
4407
|
+
const isRange = rangePart.includes("-");
|
|
4408
|
+
if (isRange && (lo === 7 || hi === 7)) {
|
|
4409
|
+
throw new Error(`Invalid day-of-week range "${rangePart}": 7 (Sunday) is only allowed as a single value, not inside a range. Use 0 for Sunday in ranges.`);
|
|
4410
|
+
}
|
|
4411
|
+
if (!isRange) {
|
|
4412
|
+
if (lo === 7)
|
|
4413
|
+
lo = 0;
|
|
4414
|
+
if (hi === 7)
|
|
4415
|
+
hi = 0;
|
|
4416
|
+
}
|
|
4417
|
+
}
|
|
4418
|
+
if (lo < min || hi > max || lo > hi) {
|
|
4419
|
+
throw new Error(`Invalid ${fieldName} range: ${lo}-${hi} (allowed ${min}-${max})`);
|
|
4420
|
+
}
|
|
4421
|
+
for (let v = lo; v <= hi; v += step) {
|
|
4422
|
+
result.add(v);
|
|
4423
|
+
}
|
|
4424
|
+
}
|
|
4425
|
+
return result;
|
|
4426
|
+
}
|
|
4427
|
+
export function parseCron(expr) {
|
|
4428
|
+
if (!expr || typeof expr !== "string") {
|
|
4429
|
+
throw new Error("Cron expression must be a non-empty string");
|
|
4430
|
+
}
|
|
4431
|
+
const fields = expr.trim().split(/\s+/);
|
|
4432
|
+
if (fields.length !== 5) {
|
|
4433
|
+
throw new Error(`Cron expression must have exactly 5 fields, got ${fields.length}: "${expr}"`);
|
|
4434
|
+
}
|
|
4435
|
+
const [minField, hourField, domField, monField, dowField] = fields;
|
|
4436
|
+
const minutes = parseField(minField, FIELD_RANGES[0].min, FIELD_RANGES[0].max, "minute");
|
|
4437
|
+
const hours = parseField(hourField, FIELD_RANGES[1].min, FIELD_RANGES[1].max, "hour");
|
|
4438
|
+
const daysOfMonth = parseField(domField, FIELD_RANGES[2].min, FIELD_RANGES[2].max, "day-of-month");
|
|
4439
|
+
const months = parseField(monField, FIELD_RANGES[3].min, FIELD_RANGES[3].max, "month");
|
|
4440
|
+
const daysOfWeek = parseField(dowField, FIELD_RANGES[4].min, FIELD_RANGES[4].max, "day-of-week");
|
|
4441
|
+
return {
|
|
4442
|
+
minutes,
|
|
4443
|
+
hours,
|
|
4444
|
+
daysOfMonth,
|
|
4445
|
+
months,
|
|
4446
|
+
daysOfWeek,
|
|
4447
|
+
dayOfMonthWild: domField === "*",
|
|
4448
|
+
dayOfWeekWild: dowField === "*",
|
|
4449
|
+
};
|
|
4450
|
+
}
|
|
4451
|
+
// ─── The two predicates both runners call ───────────────────────────────
|
|
4452
|
+
/**
|
|
4453
|
+
* The message `parseCron` would throw for `expr`, or null when it parses.
|
|
4454
|
+
*
|
|
4455
|
+
* The same grammar, asked as a question instead of as an exception, because
|
|
4456
|
+
* both callers want the sentence rather than the control flow: the CLI's push
|
|
4457
|
+
* preflight collects one message per broken entry, and the standalone cron
|
|
4458
|
+
* routes render theirs into a 400 body. `expr` is typed `unknown` because it
|
|
4459
|
+
* arrives as an authored TOML value, where it can be anything at all.
|
|
4460
|
+
*/
|
|
4461
|
+
export function cronExpressionError(expr) {
|
|
4462
|
+
try {
|
|
4463
|
+
parseCron(expr);
|
|
4464
|
+
return null;
|
|
4465
|
+
}
|
|
4466
|
+
catch (err) {
|
|
4467
|
+
return err?.message || String(err);
|
|
4468
|
+
}
|
|
4469
|
+
}
|
|
4470
|
+
/**
|
|
4471
|
+
* Whether `Intl.DateTimeFormat` accepts `name` as a timezone.
|
|
4472
|
+
*
|
|
4473
|
+
* This is THE timezone check — before #3376 the identical probe was written
|
|
4474
|
+
* out three times (the function push boundary and the standalone create and
|
|
4475
|
+
* update). Each of those sites keeps its own sentence and its own defaulting;
|
|
4476
|
+
* what they now share is this answer. A non-string is false rather than a
|
|
4477
|
+
* throw, for the same reason as above.
|
|
4478
|
+
*/
|
|
4479
|
+
export function isValidTimezone(name) {
|
|
4480
|
+
try {
|
|
4481
|
+
new Intl.DateTimeFormat("en-US", { timeZone: name });
|
|
4482
|
+
return true;
|
|
4483
|
+
}
|
|
4484
|
+
catch {
|
|
4485
|
+
return false;
|
|
4486
|
+
}
|
|
4487
|
+
}
|
|
4488
|
+
// ── src/config-surface/cron-rules.ts ─────────────────────────────────────
|
|
4489
|
+
/**
|
|
4490
|
+
* The cron schedule rules two runners share (#3376, project
|
|
4491
|
+
* `cli-server-rule-parity` phase 4; intent §Success criteria 6).
|
|
4492
|
+
*
|
|
4493
|
+
* ── One implementation, two runners ──────────────────────────────────────
|
|
4494
|
+
*
|
|
4495
|
+
* A cron expression the grammar refuses, and a timezone `Intl` does not know,
|
|
4496
|
+
* are both decidable from the authored file. Before this issue only the SERVER
|
|
4497
|
+
* decided them, inside the apply loop: a `functions/<key>.toml` carrying
|
|
4498
|
+
* either passed `config push`'s preflight, sibling entities were written, and
|
|
4499
|
+
* the function alone was refused — while the command promises that a refusal
|
|
4500
|
+
* means nothing was applied. Now `cli/src/lib/function-sync.ts` and
|
|
4501
|
+
* `src/services/function-trigger-validation.ts` both call the rule below, and
|
|
4502
|
+
* the sentence an author reads is the same on either side of the wire because
|
|
4503
|
+
* it is built in one place.
|
|
4504
|
+
*
|
|
4505
|
+
* ── Why this is a `*-rules.ts` module and the parser is not ──────────────
|
|
4506
|
+
*
|
|
4507
|
+
* The registry's unclaimed-export check (`findUnclaimedRuleModuleExports`)
|
|
4508
|
+
* keys on this name under `src/config-surface/`: every exported function here
|
|
4509
|
+
* is a rule, so one no surface declares fails `cli-unit-tests`. The anchor
|
|
4510
|
+
* also forbids a `throw` outside the one refusal constructor — and `parseCron`
|
|
4511
|
+
* throws eight different sentences, which `tests/unit/cron-parser.test.ts`
|
|
4512
|
+
* pins. So the grammar and the `Intl` probe live next door in
|
|
4513
|
+
* `cron-expression.ts`, which is a shared-predicate module rather than a rules
|
|
4514
|
+
* module, and this one only decides and renders.
|
|
4515
|
+
*
|
|
4516
|
+
* ── What is NOT shared ───────────────────────────────────────────────────
|
|
4517
|
+
*
|
|
4518
|
+
* The standalone cron routes ask the same two predicates and render their own
|
|
4519
|
+
* `Invalid cron expression: …` / `Invalid timezone: …`, with their own
|
|
4520
|
+
* defaulting (create reads an absent timezone as UTC, update does not). That
|
|
4521
|
+
* is intent assumption 2 in the form its own fallback describes, and the shape
|
|
4522
|
+
* `workflow-rules.ts` set in phase 2: one predicate, each site's message and
|
|
4523
|
+
* defaulting preserved. No refusal message changed anywhere.
|
|
4524
|
+
*/
|
|
4525
|
+
/** The rule ids this module owns — the union type below anchors them (#3373). */
|
|
4526
|
+
export const CRON_TRIGGER_RULE_IDS = [
|
|
4527
|
+
"cron-trigger-expression",
|
|
4528
|
+
"cron-trigger-timezone",
|
|
4529
|
+
];
|
|
4530
|
+
/** The one refusal constructor: a new branch cannot compile without an id. */
|
|
4531
|
+
function refuseCronTriggerRule(ruleId, message) {
|
|
4532
|
+
return { ruleId, message };
|
|
4533
|
+
}
|
|
4534
|
+
/**
|
|
4535
|
+
* One `[[function.triggers.cron]]` entry's schedule, or null.
|
|
4536
|
+
*
|
|
4537
|
+
* Expression first, then timezone, which is the order the push boundary ran
|
|
4538
|
+
* them in — so an entry broken both ways still answers with the expression's
|
|
4539
|
+
* refusal, exactly as before. An unset or empty `timezone` reads as UTC, again
|
|
4540
|
+
* exactly as before: the boundary's `entry.timezone || "UTC"`.
|
|
4541
|
+
*/
|
|
4542
|
+
export function checkCronTriggerEntry(entry) {
|
|
4543
|
+
const cronError = cronExpressionError(entry.cron);
|
|
4544
|
+
if (cronError !== null) {
|
|
4545
|
+
return refuseCronTriggerRule("cron-trigger-expression", `cron trigger '${entry.name}': invalid cron expression — ${cronError}`);
|
|
4546
|
+
}
|
|
4547
|
+
const timezone = entry.timezone || "UTC";
|
|
4548
|
+
if (!isValidTimezone(timezone)) {
|
|
4549
|
+
return refuseCronTriggerRule("cron-trigger-timezone", `cron trigger '${entry.name}': invalid timezone '${timezone}'`);
|
|
4550
|
+
}
|
|
4551
|
+
return null;
|
|
4552
|
+
}
|
|
4553
|
+
// ── src/config-surface/prompt-reasoning.ts ───────────────────────────────
|
|
4554
|
+
/**
|
|
4555
|
+
* The prompt config's reasoning controls, resolved onto each provider's own
|
|
4556
|
+
* spelling — issue #3358.
|
|
4557
|
+
*
|
|
4558
|
+
* A `[[configs]]` entry states EITHER `reasoningEffort` (a provider-neutral
|
|
4559
|
+
* level, `none` through `high`) or `reasoningBudget` (a token count), never
|
|
4560
|
+
* both. This module is the whole translation: every authored pair either maps
|
|
4561
|
+
* onto the provider's native key or is REFUSED with a message naming the
|
|
4562
|
+
* remedy. Nothing is dropped — that was the trap `providerConfig` already laid
|
|
4563
|
+
* in the tree (stored, serialized, round-tripped, and never read at execution),
|
|
4564
|
+
* and the issue's third success criterion is precisely that a config asking for
|
|
4565
|
+
* something a provider cannot express fails rather than silently doing nothing.
|
|
4566
|
+
*
|
|
4567
|
+
* Pure and dependency-free, like every module under `src/config-surface/`: the
|
|
4568
|
+
* server's create/update handlers, the executor, and the vendored CLI artifact
|
|
4569
|
+
* all read this one copy, so a refusal at push and a refusal at execution
|
|
4570
|
+
* cannot disagree.
|
|
4571
|
+
*
|
|
4572
|
+
* Provider facts this encodes:
|
|
4573
|
+
*
|
|
4574
|
+
* - OpenRouter takes a unified `reasoning` block: `{ effort }`,
|
|
4575
|
+
* `{ max_tokens }`, or `{ enabled: false }` to decline it. `max_tokens` is an
|
|
4576
|
+
* exact bound on budget-native models and is translated to the nearest
|
|
4577
|
+
* effort level on effort-only ones, so it is documented as a bound, not a
|
|
4578
|
+
* guarantee — `metrics.reasoningTokens` is the measure of what was spent.
|
|
4579
|
+
* Default routing lets an endpoint IGNORE a parameter it does not support,
|
|
4580
|
+
* so a request carrying `reasoning` also carries
|
|
4581
|
+
* `provider: { require_parameters: true }`: an endpoint that cannot honor it
|
|
4582
|
+
* is refused by OpenRouter and surfaces as a failed execution rather than a
|
|
4583
|
+
* silent drop.
|
|
4584
|
+
* - Gemini 3.x expresses thinking as a LEVEL
|
|
4585
|
+
* (`thinkingConfig.thinkingLevel`) and cannot turn it off; `thinkingBudget`
|
|
4586
|
+
* is accepted there only for backward compatibility and is translated to a
|
|
4587
|
+
* level rather than honored as a bound, so a numeric budget is refused with
|
|
4588
|
+
* a pointer at `reasoningEffort`.
|
|
4589
|
+
* - Gemini 2.5 expresses it as a numeric BUDGET
|
|
4590
|
+
* (`thinkingConfig.thinkingBudget`); `0` turns thinking off on Flash and
|
|
4591
|
+
* Flash-Lite, while Pro has a floor of 128 and answers 400 for less.
|
|
4592
|
+
* - Gemini 2.0 and earlier have no thinking control at all.
|
|
4593
|
+
*/
|
|
4594
|
+
/** The provider-neutral effort levels a config may state. */
|
|
4595
|
+
export const REASONING_EFFORTS = [
|
|
4596
|
+
"none",
|
|
4597
|
+
"minimal",
|
|
4598
|
+
"low",
|
|
4599
|
+
"medium",
|
|
4600
|
+
"high",
|
|
4601
|
+
];
|
|
4602
|
+
/** The smallest budget Gemini 2.5 Pro accepts; it cannot stop thinking. */
|
|
4603
|
+
export const GEMINI_PRO_MIN_THINKING_BUDGET = 128;
|
|
4604
|
+
export function geminiReasoningFamily(model) {
|
|
4605
|
+
const match = /gemini-(\d+)(?:[.-](\d+))?/i.exec(model || "");
|
|
4606
|
+
if (!match)
|
|
4607
|
+
return "none";
|
|
4608
|
+
const major = Number(match[1]);
|
|
4609
|
+
const minor = match[2] === undefined ? 0 : Number(match[2]);
|
|
4610
|
+
if (!Number.isFinite(major))
|
|
4611
|
+
return "none";
|
|
4612
|
+
if (major >= 3)
|
|
4613
|
+
return "level";
|
|
4614
|
+
if (major === 2 && minor >= 5)
|
|
4615
|
+
return "budget";
|
|
4616
|
+
return "none";
|
|
4617
|
+
}
|
|
4618
|
+
/** Gemini Pro: the tier that cannot stop thinking in either family. */
|
|
4619
|
+
export function isGeminiProModel(model) {
|
|
4620
|
+
return /(^|[-_/.])pro($|[-_/.])/i.test(model || "");
|
|
4621
|
+
}
|
|
4622
|
+
/** Is EITHER reasoning key stated on this config? */
|
|
4623
|
+
export function hasPromptReasoning(input) {
|
|
4624
|
+
return (normalizeEffort(input.reasoningEffort) !== null ||
|
|
4625
|
+
normalizeBudget(input.reasoningBudget) !== null);
|
|
4626
|
+
}
|
|
4627
|
+
function normalizeEffort(value) {
|
|
4628
|
+
if (value === undefined || value === null)
|
|
4629
|
+
return null;
|
|
4630
|
+
const text = String(value).trim();
|
|
4631
|
+
return text === "" ? null : text.toLowerCase();
|
|
4632
|
+
}
|
|
4633
|
+
function normalizeBudget(value) {
|
|
4634
|
+
if (value === undefined || value === null)
|
|
4635
|
+
return null;
|
|
4636
|
+
if (typeof value === "string" && value.trim() === "")
|
|
4637
|
+
return null;
|
|
4638
|
+
const parsed = typeof value === "number" ? value : Number(value);
|
|
4639
|
+
return Number.isNaN(parsed) ? Number.NaN : parsed;
|
|
4640
|
+
}
|
|
4641
|
+
/**
|
|
4642
|
+
* Resolve an authored pair onto the provider's spelling.
|
|
4643
|
+
*
|
|
4644
|
+
* Returns `{ ok: true, directive: null }` when neither key is set — the case
|
|
4645
|
+
* that must leave the upstream payload byte-identical to what it was before
|
|
4646
|
+
* this issue — a directive when the pair maps, and `{ ok: false, error }` when
|
|
4647
|
+
* it does not. The error text is what `config push` reports as the file's
|
|
4648
|
+
* error and what the admin routes answer 400 with.
|
|
4649
|
+
*/
|
|
4650
|
+
export function resolvePromptReasoning(input) {
|
|
4651
|
+
const effort = normalizeEffort(input.reasoningEffort);
|
|
4652
|
+
const budget = normalizeBudget(input.reasoningBudget);
|
|
4653
|
+
if (effort === null && budget === null) {
|
|
4654
|
+
return { ok: true, directive: null };
|
|
4655
|
+
}
|
|
4656
|
+
if (effort !== null && budget !== null && !Number.isNaN(budget)) {
|
|
4657
|
+
return {
|
|
4658
|
+
ok: false,
|
|
4659
|
+
error: "reasoningEffort and reasoningBudget cannot both be set on one prompt " +
|
|
4660
|
+
"config: state an effort level or a numeric token budget, not both. " +
|
|
4661
|
+
"Remove one of the two keys.",
|
|
4662
|
+
};
|
|
4663
|
+
}
|
|
4664
|
+
if (effort !== null && !REASONING_EFFORTS.includes(effort)) {
|
|
4665
|
+
return {
|
|
4666
|
+
ok: false,
|
|
4667
|
+
error: `reasoningEffort must be one of ${REASONING_EFFORTS.join(", ")}; ` +
|
|
4668
|
+
`got '${input.reasoningEffort}'`,
|
|
4669
|
+
};
|
|
4670
|
+
}
|
|
4671
|
+
if (budget !== null && (!Number.isInteger(budget) || budget < 0)) {
|
|
4672
|
+
return {
|
|
4673
|
+
ok: false,
|
|
4674
|
+
error: "reasoningBudget must be a non-negative whole number of reasoning " +
|
|
4675
|
+
`tokens; got '${input.reasoningBudget}'`,
|
|
4676
|
+
};
|
|
4677
|
+
}
|
|
4678
|
+
const provider = String(input.provider || "").trim().toLowerCase();
|
|
4679
|
+
const model = String(input.model || "").trim();
|
|
4680
|
+
if (provider === "openrouter") {
|
|
4681
|
+
return { ok: true, directive: openRouterDirective(effort, budget) };
|
|
4682
|
+
}
|
|
4683
|
+
if (provider === "gemini") {
|
|
4684
|
+
return geminiResolution(model, effort, budget);
|
|
4685
|
+
}
|
|
4686
|
+
return {
|
|
4687
|
+
ok: false,
|
|
4688
|
+
error: `provider '${input.provider ?? ""}' has no reasoning control; ` +
|
|
4689
|
+
"reasoningEffort and reasoningBudget are supported on 'openrouter' and " +
|
|
4690
|
+
"'gemini'",
|
|
4691
|
+
};
|
|
4692
|
+
}
|
|
4693
|
+
function openRouterDirective(effort, budget) {
|
|
4694
|
+
const reasoning = effort === "none"
|
|
4695
|
+
? // `enabled: false` rather than `effort: "none"`: the older switch every
|
|
4696
|
+
// model path handles, where the newer spelling is not universal.
|
|
4697
|
+
{ enabled: false }
|
|
4698
|
+
: effort !== null
|
|
4699
|
+
? { effort }
|
|
4700
|
+
: { max_tokens: budget };
|
|
4701
|
+
return { provider: "openrouter", reasoning, requireParameters: true };
|
|
4702
|
+
}
|
|
4703
|
+
function geminiResolution(model, effort, budget) {
|
|
4704
|
+
const family = geminiReasoningFamily(model);
|
|
4705
|
+
const isPro = isGeminiProModel(model);
|
|
4706
|
+
if (family === "none") {
|
|
4707
|
+
return {
|
|
4708
|
+
ok: false,
|
|
4709
|
+
error: `Gemini model '${model}' has no reasoning control: reasoningEffort and ` +
|
|
4710
|
+
"reasoningBudget need Gemini 2.5 (a token budget) or Gemini 3 (an " +
|
|
4711
|
+
"effort level). Remove the key or move the config to a model that has one.",
|
|
4712
|
+
};
|
|
4713
|
+
}
|
|
4714
|
+
if (family === "level") {
|
|
4715
|
+
if (budget !== null) {
|
|
4716
|
+
return {
|
|
4717
|
+
ok: false,
|
|
4718
|
+
error: `Gemini model '${model}' expresses reasoning as a LEVEL, not a token ` +
|
|
4719
|
+
"budget — it translates thinkingBudget to a level rather than honoring " +
|
|
4720
|
+
"it as a bound. Use reasoningEffort " +
|
|
4721
|
+
`(${REASONING_EFFORTS.filter((e) => e !== "none").join(", ")}) instead ` +
|
|
4722
|
+
"of reasoningBudget.",
|
|
4723
|
+
};
|
|
4724
|
+
}
|
|
4725
|
+
if (effort === "none") {
|
|
4726
|
+
return {
|
|
4727
|
+
ok: false,
|
|
4728
|
+
error: `Gemini model '${model}' cannot turn thinking off. The lowest level it ` +
|
|
4729
|
+
`accepts is '${isPro ? "low" : "minimal"}'.`,
|
|
4730
|
+
};
|
|
4731
|
+
}
|
|
4732
|
+
if (isPro && (effort === "minimal" || effort === "medium")) {
|
|
4733
|
+
return {
|
|
4734
|
+
ok: false,
|
|
4735
|
+
error: `Gemini Pro model '${model}' accepts only 'low' or 'high' as a ` +
|
|
4736
|
+
`thinking level; '${effort}' is a Flash-only level.`,
|
|
4737
|
+
};
|
|
4738
|
+
}
|
|
4739
|
+
return {
|
|
4740
|
+
ok: true,
|
|
4741
|
+
directive: {
|
|
4742
|
+
provider: "gemini",
|
|
4743
|
+
thinkingConfig: { thinkingLevel: String(effort).toUpperCase() },
|
|
4744
|
+
},
|
|
4745
|
+
};
|
|
4746
|
+
}
|
|
4747
|
+
// family === "budget" — Gemini 2.5.
|
|
4748
|
+
if (effort !== null && effort !== "none") {
|
|
4749
|
+
return {
|
|
4750
|
+
ok: false,
|
|
4751
|
+
error: `Gemini model '${model}' expresses reasoning as a token BUDGET, not an ` +
|
|
4752
|
+
`effort level. Use reasoningBudget instead of reasoningEffort = '${effort}' ` +
|
|
4753
|
+
"(reasoningEffort = \"none\" is accepted and means a budget of 0).",
|
|
4754
|
+
};
|
|
4755
|
+
}
|
|
4756
|
+
const resolved = effort === "none" ? 0 : budget;
|
|
4757
|
+
if (isPro && resolved < GEMINI_PRO_MIN_THINKING_BUDGET) {
|
|
4758
|
+
return {
|
|
4759
|
+
ok: false,
|
|
4760
|
+
error: `Gemini Pro model '${model}' cannot turn thinking off: its smallest ` +
|
|
4761
|
+
`thinking budget is ${GEMINI_PRO_MIN_THINKING_BUDGET} tokens, and ` +
|
|
4762
|
+
`${resolved} is refused by the provider.`,
|
|
4763
|
+
};
|
|
4764
|
+
}
|
|
4765
|
+
return {
|
|
4766
|
+
ok: true,
|
|
4767
|
+
directive: {
|
|
4768
|
+
provider: "gemini",
|
|
4769
|
+
thinkingConfig: { thinkingBudget: resolved },
|
|
4770
|
+
},
|
|
4771
|
+
};
|
|
4772
|
+
}
|
|
3528
4773
|
// ── src/config-surface/prompt.ts ─────────────────────────────────────────
|
|
3529
4774
|
/**
|
|
3530
4775
|
* The `prompt` configuration object's definition (issue #2644, phase 2).
|
|
@@ -3572,7 +4817,7 @@ export const PROMPT_SURFACE = {
|
|
|
3572
4817
|
type: "json",
|
|
3573
4818
|
emit: "whenSet",
|
|
3574
4819
|
writableOn: BOTH,
|
|
3575
|
-
validation: handledBy(PROMPT_HANDLER, "AdminAPI"),
|
|
4820
|
+
validation: handledBy(PROMPT_HANDLER, "AdminAPI", "apply"),
|
|
3576
4821
|
},
|
|
3577
4822
|
// #2644 — accepted by both handlers and returned by the detail, but
|
|
3578
4823
|
// never written to TOML before this: a prompt's declared output shape
|
|
@@ -3583,7 +4828,7 @@ export const PROMPT_SURFACE = {
|
|
|
3583
4828
|
type: "json",
|
|
3584
4829
|
emit: "whenSet",
|
|
3585
4830
|
writableOn: BOTH,
|
|
3586
|
-
validation: handledBy(PROMPT_HANDLER, "AdminAPI"),
|
|
4831
|
+
validation: handledBy(PROMPT_HANDLER, "AdminAPI", "apply"),
|
|
3587
4832
|
},
|
|
3588
4833
|
// #2652 — the CEL rule gating who may execute the prompt. Required at
|
|
3589
4834
|
// create; an explicit null/"" on update clears it (fail-closed). This
|
|
@@ -3596,7 +4841,7 @@ export const PROMPT_SURFACE = {
|
|
|
3596
4841
|
type: "string",
|
|
3597
4842
|
emit: "whenSet",
|
|
3598
4843
|
writableOn: BOTH,
|
|
3599
|
-
validation: handledBy(PROMPT_HANDLER, "AdminAPI"),
|
|
4844
|
+
validation: handledBy(PROMPT_HANDLER, "AdminAPI", "apply"),
|
|
3600
4845
|
},
|
|
3601
4846
|
],
|
|
3602
4847
|
notExposed: {
|
|
@@ -3669,8 +4914,13 @@ export const PROMPT_SURFACE = {
|
|
|
3669
4914
|
tomlOnlyKeys: {
|
|
3670
4915
|
key: {
|
|
3671
4916
|
kind: "structural",
|
|
3672
|
-
note: "The prompt key. `config pull` writes it
|
|
3673
|
-
"
|
|
4917
|
+
note: "The prompt key. `config pull` writes it, and push resolves the " +
|
|
4918
|
+
"identity as the declared `[prompt] key` when there is one and the " +
|
|
4919
|
+
"file name otherwise (`tomlData.prompt?.key || basename(file)`), " +
|
|
4920
|
+
"then attaches `promptKey` itself. In a pulled tree the two always " +
|
|
4921
|
+
"agree; a hand-written file whose key differs from its name is " +
|
|
4922
|
+
"deployed — and typed by `functions codegen` — under the DECLARED " +
|
|
4923
|
+
"key (#3330).",
|
|
3674
4924
|
},
|
|
3675
4925
|
},
|
|
3676
4926
|
responseOnlyKeys: {
|
|
@@ -3693,7 +4943,7 @@ export const PROMPT_SURFACE = {
|
|
|
3693
4943
|
emit: "always",
|
|
3694
4944
|
writableOn: BOTH,
|
|
3695
4945
|
// Renamable on update, uniqueness-checked within the prompt.
|
|
3696
|
-
validation: handledBy(PROMPT_HANDLER, "AdminAPI"),
|
|
4946
|
+
validation: handledBy(PROMPT_HANDLER, "AdminAPI", "apply"),
|
|
3697
4947
|
},
|
|
3698
4948
|
// #2645 — a config's status is configuration: `prompts configs update
|
|
3699
4949
|
// --status archived` used to be the only way to retire one, and pull
|
|
@@ -3716,7 +4966,7 @@ export const PROMPT_SURFACE = {
|
|
|
3716
4966
|
type: "string",
|
|
3717
4967
|
emit: "whenSet",
|
|
3718
4968
|
writableOn: UPDATE_ONLY,
|
|
3719
|
-
validation: handledBy(PROMPT_HANDLER, "AdminAPI"),
|
|
4969
|
+
validation: handledBy(PROMPT_HANDLER, "AdminAPI", "apply"),
|
|
3720
4970
|
serverDefault: "active",
|
|
3721
4971
|
},
|
|
3722
4972
|
{
|
|
@@ -3734,7 +4984,7 @@ export const PROMPT_SURFACE = {
|
|
|
3734
4984
|
emit: "always",
|
|
3735
4985
|
writableOn: BOTH,
|
|
3736
4986
|
// openrouter | gemini, lower-cased and cross-checked against the model.
|
|
3737
|
-
validation: handledBy(PROMPT_HANDLER, "AdminAPI"),
|
|
4987
|
+
validation: handledBy(PROMPT_HANDLER, "AdminAPI", "apply"),
|
|
3738
4988
|
},
|
|
3739
4989
|
{
|
|
3740
4990
|
field: "model",
|
|
@@ -3742,7 +4992,7 @@ export const PROMPT_SURFACE = {
|
|
|
3742
4992
|
type: "string",
|
|
3743
4993
|
emit: "always",
|
|
3744
4994
|
writableOn: BOTH,
|
|
3745
|
-
validation: handledBy(PROMPT_HANDLER, "AdminAPI"),
|
|
4995
|
+
validation: handledBy(PROMPT_HANDLER, "AdminAPI", "apply"),
|
|
3746
4996
|
},
|
|
3747
4997
|
{
|
|
3748
4998
|
field: "systemPrompt",
|
|
@@ -3752,7 +5002,7 @@ export const PROMPT_SURFACE = {
|
|
|
3752
5002
|
writableOn: BOTH,
|
|
3753
5003
|
// Offloaded to R2 above the inline size limit and read back
|
|
3754
5004
|
// transparently, so the TOML value is the whole prompt either way.
|
|
3755
|
-
validation: handledBy("src/services/large-field-storage.ts", "storeField"),
|
|
5005
|
+
validation: handledBy("src/services/large-field-storage.ts", "storeField", "apply"),
|
|
3756
5006
|
},
|
|
3757
5007
|
{
|
|
3758
5008
|
field: "userPromptTemplate",
|
|
@@ -3760,7 +5010,7 @@ export const PROMPT_SURFACE = {
|
|
|
3760
5010
|
type: "string",
|
|
3761
5011
|
emit: "whenSet",
|
|
3762
5012
|
writableOn: BOTH,
|
|
3763
|
-
validation: handledBy("src/services/large-field-storage.ts", "storeField"),
|
|
5013
|
+
validation: handledBy("src/services/large-field-storage.ts", "storeField", "apply"),
|
|
3764
5014
|
},
|
|
3765
5015
|
{
|
|
3766
5016
|
field: "temperature",
|
|
@@ -3769,7 +5019,7 @@ export const PROMPT_SURFACE = {
|
|
|
3769
5019
|
emit: "whenSet",
|
|
3770
5020
|
writableOn: BOTH,
|
|
3771
5021
|
// Stored as a string on the model, returned parsed as a number.
|
|
3772
|
-
validation: handledBy(PROMPT_HANDLER, "AdminAPI"),
|
|
5022
|
+
validation: handledBy(PROMPT_HANDLER, "AdminAPI", "apply"),
|
|
3773
5023
|
dualEncoded: {
|
|
3774
5024
|
note: "#2869 — a `StringField` on the model: the server stores what it " +
|
|
3775
5025
|
"is given and returns `parseFloat` of it, so `temperature = " +
|
|
@@ -3785,7 +5035,7 @@ export const PROMPT_SURFACE = {
|
|
|
3785
5035
|
type: "number",
|
|
3786
5036
|
emit: "whenSet",
|
|
3787
5037
|
writableOn: BOTH,
|
|
3788
|
-
validation: handledBy(PROMPT_HANDLER, "AdminAPI"),
|
|
5038
|
+
validation: handledBy(PROMPT_HANDLER, "AdminAPI", "apply"),
|
|
3789
5039
|
dualEncoded: {
|
|
3790
5040
|
note: "#2869 — a `StringField` returned parsed, exactly as `temperature`.",
|
|
3791
5041
|
},
|
|
@@ -3814,7 +5064,7 @@ export const PROMPT_SURFACE = {
|
|
|
3814
5064
|
type: "json",
|
|
3815
5065
|
emit: "whenSet",
|
|
3816
5066
|
writableOn: BOTH,
|
|
3817
|
-
validation: handledBy(PROMPT_HANDLER, "AdminAPI"),
|
|
5067
|
+
validation: handledBy(PROMPT_HANDLER, "AdminAPI", "apply"),
|
|
3818
5068
|
},
|
|
3819
5069
|
{
|
|
3820
5070
|
field: "providerConfig",
|
|
@@ -3822,7 +5072,35 @@ export const PROMPT_SURFACE = {
|
|
|
3822
5072
|
type: "json",
|
|
3823
5073
|
emit: "whenSet",
|
|
3824
5074
|
writableOn: BOTH,
|
|
3825
|
-
validation: handledBy(PROMPT_HANDLER, "AdminAPI"),
|
|
5075
|
+
validation: handledBy(PROMPT_HANDLER, "AdminAPI", "apply"),
|
|
5076
|
+
},
|
|
5077
|
+
// #3358 — the provider's reasoning budget, in a provider-neutral
|
|
5078
|
+
// spelling. Before this the only lever was the model itself: an app
|
|
5079
|
+
// running a classification prompt on a reasoning-by-default model paid
|
|
5080
|
+
// 49–82% of its decode for deliberation it did not want, in latency as
|
|
5081
|
+
// well as money, and no key in this table could decline it.
|
|
5082
|
+
//
|
|
5083
|
+
// The two are MUTUALLY EXCLUSIVE — a config states an effort level or a
|
|
5084
|
+
// numeric token budget, never both — and every authored value either
|
|
5085
|
+
// maps onto the provider's own spelling or is refused at push. That
|
|
5086
|
+
// mapping is `resolvePromptReasoning`, named here so the drift guard
|
|
5087
|
+
// checks the export and the parity project (#3374/#3375) can declare it
|
|
5088
|
+
// as a preflight rule without moving it.
|
|
5089
|
+
{
|
|
5090
|
+
field: "reasoningEffort",
|
|
5091
|
+
tomlKey: "reasoningEffort",
|
|
5092
|
+
type: "string",
|
|
5093
|
+
emit: "whenSet",
|
|
5094
|
+
writableOn: BOTH,
|
|
5095
|
+
validation: handledBy("src/config-surface/prompt-reasoning.ts", "resolvePromptReasoning", "apply"),
|
|
5096
|
+
},
|
|
5097
|
+
{
|
|
5098
|
+
field: "reasoningBudget",
|
|
5099
|
+
tomlKey: "reasoningBudget",
|
|
5100
|
+
type: "number",
|
|
5101
|
+
emit: "whenSet",
|
|
5102
|
+
writableOn: BOTH,
|
|
5103
|
+
validation: handledBy("src/config-surface/prompt-reasoning.ts", "resolvePromptReasoning", "apply"),
|
|
3826
5104
|
},
|
|
3827
5105
|
],
|
|
3828
5106
|
notExposed: {
|
|
@@ -3945,7 +5223,7 @@ export const INTEGRATION_SURFACE = {
|
|
|
3945
5223
|
type: "number",
|
|
3946
5224
|
emit: "always",
|
|
3947
5225
|
writableOn: BOTH,
|
|
3948
|
-
validation: handledBy(INTEGRATION_HANDLER, "AdminAPI"),
|
|
5226
|
+
validation: handledBy(INTEGRATION_HANDLER, "AdminAPI", "apply"),
|
|
3949
5227
|
// `src/admin-api.ts` createAppIntegration: a create with no
|
|
3950
5228
|
// `timeoutMs` stores 300000. Recorded so a hand-authored file that
|
|
3951
5229
|
// omits the key compares (and pushes) as the value the server holds
|
|
@@ -3961,7 +5239,7 @@ export const INTEGRATION_SURFACE = {
|
|
|
3961
5239
|
type: "number",
|
|
3962
5240
|
emit: "whenSet",
|
|
3963
5241
|
writableOn: BOTH,
|
|
3964
|
-
validation: handledBy(INTEGRATION_HANDLER, "AdminAPI"),
|
|
5242
|
+
validation: handledBy(INTEGRATION_HANDLER, "AdminAPI", "apply"),
|
|
3965
5243
|
// Same create default, same reason (#2880 DSO-002): 1 MiB.
|
|
3966
5244
|
serverDefault: 1048576,
|
|
3967
5245
|
},
|
|
@@ -3973,7 +5251,7 @@ export const INTEGRATION_SURFACE = {
|
|
|
3973
5251
|
type: "string",
|
|
3974
5252
|
emit: "whenSet",
|
|
3975
5253
|
writableOn: BOTH,
|
|
3976
|
-
validation: handledBy(INTEGRATION_HANDLER, "AdminAPI"),
|
|
5254
|
+
validation: handledBy(INTEGRATION_HANDLER, "AdminAPI", "apply"),
|
|
3977
5255
|
},
|
|
3978
5256
|
],
|
|
3979
5257
|
notExposed: {
|
|
@@ -4087,6 +5365,18 @@ const WEBHOOK_CONFIG_VALIDATION = "src/app-api/services/webhook-verification/con
|
|
|
4087
5365
|
// alone, so it moved beside the definitions where `config push`'s preflight can
|
|
4088
5366
|
// run the server's own copy of it.
|
|
4089
5367
|
const WEBHOOK_SIGNING_SECRET_RULE = "src/config-surface/webhook-signing-secret.ts";
|
|
5368
|
+
/**
|
|
5369
|
+
* Every rule of this object is `apply` (#3374): the standalone webhook's CLI
|
|
5370
|
+
* path runs field-shape checks only, and everything else needs the app's state
|
|
5371
|
+
* or the STORED row. A module-local builder, named so it cannot collide with a
|
|
5372
|
+
* sibling's once the directory is concatenated into the artifact.
|
|
5373
|
+
*/
|
|
5374
|
+
const webhookApplyRules = (modulePath, exportName, entries) => entries.map(([id, description]) => ({
|
|
5375
|
+
id,
|
|
5376
|
+
description,
|
|
5377
|
+
stage: "apply",
|
|
5378
|
+
handler: enforcedBy(modulePath, exportName),
|
|
5379
|
+
}));
|
|
4090
5380
|
export const WEBHOOK_SURFACE = {
|
|
4091
5381
|
label: "webhook",
|
|
4092
5382
|
tables: [
|
|
@@ -4126,7 +5416,7 @@ export const WEBHOOK_SURFACE = {
|
|
|
4126
5416
|
type: "string",
|
|
4127
5417
|
emit: "always",
|
|
4128
5418
|
writableOn: BOTH,
|
|
4129
|
-
validation: handledBy(WEBHOOK_HANDLER, "AdminAPI"),
|
|
5419
|
+
validation: handledBy(WEBHOOK_HANDLER, "AdminAPI", "apply"),
|
|
4130
5420
|
},
|
|
4131
5421
|
{
|
|
4132
5422
|
field: "toleranceSeconds",
|
|
@@ -4134,7 +5424,7 @@ export const WEBHOOK_SURFACE = {
|
|
|
4134
5424
|
type: "number",
|
|
4135
5425
|
emit: "always",
|
|
4136
5426
|
writableOn: BOTH,
|
|
4137
|
-
validation: handledBy(WEBHOOK_CONFIG_VALIDATION, "validateReplayProtection"),
|
|
5427
|
+
validation: handledBy(WEBHOOK_CONFIG_VALIDATION, "validateReplayProtection", "apply"),
|
|
4138
5428
|
},
|
|
4139
5429
|
{
|
|
4140
5430
|
field: "deduplicationEnabled",
|
|
@@ -4142,7 +5432,7 @@ export const WEBHOOK_SURFACE = {
|
|
|
4142
5432
|
type: "boolean",
|
|
4143
5433
|
emit: "always",
|
|
4144
5434
|
writableOn: BOTH,
|
|
4145
|
-
validation: handledBy(WEBHOOK_CONFIG_VALIDATION, "validateReplayProtection"),
|
|
5435
|
+
validation: handledBy(WEBHOOK_CONFIG_VALIDATION, "validateReplayProtection", "apply"),
|
|
4146
5436
|
},
|
|
4147
5437
|
{
|
|
4148
5438
|
field: "deduplicationWindowMs",
|
|
@@ -4150,7 +5440,7 @@ export const WEBHOOK_SURFACE = {
|
|
|
4150
5440
|
type: "number",
|
|
4151
5441
|
emit: "always",
|
|
4152
5442
|
writableOn: BOTH,
|
|
4153
|
-
validation: handledBy(WEBHOOK_CONFIG_VALIDATION, "validateReplayProtection"),
|
|
5443
|
+
validation: handledBy(WEBHOOK_CONFIG_VALIDATION, "validateReplayProtection", "apply"),
|
|
4154
5444
|
},
|
|
4155
5445
|
// Per-webhook inbound body cap. Unset means the platform default
|
|
4156
5446
|
// (5 MiB), so it is omitted from TOML rather than written as a zero.
|
|
@@ -4160,7 +5450,7 @@ export const WEBHOOK_SURFACE = {
|
|
|
4160
5450
|
type: "number",
|
|
4161
5451
|
emit: "whenSet",
|
|
4162
5452
|
writableOn: BOTH,
|
|
4163
|
-
validation: handledBy(WEBHOOK_CONFIG_VALIDATION, "validateMaxBodyBytes"),
|
|
5453
|
+
validation: handledBy(WEBHOOK_CONFIG_VALIDATION, "validateMaxBodyBytes", "apply"),
|
|
4164
5454
|
},
|
|
4165
5455
|
{
|
|
4166
5456
|
field: "secretGracePeriodMs",
|
|
@@ -4168,7 +5458,7 @@ export const WEBHOOK_SURFACE = {
|
|
|
4168
5458
|
type: "number",
|
|
4169
5459
|
emit: "always",
|
|
4170
5460
|
writableOn: BOTH,
|
|
4171
|
-
validation: handledBy(WEBHOOK_CONFIG_VALIDATION, "normalizeSecretGracePeriodMs"),
|
|
5461
|
+
validation: handledBy(WEBHOOK_CONFIG_VALIDATION, "normalizeSecretGracePeriodMs", "apply"),
|
|
4172
5462
|
},
|
|
4173
5463
|
// #2254 — reference-only: a whole `{{secrets.KEY}}` reference naming an
|
|
4174
5464
|
// existing app secret, and nothing else. The server never returns
|
|
@@ -4180,7 +5470,7 @@ export const WEBHOOK_SURFACE = {
|
|
|
4180
5470
|
type: "string",
|
|
4181
5471
|
emit: "whenSet",
|
|
4182
5472
|
writableOn: BOTH,
|
|
4183
|
-
validation: handledBy(WEBHOOK_SIGNING_SECRET_RULE, "validateWholeSecretReference"),
|
|
5473
|
+
validation: handledBy(WEBHOOK_SIGNING_SECRET_RULE, "validateWholeSecretReference", "apply"),
|
|
4184
5474
|
},
|
|
4185
5475
|
],
|
|
4186
5476
|
notExposed: {
|
|
@@ -4345,6 +5635,91 @@ export const WEBHOOK_SURFACE = {
|
|
|
4345
5635
|
"configs, which spells `[metadata]` the same way.",
|
|
4346
5636
|
},
|
|
4347
5637
|
},
|
|
5638
|
+
// ── Cross-field rules (#3374) ─────────────────────────────────────────────
|
|
5639
|
+
//
|
|
5640
|
+
// Nothing here is `preflight`. Stage is a property of the (surface, rule)
|
|
5641
|
+
// pair, and on THIS surface even the file-decidable signing-secret rule is
|
|
5642
|
+
// enforced mid-apply: the standalone webhook CLI path runs no signing-secret
|
|
5643
|
+
// preflight (`function-sync.ts` is that rule's only CLI caller), and an
|
|
5644
|
+
// update that omits `signingSecret` keeps the stored one, so a local check
|
|
5645
|
+
// would need update-retains-stored-secret semantics the intent lists as a
|
|
5646
|
+
// non-goal. Declaring `preflight` would promise a refusal that lands after
|
|
5647
|
+
// sibling entities are written.
|
|
5648
|
+
//
|
|
5649
|
+
// The residue, named: `webhook-verification/config-validation.ts` is not a
|
|
5650
|
+
// rules module (it builds forty-odd refusals, most single-field). Its
|
|
5651
|
+
// cross-field refusals — scheme × config, replay protection, config secret
|
|
5652
|
+
// references — reach a write only through `validateWebhookSettings`, which
|
|
5653
|
+
// re-builds each under its own pipeline id, so they are declared here under
|
|
5654
|
+
// those ids; the module itself is unmigrated.
|
|
5655
|
+
crossFieldRules: [
|
|
5656
|
+
...webhookApplyRules(WEBHOOK_SIGNING_SECRET_RULE, "validateSigningSecretDeclaration", [
|
|
5657
|
+
[
|
|
5658
|
+
"signing-secret-required",
|
|
5659
|
+
"A scheme that verifies with an HMAC secret must be given a " +
|
|
5660
|
+
"`signingSecret`. The same handler the function surface runs in " +
|
|
5661
|
+
"preflight; here it runs at apply because the standalone CLI path " +
|
|
5662
|
+
"never calls it and an update preserves an omitted stored secret.",
|
|
5663
|
+
],
|
|
5664
|
+
[
|
|
5665
|
+
"whole-secret-reference",
|
|
5666
|
+
"A supplied `signingSecret` is a whole `{{secrets.KEY}}` reference " +
|
|
5667
|
+
"and nothing else — a literal is never stored (#2254).",
|
|
5668
|
+
],
|
|
5669
|
+
[
|
|
5670
|
+
"signing-secret-not-supported",
|
|
5671
|
+
"A scheme whose key material lives in its config (`discord`, `jwt`, " +
|
|
5672
|
+
"`plaid`) or that verifies nothing (`none`) refuses a `signingSecret`.",
|
|
5673
|
+
],
|
|
5674
|
+
]),
|
|
5675
|
+
...webhookApplyRules("src/app-api/services/webhook-limits.ts", "validateWebhookCountUnderCap", [
|
|
5676
|
+
[
|
|
5677
|
+
"webhook-cap",
|
|
5678
|
+
"An app holds at most fifty webhook rows, archived included; a " +
|
|
5679
|
+
"create that would add one past the cap is refused with " +
|
|
5680
|
+
"`WEBHOOK_LIMIT_REACHED` (#2232). Read early by `config push` as an " +
|
|
5681
|
+
"advisory check; the server decides.",
|
|
5682
|
+
],
|
|
5683
|
+
]),
|
|
5684
|
+
...webhookApplyRules("src/app-api/services/webhook-settings-validation.ts", "validateWebhookSettings", [
|
|
5685
|
+
["verification-scheme-required", "A webhook names its `verificationScheme`."],
|
|
5686
|
+
["verification-scheme-value", "The scheme is one the receiver knows."],
|
|
5687
|
+
[
|
|
5688
|
+
"verification-config",
|
|
5689
|
+
"The scheme's `config` is valid for that scheme under this " +
|
|
5690
|
+
"environment's JWKS policy, and carries no literal credential.",
|
|
5691
|
+
],
|
|
5692
|
+
[
|
|
5693
|
+
"replay-protection",
|
|
5694
|
+
"A signed scheme keeps deduplication on, with a window and tolerance " +
|
|
5695
|
+
"in range and long enough to cover the signature's freshness window.",
|
|
5696
|
+
],
|
|
5697
|
+
[
|
|
5698
|
+
"signing-secret-exists",
|
|
5699
|
+
"The app secret a `signingSecret` reference names must exist, so a " +
|
|
5700
|
+
"missing key fails the write rather than the first delivery.",
|
|
5701
|
+
],
|
|
5702
|
+
[
|
|
5703
|
+
"verification-config-secrets-exist",
|
|
5704
|
+
"Every secret reference the active scheme's `config` resolves at " +
|
|
5705
|
+
"delivery must name an existing app secret.",
|
|
5706
|
+
],
|
|
5707
|
+
]),
|
|
5708
|
+
...webhookApplyRules("src/app-api/services/webhook-verification/signing-secret-migration.ts", "validateSigningSecretMigration", [
|
|
5709
|
+
[
|
|
5710
|
+
"signing-secret-migration",
|
|
5711
|
+
"A row whose STORED `signingSecret` is still a legacy literal cannot " +
|
|
5712
|
+
"be updated until the write migrates it to a reference (#2332); " +
|
|
5713
|
+
"gated on the scheme and status the row will end up with.",
|
|
5714
|
+
],
|
|
5715
|
+
[
|
|
5716
|
+
"signing-secret-migration-malformed",
|
|
5717
|
+
"The same gate for a stored value carrying reference syntax that does " +
|
|
5718
|
+
"not resolve — every signed delivery is already failing, and the " +
|
|
5719
|
+
"message says so.",
|
|
5720
|
+
],
|
|
5721
|
+
]),
|
|
5722
|
+
],
|
|
4348
5723
|
};
|
|
4349
5724
|
// ── src/config-surface/cron-trigger.ts ───────────────────────────────────
|
|
4350
5725
|
/**
|
|
@@ -4394,7 +5769,23 @@ export const CRON_TRIGGER_SURFACE = {
|
|
|
4394
5769
|
type: "string",
|
|
4395
5770
|
emit: "always",
|
|
4396
5771
|
writableOn: BOTH,
|
|
4397
|
-
|
|
5772
|
+
// #3376 — re-pointed from `src/cron-parser.ts`, which now only
|
|
5773
|
+
// re-exports the parser: the drift guard resolves `export function
|
|
5774
|
+
// <name>` in the named module's OWN source, so the declaring module
|
|
5775
|
+
// is the only one it can name.
|
|
5776
|
+
//
|
|
5777
|
+
// `apply`, not `preflight`, even though the predicate is vendored
|
|
5778
|
+
// and the rule is decidable from the file: no CLI check reads a
|
|
5779
|
+
// standalone `cron-triggers/<key>.toml`'s VALUES.
|
|
5780
|
+
// `collectConfigFileValidationErrors` is definition-driven and
|
|
5781
|
+
// cannot dispatch through a `handledBy` string (#1976), so this
|
|
5782
|
+
// file still fails at apply with the controller's message.
|
|
5783
|
+
// `preflight` would make the registry promise a refusal that in
|
|
5784
|
+
// fact lands mid-apply — and the reachability walker could not
|
|
5785
|
+
// catch the lie, because the FUNCTION leg reaches this same
|
|
5786
|
+
// predicate. Building that standalone leg, with its `config diff`
|
|
5787
|
+
// twin, is a finding for #3379.
|
|
5788
|
+
validation: handledBy("src/config-surface/cron-expression.ts", "cronExpressionError", "apply"),
|
|
4398
5789
|
},
|
|
4399
5790
|
{
|
|
4400
5791
|
field: "timezone",
|
|
@@ -4402,8 +5793,12 @@ export const CRON_TRIGGER_SURFACE = {
|
|
|
4402
5793
|
type: "string",
|
|
4403
5794
|
emit: "always",
|
|
4404
5795
|
writableOn: BOTH,
|
|
4405
|
-
// Validated against Intl; unset reads as "UTC".
|
|
4406
|
-
|
|
5796
|
+
// Validated against Intl; unset reads as "UTC". #3376 named the
|
|
5797
|
+
// predicate itself rather than the controller class: the probe the
|
|
5798
|
+
// three sites used to spell out is one function now, and this is
|
|
5799
|
+
// one of the two surfaces that own it. `apply` for the same reason
|
|
5800
|
+
// as `cron` above — no standalone CLI preflight runs it (#3379).
|
|
5801
|
+
validation: handledBy("src/config-surface/cron-expression.ts", "isValidTimezone", "apply"),
|
|
4407
5802
|
// `cron-triggers-controller.ts` stores `timezone || "UTC"`, so a file
|
|
4408
5803
|
// that omits the key describes a trigger running in UTC — compared
|
|
4409
5804
|
// and pushed as that, rather than as a difference no push can clear
|
|
@@ -4417,7 +5812,7 @@ export const CRON_TRIGGER_SURFACE = {
|
|
|
4417
5812
|
emit: "always",
|
|
4418
5813
|
writableOn: BOTH,
|
|
4419
5814
|
// #1002 — a reserved `__internal.` key is rejected at save time.
|
|
4420
|
-
validation: handledBy(CRON_TRIGGER_HANDLER, "CronTriggersController"),
|
|
5815
|
+
validation: handledBy(CRON_TRIGGER_HANDLER, "CronTriggersController", "apply"),
|
|
4421
5816
|
},
|
|
4422
5817
|
{
|
|
4423
5818
|
field: "overlapPolicy",
|
|
@@ -4426,7 +5821,7 @@ export const CRON_TRIGGER_SURFACE = {
|
|
|
4426
5821
|
emit: "always",
|
|
4427
5822
|
writableOn: BOTH,
|
|
4428
5823
|
// skip | allow.
|
|
4429
|
-
validation: handledBy(CRON_TRIGGER_HANDLER, "CronTriggersController"),
|
|
5824
|
+
validation: handledBy(CRON_TRIGGER_HANDLER, "CronTriggersController", "apply"),
|
|
4430
5825
|
// Same store-time default as `timezone` (#2880 DSO-002).
|
|
4431
5826
|
serverDefault: "skip",
|
|
4432
5827
|
},
|
|
@@ -4630,7 +6025,7 @@ export const BLOB_BUCKET_SURFACE = {
|
|
|
4630
6025
|
emit: "always",
|
|
4631
6026
|
writableOn: CREATE_ONLY,
|
|
4632
6027
|
// Immutable after create: `update` never reads it.
|
|
4633
|
-
validation: handledBy(BLOB_BUCKET_HANDLER, "BlobBucketsController"),
|
|
6028
|
+
validation: handledBy(BLOB_BUCKET_HANDLER, "BlobBucketsController", "apply"),
|
|
4634
6029
|
},
|
|
4635
6030
|
{
|
|
4636
6031
|
field: "preset",
|
|
@@ -4640,7 +6035,7 @@ export const BLOB_BUCKET_SURFACE = {
|
|
|
4640
6035
|
writableOn: BOTH,
|
|
4641
6036
|
// #1020 — the honest access field. A rule-set-backed bucket reports
|
|
4642
6037
|
// "custom", which is not written back as a preset.
|
|
4643
|
-
validation: handledBy("src/app-api/resource-types/blob-bucket.ts", "resolveBucketPreset"),
|
|
6038
|
+
validation: handledBy("src/app-api/resource-types/blob-bucket.ts", "resolveBucketPreset", "apply"),
|
|
4644
6039
|
},
|
|
4645
6040
|
{
|
|
4646
6041
|
field: "ruleSetId",
|
|
@@ -4650,7 +6045,7 @@ export const BLOB_BUCKET_SURFACE = {
|
|
|
4650
6045
|
writableOn: BOTH,
|
|
4651
6046
|
// Existence-checked against AccessRuleSet; mutually exclusive with a
|
|
4652
6047
|
// preset change in the same request.
|
|
4653
|
-
validation: handledBy(BLOB_BUCKET_HANDLER, "BlobBucketsController"),
|
|
6048
|
+
validation: handledBy(BLOB_BUCKET_HANDLER, "BlobBucketsController", "apply"),
|
|
4654
6049
|
},
|
|
4655
6050
|
],
|
|
4656
6051
|
notExposed: {
|
|
@@ -4778,7 +6173,7 @@ export const EMAIL_TEMPLATE_SURFACE = {
|
|
|
4778
6173
|
emit: "always",
|
|
4779
6174
|
writableOn: BOTH,
|
|
4780
6175
|
// Size-capped and checked for unescaped `{{{var}}}` interpolation.
|
|
4781
|
-
validation: handledBy(EMAIL_TEMPLATE_HANDLER, "AdminAPI"),
|
|
6176
|
+
validation: handledBy(EMAIL_TEMPLATE_HANDLER, "AdminAPI", "apply"),
|
|
4782
6177
|
},
|
|
4783
6178
|
{
|
|
4784
6179
|
field: "htmlBody",
|
|
@@ -4786,7 +6181,7 @@ export const EMAIL_TEMPLATE_SURFACE = {
|
|
|
4786
6181
|
type: "string",
|
|
4787
6182
|
emit: "whenSet",
|
|
4788
6183
|
writableOn: BOTH,
|
|
4789
|
-
validation: handledBy(EMAIL_TEMPLATE_HANDLER, "AdminAPI"),
|
|
6184
|
+
validation: handledBy(EMAIL_TEMPLATE_HANDLER, "AdminAPI", "apply"),
|
|
4790
6185
|
},
|
|
4791
6186
|
{
|
|
4792
6187
|
field: "textBody",
|
|
@@ -4794,7 +6189,7 @@ export const EMAIL_TEMPLATE_SURFACE = {
|
|
|
4794
6189
|
type: "string",
|
|
4795
6190
|
emit: "whenSet",
|
|
4796
6191
|
writableOn: BOTH,
|
|
4797
|
-
validation: handledBy(EMAIL_TEMPLATE_HANDLER, "AdminAPI"),
|
|
6192
|
+
validation: handledBy(EMAIL_TEMPLATE_HANDLER, "AdminAPI", "apply"),
|
|
4798
6193
|
},
|
|
4799
6194
|
],
|
|
4800
6195
|
notExposed: {
|
|
@@ -4924,7 +6319,7 @@ export const DATABASE_TYPE_SURFACE = {
|
|
|
4924
6319
|
writableOn: CREATE_ONLY,
|
|
4925
6320
|
// The type's identity: required and `#`-free on create, taken from
|
|
4926
6321
|
// the URL on update.
|
|
4927
|
-
validation: handledBy(DB_TYPE_HANDLER, "DatabaseTypeConfigController"),
|
|
6322
|
+
validation: handledBy(DB_TYPE_HANDLER, "DatabaseTypeConfigController", "apply"),
|
|
4928
6323
|
},
|
|
4929
6324
|
{
|
|
4930
6325
|
field: "ruleSetId",
|
|
@@ -4934,7 +6329,7 @@ export const DATABASE_TYPE_SURFACE = {
|
|
|
4934
6329
|
writableOn: BOTH,
|
|
4935
6330
|
// Authored as a portable name and sent as an id; the server checks
|
|
4936
6331
|
// the set exists and has `resourceType: "database"`.
|
|
4937
|
-
validation: handledBy(DB_TYPE_HANDLER, "DatabaseTypeConfigController"),
|
|
6332
|
+
validation: handledBy(DB_TYPE_HANDLER, "DatabaseTypeConfigController", "apply"),
|
|
4938
6333
|
},
|
|
4939
6334
|
{
|
|
4940
6335
|
field: "metadataAccess",
|
|
@@ -4944,7 +6339,7 @@ export const DATABASE_TYPE_SURFACE = {
|
|
|
4944
6339
|
writableOn: BOTH,
|
|
4945
6340
|
// CEL, parsed before storing (#1336). The legacy `metadataAccess`
|
|
4946
6341
|
// spelling stays readable on push — see tomlOnlyKeys.
|
|
4947
|
-
validation: handledBy(DB_TYPE_HANDLER, "DatabaseTypeConfigController"),
|
|
6342
|
+
validation: handledBy(DB_TYPE_HANDLER, "DatabaseTypeConfigController", "apply"),
|
|
4948
6343
|
},
|
|
4949
6344
|
{
|
|
4950
6345
|
field: "defaultAccess",
|
|
@@ -4953,7 +6348,7 @@ export const DATABASE_TYPE_SURFACE = {
|
|
|
4953
6348
|
emit: "whenSet",
|
|
4954
6349
|
writableOn: BOTH,
|
|
4955
6350
|
// CEL, plus the shared `md`-shape allowlist (#1523).
|
|
4956
|
-
validation: handledBy(DB_TYPE_HANDLER, "DatabaseTypeConfigController"),
|
|
6351
|
+
validation: handledBy(DB_TYPE_HANDLER, "DatabaseTypeConfigController", "apply"),
|
|
4957
6352
|
},
|
|
4958
6353
|
{
|
|
4959
6354
|
field: "autoPopulatedFields",
|
|
@@ -4963,7 +6358,7 @@ export const DATABASE_TYPE_SURFACE = {
|
|
|
4963
6358
|
writableOn: BOTH,
|
|
4964
6359
|
// #750 — shorthand and verbose forms both accepted, normalized by
|
|
4965
6360
|
// `validateAndNormalizeAutoPopulatedFields`.
|
|
4966
|
-
validation: handledBy(DB_TYPE_HANDLER, "DatabaseTypeConfigController"),
|
|
6361
|
+
validation: handledBy(DB_TYPE_HANDLER, "DatabaseTypeConfigController", "apply"),
|
|
4967
6362
|
},
|
|
4968
6363
|
{
|
|
4969
6364
|
field: "timestamps",
|
|
@@ -4973,7 +6368,7 @@ export const DATABASE_TYPE_SURFACE = {
|
|
|
4973
6368
|
writableOn: BOTH,
|
|
4974
6369
|
// #748 — `{ create?, update?, models? }`, normalized by
|
|
4975
6370
|
// `validateAndNormalizeTimestamps`.
|
|
4976
|
-
validation: handledBy(DB_TYPE_HANDLER, "DatabaseTypeConfigController"),
|
|
6371
|
+
validation: handledBy(DB_TYPE_HANDLER, "DatabaseTypeConfigController", "apply"),
|
|
4977
6372
|
},
|
|
4978
6373
|
],
|
|
4979
6374
|
notExposed: {
|
|
@@ -5115,7 +6510,7 @@ export const RULE_SET_SURFACE = {
|
|
|
5115
6510
|
emit: "always",
|
|
5116
6511
|
writableOn: BOTH,
|
|
5117
6512
|
// Required, trimmed, and non-empty on both modes.
|
|
5118
|
-
validation: handledBy(RULE_SET_HANDLER, "RuleSetsController"),
|
|
6513
|
+
validation: handledBy(RULE_SET_HANDLER, "RuleSetsController", "apply"),
|
|
5119
6514
|
},
|
|
5120
6515
|
{
|
|
5121
6516
|
field: "resourceType",
|
|
@@ -5125,7 +6520,7 @@ export const RULE_SET_SURFACE = {
|
|
|
5125
6520
|
writableOn: CREATE_ONLY,
|
|
5126
6521
|
// Checked against `getValidResourceTypes()`, and singleton types
|
|
5127
6522
|
// (#1148) reject a second set. `update` never reads it.
|
|
5128
|
-
validation: handledBy(RULE_SET_HANDLER, "RuleSetsController"),
|
|
6523
|
+
validation: handledBy(RULE_SET_HANDLER, "RuleSetsController", "apply"),
|
|
5129
6524
|
},
|
|
5130
6525
|
{
|
|
5131
6526
|
field: "description",
|
|
@@ -5217,7 +6612,7 @@ export const GROUP_TYPE_CONFIG_SURFACE = {
|
|
|
5217
6612
|
writableOn: CREATE_ONLY,
|
|
5218
6613
|
// The config's identity: required and `#`-free on create, taken from
|
|
5219
6614
|
// the URL on update.
|
|
5220
|
-
validation: handledBy(GROUP_TYPE_HANDLER, "GroupTypeConfigController"),
|
|
6615
|
+
validation: handledBy(GROUP_TYPE_HANDLER, "GroupTypeConfigController", "apply"),
|
|
5221
6616
|
},
|
|
5222
6617
|
{
|
|
5223
6618
|
field: "ruleSetId",
|
|
@@ -5227,7 +6622,7 @@ export const GROUP_TYPE_CONFIG_SURFACE = {
|
|
|
5227
6622
|
writableOn: BOTH,
|
|
5228
6623
|
// Authored as a name, sent as an id; existence and resourceType are
|
|
5229
6624
|
// checked server-side, and an empty value clears the reference.
|
|
5230
|
-
validation: handledBy(GROUP_TYPE_HANDLER, "GroupTypeConfigController"),
|
|
6625
|
+
validation: handledBy(GROUP_TYPE_HANDLER, "GroupTypeConfigController", "apply"),
|
|
5231
6626
|
},
|
|
5232
6627
|
{
|
|
5233
6628
|
field: "autoAddCreator",
|
|
@@ -5237,7 +6632,7 @@ export const GROUP_TYPE_CONFIG_SURFACE = {
|
|
|
5237
6632
|
writableOn: BOTH,
|
|
5238
6633
|
// Defaults to `true` when absent on create, and is coerced with `!!`
|
|
5239
6634
|
// on update — not a stored-as-is passthrough.
|
|
5240
|
-
validation: handledBy(GROUP_TYPE_HANDLER, "GroupTypeConfigController"),
|
|
6635
|
+
validation: handledBy(GROUP_TYPE_HANDLER, "GroupTypeConfigController", "apply"),
|
|
5241
6636
|
},
|
|
5242
6637
|
],
|
|
5243
6638
|
notExposed: {
|
|
@@ -5310,7 +6705,7 @@ export const COLLECTION_TYPE_CONFIG_SURFACE = {
|
|
|
5310
6705
|
writableOn: CREATE_ONLY,
|
|
5311
6706
|
// The config's identity: required on create, taken from the URL on
|
|
5312
6707
|
// update.
|
|
5313
|
-
validation: handledBy(COLLECTION_TYPE_HANDLER, "CollectionTypeConfigController"),
|
|
6708
|
+
validation: handledBy(COLLECTION_TYPE_HANDLER, "CollectionTypeConfigController", "apply"),
|
|
5314
6709
|
},
|
|
5315
6710
|
{
|
|
5316
6711
|
field: "ruleSetId",
|
|
@@ -5320,7 +6715,7 @@ export const COLLECTION_TYPE_CONFIG_SURFACE = {
|
|
|
5320
6715
|
writableOn: BOTH,
|
|
5321
6716
|
// Authored as a name, sent as an id; existence and resourceType are
|
|
5322
6717
|
// checked server-side, and an empty value clears the reference.
|
|
5323
|
-
validation: handledBy(COLLECTION_TYPE_HANDLER, "CollectionTypeConfigController"),
|
|
6718
|
+
validation: handledBy(COLLECTION_TYPE_HANDLER, "CollectionTypeConfigController", "apply"),
|
|
5324
6719
|
},
|
|
5325
6720
|
],
|
|
5326
6721
|
notExposed: {
|
|
@@ -5385,6 +6780,18 @@ export const COLLECTION_TYPE_CONFIG_SURFACE = {
|
|
|
5385
6780
|
* enum and `unique` rules — but the field itself round-trips as one value.
|
|
5386
6781
|
*/
|
|
5387
6782
|
const METADATA_HANDLER = "src/app-api/controllers/metadata-controller.ts";
|
|
6783
|
+
/**
|
|
6784
|
+
* ── Why this module's CLI handler is declared `apply` (#3375) ─────────────
|
|
6785
|
+
*
|
|
6786
|
+
* A `preflight` handler must be VENDORED — its module lives under
|
|
6787
|
+
* `src/config-surface/`, so the CLI runs the server's own copy through the
|
|
6788
|
+
* generated artifact rather than a second implementation. A handler that
|
|
6789
|
+
* lives in `cli/src/` is a CLI-only copy of a server rule, which is precisely
|
|
6790
|
+
* the drift this project exists to remove; until it is vendored it cannot make
|
|
6791
|
+
* the registry's preflight promise, whatever the push path happens to do
|
|
6792
|
+
* today. Declared `apply`, and recorded as a finding for the validation child
|
|
6793
|
+
* (#3379) rather than papered over with a third stage.
|
|
6794
|
+
*/
|
|
5388
6795
|
const CLI_METADATA_TOML = "cli/src/lib/toml-metadata-config.ts";
|
|
5389
6796
|
export const METADATA_CATEGORY_CONFIG_SURFACE = {
|
|
5390
6797
|
label: "metadata-category-config",
|
|
@@ -5403,7 +6810,7 @@ export const METADATA_CATEGORY_CONFIG_SURFACE = {
|
|
|
5403
6810
|
emit: "always",
|
|
5404
6811
|
writableOn: BOTH,
|
|
5405
6812
|
// Half of the category's identity; required on every upsert.
|
|
5406
|
-
validation: handledBy(METADATA_HANDLER, "MetadataController"),
|
|
6813
|
+
validation: handledBy(METADATA_HANDLER, "MetadataController", "apply"),
|
|
5407
6814
|
},
|
|
5408
6815
|
{
|
|
5409
6816
|
field: "category",
|
|
@@ -5412,7 +6819,7 @@ export const METADATA_CATEGORY_CONFIG_SURFACE = {
|
|
|
5412
6819
|
emit: "always",
|
|
5413
6820
|
writableOn: BOTH,
|
|
5414
6821
|
// The other half of the identity; required on every upsert.
|
|
5415
|
-
validation: handledBy(METADATA_HANDLER, "MetadataController"),
|
|
6822
|
+
validation: handledBy(METADATA_HANDLER, "MetadataController", "apply"),
|
|
5416
6823
|
},
|
|
5417
6824
|
{
|
|
5418
6825
|
field: "readRule",
|
|
@@ -5421,7 +6828,7 @@ export const METADATA_CATEGORY_CONFIG_SURFACE = {
|
|
|
5421
6828
|
emit: "whenSet",
|
|
5422
6829
|
writableOn: BOTH,
|
|
5423
6830
|
// CEL, parsed and stored or nulled.
|
|
5424
|
-
validation: handledBy(METADATA_HANDLER, "MetadataController"),
|
|
6831
|
+
validation: handledBy(METADATA_HANDLER, "MetadataController", "apply"),
|
|
5425
6832
|
},
|
|
5426
6833
|
{
|
|
5427
6834
|
field: "writeRule",
|
|
@@ -5429,7 +6836,7 @@ export const METADATA_CATEGORY_CONFIG_SURFACE = {
|
|
|
5429
6836
|
type: "string",
|
|
5430
6837
|
emit: "whenSet",
|
|
5431
6838
|
writableOn: BOTH,
|
|
5432
|
-
validation: handledBy(METADATA_HANDLER, "MetadataController"),
|
|
6839
|
+
validation: handledBy(METADATA_HANDLER, "MetadataController", "apply"),
|
|
5433
6840
|
},
|
|
5434
6841
|
{
|
|
5435
6842
|
field: "description",
|
|
@@ -5449,7 +6856,7 @@ export const METADATA_CATEGORY_CONFIG_SURFACE = {
|
|
|
5449
6856
|
// `{ fields: {} }` so the file stays pushable. Field types, enums and
|
|
5450
6857
|
// the single-`unique` rule are checked locally before push and again
|
|
5451
6858
|
// server-side.
|
|
5452
|
-
validation: handledBy(CLI_METADATA_TOML, "validateCategorySchema"),
|
|
6859
|
+
validation: handledBy(CLI_METADATA_TOML, "validateCategorySchema", "apply"),
|
|
5453
6860
|
},
|
|
5454
6861
|
],
|
|
5455
6862
|
notExposed: {
|
|
@@ -5591,6 +6998,128 @@ export const TRANSFORM_SURFACE = {
|
|
|
5591
6998
|
* table's prefix.
|
|
5592
6999
|
*/
|
|
5593
7000
|
const FUNCTION_HANDLER = "src/admin-api/controllers/server-functions-controller.ts";
|
|
7001
|
+
/**
|
|
7002
|
+
* The rule declarations (#3374). Three vendored modules decide everything the
|
|
7003
|
+
* file alone can decide, and the CLI's push preflight runs each of them
|
|
7004
|
+
* (`cli/src/lib/function-sync.ts`) before the first request — so those are
|
|
7005
|
+
* `preflight`. One server module decides everything that needs the app's
|
|
7006
|
+
* state, and it is `apply`. A rule id here is one id in its module's
|
|
7007
|
+
* `*_RULE_IDS` list; the guard holds the two sides equal.
|
|
7008
|
+
*/
|
|
7009
|
+
const functionRules = (stage, modulePath, exportName, entries) => entries.map(([id, description]) => ({
|
|
7010
|
+
id,
|
|
7011
|
+
description,
|
|
7012
|
+
stage,
|
|
7013
|
+
handler: enforcedBy(modulePath, exportName),
|
|
7014
|
+
}));
|
|
7015
|
+
/** The trigger blocks' shape rules — one id per refusal (#3181, #3184, #3334). */
|
|
7016
|
+
const TRIGGER_DECLARATION_RULES = functionRules("preflight", "src/config-surface/trigger-declaration.ts", "normalizeTriggerDeclaration", [
|
|
7017
|
+
["triggers-table", "`[function.triggers]` must be a table."],
|
|
7018
|
+
["trigger-kind", "Only `webhook` and `cron` are trigger kinds."],
|
|
7019
|
+
[
|
|
7020
|
+
"database-trigger-removed",
|
|
7021
|
+
"`[[function.triggers.database]]` is refused: the trigger was removed " +
|
|
7022
|
+
"(#3483), and the refusal names the pattern that replaces it — do the " +
|
|
7023
|
+
"follow-up in the handler that writes, or start a task.",
|
|
7024
|
+
],
|
|
7025
|
+
[
|
|
7026
|
+
"webhook-trigger-single",
|
|
7027
|
+
"`[function.triggers.webhook]` is one table, never an array: one webhook " +
|
|
7028
|
+
"trigger per function.",
|
|
7029
|
+
],
|
|
7030
|
+
["webhook-trigger-table", "`[function.triggers.webhook]` must be a table."],
|
|
7031
|
+
["webhook-trigger-keys", "A webhook block accepts only its declared keys."],
|
|
7032
|
+
["webhook-trigger-scheme-required", "A webhook block names its `verificationScheme`."],
|
|
7033
|
+
[
|
|
7034
|
+
"webhook-trigger-scheme-none",
|
|
7035
|
+
"Scheme `none` is refused on a function trigger: an unauthenticated " +
|
|
7036
|
+
"caller could run the function.",
|
|
7037
|
+
],
|
|
7038
|
+
["webhook-trigger-number", "The numeric webhook settings must be numbers."],
|
|
7039
|
+
["webhook-trigger-dedup-boolean", "`deduplicationEnabled` must be a boolean."],
|
|
7040
|
+
[
|
|
7041
|
+
"webhook-trigger-verification-table",
|
|
7042
|
+
"`[function.triggers.webhook.verification]` must be a table.",
|
|
7043
|
+
],
|
|
7044
|
+
["cron-trigger-cap", "A function declares at most ten cron entries."],
|
|
7045
|
+
["cron-trigger-entry", "Each `[[function.triggers.cron]]` entry is a table."],
|
|
7046
|
+
["cron-trigger-keys", "A cron entry accepts only its declared keys."],
|
|
7047
|
+
[
|
|
7048
|
+
"cron-trigger-name-required",
|
|
7049
|
+
"A cron entry names itself: the name is half of its trigger key.",
|
|
7050
|
+
],
|
|
7051
|
+
["cron-trigger-name-charset", "A cron name uses the standalone trigger-key charset."],
|
|
7052
|
+
["cron-trigger-duplicate", "Each cron name appears once within the function."],
|
|
7053
|
+
["cron-trigger-expression-required", "A cron entry carries a `cron` expression."],
|
|
7054
|
+
["cron-trigger-overlap-policy", "`overlapPolicy` is `skip` or `allow`."],
|
|
7055
|
+
]);
|
|
7056
|
+
/**
|
|
7057
|
+
* The file-decidable half of the webhook `signingSecret` rule (#3320). The
|
|
7058
|
+
* SAME handler under the SAME ids is declared `apply` on the standalone
|
|
7059
|
+
* `webhook` surface, whose CLI path does not run it: stage is a property of
|
|
7060
|
+
* the (surface, rule) pair.
|
|
7061
|
+
*/
|
|
7062
|
+
const SIGNING_SECRET_RULES = functionRules("preflight", "src/config-surface/webhook-signing-secret.ts", "validateSigningSecretDeclaration", [
|
|
7063
|
+
[
|
|
7064
|
+
"signing-secret-required",
|
|
7065
|
+
"A scheme that verifies with an HMAC secret must be given a `signingSecret`.",
|
|
7066
|
+
],
|
|
7067
|
+
[
|
|
7068
|
+
"whole-secret-reference",
|
|
7069
|
+
"A supplied `signingSecret` is a whole `{{secrets.KEY}}` reference and " +
|
|
7070
|
+
"nothing else — a literal is never stored.",
|
|
7071
|
+
],
|
|
7072
|
+
[
|
|
7073
|
+
"signing-secret-not-supported",
|
|
7074
|
+
"A scheme whose key material lives in its config (`discord`, `jwt`, " +
|
|
7075
|
+
"`plaid`) or that verifies nothing (`none`) refuses a `signingSecret`.",
|
|
7076
|
+
],
|
|
7077
|
+
]);
|
|
7078
|
+
/**
|
|
7079
|
+
* The cron entries' schedule, decidable from the file (#3376).
|
|
7080
|
+
*
|
|
7081
|
+
* Both were `apply` until this issue — declared where they ran, with a note
|
|
7082
|
+
* saying so — because the only implementation lived on the server. Splitting
|
|
7083
|
+
* `src/cron-parser.ts` put the grammar and the `Intl` probe under
|
|
7084
|
+
* `src/config-surface/`, so the CLI's push preflight runs the same rule
|
|
7085
|
+
* before the first request and a malformed schedule no longer reaches apply.
|
|
7086
|
+
*/
|
|
7087
|
+
const CRON_SCHEDULE_RULES = functionRules("preflight", "src/config-surface/cron-rules.ts", "checkCronTriggerEntry", [
|
|
7088
|
+
[
|
|
7089
|
+
"cron-trigger-expression",
|
|
7090
|
+
"The `cron` expression must parse under the server's five-field grammar " +
|
|
7091
|
+
"(`parseCron`).",
|
|
7092
|
+
],
|
|
7093
|
+
[
|
|
7094
|
+
"cron-trigger-timezone",
|
|
7095
|
+
"The `timezone`, unset reading as UTC, must be one " +
|
|
7096
|
+
"`Intl.DateTimeFormat` accepts.",
|
|
7097
|
+
],
|
|
7098
|
+
]);
|
|
7099
|
+
/** What only the app's state can decide, at the push boundary (#3181). */
|
|
7100
|
+
const FUNCTION_TRIGGER_STATE_RULES = functionRules("apply", "src/services/function-trigger-validation.ts", "validateFunctionTriggerDeclaration", [
|
|
7101
|
+
[
|
|
7102
|
+
"webhook-trigger-settings",
|
|
7103
|
+
"The webhook block's verification settings pass the same pipeline the " +
|
|
7104
|
+
"standalone create runs (scheme config, replay protection, secret " +
|
|
7105
|
+
"existence), prefixed `[function.triggers.webhook]: `.",
|
|
7106
|
+
],
|
|
7107
|
+
[
|
|
7108
|
+
"webhook-trigger-key-held",
|
|
7109
|
+
"A standalone webhook that already holds the function's key blocks the " +
|
|
7110
|
+
"trigger — nobody else's webhook is rewritten.",
|
|
7111
|
+
],
|
|
7112
|
+
[
|
|
7113
|
+
"function-webhook-cap",
|
|
7114
|
+
"The per-app webhook cap, measured prospectively over what this push " +
|
|
7115
|
+
"would add (the standalone cap's sentence, this surface's count).",
|
|
7116
|
+
],
|
|
7117
|
+
[
|
|
7118
|
+
"function-cron-cap",
|
|
7119
|
+
"The per-app cron-trigger cap, measured prospectively over what this " +
|
|
7120
|
+
"push would add.",
|
|
7121
|
+
],
|
|
7122
|
+
]);
|
|
5594
7123
|
export const SERVER_FUNCTION_SURFACE = {
|
|
5595
7124
|
label: "function",
|
|
5596
7125
|
tables: [
|
|
@@ -5617,7 +7146,7 @@ export const SERVER_FUNCTION_SURFACE = {
|
|
|
5617
7146
|
// function with no gate fails closed at invoke, so accepting a bundle
|
|
5618
7147
|
// for one would report a successful push of something that can never
|
|
5619
7148
|
// run.
|
|
5620
|
-
validation: handledBy(FUNCTION_HANDLER, "ServerFunctionsController"),
|
|
7149
|
+
validation: handledBy(FUNCTION_HANDLER, "ServerFunctionsController", "apply"),
|
|
5621
7150
|
},
|
|
5622
7151
|
{
|
|
5623
7152
|
field: "inputSchema",
|
|
@@ -5625,7 +7154,7 @@ export const SERVER_FUNCTION_SURFACE = {
|
|
|
5625
7154
|
type: "json",
|
|
5626
7155
|
emit: "whenSet",
|
|
5627
7156
|
writableOn: BOTH,
|
|
5628
|
-
validation: handledBy(FUNCTION_HANDLER, "ServerFunctionsController"),
|
|
7157
|
+
validation: handledBy(FUNCTION_HANDLER, "ServerFunctionsController", "apply"),
|
|
5629
7158
|
},
|
|
5630
7159
|
{
|
|
5631
7160
|
field: "outputSchema",
|
|
@@ -5633,7 +7162,7 @@ export const SERVER_FUNCTION_SURFACE = {
|
|
|
5633
7162
|
type: "json",
|
|
5634
7163
|
emit: "whenSet",
|
|
5635
7164
|
writableOn: BOTH,
|
|
5636
|
-
validation: handledBy(FUNCTION_HANDLER, "ServerFunctionsController"),
|
|
7165
|
+
validation: handledBy(FUNCTION_HANDLER, "ServerFunctionsController", "apply"),
|
|
5637
7166
|
},
|
|
5638
7167
|
],
|
|
5639
7168
|
notExposed: {
|
|
@@ -5669,6 +7198,18 @@ export const SERVER_FUNCTION_SURFACE = {
|
|
|
5669
7198
|
"Repointed by a config-version push; versions are nameless, so " +
|
|
5670
7199
|
"there is no `activeConfigName` for an author to write.",
|
|
5671
7200
|
},
|
|
7201
|
+
activeConfigActivatedAt: {
|
|
7202
|
+
kind: "server-owned",
|
|
7203
|
+
note: "#3462 — WHEN the active version was put live. Stamped by both " +
|
|
7204
|
+
"doors that move the pointer (`config push` and the activate " +
|
|
7205
|
+
"route); an audit fact, so nothing authors it and `config pull` " +
|
|
7206
|
+
"does not write it back.",
|
|
7207
|
+
},
|
|
7208
|
+
activeConfigActivatedBy: {
|
|
7209
|
+
kind: "server-owned",
|
|
7210
|
+
note: "#3462 — WHICH admin put the active version live. Same two doors, " +
|
|
7211
|
+
"same reason: the actor is observed, never declared.",
|
|
7212
|
+
},
|
|
5672
7213
|
runAs: {
|
|
5673
7214
|
kind: "server-owned",
|
|
5674
7215
|
note: "RETIRED from the write surface by #3279: function code acts as " +
|
|
@@ -5713,21 +7254,24 @@ export const SERVER_FUNCTION_SURFACE = {
|
|
|
5713
7254
|
},
|
|
5714
7255
|
mode: {
|
|
5715
7256
|
kind: "structural",
|
|
5716
|
-
note:
|
|
5717
|
-
"
|
|
5718
|
-
"
|
|
5719
|
-
"
|
|
5720
|
-
"
|
|
5721
|
-
"
|
|
7257
|
+
note: "RETIRED (#3482), and still ACCEPTED through phase 4. A function " +
|
|
7258
|
+
"is a function: the CALLER picks the runtime at each call, with " +
|
|
7259
|
+
"`invoke` running it inside the request and `start` running it as " +
|
|
7260
|
+
"a task, cron fires always starting a task run and webhook " +
|
|
7261
|
+
"deliveries always running as a request. A file that still " +
|
|
7262
|
+
"carries the key pushes, with a warning naming it as ignored and " +
|
|
7263
|
+
"every other key applied, and `config pull` removes it; from " +
|
|
7264
|
+
"phase 5 (#3188) it is refused as an unknown key. Nothing reads " +
|
|
7265
|
+
"it, and its value is not validated. A function that must not run " +
|
|
7266
|
+
"under one runtime says so in code with `assertRuntime`.",
|
|
5722
7267
|
},
|
|
5723
7268
|
durable: {
|
|
5724
7269
|
kind: "structural",
|
|
5725
|
-
note:
|
|
5726
|
-
|
|
5727
|
-
|
|
5728
|
-
"
|
|
5729
|
-
"
|
|
5730
|
-
"so a file that says it keeps saying it until its author edits it.",
|
|
7270
|
+
note: "RETIRED (#3482), and still ACCEPTED through phase 4 — the same " +
|
|
7271
|
+
"as `mode`, whose alias it was. It named one runner for the whole " +
|
|
7272
|
+
"function, and there is no such thing any more. A file that " +
|
|
7273
|
+
"carries it pushes with a warning, `config pull` removes it, and " +
|
|
7274
|
+
"phase 5 refuses it.",
|
|
5731
7275
|
},
|
|
5732
7276
|
limits: {
|
|
5733
7277
|
kind: "structural",
|
|
@@ -5763,6 +7307,20 @@ export const SERVER_FUNCTION_SURFACE = {
|
|
|
5763
7307
|
// The file is the `[function]` table and nothing else. The function's code is
|
|
5764
7308
|
// in the source files `entry` reaches, not in a TOML table.
|
|
5765
7309
|
tomlDocumentKeys: {},
|
|
7310
|
+
// ── Cross-field rules (#3374) ─────────────────────────────────────────────
|
|
7311
|
+
//
|
|
7312
|
+
// Single-field refusals inside these modules (`mode`'s value, a webhook
|
|
7313
|
+
// block's `verificationScheme`) are declared here too: this is the only
|
|
7314
|
+
// id-bearing slot until #3375 gives `ConfigField.validation` a stage, and a
|
|
7315
|
+
// module's list must equal its declarations in both directions.
|
|
7316
|
+
crossFieldRules: [
|
|
7317
|
+
...TRIGGER_DECLARATION_RULES,
|
|
7318
|
+
...SIGNING_SECRET_RULES,
|
|
7319
|
+
// #3376 — after the signing-secret group, mirroring the order
|
|
7320
|
+
// `functionTomlPreflightErrors` runs them in.
|
|
7321
|
+
...CRON_SCHEDULE_RULES,
|
|
7322
|
+
...FUNCTION_TRIGGER_STATE_RULES,
|
|
7323
|
+
],
|
|
5766
7324
|
};
|
|
5767
7325
|
// ── src/config-surface/test-case.ts ──────────────────────────────────────
|
|
5768
7326
|
/**
|
|
@@ -5789,6 +7347,18 @@ export const SERVER_FUNCTION_SURFACE = {
|
|
|
5789
7347
|
* re-stringifies on store.
|
|
5790
7348
|
*/
|
|
5791
7349
|
const TEST_CASE_HANDLER = "src/admin-api.ts";
|
|
7350
|
+
/**
|
|
7351
|
+
* ── Why this module's CLI handler is declared `apply` (#3375) ─────────────
|
|
7352
|
+
*
|
|
7353
|
+
* A `preflight` handler must be VENDORED — its module lives under
|
|
7354
|
+
* `src/config-surface/`, so the CLI runs the server's own copy through the
|
|
7355
|
+
* generated artifact rather than a second implementation. A handler that
|
|
7356
|
+
* lives in `cli/src/` is a CLI-only copy of a server rule, which is precisely
|
|
7357
|
+
* the drift this project exists to remove; until it is vendored it cannot make
|
|
7358
|
+
* the registry's preflight promise, whatever the push path happens to do
|
|
7359
|
+
* today. Declared `apply`, and recorded as a finding for the validation child
|
|
7360
|
+
* (#3379) rather than papered over with a third stage.
|
|
7361
|
+
*/
|
|
5792
7362
|
const CLI_SYNC = "cli/src/commands/sync.ts";
|
|
5793
7363
|
export const TEST_CASE_SURFACE = {
|
|
5794
7364
|
label: "test-case",
|
|
@@ -5806,7 +7376,7 @@ export const TEST_CASE_SURFACE = {
|
|
|
5806
7376
|
emit: "always",
|
|
5807
7377
|
writableOn: BOTH,
|
|
5808
7378
|
// Required and trimmed on create; an empty name is a 400.
|
|
5809
|
-
validation: handledBy(TEST_CASE_HANDLER, "AdminAPI"),
|
|
7379
|
+
validation: handledBy(TEST_CASE_HANDLER, "AdminAPI", "apply"),
|
|
5810
7380
|
},
|
|
5811
7381
|
{
|
|
5812
7382
|
field: "description",
|
|
@@ -5824,7 +7394,7 @@ export const TEST_CASE_SURFACE = {
|
|
|
5824
7394
|
writableOn: BOTH,
|
|
5825
7395
|
// Required. TOML carries the JSON text; the wire carries the parsed
|
|
5826
7396
|
// object, which the server re-stringifies for storage.
|
|
5827
|
-
validation: handledBy(CLI_SYNC, "parseTestCaseToml"),
|
|
7397
|
+
validation: handledBy(CLI_SYNC, "parseTestCaseToml", "apply"),
|
|
5828
7398
|
},
|
|
5829
7399
|
{
|
|
5830
7400
|
field: "configId",
|
|
@@ -5833,7 +7403,7 @@ export const TEST_CASE_SURFACE = {
|
|
|
5833
7403
|
emit: "always",
|
|
5834
7404
|
writableOn: BOTH,
|
|
5835
7405
|
// Authored as the config's NAME and resolved to an id at push time.
|
|
5836
|
-
validation: handledBy(CLI_SYNC, "parseTestCaseToml"),
|
|
7406
|
+
validation: handledBy(CLI_SYNC, "parseTestCaseToml", "apply"),
|
|
5837
7407
|
},
|
|
5838
7408
|
{
|
|
5839
7409
|
field: "evaluatorPromptId",
|
|
@@ -5842,7 +7412,7 @@ export const TEST_CASE_SURFACE = {
|
|
|
5842
7412
|
emit: "always",
|
|
5843
7413
|
writableOn: BOTH,
|
|
5844
7414
|
// Authored as the judge prompt's KEY and resolved to an id at push.
|
|
5845
|
-
validation: handledBy(CLI_SYNC, "parseTestCaseToml"),
|
|
7415
|
+
validation: handledBy(CLI_SYNC, "parseTestCaseToml", "apply"),
|
|
5846
7416
|
},
|
|
5847
7417
|
{
|
|
5848
7418
|
field: "evaluatorConfigId",
|
|
@@ -5850,7 +7420,7 @@ export const TEST_CASE_SURFACE = {
|
|
|
5850
7420
|
type: "string",
|
|
5851
7421
|
emit: "always",
|
|
5852
7422
|
writableOn: BOTH,
|
|
5853
|
-
validation: handledBy(CLI_SYNC, "parseTestCaseToml"),
|
|
7423
|
+
validation: handledBy(CLI_SYNC, "parseTestCaseToml", "apply"),
|
|
5854
7424
|
},
|
|
5855
7425
|
{
|
|
5856
7426
|
field: "expectedOutputPattern",
|
|
@@ -5867,7 +7437,7 @@ export const TEST_CASE_SURFACE = {
|
|
|
5867
7437
|
emit: "always",
|
|
5868
7438
|
writableOn: BOTH,
|
|
5869
7439
|
// JSON array of strings; TOML carries the JSON text.
|
|
5870
|
-
validation: handledBy(CLI_SYNC, "parseTestCaseToml"),
|
|
7440
|
+
validation: handledBy(CLI_SYNC, "parseTestCaseToml", "apply"),
|
|
5871
7441
|
},
|
|
5872
7442
|
{
|
|
5873
7443
|
field: "expectedJsonSubset",
|
|
@@ -5876,7 +7446,7 @@ export const TEST_CASE_SURFACE = {
|
|
|
5876
7446
|
emit: "always",
|
|
5877
7447
|
writableOn: BOTH,
|
|
5878
7448
|
// JSON object, deep-partial-matched against the parsed output.
|
|
5879
|
-
validation: handledBy(CLI_SYNC, "parseTestCaseToml"),
|
|
7449
|
+
validation: handledBy(CLI_SYNC, "parseTestCaseToml", "apply"),
|
|
5880
7450
|
},
|
|
5881
7451
|
],
|
|
5882
7452
|
notExposed: {
|
|
@@ -6070,7 +7640,7 @@ export const APP_SETTINGS_SURFACE = {
|
|
|
6070
7640
|
// with `=== true`, so `"false"` must be a 400 rather than a stored
|
|
6071
7641
|
// `true`. The generated request schema stays keys-only (#1976), so
|
|
6072
7642
|
// the check is `parseDirectLlmEnabledWrite` in the handler.
|
|
6073
|
-
validation: handledBy(SETTINGS_HANDLER, "SettingsController"),
|
|
7643
|
+
validation: handledBy(SETTINGS_HANDLER, "SettingsController", "apply"),
|
|
6074
7644
|
},
|
|
6075
7645
|
{
|
|
6076
7646
|
field: "allowedDomains",
|
|
@@ -6088,7 +7658,7 @@ export const APP_SETTINGS_SURFACE = {
|
|
|
6088
7658
|
writableOn: UPDATE_ONLY,
|
|
6089
7659
|
// `+primitivetest` bypass whitelist (#490/#675), shape-checked by
|
|
6090
7660
|
// `validateTestAccountBaseEmails`.
|
|
6091
|
-
validation: handledBy(SETTINGS_HANDLER, "SettingsController"),
|
|
7661
|
+
validation: handledBy(SETTINGS_HANDLER, "SettingsController", "apply"),
|
|
6092
7662
|
},
|
|
6093
7663
|
{
|
|
6094
7664
|
field: "largeDocumentWindowDays",
|
|
@@ -6102,7 +7672,7 @@ export const APP_SETTINGS_SURFACE = {
|
|
|
6102
7672
|
// Refused rather than clamped: answering 200 to a 30-day window the
|
|
6103
7673
|
// server does not keep would have the caller plan a month of
|
|
6104
7674
|
// offline work around it (`parseLargeDocumentWindowSetting`).
|
|
6105
|
-
validation: handledBy(SETTINGS_HANDLER, "SettingsController"),
|
|
7675
|
+
validation: handledBy(SETTINGS_HANDLER, "SettingsController", "apply"),
|
|
6106
7676
|
},
|
|
6107
7677
|
],
|
|
6108
7678
|
notExposed: {
|
|
@@ -6220,7 +7790,7 @@ export const APP_SETTINGS_SURFACE = {
|
|
|
6220
7790
|
// per-type secret table, cross-entry URI uniqueness and the
|
|
6221
7791
|
// aggregate caps are all `validateGoogleClientsWrite`
|
|
6222
7792
|
// (`src/services/google-clients.ts`), applied by the handler.
|
|
6223
|
-
validation: handledBy(SETTINGS_HANDLER, "SettingsController"),
|
|
7793
|
+
validation: handledBy(SETTINGS_HANDLER, "SettingsController", "apply"),
|
|
6224
7794
|
},
|
|
6225
7795
|
{
|
|
6226
7796
|
field: "passkeyEnabled",
|
|
@@ -6241,7 +7811,7 @@ export const APP_SETTINGS_SURFACE = {
|
|
|
6241
7811
|
writableOn: UPDATE_ONLY,
|
|
6242
7812
|
// Mirrored into the two retired model fields by the handler so an
|
|
6243
7813
|
// un-migrated reader cannot disagree.
|
|
6244
|
-
validation: handledBy(SETTINGS_HANDLER, "SettingsController"),
|
|
7814
|
+
validation: handledBy(SETTINGS_HANDLER, "SettingsController", "apply"),
|
|
6245
7815
|
},
|
|
6246
7816
|
{
|
|
6247
7817
|
field: "appleSignInEnabled",
|
|
@@ -6271,7 +7841,7 @@ export const APP_SETTINGS_SURFACE = {
|
|
|
6271
7841
|
// `validateRedirectUris`; matching is fail-closed, and a request
|
|
6272
7842
|
// whose target this list does not cover gets a code-only email
|
|
6273
7843
|
// rather than a link (#2884).
|
|
6274
|
-
validation: handledBy(SETTINGS_HANDLER, "SettingsController"),
|
|
7844
|
+
validation: handledBy(SETTINGS_HANDLER, "SettingsController", "apply"),
|
|
6275
7845
|
},
|
|
6276
7846
|
{
|
|
6277
7847
|
field: "passkeyRpConfig",
|
|
@@ -6281,7 +7851,7 @@ export const APP_SETTINGS_SURFACE = {
|
|
|
6281
7851
|
writableOn: UPDATE_ONLY,
|
|
6282
7852
|
// `[auth.passkeys]` — rpId → { name }, shape-checked by
|
|
6283
7853
|
// `SettingsController.validatePasskeyRpConfig`.
|
|
6284
|
-
validation: handledBy(SETTINGS_HANDLER, "SettingsController"),
|
|
7854
|
+
validation: handledBy(SETTINGS_HANDLER, "SettingsController", "apply"),
|
|
6285
7855
|
},
|
|
6286
7856
|
{
|
|
6287
7857
|
field: "passkeyUserVerification",
|
|
@@ -6299,7 +7869,7 @@ export const APP_SETTINGS_SURFACE = {
|
|
|
6299
7869
|
// both halves of every passkey ceremony state, so an unrecognized
|
|
6300
7870
|
// value is a 400 rather than a silent fall back to the default —
|
|
6301
7871
|
// the `directLlmEnabled` rationale, applied to a security policy.
|
|
6302
|
-
validation: handledBy(SETTINGS_HANDLER, "SettingsController"),
|
|
7872
|
+
validation: handledBy(SETTINGS_HANDLER, "SettingsController", "apply"),
|
|
6303
7873
|
},
|
|
6304
7874
|
],
|
|
6305
7875
|
notExposed: {},
|
|
@@ -6463,10 +8033,8 @@ export const CONFIG_SURFACES = [
|
|
|
6463
8033
|
* is as review-visible as weakening any other assertion.
|
|
6464
8034
|
*/
|
|
6465
8035
|
export const CROSS_FIELD_RULES_UNMIGRATED = [
|
|
6466
|
-
"workflow",
|
|
6467
8036
|
"prompt",
|
|
6468
8037
|
"integration",
|
|
6469
|
-
"webhook",
|
|
6470
8038
|
"cron-trigger",
|
|
6471
8039
|
"blob-bucket",
|
|
6472
8040
|
"email-template",
|
|
@@ -6475,7 +8043,6 @@ export const CROSS_FIELD_RULES_UNMIGRATED = [
|
|
|
6475
8043
|
"group-type-config",
|
|
6476
8044
|
"collection-type-config",
|
|
6477
8045
|
"metadata-category-config",
|
|
6478
|
-
"function",
|
|
6479
8046
|
"test-case",
|
|
6480
8047
|
"app-settings",
|
|
6481
8048
|
];
|
|
@@ -6603,6 +8170,8 @@ export const GENERATED_CONFIG_MODEL_FIELDS = {
|
|
|
6603
8170
|
"outputFormat",
|
|
6604
8171
|
"outputSchema",
|
|
6605
8172
|
"providerConfig",
|
|
8173
|
+
"reasoningEffort",
|
|
8174
|
+
"reasoningBudget",
|
|
6606
8175
|
"status",
|
|
6607
8176
|
"createdBy",
|
|
6608
8177
|
"createdAt",
|
|
@@ -6770,6 +8339,8 @@ export const GENERATED_CONFIG_MODEL_FIELDS = {
|
|
|
6770
8339
|
"description",
|
|
6771
8340
|
"status",
|
|
6772
8341
|
"activeConfigId",
|
|
8342
|
+
"activeConfigActivatedAt",
|
|
8343
|
+
"activeConfigActivatedBy",
|
|
6773
8344
|
"runAs",
|
|
6774
8345
|
"access",
|
|
6775
8346
|
"inputSchema",
|