primitive-admin 1.1.0-alpha.78 → 1.1.0-alpha.79

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.
Files changed (105) hide show
  1. package/README.md +38 -17
  2. package/assets/skill/skills/primitive-platform/SKILL.md +39 -52
  3. package/dist/bin/primitive.js +14 -25
  4. package/dist/bin/primitive.js.map +1 -1
  5. package/dist/src/commands/admins.js +8 -18
  6. package/dist/src/commands/admins.js.map +1 -1
  7. package/dist/src/commands/analytics.js +46 -5
  8. package/dist/src/commands/analytics.js.map +1 -1
  9. package/dist/src/commands/auth.js +16 -1
  10. package/dist/src/commands/auth.js.map +1 -1
  11. package/dist/src/commands/collection-type-configs.js +1 -9
  12. package/dist/src/commands/collection-type-configs.js.map +1 -1
  13. package/dist/src/commands/config.js +11 -29
  14. package/dist/src/commands/config.js.map +1 -1
  15. package/dist/src/commands/database-type-configs.js +1 -9
  16. package/dist/src/commands/database-type-configs.js.map +1 -1
  17. package/dist/src/commands/databases.js +8 -38
  18. package/dist/src/commands/databases.js.map +1 -1
  19. package/dist/src/commands/env.js +27 -32
  20. package/dist/src/commands/env.js.map +1 -1
  21. package/dist/src/commands/functions.js +197 -23
  22. package/dist/src/commands/functions.js.map +1 -1
  23. package/dist/src/commands/groups.js +1 -9
  24. package/dist/src/commands/groups.js.map +1 -1
  25. package/dist/src/commands/init.js +6 -0
  26. package/dist/src/commands/init.js.map +1 -1
  27. package/dist/src/commands/rule-sets.js +1 -9
  28. package/dist/src/commands/rule-sets.js.map +1 -1
  29. package/dist/src/commands/scripts.js +12 -28
  30. package/dist/src/commands/scripts.js.map +1 -1
  31. package/dist/src/commands/sync.d.ts +119 -0
  32. package/dist/src/commands/sync.js +387 -196
  33. package/dist/src/commands/sync.js.map +1 -1
  34. package/dist/src/commands/workflows.js +2 -14
  35. package/dist/src/commands/workflows.js.map +1 -1
  36. package/dist/src/lib/api-client.d.ts +46 -11
  37. package/dist/src/lib/api-client.js +21 -11
  38. package/dist/src/lib/api-client.js.map +1 -1
  39. package/dist/src/lib/codegen-shared/resolveCodegenSourceDir.d.ts +24 -57
  40. package/dist/src/lib/codegen-shared/resolveCodegenSourceDir.js +30 -204
  41. package/dist/src/lib/codegen-shared/resolveCodegenSourceDir.js.map +1 -1
  42. package/dist/src/lib/config-json-field.d.ts +28 -0
  43. package/dist/src/lib/config-json-field.js +56 -0
  44. package/dist/src/lib/config-json-field.js.map +1 -0
  45. package/dist/src/lib/config-object-descriptor.js +4 -3
  46. package/dist/src/lib/config-object-descriptor.js.map +1 -1
  47. package/dist/src/lib/config-surface.js +1 -1
  48. package/dist/src/lib/config-surface.js.map +1 -1
  49. package/dist/src/lib/config.d.ts +21 -14
  50. package/dist/src/lib/config.js +25 -44
  51. package/dist/src/lib/config.js.map +1 -1
  52. package/dist/src/lib/credentials-store.d.ts +31 -8
  53. package/dist/src/lib/credentials-store.js +52 -13
  54. package/dist/src/lib/credentials-store.js.map +1 -1
  55. package/dist/src/lib/env-resolver-core.d.ts +18 -0
  56. package/dist/src/lib/env-resolver-core.js +26 -3
  57. package/dist/src/lib/env-resolver-core.js.map +1 -1
  58. package/dist/src/lib/env-resolver.d.ts +15 -0
  59. package/dist/src/lib/env-resolver.js +21 -1
  60. package/dist/src/lib/env-resolver.js.map +1 -1
  61. package/dist/src/lib/function-collect.d.ts +1 -1
  62. package/dist/src/lib/function-collect.js +138 -7
  63. package/dist/src/lib/function-collect.js.map +1 -1
  64. package/dist/src/lib/function-db-types.d.ts +49 -11
  65. package/dist/src/lib/function-db-types.js +234 -39
  66. package/dist/src/lib/function-db-types.js.map +1 -1
  67. package/dist/src/lib/function-document-types.d.ts +50 -0
  68. package/dist/src/lib/function-document-types.js +163 -0
  69. package/dist/src/lib/function-document-types.js.map +1 -0
  70. package/dist/src/lib/function-log-tail.d.ts +83 -0
  71. package/dist/src/lib/function-log-tail.js +115 -0
  72. package/dist/src/lib/function-log-tail.js.map +1 -0
  73. package/dist/src/lib/function-schema-codegen.d.ts +118 -0
  74. package/dist/src/lib/function-schema-codegen.js +402 -0
  75. package/dist/src/lib/function-schema-codegen.js.map +1 -0
  76. package/dist/src/lib/function-sync.d.ts +85 -2
  77. package/dist/src/lib/function-sync.js +196 -27
  78. package/dist/src/lib/function-sync.js.map +1 -1
  79. package/dist/src/lib/function-triggers.d.ts +9 -3
  80. package/dist/src/lib/function-triggers.js +12 -9
  81. package/dist/src/lib/function-triggers.js.map +1 -1
  82. package/dist/src/lib/function-typecheck.d.ts +86 -0
  83. package/dist/src/lib/function-typecheck.js +370 -0
  84. package/dist/src/lib/function-typecheck.js.map +1 -0
  85. package/dist/src/lib/generated-allowlist.js +4 -0
  86. package/dist/src/lib/generated-allowlist.js.map +1 -1
  87. package/dist/src/lib/generated-config-surfaces.d.ts +422 -0
  88. package/dist/src/lib/generated-config-surfaces.js +838 -9
  89. package/dist/src/lib/generated-config-surfaces.js.map +1 -1
  90. package/dist/src/lib/generated-sdk-types.d.ts +1 -1
  91. package/dist/src/lib/generated-sdk-types.js +1 -1
  92. package/dist/src/lib/generated-sdk-types.js.map +1 -1
  93. package/dist/src/lib/log-inspection.d.ts +116 -4
  94. package/dist/src/lib/log-inspection.js +147 -2
  95. package/dist/src/lib/log-inspection.js.map +1 -1
  96. package/dist/src/lib/snapshots.d.ts +6 -7
  97. package/dist/src/lib/snapshots.js +18 -81
  98. package/dist/src/lib/snapshots.js.map +1 -1
  99. package/dist/src/lib/sync-paths.d.ts +29 -53
  100. package/dist/src/lib/sync-paths.js +46 -97
  101. package/dist/src/lib/sync-paths.js.map +1 -1
  102. package/dist/src/lib/workflow-toml-validator.d.ts +8 -3
  103. package/dist/src/lib/workflow-toml-validator.js +8 -3
  104. package/dist/src/lib/workflow-toml-validator.js.map +1 -1
  105. package/package.json +3 -3
