@gradial/aci 0.1.52-rc.9 → 0.1.52
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/CHANGELOG.md +126 -0
- package/README.md +86 -5
- package/dist/astro/error-page.js +1 -0
- package/dist/astro/index.js +4 -0
- package/dist/astro/page.d.ts +5 -2
- package/dist/astro/page.js +35 -23
- package/dist/astro/preview.js +2 -17
- package/dist/audience-data-models.d.ts +308 -197
- package/dist/audience-data-models.js +313 -51
- package/dist/cli/compile-registry.mjs +6 -34
- package/dist/cli/mintlify-render.mjs +8 -0
- package/dist/collect/aci.js +1 -1
- package/dist/collect/aci.js.sha256 +1 -1
- package/dist/collect/api.d.ts +6 -7
- package/dist/collect/api.js +21 -8
- package/dist/collect/assignments.d.ts +7 -9
- package/dist/collect/assignments.js +23 -32
- package/dist/collect/batcher.d.ts +9 -7
- package/dist/collect/batcher.js +6 -5
- package/dist/collect/consent/envelope.d.ts +7 -1
- package/dist/collect/consent/envelope.js +3 -1
- package/dist/collect/consent/mapping.d.ts +2 -0
- package/dist/collect/consent/mapping.js +1 -0
- package/dist/collect/consenthold.d.ts +38 -0
- package/dist/collect/consenthold.js +82 -0
- package/dist/collect/declarative.js +7 -5
- package/dist/collect/gapbuffer.d.ts +13 -4
- package/dist/collect/gapbuffer.js +21 -10
- package/dist/collect/index.js +78 -21
- package/dist/collect/pageviews.d.ts +11 -11
- package/dist/collect/pageviews.js +27 -22
- package/dist/consent-banner/index.d.ts +17 -0
- package/dist/consent-banner/index.js +23 -0
- package/dist/content/index.d.ts +2 -1
- package/dist/content/index.js +4 -1
- package/dist/content/provider.d.ts +16 -2
- package/dist/content/provider.js +5 -1
- package/dist/content/resolve-slots.d.ts +24 -2
- package/dist/content/resolve-slots.js +127 -21
- package/dist/content/types.d.ts +5 -1
- package/dist/edge/expression-capabilities.v1.d.ts +106 -0
- package/dist/edge/expression-capabilities.v1.js +165 -0
- package/dist/index.d.ts +1 -2
- package/dist/index.js +0 -1
- package/dist/internal/domain/edge/identity.d.ts +47 -4
- package/dist/internal/domain/edge/identity.js +130 -26
- package/dist/internal/domain/edge/index.d.ts +1 -1
- package/dist/internal/domain/edge/index.js +1 -1
- package/dist/internal/domain/edge/mimic_token.d.ts +14 -0
- package/dist/internal/domain/edge/mimic_token.js +125 -0
- package/dist/internal/domain/edge/personalization-core.d.ts +61 -22
- package/dist/internal/domain/edge/personalization-core.js +790 -200
- package/dist/internal/domain/edge/personalization_index.d.ts +17 -0
- package/dist/internal/domain/edge/personalization_index.js +55 -0
- package/dist/internal/domain/edge/resolve.js +76 -39
- package/dist/internal/domain/edge/testdata/flat_only_validator_v1.d.ts +10 -0
- package/dist/internal/domain/edge/testdata/flat_only_validator_v1.js +253 -0
- package/dist/internal/domain/edge/types.d.ts +82 -78
- package/dist/internal/domain/edge/types.js +3 -3
- package/dist/internal/domain/edge/variant_key.d.ts +4 -0
- package/dist/internal/domain/edge/variant_key.js +56 -0
- package/dist/internal/platform/collectionstore/errors.d.ts +24 -0
- package/dist/internal/platform/collectionstore/errors.js +48 -0
- package/dist/mintlify/blocks.d.ts +6 -0
- package/dist/mintlify/blocks.js +201 -0
- package/dist/mintlify/config.d.ts +3 -0
- package/dist/mintlify/config.js +77 -0
- package/dist/mintlify/index.d.ts +3 -0
- package/dist/mintlify/index.js +2 -0
- package/dist/mintlify/json.d.ts +8 -0
- package/dist/mintlify/json.js +37 -0
- package/dist/mintlify/materialize.d.ts +14 -0
- package/dist/mintlify/materialize.js +254 -0
- package/dist/mintlify/types.d.ts +74 -0
- package/dist/mintlify/types.js +1 -0
- package/dist/mintlify/validate.d.ts +7 -0
- package/dist/mintlify/validate.js +256 -0
- package/dist/next/assignments-navigation.d.ts +4 -0
- package/dist/next/assignments-navigation.js +18 -0
- package/dist/next/assignments-script.d.ts +3 -2
- package/dist/next/assignments-script.js +5 -5
- package/dist/next/error-pages.d.ts +4 -0
- package/dist/next/error-pages.js +4 -0
- package/dist/next/error-seat-payload.d.ts +2 -1
- package/dist/next/error-seat-payload.js +14 -24
- package/dist/next/error-seat.js +7 -2
- package/dist/next/index.d.ts +1 -1
- package/dist/next/index.js +3 -1
- package/dist/next/page.d.ts +2 -1
- package/dist/next/page.js +15 -4
- package/dist/next/release-context.d.ts +30 -0
- package/dist/next/release-context.js +56 -0
- package/dist/next/render.d.ts +1 -1
- package/dist/next/render.js +15 -5
- package/dist/next/server.d.ts +9 -3
- package/dist/next/server.js +17 -32
- package/dist/preview/core.d.ts +1 -0
- package/dist/preview/core.js +27 -0
- package/dist/react/VideoPlayer.d.ts +3 -3
- package/dist/react/index.d.ts +3 -3
- package/dist/registry.d.ts +4 -2
- package/dist/testing/conformance/checks.d.ts +2 -2
- package/dist/testing/conformance/checks.js +2 -0
- package/dist/testing/conformance/index.d.ts +1 -0
- package/dist/testing/conformance/index.js +1 -0
- package/dist/testing/conformance/next-stubs.d.ts +6 -0
- package/dist/testing/conformance/next-stubs.js +9 -0
- package/dist/testing/conformance/preset.d.ts +3 -2
- package/dist/testing/conformance/preset.js +15 -8
- package/dist/testing/conformance/root-identity.d.ts +31 -0
- package/dist/testing/conformance/root-identity.js +361 -0
- package/dist/testing/conformance/stubs/next-image.d.ts +7 -0
- package/dist/testing/conformance/stubs/next-image.js +5 -0
- package/dist/testing/conformance/stubs/next-link.d.ts +7 -0
- package/dist/testing/conformance/stubs/next-link.js +4 -0
- package/dist/testing/conformance/types.d.ts +5 -2
- package/dist/testing/index.d.ts +1 -1
- package/dist/testing/index.js +4 -3
- package/dist/types/block-ref.js +2 -2
- package/dist/types/component.d.ts +9 -0
- package/dist/types/config.d.ts +0 -1
- package/dist/types/index.d.ts +0 -1
- package/dist/types/index.js +0 -1
- package/dist/types/page.d.ts +0 -1
- package/migrations/conformance-registry.md +22 -0
- package/migrations/experiment-goal-ids.md +43 -0
- package/package.json +21 -7
- package/src/cli/compile-registry.mjs +6 -30
- package/src/cli/mintlify-render.mjs +9 -0
- package/src/types/block-ref.test.ts +16 -1
- package/src/types/block-ref.ts +2 -2
- package/src/types/component.ts +10 -0
- package/src/types/config.ts +0 -1
- package/src/types/index.ts +0 -1
- package/src/types/page.ts +0 -1
- package/templates/astro/starter-content/src/components/chrome/Footer.astro +5 -3
- package/templates/astro/starter-content/src/components/chrome/Header.astro +5 -3
- package/templates/astro/starter-content/src/components/elements/Button.astro +5 -2
- package/templates/astro/starter-content/src/components/elements/ButtonBlock.astro +5 -3
- package/templates/astro/starter-content/src/components/sections/ContainerSection.astro +4 -3
- package/templates/astro/starter-content/src/components/sections/FeatureCard.astro +4 -3
- package/templates/astro/starter-content/src/components/sections/FeatureGrid.astro +4 -3
- package/templates/astro/starter-content/src/components/sections/HomeHero.astro +5 -3
- package/templates/astro/starter-content/src/components/sections/PersonalizationChoice.astro +4 -3
- package/templates/astro/starter-content/src/components/sections/StatsBar.astro +4 -3
- package/templates/astro/starter-content/src/fixtures/sections.ts +1 -1
- package/templates/astro/starter-content/src/stories/CompositionStoryFrame.astro +7 -2
- package/templates/astro/template/.aci.yaml.tmpl +0 -1
- package/templates/astro/template/package.json.tmpl +5 -5
- package/templates/astro/template/src/layouts/SiteLayout.astro +2 -2
- package/templates/common/.agents/skills/aci/SKILL.md +58 -1
- package/templates/common/.agents/skills/authoring/SKILL.md +194 -42
- package/templates/common/.agents/skills/authoring/references/block-registry.md +19 -3
- package/templates/common/.agents/skills/authoring/references/contracts.md +9 -30
- package/templates/common/.agents/skills/authoring/references/repo-structure.md +5 -4
- package/templates/common/.agents/skills/authoring/references/sdk-server-functions.md +3 -3
- package/templates/common/.agents/skills/dam-delivery/SKILL.md +216 -0
- package/templates/common/.agents/skills/dam-delivery/references/permalink-grammar.md +167 -0
- package/templates/common/.agents/skills/migration/references/canon-migration.md +18 -67
- package/templates/common/.agents/skills/migration/references/selector-map.md +1 -1
- package/templates/common/.content/fragments/navbar/_index.json +24 -0
- package/templates/common/.content/fragments/site-footer/_index.json +61 -0
- package/templates/common/.content/pages/404/_index.json +4 -2
- package/templates/common/.content/pages/_index.json +0 -1
- package/templates/common/.content/pages/error/_index.json +4 -2
- package/templates/common/.content/schemas/site.schema.json +11 -1
- package/templates/common/.content/templates/error.json +24 -0
- package/templates/common/.content/templates/home.json +28 -0
- package/templates/common/.content/templates/site.json +8 -0
- package/templates/common/.content/templates/standard.json +24 -0
- package/templates/common/.nvmrc +1 -1
- package/templates/common/starter-content/.content/config/site.json +1 -1
- package/templates/common/starter-content/.content/dam/README.md +74 -0
- package/templates/common/starter-content/.content/dam/folders/seed/{ast_site_about_hero → 000000000000000000000000101}/asset.json +1 -1
- package/templates/common/starter-content/.content/dam/folders/seed/{ast_site_home_hero → 000000000000000000000000102}/asset.json +1 -1
- package/templates/common/starter-content/.content/pages/404/_index.json +1 -2
- package/templates/common/starter-content/.content/pages/{home/_index.json → _index.json} +14 -3
- package/templates/common/starter-content/.content/pages/about/_index.json +2 -2
- package/templates/common/starter-content/.content/pages/error/_index.json +1 -2
- package/templates/common/starter-content/.content/patterns/components/button.json +2 -2
- package/templates/common/starter-content/.content/patterns/components/container.json +8 -3
- package/templates/common/starter-content/.content/patterns/components/feature_card.json +2 -2
- package/templates/common/starter-content/.content/patterns/components/feature_grid.json +5 -3
- package/templates/common/starter-content/.content/patterns/components/home_hero.json +2 -2
- package/templates/common/starter-content/.content/patterns/components/personalization_choice.json +2 -2
- package/templates/common/starter-content/.content/patterns/components/site-footer.json +2 -2
- package/templates/common/starter-content/.content/patterns/components/site-navigation.json +2 -2
- package/templates/common/starter-content/.content/patterns/components/stats_bar.json +2 -2
- package/templates/common/starter-content/.content/schemas/site.schema.json +11 -1
- package/templates/nextjs/starter-content/src/components/chrome/Footer.tsx +4 -3
- package/templates/nextjs/starter-content/src/components/chrome/Header.tsx +4 -3
- package/templates/nextjs/starter-content/src/components/elements/Button.tsx +4 -3
- package/templates/nextjs/starter-content/src/components/elements/ButtonBlock.tsx +4 -3
- package/templates/nextjs/starter-content/src/components/sections/ContainerSection.tsx +4 -2
- package/templates/nextjs/starter-content/src/components/sections/FeatureCard.tsx +4 -3
- package/templates/nextjs/starter-content/src/components/sections/FeatureGrid.tsx +4 -2
- package/templates/nextjs/starter-content/src/components/sections/HomeHero.tsx +4 -3
- package/templates/nextjs/starter-content/src/components/sections/PersonalizationChoice.tsx +4 -2
- package/templates/nextjs/starter-content/src/components/sections/StatsBar.tsx +4 -3
- package/templates/nextjs/template/.aci.yaml +0 -1
- package/templates/nextjs/template/.aci.yaml.tmpl +0 -1
- package/templates/nextjs/template/package.json.tmpl +4 -3
- package/templates/nextjs/template/src/app/_components/c15t-bridge.ts +30 -0
- package/templates/nextjs/template/src/app/_components/c15t-consent-ui.tsx +37 -0
- package/templates/nextjs/template/src/app/_components/consent-manager.css +63 -0
- package/templates/nextjs/template/src/app/_components/consent-manager.tsx +76 -0
- package/templates/nextjs/template/src/app/fonts/inter/OFL.txt +93 -0
- package/templates/nextjs/template/src/app/fonts/inter/inter-latin.woff2 +0 -0
- package/templates/nextjs/template/src/app/fonts/space-grotesk/OFL.txt +93 -0
- package/templates/nextjs/template/src/app/fonts/space-grotesk/space-grotesk-latin.woff2 +0 -0
- package/templates/nextjs/template/src/app/layout.tsx +12 -7
- package/templates/nextjs/template/tests/conformance.test.ts.tmpl +1 -0
- package/dist/define-layout.d.ts +0 -6
- package/dist/define-layout.js +0 -14
- package/dist/edge/personalization-core.d.ts +0 -11
- package/dist/edge/personalization-core.js +0 -269
- package/dist/internal/domain/edge/compose.d.ts +0 -24
- package/dist/internal/domain/edge/compose.js +0 -89
- package/dist/internal/domain/edge/record/codec.d.ts +0 -71
- package/dist/internal/domain/edge/record/codec.js +0 -401
- package/dist/next/collect-route.d.ts +0 -19
- package/dist/next/collect-route.js +0 -459
- package/dist/next/edge-config.d.ts +0 -1
- package/dist/next/edge-config.js +0 -92
- package/dist/next/envelope-digest.d.ts +0 -21
- package/dist/next/envelope-digest.js +0 -58
- package/dist/next/middleware.d.ts +0 -40
- package/dist/next/middleware.js +0 -475
- package/dist/next/personalization-host.d.ts +0 -134
- package/dist/next/personalization-host.js +0 -270
- package/dist/next/root-layout.d.ts +0 -4
- package/dist/next/root-layout.js +0 -5
- package/dist/next/vercel-collect-sdk-route.d.ts +0 -27
- package/dist/next/vercel-collect-sdk-route.js +0 -181
- package/dist/next/vercel-edge-projection-v1.d.ts +0 -24
- package/dist/next/vercel-edge-projection-v1.js +0 -880
- package/dist/next/vercel-personalization.d.ts +0 -4
- package/dist/next/vercel-personalization.js +0 -4
- package/dist/next/vercel-s3-personalization.d.ts +0 -79
- package/dist/next/vercel-s3-personalization.js +0 -570
- package/dist/types/layout.d.ts +0 -24
- package/src/types/layout.ts +0 -29
- package/templates/astro/starter-content/src/render/RenderPage.astro +0 -67
- package/templates/astro/template/src/cms/contracts/layouts/index.ts +0 -18
- package/templates/common/starter-content/.content/patterns/layouts/default.json +0 -14
- package/templates/nextjs/starter-content/src/components/elements/AtomicBlock.tsx +0 -13
- package/templates/nextjs/template/src/cms/contracts/layouts/index.ts +0 -16
- /package/dist/{types/layout.js → cli/mintlify-render.d.mts} +0 -0
- /package/templates/common/starter-content/.content/dam/folders/seed/{ast_site_home_hero → 000000000000000000000000101}/editorial.json +0 -0
- /package/templates/common/starter-content/.content/dam/folders/seed/{ast_site_about_hero → 000000000000000000000000101}/governance.json +0 -0
- /package/templates/common/starter-content/.content/dam/folders/seed/{ast_site_home_hero → 000000000000000000000000101}/versions/v0001.json +0 -0
- /package/templates/common/starter-content/.content/dam/folders/seed/{ast_site_about_hero → 000000000000000000000000102}/editorial.json +0 -0
- /package/templates/common/starter-content/.content/dam/folders/seed/{ast_site_home_hero → 000000000000000000000000102}/governance.json +0 -0
- /package/templates/common/starter-content/.content/dam/folders/seed/{ast_site_about_hero → 000000000000000000000000102}/versions/v0001.json +0 -0
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { PersonalizationExperiment, PersonalizationRoute, PersonalizationVariant } from './types.js';
|
|
2
|
+
export interface AuthoredCandidate {
|
|
3
|
+
readonly key: string;
|
|
4
|
+
readonly variant: PersonalizationVariant;
|
|
5
|
+
readonly values: Record<string, string>;
|
|
6
|
+
readonly experiments: readonly PersonalizationExperiment[];
|
|
7
|
+
}
|
|
8
|
+
export interface PersonalizationSelection {
|
|
9
|
+
readonly sourceExperiments: Record<string, PersonalizationExperiment> | undefined;
|
|
10
|
+
readonly authored: readonly AuthoredCandidate[];
|
|
11
|
+
readonly experiments: readonly PersonalizationExperiment[];
|
|
12
|
+
readonly arms: Readonly<Record<string, Readonly<Record<string, PersonalizationVariant>>>>;
|
|
13
|
+
readonly tuples: Readonly<Record<string, PersonalizationVariant>>;
|
|
14
|
+
readonly legacyExperimentExclusions: number;
|
|
15
|
+
}
|
|
16
|
+
export declare function preparePersonalizationRoute(route: PersonalizationRoute, experiments?: Record<string, PersonalizationExperiment>): PersonalizationRoute;
|
|
17
|
+
export declare function personalizationSelection(route: PersonalizationRoute, experiments?: Record<string, PersonalizationExperiment>): PersonalizationSelection;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { compareUTF8, variantValuesFromKey } from './variant_key.js';
|
|
2
|
+
const preparedSelection = Symbol('prepared personalization selection');
|
|
3
|
+
// Call once when an immutable partition is decoded. The derived index is a
|
|
4
|
+
// non-enumerable symbol property, so it cannot alter any serialized record.
|
|
5
|
+
export function preparePersonalizationRoute(route, experiments) {
|
|
6
|
+
const existing = route[preparedSelection];
|
|
7
|
+
if (existing && existing.sourceExperiments === experiments)
|
|
8
|
+
return route;
|
|
9
|
+
return Object.freeze(Object.defineProperty({ ...route }, preparedSelection, { value: deriveSelection(route, experiments) }));
|
|
10
|
+
}
|
|
11
|
+
export function personalizationSelection(route, experiments) {
|
|
12
|
+
return preparePersonalizationRoute(route, experiments)[preparedSelection];
|
|
13
|
+
}
|
|
14
|
+
function deriveSelection(route, experiments) {
|
|
15
|
+
const authored = [];
|
|
16
|
+
const arms = Object.create(null);
|
|
17
|
+
const legacy = new Set();
|
|
18
|
+
const qualified = new Set();
|
|
19
|
+
for (const [key, variant] of Object.entries(route.variants || {})) {
|
|
20
|
+
const values = Object.freeze({ ...(variant.variantValues || variantValuesFromKey(key)) });
|
|
21
|
+
// DEBT(edge-functions): ADR-0045 bounded pair reader and counter. Remove
|
|
22
|
+
// only when every retained release in every tenant compiles under the
|
|
23
|
+
// post-removal compiler; pair entries then become malformed.
|
|
24
|
+
if (isPairVariant(key, values)) {
|
|
25
|
+
const id = values.experiment || key.split(';').find((part) => part.startsWith('experiment='))?.slice('experiment='.length);
|
|
26
|
+
if (id && experiments?.[id])
|
|
27
|
+
legacy.add(id);
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
const names = Object.keys(values);
|
|
31
|
+
const ids = names.filter((name) => name.startsWith('experiment.')).map((name) => name.slice('experiment.'.length));
|
|
32
|
+
for (const id of ids)
|
|
33
|
+
qualified.add(id);
|
|
34
|
+
if (ids.length === 1 && names.length === 1) {
|
|
35
|
+
const id = ids[0];
|
|
36
|
+
(arms[id] ||= Object.create(null))[values[`experiment.${id}`]] = variant;
|
|
37
|
+
}
|
|
38
|
+
const factors = ids.map((id) => experiments?.[id]);
|
|
39
|
+
if (factors.some((item) => !item || item.kind !== 'xt' || item.status !== 'running'))
|
|
40
|
+
continue;
|
|
41
|
+
authored.push(Object.freeze({ key, variant, values, experiments: Object.freeze(factors) }));
|
|
42
|
+
}
|
|
43
|
+
for (const id of qualified)
|
|
44
|
+
legacy.delete(id);
|
|
45
|
+
authored.sort((left, right) => (right.variant.priority || 0) - (left.variant.priority || 0) || compareUTF8(left.key, right.key));
|
|
46
|
+
const scoped = Object.keys(experiments || {}).sort(compareUTF8).map((id) => experiments[id])
|
|
47
|
+
.filter((item) => (item.kind === 'ab' || item.kind === 'abn') && item.status === 'running' && !legacy.has(item.id));
|
|
48
|
+
for (const entries of Object.values(arms))
|
|
49
|
+
Object.freeze(entries);
|
|
50
|
+
return Object.freeze({ sourceExperiments: experiments, authored: Object.freeze(authored), experiments: Object.freeze(scoped), arms: Object.freeze(arms), tuples: route.variants || {}, legacyExperimentExclusions: legacy.size });
|
|
51
|
+
}
|
|
52
|
+
function isPairVariant(key, values) {
|
|
53
|
+
const names = [...key.split(';').map((part) => part.split('=')[0]), ...Object.keys(values)];
|
|
54
|
+
return names.some((name) => name.toLowerCase() === 'experiment' || name.toLowerCase() === 'experimentarm');
|
|
55
|
+
}
|
|
@@ -1,58 +1,91 @@
|
|
|
1
1
|
// resolvePersonalizationRequest — the edge decisioning entry point.
|
|
2
2
|
//
|
|
3
|
-
// Reads cookies for classification but never writes them
|
|
4
|
-
// identity
|
|
5
|
-
//
|
|
6
|
-
// the selected immutable artifact by release and
|
|
3
|
+
// Reads cookies for classification but never writes them. It may issue a
|
|
4
|
+
// first-party identity in memory when the host permits (ADR 0048); persisting
|
|
5
|
+
// that identity is the host's job. Public personalized responses may be
|
|
6
|
+
// private while providers cache the selected immutable artifact by release and
|
|
7
|
+
// exact artifact path.
|
|
7
8
|
import { resolvePersonalizationDecision, } from './personalization-core.js';
|
|
8
|
-
import { consentSignals, verifyIdentityCookie, identityHashHex, verifyClaims, parseCookieHeader, } from './identity.js';
|
|
9
|
+
import { consentSignals, verifyIdentityCookie, identityHashHex, randomIdentity, verifyClaims, parseCookieHeader, } from './identity.js';
|
|
9
10
|
export async function resolvePersonalizationRequest(request, config) {
|
|
10
11
|
const cookies = parseCookieHeader(request.headers.get('cookie'));
|
|
11
12
|
const consent = consentSignals(request, cookies);
|
|
12
13
|
const now = (config.now ?? (() => new Date()))();
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
14
|
+
// Identity model (ADR 0016 as amended by ADR 0048): experiment bucketing is
|
|
15
|
+
// measurement, keyed by the first-party aci_id. A verified cookie (or the
|
|
16
|
+
// provider bootstrap's identity) is used as presented. With none usable, and
|
|
17
|
+
// only when the host's issuance rule permits it for this request, resolve
|
|
18
|
+
// issues a fresh identity so the request that issues it is also bucketed —
|
|
19
|
+
// an unverifiable cookie is replaced the same way. Persisting the issued
|
|
20
|
+
// identity is the host's decision (page responses only, never shared-cached).
|
|
21
|
+
// Claims and memberships stay gated on the personalization grant.
|
|
18
22
|
//
|
|
19
|
-
// GPC/DNT deny fully: no read, no identity, no
|
|
20
|
-
|
|
23
|
+
// GPC/DNT deny fully: no read, no identity, no issuance, no bucketing.
|
|
24
|
+
//
|
|
25
|
+
// Mimic (specs/features/audience-mimic): a validated mimicClaims input
|
|
26
|
+
// bypasses identity resolution entirely — the session reads no aci_id and
|
|
27
|
+
// no aci_claims (D-2/D-7), so identityHash stays empty and the experiment
|
|
28
|
+
// tier is structurally skipped (D-5). An ARMS-bearing token is the D-17
|
|
29
|
+
// exception: the experiment tier runs in forced mode — the token's named
|
|
30
|
+
// arm replaces the dice for each experiment the request selects through
|
|
31
|
+
// the ordinary gate — and identity still never exists (I-17).
|
|
32
|
+
const mimic = config.mimicClaims;
|
|
33
|
+
const verifiedVisitor = mimic || consent.privacyDenied ? undefined : config.verifiedVisitor;
|
|
34
|
+
const presentedIdentity = mimic || consent.privacyDenied
|
|
21
35
|
? null
|
|
22
|
-
:
|
|
36
|
+
: verifiedVisitor
|
|
37
|
+
? verifiedVisitor.rawIdentity
|
|
38
|
+
: await verifyIdentityCookie(cookies.get('aci_id') || '', config.identityKeys);
|
|
39
|
+
const issuedIdentity = !presentedIdentity && !mimic && !consent.privacyDenied && config.issueIdentity
|
|
40
|
+
? randomIdentity(config.randomBytes)
|
|
41
|
+
: undefined;
|
|
42
|
+
const rawIdentity = presentedIdentity || issuedIdentity || null;
|
|
23
43
|
// The identity hash feeds experiment bucketing for identified visitors.
|
|
24
|
-
// Claims are a signed cookie written by /collect
|
|
25
|
-
// never reads the customer object store to
|
|
26
|
-
|
|
44
|
+
// Claims are a signed cookie written by /collect or the provider identity
|
|
45
|
+
// bootstrap; the EdgeRouter never reads the customer object store to
|
|
46
|
+
// refresh or revoke them.
|
|
27
47
|
let identityHash = '';
|
|
28
48
|
if (rawIdentity) {
|
|
29
49
|
identityHash = await identityHashHex('aci_id', rawIdentity);
|
|
30
50
|
}
|
|
31
|
-
if (rawIdentity && consent.allowsIdentityTier && signedClaims) {
|
|
32
|
-
if (!await verifyClaims(signedClaims, config.claimsKeys || {}, now)) {
|
|
33
|
-
signedClaims = '';
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
51
|
const matchedRoute = config.route;
|
|
37
52
|
let variantKey = '';
|
|
38
53
|
if (matchedRoute) {
|
|
39
|
-
const claims =
|
|
40
|
-
?
|
|
41
|
-
:
|
|
42
|
-
|
|
54
|
+
const claims = mimic
|
|
55
|
+
? { aud: mimic.aud, seg: mimic.seg, iat: 0, exp: 0 }
|
|
56
|
+
: consent.allowsIdentityTier
|
|
57
|
+
? verifiedVisitor
|
|
58
|
+
? verifiedVisitor.claims
|
|
59
|
+
: await verifyClaims(cookies.get('aci_claims') || '', config.claimsKeys || {}, now)
|
|
60
|
+
: null;
|
|
61
|
+
const decision = resolvePersonalizationDecision(toPersonalizationRequestContext(request, now, config.requestGeo, config.onRuleRejected), matchedRoute, claims, config.experiments, identityHash
|
|
43
62
|
? { usable: true, siteId: config.siteId, namespace: 'aci_id', identityHash }
|
|
44
|
-
: undefined);
|
|
63
|
+
: undefined, mimic?.arms);
|
|
45
64
|
if (decision.variant) {
|
|
46
65
|
variantKey = decision.variant.variantKey;
|
|
47
66
|
}
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
:
|
|
51
|
-
|
|
67
|
+
const experiments = decision.experiments.map((item) => ({
|
|
68
|
+
id: item.experimentId,
|
|
69
|
+
arm: item.armId,
|
|
70
|
+
allocationVersion: item.allocationVersion,
|
|
71
|
+
...(item.forced ? { forced: true } : {}),
|
|
72
|
+
}));
|
|
73
|
+
return {
|
|
74
|
+
variantKey,
|
|
75
|
+
experiments,
|
|
76
|
+
...(decision.legacyExperimentExclusions ? { legacyExperimentExclusions: decision.legacyExperimentExclusions } : {}),
|
|
77
|
+
...(issuedIdentity ? { issuedIdentity } : {}),
|
|
78
|
+
};
|
|
52
79
|
}
|
|
53
|
-
return { variantKey };
|
|
80
|
+
return { variantKey, experiments: [], ...(issuedIdentity ? { issuedIdentity } : {}) };
|
|
54
81
|
}
|
|
55
|
-
|
|
82
|
+
// The return type is annotated rather than inferred so the contract is stated
|
|
83
|
+
// at the construction site: a misspelled property would then silently never
|
|
84
|
+
// fire at the sink, which is the exact failure mode this context exists to
|
|
85
|
+
// make visible. No tsconfig covers internal/domain/** today (esbuild/tsx strip
|
|
86
|
+
// types without checking), so the annotation is documentation now and becomes
|
|
87
|
+
// real protection under any future typecheck of these trees.
|
|
88
|
+
function toPersonalizationRequestContext(request, now, requestGeo, onRuleRejected) {
|
|
56
89
|
const url = new URL(request.url);
|
|
57
90
|
const cookies = parseCookieHeader(request.headers.get('cookie'));
|
|
58
91
|
const query = {};
|
|
@@ -60,17 +93,21 @@ function toPersonalizationRequestContext(request) {
|
|
|
60
93
|
(query[key] ||= []).push(value);
|
|
61
94
|
});
|
|
62
95
|
const headers = Object.fromEntries(request.headers.entries());
|
|
96
|
+
// geo.* facts come only from the host's trusted geo. Reading provider geo
|
|
97
|
+
// headers here trusted whatever the viewer sent on every lane whose
|
|
98
|
+
// provider does not stamp that exact header before the decision runs.
|
|
99
|
+
const geo = Object.fromEntries(Object.entries(requestGeo ?? {})
|
|
100
|
+
.filter((entry) => typeof entry[1] === 'string' && entry[1] !== ''));
|
|
63
101
|
return {
|
|
64
102
|
path: url.pathname,
|
|
65
103
|
query,
|
|
66
104
|
headers,
|
|
67
105
|
cookies: Object.fromEntries(cookies.entries()),
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
},
|
|
106
|
+
// The host's pinned per-request clock resolves the time.now fact
|
|
107
|
+
// (ADR 0038), so claims verification and window evaluation read one
|
|
108
|
+
// instant — and config.now-injected tests are deterministic.
|
|
109
|
+
now: now.getTime(),
|
|
110
|
+
...(Object.keys(geo).length > 0 ? { signals: { geo } } : {}),
|
|
111
|
+
...(onRuleRejected ? { onRuleRejected } : {}),
|
|
75
112
|
};
|
|
76
113
|
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export interface FrozenExpressionDiagnostic {
|
|
2
|
+
code: string;
|
|
3
|
+
path: string;
|
|
4
|
+
scope: 'request';
|
|
5
|
+
message: string;
|
|
6
|
+
}
|
|
7
|
+
export declare function frozenValidateRequestExpression(rule: {
|
|
8
|
+
all?: unknown[];
|
|
9
|
+
any?: unknown[];
|
|
10
|
+
}): FrozenExpressionDiagnostic[];
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
// FROZEN FIXTURE — the request-expression validation path as it stood before
|
|
2
|
+
// ADR 0040 added nested boolean expression trees, vendored from
|
|
3
|
+
// `internal/domain/edge/personalization-core.ts` at commit 1b785e4ac^ (the
|
|
4
|
+
// parent of the ADR 0040 landing commit). Do NOT update this file to track the
|
|
5
|
+
// live validator: its whole value is that it is stale.
|
|
6
|
+
//
|
|
7
|
+
// It exists so one test can reproduce the incident ADR 0040 predicts and says
|
|
8
|
+
// it could not previously debug — a lane running a flat-only evaluator meeting
|
|
9
|
+
// a record carrying a valid nested rule. That evaluator has no concept of a
|
|
10
|
+
// group node, so it classifies `{all:[...]}` as a leaf condition and reports
|
|
11
|
+
// the leaf vocabulary as unsupported. The resulting diagnostics are what the
|
|
12
|
+
// host recorder actually writes during real version skew, and they are NOT the
|
|
13
|
+
// same as those produced by a current, nesting-aware validator refusing a
|
|
14
|
+
// malformed rule.
|
|
15
|
+
//
|
|
16
|
+
// What is vendored: the COMPLETE validation path — `validateRequestExpression`
|
|
17
|
+
// and `validateRequestCondition` with every branch (per-field operator
|
|
18
|
+
// restrictions for device.class / time.now / audience.memberships, and every
|
|
19
|
+
// value check), plus each helper those functions reach
|
|
20
|
+
// (`expressionNumberInDomain`, `parseExpressionTimestamp`,
|
|
21
|
+
// `expressionDaysInMonth`, `canonicalScalar`, `validExpressionRange`,
|
|
22
|
+
// `homogeneousExpressionMembers`, and the DEVICE_CLASSES / MAX_IN_VALUES /
|
|
23
|
+
// grammar constants). Evaluation, matching, and the fact vocabulary are
|
|
24
|
+
// irrelevant to the record's shape and are deliberately absent.
|
|
25
|
+
//
|
|
26
|
+
// Adaptations from the source commit (everything not listed here is verbatim):
|
|
27
|
+
// 1. Entry-point signature. The original `validateRequestExpression` was
|
|
28
|
+
// 3-argument — its callers pre-extracted `all`/`any` with `rule.all || []`.
|
|
29
|
+
// The frozen export is 1-argument and derives them itself with
|
|
30
|
+
// `Array.isArray` guards (strictly stronger than the original `|| []`) so
|
|
31
|
+
// the fixture is safe to call directly on untrusted record data.
|
|
32
|
+
// 2. The diagnostic type is renamed `FrozenExpressionDiagnostic` (from
|
|
33
|
+
// `ExpressionDiagnostic`) so a frozen diagnostic can never be confused
|
|
34
|
+
// with a live one at a call site.
|
|
35
|
+
// 3. Export keywords were removed from the vendored internals; only the
|
|
36
|
+
// entry point and its diagnostic type are exported.
|
|
37
|
+
const MAX_EXPRESSION_CONDITIONS = 64;
|
|
38
|
+
const EXPRESSION_OPERATORS = [
|
|
39
|
+
'equals', 'contains', 'startsWith', 'exists',
|
|
40
|
+
'greaterThan', 'greaterThanOrEqual', 'lessThan', 'lessThanOrEqual',
|
|
41
|
+
'before', 'after', 'between', 'in',
|
|
42
|
+
];
|
|
43
|
+
const MAX_IN_VALUES = 32;
|
|
44
|
+
// Observed values are gated on the published grammars before any host parser
|
|
45
|
+
// runs (ADR 0038): Number() and Date.parse accept supersets (hex, empty
|
|
46
|
+
// string, vendor date formats) that would let the same fact match here and
|
|
47
|
+
// not in Go. Keep these literals identical to the capability artifact — the
|
|
48
|
+
// drift test compares them byte-for-byte.
|
|
49
|
+
const EXPRESSION_NUMBER_GRAMMAR = /^-?(0|[1-9][0-9]*)(\.[0-9]+)?([eE][+-]?[0-9]+)?$/;
|
|
50
|
+
const EXPRESSION_TIMESTAMP_GRAMMAR = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(\.\d{1,9})?(Z|[+-]\d{2}:\d{2})$/;
|
|
51
|
+
const DEVICE_CLASSES = ['desktop', 'mobile', 'tablet', 'unknown'];
|
|
52
|
+
// Adaptation 1 (see header): the original was 3-argument with caller-extracted
|
|
53
|
+
// `all`/`any`; this 1-argument form derives them with Array.isArray guards.
|
|
54
|
+
// The body below this line is verbatim from the source commit.
|
|
55
|
+
export function frozenValidateRequestExpression(rule) {
|
|
56
|
+
const all = (Array.isArray(rule.all) ? rule.all : []);
|
|
57
|
+
const any = (Array.isArray(rule.any) ? rule.any : []);
|
|
58
|
+
const diagnostics = [];
|
|
59
|
+
if (all.length + any.length === 0)
|
|
60
|
+
diagnostics.push({ code: 'expression.empty', path: 'expression', scope: 'request', message: 'expression must include at least one all or any condition' });
|
|
61
|
+
if (all.length + any.length > MAX_EXPRESSION_CONDITIONS)
|
|
62
|
+
diagnostics.push({ code: 'expression.conditions.too_many', path: 'expression', scope: 'request', message: `expression must contain at most ${MAX_EXPRESSION_CONDITIONS} conditions` });
|
|
63
|
+
Object.keys(rule).filter((group) => group !== 'all' && group !== 'any').sort().forEach((group) => {
|
|
64
|
+
diagnostics.push({ code: 'expression.group.unsupported', path: `expression.${group}`, scope: 'request', message: `expression.${group} is not a supported expression group` });
|
|
65
|
+
});
|
|
66
|
+
for (const [group, conditions] of [['all', all], ['any', any]]) {
|
|
67
|
+
conditions.forEach((condition, index) => frozenValidateRequestCondition(condition, `expression.${group}[${index}]`, diagnostics));
|
|
68
|
+
}
|
|
69
|
+
return diagnostics;
|
|
70
|
+
}
|
|
71
|
+
// Verbatim from the source commit: the full flat-era condition validator,
|
|
72
|
+
// including the per-field operator restrictions and every value check.
|
|
73
|
+
function frozenValidateRequestCondition(condition, path, diagnostics) {
|
|
74
|
+
Object.keys(condition).filter((property) => !['field', 'operator', 'value'].includes(property)).sort().forEach((property) => {
|
|
75
|
+
diagnostics.push({ code: 'expression.property.unsupported', path: `${path}.${property}`, scope: 'request', message: `${path}.${property} is not a supported condition property` });
|
|
76
|
+
});
|
|
77
|
+
const field = String(condition.field || '');
|
|
78
|
+
if (field !== 'referrer' && field !== 'device.class' && field !== 'time.now' && field !== 'audience.memberships' && !['query.', 'header.', 'cookie.', 'geo.', 'utm.'].some((prefix) => field.startsWith(prefix) && field.length > prefix.length)) {
|
|
79
|
+
diagnostics.push({ code: 'expression.field.unsupported', path: `${path}.field`, scope: 'request', message: `${path}.field ${JSON.stringify(field)} is not supported` });
|
|
80
|
+
}
|
|
81
|
+
const operator = String(condition.operator || '');
|
|
82
|
+
if (!EXPRESSION_OPERATORS.includes(operator)) {
|
|
83
|
+
diagnostics.push({ code: 'expression.operator.unsupported', path: `${path}.operator`, scope: 'request', message: `${path}.operator ${JSON.stringify(operator)} is not supported` });
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
// device.class compares against a closed four-value set, so substring
|
|
87
|
+
// operators express nothing the set cannot — and admitting them would
|
|
88
|
+
// re-create the user-agent-substring habit inside the safe field (ADR 0037).
|
|
89
|
+
if (field === 'device.class' && !['equals', 'in', 'exists'].includes(operator)) {
|
|
90
|
+
diagnostics.push({ code: 'expression.operator.unsupported', path: `${path}.operator`, scope: 'request', message: `${path}.operator ${JSON.stringify(operator)} is not supported for field ${JSON.stringify(field)}, which compares against a closed value set` });
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
// time.now is a sub-second-churning clock: string operators are silently
|
|
94
|
+
// inert on it and the numeric family can never match an RFC 3339 string
|
|
95
|
+
// (ADR 0038).
|
|
96
|
+
if (field === 'time.now' && !['before', 'after', 'between', 'exists'].includes(operator)) {
|
|
97
|
+
diagnostics.push({ code: 'expression.operator.unsupported', path: `${path}.operator`, scope: 'request', message: `${path}.operator ${JSON.stringify(operator)} is not supported for field ${JSON.stringify(field)}, which supports only before, after, between, exists` });
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
// audience.memberships is an opaque-ID list: equals is already
|
|
101
|
+
// any-member-equals under the multi-valued match loop, so the substring
|
|
102
|
+
// family would only add silent overmatching of prefixed IDs, and the
|
|
103
|
+
// numeric/instant families can never match an ID (ADR 0039).
|
|
104
|
+
if (field === 'audience.memberships' && !['equals', 'in', 'exists'].includes(operator)) {
|
|
105
|
+
diagnostics.push({ code: 'expression.operator.unsupported', path: `${path}.operator`, scope: 'request', message: `${path}.operator ${JSON.stringify(operator)} is not supported for field ${JSON.stringify(field)}, which supports only equals, in, exists` });
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
const value = condition.value;
|
|
109
|
+
if (operator === 'exists') {
|
|
110
|
+
if (value !== undefined && value !== null && typeof value !== 'boolean') {
|
|
111
|
+
diagnostics.push({ code: 'expression.value.invalid', path: `${path}.value`, scope: 'request', message: `${path}.value must be a boolean for operator exists` });
|
|
112
|
+
}
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
if (value === undefined || value === null) {
|
|
116
|
+
diagnostics.push({ code: 'expression.value.required', path: `${path}.value`, scope: 'request', message: `${path}.value is required for operator ${operator}` });
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
if (['equals', 'contains', 'startsWith'].includes(operator)) {
|
|
120
|
+
if (!['string', 'number', 'boolean'].includes(typeof value)
|
|
121
|
+
|| (typeof value === 'number' && !expressionNumberInDomain(value))) {
|
|
122
|
+
diagnostics.push({ code: 'expression.value.invalid', path: `${path}.value`, scope: 'request', message: `${path}.value must be a string, boolean, or finite number using only safe integers` });
|
|
123
|
+
}
|
|
124
|
+
else if (field === 'device.class' && DEVICE_CLASSES.indexOf(canonicalScalar(value)) < 0) {
|
|
125
|
+
diagnostics.push({ code: 'expression.value.invalid', path: `${path}.value`, scope: 'request', message: `${path}.value must be one of ${DEVICE_CLASSES.join(', ')} for field ${JSON.stringify(field)}` });
|
|
126
|
+
}
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
if (['greaterThan', 'greaterThanOrEqual', 'lessThan', 'lessThanOrEqual'].includes(operator)) {
|
|
130
|
+
if (typeof value !== 'number' || !expressionNumberInDomain(value)) {
|
|
131
|
+
diagnostics.push({ code: 'expression.value.invalid', path: `${path}.value`, scope: 'request', message: `${path}.value must be a finite JSON number using only safe integers for operator ${operator}` });
|
|
132
|
+
}
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
if (operator === 'before' || operator === 'after') {
|
|
136
|
+
if (typeof value !== 'string' || parseExpressionTimestamp(value) === null) {
|
|
137
|
+
diagnostics.push({ code: 'expression.value.invalid', path: `${path}.value`, scope: 'request', message: `${path}.value must be an RFC 3339 timestamp with an explicit UTC offset for operator ${operator}` });
|
|
138
|
+
}
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
if (operator === 'between') {
|
|
142
|
+
if (!validExpressionRange(value)) {
|
|
143
|
+
diagnostics.push({ code: 'expression.value.invalid', path: `${path}.value`, scope: 'request', message: `${path}.value must be a min/max object whose bounds are both numbers or both timestamps with min not above max` });
|
|
144
|
+
}
|
|
145
|
+
else if (field === 'time.now' && typeof value.min === 'number') {
|
|
146
|
+
// An instant field's observed values are instants: a numeric range on
|
|
147
|
+
// it would validate and then sit silently inert at execution.
|
|
148
|
+
diagnostics.push({ code: 'expression.value.invalid', path: `${path}.value`, scope: 'request', message: `${path}.value bounds must be timestamps for field ${JSON.stringify(field)}` });
|
|
149
|
+
}
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
// in: bounded, non-empty, every member one canonicalizable scalar, all
|
|
153
|
+
// members one JSON type — a mixed list is almost always an authoring bug.
|
|
154
|
+
const members = homogeneousExpressionMembers(value);
|
|
155
|
+
if (members === null) {
|
|
156
|
+
diagnostics.push({ code: 'expression.value.invalid', path: `${path}.value`, scope: 'request', message: `${path}.value must be an array of 1 to ${MAX_IN_VALUES} scalars sharing one type` });
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
if (field === 'device.class') {
|
|
160
|
+
for (const member of members) {
|
|
161
|
+
if (DEVICE_CLASSES.indexOf(member) < 0) {
|
|
162
|
+
diagnostics.push({ code: 'expression.value.invalid', path: `${path}.value`, scope: 'request', message: `${path}.value must be one of ${DEVICE_CLASSES.join(', ')} for field ${JSON.stringify(field)}` });
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
// ── Validation helpers, verbatim from the source commit ──────────────────
|
|
169
|
+
function expressionNumberInDomain(value) {
|
|
170
|
+
if (!Number.isFinite(value))
|
|
171
|
+
return false;
|
|
172
|
+
if (Number.isInteger(value) && !Number.isSafeInteger(value))
|
|
173
|
+
return false;
|
|
174
|
+
return true;
|
|
175
|
+
}
|
|
176
|
+
// Explicit calendar validation runs before Date arithmetic because Date.UTC
|
|
177
|
+
// (like Go's time.Date) silently normalizes impossible dates. Instants
|
|
178
|
+
// compare as epoch milliseconds; fractional seconds truncate to milliseconds.
|
|
179
|
+
function parseExpressionTimestamp(value) {
|
|
180
|
+
const match = EXPRESSION_TIMESTAMP_GRAMMAR.exec(value);
|
|
181
|
+
if (!match)
|
|
182
|
+
return null;
|
|
183
|
+
const year = Number(match[1]), month = Number(match[2]), day = Number(match[3]);
|
|
184
|
+
const hour = Number(match[4]), minute = Number(match[5]), second = Number(match[6]);
|
|
185
|
+
if (month < 1 || month > 12 || day < 1 || day > expressionDaysInMonth(year, month))
|
|
186
|
+
return null;
|
|
187
|
+
if (hour > 23 || minute > 59 || second > 59)
|
|
188
|
+
return null;
|
|
189
|
+
let offsetMinutes = 0;
|
|
190
|
+
if (match[8] !== 'Z') {
|
|
191
|
+
const offsetHour = Number(match[8].slice(1, 3)), offsetMinute = Number(match[8].slice(4, 6));
|
|
192
|
+
if (offsetHour > 23 || offsetMinute > 59)
|
|
193
|
+
return null;
|
|
194
|
+
offsetMinutes = offsetHour * 60 + offsetMinute;
|
|
195
|
+
if (match[8][0] === '-')
|
|
196
|
+
offsetMinutes = -offsetMinutes;
|
|
197
|
+
}
|
|
198
|
+
const fractionMillis = match[7] ? Number((match[7].slice(1) + '00').slice(0, 3)) : 0;
|
|
199
|
+
// setUTCFullYear, not Date.UTC: Date.UTC maps two-digit years to 1900+.
|
|
200
|
+
const date = new Date(0);
|
|
201
|
+
date.setUTCFullYear(year, month - 1, day);
|
|
202
|
+
date.setUTCHours(hour, minute, second, 0);
|
|
203
|
+
return date.getTime() - offsetMinutes * 60000 + fractionMillis;
|
|
204
|
+
}
|
|
205
|
+
function expressionDaysInMonth(year, month) {
|
|
206
|
+
if (month === 4 || month === 6 || month === 9 || month === 11)
|
|
207
|
+
return 30;
|
|
208
|
+
if (month === 2)
|
|
209
|
+
return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0) ? 29 : 28;
|
|
210
|
+
return 31;
|
|
211
|
+
}
|
|
212
|
+
function canonicalScalar(value) {
|
|
213
|
+
if (typeof value === 'number')
|
|
214
|
+
return Object.is(value, -0) ? '0' : JSON.stringify(value);
|
|
215
|
+
return String(value);
|
|
216
|
+
}
|
|
217
|
+
function validExpressionRange(value) {
|
|
218
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value))
|
|
219
|
+
return false;
|
|
220
|
+
const record = value;
|
|
221
|
+
if (Object.keys(record).some((key) => key !== 'min' && key !== 'max'))
|
|
222
|
+
return false;
|
|
223
|
+
const min = record.min, max = record.max;
|
|
224
|
+
if (min === undefined || min === null || max === undefined || max === null)
|
|
225
|
+
return false;
|
|
226
|
+
if (typeof min === 'number' && expressionNumberInDomain(min)) {
|
|
227
|
+
return typeof max === 'number' && expressionNumberInDomain(max) && min <= max;
|
|
228
|
+
}
|
|
229
|
+
const minMillis = typeof min === 'string' ? parseExpressionTimestamp(min) : null;
|
|
230
|
+
if (minMillis === null)
|
|
231
|
+
return false;
|
|
232
|
+
const maxMillis = typeof max === 'string' ? parseExpressionTimestamp(max) : null;
|
|
233
|
+
return maxMillis !== null && minMillis <= maxMillis;
|
|
234
|
+
}
|
|
235
|
+
function homogeneousExpressionMembers(value) {
|
|
236
|
+
if (!Array.isArray(value) || value.length === 0 || value.length > MAX_IN_VALUES)
|
|
237
|
+
return null;
|
|
238
|
+
let memberKind = '';
|
|
239
|
+
const members = [];
|
|
240
|
+
for (const member of value) {
|
|
241
|
+
const kind = typeof member;
|
|
242
|
+
if (kind !== 'string' && kind !== 'number' && kind !== 'boolean')
|
|
243
|
+
return null;
|
|
244
|
+
if (kind === 'number' && !expressionNumberInDomain(member))
|
|
245
|
+
return null;
|
|
246
|
+
if (!memberKind)
|
|
247
|
+
memberKind = kind;
|
|
248
|
+
else if (memberKind !== kind)
|
|
249
|
+
return null;
|
|
250
|
+
members.push(canonicalScalar(member));
|
|
251
|
+
}
|
|
252
|
+
return members;
|
|
253
|
+
}
|