@intentius/chant 0.122.0 → 0.123.0
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/dist/cli/commands/lint.d.ts.map +1 -1
- package/dist/cli/handlers/gate-resume.d.ts +1 -1
- package/dist/cli/handlers/gate-resume.d.ts.map +1 -1
- package/dist/cli/handlers/operator.d.ts +1 -1
- package/dist/cli/handlers/operator.d.ts.map +1 -1
- package/dist/cli/handlers/override-approve.d.ts +17 -0
- package/dist/cli/handlers/override-approve.d.ts.map +1 -0
- package/dist/cli/handlers/run-wave.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +4 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/gate-decision.d.ts +157 -0
- package/dist/gate-decision.d.ts.map +1 -0
- package/dist/lifecycle/gate-applied.d.ts +75 -0
- package/dist/lifecycle/gate-applied.d.ts.map +1 -0
- package/dist/lifecycle/gate-audit.d.ts +145 -0
- package/dist/lifecycle/gate-audit.d.ts.map +1 -0
- package/dist/lifecycle/gate-ledger-select.d.ts +54 -0
- package/dist/lifecycle/gate-ledger-select.d.ts.map +1 -0
- package/dist/lifecycle/gate-ledger.d.ts +1 -45
- package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
- package/dist/lifecycle/override-ledger.d.ts +34 -0
- package/dist/lifecycle/override-ledger.d.ts.map +1 -0
- package/dist/lifecycle/rollback.d.ts.map +1 -1
- package/dist/lifecycle/work-item-id.d.ts +22 -0
- package/dist/lifecycle/work-item-id.d.ts.map +1 -0
- package/dist/lifecycle/work-lease.d.ts +2 -2
- package/dist/lifecycle/work-lease.d.ts.map +1 -1
- package/dist/lint/post-synth.d.ts +20 -1
- package/dist/lint/post-synth.d.ts.map +1 -1
- package/dist/notify-verify.d.ts +62 -0
- package/dist/notify-verify.d.ts.map +1 -0
- package/dist/notify.d.ts +202 -0
- package/dist/notify.d.ts.map +1 -0
- package/dist/op/activities/activity-contracts.d.ts +4 -4
- package/dist/op/activities/gh-host.d.ts +37 -0
- package/dist/op/activities/gh-host.d.ts.map +1 -0
- package/dist/op/activities/lexicon-upgrade.d.ts +11 -0
- package/dist/op/activities/lexicon-upgrade.d.ts.map +1 -1
- package/dist/op/activities/propose-upgrade.d.ts.map +1 -1
- package/dist/op/activities/reconcile.d.ts +74 -76
- package/dist/op/activities/reconcile.d.ts.map +1 -1
- package/dist/op/gate-approval.d.ts +17 -1
- package/dist/op/gate-approval.d.ts.map +1 -1
- package/dist/op/gate-name.d.ts +8 -1
- package/dist/op/gate-name.d.ts.map +1 -1
- package/dist/op/gate-resume.d.ts +4 -2
- package/dist/op/gate-resume.d.ts.map +1 -1
- package/dist/op/gate-review.d.ts +2 -1
- package/dist/op/gate-review.d.ts.map +1 -1
- package/dist/op/gate.d.ts +43 -6
- package/dist/op/gate.d.ts.map +1 -1
- package/dist/op/index.d.ts +8 -6
- package/dist/op/index.d.ts.map +1 -1
- package/dist/op/op-waves-notify.d.ts +31 -0
- package/dist/op/op-waves-notify.d.ts.map +1 -0
- package/dist/op/op-waves-run.d.ts +91 -16
- package/dist/op/op-waves-run.d.ts.map +1 -1
- package/dist/op/op-waves.d.ts +99 -2
- package/dist/op/op-waves.d.ts.map +1 -1
- package/dist/op/operator.d.ts +15 -0
- package/dist/op/operator.d.ts.map +1 -1
- package/dist/op/work-lease-decl.d.ts.map +1 -1
- package/dist/op/work-lease-run.d.ts.map +1 -1
- package/dist/override-gate.d.ts +224 -0
- package/dist/override-gate.d.ts.map +1 -0
- package/dist/pr-forge.d.ts +198 -19
- package/dist/pr-forge.d.ts.map +1 -1
- package/dist/workspace/status-gates.d.ts +1 -0
- package/dist/workspace/status-gates.d.ts.map +1 -1
- package/dist/workspace/trust/gate-seal.d.ts +96 -0
- package/dist/workspace/trust/gate-seal.d.ts.map +1 -0
- package/dist/workspace/trust/policy.d.ts +2 -41
- package/dist/workspace/trust/policy.d.ts.map +1 -1
- package/dist/workspace/trust/seal.d.ts +2 -27
- package/dist/workspace/trust/seal.d.ts.map +1 -1
- package/dist/workspace/trust/trust-files.d.ts +57 -0
- package/dist/workspace/trust/trust-files.d.ts.map +1 -0
- package/dist/workspace/work-cli.d.ts.map +1 -1
- package/package.json +2 -1
- package/src/cli/commands/build.ts +1 -1
- package/src/cli/commands/lint.ts +5 -2
- package/src/cli/handlers/__snapshots__/build-member.test.ts.snap +2 -2
- package/src/cli/handlers/gate-resume.ts +3 -2
- package/src/cli/handlers/operator.test.ts +12 -1
- package/src/cli/handlers/operator.ts +25 -13
- package/src/cli/handlers/override-approve.test.ts +87 -0
- package/src/cli/handlers/override-approve.ts +103 -0
- package/src/cli/handlers/run-wave.ts +10 -2
- package/src/cli/main.test.ts +8 -0
- package/src/cli/main.ts +15 -0
- package/src/cli/registry.ts +4 -0
- package/src/gate-decision-bundle.test.ts +56 -0
- package/src/gate-decision.test.ts +237 -0
- package/src/gate-decision.ts +321 -0
- package/src/lifecycle/gate-applied.ts +115 -0
- package/src/lifecycle/gate-audit-bundle.test.ts +47 -0
- package/src/lifecycle/gate-audit.test.ts +134 -0
- package/src/lifecycle/gate-audit.ts +438 -0
- package/src/lifecycle/gate-ledger-select.ts +113 -0
- package/src/lifecycle/gate-ledger.test.ts +22 -0
- package/src/lifecycle/gate-ledger.ts +9 -104
- package/src/lifecycle/override-ledger.test.ts +63 -0
- package/src/lifecycle/override-ledger.ts +69 -0
- package/src/lifecycle/rollback.ts +5 -1
- package/src/lifecycle/work-item-id.test.ts +71 -0
- package/src/lifecycle/work-item-id.ts +35 -0
- package/src/lifecycle/work-lease.ts +4 -5
- package/src/lint/post-synth.test.ts +29 -0
- package/src/lint/post-synth.ts +21 -2
- package/src/notify-bundle.test.ts +50 -0
- package/src/notify-verify.test.ts +59 -0
- package/src/notify-verify.ts +93 -0
- package/src/notify.schema.json +72 -0
- package/src/notify.test.ts +279 -0
- package/src/notify.ts +398 -0
- package/src/op/activities/gh-host.test.ts +130 -0
- package/src/op/activities/gh-host.ts +79 -0
- package/src/op/activities/lexicon-upgrade.ts +22 -1
- package/src/op/activities/propose-upgrade.ts +5 -1
- package/src/op/activities/reconcile.test.ts +284 -280
- package/src/op/activities/reconcile.ts +128 -131
- package/src/op/gate-approval.test.ts +175 -7
- package/src/op/gate-approval.ts +27 -1
- package/src/op/gate-name.ts +10 -2
- package/src/op/gate-resume.ts +6 -3
- package/src/op/gate-review.ts +4 -7
- package/src/op/gate.ts +117 -16
- package/src/op/index.ts +20 -5
- package/src/op/op-waves-notify.test.ts +127 -0
- package/src/op/op-waves-notify.ts +101 -0
- package/src/op/op-waves-run.ts +527 -104
- package/src/op/op-waves.test.ts +383 -3
- package/src/op/op-waves.ts +187 -6
- package/src/op/operator.test.ts +29 -0
- package/src/op/operator.ts +27 -2
- package/src/op/run-context.test.ts +2 -2
- package/src/op/work-lease-decl.ts +4 -4
- package/src/op/work-lease-run.test.ts +5 -0
- package/src/op/work-lease-run.ts +4 -3
- package/src/override-gate-bundle.test.ts +47 -0
- package/src/override-gate.test.ts +239 -0
- package/src/override-gate.ts +395 -0
- package/src/pr-forge-bundle.test.ts +40 -0
- package/src/pr-forge.test.ts +303 -0
- package/src/pr-forge.ts +502 -47
- package/src/workspace/records-write-contract.test.ts +3 -2
- package/src/workspace/status-gates.ts +7 -4
- package/src/workspace/trust/gate-seal.test.ts +156 -0
- package/src/workspace/trust/gate-seal.ts +254 -0
- package/src/workspace/trust/policy.ts +10 -165
- package/src/workspace/trust/seal.ts +19 -34
- package/src/workspace/trust/trust-files.test.ts +50 -0
- package/src/workspace/trust/trust-files.ts +245 -0
- package/src/workspace/work-cli.ts +5 -3
- package/src/workspace/work-history.test.ts +7 -0
|
@@ -42,13 +42,21 @@
|
|
|
42
42
|
* `"resolution"` is the default reading).
|
|
43
43
|
*/
|
|
44
44
|
import { isNeverOverMcpGateOp } from "../op/gate-name";
|
|
45
|
-
import { samePlanDigest } from "./plan-digest";
|
|
46
45
|
import { sortedJsonReplacer } from "../utils";
|
|
47
46
|
import { currentGateOrigin, type GateOrigin } from "./gate-origin";
|
|
48
47
|
import type { GateApprover, GatePolicyDecision, ResolvedGateApproval } from "../op/gate-approval";
|
|
49
48
|
import type { GateRunLocator } from "../op/gate-resume";
|
|
50
49
|
import { readBlobFromPath, readPathSha, readBlobBySha, writeBlobToPath, RefCASConflictError } from "./git";
|
|
51
50
|
|
|
51
|
+
// The selection rules a gate decides by live apart, so a bundle can import them without git (#3763).
|
|
52
|
+
export {
|
|
53
|
+
isPendingGateExpired,
|
|
54
|
+
latestPendingGate,
|
|
55
|
+
latestResolutionForPlan,
|
|
56
|
+
latestResolutionSince,
|
|
57
|
+
type PlanBoundResolution,
|
|
58
|
+
} from "./gate-ledger-select";
|
|
59
|
+
|
|
52
60
|
const DIR = "_gates";
|
|
53
61
|
|
|
54
62
|
/** The directory on the `chant/lifecycle` branch that holds gate ledgers, under a member's prefix when it has one. */
|
|
@@ -337,11 +345,6 @@ export type GateLedgerRecord = GateResolutionRecord | PendingGateRecord;
|
|
|
337
345
|
/** The default a gate's pending fact expires after when its `GateStep` declares no `timeout` — the same 48h `GateStep.timeout` documents as its own default. */
|
|
338
346
|
export const DEFAULT_GATE_EXPIRY = "48h";
|
|
339
347
|
|
|
340
|
-
/** Whether a pending fact has aged out of relevance at `nowIso`. */
|
|
341
|
-
export function isPendingGateExpired(record: PendingGateRecord, nowIso: string): boolean {
|
|
342
|
-
return new Date(record.expiresAt).getTime() <= new Date(nowIso).getTime();
|
|
343
|
-
}
|
|
344
|
-
|
|
345
348
|
function filename(op: string): string {
|
|
346
349
|
return `${op}.jsonl`;
|
|
347
350
|
}
|
|
@@ -504,101 +507,3 @@ export async function readGateResolutions(
|
|
|
504
507
|
const { resolutions, malformed } = await readGateLedger(op, opts);
|
|
505
508
|
return { records: resolutions, malformed };
|
|
506
509
|
}
|
|
507
|
-
|
|
508
|
-
/** The most recent pending fact for `gate`, expired or not — the anchor {@link latestResolutionSince} measures a resolution against. `undefined` when the gate has never been recorded pending. */
|
|
509
|
-
export function latestPendingGate(
|
|
510
|
-
records: PendingGateRecord[],
|
|
511
|
-
gate: string,
|
|
512
|
-
): PendingGateRecord | undefined {
|
|
513
|
-
let latest: PendingGateRecord | undefined;
|
|
514
|
-
for (const r of records) {
|
|
515
|
-
if (r.gate !== gate) continue;
|
|
516
|
-
if (!latest || new Date(r.timestamp).getTime() >= new Date(latest.timestamp).getTime()) latest = r;
|
|
517
|
-
}
|
|
518
|
-
return latest;
|
|
519
|
-
}
|
|
520
|
-
|
|
521
|
-
/** The most recent resolution for `gate` recorded after `sinceIso` (a gated tick's own timestamp) — what `chant operator status` uses to tell a resolved gate from a still-pending one. `undefined` when no such resolution exists. */
|
|
522
|
-
export function latestResolutionSince(
|
|
523
|
-
records: GateResolutionRecord[],
|
|
524
|
-
gate: string,
|
|
525
|
-
sinceIso: string,
|
|
526
|
-
): GateResolutionRecord | undefined {
|
|
527
|
-
const since = new Date(sinceIso).getTime();
|
|
528
|
-
let latest: GateResolutionRecord | undefined;
|
|
529
|
-
for (const r of records) {
|
|
530
|
-
if (r.gate !== gate) continue;
|
|
531
|
-
if (new Date(r.timestamp).getTime() < since) continue;
|
|
532
|
-
if (!latest || new Date(r.timestamp).getTime() >= new Date(latest.timestamp).getTime()) latest = r;
|
|
533
|
-
}
|
|
534
|
-
return latest;
|
|
535
|
-
}
|
|
536
|
-
|
|
537
|
-
/** What {@link latestResolutionForPlan} found for the plan a run is holding. */
|
|
538
|
-
export interface PlanBoundResolution {
|
|
539
|
-
/** The resolution that answers this gate for this plan. Absent when nothing does. */
|
|
540
|
-
resolution?: GateResolutionRecord;
|
|
541
|
-
/**
|
|
542
|
-
* Present instead of {@link PlanBoundResolution.resolution} when a
|
|
543
|
-
* resolution stands for this gate but for a different plan — the newest
|
|
544
|
-
* such record, so a refusal can name who approved what and when. Its
|
|
545
|
-
* `planDigest` is `undefined` for a record written before #2300.
|
|
546
|
-
*/
|
|
547
|
-
mismatched?: GateResolutionRecord;
|
|
548
|
-
}
|
|
549
|
-
|
|
550
|
-
/**
|
|
551
|
-
* The resolution that answers `gate` for the plan `planDigest` identifies
|
|
552
|
-
* (#2300).
|
|
553
|
-
*
|
|
554
|
-
* The criterion is the digest; recency is only the tiebreak between several
|
|
555
|
-
* resolutions that all match it. That inversion is the whole point of the
|
|
556
|
-
* issue: {@link latestResolutionSince} asks "is there a newer approval",
|
|
557
|
-
* which a run answers yes to no matter what has changed since, and this asks
|
|
558
|
-
* "is there an approval of *this*".
|
|
559
|
-
*
|
|
560
|
-
* - `planDigest` `undefined` — the gate binds no plan (an Op `gate` step
|
|
561
|
-
* authored with no `plan`). Falls straight through to
|
|
562
|
-
* {@link latestResolutionSince}: gates that never claimed to bind a plan
|
|
563
|
-
* behave exactly as they did before #2300.
|
|
564
|
-
* - A resolution whose `planDigest` equals `planDigest` answers the gate.
|
|
565
|
-
* - A resolution for a different plan does not, and comes back as
|
|
566
|
-
* `mismatched` so the caller can name both digests.
|
|
567
|
-
* - A resolution with no `planDigest` at all — every record written before
|
|
568
|
-
* #2300 — does not either, and comes back as `mismatched` with an absent
|
|
569
|
-
* `planDigest`. This is the safe reading of the migration: such a record
|
|
570
|
-
* proves someone approved something, and nothing about what. Accepting it
|
|
571
|
-
* once would silently apply the very change this check exists to catch, on
|
|
572
|
-
* exactly the estates that have been running longest. The cost is one
|
|
573
|
-
* further `chant approve` per gate after the upgrade, which the refusal
|
|
574
|
-
* says out loud.
|
|
575
|
-
*/
|
|
576
|
-
export function latestResolutionForPlan(
|
|
577
|
-
records: GateResolutionRecord[],
|
|
578
|
-
gate: string,
|
|
579
|
-
sinceIso: string,
|
|
580
|
-
planDigest: string | undefined,
|
|
581
|
-
): PlanBoundResolution {
|
|
582
|
-
if (planDigest === undefined) {
|
|
583
|
-
const resolution = latestResolutionSince(records, gate, sinceIso);
|
|
584
|
-
return resolution ? { resolution } : {};
|
|
585
|
-
}
|
|
586
|
-
|
|
587
|
-
const since = new Date(sinceIso).getTime();
|
|
588
|
-
let matched: GateResolutionRecord | undefined;
|
|
589
|
-
let mismatched: GateResolutionRecord | undefined;
|
|
590
|
-
for (const r of records) {
|
|
591
|
-
if (r.gate !== gate) continue;
|
|
592
|
-
if (new Date(r.timestamp).getTime() < since) continue;
|
|
593
|
-
const newest = (best: GateResolutionRecord | undefined) =>
|
|
594
|
-
!best || new Date(r.timestamp).getTime() >= new Date(best.timestamp).getTime();
|
|
595
|
-
if (samePlanDigest(r.planDigest, planDigest)) {
|
|
596
|
-
if (newest(matched)) matched = r;
|
|
597
|
-
} else if (newest(mismatched)) {
|
|
598
|
-
mismatched = r;
|
|
599
|
-
}
|
|
600
|
-
}
|
|
601
|
-
|
|
602
|
-
if (matched) return { resolution: matched };
|
|
603
|
-
return mismatched ? { mismatched } : {};
|
|
604
|
-
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #3760: the override gate's lines on chant/lifecycle. A denial is an
|
|
3
|
+
* ordinary pending fact on `_gates/policy-override.jsonl`, and applied
|
|
4
|
+
* records sit beside it where no gate reader lists them.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, expect, test } from "vitest";
|
|
7
|
+
import { withTestDir } from "@intentius/chant-test-utils";
|
|
8
|
+
import { spawnSync } from "node:child_process";
|
|
9
|
+
import { writeFileSync } from "node:fs";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { appendGateResolution, readGateLedger } from "./gate-ledger";
|
|
12
|
+
import { readOverrideLedger, recordOverrideDenial, recordOverridesApplied } from "./override-ledger";
|
|
13
|
+
import { appliedOverride, decideOverride, overrideDigest, OVERRIDE_OP } from "../override-gate";
|
|
14
|
+
import { readBlobFromPath } from "./git";
|
|
15
|
+
|
|
16
|
+
function initRepo(dir: string): void {
|
|
17
|
+
const git = (args: string[]) => spawnSync("git", args, { cwd: dir, encoding: "utf-8" });
|
|
18
|
+
git(["init", "-q", "-b", "main"]);
|
|
19
|
+
git(["config", "user.email", "test@chant.dev"]);
|
|
20
|
+
git(["config", "user.name", "Test"]);
|
|
21
|
+
writeFileSync(join(dir, "README.md"), "fixture\n");
|
|
22
|
+
git(["add", "README.md"]);
|
|
23
|
+
git(["commit", "-q", "-m", "init"]);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const PLAN = `sha256:${"a".repeat(64)}`;
|
|
27
|
+
|
|
28
|
+
describe("lifecycle/override-ledger", () => {
|
|
29
|
+
test("a denial, its override and the applied record round-trip, and the override is spent", async () => {
|
|
30
|
+
await withTestDir(async (dir) => {
|
|
31
|
+
initRepo(dir);
|
|
32
|
+
const opts = { cwd: dir };
|
|
33
|
+
const { record: denial } = await recordOverrideDenial(
|
|
34
|
+
{ subject: "prod", planDigest: PLAN, rules: ["no-drop"], timestamp: "2026-10-11T10:00:00.000Z", expiresAt: "2026-10-13T00:00:00.000Z" },
|
|
35
|
+
opts,
|
|
36
|
+
);
|
|
37
|
+
expect(denial).toMatchObject({ op: OVERRIDE_OP, gate: "prod", plan: PLAN, rules: ["no-drop"], planDigest: overrideDigest("prod", PLAN, ["no-drop"]), neverOverMcp: true });
|
|
38
|
+
await appendGateResolution(
|
|
39
|
+
{ op: OVERRIDE_OP, gate: "prod", resolvedBy: "github:alice", timestamp: "2026-10-11T11:00:00.000Z", planDigest: denial.planDigest, note: "the column is unused since 0.6" },
|
|
40
|
+
opts,
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
const ledger = await readOverrideLedger(opts);
|
|
44
|
+
const input = { ledger, applied: ledger.applied, subject: "prod", planDigest: PLAN, rules: ["no-drop"], overriders: ["github:alice"], now: "2026-10-11T12:00:00.000Z" };
|
|
45
|
+
const decided = decideOverride(input);
|
|
46
|
+
expect(decided.status).toBe("overridden");
|
|
47
|
+
if (decided.status !== "overridden") return;
|
|
48
|
+
|
|
49
|
+
await recordOverridesApplied([appliedOverride(decided.override, { timestamp: "2026-10-11T12:00:00.000Z" })], opts);
|
|
50
|
+
await recordOverridesApplied([], opts);
|
|
51
|
+
const after = await readOverrideLedger(opts);
|
|
52
|
+
expect(after.applied).toHaveLength(1);
|
|
53
|
+
expect(decideOverride({ ...input, ledger: after, applied: after.applied }).status).toBe("none");
|
|
54
|
+
|
|
55
|
+
// The gate ledger reads the denial and the override, and nothing in the applied file.
|
|
56
|
+
const gates = await readGateLedger(OVERRIDE_OP, opts);
|
|
57
|
+
expect(gates).toMatchObject({ malformed: 0 });
|
|
58
|
+
expect(gates.pending).toHaveLength(1);
|
|
59
|
+
expect(gates.resolutions).toHaveLength(1);
|
|
60
|
+
expect(await readBlobFromPath(`_gates/${OVERRIDE_OP}`, "applied.jsonl", opts)).toContain('"kind":"applied"');
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
});
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The override gate's lines on `chant/lifecycle` (#3760): read the denials,
|
|
3
|
+
* overrides and applied records, record a denial, and record that a run
|
|
4
|
+
* applied under an override, which spends it. The decision is
|
|
5
|
+
* `../override-gate.ts`'s, which bundles without this module's git code.
|
|
6
|
+
*
|
|
7
|
+
* Denials and overrides share `_gates/policy-override.jsonl` with the
|
|
8
|
+
* ordinary gate line shapes, so `chant approve`, `chant operator status` and
|
|
9
|
+
* the audit reader read them as they read any gate. Applied records live in
|
|
10
|
+
* `_gates/policy-override/applied.jsonl`, which no gate reader lists.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { sortedJsonReplacer } from "../utils";
|
|
14
|
+
import { appendPendingGate, readGateLedger, type GateResolutionRecord, type PendingGateRecord } from "./gate-ledger";
|
|
15
|
+
import { readBlobBySha, readBlobFromPath, readPathSha, RefCASConflictError, writeBlobToPath } from "./git";
|
|
16
|
+
import {
|
|
17
|
+
OVERRIDE_OP,
|
|
18
|
+
parseAppliedOverrides,
|
|
19
|
+
overridePending,
|
|
20
|
+
type AppliedOverrideRecord,
|
|
21
|
+
type OverridePendingInput,
|
|
22
|
+
type OverridePendingRecord,
|
|
23
|
+
} from "../override-gate";
|
|
24
|
+
|
|
25
|
+
const APPLIED_DIR = `_gates/${OVERRIDE_OP}`;
|
|
26
|
+
const APPLIED_FILE = "applied.jsonl";
|
|
27
|
+
const APPEND_RETRY_ATTEMPTS = 5;
|
|
28
|
+
|
|
29
|
+
export interface OverrideLedger {
|
|
30
|
+
resolutions: GateResolutionRecord[];
|
|
31
|
+
pending: PendingGateRecord[];
|
|
32
|
+
applied: AppliedOverrideRecord[];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The override ledger and its applied records, oldest first. Empty when nothing is recorded. */
|
|
36
|
+
export async function readOverrideLedger(opts?: { cwd?: string }): Promise<OverrideLedger> {
|
|
37
|
+
const { resolutions, pending } = await readGateLedger(OVERRIDE_OP, opts);
|
|
38
|
+
const text = await readBlobFromPath(APPLIED_DIR, APPLIED_FILE, opts);
|
|
39
|
+
return { resolutions, pending, applied: text ? parseAppliedOverrides(text) : [] };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Append a denial's pending fact ({@link overridePending}). Does not push. */
|
|
43
|
+
export async function recordOverrideDenial(input: OverridePendingInput, opts?: { cwd?: string }): Promise<{ commit: string; record: OverridePendingRecord }> {
|
|
44
|
+
const { version: _v, kind: _k, ...rest } = overridePending(input);
|
|
45
|
+
const { commit, record } = await appendPendingGate(rest, opts);
|
|
46
|
+
return { commit, record: record as OverridePendingRecord };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Append applied records, spending the overrides they name. Write them before applying; a write that fails should stop the run. Does not push. */
|
|
50
|
+
export async function recordOverridesApplied(records: readonly AppliedOverrideRecord[], opts?: { cwd?: string }): Promise<string | undefined> {
|
|
51
|
+
if (records.length === 0) return undefined;
|
|
52
|
+
const lines = records.map((r) => JSON.stringify(r, sortedJsonReplacer)).join("\n");
|
|
53
|
+
let lastErr: unknown;
|
|
54
|
+
for (let attempt = 1; attempt <= APPEND_RETRY_ATTEMPTS; attempt++) {
|
|
55
|
+
try {
|
|
56
|
+
const priorSha = await readPathSha(APPLIED_DIR, APPLIED_FILE, opts);
|
|
57
|
+
const existing = priorSha ? await readBlobBySha(priorSha, opts) : null;
|
|
58
|
+
const content = existing ? `${existing.replace(/\n$/, "")}\n${lines}` : lines;
|
|
59
|
+
return await writeBlobToPath(APPLIED_DIR, APPLIED_FILE, content, `Applied under override: ${records.map((r) => r.gate).join(", ")}`, {
|
|
60
|
+
...opts,
|
|
61
|
+
expectPriorPathSha: priorSha,
|
|
62
|
+
});
|
|
63
|
+
} catch (err) {
|
|
64
|
+
if (!(err instanceof RefCASConflictError)) throw err;
|
|
65
|
+
lastErr = err;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
throw lastErr;
|
|
69
|
+
}
|
|
@@ -12,6 +12,7 @@ import { execFile } from "node:child_process";
|
|
|
12
12
|
import { promisify } from "node:util";
|
|
13
13
|
import { tmpdir } from "node:os";
|
|
14
14
|
import { join } from "node:path";
|
|
15
|
+
import { ghHostEnv } from "../op/activities/gh-host";
|
|
15
16
|
|
|
16
17
|
const execFileAsync = promisify(execFile);
|
|
17
18
|
|
|
@@ -106,10 +107,13 @@ export async function rollbackToRevision(opts: {
|
|
|
106
107
|
|
|
107
108
|
await git(["commit", "-m", rollbackTitle(env, ref)], wt);
|
|
108
109
|
await git(["push", "-u", "origin", branch], wt);
|
|
110
|
+
// `gh pr create` names no host: tell it the one `origin` is on, or the
|
|
111
|
+
// CI job's forge, so a GitHub Enterprise Server or Forgejo checkout is not
|
|
112
|
+
// sent to github.com (#3780).
|
|
109
113
|
const { stdout } = await execFileAsync(
|
|
110
114
|
"gh",
|
|
111
115
|
["pr", "create", "--title", rollbackTitle(env, ref), "--body", rollbackBody(env, ref, sourceDir), "--head", branch],
|
|
112
|
-
{ cwd: wt },
|
|
116
|
+
{ cwd: wt, env: await ghHostEnv(process.env, wt) },
|
|
113
117
|
);
|
|
114
118
|
return { noop: false, branch, prUrl: stdout.trim() };
|
|
115
119
|
} finally {
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #2815 — a work item id has to be a ref path segment git accepts. The cases
|
|
3
|
+
* are checked against `git check-ref-format` itself, in every ref the id lands in.
|
|
4
|
+
*/
|
|
5
|
+
import { describe, expect, test } from "vitest";
|
|
6
|
+
import { spawnSync } from "node:child_process";
|
|
7
|
+
import { isWorkItemId, workItemIdProblem } from "./work-item-id";
|
|
8
|
+
|
|
9
|
+
function gitAccepts(ref: string): boolean {
|
|
10
|
+
return spawnSync("git", ["check-ref-format", ref]).status === 0;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const REF_SHAPES = [
|
|
14
|
+
(id: string) => `refs/chant/lease/work/${id}`,
|
|
15
|
+
(id: string) => `refs/chant/lease/_members/api/work/${id}`,
|
|
16
|
+
(id: string) => `refs/chant/kept/api/${id}/0123abcd`,
|
|
17
|
+
(id: string) => `refs/heads/chant/work/api/${id}`,
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
const ACCEPTED = ["W-001", "upgrade-1", "a", "0", "a.b", "a_b", "v1.2.3", "x.lock2", "lock", "a.lockb", "A".repeat(100)];
|
|
21
|
+
const REFUSED = [
|
|
22
|
+
"upgrade-.",
|
|
23
|
+
".",
|
|
24
|
+
"x.",
|
|
25
|
+
"x.lock",
|
|
26
|
+
"x.LOCK",
|
|
27
|
+
"x.Lock",
|
|
28
|
+
"a..b",
|
|
29
|
+
"..",
|
|
30
|
+
".hidden",
|
|
31
|
+
"-lead",
|
|
32
|
+
"_lead",
|
|
33
|
+
"a/b",
|
|
34
|
+
"a b",
|
|
35
|
+
"a~b",
|
|
36
|
+
"a^b",
|
|
37
|
+
"a:b",
|
|
38
|
+
"a?b",
|
|
39
|
+
"a*b",
|
|
40
|
+
"a[b",
|
|
41
|
+
"a\\b",
|
|
42
|
+
"a@{b",
|
|
43
|
+
"@",
|
|
44
|
+
"",
|
|
45
|
+
"a\tb",
|
|
46
|
+
"A".repeat(101),
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
describe("work item id (#2815)", () => {
|
|
50
|
+
test.each(ACCEPTED)("%s keys a lease, and git takes it in every ref it lands in", (id) => {
|
|
51
|
+
expect(workItemIdProblem(id)).toBeUndefined();
|
|
52
|
+
expect(isWorkItemId(id)).toBe(true);
|
|
53
|
+
for (const shape of REF_SHAPES) expect(gitAccepts(shape(id)), shape(id)).toBe(true);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test.each(REFUSED)("%j is refused", (id) => {
|
|
57
|
+
expect(workItemIdProblem(id)).toMatch(/can't key a work lease/);
|
|
58
|
+
expect(isWorkItemId(id)).toBe(false);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("everything git refuses in the lease ref is refused here", () => {
|
|
62
|
+
for (const id of REFUSED.filter((i) => i !== "" && !i.includes("\t") && i.length <= 100)) {
|
|
63
|
+
if (!gitAccepts(`refs/chant/lease/work/${id}`)) expect(isWorkItemId(id), id).toBe(false);
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("a non-string is refused", () => {
|
|
68
|
+
expect(workItemIdProblem(undefined)).toMatch(/not a string/);
|
|
69
|
+
expect(isWorkItemId(42)).toBe(false);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a work item id has to be to key a work lease (#2815). The id lands in
|
|
3
|
+
* refs (`refs/chant/lease/work/<id>`, `refs/chant/kept/.../<item>/<token>`,
|
|
4
|
+
* the work branch `chant/work/.../<id>`) and in a file name
|
|
5
|
+
* (`_leases/<id>.jsonl`), so it has to be a valid ref path segment under
|
|
6
|
+
* `git check-ref-format`, and git's check is stricter than a character class.
|
|
7
|
+
*
|
|
8
|
+
* Kept free of imports so `Op()`, `declareSteward` and the CLI can all call it
|
|
9
|
+
* without loading the lease machinery.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** The characters an id may use: letters, digits, ".", "_" and "-", starting with a letter or digit, at most 100. */
|
|
13
|
+
export const WORK_ITEM_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Why `id` can't key a work lease, or `undefined` when it can. On top of
|
|
17
|
+
* {@link WORK_ITEM_ID_PATTERN}, git refuses a ref component that has `..`,
|
|
18
|
+
* ends in `.`, or ends in `.lock` (git's own lock file for the ref; matched
|
|
19
|
+
* case-insensitively here since a case-folding filesystem would collide).
|
|
20
|
+
*/
|
|
21
|
+
export function workItemIdProblem(id: unknown): string | undefined {
|
|
22
|
+
if (typeof id !== "string") return `${JSON.stringify(id)} is not a string`;
|
|
23
|
+
if (!WORK_ITEM_ID_PATTERN.test(id)) {
|
|
24
|
+
return `${JSON.stringify(id)} can't key a work lease: use letters, digits, ".", "_" and "-", starting with a letter or digit, at most 100 characters`;
|
|
25
|
+
}
|
|
26
|
+
if (id.includes("..")) return `${JSON.stringify(id)} can't key a work lease: git refuses ".." in a ref name`;
|
|
27
|
+
if (id.endsWith(".")) return `${JSON.stringify(id)} can't key a work lease: git refuses a ref name ending in "."`;
|
|
28
|
+
if (id.toLowerCase().endsWith(".lock")) return `${JSON.stringify(id)} can't key a work lease: git refuses a ref name ending in ".lock"`;
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Whether `id` can key a work lease. */
|
|
33
|
+
export function isWorkItemId(id: unknown): id is string {
|
|
34
|
+
return workItemIdProblem(id) === undefined;
|
|
35
|
+
}
|
|
@@ -50,6 +50,7 @@ import {
|
|
|
50
50
|
import { dirname } from "node:path";
|
|
51
51
|
import type { ReasonCode } from "../workspace/reason-codes";
|
|
52
52
|
import { resolveMemberLedger } from "./member-ledger";
|
|
53
|
+
import { WORK_ITEM_ID_PATTERN, isWorkItemId, workItemIdProblem } from "./work-item-id";
|
|
53
54
|
|
|
54
55
|
/** The key prefix of a work lease under the operator lease's ref namespace. */
|
|
55
56
|
export const WORK_LEASE_KEY_PREFIX = "work/";
|
|
@@ -60,8 +61,7 @@ export const LEASES_DIR = "_leases";
|
|
|
60
61
|
/** How long a claim lasts unless renewed: chud's default, which its dispatcher renews at a third of. */
|
|
61
62
|
export const DEFAULT_WORK_LEASE_TTL_MS = 10 * 60_000;
|
|
62
63
|
|
|
63
|
-
|
|
64
|
-
export const WORK_ITEM_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/;
|
|
64
|
+
export { WORK_ITEM_ID_PATTERN, isWorkItemId, workItemIdProblem };
|
|
65
65
|
|
|
66
66
|
/** How long a claim waits out another process's lock on the ref before calling it stale. */
|
|
67
67
|
const LOCK_WAIT_MS = 2_000;
|
|
@@ -245,9 +245,8 @@ function toWorkLease(item: string, r: LeaseRecord): WorkLease {
|
|
|
245
245
|
}
|
|
246
246
|
|
|
247
247
|
function checkId(id: string): void {
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
}
|
|
248
|
+
const problem = workItemIdProblem(id);
|
|
249
|
+
if (problem) throw new Error(problem);
|
|
251
250
|
}
|
|
252
251
|
|
|
253
252
|
const REASONS: Record<string, WorkLeaseRefusal> = {
|
|
@@ -181,6 +181,35 @@ describe("telemetry attribution (#3375)", () => {
|
|
|
181
181
|
});
|
|
182
182
|
});
|
|
183
183
|
|
|
184
|
+
describe("project directory (#3751)", () => {
|
|
185
|
+
const readsDir: PostSynthCheck = {
|
|
186
|
+
id: "PROJECT_DIR",
|
|
187
|
+
description: "reports the project directory the context carries",
|
|
188
|
+
check(ctx) {
|
|
189
|
+
return [{ checkId: "PROJECT_DIR", severity: "warning", message: JSON.stringify(ctx.projectDir ?? null) }];
|
|
190
|
+
},
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
test("the build's config directory is threaded into the context", () => {
|
|
194
|
+
const diags = runPostSynthChecks([readsDir], createBuildResult(), undefined, { projectDir: "/work/app" });
|
|
195
|
+
expect(diags[0].message).toBe('"/work/app"');
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test("the loaded config is threaded too", () => {
|
|
199
|
+
const readsConfig: PostSynthCheck = {
|
|
200
|
+
id: "PROJECT_CONFIG",
|
|
201
|
+
description: "reports the config the context carries",
|
|
202
|
+
check: (ctx) => [{ checkId: "PROJECT_CONFIG", severity: "warning", message: JSON.stringify(ctx.projectConfig ?? null) }],
|
|
203
|
+
};
|
|
204
|
+
expect(runPostSynthChecks([readsConfig], createBuildResult(), undefined, { projectConfig: { sourceDir: "src" } })[0].message).toBe('{"sourceDir":"src"}');
|
|
205
|
+
expect(runPostSynthChecks([readsConfig], createBuildResult())[0].message).toBe("null");
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test("a hand-built run leaves the field undefined", () => {
|
|
209
|
+
expect(runPostSynthChecks([readsDir], createBuildResult())[0].message).toBe("null");
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
|
|
184
213
|
describe("isPostSynthCheck", () => {
|
|
185
214
|
test("accepts a well-formed check and rejects others", async () => {
|
|
186
215
|
const { isPostSynthCheck } = await import("./post-synth");
|
package/src/lint/post-synth.ts
CHANGED
|
@@ -63,6 +63,21 @@ export interface PostSynthContext {
|
|
|
63
63
|
* hand-built context. OTEL118 runs only when it is set.
|
|
64
64
|
*/
|
|
65
65
|
readonly telemetry?: TelemetryAttribution;
|
|
66
|
+
/**
|
|
67
|
+
* The directory of the project config the build loaded (#3751): where a
|
|
68
|
+
* relative `sourceDir` resolves, and so where a check looks for a file the
|
|
69
|
+
* project keeps beside its source (the sql lexicon's `sql-types.d.ts`).
|
|
70
|
+
* Set by `chant build` and `chant lint`; absent from a hand-built context,
|
|
71
|
+
* where a check that reads the disk stays silent.
|
|
72
|
+
*/
|
|
73
|
+
readonly projectDir?: string;
|
|
74
|
+
/**
|
|
75
|
+
* The project config the build loaded (#3751), as source lint rules get it
|
|
76
|
+
* in `LintContext.projectConfig`. A check that renders output again, as the
|
|
77
|
+
* sql lexicon's does for `sql-types.d.ts`, needs the settings the build ran
|
|
78
|
+
* with. Absent from a hand-built context.
|
|
79
|
+
*/
|
|
80
|
+
readonly projectConfig?: Record<string, unknown>;
|
|
66
81
|
/** Raw build result object */
|
|
67
82
|
buildResult: {
|
|
68
83
|
outputs: Map<string, string | SerializerResult>;
|
|
@@ -217,13 +232,15 @@ export function isPostSynthCheck(value: unknown): value is PostSynthCheck {
|
|
|
217
232
|
* into the context so a check can branch on the current environment/stack, and
|
|
218
233
|
* `opts.activityContracts` (chant #2101) carries the build's cross-lexicon
|
|
219
234
|
* activity-contract map to the checks that validate Op steps against it.
|
|
220
|
-
* `opts.telemetry` is the build's resolved telemetry attribution
|
|
235
|
+
* `opts.telemetry` is the build's resolved telemetry attribution, and
|
|
236
|
+
* `opts.projectDir` and `opts.projectConfig` the config the build loaded and
|
|
237
|
+
* its directory.
|
|
221
238
|
*/
|
|
222
239
|
export function runPostSynthChecks(
|
|
223
240
|
checks: PostSynthCheck[],
|
|
224
241
|
buildResult: PostSynthContext["buildResult"],
|
|
225
242
|
env?: string,
|
|
226
|
-
opts?: { activityContracts?: ReadonlyMap<string, ActivityContract>; telemetry?: TelemetryAttribution },
|
|
243
|
+
opts?: { activityContracts?: ReadonlyMap<string, ActivityContract>; telemetry?: TelemetryAttribution; projectDir?: string; projectConfig?: Record<string, unknown> },
|
|
227
244
|
): PostSynthDiagnostic[] {
|
|
228
245
|
const getDocs = createDocsAccessor(buildResult.outputs);
|
|
229
246
|
const ctx: PostSynthContext = {
|
|
@@ -233,6 +250,8 @@ export function runPostSynthChecks(
|
|
|
233
250
|
buildResult,
|
|
234
251
|
...(opts?.activityContracts ? { activityContracts: opts.activityContracts } : {}),
|
|
235
252
|
...(opts?.telemetry ? { telemetry: opts.telemetry } : {}),
|
|
253
|
+
...(opts?.projectDir ? { projectDir: opts.projectDir } : {}),
|
|
254
|
+
...(opts?.projectConfig ? { projectConfig: opts.projectConfig } : {}),
|
|
236
255
|
get docs(): OutputDoc[] {
|
|
237
256
|
return getDocs();
|
|
238
257
|
},
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The notify modules bundle small (#3761, ruling #3421).
|
|
3
|
+
*
|
|
4
|
+
* terragucci runs its stages in customer CI as one bundled file with no
|
|
5
|
+
* TypeScript toolchain, and a webhook receiver imports the verifier alone.
|
|
6
|
+
* So `@intentius/chant/notify` must reach nothing but `node:crypto` and the
|
|
7
|
+
* verifier, and `@intentius/chant/notify-verify` nothing but `node:crypto`.
|
|
8
|
+
* This bundles both the way a product and a receiver would and reads
|
|
9
|
+
* esbuild's own list of what went in.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import { build } from "esbuild";
|
|
14
|
+
import { describe, expect, test } from "vitest";
|
|
15
|
+
import { REPO } from "./workspace/__fixtures__/contract-repo";
|
|
16
|
+
|
|
17
|
+
async function bundle(entry: string): Promise<{ inputs: string[]; bytes: number; imports: string[] }> {
|
|
18
|
+
const result = await build({
|
|
19
|
+
entryPoints: [join(REPO, "packages", "core", "src", entry)],
|
|
20
|
+
bundle: true,
|
|
21
|
+
platform: "node",
|
|
22
|
+
format: "esm",
|
|
23
|
+
write: false,
|
|
24
|
+
metafile: true,
|
|
25
|
+
outdir: "out",
|
|
26
|
+
logLevel: "silent",
|
|
27
|
+
});
|
|
28
|
+
const imports = Object.values(result.metafile.outputs).flatMap((o) => o.imports.map((i) => i.path));
|
|
29
|
+
return {
|
|
30
|
+
inputs: Object.keys(result.metafile.inputs).map((i) => i.replace(/^.*?packages\//, "packages/")).sort(),
|
|
31
|
+
bytes: result.outputFiles.reduce((n, f) => n + f.contents.byteLength, 0),
|
|
32
|
+
imports: [...new Set(imports)].sort(),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe("the notify bundle", () => {
|
|
37
|
+
test("the sender holds itself and the verifier, and imports only node:crypto", async () => {
|
|
38
|
+
const { inputs, bytes, imports } = await bundle("notify.ts");
|
|
39
|
+
expect(inputs).toEqual(["packages/core/src/notify-verify.ts", "packages/core/src/notify.ts"]);
|
|
40
|
+
expect(imports).toEqual(["node:crypto"]);
|
|
41
|
+
expect(bytes).toBeLessThan(16 * 1024);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("the verifier holds itself alone, and imports only node:crypto", async () => {
|
|
45
|
+
const { inputs, bytes, imports } = await bundle("notify-verify.ts");
|
|
46
|
+
expect(inputs).toEqual(["packages/core/src/notify-verify.ts"]);
|
|
47
|
+
expect(imports).toEqual(["node:crypto"]);
|
|
48
|
+
expect(bytes).toBeLessThan(4 * 1024);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { createHmac } from "node:crypto";
|
|
2
|
+
import { describe, expect, test } from "vitest";
|
|
3
|
+
import { DEFAULT_TOLERANCE_SECONDS, signWebhook, verifyWebhook, webhookHeaderNames } from "./notify-verify";
|
|
4
|
+
|
|
5
|
+
const KEY = "s3cret";
|
|
6
|
+
const NOW = 1_760_000_000;
|
|
7
|
+
const BODY = JSON.stringify({ schema: "chant.notify/v1", event: "waiting" });
|
|
8
|
+
|
|
9
|
+
function headers(body = BODY, ts: number | string = NOW, key = KEY, prefix = "x-chant"): Record<string, string> {
|
|
10
|
+
return { [`${prefix}-timestamp`]: String(ts), [`${prefix}-signature`]: signWebhook(key, ts, body) };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
describe("signWebhook", () => {
|
|
14
|
+
test("is HMAC-SHA256 over <timestamp>.<body>, as sha256=<hex>", () => {
|
|
15
|
+
const hex = createHmac("sha256", KEY).update(`${NOW}.${BODY}`).digest("hex");
|
|
16
|
+
expect(signWebhook(KEY, NOW, BODY)).toBe(`sha256=${hex}`);
|
|
17
|
+
});
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
describe("verifyWebhook", () => {
|
|
21
|
+
test("accepts a fresh delivery signed with the key, and returns the event", () => {
|
|
22
|
+
expect(verifyWebhook({ body: BODY, headers: headers(), key: KEY, now: NOW + 10 })).toEqual({ ok: true, event: JSON.parse(BODY) });
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("matches header names without regard to case, and reads a Headers object and a byte body", () => {
|
|
26
|
+
const upper = Object.fromEntries(Object.entries(headers()).map(([k, v]) => [k.toUpperCase(), v]));
|
|
27
|
+
expect(verifyWebhook({ body: BODY, headers: upper, key: KEY, now: NOW }).ok).toBe(true);
|
|
28
|
+
expect(verifyWebhook({ body: new TextEncoder().encode(BODY), headers: new Headers(headers()), key: KEY, now: NOW }).ok).toBe(true);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("refuses another key, a changed body, a moved timestamp and a missing key", () => {
|
|
32
|
+
expect(verifyWebhook({ body: BODY, headers: headers(BODY, NOW, "other"), key: KEY, now: NOW })).toEqual({ ok: false, reason: "the signature does not match" });
|
|
33
|
+
expect(verifyWebhook({ body: BODY + " ", headers: headers(), key: KEY, now: NOW }).ok).toBe(false);
|
|
34
|
+
expect(verifyWebhook({ body: BODY, headers: { ...headers(), "x-chant-timestamp": String(NOW + 1) }, key: KEY, now: NOW }).ok).toBe(false);
|
|
35
|
+
expect(verifyWebhook({ body: BODY, headers: headers(), key: "", now: NOW })).toEqual({ ok: false, reason: "no key to verify with" });
|
|
36
|
+
expect(verifyWebhook({ body: BODY, headers: headers(), key: undefined, now: NOW }).ok).toBe(false);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("refuses a replay older than the tolerance, and one from too far ahead", () => {
|
|
40
|
+
const late = verifyWebhook({ body: BODY, headers: headers(), key: KEY, now: NOW + DEFAULT_TOLERANCE_SECONDS + 1 });
|
|
41
|
+
expect(late).toEqual({ ok: false, reason: `the timestamp is more than ${DEFAULT_TOLERANCE_SECONDS}s from now` });
|
|
42
|
+
expect(verifyWebhook({ body: BODY, headers: headers(), key: KEY, now: NOW - 61, toleranceSeconds: 60 }).ok).toBe(false);
|
|
43
|
+
expect(verifyWebhook({ body: BODY, headers: headers(), key: KEY, now: NOW + DEFAULT_TOLERANCE_SECONDS }).ok).toBe(true);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("refuses missing or malformed headers, and a body that is not JSON", () => {
|
|
47
|
+
expect(verifyWebhook({ body: BODY, headers: {}, key: KEY, now: NOW })).toEqual({ ok: false, reason: "no x-chant-signature header" });
|
|
48
|
+
expect(verifyWebhook({ body: BODY, headers: { "x-chant-signature": "sha256=00" }, key: KEY, now: NOW }).ok).toBe(false);
|
|
49
|
+
expect(verifyWebhook({ body: BODY, headers: headers(BODY, "soon"), key: KEY, now: NOW }).ok).toBe(false);
|
|
50
|
+
expect(verifyWebhook({ body: "not json", headers: headers("not json"), key: KEY, now: NOW })).toEqual({ ok: false, reason: "the body is not JSON" });
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("reads a product's own header prefix", () => {
|
|
54
|
+
expect(webhookHeaderNames("X-Yodel")).toEqual({ signature: "x-yodel-signature", timestamp: "x-yodel-timestamp", event: "x-yodel-event", delivery: "x-yodel-delivery" });
|
|
55
|
+
const h = headers(BODY, NOW, KEY, "x-yodel");
|
|
56
|
+
expect(verifyWebhook({ body: BODY, headers: h, key: KEY, now: NOW, prefix: "x-yodel" }).ok).toBe(true);
|
|
57
|
+
expect(verifyWebhook({ body: BODY, headers: h, key: KEY, now: NOW }).ok).toBe(false);
|
|
58
|
+
});
|
|
59
|
+
});
|