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.
- package/README.md +38 -17
- package/assets/skill/skills/primitive-platform/SKILL.md +39 -52
- package/dist/bin/primitive.js +14 -25
- package/dist/bin/primitive.js.map +1 -1
- package/dist/src/commands/admins.js +8 -18
- package/dist/src/commands/admins.js.map +1 -1
- package/dist/src/commands/analytics.js +46 -5
- package/dist/src/commands/analytics.js.map +1 -1
- package/dist/src/commands/auth.js +16 -1
- package/dist/src/commands/auth.js.map +1 -1
- package/dist/src/commands/collection-type-configs.js +1 -9
- package/dist/src/commands/collection-type-configs.js.map +1 -1
- package/dist/src/commands/config.js +11 -29
- package/dist/src/commands/config.js.map +1 -1
- package/dist/src/commands/database-type-configs.js +1 -9
- package/dist/src/commands/database-type-configs.js.map +1 -1
- package/dist/src/commands/databases.js +8 -38
- package/dist/src/commands/databases.js.map +1 -1
- package/dist/src/commands/env.js +27 -32
- package/dist/src/commands/env.js.map +1 -1
- package/dist/src/commands/functions.js +197 -23
- package/dist/src/commands/functions.js.map +1 -1
- package/dist/src/commands/groups.js +1 -9
- package/dist/src/commands/groups.js.map +1 -1
- package/dist/src/commands/init.js +6 -0
- package/dist/src/commands/init.js.map +1 -1
- package/dist/src/commands/rule-sets.js +1 -9
- package/dist/src/commands/rule-sets.js.map +1 -1
- package/dist/src/commands/scripts.js +12 -28
- package/dist/src/commands/scripts.js.map +1 -1
- package/dist/src/commands/sync.d.ts +119 -0
- package/dist/src/commands/sync.js +387 -196
- package/dist/src/commands/sync.js.map +1 -1
- package/dist/src/commands/workflows.js +2 -14
- package/dist/src/commands/workflows.js.map +1 -1
- package/dist/src/lib/api-client.d.ts +46 -11
- package/dist/src/lib/api-client.js +21 -11
- package/dist/src/lib/api-client.js.map +1 -1
- package/dist/src/lib/codegen-shared/resolveCodegenSourceDir.d.ts +24 -57
- package/dist/src/lib/codegen-shared/resolveCodegenSourceDir.js +30 -204
- package/dist/src/lib/codegen-shared/resolveCodegenSourceDir.js.map +1 -1
- package/dist/src/lib/config-json-field.d.ts +28 -0
- package/dist/src/lib/config-json-field.js +56 -0
- package/dist/src/lib/config-json-field.js.map +1 -0
- package/dist/src/lib/config-object-descriptor.js +4 -3
- package/dist/src/lib/config-object-descriptor.js.map +1 -1
- package/dist/src/lib/config-surface.js +1 -1
- package/dist/src/lib/config-surface.js.map +1 -1
- package/dist/src/lib/config.d.ts +21 -14
- package/dist/src/lib/config.js +25 -44
- package/dist/src/lib/config.js.map +1 -1
- package/dist/src/lib/credentials-store.d.ts +31 -8
- package/dist/src/lib/credentials-store.js +52 -13
- package/dist/src/lib/credentials-store.js.map +1 -1
- package/dist/src/lib/env-resolver-core.d.ts +18 -0
- package/dist/src/lib/env-resolver-core.js +26 -3
- package/dist/src/lib/env-resolver-core.js.map +1 -1
- package/dist/src/lib/env-resolver.d.ts +15 -0
- package/dist/src/lib/env-resolver.js +21 -1
- package/dist/src/lib/env-resolver.js.map +1 -1
- package/dist/src/lib/function-collect.d.ts +1 -1
- package/dist/src/lib/function-collect.js +138 -7
- package/dist/src/lib/function-collect.js.map +1 -1
- package/dist/src/lib/function-db-types.d.ts +49 -11
- package/dist/src/lib/function-db-types.js +234 -39
- package/dist/src/lib/function-db-types.js.map +1 -1
- package/dist/src/lib/function-document-types.d.ts +50 -0
- package/dist/src/lib/function-document-types.js +163 -0
- package/dist/src/lib/function-document-types.js.map +1 -0
- package/dist/src/lib/function-log-tail.d.ts +83 -0
- package/dist/src/lib/function-log-tail.js +115 -0
- package/dist/src/lib/function-log-tail.js.map +1 -0
- package/dist/src/lib/function-schema-codegen.d.ts +118 -0
- package/dist/src/lib/function-schema-codegen.js +402 -0
- package/dist/src/lib/function-schema-codegen.js.map +1 -0
- package/dist/src/lib/function-sync.d.ts +85 -2
- package/dist/src/lib/function-sync.js +196 -27
- package/dist/src/lib/function-sync.js.map +1 -1
- package/dist/src/lib/function-triggers.d.ts +9 -3
- package/dist/src/lib/function-triggers.js +12 -9
- package/dist/src/lib/function-triggers.js.map +1 -1
- package/dist/src/lib/function-typecheck.d.ts +86 -0
- package/dist/src/lib/function-typecheck.js +370 -0
- package/dist/src/lib/function-typecheck.js.map +1 -0
- package/dist/src/lib/generated-allowlist.js +4 -0
- package/dist/src/lib/generated-allowlist.js.map +1 -1
- package/dist/src/lib/generated-config-surfaces.d.ts +422 -0
- package/dist/src/lib/generated-config-surfaces.js +838 -9
- 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 +116 -4
- package/dist/src/lib/log-inspection.js +147 -2
- package/dist/src/lib/log-inspection.js.map +1 -1
- package/dist/src/lib/snapshots.d.ts +6 -7
- package/dist/src/lib/snapshots.js +18 -81
- package/dist/src/lib/snapshots.js.map +1 -1
- package/dist/src/lib/sync-paths.d.ts +29 -53
- package/dist/src/lib/sync-paths.js +46 -97
- package/dist/src/lib/sync-paths.js.map +1 -1
- package/dist/src/lib/workflow-toml-validator.d.ts +8 -3
- package/dist/src/lib/workflow-toml-validator.js +8 -3
- package/dist/src/lib/workflow-toml-validator.js.map +1 -1
- 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
|
-
...(
|
|
1457
|
+
...(declaredType !== undefined ? { type: declaredType } : {}),
|
|
1458
|
+
...(items !== undefined ? { items } : {}),
|
|
1370
1459
|
...(spec.caller === true ? { caller: true } : {}),
|
|
1371
1460
|
...(spec.optional === true ? { optional: true } : {}),
|
|
1372
|
-
...(
|
|
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(
|
|
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`, `
|
|
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:
|
|
3953
|
-
|
|
3954
|
-
"
|
|
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",
|