@@ -1284,6 +1284,62 @@ export const MAX_MANIFEST_QUERIES = 200;
1284
1284
  export const MAX_MANIFEST_NAME_LENGTH = 120;
1285
1285
  export const MAX_MANIFEST_MODELS = 200;
1286
1286
  export const MAX_MANIFEST_FAMILIES = 40;
1287
+ /** The scalar parameter types a registration may declare, and an array's element types. */
1288
+ export const QUERY_PARAM_SCALAR_TYPES = ["string", "number", "boolean", "any"];
1289
+ /**
1290
+ * Coerce one value to a declared parameter type the way the SDK coerces a
1291
+ * SUPPLIED value: a digit string becomes a number, `"true"`/`"false"` a
1292
+ * boolean, an array's elements each by the `items` type. Applied to a
1293
+ * declared `default` at registration (D3281-SO-005) so what `run` receives is
1294
+ * what the declaration promises — here at push (the collect stub and this
1295
+ * grammar) and in the SDK at the first load. ONE rule, spelled in the SDK's
1296
+ * source a second time because that module is baked text; the tests hold the
1297
+ * two together.
1298
+ */
1299
+ export function coerceParamDefault(spec, value) {
1300
+ const type = spec.type === undefined ? "any" : String(spec.type);
1301
+ if (type === "array") {
1302
+ if (!Array.isArray(value))
1303
+ return { ok: false, reason: "must be an array" };
1304
+ const itemType = spec.items?.type === undefined ? "any" : String(spec.items.type);
1305
+ const out = [];
1306
+ for (const element of value) {
1307
+ const coerced = coerceScalar(itemType, element);
1308
+ // `=== false` rather than `!`: the CLI's vendored copy compiles without
1309
+ // strict null checks, where truthiness does not narrow the union.
1310
+ if (coerced.ok === false) {
1311
+ return { ok: false, reason: `every element ${coerced.reason}` };
1312
+ }
1313
+ out.push(coerced.value);
1314
+ }
1315
+ return { ok: true, value: out };
1316
+ }
1317
+ return coerceScalar(type, value);
1318
+ }
1319
+ function coerceScalar(type, value) {
1320
+ if (type === "number") {
1321
+ const asNumber = typeof value === "number" ? value : Number(value);
1322
+ if (typeof value === "boolean" || value === null || value === "" || !Number.isFinite(asNumber)) {
1323
+ return { ok: false, reason: "must be a number" };
1324
+ }
1325
+ return { ok: true, value: asNumber };
1326
+ }
1327
+ if (type === "boolean") {
1328
+ if (typeof value === "boolean")
1329
+ return { ok: true, value };
1330
+ if (value === "true")
1331
+ return { ok: true, value: true };
1332
+ if (value === "false")
1333
+ return { ok: true, value: false };
1334
+ return { ok: false, reason: "must be a boolean" };
1335
+ }
1336
+ if (type === "string") {
1337
+ if (typeof value !== "string")
1338
+ return { ok: false, reason: "must be a string" };
1339
+ return { ok: true, value };
1340
+ }
1341
+ return { ok: true, value };
1342
+ }
1287
1343
  /**
1288
1344
  * Validate a manifest's grammar and normalize it.
1289
1345
  *
@@ -1365,13 +1421,44 @@ export function parseFunctionManifest(value) {
1365
1421
  "something other than an object.");
1366
1422
  continue;
1367
1423
  }
1424
+ // #3281 — an array parameter carries its element type; `caller` is a
1425
+ // scalar binding and cannot be a list; a default is recorded COERCED
1426
+ // to the declared type, or the entry is refused (D3281-SO-005).
1427
+ const declaredType = spec.type === undefined ? undefined : String(spec.type);
1428
+ let items;
1429
+ if (declaredType === "array") {
1430
+ if (spec.items !== undefined && spec.items !== null) {
1431
+ const itemType = spec.items && typeof spec.items === "object" ? String(spec.items.type ?? "") : "";
1432
+ if (!QUERY_PARAM_SCALAR_TYPES.includes(itemType)) {
1433
+ errors.push(`manifest entry '${name}' declares parameter '${key}' with an \`items\` ` +
1434
+ `type outside ${QUERY_PARAM_SCALAR_TYPES.join(", ")}.`);
1435
+ continue;
1436
+ }
1437
+ items = { type: itemType };
1438
+ }
1439
+ if (spec.caller === true) {
1440
+ errors.push(`manifest entry '${name}' declares parameter '${key}' as an array bound to ` +
1441
+ "$caller; `caller` is a scalar binding.");
1442
+ continue;
1443
+ }
1444
+ }
1445
+ let defaultValue;
1446
+ const hasDefault = Object.prototype.hasOwnProperty.call(spec, "default");
1447
+ if (hasDefault) {
1448
+ const coerced = coerceParamDefault({ type: declaredType, items }, spec.default);
1449
+ if (coerced.ok === false) {
1450
+ errors.push(`manifest entry '${name}' declares parameter '${key}' with a default that ` +
1451
+ `${coerced.reason}.`);
1452
+ continue;
1453
+ }
1454
+ defaultValue = coerced.value;
1455
+ }
1368
1456
  params[key] = {
1369
- ...(spec.type !== undefined ? { type: String(spec.type) } : {}),
1457
+ ...(declaredType !== undefined ? { type: declaredType } : {}),
1458
+ ...(items !== undefined ? { items } : {}),
1370
1459
  ...(spec.caller === true ? { caller: true } : {}),
1371
1460
  ...(spec.optional === true ? { optional: true } : {}),
1372
- ...(Object.prototype.hasOwnProperty.call(spec, "default")
1373
- ? { default: spec.default }
1374
- : {}),
1461
+ ...(hasDefault ? { default: defaultValue } : {}),
1375
1462
  };
1376
1463
  }
1377
1464
  }
@@ -1441,6 +1528,732 @@ export function parseFunctionManifest(value) {
1441
1528
  errors: [],
1442
1529
  };
1443
1530
  }
1531
+ export const FUNCTION_MODES = ["request", "task"];
1532
+ /**
1533
+ * Resolve `mode` and its alias `durable` to one mode.
1534
+ *
1535
+ * Absent both → `request`. `mode` must be exactly `"request"` or `"task"`;
1536
+ * `durable` must be a boolean. Both present and disagreeing is refused naming
1537
+ * both keys, so an author who half-migrated a file hears which line to fix.
1538
+ */
1539
+ export function resolveDeclaredMode(input) {
1540
+ const rawMode = input?.mode;
1541
+ const rawDurable = input?.durable;
1542
+ const hasMode = rawMode !== undefined && rawMode !== null;
1543
+ const hasDurable = rawDurable !== undefined && rawDurable !== null;
1544
+ let fromMode = null;
1545
+ if (hasMode) {
1546
+ if (typeof rawMode !== "string" || !FUNCTION_MODES.includes(rawMode)) {
1547
+ return {
1548
+ ok: false,
1549
+ error: `\`mode\` must be "request" or "task" (got ${describe(rawMode)}): a ` +
1550
+ "request function runs inside the call and returns its result; a " +
1551
+ "task function returns a run id and survives restarts",
1552
+ };
1553
+ }
1554
+ fromMode = rawMode;
1555
+ }
1556
+ let fromDurable = null;
1557
+ if (hasDurable) {
1558
+ if (typeof rawDurable !== "boolean") {
1559
+ return {
1560
+ ok: false,
1561
+ error: `\`durable\` must be true or false (got ${describe(rawDurable)}); it is ` +
1562
+ 'the accepted alias for `mode = "task"` / `mode = "request"`',
1563
+ };
1564
+ }
1565
+ fromDurable = rawDurable ? "task" : "request";
1566
+ }
1567
+ if (fromMode && fromDurable && fromMode !== fromDurable) {
1568
+ return {
1569
+ ok: false,
1570
+ error: `\`mode = "${fromMode}"\` and \`durable = ${String(rawDurable)}\` disagree: ` +
1571
+ '`durable` is the accepted alias for `mode = "task"` (true) and ' +
1572
+ '`mode = "request"` (false), so keep one key, or make the two agree',
1573
+ };
1574
+ }
1575
+ const mode = fromMode ?? fromDurable ?? "request";
1576
+ return { ok: true, mode, declared: hasMode || hasDurable };
1577
+ }
1578
+ /**
1579
+ * The mode a stored config-version row runs in.
1580
+ *
1581
+ * The column is `durable`; a row written before #3281 carries only that, and a
1582
+ * row written after it carries the same thing, so there is exactly one reading.
1583
+ */
1584
+ export function configMode(row) {
1585
+ return row?.durable === true ? "task" : "request";
1586
+ }
1587
+ /** Is this mode the durable one? The adjective, for the paths that branch on it. */
1588
+ export function isDurableMode(mode) {
1589
+ return mode === "task";
1590
+ }
1591
+ function describe(value) {
1592
+ if (typeof value === "string")
1593
+ return JSON.stringify(value);
1594
+ if (value === null)
1595
+ return "null";
1596
+ if (typeof value === "object")
1597
+ return Array.isArray(value) ? "an array" : "a table";
1598
+ return String(value);
1599
+ }
1600
+ /**
1601
+ * Why a webhook cannot drive a task function, and what to do instead (#3334).
1602
+ *
1603
+ * The first clause is verbatim what it has always been, so sibling pins on the
1604
+ * request-mode prefix (#3281's) keep holding; everything after the dash is the
1605
+ * half that was missing — an author who meets this at the push door could not
1606
+ * previously tell whether it was a limitation or a design, nor what to write.
1607
+ */
1608
+ export const WEBHOOK_ON_TASK_REFUSAL = "webhook triggers require request mode: set `mode = \"request\"` (or " +
1609
+ "remove `mode = \"task\"`) — a webhook delivery needs its answer inside the " +
1610
+ "request, and a task run has none to give there: verify and acknowledge in " +
1611
+ "the request function, then hand the work to a task function with " +
1612
+ "ctx.functions.start — or remove [function.triggers.webhook]";
1613
+ export const EMPTY_TRIGGER_DECLARATION = {
1614
+ webhook: null,
1615
+ cron: [],
1616
+ databases: [],
1617
+ };
1618
+ /**
1619
+ * The most cron entries one function may declare.
1620
+ *
1621
+ * The per-app cap (50) is a resource ceiling; this is a per-OBJECT one, so a
1622
+ * single function cannot take most of an app's budget by itself. Ten is the
1623
+ * same order as the schedules a real function needs and leaves the app's
1624
+ * remaining slots for the other 40+ objects the cap is sized for.
1625
+ */
1626
+ export const MAX_CRON_TRIGGERS_PER_FUNCTION = 10;
1627
+ /**
1628
+ * Cron entry names follow the STANDALONE `triggerKey` charset, minus its
1629
+ * ability to hold a `:` — the standalone create already rejects `:`, which is
1630
+ * exactly what makes `<functionKey>:<name>` a key no standalone row can ever
1631
+ * collide with (the intent, "Key namespace?").
1632
+ */
1633
+ const CRON_NAME_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/;
1634
+ /**
1635
+ * The most database types one function may watch (#3184, D-07).
1636
+ *
1637
+ * Per-OBJECT, like the cron cap above: a function watching more than a handful
1638
+ * of types is describing a fan-in the platform cannot bound per write, and
1639
+ * every extra type is another discovery read on somebody's write path.
1640
+ */
1641
+ export const MAX_DATABASE_TRIGGERS_PER_FUNCTION = 5;
1642
+ /**
1643
+ * A database type key, matching the charset the grant grammar already uses for
1644
+ * the same value (`src/config-surface/function-grants.ts`). One rule for one
1645
+ * concept: `database:<type>/<model>:read` and `[[function.triggers.database]]`
1646
+ * name the same thing.
1647
+ */
1648
+ const DATABASE_TYPE_PATTERN = /^[A-Za-z0-9_-]+$/;
1649
+ const DATABASE_KEYS = new Set(["type"]);
1650
+ const WEBHOOK_KEYS = new Set([
1651
+ "verificationScheme",
1652
+ "signingSecret",
1653
+ "toleranceSeconds",
1654
+ "deduplicationEnabled",
1655
+ "deduplicationWindowMs",
1656
+ "maxBodyBytes",
1657
+ "secretGracePeriodMs",
1658
+ "verification",
1659
+ ]);
1660
+ const CRON_KEYS = new Set([
1661
+ "name",
1662
+ "cron",
1663
+ "timezone",
1664
+ "overlapPolicy",
1665
+ "rootInput",
1666
+ ]);
1667
+ const VALID_OVERLAP_POLICIES = ["skip", "allow"];
1668
+ export class TriggerDeclarationError extends Error {
1669
+ }
1670
+ /** Is anything at all declared? Used to skip work on the common case. */
1671
+ export function hasDeclaredTriggers(declaration) {
1672
+ return (declaration.webhook !== null ||
1673
+ declaration.cron.length > 0 ||
1674
+ declaration.databases.length > 0);
1675
+ }
1676
+ /**
1677
+ * Normalize the `triggers` table of an already-parsed `[function]` table.
1678
+ * Throws `TriggerDeclarationError` naming the rule that was broken.
1679
+ *
1680
+ * Split out from the parse (`deriveTriggerDeclaration`, which owns the bytes)
1681
+ * so the CLI's preflight can check the document it has already read, and so the
1682
+ * push boundary can normalize a declaration it read from a stored row without
1683
+ * re-parsing bytes.
1684
+ */
1685
+ export function normalizeTriggerDeclaration(functionTable, options = {}) {
1686
+ const triggers = functionTable?.triggers;
1687
+ if (triggers === undefined || triggers === null) {
1688
+ return EMPTY_TRIGGER_DECLARATION;
1689
+ }
1690
+ if (typeof triggers !== "object" || Array.isArray(triggers)) {
1691
+ throw new TriggerDeclarationError("[function.triggers] must be a table declaring `webhook` and/or `cron`");
1692
+ }
1693
+ for (const key of Object.keys(triggers)) {
1694
+ if (key !== "webhook" && key !== "cron" && key !== "database") {
1695
+ throw new TriggerDeclarationError(`unknown trigger kind '${key}' under [function.triggers]: the kinds are ` +
1696
+ "`webhook`, `cron` and `database`");
1697
+ }
1698
+ }
1699
+ const webhook = normalizeWebhook(triggers.webhook, options);
1700
+ const cron = normalizeCron(triggers.cron, options);
1701
+ const databases = normalizeDatabases(triggers.database, options);
1702
+ return { webhook, cron, databases };
1703
+ }
1704
+ /**
1705
+ * `[[function.triggers.database]]` — one accepted key, `type` (#3184, D-07).
1706
+ *
1707
+ * Sync mode only, permanently: a database-change fire runs INSIDE the write
1708
+ * request, best-effort, with no run row (the intent, "Sync functions write a
1709
+ * run row?"), and there is no durable shape for that. This is the same
1710
+ * permanent refusal `webhook` carries, not the release-scoped one `cron` does.
1711
+ */
1712
+ function normalizeDatabases(raw, options) {
1713
+ if (raw === undefined || raw === null)
1714
+ return [];
1715
+ const entries = Array.isArray(raw) ? raw : [raw];
1716
+ if (entries.length === 0)
1717
+ return [];
1718
+ if (options.mode === "task") {
1719
+ throw new TriggerDeclarationError("database triggers require request mode: set `mode = \"request\"` (or " +
1720
+ "remove `mode = \"task\"`), or remove the [[function.triggers.database]] " +
1721
+ "entries");
1722
+ }
1723
+ if (entries.length > MAX_DATABASE_TRIGGERS_PER_FUNCTION) {
1724
+ throw new TriggerDeclarationError(`a function may watch at most ${MAX_DATABASE_TRIGGERS_PER_FUNCTION} database ` +
1725
+ `types, and this one declares ${entries.length}`);
1726
+ }
1727
+ const seen = new Set();
1728
+ const normalized = [];
1729
+ for (const entry of entries) {
1730
+ if (entry === null || typeof entry !== "object" || Array.isArray(entry)) {
1731
+ throw new TriggerDeclarationError("each [[function.triggers.database]] entry must be a table");
1732
+ }
1733
+ const table = entry;
1734
+ for (const key of Object.keys(table)) {
1735
+ if (!DATABASE_KEYS.has(key)) {
1736
+ throw new TriggerDeclarationError(`unknown key '${key}' in a [[function.triggers.database]] entry: the ` +
1737
+ `only accepted key is ${[...DATABASE_KEYS].join(", ")}`);
1738
+ }
1739
+ }
1740
+ const type = String(table.type ?? "").trim();
1741
+ if (!type) {
1742
+ throw new TriggerDeclarationError("each [[function.triggers.database]] entry needs a `type`: it names the " +
1743
+ "database type whose writes fire the function");
1744
+ }
1745
+ if (!DATABASE_TYPE_PATTERN.test(type)) {
1746
+ throw new TriggerDeclarationError(`database trigger type '${type}' must contain only letters, digits, ` +
1747
+ "'-' or '_'");
1748
+ }
1749
+ if (seen.has(type)) {
1750
+ throw new TriggerDeclarationError(`duplicate database trigger type '${type}': a function fires once per ` +
1751
+ "write request, so each type is declared once");
1752
+ }
1753
+ seen.add(type);
1754
+ normalized.push({ type });
1755
+ }
1756
+ // Sorted, like the cron entries: two files that differ only in the order
1757
+ // they list their blocks describe the same triggers.
1758
+ normalized.sort((a, b) => a.type.localeCompare(b.type));
1759
+ return normalized;
1760
+ }
1761
+ function normalizeWebhook(raw, options) {
1762
+ if (raw === undefined || raw === null)
1763
+ return null;
1764
+ if (Array.isArray(raw)) {
1765
+ // `[[function.triggers.webhook]]` — the shape that would declare two.
1766
+ throw new TriggerDeclarationError("[function.triggers.webhook] is a single table: one webhook trigger per " +
1767
+ "function (the intent, \"Webhook trigger URL and scheme?\")");
1768
+ }
1769
+ if (typeof raw !== "object") {
1770
+ throw new TriggerDeclarationError("[function.triggers.webhook] must be a table");
1771
+ }
1772
+ const table = raw;
1773
+ for (const key of Object.keys(table)) {
1774
+ if (!WEBHOOK_KEYS.has(key)) {
1775
+ throw new TriggerDeclarationError(`unknown key '${key}' under [function.triggers.webhook]: accepted keys ` +
1776
+ `are ${[...WEBHOOK_KEYS].join(", ")}`);
1777
+ }
1778
+ }
1779
+ // The intent forbids this permanently: a webhook fires the function inside
1780
+ // the receiver, and the provider is holding the connection open waiting for
1781
+ // its answer. A task run has no answer to give inside that request — it
1782
+ // returns a run id and finishes later — so the refusal says so and names
1783
+ // the pattern that does the same job (#3334).
1784
+ if (options.mode === "task") {
1785
+ throw new TriggerDeclarationError(WEBHOOK_ON_TASK_REFUSAL);
1786
+ }
1787
+ const scheme = String(table.verificationScheme ?? "").trim();
1788
+ if (!scheme) {
1789
+ throw new TriggerDeclarationError("[function.triggers.webhook] verificationScheme is required");
1790
+ }
1791
+ if (scheme === "none") {
1792
+ throw new TriggerDeclarationError("verification scheme 'none' is not allowed for function triggers: an " +
1793
+ "unauthenticated caller could run the function");
1794
+ }
1795
+ const declaration = { verificationScheme: scheme };
1796
+ if (table.signingSecret !== undefined) {
1797
+ declaration.signingSecret = String(table.signingSecret);
1798
+ }
1799
+ for (const key of [
1800
+ "toleranceSeconds",
1801
+ "deduplicationWindowMs",
1802
+ "maxBodyBytes",
1803
+ "secretGracePeriodMs",
1804
+ ]) {
1805
+ if (table[key] === undefined)
1806
+ continue;
1807
+ const value = Number(table[key]);
1808
+ if (!Number.isFinite(value)) {
1809
+ throw new TriggerDeclarationError(`[function.triggers.webhook] ${key} must be a number`);
1810
+ }
1811
+ declaration[key] = value;
1812
+ }
1813
+ if (table.deduplicationEnabled !== undefined) {
1814
+ if (typeof table.deduplicationEnabled !== "boolean") {
1815
+ throw new TriggerDeclarationError("[function.triggers.webhook] deduplicationEnabled must be a boolean");
1816
+ }
1817
+ declaration.deduplicationEnabled = table.deduplicationEnabled;
1818
+ }
1819
+ if (table.verification !== undefined) {
1820
+ const verification = table.verification;
1821
+ if (verification === null ||
1822
+ typeof verification !== "object" ||
1823
+ Array.isArray(verification)) {
1824
+ throw new TriggerDeclarationError("[function.triggers.webhook.verification] must be a table");
1825
+ }
1826
+ declaration.verification = jsonSafe(verification);
1827
+ }
1828
+ return declaration;
1829
+ }
1830
+ function normalizeCron(raw, options) {
1831
+ if (raw === undefined || raw === null)
1832
+ return [];
1833
+ const entries = Array.isArray(raw) ? raw : [raw];
1834
+ if (entries.length === 0)
1835
+ return [];
1836
+ // No mode check: a cron entry is legal in EITHER mode (#3334). The intent
1837
+ // allows it ("Sync functions write a run row?": *HTTP and cron allow both*),
1838
+ // and the cron DO starts a durable run for a task target. #3181's refusal
1839
+ // here was release-scoped, written before task starts existed, and its own
1840
+ // comment asked for exactly this one-line removal.
1841
+ if (entries.length > MAX_CRON_TRIGGERS_PER_FUNCTION) {
1842
+ throw new TriggerDeclarationError(`a function may declare at most ${MAX_CRON_TRIGGERS_PER_FUNCTION} cron ` +
1843
+ `trigger entries, and this one declares ${entries.length}`);
1844
+ }
1845
+ const seen = new Set();
1846
+ const normalized = [];
1847
+ for (const entry of entries) {
1848
+ if (entry === null || typeof entry !== "object" || Array.isArray(entry)) {
1849
+ throw new TriggerDeclarationError("each [[function.triggers.cron]] entry must be a table");
1850
+ }
1851
+ const table = entry;
1852
+ for (const key of Object.keys(table)) {
1853
+ if (!CRON_KEYS.has(key)) {
1854
+ throw new TriggerDeclarationError(`unknown key '${key}' in a [[function.triggers.cron]] entry: accepted ` +
1855
+ `keys are ${[...CRON_KEYS].join(", ")}`);
1856
+ }
1857
+ }
1858
+ const name = String(table.name ?? "").trim();
1859
+ if (!name) {
1860
+ throw new TriggerDeclarationError("each [[function.triggers.cron]] entry needs a `name`: it is required " +
1861
+ "because it is half of the trigger's key, `<functionKey>:<name>`");
1862
+ }
1863
+ if (!CRON_NAME_PATTERN.test(name)) {
1864
+ throw new TriggerDeclarationError(`cron trigger name '${name}' must start with a letter or digit and ` +
1865
+ "contain only letters, digits, '-' or '_'");
1866
+ }
1867
+ if (seen.has(name)) {
1868
+ throw new TriggerDeclarationError(`duplicate cron trigger name '${name}': names are the trigger's key ` +
1869
+ "within the function, so each must appear once");
1870
+ }
1871
+ seen.add(name);
1872
+ const cron = String(table.cron ?? "").trim();
1873
+ if (!cron) {
1874
+ throw new TriggerDeclarationError(`cron trigger '${name}' needs a \`cron\` expression`);
1875
+ }
1876
+ const declaration = { name, cron };
1877
+ if (table.timezone !== undefined) {
1878
+ declaration.timezone = String(table.timezone);
1879
+ }
1880
+ if (table.overlapPolicy !== undefined) {
1881
+ const policy = String(table.overlapPolicy);
1882
+ if (!VALID_OVERLAP_POLICIES.includes(policy)) {
1883
+ throw new TriggerDeclarationError(`cron trigger '${name}': overlapPolicy must be one of ` +
1884
+ VALID_OVERLAP_POLICIES.join(", "));
1885
+ }
1886
+ declaration.overlapPolicy = policy;
1887
+ }
1888
+ if (table.rootInput !== undefined) {
1889
+ declaration.rootInput = jsonSafe(table.rootInput);
1890
+ }
1891
+ normalized.push(declaration);
1892
+ }
1893
+ // Sorted by name so the stored declaration is canonical: two files that
1894
+ // differ only in the order they list their entries describe the same
1895
+ // triggers and must produce the same JSON.
1896
+ normalized.sort((a, b) => a.name.localeCompare(b.name));
1897
+ return normalized;
1898
+ }
1899
+ /**
1900
+ * TOML values that JSON cannot carry (dates, and the bigints `smol-toml`
1901
+ * produces for large integers) become their string / number form, so the
1902
+ * stored declaration round-trips through `JSON.stringify` unchanged.
1903
+ */
1904
+ function jsonSafe(value) {
1905
+ if (value === null || value === undefined)
1906
+ return value;
1907
+ if (typeof value === "bigint")
1908
+ return Number(value);
1909
+ if (value instanceof Date)
1910
+ return value.toISOString();
1911
+ if (Array.isArray(value))
1912
+ return value.map(jsonSafe);
1913
+ if (typeof value === "object") {
1914
+ // A smol-toml date-like carries a `toISOString`; anything else that is not
1915
+ // a plain object is stringified rather than silently emitted as `{}`.
1916
+ const anyValue = value;
1917
+ if (typeof anyValue.toISOString === "function") {
1918
+ return String(anyValue.toISOString());
1919
+ }
1920
+ const out = {};
1921
+ for (const [key, child] of Object.entries(anyValue)) {
1922
+ out[key] = jsonSafe(child);
1923
+ }
1924
+ return out;
1925
+ }
1926
+ return value;
1927
+ }
1928
+ /** The stored JSON for `ServerFunctionConfig.triggers`, or null when empty. */
1929
+ export function serializeTriggerDeclaration(declaration) {
1930
+ if (!hasDeclaredTriggers(declaration))
1931
+ return null;
1932
+ return JSON.stringify(declaration);
1933
+ }
1934
+ /** Read a stored `ServerFunctionConfig.triggers` value back. */
1935
+ export function parseStoredTriggerDeclaration(stored) {
1936
+ if (typeof stored !== "string" || !stored.trim()) {
1937
+ return EMPTY_TRIGGER_DECLARATION;
1938
+ }
1939
+ try {
1940
+ const parsed = JSON.parse(stored);
1941
+ return {
1942
+ webhook: parsed?.webhook ?? null,
1943
+ cron: Array.isArray(parsed?.cron) ? parsed.cron : [],
1944
+ // Absent in every row written before #3184, which is exactly what an
1945
+ // empty list means: that version watches no database type.
1946
+ databases: Array.isArray(parsed?.databases) ? parsed.databases : [],
1947
+ };
1948
+ }
1949
+ catch {
1950
+ return EMPTY_TRIGGER_DECLARATION;
1951
+ }
1952
+ }
1953
+ /** The `CronTrigger.triggerKey` an entry owns. */
1954
+ export function cronTriggerKeyFor(functionKey, name) {
1955
+ return `${functionKey}:${name}`;
1956
+ }
1957
+ /** The entry name a `<functionKey>:<name>` trigger key carries. */
1958
+ export function cronTriggerNameFrom(functionKey, triggerKey) {
1959
+ const prefix = `${functionKey}:`;
1960
+ return triggerKey.startsWith(prefix)
1961
+ ? triggerKey.slice(prefix.length)
1962
+ : triggerKey;
1963
+ }
1964
+ // ── src/config-surface/secret-reference.ts ───────────────────────────────
1965
+ /**
1966
+ * The `{{secrets.KEY}}` reference SHAPE, decided from the value alone.
1967
+ *
1968
+ * Split out of `src/services/secret-templates.ts` by #3320 so the one rule that
1969
+ * says whether a stored value IS a whole secret reference has one
1970
+ * implementation on both sides of a `config push`. The CLI preflight refuses a
1971
+ * webhook trigger whose `signingSecret` is a literal before anything is applied,
1972
+ * and the server refuses the same value at the write boundary, because both run
1973
+ * this code — the CLI through the artifact `cli/scripts/gen-config-surfaces.mjs`
1974
+ * renders, the server by importing this module. A shape-only restatement in the
1975
+ * CLI would have been subtly different: a reference spoiled by an invisible
1976
+ * character (#2297) survives `String.trim()` and would have passed a preflight
1977
+ * the server then failed.
1978
+ *
1979
+ * Pure by construction, which is what lets it live here: no secret store is
1980
+ * consulted, because whether a value is a REFERENCE never depends on which keys
1981
+ * exist. Whether the referenced key exists is the server's question, and stays
1982
+ * in `secret-templates.ts` beside the rest of resolution.
1983
+ *
1984
+ * `secret-templates.ts` re-exports everything here, so every existing caller
1985
+ * keeps its import and the two can never be different functions.
1986
+ */
1987
+ // Matches {{secrets.KEY}}. Kept as a named export for existing callers;
1988
+ // `secret-templates.ts`'s generic resolver compiles the same pattern per
1989
+ // namespace.
1990
+ export const SECRETS_TEMPLATE_RE = /\{\{\s*secrets\.([A-Z][A-Z0-9_]{0,63})\s*\}\}/g;
1991
+ /** True when the value carries at least one `{{secrets.KEY}}` reference. */
1992
+ export function isSecretTemplate(value) {
1993
+ if (typeof value !== "string")
1994
+ return false;
1995
+ SECRETS_TEMPLATE_RE.lastIndex = 0;
1996
+ return SECRETS_TEMPLATE_RE.test(value);
1997
+ }
1998
+ /**
1999
+ * The value with every well-formed `{{secrets.KEY}}` reference removed — the
2000
+ * text that was NOT part of a reference the grammar accepts.
2001
+ *
2002
+ * This is the string the leftover-syntax rule below has to judge, and it is
2003
+ * always derived from the ORIGINAL stored value, never from a substituted
2004
+ * result. Resolution is a single `String.replace` pass that never re-scans
2005
+ * replacement text (`resolveMultiNamespaceTemplate`), so a brace or a
2006
+ * `secrets.` token coming out of a SECRET'S VALUE is inert — it is credential
2007
+ * material the operator stored, not config text, and testing the substituted
2008
+ * output would reject it (a Stripe key suffix stored as `}v2{` in an otherwise
2009
+ * valid `sk_live_{{secrets.SUFFIX}}` value).
2010
+ */
2011
+ export function withoutSecretReferences(value) {
2012
+ SECRETS_TEMPLATE_RE.lastIndex = 0;
2013
+ return value.replace(SECRETS_TEMPLATE_RE, "");
2014
+ }
2015
+ /**
2016
+ * Ordinary whitespace: the padding a stored reference is allowed to carry
2017
+ * beside it (#2191). Written as `\s` MINUS U+FEFF — `[^\S\uFEFF]` reads as
2018
+ * "matches `\s` and is not U+FEFF".
2019
+ *
2020
+ * Derived from `\s` on purpose, because `isWholeSecretReference` decides with
2021
+ * `String.trim()` and the two definitions have to be the same set or a
2022
+ * character falls through the seam between them. That is not hypothetical: an
2023
+ * earlier revision of this rule used Unicode's `White_Space` property, whose
2024
+ * symmetric difference from `\s` is two codepoints, one in each direction —
2025
+ * U+FEFF is `\s` but not `White_Space`, and U+0085 NEXT LINE is `White_Space`
2026
+ * but not `\s`. U+0085 was therefore padding here and stray text to `trim()`,
2027
+ * so `<U+0085>{{secrets.KEY}}` was neither spoiled nor a whole reference and
2028
+ * resolved as a mixed legacy literal to U+0085 followed by the secret — an HMAC
2029
+ * key one byte wrong, reported to the operator as `legacy-literal` ("this still
2030
+ * works"). Deriving the set from `\s` closes that by construction rather than
2031
+ * by listing one more character.
2032
+ *
2033
+ * The single deliberate exclusion is U+FEFF: ECMAScript counts the byte-order
2034
+ * mark as whitespace — `String.trim()` and `\s` both absorb it — where Unicode
2035
+ * does not. That accident is what used to let a BOM-spoiled reference resolve
2036
+ * silently while its zero-width siblings failed, and #2297 settles it the other
2037
+ * way: a BOM is stray text beside the reference, not padding. Excluding it here
2038
+ * is what makes it spoil the reference, and `isWholeSecretReference` rejects
2039
+ * such a value before its own `trim()` can absorb it.
2040
+ */
2041
+ const ORDINARY_WHITESPACE_RE = /[^\S\uFEFF]/g;
2042
+ /**
2043
+ * A character we can affirmatively recognize as credential material: printable
2044
+ * ASCII, space excluded.
2045
+ *
2046
+ * Stated as an ALLOWLIST, for the reason `containsReferenceSyntax` gives at
2047
+ * length: a denylist cannot close a class. The tempting denylist here is
2048
+ * "characters that render as nothing", and no Unicode category names it —
2049
+ * general category Cf holds U+200B/U+200C/U+200D/U+2060/U+FEFF but not U+2800
2050
+ * BRAILLE PATTERN BLANK (So), U+FE0F or U+034F (Mn), or U+3164 / U+FFA0, the
2051
+ * Hangul fillers (Lo). An allowlist has no such gap: every character nobody
2052
+ * thought of lands on the fail-closed side by construction.
2053
+ *
2054
+ * Every credential shape this boundary carries is built from printable ASCII —
2055
+ * base64 and base64url, hex, `whsec_…`, `sk_live_…`, `GOCSPX-…`. The cost of
2056
+ * the allowlist is the case on the other side of that: a genuine mixed legacy
2057
+ * value whose literal part is entirely non-ASCII would fail closed instead of
2058
+ * resolving. That is the asymmetry `containsReferenceSyntax` already accepts —
2059
+ * one clear, remediable failure on a row that has to be migrated anyway,
2060
+ * against a credential silently keyed one byte wrong.
2061
+ */
2062
+ const CREDENTIAL_CHARACTER_RE = /[\x21-\x7E]/;
2063
+ /**
2064
+ * True when the value holds a well-formed `{{secrets.KEY}}` reference and the
2065
+ * text beside it is not credential material — a reference SPOILED by an
2066
+ * invisible character, rather than a legacy literal that happens to contain one
2067
+ * (#2297, consolidating #2386).
2068
+ *
2069
+ * Such a value is a reference spoiled by an authoring mistake — pasted out of
2070
+ * an editor or a document that carried a zero-width character along with it —
2071
+ * and it must fail closed rather than resolve. Before this rule a value spelled
2072
+ * `<U+200B>{{secrets.KEY}}` was not a whole reference (neither `\s` nor
2073
+ * `String.trim()` matches U+200B), carried no leftover reference SYNTAX once
2074
+ * the template was removed, and so classified as a working legacy literal that
2075
+ * resolved to U+200B followed by the secret: an HMAC key silently one byte
2076
+ * wrong, and a read surface pointing the operator at the wrong remediation.
2077
+ *
2078
+ * The rule judges the REMAINDER — the value with its well-formed references
2079
+ * taken out — against an allowlist, which is what makes the class closed:
2080
+ *
2081
+ * - Remainder empty, or ordinary whitespace only: the value is the reference it
2082
+ * plainly is, padding and all, and keeps resolving (#2191).
2083
+ * - Remainder carries at least one credential character: a genuine mixed
2084
+ * literal-plus-reference value (`sk_live_{{secrets.SUFFIX}}`), which predates
2085
+ * reference-only and keeps resolving as shipped (#2332) — including when the
2086
+ * operator's own bytes contain an invisible character, because that is
2087
+ * credential material rather than a spoiled pointer.
2088
+ * - Anything else: text that is present but that we cannot recognize as
2089
+ * credential material. Fail closed.
2090
+ *
2091
+ * Failing closed rather than stripping the character is the deliberate choice
2092
+ * (sponsor decision on #2297): stripping hides the mistake, where a
2093
+ * `malformed-reference` names it at the moment the value is written.
2094
+ *
2095
+ * A value with NO well-formed reference is not this rule's business, and needs
2096
+ * no separate handling: a pure literal is credential material the platform has
2097
+ * no standing to judge (`whsec_<U+200B>raw`), and a MISTYPED reference —
2098
+ * including one spoiled inside the braces, `{{secrets.<U+200B>KEY}}` — leaves
2099
+ * its syntax in the remainder and is already caught by
2100
+ * `carriesMalformedReferenceSyntax` in `secret-templates.ts`.
2101
+ *
2102
+ * Scoped to the reference-only credential boundary, not to
2103
+ * `resolveSecretTemplate`: an integration proxy header is a template by design
2104
+ * (`Authorization: Bearer {{secrets.TOKEN}}`), so it keeps resolving exactly
2105
+ * what the operator wrote.
2106
+ */
2107
+ export function isSpoiledSecretReference(value) {
2108
+ if (typeof value !== "string")
2109
+ return false;
2110
+ if (!isSecretTemplate(value))
2111
+ return false;
2112
+ // `String.replace` with a global regex always scans from 0 and resets
2113
+ // `lastIndex`, so the shared pattern carries no state between calls.
2114
+ const remainder = withoutSecretReferences(value).replace(ORDINARY_WHITESPACE_RE, "");
2115
+ if (remainder.length === 0)
2116
+ return false;
2117
+ return !CREDENTIAL_CHARACTER_RE.test(remainder);
2118
+ }
2119
+ /**
2120
+ * Matches a value that is EXACTLY one `{{secrets.KEY}}` reference and nothing
2121
+ * else. Anchored, and deliberately not global — `SECRETS_TEMPLATE_RE` carries
2122
+ * `lastIndex` state between calls.
2123
+ */
2124
+ export const WHOLE_SECRET_REFERENCE_RE = /^\{\{\s*secrets\.([A-Z][A-Z0-9_]{0,63})\s*\}\}$/;
2125
+ /**
2126
+ * True when the whole (trimmed) value is a single `{{secrets.KEY}}` reference.
2127
+ *
2128
+ * The stricter sibling of `isSecretTemplate`, for the callers that use "is a
2129
+ * reference" to mean "carries no secret material of its own". `isSecretTemplate`
2130
+ * is a *contains* test, so `"sk_live_abcd{{secrets.SUFFIX}}"` satisfies it —
2131
+ * which is fine where a value is a template to be resolved (an
2132
+ * `Authorization: Bearer {{secrets.TOKEN}}` header is exactly that), and wrong
2133
+ * where the value is a credential that must live entirely in the encrypted
2134
+ * secret store. Used by the webhook `config` credential rule and by the
2135
+ * redaction that backs it: a mixed value would otherwise pass the write rule
2136
+ * AND skip redaction, storing and echoing most of a working credential in
2137
+ * cleartext.
2138
+ *
2139
+ * Trimmed, so `" {{secrets.KEY}} "` is accepted as the reference it plainly is;
2140
+ * two references, or a reference with any literal text beside it, are not.
2141
+ *
2142
+ * Invisible text beside the reference disqualifies the value before the trim
2143
+ * (#2297): U+FEFF is stripped by `String.trim()` and matched by `\s`, so
2144
+ * without the check a byte-order mark beside the braces would be silently
2145
+ * accepted while its zero-width siblings were not. Rejecting here is what makes
2146
+ * the write gates built on this predicate (`validateWholeSecretReference`, the
2147
+ * webhook `config` credential rule) refuse such a value at configuration time
2148
+ * rather than storing a row that can only fail at use time.
2149
+ */
2150
+ export function isWholeSecretReference(value) {
2151
+ if (typeof value !== "string")
2152
+ return false;
2153
+ if (isSpoiledSecretReference(value))
2154
+ return false;
2155
+ return WHOLE_SECRET_REFERENCE_RE.test(value.trim());
2156
+ }
2157
+ // ── src/config-surface/webhook-signing-secret.ts ─────────────────────────
2158
+ /**
2159
+ * The `signingSecret` rule a webhook's verification scheme carries, decided
2160
+ * from the declaration alone (#3320).
2161
+ *
2162
+ * `signingSecret` is reference-only (#2254): a whole `{{secrets.KEY}}`
2163
+ * reference naming an app secret, and nothing else. Which schemes need one,
2164
+ * whether one was supplied, and whether the supplied value is a reference are
2165
+ * all answerable from the file — so they belong in the `config push` preflight,
2166
+ * which aborts before anything is applied, rather than in the apply loop where
2167
+ * a refusal lands after sibling entities are already written.
2168
+ *
2169
+ * That is the whole reason this module exists here rather than beside the rest
2170
+ * of the webhook write pipeline: `src/config-surface/` is vendored verbatim
2171
+ * into `cli/src/lib/generated-config-surfaces.ts`, so the CLI runs the server's
2172
+ * rule instead of a restatement of it, and a change that is not regenerated
2173
+ * fails `gen-config-surfaces.mjs --check`.
2174
+ *
2175
+ * ── What is deliberately NOT here ─────────────────────────────────────────
2176
+ *
2177
+ * Everything that needs the app's state: whether the referenced secret exists,
2178
+ * whether the scheme's `config` is valid under this environment's JWKS policy,
2179
+ * the per-app row cap, a key a standalone webhook already holds. Those stay in
2180
+ * `src/app-api/services/webhook-settings-validation.ts`, which has `Env`. A
2181
+ * local copy of them would be a preflight that gives a false all-clear — and
2182
+ * the residue is then app-state-dependent by construction rather than by
2183
+ * accident of where a rule happened to live.
2184
+ */
2185
+ /**
2186
+ * Schemes that don't carry an HMAC `signingSecret`. These either store no key
2187
+ * material at all (`none`) or store it under `AppWebhook.config` instead:
2188
+ * `discord` puts the application public key in `config.publicKey`, `jwt` puts
2189
+ * a JWKS in `config.jwt.jwks`, and `plaid` fetches keys from Plaid using the
2190
+ * API credentials referenced in `config.plaid`.
2191
+ */
2192
+ export const SCHEMES_WITHOUT_SIGNING_SECRET = [
2193
+ "none",
2194
+ "discord",
2195
+ "jwt",
2196
+ "plaid",
2197
+ ];
2198
+ export function requiresSigningSecret(scheme) {
2199
+ return (typeof scheme === "string" &&
2200
+ !SCHEMES_WITHOUT_SIGNING_SECRET.includes(scheme));
2201
+ }
2202
+ /** The coded refusals, so a client can branch without matching prose. */
2203
+ export const SIGNING_SECRET_MUST_BE_SECRET_REF = "SIGNING_SECRET_MUST_BE_SECRET_REF";
2204
+ export const SIGNING_SECRET_NOT_SUPPORTED_FOR_SCHEME = "SIGNING_SECRET_NOT_SUPPORTED_FOR_SCHEME";
2205
+ /** The reference-only message, shared by every field that carries one. */
2206
+ export function wholeSecretReferenceMessage(label) {
2207
+ return (`${label} must be a whole {{secrets.KEY}} reference to an app secret, not ` +
2208
+ "a literal value — secrets live only in the encrypted secret store. Store " +
2209
+ `the value (with \`primitive secrets set KEY --value <value>\` or the ` +
2210
+ `secrets API) and set ${label} to \`{{secrets.KEY}}\`.`);
2211
+ }
2212
+ /**
2213
+ * Is this value a whole `{{secrets.KEY}}` reference? Returns the refusal a
2214
+ * caller renders, or null.
2215
+ *
2216
+ * `isWholeSecretReference`, never `isSecretTemplate`: a *contains* test admits
2217
+ * `sk_live_abcd{{secrets.SUFFIX}}`, which stores most of a working credential
2218
+ * in cleartext.
2219
+ */
2220
+ export function validateWholeSecretReference(label, value, code) {
2221
+ if (typeof value === "string" && isWholeSecretReference(value))
2222
+ return null;
2223
+ return { message: wholeSecretReferenceMessage(label), code };
2224
+ }
2225
+ /**
2226
+ * The whole file-decidable `signingSecret` rule, in the order the standalone
2227
+ * webhook create ran it: a required secret must be present, a present one must
2228
+ * be a whole reference, and a scheme that carries no secret must not be given
2229
+ * one.
2230
+ *
2231
+ * Returns null when the declaration is acceptable — which does NOT mean the
2232
+ * write will succeed: the referenced key still has to exist, and that is the
2233
+ * server's question.
2234
+ */
2235
+ export function validateSigningSecretDeclaration(input) {
2236
+ const scheme = input.verificationScheme;
2237
+ if (requiresSigningSecret(scheme)) {
2238
+ if (!input.signingSecret) {
2239
+ return {
2240
+ message: `signingSecret is required for verification scheme '${scheme}'`,
2241
+ plain: true,
2242
+ };
2243
+ }
2244
+ return validateWholeSecretReference("signingSecret", input.signingSecret, SIGNING_SECRET_MUST_BE_SECRET_REF);
2245
+ }
2246
+ if (input.signingSecret !== undefined &&
2247
+ input.signingSecret !== null &&
2248
+ input.signingSecret !== "") {
2249
+ return {
2250
+ message: `signingSecret is not supported for verification scheme '${scheme}' — ` +
2251
+ "its key material comes from the scheme's config.",
2252
+ code: SIGNING_SECRET_NOT_SUPPORTED_FOR_SCHEME,
2253
+ };
2254
+ }
2255
+ return null;
2256
+ }
1444
2257
  // ── src/config-surface/workflow.ts ───────────────────────────────────────
