@crouton-kit/capture 1.3.8 → 1.3.9
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/bin/capture +29841 -8539
- package/package.json +6 -3
- package/src/capture.ts +86 -173
- package/src/cdp/a11y.ts +113 -14
- package/src/cdp/args.ts +300 -205
- package/src/cdp/bridge/protocol.ts +93 -6
- package/src/cdp/bridge/server.ts +118 -53
- package/src/cdp/bridge/spawn.ts +53 -6
- package/src/cdp/client.ts +13 -3
- package/src/cdp/commands/bridge-serve.ts +20 -14
- package/src/cdp/commands/cdp.ts +259 -90
- package/src/cdp/commands/gate-guard.ts +43 -0
- package/src/cdp/commands/lib.ts +339 -213
- package/src/cdp/commands/measure/census.ts +76 -20
- package/src/cdp/commands/measure/check.ts +100 -26
- package/src/cdp/commands/measure/diff.ts +109 -21
- package/src/cdp/commands/measure/explain.ts +121 -19
- package/src/cdp/commands/measure/index.ts +31 -39
- package/src/cdp/commands/measure/map-ax.ts +52 -0
- package/src/cdp/commands/measure/map-focus.ts +41 -13
- package/src/cdp/commands/measure/map-layers.ts +49 -15
- package/src/cdp/commands/measure/map-scroll.ts +85 -15
- package/src/cdp/commands/measure/snap.ts +405 -25
- package/src/cdp/commands/measure/sweep.ts +217 -20
- package/src/cdp/commands/motion/index.ts +18 -17
- package/src/cdp/commands/motion/jank.ts +119 -9
- package/src/cdp/commands/motion/mask.ts +81 -12
- package/src/cdp/commands/motion/rec.ts +520 -19
- package/src/cdp/commands/motion/response.ts +65 -15
- package/src/cdp/commands/motion/timeline.ts +139 -14
- package/src/cdp/commands/page/click.ts +194 -0
- package/src/cdp/commands/page/elements.ts +154 -0
- package/src/cdp/commands/page/exec.ts +317 -0
- package/src/cdp/commands/page/index.ts +72 -0
- package/src/cdp/commands/page/navigate.ts +330 -0
- package/src/cdp/commands/page/scroll.ts +107 -0
- package/src/cdp/commands/page/shot.ts +208 -0
- package/src/cdp/commands/page/type.ts +104 -0
- package/src/cdp/commands/tab/index.ts +59 -0
- package/src/cdp/commands/tab/list.ts +167 -0
- package/src/cdp/commands/tab/network.ts +162 -0
- package/src/cdp/commands/tab/open.ts +90 -0
- package/src/cdp/commands/tab/reset.ts +160 -0
- package/src/cdp/connection.ts +368 -37
- package/src/cdp/detect.ts +42 -13
- package/src/cdp/dispatch.ts +74 -28
- package/src/cdp/har-recorder.ts +648 -115
- package/src/cdp/leaf-grammar.ts +99 -0
- package/src/cdp/measure/census.ts +205 -0
- package/src/cdp/measure/check.ts +318 -0
- package/src/cdp/measure/collectors/animation.ts +385 -0
- package/src/cdp/measure/collectors/ax.ts +278 -0
- package/src/cdp/measure/collectors/focus.ts +1041 -0
- package/src/cdp/measure/collectors/forms.ts +905 -0
- package/src/cdp/measure/collectors/geometry.ts +1038 -0
- package/src/cdp/measure/collectors/hittest.ts +835 -0
- package/src/cdp/measure/collectors/layers.ts +697 -0
- package/src/cdp/measure/collectors/media.ts +522 -0
- package/src/cdp/measure/collectors/pixels.ts +1584 -0
- package/src/cdp/measure/collectors/queries.ts +464 -0
- package/src/cdp/measure/collectors/scroll.ts +599 -0
- package/src/cdp/measure/collectors/states.ts +992 -0
- package/src/cdp/measure/collectors/style-provenance.ts +643 -0
- package/src/cdp/measure/collectors/styles.ts +355 -0
- package/src/cdp/measure/collectors/text.ts +713 -0
- package/src/cdp/measure/diff.ts +470 -0
- package/src/cdp/measure/explain.ts +498 -0
- package/src/cdp/measure/map-ax.ts +633 -0
- package/src/cdp/measure/map-focus.ts +188 -0
- package/src/cdp/measure/map-layers.ts +177 -0
- package/src/cdp/measure/map-scroll.ts +317 -0
- package/src/cdp/measure/redaction.ts +51 -0
- package/src/cdp/measure/settle.ts +1051 -0
- package/src/cdp/measure/snapshot.ts +390 -0
- package/src/cdp/measure/sweep.ts +330 -0
- package/src/cdp/measure/types.ts +220 -0
- package/src/cdp/measure/v2/collectors.ts +19 -0
- package/src/cdp/measure/v2/orchestrator.ts +46 -0
- package/src/cdp/measure/v2/snap-selection.ts +33 -0
- package/src/cdp/measure/v2/target-lock.ts +15 -0
- package/src/cdp/measure/v2/target.ts +16 -0
- package/src/cdp/motion/jank.ts +433 -0
- package/src/cdp/motion/mask.ts +421 -0
- package/src/cdp/motion/recorder.ts +764 -0
- package/src/cdp/motion/response.ts +356 -0
- package/src/cdp/motion/timeline.ts +215 -0
- package/src/cdp/record.ts +5 -212
- package/src/cdp/recorder-bridge.ts +1478 -105
- package/src/cdp/recorder-client.ts +326 -0
- package/src/cdp/scope-lock.ts +73 -0
- package/src/cdp/screenshot.ts +199 -66
- package/src/cdp/targets.ts +21 -1
- package/src/cdp/timing.ts +15 -10
- package/src/cdp/types.ts +22 -7
- package/src/cdp/viewport.ts +21 -0
- package/src/cdp.ts +12 -58
- package/src/command-help.ts +29 -0
- package/src/command-resolution.ts +22 -0
- package/src/command-walker.ts +35 -0
- package/src/contracts/a11y.ts +33 -0
- package/src/contracts/cursor.ts +35 -0
- package/src/contracts/index.ts +15 -0
- package/src/contracts/motion.ts +19 -0
- package/src/contracts/neutral.ts +56 -0
- package/src/contracts/primitives.ts +205 -0
- package/src/contracts/release.ts +17 -0
- package/src/contracts/results.ts +208 -0
- package/src/contracts/routes.ts +140 -0
- package/src/contracts/snapshot.ts +92 -0
- package/src/cursor/index.ts +13 -0
- package/src/cursor/service.ts +310 -0
- package/src/errors.ts +71 -0
- package/src/har-manager.ts +992 -41
- package/src/interact.ts +410 -98
- package/src/output/artifact-lifecycle.ts +371 -0
- package/src/output/artifact.ts +6 -0
- package/src/output/exact-raw.ts +114 -0
- package/src/output/render.ts +396 -2
- package/src/output/structured.ts +211 -0
- package/src/registry.ts +208 -0
- package/src/session/artifacts.ts +452 -200
- package/src/session/commands.ts +1162 -397
- package/src/session/coordinator.ts +251 -0
- package/src/session/har-filter.ts +57 -0
- package/src/session/log-tailer.ts +653 -0
- package/src/session-context.ts +279 -51
- package/src/vault/bundle.ts +32 -17
- package/src/cdp/commands/exec.ts +0 -105
- package/src/cdp/commands/har.ts +0 -128
- package/src/cdp/commands/tabs.ts +0 -183
- package/src/cdp/commands/traffic.ts +0 -121
- package/src/cdp/commands/ui.ts +0 -172
- package/src/cdp/execute.ts +0 -113
- package/src/cdp/har-output.ts +0 -47
- package/src/cdp/usage.ts +0 -79
- package/src/tab-session.ts +0 -91
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Frozen public route/leaf descriptor contract. A descriptor is the sole truth
|
|
3
|
+
* for one canonical public path. It names inputs, effects, output lane, bounds,
|
|
4
|
+
* stderr, exits, and an opaque handler slot; it never imports a handler.
|
|
5
|
+
*/
|
|
6
|
+
import { OK, ValidationResult, combine, contextualize, fail } from './primitives.js';
|
|
7
|
+
import { BoundedBounds, ResultLane, validateBoundedBounds, validateResultLane } from './results.js';
|
|
8
|
+
|
|
9
|
+
export interface RouteEffects {
|
|
10
|
+
readonly browser: boolean;
|
|
11
|
+
readonly session: boolean;
|
|
12
|
+
readonly artifact: boolean;
|
|
13
|
+
readonly environment: boolean;
|
|
14
|
+
}
|
|
15
|
+
export const NO_EFFECTS: RouteEffects = { browser: false, session: false, artifact: false, environment: false };
|
|
16
|
+
|
|
17
|
+
export interface PositionalSpec { readonly name: string; readonly grammar: string; readonly required: boolean; readonly variadic?: boolean; }
|
|
18
|
+
export interface FlagSpec {
|
|
19
|
+
readonly name: string;
|
|
20
|
+
readonly grammar: string | 'boolean';
|
|
21
|
+
/** Required singleton unless repeatable is explicitly declared. */
|
|
22
|
+
readonly required?: boolean;
|
|
23
|
+
/** Repetition is accepted; required means at least one occurrence. */
|
|
24
|
+
readonly repeatable?: boolean;
|
|
25
|
+
readonly values?: readonly string[];
|
|
26
|
+
readonly default?: string | number | boolean;
|
|
27
|
+
readonly units?: string;
|
|
28
|
+
}
|
|
29
|
+
export type StderrPolicy = 'empty-on-success' | 'declared-progress';
|
|
30
|
+
export type LeafExit = 0 | 2 | 3 | 10 | 11 | 130 | 143;
|
|
31
|
+
|
|
32
|
+
export interface BranchDescriptor { readonly kind: 'branch'; readonly path: string; readonly summary: string; }
|
|
33
|
+
export interface LeafDescriptor {
|
|
34
|
+
readonly kind: 'leaf'; readonly path: string; readonly summary: string;
|
|
35
|
+
readonly positionals: readonly PositionalSpec[]; readonly flags: readonly FlagSpec[];
|
|
36
|
+
readonly mutualExclusions: readonly (readonly string[])[]; readonly effects: RouteEffects;
|
|
37
|
+
readonly result: ResultLane; readonly bounds?: BoundedBounds; readonly stderr: StderrPolicy;
|
|
38
|
+
readonly exits: readonly LeafExit[]; readonly handler: string;
|
|
39
|
+
}
|
|
40
|
+
export type RouteDescriptor = BranchDescriptor | LeafDescriptor;
|
|
41
|
+
|
|
42
|
+
/** All public non-root branches in the seven-noun Capture topology. */
|
|
43
|
+
export const EXPECTED_BRANCH_PATHS: readonly string[] = [
|
|
44
|
+
'session', 'page', 'measure', 'measure map', 'measure variation', 'motion',
|
|
45
|
+
'traffic', 'traffic har', 'browser', 'library',
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
/** Every public runnable path, exactly once. There are no aliases. */
|
|
49
|
+
export const EXPECTED_LEAF_PATHS: readonly string[] = [
|
|
50
|
+
'session start', 'session stop', 'session list', 'session view', 'session log',
|
|
51
|
+
'page a11y', 'page screenshot', 'page click', 'page type', 'page navigate', 'page exec',
|
|
52
|
+
'measure snap', 'measure check', 'measure geometry', 'measure map focus', 'measure map scroll', 'measure map layers', 'measure explain', 'measure variation diff', 'measure variation census', 'measure variation sweep',
|
|
53
|
+
'motion rec', 'motion mask', 'motion timeline', 'motion jank', 'motion response',
|
|
54
|
+
'traffic record', 'traffic har create', 'traffic har read', 'traffic har delete',
|
|
55
|
+
'browser detect', 'browser list', 'browser open', 'browser reset', 'browser network', 'browser cdp',
|
|
56
|
+
'library list', 'library search', 'library show', 'library read',
|
|
57
|
+
];
|
|
58
|
+
|
|
59
|
+
export const PAGINATED_LEAF_PATHS: readonly string[] = [
|
|
60
|
+
'browser detect', 'browser list', 'library list', 'library search', 'library show',
|
|
61
|
+
];
|
|
62
|
+
|
|
63
|
+
/** Exact raw leaves are handler-owned bytes/text, never global JSON envelopes. */
|
|
64
|
+
export const EXACT_RAW_LEAF_PAYLOADS: Readonly<Record<string, string>> = {
|
|
65
|
+
'session log': 'recorded session log bytes',
|
|
66
|
+
'page a11y': 'full accessibility-tree text',
|
|
67
|
+
'page exec': 'handler evaluation bytes/text',
|
|
68
|
+
'traffic har read': 'stored HAR bytes',
|
|
69
|
+
'browser cdp': 'raw protocol response bytes',
|
|
70
|
+
'library read': 'bundled function source bytes',
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/** Legacy spellings are deliberately unreachable after the U15 cutover. */
|
|
74
|
+
export const FORBIDDEN_PUBLIC_PATHS: readonly string[] = [
|
|
75
|
+
'-v', 'version', 'log', 'detect', 'list', 'open', 'reset-tab', 'screenshot',
|
|
76
|
+
'click', 'type', 'a11y', 'record', 'navigate', 'network', 'har', 'lib', 'cdp',
|
|
77
|
+
'exec', 'motion observations', '__bridge-serve',
|
|
78
|
+
];
|
|
79
|
+
|
|
80
|
+
function flagNames(leaf: LeafDescriptor): Set<string> { return new Set(leaf.flags.map((flag) => flag.name)); }
|
|
81
|
+
|
|
82
|
+
export function validateLeafDescriptor(leaf: LeafDescriptor): ValidationResult {
|
|
83
|
+
const errors: ValidationResult[] = [];
|
|
84
|
+
if (!leaf.path) errors.push(fail('leaf has empty path'));
|
|
85
|
+
const fixed = leaf.positionals.filter((positional) => !positional.variadic);
|
|
86
|
+
const required = fixed.filter((positional) => positional.required);
|
|
87
|
+
const optional = fixed.filter((positional) => !positional.required);
|
|
88
|
+
if (required.length > 1 || optional.length > 1 || fixed.length > 2 || (fixed.length === 2 && (!fixed[0].required || fixed[1].required))) {
|
|
89
|
+
errors.push(fail(`leaf ${leaf.path} permits only one required positional followed by one optional trailing positional`));
|
|
90
|
+
}
|
|
91
|
+
const names = flagNames(leaf);
|
|
92
|
+
if (names.size !== leaf.flags.length) errors.push(fail(`leaf ${leaf.path} declares duplicate flag names`));
|
|
93
|
+
for (const flag of leaf.flags) {
|
|
94
|
+
if (!flag.name.startsWith('--')) errors.push(fail(`leaf ${leaf.path} flag ${flag.name} is not a long option`));
|
|
95
|
+
if (flag.grammar === 'boolean' && (flag.values || flag.default !== undefined || flag.units)) errors.push(fail(`boolean flag ${flag.name} must not declare value metadata`));
|
|
96
|
+
if (flag.repeatable && flag.default !== undefined) errors.push(fail(`repeatable flag ${flag.name} must not declare a default`));
|
|
97
|
+
}
|
|
98
|
+
for (const group of leaf.mutualExclusions) for (const name of group) if (!names.has(name)) errors.push(fail(`leaf ${leaf.path} mutual-exclusion references undeclared flag ${name}`));
|
|
99
|
+
errors.push(contextualize(`leaf ${leaf.path} result`, validateResultLane(leaf.result)));
|
|
100
|
+
if (leaf.result.kind === 'bounded') {
|
|
101
|
+
if (!leaf.bounds) errors.push(fail(`bounded leaf ${leaf.path} missing byte/list bounds`));
|
|
102
|
+
else errors.push(contextualize(`leaf ${leaf.path} bounds`, validateBoundedBounds(leaf.bounds)));
|
|
103
|
+
} else if (leaf.result.kind === 'exact-raw') {
|
|
104
|
+
if (leaf.bounds) errors.push(fail(`exact raw leaf ${leaf.path} must not declare bounded bounds`));
|
|
105
|
+
if (leaf.stderr !== 'empty-on-success') errors.push(fail(`exact raw leaf ${leaf.path} must have empty-on-success stderr`));
|
|
106
|
+
} else errors.push(fail(`leaf ${leaf.path} must use bounded or exact-raw result lane`));
|
|
107
|
+
if (!leaf.handler) errors.push(fail(`leaf ${leaf.path} missing handler slot`));
|
|
108
|
+
if (!leaf.exits.length) errors.push(fail(`leaf ${leaf.path} declares no exits`));
|
|
109
|
+
return combine(...errors);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function validateBranchDescriptor(branch: BranchDescriptor): ValidationResult {
|
|
113
|
+
if (!branch.path) return fail('branch has empty path');
|
|
114
|
+
if ((branch as unknown as { handler?: unknown }).handler !== undefined) return fail(`branch ${branch.path} must not declare a handler`);
|
|
115
|
+
return OK;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Validate an assembled public registry against the canonical seven-noun census. */
|
|
119
|
+
export function validateRegistry(descriptors: readonly RouteDescriptor[]): ValidationResult {
|
|
120
|
+
const errors: ValidationResult[] = [];
|
|
121
|
+
const seen = new Set<string>(); const leaves = new Set<string>(); const branches = new Set<string>();
|
|
122
|
+
for (const descriptor of descriptors) {
|
|
123
|
+
if (seen.has(descriptor.path)) errors.push(fail(`duplicate route path: ${descriptor.path}`));
|
|
124
|
+
seen.add(descriptor.path);
|
|
125
|
+
if (FORBIDDEN_PUBLIC_PATHS.includes(descriptor.path)) errors.push(fail(`forbidden public path present: ${descriptor.path}`));
|
|
126
|
+
if (descriptor.kind === 'leaf') {
|
|
127
|
+
leaves.add(descriptor.path); errors.push(validateLeafDescriptor(descriptor));
|
|
128
|
+
const expectedPayload = EXACT_RAW_LEAF_PAYLOADS[descriptor.path];
|
|
129
|
+
if (expectedPayload && (descriptor.result.kind !== 'exact-raw' || descriptor.result.payload !== expectedPayload)) errors.push(fail(`leaf ${descriptor.path} must be exact-raw:${expectedPayload}`));
|
|
130
|
+
if (!expectedPayload && descriptor.result.kind === 'exact-raw') errors.push(fail(`leaf ${descriptor.path} is not declared exact-raw by the public output contract`));
|
|
131
|
+
const paginated = descriptor.result.kind === 'bounded' && descriptor.bounds?.paginated === true;
|
|
132
|
+
if (PAGINATED_LEAF_PATHS.includes(descriptor.path) !== paginated) errors.push(fail(`leaf ${descriptor.path} pagination declaration disagrees with census`));
|
|
133
|
+
} else { branches.add(descriptor.path); errors.push(validateBranchDescriptor(descriptor)); }
|
|
134
|
+
}
|
|
135
|
+
for (const path of EXPECTED_LEAF_PATHS) if (!leaves.has(path)) errors.push(fail(`missing expected leaf path: ${path}`));
|
|
136
|
+
for (const path of EXPECTED_BRANCH_PATHS) if (!branches.has(path)) errors.push(fail(`missing expected branch path: ${path}`));
|
|
137
|
+
for (const path of leaves) if (!EXPECTED_LEAF_PATHS.includes(path)) errors.push(fail(`unexpected leaf path (not in census): ${path}`));
|
|
138
|
+
for (const path of branches) if (!EXPECTED_BRANCH_PATHS.includes(path)) errors.push(fail(`unexpected branch path (not in census): ${path}`));
|
|
139
|
+
return combine(...errors);
|
|
140
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/** Immutable source snapshot contracts. V2 is the only acquisition schema; V1 is read-only legacy evidence. */
|
|
2
|
+
import { OK, ValidationResult, combine, fail, isObject } from './primitives.js';
|
|
3
|
+
|
|
4
|
+
export type SchemaVersion = 1 | 2;
|
|
5
|
+
export const SNAPSHOT_FACET_NAMES = ['viewport', 'geometry', 'styles', 'hit-testing', 'text', 'forms', 'media', 'animation', 'accessibility', 'queries', 'focus', 'scroll', 'layers', 'states', 'pixels', 'screenshot', 'dom-html'] as const;
|
|
6
|
+
export type SnapshotFacetName = (typeof SNAPSHOT_FACET_NAMES)[number];
|
|
7
|
+
/** @deprecated Legacy source facets are read-only; new snapshots use SNAPSHOT_FACET_NAMES. */
|
|
8
|
+
export const FACET_NAMES = SNAPSHOT_FACET_NAMES;
|
|
9
|
+
export type FacetName = SnapshotFacetName;
|
|
10
|
+
|
|
11
|
+
export type CollectorCoverage = {
|
|
12
|
+
readonly retained_count: number;
|
|
13
|
+
readonly availability: 'available' | { readonly state: 'unavailable'; readonly reason: string };
|
|
14
|
+
readonly truncation: { readonly state: 'none' } | { readonly state: 'exact'; readonly dropped_count: number } | { readonly state: 'lower_bound'; readonly dropped_count_lower_bound: number } | { readonly state: 'unknown' };
|
|
15
|
+
readonly source_total: { readonly state: 'exact'; readonly count: number } | { readonly state: 'lower_bound'; readonly count_lower_bound: number } | { readonly state: 'unavailable'; readonly reason: string };
|
|
16
|
+
};
|
|
17
|
+
export type SnapshotFacet = { readonly status: 'available' | 'unavailable' | 'not-requested'; readonly primary_population: { readonly name: string; readonly coverage: CollectorCoverage }; readonly subpopulations: Readonly<Record<string, CollectorCoverage>>; readonly unavailable_reason: string | null };
|
|
18
|
+
export interface CdpEndpoint { readonly host: '127.0.0.1'; readonly port: number; }
|
|
19
|
+
export interface SnapshotTargetAttestation { readonly mode: 'fresh-url' | 'session' | 'explicit-target'; readonly session_id: string | null; readonly session_source: 'active' | 'explicit' | null; readonly target_id: string; readonly endpoint: CdpEndpoint; readonly observed_url: string; }
|
|
20
|
+
export interface SourceManifestArtifact { readonly key: string; readonly path: string; readonly bytes: number; readonly sha256: string; readonly media_type: string; readonly retained_arrays: readonly { readonly key: string; readonly json_pointer: string; readonly retained_count: number }[]; }
|
|
21
|
+
export interface SourceArtifactManifest { readonly schemaVersion: 2; readonly artifacts: readonly SourceManifestArtifact[]; }
|
|
22
|
+
export interface SnapshotMetaV2 {
|
|
23
|
+
readonly schemaVersion: 2;
|
|
24
|
+
readonly snapshotId: string;
|
|
25
|
+
readonly request: Record<string, unknown>;
|
|
26
|
+
readonly settled: boolean;
|
|
27
|
+
readonly timing: Record<string, unknown>;
|
|
28
|
+
readonly coordinateAuthority: Record<string, unknown>;
|
|
29
|
+
readonly contentInputs: Record<string, unknown>;
|
|
30
|
+
readonly target: SnapshotTargetAttestation;
|
|
31
|
+
readonly facets: { readonly [K in SnapshotFacetName]: SnapshotFacet };
|
|
32
|
+
readonly source_artifact_manifest: SourceArtifactManifest;
|
|
33
|
+
readonly sourcePixelCoverage: unknown;
|
|
34
|
+
}
|
|
35
|
+
export interface SnapshotMetaV1 { readonly schemaVersion?: 1; readonly snapshotId: string; readonly request?: Record<string, unknown>; readonly facets: readonly { readonly name: string; readonly path: string }[]; }
|
|
36
|
+
export type SnapshotMeta = SnapshotMetaV1 | SnapshotMetaV2;
|
|
37
|
+
export function isV2Meta(meta: SnapshotMeta): meta is SnapshotMetaV2 { return (meta as { schemaVersion?: number }).schemaVersion === 2; }
|
|
38
|
+
export function effectiveSchemaVersion(obj: unknown): SchemaVersion | 'unknown' { if (!isObject(obj)) return 'unknown'; return obj.schemaVersion === undefined || obj.schemaVersion === 1 ? 1 : obj.schemaVersion === 2 ? 2 : 'unknown'; }
|
|
39
|
+
|
|
40
|
+
const safe = (n: unknown): n is number => Number.isSafeInteger(n) && n >= 0;
|
|
41
|
+
const reason = (x: unknown): x is string => typeof x === 'string' && x.length > 0 && x.length <= 512;
|
|
42
|
+
const keys = (x: object): string[] => Object.keys(x);
|
|
43
|
+
const SUBPOPULATIONS: Record<SnapshotFacetName, readonly string[]> = { viewport: [], geometry: [], styles: [], 'hit-testing': [], text: [], forms: [], media: [], animation: [], accessibility: [], queries: ['media-queries', 'container-queries'], focus: ['forward', 'reverse'], scroll: ['snap-descendants', 'sticky-fixed-descendants', 'sampled-visible-children'], layers: [], states: [], pixels: [], screenshot: [], 'dom-html': [] };
|
|
44
|
+
function normalizedPath(value: unknown): value is string { return typeof value === 'string' && value.length > 0 && value.length <= 512 && !value.includes('\\') && !value.startsWith('/') && !value.split('/').some(p => !p || p === '.' || p === '..'); }
|
|
45
|
+
function coverage(value: unknown): ValidationResult {
|
|
46
|
+
if (!isObject(value)) return fail('coverage must be an object');
|
|
47
|
+
const v = value as Partial<CollectorCoverage>;
|
|
48
|
+
const errors: ValidationResult[] = [];
|
|
49
|
+
if (!safe(v.retained_count)) errors.push(fail('retained_count must be a nonnegative safe integer'));
|
|
50
|
+
const unavailable = isObject(v.availability) && v.availability.state === 'unavailable' && reason(v.availability.reason);
|
|
51
|
+
if (v.availability !== 'available' && !unavailable) errors.push(fail('coverage availability is invalid'));
|
|
52
|
+
if (!isObject(v.truncation) || !['none', 'exact', 'lower_bound', 'unknown'].includes(String(v.truncation.state))) errors.push(fail('coverage truncation is invalid'));
|
|
53
|
+
else if (v.truncation.state === 'exact' && !safe(v.truncation.dropped_count)) errors.push(fail('exact truncation needs dropped_count'));
|
|
54
|
+
else if (v.truncation.state === 'lower_bound' && !safe(v.truncation.dropped_count_lower_bound)) errors.push(fail('lower_bound truncation needs dropped_count_lower_bound'));
|
|
55
|
+
if (!isObject(v.source_total) || !['exact', 'lower_bound', 'unavailable'].includes(String(v.source_total.state))) errors.push(fail('coverage source_total is invalid'));
|
|
56
|
+
else if (v.source_total.state === 'exact' && !safe(v.source_total.count)) errors.push(fail('exact source_total needs count'));
|
|
57
|
+
else if (v.source_total.state === 'lower_bound' && !safe(v.source_total.count_lower_bound)) errors.push(fail('lower_bound source_total needs count_lower_bound'));
|
|
58
|
+
else if (v.source_total.state === 'unavailable' && !reason(v.source_total.reason)) errors.push(fail('unavailable source_total needs reason'));
|
|
59
|
+
if (unavailable && (v.retained_count !== 0 || v.truncation?.state !== 'unknown' || v.source_total?.state !== 'unavailable')) errors.push(fail('unavailable coverage cannot represent zero evidence'));
|
|
60
|
+
if (v.source_total?.state === 'exact' && v.truncation?.state === 'exact' && v.source_total.count !== (v.retained_count ?? 0) + v.truncation.dropped_count) errors.push(fail('exact coverage totals disagree'));
|
|
61
|
+
return combine(...errors);
|
|
62
|
+
}
|
|
63
|
+
export function validateCollectorCoverage(value: unknown): ValidationResult { return coverage(value); }
|
|
64
|
+
function facet(name: SnapshotFacetName, value: unknown): ValidationResult {
|
|
65
|
+
if (!isObject(value)) return fail(`facet ${name} must be an object`);
|
|
66
|
+
const v = value as Partial<SnapshotFacet>; const errors = [coverage(v.primary_population?.coverage)];
|
|
67
|
+
if (!v.primary_population || !reason(v.primary_population.name)) errors.push(fail(`facet ${name} primary population is invalid`));
|
|
68
|
+
const expected = SUBPOPULATIONS[name];
|
|
69
|
+
if (!isObject(v.subpopulations) || keys(v.subpopulations).join('\0') !== expected.join('\0')) errors.push(fail(`facet ${name} subpopulations are invalid`));
|
|
70
|
+
else for (const item of expected) errors.push(coverage(v.subpopulations[item]));
|
|
71
|
+
if (!['available', 'unavailable', 'not-requested'].includes(String(v.status))) errors.push(fail(`facet ${name} status is invalid`));
|
|
72
|
+
if (v.status === 'unavailable' && !reason(v.unavailable_reason)) errors.push(fail(`facet ${name} unavailable without reason`));
|
|
73
|
+
if (v.status !== 'unavailable' && v.unavailable_reason !== null) errors.push(fail(`facet ${name} has inconsistent unavailable reason`));
|
|
74
|
+
if (v.status === 'not-requested' && name !== 'states' && name !== 'pixels') errors.push(fail(`facet ${name} cannot be not-requested`));
|
|
75
|
+
return combine(...errors);
|
|
76
|
+
}
|
|
77
|
+
function validateSourceManifest(value: unknown): ValidationResult {
|
|
78
|
+
if (!isObject(value) || value.schemaVersion !== 2 || !Array.isArray(value.artifacts)) return fail('source_artifact_manifest is invalid');
|
|
79
|
+
const artifactKeys = new Set<string>(); const paths = new Set<string>(); const arrayKeys = new Set<string>(); const errors: ValidationResult[] = [];
|
|
80
|
+
for (const a of value.artifacts) { if (!isObject(a) || !reason(a.key) || !normalizedPath(a.path) || !safe(a.bytes) || typeof a.media_type !== 'string' || !/^[a-f0-9]{64}$/.test(String(a.sha256)) || !Array.isArray(a.retained_arrays)) { errors.push(fail('source artifact is invalid')); continue; } if (artifactKeys.has(a.key) || paths.has(a.path)) errors.push(fail('duplicate source artifact key or path')); artifactKeys.add(a.key); paths.add(a.path); for (const r of a.retained_arrays) { if (!isObject(r) || !reason(r.key) || typeof r.json_pointer !== 'string' || (!r.json_pointer.startsWith('/') && r.json_pointer !== '') || !safe(r.retained_count) || arrayKeys.has(r.key)) errors.push(fail('source retained array is invalid')); else arrayKeys.add(r.key); } }
|
|
81
|
+
return combine(...errors);
|
|
82
|
+
}
|
|
83
|
+
export function validateSnapshotMetaV2(meta: SnapshotMetaV2): ValidationResult {
|
|
84
|
+
const errors: ValidationResult[] = [];
|
|
85
|
+
if (!isObject(meta) || meta.schemaVersion !== 2 || typeof meta.snapshotId !== 'string' || !meta.snapshotId) errors.push(fail('invalid v2 identity'));
|
|
86
|
+
if (!isObject(meta.request) || !isObject(meta.timing) || !isObject(meta.coordinateAuthority) || !isObject(meta.contentInputs)) errors.push(fail('v2 request/timing/authority fields must be objects'));
|
|
87
|
+
if (!isObject(meta.facets) || keys(meta.facets).join('\0') !== SNAPSHOT_FACET_NAMES.join('\0')) errors.push(fail('v2 facets must use fixed ordered keys')); else for (const n of SNAPSHOT_FACET_NAMES) errors.push(facet(n, meta.facets[n]));
|
|
88
|
+
const t = meta.target; if (!isObject(t) || !['fresh-url', 'session', 'explicit-target'].includes(String(t.mode)) || typeof t.target_id !== 'string' || !t.target_id || !isObject(t.endpoint) || t.endpoint.host !== '127.0.0.1' || !Number.isSafeInteger(t.endpoint.port) || t.endpoint.port < 1 || t.endpoint.port > 65535 || typeof t.observed_url !== 'string') errors.push(fail('invalid target attestation')); else if (t.mode === 'session' ? !(typeof t.session_id === 'string' && /^ses_[0-9a-z]{26}$/.test(t.session_id) && (t.session_source === 'active' || t.session_source === 'explicit')) : t.session_id !== null || t.session_source !== null) errors.push(fail('target attestation session fields disagree with mode'));
|
|
89
|
+
errors.push(validateSourceManifest(meta.source_artifact_manifest));
|
|
90
|
+
return combine(...errors);
|
|
91
|
+
}
|
|
92
|
+
export function validateSnapshotMeta(meta: unknown): ValidationResult { const version = effectiveSchemaVersion(meta); if (version === 'unknown' || !isObject(meta)) return fail('unsupported snapshot schemaVersion'); if (version === 2) return validateSnapshotMetaV2(meta as SnapshotMetaV2); return typeof meta.snapshotId === 'string' && Array.isArray(meta.facets) ? OK : fail('legacy meta is invalid'); }
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** Narrow, unreachable cursor-pagination service seam for future bounded read handlers. */
|
|
2
|
+
export {
|
|
3
|
+
CursorService,
|
|
4
|
+
CursorServiceError,
|
|
5
|
+
MemoryCursorIndexStore,
|
|
6
|
+
type ContinueCursorPageInput,
|
|
7
|
+
type CreateCursorPageInput,
|
|
8
|
+
type CursorIndexStore,
|
|
9
|
+
type CursorPage,
|
|
10
|
+
type CursorQueryIdentity,
|
|
11
|
+
type CursorServiceErrorCode,
|
|
12
|
+
type CursorServiceOptions,
|
|
13
|
+
} from './service.js';
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
import { createHash, createHmac, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
DEFAULT_PAGE_SIZE,
|
|
5
|
+
MAX_PAGE_SIZE,
|
|
6
|
+
type CursorClaims,
|
|
7
|
+
type CursorLeaf,
|
|
8
|
+
type ImmutableIndex,
|
|
9
|
+
validateCursorClaims,
|
|
10
|
+
validateCursorToken,
|
|
11
|
+
validateImmutableIndex,
|
|
12
|
+
} from '../contracts/index.js';
|
|
13
|
+
|
|
14
|
+
/** A durable owner for immutable indexes. This service never chooses a session or artifact location. */
|
|
15
|
+
export interface CursorIndexStore<Row> {
|
|
16
|
+
put(index: ImmutableIndex<Row>): void | Promise<void>;
|
|
17
|
+
get(digest: string): ImmutableIndex<Row> | undefined | Promise<ImmutableIndex<Row> | undefined>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Caller-owned identity for one cursorable read. `scope` must be explicit and nonempty. */
|
|
21
|
+
export interface CursorQueryIdentity {
|
|
22
|
+
readonly scope: Record<string, unknown>;
|
|
23
|
+
readonly query: Record<string, unknown>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface CreateCursorPageInput<Row> extends CursorQueryIdentity {
|
|
27
|
+
readonly leaf: CursorLeaf;
|
|
28
|
+
readonly path: string;
|
|
29
|
+
/** The declared source order. Rows are retained in exactly this order; the service never re-sorts them. */
|
|
30
|
+
readonly order: string;
|
|
31
|
+
readonly coverage: unknown;
|
|
32
|
+
readonly rows: readonly Row[];
|
|
33
|
+
readonly expiresAt: string;
|
|
34
|
+
readonly limit?: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface ContinueCursorPageInput extends CursorQueryIdentity {
|
|
38
|
+
readonly cursor: string;
|
|
39
|
+
/** A continuation's limit is fixed by its signed first-page limit. */
|
|
40
|
+
readonly limit?: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Bounded public page; the retained index and its undisplayed rows remain store-private. */
|
|
44
|
+
export interface CursorPage<Row> {
|
|
45
|
+
readonly indexDigest: string;
|
|
46
|
+
readonly expiresAt: string;
|
|
47
|
+
readonly rows: readonly Row[];
|
|
48
|
+
readonly limit: number;
|
|
49
|
+
readonly total: number;
|
|
50
|
+
readonly nextCursor: string | null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export type CursorServiceErrorCode =
|
|
54
|
+
| 'cursor_invalid'
|
|
55
|
+
| 'cursor_expired'
|
|
56
|
+
| 'cursor_scope_mismatch'
|
|
57
|
+
| 'cursor_query_mismatch'
|
|
58
|
+
| 'cursor_limit_invalid'
|
|
59
|
+
| 'cursor_index_unavailable'
|
|
60
|
+
| 'cursor_index_invalid';
|
|
61
|
+
|
|
62
|
+
/** A typed public error; handlers map it to their stable error envelope. */
|
|
63
|
+
export class CursorServiceError extends Error {
|
|
64
|
+
constructor(readonly code: CursorServiceErrorCode, message: string) {
|
|
65
|
+
super(message);
|
|
66
|
+
this.name = 'CursorServiceError';
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface CursorServiceOptions<Row> {
|
|
71
|
+
readonly store: CursorIndexStore<Row>;
|
|
72
|
+
/** HMAC key. It is caller configuration, never read from an ambient environment. */
|
|
73
|
+
readonly secret: string | Uint8Array;
|
|
74
|
+
/** Injectable only for deterministic tests; production callers pass no clock. */
|
|
75
|
+
readonly now?: () => Date;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
interface CursorPayload {
|
|
79
|
+
readonly schemaVersion: 1;
|
|
80
|
+
readonly indexDigest: string;
|
|
81
|
+
readonly leaf: CursorLeaf;
|
|
82
|
+
readonly filter: Record<string, unknown>;
|
|
83
|
+
readonly nextExclusiveOrdinal: number;
|
|
84
|
+
readonly pageSize: number;
|
|
85
|
+
readonly expiresAt: string;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const TOKEN_PREFIX = 'c1';
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* An isolated immutable-index paginator. It has no active-session, cwd, environment,
|
|
92
|
+
* filesystem, or browser lookup: callers supply the durable index store and identity.
|
|
93
|
+
*/
|
|
94
|
+
export class CursorService<Row> {
|
|
95
|
+
private readonly now: () => Date;
|
|
96
|
+
|
|
97
|
+
constructor(private readonly options: CursorServiceOptions<Row>) {
|
|
98
|
+
if ((typeof options.secret === 'string' && options.secret.length === 0) || (options.secret instanceof Uint8Array && options.secret.byteLength === 0)) {
|
|
99
|
+
throw new Error('cursor service secret must not be empty');
|
|
100
|
+
}
|
|
101
|
+
this.now = options.now ?? (() => new Date());
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async createPage(input: CreateCursorPageInput<Row>): Promise<CursorPage<Row>> {
|
|
105
|
+
const limit = validateLimit(input.limit ?? DEFAULT_PAGE_SIZE);
|
|
106
|
+
canonicalIdentity(input);
|
|
107
|
+
const expiresAt = parseFutureExpiry(input.expiresAt, this.now());
|
|
108
|
+
if (!input.path || !input.order) throw new CursorServiceError('cursor_index_invalid', 'cursor index requires nonempty path and order');
|
|
109
|
+
|
|
110
|
+
// Canonical JSON both makes the digest deterministic and snapshots caller-owned rows.
|
|
111
|
+
const rows = cloneJson(input.rows) as readonly Row[];
|
|
112
|
+
const filter = cloneJson({ scope: input.scope, query: input.query }) as Record<string, unknown>;
|
|
113
|
+
const draft = {
|
|
114
|
+
schemaVersion: 1 as const,
|
|
115
|
+
leaf: input.leaf,
|
|
116
|
+
path: input.path,
|
|
117
|
+
createdAt: this.now().toISOString(),
|
|
118
|
+
expiresAt: expiresAt.toISOString(),
|
|
119
|
+
filter,
|
|
120
|
+
pageSize: limit,
|
|
121
|
+
order: input.order,
|
|
122
|
+
coverage: cloneJson(input.coverage),
|
|
123
|
+
rows,
|
|
124
|
+
};
|
|
125
|
+
const index: ImmutableIndex<Row> = { ...draft, digest: digestIndex(draft) };
|
|
126
|
+
assertIndex(index);
|
|
127
|
+
// Identity was validated before the durable write and is represented in the signed/indexed filter.
|
|
128
|
+
await this.options.store.put(index);
|
|
129
|
+
return this.page(index, 0);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async continuePage(input: ContinueCursorPageInput): Promise<CursorPage<Row>> {
|
|
133
|
+
// All untrusted token/identity/limit checks precede the store lookup.
|
|
134
|
+
const claims = this.decode(input.cursor);
|
|
135
|
+
const now = this.now();
|
|
136
|
+
if (Date.parse(claims.expiresAt) <= now.getTime()) {
|
|
137
|
+
throw new CursorServiceError('cursor_expired', 'cursor has expired');
|
|
138
|
+
}
|
|
139
|
+
const expected = canonicalIdentity(input);
|
|
140
|
+
const actual = canonicalJson(claims.filter);
|
|
141
|
+
if (canonicalJson((claims.filter as Record<string, unknown>).scope) !== canonicalJson(input.scope)) {
|
|
142
|
+
throw new CursorServiceError('cursor_scope_mismatch', 'cursor scope does not match this read');
|
|
143
|
+
}
|
|
144
|
+
if (actual !== expected) {
|
|
145
|
+
throw new CursorServiceError('cursor_query_mismatch', 'cursor query does not match this read');
|
|
146
|
+
}
|
|
147
|
+
if (input.limit !== undefined && input.limit !== claims.pageSize) {
|
|
148
|
+
throw new CursorServiceError('cursor_limit_invalid', 'continuation limit must equal the signed cursor limit');
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const index = await this.options.store.get(claims.indexDigest);
|
|
152
|
+
if (!index) throw new CursorServiceError('cursor_index_unavailable', 'cursor index is unavailable');
|
|
153
|
+
this.assertClaimsMatchIndex(claims, index);
|
|
154
|
+
return this.page(index, claims.nextExclusiveOrdinal);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
private page(index: ImmutableIndex<Row>, start: number): CursorPage<Row> {
|
|
158
|
+
if (start > index.rows.length) {
|
|
159
|
+
throw new CursorServiceError('cursor_index_invalid', 'cursor ordinal exceeds retained index rows');
|
|
160
|
+
}
|
|
161
|
+
const end = Math.min(start + index.pageSize, index.rows.length);
|
|
162
|
+
const rows = index.rows.slice(start, end);
|
|
163
|
+
const nextCursor = end < index.rows.length ? this.encode({
|
|
164
|
+
schemaVersion: 1,
|
|
165
|
+
indexDigest: index.digest,
|
|
166
|
+
leaf: index.leaf,
|
|
167
|
+
filter: index.filter,
|
|
168
|
+
// This is the exclusive array offset: rows [0, offset) have already been returned.
|
|
169
|
+
nextExclusiveOrdinal: end,
|
|
170
|
+
pageSize: index.pageSize,
|
|
171
|
+
expiresAt: index.expiresAt,
|
|
172
|
+
}) : null;
|
|
173
|
+
return { indexDigest: index.digest, expiresAt: index.expiresAt, rows, limit: index.pageSize, total: index.rows.length, nextCursor };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
private encode(payload: CursorPayload): string {
|
|
177
|
+
const encoded = Buffer.from(canonicalJson(payload), 'utf8').toString('base64url');
|
|
178
|
+
const mac = this.mac(`${TOKEN_PREFIX}.${encoded}`);
|
|
179
|
+
const token = `${TOKEN_PREFIX}.${encoded}.${mac}`;
|
|
180
|
+
const result = validateCursorToken(token);
|
|
181
|
+
if (!result.valid) throw new CursorServiceError('cursor_index_invalid', result.errors.join('; '));
|
|
182
|
+
return token;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
private decode(token: string): CursorClaims {
|
|
186
|
+
const syntax = validateCursorToken(token);
|
|
187
|
+
if (!syntax.valid) throw new CursorServiceError('cursor_invalid', syntax.errors.join('; '));
|
|
188
|
+
const parts = token.split('.');
|
|
189
|
+
if (parts.length !== 3 || parts[0] !== TOKEN_PREFIX || !/^[A-Za-z0-9_-]+$/.test(parts[1]) || !/^[A-Za-z0-9_-]+$/.test(parts[2])) {
|
|
190
|
+
throw new CursorServiceError('cursor_invalid', 'cursor has invalid encoding');
|
|
191
|
+
}
|
|
192
|
+
const expectedMac = this.mac(`${parts[0]}.${parts[1]}`);
|
|
193
|
+
if (!constantTimeEqual(parts[2], expectedMac)) throw new CursorServiceError('cursor_invalid', 'cursor MAC is invalid');
|
|
194
|
+
let payload: unknown;
|
|
195
|
+
try {
|
|
196
|
+
const bytes = Buffer.from(parts[1], 'base64url');
|
|
197
|
+
if (bytes.toString('base64url') !== parts[1]) throw new Error('noncanonical base64url');
|
|
198
|
+
payload = JSON.parse(bytes.toString('utf8'));
|
|
199
|
+
} catch {
|
|
200
|
+
throw new CursorServiceError('cursor_invalid', 'cursor payload is invalid');
|
|
201
|
+
}
|
|
202
|
+
const claims = { ...(payload as Record<string, unknown>), mac: parts[2] } as CursorClaims;
|
|
203
|
+
const valid = validateCursorClaims(claims);
|
|
204
|
+
if (!valid.valid) throw new CursorServiceError('cursor_invalid', valid.errors.join('; '));
|
|
205
|
+
try {
|
|
206
|
+
canonicalIdentityFilter(claims.filter);
|
|
207
|
+
} catch {
|
|
208
|
+
throw new CursorServiceError('cursor_invalid', 'cursor filter must contain an explicit scope and query');
|
|
209
|
+
}
|
|
210
|
+
return claims;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
private assertClaimsMatchIndex(claims: CursorClaims, index: ImmutableIndex<Row>): void {
|
|
214
|
+
try {
|
|
215
|
+
assertIndex(index);
|
|
216
|
+
const { digest: _digest, ...draft } = index;
|
|
217
|
+
if (digestIndex(draft) !== index.digest) throw new Error('index digest does not match contents');
|
|
218
|
+
if (index.digest !== claims.indexDigest || index.leaf !== claims.leaf || index.pageSize !== claims.pageSize || index.expiresAt !== claims.expiresAt || canonicalJson(index.filter) !== canonicalJson(claims.filter)) {
|
|
219
|
+
throw new Error('index does not match cursor claims');
|
|
220
|
+
}
|
|
221
|
+
} catch (error) {
|
|
222
|
+
throw new CursorServiceError('cursor_index_invalid', error instanceof Error ? error.message : 'cursor index is invalid');
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
private mac(value: string): string {
|
|
227
|
+
return createHmac('sha256', this.options.secret).update(value, 'utf8').digest('base64url');
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Minimal reusable in-memory store for tests and callers that own their own process lifetime. */
|
|
232
|
+
export class MemoryCursorIndexStore<Row> implements CursorIndexStore<Row> {
|
|
233
|
+
private readonly indexes = new Map<string, ImmutableIndex<Row>>();
|
|
234
|
+
put(index: ImmutableIndex<Row>): void { this.indexes.set(index.digest, index); }
|
|
235
|
+
get(digest: string): ImmutableIndex<Row> | undefined { return this.indexes.get(digest); }
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function validateLimit(limit: number): number {
|
|
239
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > MAX_PAGE_SIZE) {
|
|
240
|
+
throw new CursorServiceError('cursor_limit_invalid', `cursor limit must be an integer from 1 to ${MAX_PAGE_SIZE}`);
|
|
241
|
+
}
|
|
242
|
+
return limit;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function parseFutureExpiry(value: string, now: Date): Date {
|
|
246
|
+
const timestamp = Date.parse(value);
|
|
247
|
+
if (!Number.isFinite(timestamp) || timestamp <= now.getTime()) {
|
|
248
|
+
throw new CursorServiceError('cursor_index_invalid', 'cursor index expiry must be a valid future timestamp');
|
|
249
|
+
}
|
|
250
|
+
return new Date(timestamp);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function canonicalIdentity(identity: CursorQueryIdentity): string {
|
|
254
|
+
try {
|
|
255
|
+
return canonicalIdentityFilter({ scope: identity.scope, query: identity.query });
|
|
256
|
+
} catch (error) {
|
|
257
|
+
if (error instanceof CursorServiceError) throw error;
|
|
258
|
+
throw new CursorServiceError('cursor_scope_mismatch', 'cursor scope and query must be JSON objects');
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function canonicalIdentityFilter(filter: Record<string, unknown>): string {
|
|
263
|
+
if (Object.keys(filter).length !== 2 || !Object.prototype.hasOwnProperty.call(filter, 'scope') || !Object.prototype.hasOwnProperty.call(filter, 'query') || !isPlainRecord(filter.scope) || !isPlainRecord(filter.query) || Object.keys(filter.scope).length === 0) {
|
|
264
|
+
throw new Error('invalid cursor identity filter');
|
|
265
|
+
}
|
|
266
|
+
return canonicalJson(filter);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function assertIndex(index: ImmutableIndex<unknown>): void {
|
|
270
|
+
const valid = validateImmutableIndex(index);
|
|
271
|
+
if (!valid.valid) throw new Error(valid.errors.join('; '));
|
|
272
|
+
canonicalIdentityFilter(index.filter);
|
|
273
|
+
canonicalJson(index.rows);
|
|
274
|
+
if (!index.path || !index.order || !Number.isFinite(Date.parse(index.createdAt)) || !Number.isFinite(Date.parse(index.expiresAt))) {
|
|
275
|
+
throw new Error('cursor index has invalid immutable metadata');
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function digestIndex(index: Omit<ImmutableIndex<unknown>, 'digest'>): string {
|
|
280
|
+
return createHash('sha256').update(canonicalJson(index), 'utf8').digest('hex');
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function cloneJson(value: unknown): unknown {
|
|
284
|
+
return JSON.parse(canonicalJson(value));
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** Deterministic JSON for query identity and immutable-index digests. */
|
|
288
|
+
function canonicalJson(value: unknown): string {
|
|
289
|
+
if (value === null || typeof value === 'boolean' || typeof value === 'string') return JSON.stringify(value);
|
|
290
|
+
if (typeof value === 'number') {
|
|
291
|
+
if (!Number.isFinite(value)) throw new Error('cursor values must be finite JSON numbers');
|
|
292
|
+
return JSON.stringify(value);
|
|
293
|
+
}
|
|
294
|
+
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`;
|
|
295
|
+
if (isPlainRecord(value)) {
|
|
296
|
+
const record = value as Record<string, unknown>;
|
|
297
|
+
return `{${Object.keys(record).sort().map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`).join(',')}}`;
|
|
298
|
+
}
|
|
299
|
+
throw new Error('cursor values must be JSON values');
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
|
303
|
+
return typeof value === 'object' && value !== null && Object.getPrototypeOf(value) === Object.prototype;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function constantTimeEqual(actual: string, expected: string): boolean {
|
|
307
|
+
const a = Buffer.from(actual, 'utf8');
|
|
308
|
+
const b = Buffer.from(expected, 'utf8');
|
|
309
|
+
return a.byteLength === b.byteLength && timingSafeEqual(a, b);
|
|
310
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { fact, type RenderableResult } from './output/render.js';
|
|
2
|
+
|
|
3
|
+
export type CaptureFailureKind = 'invocation' | 'precondition' | 'world' | 'artifact' | 'cleanup' | 'internal';
|
|
4
|
+
|
|
5
|
+
export interface CaptureFailureDescriptor {
|
|
6
|
+
readonly code: string;
|
|
7
|
+
readonly message: string;
|
|
8
|
+
readonly kind: CaptureFailureKind;
|
|
9
|
+
readonly cause?: unknown;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** A failure that crosses Capture's command boundary without rendering itself. */
|
|
13
|
+
export class CaptureError extends Error {
|
|
14
|
+
readonly descriptor: CaptureFailureDescriptor;
|
|
15
|
+
|
|
16
|
+
constructor(descriptor: CaptureFailureDescriptor) {
|
|
17
|
+
super(descriptor.message, descriptor.cause === undefined ? undefined : { cause: descriptor.cause });
|
|
18
|
+
this.name = 'CaptureError';
|
|
19
|
+
this.descriptor = descriptor;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function captureError(kind: CaptureFailureKind, code: string, message: string, cause?: unknown): CaptureError {
|
|
24
|
+
return new CaptureError({ kind, code, message, cause });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function invalidInput(message: string, code = 'invalid_input'): CaptureError {
|
|
28
|
+
return captureError('invocation', code, message);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function worldFailure(message: string, cause?: unknown): CaptureError {
|
|
32
|
+
return captureError('world', 'world_failure', message, cause);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Convert an arbitrary thrown value into Capture's single typed vocabulary. */
|
|
36
|
+
export function normalizeFailure(error: unknown): CaptureError {
|
|
37
|
+
if (error instanceof CaptureError) return error;
|
|
38
|
+
if (error instanceof AggregateError) return normalizeAggregateFailure(error);
|
|
39
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
40
|
+
return captureError('internal', 'internal_error', message || 'Capture encountered an unknown internal failure.', error);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* A dual-failure envelope (primary + cleanup, e.g. a screenshot capture that
|
|
45
|
+
* failed AND whose device-metrics restore failed) renders with the primary
|
|
46
|
+
* sub-failure's kind/code and EVERY sub-failure's message, so both failures
|
|
47
|
+
* stay distinguishable in the one rendered error an agent sees — the thrown
|
|
48
|
+
* objects themselves never cross the process boundary. Producers put the
|
|
49
|
+
* primary failure first (`new AggregateError([primary, cleanup], …)`).
|
|
50
|
+
*/
|
|
51
|
+
function normalizeAggregateFailure(error: AggregateError): CaptureError {
|
|
52
|
+
const subs = error.errors.map((sub) => normalizeFailure(sub));
|
|
53
|
+
const primary = subs[0];
|
|
54
|
+
const message = [error.message, ...subs.map((sub, i) => `[${i + 1}] ${sub.descriptor.message}`)]
|
|
55
|
+
.filter((part) => part.length > 0)
|
|
56
|
+
.join(' ');
|
|
57
|
+
if (primary === undefined) {
|
|
58
|
+
return captureError('internal', 'internal_error', message || 'Capture encountered an empty aggregate failure.', error);
|
|
59
|
+
}
|
|
60
|
+
return captureError(primary.descriptor.kind, primary.descriptor.code, message, error);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The one renderer-compatible error envelope, emitted only by capture.ts. */
|
|
64
|
+
export function failureResult(error: unknown): RenderableResult {
|
|
65
|
+
const { descriptor } = normalizeFailure(error);
|
|
66
|
+
return {
|
|
67
|
+
tag: 'error',
|
|
68
|
+
attrs: { code: descriptor.code, kind: descriptor.kind },
|
|
69
|
+
summary: fact`${descriptor.message}`,
|
|
70
|
+
};
|
|
71
|
+
}
|