@caelo-cms/edge-router 0.10.23

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 ADDED
@@ -0,0 +1,25 @@
1
+ <!-- SPDX-License-Identifier: MPL-2.0 -->
2
+
3
+ # @caelo-cms/edge-router
4
+
5
+ Locale-aware edge request router for [Caelo CMS](https://github.com/caelo-cms/caelo-cms)
6
+ deployments. Maps incoming visitor URLs onto the static site's locale/URL
7
+ strategy (prefix, domain, or hybrid) from the deploy manifest — the same
8
+ routing logic everywhere a request first lands:
9
+
10
+ - the provisioning stacks' edge handlers bundle it at provision time
11
+ (Lambda@Edge on AWS; the equivalent edge hooks on the GCP and Azure
12
+ stacks),
13
+ - the static generator uses it to precompute per-locale routes.
14
+
15
+ It is published as part of Caelo's lockstep release because the shipped
16
+ `@caelo-cms/provisioning` package depends on it at provision time. It has no
17
+ runtime dependencies.
18
+
19
+ You normally don't install this directly — it comes in through
20
+ `bunx @caelo-cms/provisioning`. See the
21
+ [main repository](https://github.com/caelo-cms/caelo-cms) for documentation.
22
+
23
+ ## License
24
+
25
+ MPL-2.0
@@ -0,0 +1,70 @@
1
+ /**
2
+ * P15 — stable-hash A/B variant assignment.
3
+ *
4
+ * MUST produce byte-identical results across all four runtimes:
5
+ * - self-hosted (P13 Caddy gateway)
6
+ * - GCP edge router (Cloud Run header rule)
7
+ * - AWS edge router (Lambda@Edge)
8
+ * - Azure edge router (Front Door rules engine)
9
+ *
10
+ * Drift between runtimes means a visitor sees different variants
11
+ * depending on which provider serves them — silent A/B contamination.
12
+ * The byte-identity test in `index.test.ts` asserts a fixed corpus of
13
+ * (visitorId, manifestVersion, experimentId) tuples maps to the same
14
+ * variant label on every implementation.
15
+ *
16
+ * Algorithm: FNV-1a 32-bit hash of (visitorId + ":" + experimentId +
17
+ * ":" + manifestVersion). The 32-bit space mod 100 gives a bucket in
18
+ * [0, 99]; cumulative variant weights determine the variant. Pure +
19
+ * deterministic; no randomness, no time, no provider-specific code.
20
+ */
21
+ import type { ManifestExperiment, ManifestVariant } from "./manifest.js";
22
+ /**
23
+ * FNV-1a 32-bit. Standard offset basis 0x811c9dc5, prime 0x01000193.
24
+ * Implemented in plain TypeScript so every runtime (V8 / SpiderMonkey
25
+ * / Node L@E sandbox / Bun) produces byte-identical output.
26
+ */
27
+ export declare function fnv1a32(input: string): number;
28
+ /**
29
+ * Assign a variant to a visitor for one experiment. Inputs:
30
+ * - visitorId: stable opaque string from the `caelo_visitor_id` cookie.
31
+ * - manifestVersion: bumped per deploy so a re-bucketing rolls out
32
+ * atomically (operators occasionally need to re-randomize after a
33
+ * biased early sample).
34
+ * - experiment: the matched ManifestExperiment.
35
+ *
36
+ * Returns the chosen variant + its rewrite path. Pure; no I/O.
37
+ */
38
+ export declare function assignVariant(opts: {
39
+ readonly visitorId: string;
40
+ readonly manifestVersion: string;
41
+ readonly experiment: ManifestExperiment;
42
+ }): ManifestVariant;
43
+ /**
44
+ * Format the assignment-log entry every runtime emits. Same JSON shape
45
+ * across providers so the P12A analytics plugin's per-provider log
46
+ * adapters can normalise into one `ab_assignment_aggregates` query.
47
+ */
48
+ export interface AssignmentLogEntry {
49
+ readonly kind: "ab_assignment";
50
+ readonly experimentId: string;
51
+ readonly variantLabel: string;
52
+ readonly visitorId: string;
53
+ readonly manifestVersion: string;
54
+ readonly tsMs: number;
55
+ }
56
+ export declare function buildAssignmentLog(opts: {
57
+ readonly experimentId: string;
58
+ readonly variant: ManifestVariant;
59
+ readonly visitorId: string;
60
+ readonly manifestVersion: string;
61
+ readonly nowMs?: number;
62
+ }): AssignmentLogEntry;
63
+ /**
64
+ * Mint a stable opaque visitor id when the request didn't carry the
65
+ * `caelo_visitor_id` cookie. Format: 16 hex bytes (128 bits — collision
66
+ * resistant for any plausible visitor population). Edge routers set
67
+ * this on the response cookie so the next request inherits the same id.
68
+ */
69
+ export declare function mintVisitorId(): string;
70
+ //# sourceMappingURL=assignment.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"assignment.d.ts","sourceRoot":"","sources":["../src/assignment.ts"],"names":[],"mappings":"AAEA;;;;;;;;;;;;;;;;;;;GAmBG;AAEH,OAAO,KAAK,EAAE,kBAAkB,EAAE,eAAe,EAAE,MAAM,eAAe,CAAC;AAEzE;;;;GAIG;AACH,wBAAgB,OAAO,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAU7C;AAcD;;;;;;;;;GASG;AACH,wBAAgB,aAAa,CAAC,IAAI,EAAE;IAClC,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,eAAe,EAAE,MAAM,CAAC;IACjC,QAAQ,CAAC,UAAU,EAAE,kBAAkB,CAAC;CACzC,GAAG,eAAe,CAclB;AAED;;;;GAIG;AACH,MAAM,WAAW,kBAAkB;IACjC,QAAQ,CAAC,IAAI,EAAE,eAAe,CAAC;IAC/B,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,eAAe,EAAE,MAAM,CAAC;IACjC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;CACvB;AAED,wBAAgB,kBAAkB,CAAC,IAAI,EAAE;IACvC,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B,QAAQ,CAAC,OAAO,EAAE,eAAe,CAAC;IAClC,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,eAAe,EAAE,MAAM,CAAC;IACjC,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;CACzB,GAAG,kBAAkB,CASrB;AAED;;;;;GAKG;AACH,wBAAgB,aAAa,IAAI,MAAM,CAMtC"}
@@ -0,0 +1,78 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+ /**
3
+ * FNV-1a 32-bit. Standard offset basis 0x811c9dc5, prime 0x01000193.
4
+ * Implemented in plain TypeScript so every runtime (V8 / SpiderMonkey
5
+ * / Node L@E sandbox / Bun) produces byte-identical output.
6
+ */
7
+ export function fnv1a32(input) {
8
+ let hash = 0x811c9dc5;
9
+ for (let i = 0; i < input.length; i += 1) {
10
+ hash ^= input.charCodeAt(i);
11
+ // Multiply by FNV prime, keeping in 32-bit unsigned range. Math.imul
12
+ // is the cross-runtime way to do 32-bit-truncated integer multiply.
13
+ hash = Math.imul(hash, 0x01000193);
14
+ }
15
+ // Coerce back to unsigned 32-bit.
16
+ return hash >>> 0;
17
+ }
18
+ /**
19
+ * Compute the cumulative bucket boundaries for an experiment's variants.
20
+ * Stable: same input order → same boundaries → same assignment.
21
+ */
22
+ function bucketBoundaries(variants) {
23
+ let cum = 0;
24
+ return variants.map((v) => {
25
+ cum += v.weight;
26
+ return cum;
27
+ });
28
+ }
29
+ /**
30
+ * Assign a variant to a visitor for one experiment. Inputs:
31
+ * - visitorId: stable opaque string from the `caelo_visitor_id` cookie.
32
+ * - manifestVersion: bumped per deploy so a re-bucketing rolls out
33
+ * atomically (operators occasionally need to re-randomize after a
34
+ * biased early sample).
35
+ * - experiment: the matched ManifestExperiment.
36
+ *
37
+ * Returns the chosen variant + its rewrite path. Pure; no I/O.
38
+ */
39
+ export function assignVariant(opts) {
40
+ const key = `${opts.visitorId}:${opts.experiment.experimentId}:${opts.manifestVersion}`;
41
+ const hash = fnv1a32(key);
42
+ const bucket = hash % 100;
43
+ const boundaries = bucketBoundaries(opts.experiment.variants);
44
+ for (let i = 0; i < boundaries.length; i += 1) {
45
+ if (bucket < (boundaries[i] ?? 0)) {
46
+ const v = opts.experiment.variants[i];
47
+ if (v)
48
+ return v;
49
+ }
50
+ }
51
+ // Fallthrough (weights summed to 100 but float drift) — return last.
52
+ // Schema-level validation guarantees this doesn't happen in practice.
53
+ return opts.experiment.variants[opts.experiment.variants.length - 1];
54
+ }
55
+ export function buildAssignmentLog(opts) {
56
+ return {
57
+ kind: "ab_assignment",
58
+ experimentId: opts.experimentId,
59
+ variantLabel: opts.variant.label,
60
+ visitorId: opts.visitorId,
61
+ manifestVersion: opts.manifestVersion,
62
+ tsMs: opts.nowMs ?? Date.now(),
63
+ };
64
+ }
65
+ /**
66
+ * Mint a stable opaque visitor id when the request didn't carry the
67
+ * `caelo_visitor_id` cookie. Format: 16 hex bytes (128 bits — collision
68
+ * resistant for any plausible visitor population). Edge routers set
69
+ * this on the response cookie so the next request inherits the same id.
70
+ */
71
+ export function mintVisitorId() {
72
+ const buf = new Uint8Array(16);
73
+ // crypto.getRandomValues is available in V8 / Node / L@E / Bun /
74
+ // every modern runtime; no Node Buffer fallback needed.
75
+ crypto.getRandomValues(buf);
76
+ return [...buf].map((b) => b.toString(16).padStart(2, "0")).join("");
77
+ }
78
+ //# sourceMappingURL=assignment.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"assignment.js","sourceRoot":"","sources":["../src/assignment.ts"],"names":[],"mappings":"AAAA,mCAAmC;AAyBnC;;;;GAIG;AACH,MAAM,UAAU,OAAO,CAAC,KAAa;IACnC,IAAI,IAAI,GAAG,UAAU,CAAC;IACtB,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,KAAK,CAAC,MAAM,EAAE,CAAC,IAAI,CAAC,EAAE,CAAC;QACzC,IAAI,IAAI,KAAK,CAAC,UAAU,CAAC,CAAC,CAAC,CAAC;QAC5B,qEAAqE;QACrE,oEAAoE;QACpE,IAAI,GAAG,IAAI,CAAC,IAAI,CAAC,IAAI,EAAE,UAAU,CAAC,CAAC;IACrC,CAAC;IACD,kCAAkC;IAClC,OAAO,IAAI,KAAK,CAAC,CAAC;AACpB,CAAC;AAED;;;GAGG;AACH,SAAS,gBAAgB,CAAC,QAAwC;IAChE,IAAI,GAAG,GAAG,CAAC,CAAC;IACZ,OAAO,QAAQ,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE;QACxB,GAAG,IAAI,CAAC,CAAC,MAAM,CAAC;QAChB,OAAO,GAAG,CAAC;IACb,CAAC,CAAC,CAAC;AACL,CAAC;AAED;;;;;;;;;GASG;AACH,MAAM,UAAU,aAAa,CAAC,IAI7B;IACC,MAAM,GAAG,GAAG,GAAG,IAAI,CAAC,SAAS,IAAI,IAAI,CAAC,UAAU,CAAC,YAAY,IAAI,IAAI,CAAC,eAAe,EAAE,CAAC;IACxF,MAAM,IAAI,GAAG,OAAO,CAAC,GAAG,CAAC,CAAC;IAC1B,MAAM,MAAM,GAAG,IAAI,GAAG,GAAG,CAAC;IAC1B,MAAM,UAAU,GAAG,gBAAgB,CAAC,IAAI,CAAC,UAAU,CAAC,QAAQ,CAAC,CAAC;IAC9D,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,UAAU,CAAC,MAAM,EAAE,CAAC,IAAI,CAAC,EAAE,CAAC;QAC9C,IAAI,MAAM,GAAG,CAAC,UAAU,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,EAAE,CAAC;YAClC,MAAM,CAAC,GAAG,IAAI,CAAC,UAAU,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC;YACtC,IAAI,CAAC;gBAAE,OAAO,CAAC,CAAC;QAClB,CAAC;IACH,CAAC;IACD,qEAAqE;IACrE,sEAAsE;IACtE,OAAO,IAAI,CAAC,UAAU,CAAC,QAAQ,CAAC,IAAI,CAAC,UAAU,CAAC,QAAQ,CAAC,MAAM,GAAG,CAAC,CAAoB,CAAC;AAC1F,CAAC;AAgBD,MAAM,UAAU,kBAAkB,CAAC,IAMlC;IACC,OAAO;QACL,IAAI,EAAE,eAAe;QACrB,YAAY,EAAE,IAAI,CAAC,YAAY;QAC/B,YAAY,EAAE,IAAI,CAAC,OAAO,CAAC,KAAK;QAChC,SAAS,EAAE,IAAI,CAAC,SAAS;QACzB,eAAe,EAAE,IAAI,CAAC,eAAe;QACrC,IAAI,EAAE,IAAI,CAAC,KAAK,IAAI,IAAI,CAAC,GAAG,EAAE;KAC/B,CAAC;AACJ,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,aAAa;IAC3B,MAAM,GAAG,GAAG,IAAI,UAAU,CAAC,EAAE,CAAC,CAAC;IAC/B,iEAAiE;IACjE,wDAAwD;IACxD,MAAM,CAAC,eAAe,CAAC,GAAG,CAAC,CAAC;IAC5B,OAAO,CAAC,GAAG,GAAG,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,QAAQ,CAAC,EAAE,CAAC,CAAC,QAAQ,CAAC,CAAC,EAAE,GAAG,CAAC,CAAC,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;AACvE,CAAC"}
@@ -0,0 +1,13 @@
1
+ /**
2
+ * @caelo-cms/edge-router — P15.
3
+ *
4
+ * Provider-agnostic edge-routing logic for A/B experiments. The same
5
+ * hash → same variant → same rewrite path holds across self-hosted
6
+ * (P13 Caddy gateway) + GCP + AWS + Azure. Provider-specific shims
7
+ * land in `packages/provisioning/stacks/<provider>/edge-handler.ts`
8
+ * and import this package's `routeRequest` for the actual decision.
9
+ */
10
+ export { type AssignmentLogEntry, assignVariant, buildAssignmentLog, fnv1a32, mintVisitorId, } from "./assignment.js";
11
+ export { EMPTY_MANIFEST, findExperimentForUrl, type ManifestExperiment, type ManifestVariant, type RoutingManifest, validateManifest, } from "./manifest.js";
12
+ export { type EdgeRequestSummary, type EdgeRouteDecision, routeRequest } from "./router.js";
13
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAEA;;;;;;;;GAQG;AAEH,OAAO,EACL,KAAK,kBAAkB,EACvB,aAAa,EACb,kBAAkB,EAClB,OAAO,EACP,aAAa,GACd,MAAM,iBAAiB,CAAC;AACzB,OAAO,EACL,cAAc,EACd,oBAAoB,EACpB,KAAK,kBAAkB,EACvB,KAAK,eAAe,EACpB,KAAK,eAAe,EACpB,gBAAgB,GACjB,MAAM,eAAe,CAAC;AACvB,OAAO,EAAE,KAAK,kBAAkB,EAAE,KAAK,iBAAiB,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,14 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+ /**
3
+ * @caelo-cms/edge-router — P15.
4
+ *
5
+ * Provider-agnostic edge-routing logic for A/B experiments. The same
6
+ * hash → same variant → same rewrite path holds across self-hosted
7
+ * (P13 Caddy gateway) + GCP + AWS + Azure. Provider-specific shims
8
+ * land in `packages/provisioning/stacks/<provider>/edge-handler.ts`
9
+ * and import this package's `routeRequest` for the actual decision.
10
+ */
11
+ export { assignVariant, buildAssignmentLog, fnv1a32, mintVisitorId, } from "./assignment.js";
12
+ export { EMPTY_MANIFEST, findExperimentForUrl, validateManifest, } from "./manifest.js";
13
+ export { routeRequest } from "./router.js";
14
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,mCAAmC;AAEnC;;;;;;;;GAQG;AAEH,OAAO,EAEL,aAAa,EACb,kBAAkB,EAClB,OAAO,EACP,aAAa,GACd,MAAM,iBAAiB,CAAC;AACzB,OAAO,EACL,cAAc,EACd,oBAAoB,EAIpB,gBAAgB,GACjB,MAAM,eAAe,CAAC;AACvB,OAAO,EAAmD,YAAY,EAAE,MAAM,aAAa,CAAC"}
@@ -0,0 +1,56 @@
1
+ /**
2
+ * P15 — A/B routing manifest. Static-generator emits this file at deploy
3
+ * time (P6 + P13 contract). Per-provider edge routers (Cloud CDN /
4
+ * CloudFront L@E / Front Door rules / self-hosted Caddy gateway) all
5
+ * read this single shape so behaviour is identical across runtimes.
6
+ *
7
+ * Variant URL paths follow the convention:
8
+ * control: /<page-slug>
9
+ * variant: /_caelo-variant/<experimentId>/<variantLabel>/<page-slug>
10
+ *
11
+ * (Picked path-based over query-string-based so CDN cache keys aren't
12
+ * affected by query-string normalisation. See plans/phases/phase_15.)
13
+ */
14
+ export interface ManifestVariant {
15
+ /** Owner-readable label (e.g. "A", "B", "control"). Stable across deploys. */
16
+ readonly label: string;
17
+ /** Bucket weight in [0, 100]. All variants for one experiment must sum to 100. */
18
+ readonly weight: number;
19
+ /**
20
+ * Path the edge router rewrites to when this variant wins. Includes
21
+ * the /_caelo-variant prefix for non-control variants; equal to
22
+ * `pageSlug` for control.
23
+ */
24
+ readonly path: string;
25
+ }
26
+ export interface ManifestExperiment {
27
+ /** Page slug the experiment runs on (e.g. "/home", "/pricing"). */
28
+ readonly pageSlug: string;
29
+ /** UUID — also the cookie partition key so two experiments don't collide. */
30
+ readonly experimentId: string;
31
+ readonly variants: ReadonlyArray<ManifestVariant>;
32
+ }
33
+ export interface RoutingManifest {
34
+ /** Bumped by the static generator on every deploy that changes routing. */
35
+ readonly manifestVersion: string;
36
+ /** All active experiments (only `status='active'` rows from cms_admin.experiments). */
37
+ readonly experiments: ReadonlyArray<ManifestExperiment>;
38
+ }
39
+ export declare const EMPTY_MANIFEST: RoutingManifest;
40
+ /**
41
+ * Look up the active experiment for a given request URL. Returns null
42
+ * when no experiment matches the URL's page slug — caller should pass
43
+ * the request through unchanged.
44
+ */
45
+ export declare function findExperimentForUrl(manifest: RoutingManifest, pathname: string): ManifestExperiment | null;
46
+ /**
47
+ * Validates a manifest's invariants: every experiment's variant weights
48
+ * sum to 100, every variant has a non-empty label, the experimentId is
49
+ * a UUID, weights are non-negative. Returns null on success or a
50
+ * structured error string the static generator surfaces in audit. The
51
+ * runtime callers (edge routers) do NOT re-validate per request — they
52
+ * trust the deploy-time validation. This function is primarily for
53
+ * tests + the deploy step.
54
+ */
55
+ export declare function validateManifest(manifest: RoutingManifest): string | null;
56
+ //# sourceMappingURL=manifest.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"manifest.d.ts","sourceRoot":"","sources":["../src/manifest.ts"],"names":[],"mappings":"AAEA;;;;;;;;;;;;GAYG;AAEH,MAAM,WAAW,eAAe;IAC9B,8EAA8E;IAC9E,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,kFAAkF;IAClF,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB;;;;OAIG;IACH,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;CACvB;AAED,MAAM,WAAW,kBAAkB;IACjC,mEAAmE;IACnE,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,6EAA6E;IAC7E,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B,QAAQ,CAAC,QAAQ,EAAE,aAAa,CAAC,eAAe,CAAC,CAAC;CACnD;AAED,MAAM,WAAW,eAAe;IAC9B,2EAA2E;IAC3E,QAAQ,CAAC,eAAe,EAAE,MAAM,CAAC;IACjC,uFAAuF;IACvF,QAAQ,CAAC,WAAW,EAAE,aAAa,CAAC,kBAAkB,CAAC,CAAC;CACzD;AAED,eAAO,MAAM,cAAc,EAAE,eAG5B,CAAC;AAEF;;;;GAIG;AACH,wBAAgB,oBAAoB,CAClC,QAAQ,EAAE,eAAe,EACzB,QAAQ,EAAE,MAAM,GACf,kBAAkB,GAAG,IAAI,CAK3B;AAED;;;;;;;;GAQG;AACH,wBAAgB,gBAAgB,CAAC,QAAQ,EAAE,eAAe,GAAG,MAAM,GAAG,IAAI,CAoBzE"}
@@ -0,0 +1,49 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+ export const EMPTY_MANIFEST = {
3
+ manifestVersion: "0",
4
+ experiments: [],
5
+ };
6
+ /**
7
+ * Look up the active experiment for a given request URL. Returns null
8
+ * when no experiment matches the URL's page slug — caller should pass
9
+ * the request through unchanged.
10
+ */
11
+ export function findExperimentForUrl(manifest, pathname) {
12
+ for (const ex of manifest.experiments) {
13
+ if (ex.pageSlug === pathname)
14
+ return ex;
15
+ }
16
+ return null;
17
+ }
18
+ /**
19
+ * Validates a manifest's invariants: every experiment's variant weights
20
+ * sum to 100, every variant has a non-empty label, the experimentId is
21
+ * a UUID, weights are non-negative. Returns null on success or a
22
+ * structured error string the static generator surfaces in audit. The
23
+ * runtime callers (edge routers) do NOT re-validate per request — they
24
+ * trust the deploy-time validation. This function is primarily for
25
+ * tests + the deploy step.
26
+ */
27
+ export function validateManifest(manifest) {
28
+ for (const ex of manifest.experiments) {
29
+ if (!ex.experimentId.match(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i)) {
30
+ return `experiment ${ex.experimentId} is not a UUID`;
31
+ }
32
+ if (ex.variants.length === 0) {
33
+ return `experiment ${ex.experimentId} has no variants`;
34
+ }
35
+ let total = 0;
36
+ for (const v of ex.variants) {
37
+ if (!v.label)
38
+ return `experiment ${ex.experimentId}: variant has empty label`;
39
+ if (v.weight < 0)
40
+ return `experiment ${ex.experimentId}: variant ${v.label} has negative weight`;
41
+ total += v.weight;
42
+ }
43
+ if (total !== 100) {
44
+ return `experiment ${ex.experimentId}: variant weights sum to ${total}, expected 100`;
45
+ }
46
+ }
47
+ return null;
48
+ }
49
+ //# sourceMappingURL=manifest.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"manifest.js","sourceRoot":"","sources":["../src/manifest.ts"],"names":[],"mappings":"AAAA,mCAAmC;AA4CnC,MAAM,CAAC,MAAM,cAAc,GAAoB;IAC7C,eAAe,EAAE,GAAG;IACpB,WAAW,EAAE,EAAE;CAChB,CAAC;AAEF;;;;GAIG;AACH,MAAM,UAAU,oBAAoB,CAClC,QAAyB,EACzB,QAAgB;IAEhB,KAAK,MAAM,EAAE,IAAI,QAAQ,CAAC,WAAW,EAAE,CAAC;QACtC,IAAI,EAAE,CAAC,QAAQ,KAAK,QAAQ;YAAE,OAAO,EAAE,CAAC;IAC1C,CAAC;IACD,OAAO,IAAI,CAAC;AACd,CAAC;AAED;;;;;;;;GAQG;AACH,MAAM,UAAU,gBAAgB,CAAC,QAAyB;IACxD,KAAK,MAAM,EAAE,IAAI,QAAQ,CAAC,WAAW,EAAE,CAAC;QACtC,IAAI,CAAC,EAAE,CAAC,YAAY,CAAC,KAAK,CAAC,iEAAiE,CAAC,EAAE,CAAC;YAC9F,OAAO,cAAc,EAAE,CAAC,YAAY,gBAAgB,CAAC;QACvD,CAAC;QACD,IAAI,EAAE,CAAC,QAAQ,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;YAC7B,OAAO,cAAc,EAAE,CAAC,YAAY,kBAAkB,CAAC;QACzD,CAAC;QACD,IAAI,KAAK,GAAG,CAAC,CAAC;QACd,KAAK,MAAM,CAAC,IAAI,EAAE,CAAC,QAAQ,EAAE,CAAC;YAC5B,IAAI,CAAC,CAAC,CAAC,KAAK;gBAAE,OAAO,cAAc,EAAE,CAAC,YAAY,2BAA2B,CAAC;YAC9E,IAAI,CAAC,CAAC,MAAM,GAAG,CAAC;gBACd,OAAO,cAAc,EAAE,CAAC,YAAY,aAAa,CAAC,CAAC,KAAK,sBAAsB,CAAC;YACjF,KAAK,IAAI,CAAC,CAAC,MAAM,CAAC;QACpB,CAAC;QACD,IAAI,KAAK,KAAK,GAAG,EAAE,CAAC;YAClB,OAAO,cAAc,EAAE,CAAC,YAAY,4BAA4B,KAAK,gBAAgB,CAAC;QACxF,CAAC;IACH,CAAC;IACD,OAAO,IAAI,CAAC;AACd,CAAC"}
@@ -0,0 +1,31 @@
1
+ /**
2
+ * P15 — generic edge-router request handler. The provider-specific
3
+ * shims (gcp.ts, aws.ts, azure.ts) wrap this in their respective
4
+ * runtime contracts (Cloud Run handler, Lambda@Edge handler, Front
5
+ * Door rules engine), but the routing decision itself is identical.
6
+ *
7
+ * One function. Three runtimes. Same hash → same variant → same path
8
+ * rewrite, every time.
9
+ */
10
+ import { buildAssignmentLog } from "./assignment.js";
11
+ import { type RoutingManifest } from "./manifest.js";
12
+ export interface EdgeRequestSummary {
13
+ /** URL pathname only (e.g. "/about"). Query string ignored for routing. */
14
+ readonly pathname: string;
15
+ /** The current request's `caelo_visitor_id` cookie value, if any. */
16
+ readonly visitorIdCookie: string | null;
17
+ }
18
+ export interface EdgeRouteDecision {
19
+ /** Final pathname the runtime should serve (control or variant). */
20
+ readonly rewritePathname: string;
21
+ /** Visitor id the runtime should set as `caelo_visitor_id` (mint when absent). */
22
+ readonly setVisitorId: string;
23
+ /**
24
+ * Assignment-log payload to emit through the runtime's logger.
25
+ * Null when the request didn't match any experiment — runtime should
26
+ * NOT emit anything in that case (avoids log spam on every static asset).
27
+ */
28
+ readonly logEntry: ReturnType<typeof buildAssignmentLog> | null;
29
+ }
30
+ export declare function routeRequest(manifest: RoutingManifest, req: EdgeRequestSummary): EdgeRouteDecision;
31
+ //# sourceMappingURL=router.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"router.d.ts","sourceRoot":"","sources":["../src/router.ts"],"names":[],"mappings":"AAEA;;;;;;;;GAQG;AAEH,OAAO,EAAiB,kBAAkB,EAAiB,MAAM,iBAAiB,CAAC;AACnF,OAAO,EAAwB,KAAK,eAAe,EAAE,MAAM,eAAe,CAAC;AAE3E,MAAM,WAAW,kBAAkB;IACjC,2EAA2E;IAC3E,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,qEAAqE;IACrE,QAAQ,CAAC,eAAe,EAAE,MAAM,GAAG,IAAI,CAAC;CACzC;AAED,MAAM,WAAW,iBAAiB;IAChC,oEAAoE;IACpE,QAAQ,CAAC,eAAe,EAAE,MAAM,CAAC;IACjC,kFAAkF;IAClF,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B;;;;OAIG;IACH,QAAQ,CAAC,QAAQ,EAAE,UAAU,CAAC,OAAO,kBAAkB,CAAC,GAAG,IAAI,CAAC;CACjE;AAED,wBAAgB,YAAY,CAC1B,QAAQ,EAAE,eAAe,EACzB,GAAG,EAAE,kBAAkB,GACtB,iBAAiB,CAyBnB"}
package/dist/router.js ADDED
@@ -0,0 +1,39 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+ /**
3
+ * P15 — generic edge-router request handler. The provider-specific
4
+ * shims (gcp.ts, aws.ts, azure.ts) wrap this in their respective
5
+ * runtime contracts (Cloud Run handler, Lambda@Edge handler, Front
6
+ * Door rules engine), but the routing decision itself is identical.
7
+ *
8
+ * One function. Three runtimes. Same hash → same variant → same path
9
+ * rewrite, every time.
10
+ */
11
+ import { assignVariant, buildAssignmentLog, mintVisitorId } from "./assignment.js";
12
+ import { findExperimentForUrl } from "./manifest.js";
13
+ export function routeRequest(manifest, req) {
14
+ const visitorId = req.visitorIdCookie ?? mintVisitorId();
15
+ const experiment = findExperimentForUrl(manifest, req.pathname);
16
+ if (!experiment) {
17
+ return {
18
+ rewritePathname: req.pathname,
19
+ setVisitorId: visitorId,
20
+ logEntry: null,
21
+ };
22
+ }
23
+ const variant = assignVariant({
24
+ visitorId,
25
+ manifestVersion: manifest.manifestVersion,
26
+ experiment,
27
+ });
28
+ return {
29
+ rewritePathname: variant.path,
30
+ setVisitorId: visitorId,
31
+ logEntry: buildAssignmentLog({
32
+ experimentId: experiment.experimentId,
33
+ variant,
34
+ visitorId,
35
+ manifestVersion: manifest.manifestVersion,
36
+ }),
37
+ };
38
+ }
39
+ //# sourceMappingURL=router.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"router.js","sourceRoot":"","sources":["../src/router.ts"],"names":[],"mappings":"AAAA,mCAAmC;AAEnC;;;;;;;;GAQG;AAEH,OAAO,EAAE,aAAa,EAAE,kBAAkB,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAC;AACnF,OAAO,EAAE,oBAAoB,EAAwB,MAAM,eAAe,CAAC;AAsB3E,MAAM,UAAU,YAAY,CAC1B,QAAyB,EACzB,GAAuB;IAEvB,MAAM,SAAS,GAAG,GAAG,CAAC,eAAe,IAAI,aAAa,EAAE,CAAC;IACzD,MAAM,UAAU,GAAG,oBAAoB,CAAC,QAAQ,EAAE,GAAG,CAAC,QAAQ,CAAC,CAAC;IAChE,IAAI,CAAC,UAAU,EAAE,CAAC;QAChB,OAAO;YACL,eAAe,EAAE,GAAG,CAAC,QAAQ;YAC7B,YAAY,EAAE,SAAS;YACvB,QAAQ,EAAE,IAAI;SACf,CAAC;IACJ,CAAC;IACD,MAAM,OAAO,GAAG,aAAa,CAAC;QAC5B,SAAS;QACT,eAAe,EAAE,QAAQ,CAAC,eAAe;QACzC,UAAU;KACX,CAAC,CAAC;IACH,OAAO;QACL,eAAe,EAAE,OAAO,CAAC,IAAI;QAC7B,YAAY,EAAE,SAAS;QACvB,QAAQ,EAAE,kBAAkB,CAAC;YAC3B,YAAY,EAAE,UAAU,CAAC,YAAY;YACrC,OAAO;YACP,SAAS;YACT,eAAe,EAAE,QAAQ,CAAC,eAAe;SAC1C,CAAC;KACH,CAAC;AACJ,CAAC"}
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "@caelo-cms/edge-router",
3
+ "version": "0.10.23",
4
+ "private": false,
5
+ "license": "MPL-2.0",
6
+ "description": "Locale-aware edge request router for Caelo CMS deployments — shared by the provisioning stacks' edge handlers (CDN URL-strategy routing) and the static generator.",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/caelo-cms/caelo-cms.git",
10
+ "directory": "packages/edge-router"
11
+ },
12
+ "homepage": "https://github.com/caelo-cms/caelo-cms#readme",
13
+ "bugs": "https://github.com/caelo-cms/caelo-cms/issues",
14
+ "type": "module",
15
+ "main": "./dist/index.js",
16
+ "types": "./dist/index.d.ts",
17
+ "exports": {
18
+ ".": {
19
+ "types": "./dist/index.d.ts",
20
+ "bun": "./src/index.ts",
21
+ "development": "./src/index.ts",
22
+ "import": "./dist/index.js",
23
+ "default": "./dist/index.js"
24
+ }
25
+ },
26
+ "files": [
27
+ "dist/",
28
+ "src/",
29
+ "README.md",
30
+ "package.json"
31
+ ],
32
+ "scripts": {
33
+ "typecheck": "tsc -b",
34
+ "build": "tsc -b --force",
35
+ "prepublishOnly": "tsc -b --force"
36
+ },
37
+ "dependencies": {}
38
+ }
@@ -0,0 +1,128 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * P15 — stable-hash A/B variant assignment.
5
+ *
6
+ * MUST produce byte-identical results across all four runtimes:
7
+ * - self-hosted (P13 Caddy gateway)
8
+ * - GCP edge router (Cloud Run header rule)
9
+ * - AWS edge router (Lambda@Edge)
10
+ * - Azure edge router (Front Door rules engine)
11
+ *
12
+ * Drift between runtimes means a visitor sees different variants
13
+ * depending on which provider serves them — silent A/B contamination.
14
+ * The byte-identity test in `index.test.ts` asserts a fixed corpus of
15
+ * (visitorId, manifestVersion, experimentId) tuples maps to the same
16
+ * variant label on every implementation.
17
+ *
18
+ * Algorithm: FNV-1a 32-bit hash of (visitorId + ":" + experimentId +
19
+ * ":" + manifestVersion). The 32-bit space mod 100 gives a bucket in
20
+ * [0, 99]; cumulative variant weights determine the variant. Pure +
21
+ * deterministic; no randomness, no time, no provider-specific code.
22
+ */
23
+
24
+ import type { ManifestExperiment, ManifestVariant } from "./manifest.js";
25
+
26
+ /**
27
+ * FNV-1a 32-bit. Standard offset basis 0x811c9dc5, prime 0x01000193.
28
+ * Implemented in plain TypeScript so every runtime (V8 / SpiderMonkey
29
+ * / Node L@E sandbox / Bun) produces byte-identical output.
30
+ */
31
+ export function fnv1a32(input: string): number {
32
+ let hash = 0x811c9dc5;
33
+ for (let i = 0; i < input.length; i += 1) {
34
+ hash ^= input.charCodeAt(i);
35
+ // Multiply by FNV prime, keeping in 32-bit unsigned range. Math.imul
36
+ // is the cross-runtime way to do 32-bit-truncated integer multiply.
37
+ hash = Math.imul(hash, 0x01000193);
38
+ }
39
+ // Coerce back to unsigned 32-bit.
40
+ return hash >>> 0;
41
+ }
42
+
43
+ /**
44
+ * Compute the cumulative bucket boundaries for an experiment's variants.
45
+ * Stable: same input order → same boundaries → same assignment.
46
+ */
47
+ function bucketBoundaries(variants: ReadonlyArray<ManifestVariant>): number[] {
48
+ let cum = 0;
49
+ return variants.map((v) => {
50
+ cum += v.weight;
51
+ return cum;
52
+ });
53
+ }
54
+
55
+ /**
56
+ * Assign a variant to a visitor for one experiment. Inputs:
57
+ * - visitorId: stable opaque string from the `caelo_visitor_id` cookie.
58
+ * - manifestVersion: bumped per deploy so a re-bucketing rolls out
59
+ * atomically (operators occasionally need to re-randomize after a
60
+ * biased early sample).
61
+ * - experiment: the matched ManifestExperiment.
62
+ *
63
+ * Returns the chosen variant + its rewrite path. Pure; no I/O.
64
+ */
65
+ export function assignVariant(opts: {
66
+ readonly visitorId: string;
67
+ readonly manifestVersion: string;
68
+ readonly experiment: ManifestExperiment;
69
+ }): ManifestVariant {
70
+ const key = `${opts.visitorId}:${opts.experiment.experimentId}:${opts.manifestVersion}`;
71
+ const hash = fnv1a32(key);
72
+ const bucket = hash % 100;
73
+ const boundaries = bucketBoundaries(opts.experiment.variants);
74
+ for (let i = 0; i < boundaries.length; i += 1) {
75
+ if (bucket < (boundaries[i] ?? 0)) {
76
+ const v = opts.experiment.variants[i];
77
+ if (v) return v;
78
+ }
79
+ }
80
+ // Fallthrough (weights summed to 100 but float drift) — return last.
81
+ // Schema-level validation guarantees this doesn't happen in practice.
82
+ return opts.experiment.variants[opts.experiment.variants.length - 1] as ManifestVariant;
83
+ }
84
+
85
+ /**
86
+ * Format the assignment-log entry every runtime emits. Same JSON shape
87
+ * across providers so the P12A analytics plugin's per-provider log
88
+ * adapters can normalise into one `ab_assignment_aggregates` query.
89
+ */
90
+ export interface AssignmentLogEntry {
91
+ readonly kind: "ab_assignment";
92
+ readonly experimentId: string;
93
+ readonly variantLabel: string;
94
+ readonly visitorId: string;
95
+ readonly manifestVersion: string;
96
+ readonly tsMs: number;
97
+ }
98
+
99
+ export function buildAssignmentLog(opts: {
100
+ readonly experimentId: string;
101
+ readonly variant: ManifestVariant;
102
+ readonly visitorId: string;
103
+ readonly manifestVersion: string;
104
+ readonly nowMs?: number;
105
+ }): AssignmentLogEntry {
106
+ return {
107
+ kind: "ab_assignment",
108
+ experimentId: opts.experimentId,
109
+ variantLabel: opts.variant.label,
110
+ visitorId: opts.visitorId,
111
+ manifestVersion: opts.manifestVersion,
112
+ tsMs: opts.nowMs ?? Date.now(),
113
+ };
114
+ }
115
+
116
+ /**
117
+ * Mint a stable opaque visitor id when the request didn't carry the
118
+ * `caelo_visitor_id` cookie. Format: 16 hex bytes (128 bits — collision
119
+ * resistant for any plausible visitor population). Edge routers set
120
+ * this on the response cookie so the next request inherits the same id.
121
+ */
122
+ export function mintVisitorId(): string {
123
+ const buf = new Uint8Array(16);
124
+ // crypto.getRandomValues is available in V8 / Node / L@E / Bun /
125
+ // every modern runtime; no Node Buffer fallback needed.
126
+ crypto.getRandomValues(buf);
127
+ return [...buf].map((b) => b.toString(16).padStart(2, "0")).join("");
128
+ }
@@ -0,0 +1,254 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * Edge-router byte-identity tests. These assert a fixed corpus of
5
+ * (visitorId, manifestVersion, experimentId) → (bucket, variantLabel)
6
+ * tuples. The numbers in this file are the ground truth; if you change
7
+ * the assignment algorithm, you change the contract for ALL FOUR
8
+ * runtime implementations (self-hosted P13 + GCP + AWS + Azure) and
9
+ * silently re-bucket every existing visitor's variant assignment. Don't
10
+ * change without bumping the manifestVersion in the same commit.
11
+ */
12
+
13
+ import { describe, expect, it } from "bun:test";
14
+ import { assignVariant, buildAssignmentLog, fnv1a32, mintVisitorId } from "./assignment.js";
15
+ import { findExperimentForUrl, validateManifest } from "./manifest.js";
16
+ import { routeRequest } from "./router.js";
17
+
18
+ describe("fnv1a32", () => {
19
+ it("matches the published FNV-1a-32 reference for a fixed corpus", () => {
20
+ // Reference values produced via the canonical algorithm —
21
+ // independent reimplementations against this list will catch
22
+ // off-by-one + signed-vs-unsigned bugs.
23
+ expect(fnv1a32("")).toBe(0x811c9dc5);
24
+ expect(fnv1a32("a")).toBe(0xe40c292c);
25
+ expect(fnv1a32("foobar")).toBe(0xbf9cf968);
26
+ });
27
+
28
+ it("is stable for identical input", () => {
29
+ const a = fnv1a32("visitor-12345:exp-abc:v3");
30
+ const b = fnv1a32("visitor-12345:exp-abc:v3");
31
+ expect(a).toBe(b);
32
+ });
33
+ });
34
+
35
+ describe("assignVariant", () => {
36
+ const experiment = {
37
+ pageSlug: "/home",
38
+ experimentId: "11111111-2222-3333-4444-555555555555",
39
+ variants: [
40
+ { label: "A", weight: 50, path: "/home" },
41
+ {
42
+ label: "B",
43
+ weight: 50,
44
+ path: "/_caelo-variant/11111111-2222-3333-4444-555555555555/B/home",
45
+ },
46
+ ],
47
+ } as const;
48
+
49
+ it("returns the same variant for the same (visitorId, manifestVersion, experimentId)", () => {
50
+ const a = assignVariant({
51
+ visitorId: "v-stable-1",
52
+ manifestVersion: "7",
53
+ experiment,
54
+ });
55
+ const b = assignVariant({
56
+ visitorId: "v-stable-1",
57
+ manifestVersion: "7",
58
+ experiment,
59
+ });
60
+ expect(a.label).toBe(b.label);
61
+ });
62
+
63
+ it("re-buckets when manifestVersion bumps (operator-triggered re-randomization)", () => {
64
+ // The whole point of bumping manifestVersion is to roll the dice
65
+ // afresh; for at least ONE visitor in 100, the assignment should
66
+ // differ between v=1 and v=2. Assert across a small population.
67
+ let differs = 0;
68
+ for (let i = 0; i < 100; i += 1) {
69
+ const v1 = assignVariant({
70
+ visitorId: `vis-${i}`,
71
+ manifestVersion: "1",
72
+ experiment,
73
+ });
74
+ const v2 = assignVariant({
75
+ visitorId: `vis-${i}`,
76
+ manifestVersion: "2",
77
+ experiment,
78
+ });
79
+ if (v1.label !== v2.label) differs += 1;
80
+ }
81
+ expect(differs).toBeGreaterThan(20);
82
+ });
83
+
84
+ it("respects bucket weights — 70/30 split lands within ±10pp over 1000 visitors", () => {
85
+ const skewed = {
86
+ pageSlug: "/pricing",
87
+ experimentId: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
88
+ variants: [
89
+ { label: "A", weight: 70, path: "/pricing" },
90
+ {
91
+ label: "B",
92
+ weight: 30,
93
+ path: "/_caelo-variant/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee/B/pricing",
94
+ },
95
+ ],
96
+ } as const;
97
+ let aCount = 0;
98
+ for (let i = 0; i < 1000; i += 1) {
99
+ const v = assignVariant({
100
+ visitorId: `vis-${i}`,
101
+ manifestVersion: "1",
102
+ experiment: skewed,
103
+ });
104
+ if (v.label === "A") aCount += 1;
105
+ }
106
+ expect(aCount).toBeGreaterThan(600);
107
+ expect(aCount).toBeLessThan(800);
108
+ });
109
+
110
+ it("byte-identity corpus — these exact assignments must hold across all runtimes", () => {
111
+ // If this test starts failing because someone "improved" the hash,
112
+ // STOP. Every runtime's edge router (self-hosted Caddy gateway,
113
+ // GCP Cloud Run, AWS L@E, Azure Front Door) consumes this same
114
+ // contract. Change here = silent re-bucketing of every live
115
+ // experiment.
116
+ const corpus = [
117
+ { visitorId: "v-aaaaaaaa", manifestVersion: "1", expectedLabel: "A" },
118
+ { visitorId: "v-bbbbbbbb", manifestVersion: "1", expectedLabel: "A" },
119
+ { visitorId: "v-cccccccc", manifestVersion: "1", expectedLabel: "B" },
120
+ { visitorId: "v-dddddddd", manifestVersion: "1", expectedLabel: "A" },
121
+ { visitorId: "v-eeeeeeee", manifestVersion: "1", expectedLabel: "B" },
122
+ ];
123
+ for (const tc of corpus) {
124
+ const v = assignVariant({
125
+ visitorId: tc.visitorId,
126
+ manifestVersion: tc.manifestVersion,
127
+ experiment,
128
+ });
129
+ expect(v.label).toBe(tc.expectedLabel);
130
+ }
131
+ });
132
+ });
133
+
134
+ describe("validateManifest", () => {
135
+ const baseExperiment = {
136
+ pageSlug: "/p",
137
+ experimentId: "22222222-3333-4444-5555-666666666666",
138
+ variants: [
139
+ { label: "A", weight: 50, path: "/p" },
140
+ { label: "B", weight: 50, path: "/_caelo-variant/22222222-3333-4444-5555-666666666666/B/p" },
141
+ ],
142
+ } as const;
143
+
144
+ it("accepts a clean manifest", () => {
145
+ expect(validateManifest({ manifestVersion: "1", experiments: [baseExperiment] })).toBeNull();
146
+ });
147
+
148
+ it("rejects non-UUID experimentId", () => {
149
+ const bad = { ...baseExperiment, experimentId: "not-a-uuid" };
150
+ expect(validateManifest({ manifestVersion: "1", experiments: [bad] })).toContain("not a UUID");
151
+ });
152
+
153
+ it("rejects weights that don't sum to 100", () => {
154
+ const bad = {
155
+ ...baseExperiment,
156
+ variants: [
157
+ { label: "A", weight: 60, path: "/p" },
158
+ { label: "B", weight: 30, path: "/p-b" },
159
+ ],
160
+ };
161
+ expect(validateManifest({ manifestVersion: "1", experiments: [bad] })).toContain("sum to 90");
162
+ });
163
+ });
164
+
165
+ describe("routeRequest", () => {
166
+ const manifest = {
167
+ manifestVersion: "1",
168
+ experiments: [
169
+ {
170
+ pageSlug: "/home",
171
+ experimentId: "33333333-4444-5555-6666-777777777777",
172
+ variants: [
173
+ { label: "A", weight: 50, path: "/home" },
174
+ {
175
+ label: "B",
176
+ weight: 50,
177
+ path: "/_caelo-variant/33333333-4444-5555-6666-777777777777/B/home",
178
+ },
179
+ ],
180
+ },
181
+ ],
182
+ };
183
+
184
+ it("passes-through requests that don't match any experiment", () => {
185
+ const out = routeRequest(manifest, { pathname: "/about", visitorIdCookie: "v-1" });
186
+ expect(out.rewritePathname).toBe("/about");
187
+ expect(out.setVisitorId).toBe("v-1");
188
+ expect(out.logEntry).toBeNull();
189
+ });
190
+
191
+ it("rewrites + emits log when the request matches", () => {
192
+ const out = routeRequest(manifest, { pathname: "/home", visitorIdCookie: "v-aaaaaaaa" });
193
+ expect(["/home", "/_caelo-variant/33333333-4444-5555-6666-777777777777/B/home"]).toContain(
194
+ out.rewritePathname,
195
+ );
196
+ expect(out.logEntry).not.toBeNull();
197
+ expect(out.logEntry?.kind).toBe("ab_assignment");
198
+ expect(out.logEntry?.experimentId).toBe("33333333-4444-5555-6666-777777777777");
199
+ });
200
+
201
+ it("mints a visitor id when the cookie is absent", () => {
202
+ const out = routeRequest(manifest, { pathname: "/home", visitorIdCookie: null });
203
+ expect(out.setVisitorId).toMatch(/^[0-9a-f]{32}$/);
204
+ });
205
+ });
206
+
207
+ describe("findExperimentForUrl", () => {
208
+ it("finds by exact pageSlug match", () => {
209
+ const m = {
210
+ manifestVersion: "1",
211
+ experiments: [
212
+ {
213
+ pageSlug: "/home",
214
+ experimentId: "44444444-5555-6666-7777-888888888888",
215
+ variants: [{ label: "A", weight: 100, path: "/home" }],
216
+ },
217
+ ],
218
+ };
219
+ expect(findExperimentForUrl(m, "/home")?.experimentId).toBe(
220
+ "44444444-5555-6666-7777-888888888888",
221
+ );
222
+ expect(findExperimentForUrl(m, "/about")).toBeNull();
223
+ });
224
+ });
225
+
226
+ describe("buildAssignmentLog", () => {
227
+ it("produces the canonical log shape", () => {
228
+ const entry = buildAssignmentLog({
229
+ experimentId: "55555555-6666-7777-8888-999999999999",
230
+ variant: { label: "B", weight: 50, path: "/x" },
231
+ visitorId: "v-1",
232
+ manifestVersion: "3",
233
+ nowMs: 1700000000000,
234
+ });
235
+ expect(entry).toEqual({
236
+ kind: "ab_assignment",
237
+ experimentId: "55555555-6666-7777-8888-999999999999",
238
+ variantLabel: "B",
239
+ visitorId: "v-1",
240
+ manifestVersion: "3",
241
+ tsMs: 1700000000000,
242
+ });
243
+ });
244
+ });
245
+
246
+ describe("mintVisitorId", () => {
247
+ it("returns 32 hex chars", () => {
248
+ expect(mintVisitorId()).toMatch(/^[0-9a-f]{32}$/);
249
+ });
250
+
251
+ it("each call is unique", () => {
252
+ expect(mintVisitorId()).not.toBe(mintVisitorId());
253
+ });
254
+ });
package/src/index.ts ADDED
@@ -0,0 +1,28 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * @caelo-cms/edge-router — P15.
5
+ *
6
+ * Provider-agnostic edge-routing logic for A/B experiments. The same
7
+ * hash → same variant → same rewrite path holds across self-hosted
8
+ * (P13 Caddy gateway) + GCP + AWS + Azure. Provider-specific shims
9
+ * land in `packages/provisioning/stacks/<provider>/edge-handler.ts`
10
+ * and import this package's `routeRequest` for the actual decision.
11
+ */
12
+
13
+ export {
14
+ type AssignmentLogEntry,
15
+ assignVariant,
16
+ buildAssignmentLog,
17
+ fnv1a32,
18
+ mintVisitorId,
19
+ } from "./assignment.js";
20
+ export {
21
+ EMPTY_MANIFEST,
22
+ findExperimentForUrl,
23
+ type ManifestExperiment,
24
+ type ManifestVariant,
25
+ type RoutingManifest,
26
+ validateManifest,
27
+ } from "./manifest.js";
28
+ export { type EdgeRequestSummary, type EdgeRouteDecision, routeRequest } from "./router.js";
@@ -0,0 +1,94 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * P15 — A/B routing manifest. Static-generator emits this file at deploy
5
+ * time (P6 + P13 contract). Per-provider edge routers (Cloud CDN /
6
+ * CloudFront L@E / Front Door rules / self-hosted Caddy gateway) all
7
+ * read this single shape so behaviour is identical across runtimes.
8
+ *
9
+ * Variant URL paths follow the convention:
10
+ * control: /<page-slug>
11
+ * variant: /_caelo-variant/<experimentId>/<variantLabel>/<page-slug>
12
+ *
13
+ * (Picked path-based over query-string-based so CDN cache keys aren't
14
+ * affected by query-string normalisation. See plans/phases/phase_15.)
15
+ */
16
+
17
+ export interface ManifestVariant {
18
+ /** Owner-readable label (e.g. "A", "B", "control"). Stable across deploys. */
19
+ readonly label: string;
20
+ /** Bucket weight in [0, 100]. All variants for one experiment must sum to 100. */
21
+ readonly weight: number;
22
+ /**
23
+ * Path the edge router rewrites to when this variant wins. Includes
24
+ * the /_caelo-variant prefix for non-control variants; equal to
25
+ * `pageSlug` for control.
26
+ */
27
+ readonly path: string;
28
+ }
29
+
30
+ export interface ManifestExperiment {
31
+ /** Page slug the experiment runs on (e.g. "/home", "/pricing"). */
32
+ readonly pageSlug: string;
33
+ /** UUID — also the cookie partition key so two experiments don't collide. */
34
+ readonly experimentId: string;
35
+ readonly variants: ReadonlyArray<ManifestVariant>;
36
+ }
37
+
38
+ export interface RoutingManifest {
39
+ /** Bumped by the static generator on every deploy that changes routing. */
40
+ readonly manifestVersion: string;
41
+ /** All active experiments (only `status='active'` rows from cms_admin.experiments). */
42
+ readonly experiments: ReadonlyArray<ManifestExperiment>;
43
+ }
44
+
45
+ export const EMPTY_MANIFEST: RoutingManifest = {
46
+ manifestVersion: "0",
47
+ experiments: [],
48
+ };
49
+
50
+ /**
51
+ * Look up the active experiment for a given request URL. Returns null
52
+ * when no experiment matches the URL's page slug — caller should pass
53
+ * the request through unchanged.
54
+ */
55
+ export function findExperimentForUrl(
56
+ manifest: RoutingManifest,
57
+ pathname: string,
58
+ ): ManifestExperiment | null {
59
+ for (const ex of manifest.experiments) {
60
+ if (ex.pageSlug === pathname) return ex;
61
+ }
62
+ return null;
63
+ }
64
+
65
+ /**
66
+ * Validates a manifest's invariants: every experiment's variant weights
67
+ * sum to 100, every variant has a non-empty label, the experimentId is
68
+ * a UUID, weights are non-negative. Returns null on success or a
69
+ * structured error string the static generator surfaces in audit. The
70
+ * runtime callers (edge routers) do NOT re-validate per request — they
71
+ * trust the deploy-time validation. This function is primarily for
72
+ * tests + the deploy step.
73
+ */
74
+ export function validateManifest(manifest: RoutingManifest): string | null {
75
+ for (const ex of manifest.experiments) {
76
+ if (!ex.experimentId.match(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i)) {
77
+ return `experiment ${ex.experimentId} is not a UUID`;
78
+ }
79
+ if (ex.variants.length === 0) {
80
+ return `experiment ${ex.experimentId} has no variants`;
81
+ }
82
+ let total = 0;
83
+ for (const v of ex.variants) {
84
+ if (!v.label) return `experiment ${ex.experimentId}: variant has empty label`;
85
+ if (v.weight < 0)
86
+ return `experiment ${ex.experimentId}: variant ${v.label} has negative weight`;
87
+ total += v.weight;
88
+ }
89
+ if (total !== 100) {
90
+ return `experiment ${ex.experimentId}: variant weights sum to ${total}, expected 100`;
91
+ }
92
+ }
93
+ return null;
94
+ }
package/src/router.ts ADDED
@@ -0,0 +1,64 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * P15 — generic edge-router request handler. The provider-specific
5
+ * shims (gcp.ts, aws.ts, azure.ts) wrap this in their respective
6
+ * runtime contracts (Cloud Run handler, Lambda@Edge handler, Front
7
+ * Door rules engine), but the routing decision itself is identical.
8
+ *
9
+ * One function. Three runtimes. Same hash → same variant → same path
10
+ * rewrite, every time.
11
+ */
12
+
13
+ import { assignVariant, buildAssignmentLog, mintVisitorId } from "./assignment.js";
14
+ import { findExperimentForUrl, type RoutingManifest } from "./manifest.js";
15
+
16
+ export interface EdgeRequestSummary {
17
+ /** URL pathname only (e.g. "/about"). Query string ignored for routing. */
18
+ readonly pathname: string;
19
+ /** The current request's `caelo_visitor_id` cookie value, if any. */
20
+ readonly visitorIdCookie: string | null;
21
+ }
22
+
23
+ export interface EdgeRouteDecision {
24
+ /** Final pathname the runtime should serve (control or variant). */
25
+ readonly rewritePathname: string;
26
+ /** Visitor id the runtime should set as `caelo_visitor_id` (mint when absent). */
27
+ readonly setVisitorId: string;
28
+ /**
29
+ * Assignment-log payload to emit through the runtime's logger.
30
+ * Null when the request didn't match any experiment — runtime should
31
+ * NOT emit anything in that case (avoids log spam on every static asset).
32
+ */
33
+ readonly logEntry: ReturnType<typeof buildAssignmentLog> | null;
34
+ }
35
+
36
+ export function routeRequest(
37
+ manifest: RoutingManifest,
38
+ req: EdgeRequestSummary,
39
+ ): EdgeRouteDecision {
40
+ const visitorId = req.visitorIdCookie ?? mintVisitorId();
41
+ const experiment = findExperimentForUrl(manifest, req.pathname);
42
+ if (!experiment) {
43
+ return {
44
+ rewritePathname: req.pathname,
45
+ setVisitorId: visitorId,
46
+ logEntry: null,
47
+ };
48
+ }
49
+ const variant = assignVariant({
50
+ visitorId,
51
+ manifestVersion: manifest.manifestVersion,
52
+ experiment,
53
+ });
54
+ return {
55
+ rewritePathname: variant.path,
56
+ setVisitorId: visitorId,
57
+ logEntry: buildAssignmentLog({
58
+ experimentId: experiment.experimentId,
59
+ variant,
60
+ visitorId,
61
+ manifestVersion: manifest.manifestVersion,
62
+ }),
63
+ };
64
+ }