1445
2258
  /**
1446
2259
  * The `workflow` configuration object's definition (issue #2644, phase 1).
@@ -2331,6 +3144,10 @@ export const INTEGRATION_SURFACE = {
2331
3144
  */
2332
3145
  const WEBHOOK_HANDLER = "src/admin-api.ts";
2333
3146
  const WEBHOOK_CONFIG_VALIDATION = "src/app-api/services/webhook-verification/config-validation.ts";
3147
+ // #3320 — the reference-only `signingSecret` rule is decidable from the value
3148
+ // alone, so it moved beside the definitions where `config push`'s preflight can
3149
+ // run the server's own copy of it.
3150
+ const WEBHOOK_SIGNING_SECRET_RULE = "src/config-surface/webhook-signing-secret.ts";
2334
3151
  export const WEBHOOK_SURFACE = {
2335
3152
  label: "webhook",
2336
3153
  tables: [
@@ -2424,7 +3241,7 @@ export const WEBHOOK_SURFACE = {
2424
3241
  type: "string",
2425
3242
  emit: "whenSet",
2426
3243
  writableOn: BOTH,
2427
- validation: handledBy(WEBHOOK_CONFIG_VALIDATION, "validateWholeSecretReference"),
3244
+ validation: handledBy(WEBHOOK_SIGNING_SECRET_RULE, "validateWholeSecretReference"),
2428
3245
  },
2429
3246
  ],
2430
3247
  notExposed: {
@@ -3794,7 +4611,7 @@ export const TRANSFORM_SURFACE = {
3794
4611
  * config version (`ServerFunctionConfig`), but it is ONE table in the file: an
3795
4612
  * author does not write a version, they write a function and push it. The
3796
4613
  * definition therefore declares the header's field surface and lists the
3797
- * version-carried keys (`entry`, `durable`, `limits`) in `tomlOnlyKeys` beside
4614
+ * version-carried keys (`entry`, `mode`, `limits`) in `tomlOnlyKeys` beside
3798
4615
  * `key` — they are authored, they are not fields of the header model, and they
3799
4616
  * travel on the config-version route rather than in the header body. Declaring
3800
4617
  * a second table over `ServerFunctionConfig` at the same `tomlPath` is not
@@ -3947,11 +4764,23 @@ export const SERVER_FUNCTION_SURFACE = {
3947
4764
  "it travels on the config-version push together with the built " +
3948
4765
  "bundle and the authored sources.",
3949
4766
  },
4767
+ mode: {
4768
+ kind: "structural",
4769
+ note: '`mode = "request" | "task"` (#3281): a request function runs ' +
4770
+ "inside the call and returns its result; a task function returns " +
4771
+ "a run id and survives restarts (#3186). Stored on the config " +
4772
+ "version (as its `durable` column) and pushed with it, so it is " +
4773
+ "versioned with the code it describes rather than mutable on the " +
4774
+ "header. Absent means request.",
4775
+ },
3950
4776
  durable: {
3951
4777
  kind: "structural",
3952
- note: "Whether this version runs durably (#3186). Stored on the config " +
3953
- "version and pushed with it, so it is versioned with the code it " +
3954
- "describes rather than mutable on the header.",
4778
+ note: 'The accepted ALIAS of `mode` through the transition (#3281): ' +
4779
+ '`durable = true` is `mode = "task"` and `durable = false` is ' +
4780
+ '`mode = "request"`. When both keys are present they must agree, ' +
4781
+ "or the file is refused naming both. Kept as the adjective for the " +
4782
+ "property; `config pull` writes the authored bytes back unchanged, " +
4783
+ "so a file that says it keeps saying it until its author edits it.",
3955
4784
  },
3956
4785
  limits: {
3957
4786
  kind: "structural",