@kontourai/lookout 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -1
- package/dist/src/cli.d.ts +1 -1
- package/dist/src/cli.js +9 -6
- package/dist/src/drift-emission.d.ts +3 -0
- package/dist/src/drift-emission.js +46 -9
- package/dist/src/index.d.ts +2 -0
- package/dist/src/index.js +1 -0
- package/dist/src/observation-admission.d.ts +47 -0
- package/dist/src/observation-admission.js +119 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -228,7 +228,7 @@ item, or hard failure — is the consumer's call.
|
|
|
228
228
|
```
|
|
229
229
|
lookout check <id> [--registry <path>] [--snapshot-root <path>]
|
|
230
230
|
lookout check --all [--registry <path>] [--snapshot-root <path>]
|
|
231
|
-
lookout emit-drift <id> --observation <path|-> [--registry <path>] [--observation-root <path>]
|
|
231
|
+
lookout emit-drift <id> --observation <path|-> [--registry <path>] [--observation-root <path>] [--snapshot-root <path>]
|
|
232
232
|
```
|
|
233
233
|
|
|
234
234
|
- Emits **exactly one compact JSON object per checked source, per stdout line**
|
|
@@ -260,6 +260,12 @@ Store paths refuse symbolic links. An existing source lock is not automatically
|
|
|
260
260
|
broken: after confirming no writer is active, an operator may remove an
|
|
261
261
|
abandoned `.lock` file and retry.
|
|
262
262
|
|
|
263
|
+
Before an observation can be diffed or committed, `emit-drift` resolves its
|
|
264
|
+
current and selected-prior Forage snapshot references against the configured
|
|
265
|
+
snapshot store. This is an explicit capability (`--snapshot-root` defaults to
|
|
266
|
+
`.kontourai/lookout/snapshots`); missing, corrupt, malformed, or insufficiently
|
|
267
|
+
bound captures produce no facts and no continuity commit.
|
|
268
|
+
|
|
263
269
|
## Library
|
|
264
270
|
|
|
265
271
|
```ts
|
package/dist/src/cli.d.ts
CHANGED
|
@@ -9,6 +9,6 @@ export interface RunCliOptions {
|
|
|
9
9
|
loadRegistry?(path?: string): Promise<SourceStore>;
|
|
10
10
|
runner?: CheckRunner;
|
|
11
11
|
readObservation?: (path: string) => Promise<unknown>;
|
|
12
|
-
emitDrift?(sourceId: string, value: unknown, store: SourceStore, observationRoot?: string): Promise<DriftResult>;
|
|
12
|
+
emitDrift?(sourceId: string, value: unknown, store: SourceStore, observationRoot?: string, snapshotRoot?: string): Promise<DriftResult>;
|
|
13
13
|
}
|
|
14
14
|
export declare function runCli(options?: RunCliOptions): Promise<number>;
|
package/dist/src/cli.js
CHANGED
|
@@ -35,7 +35,7 @@ export async function runCli(options = {}) {
|
|
|
35
35
|
stderr.write(`Could not read observation: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
36
36
|
return 1;
|
|
37
37
|
}
|
|
38
|
-
const result = await (options.emitDrift ?? emitDrift)(parsed.id, value, store, parsed.observationRoot);
|
|
38
|
+
const result = await (options.emitDrift ?? emitDrift)(parsed.id, value, store, parsed.observationRoot, parsed.snapshotRoot);
|
|
39
39
|
if (!result.ok) {
|
|
40
40
|
stderr.write(`${result.error.kind}: ${result.error.message}\n`);
|
|
41
41
|
return 1;
|
|
@@ -104,9 +104,10 @@ function parseEmitArgs(argv) {
|
|
|
104
104
|
let observationPath;
|
|
105
105
|
let registryPath;
|
|
106
106
|
let observationRoot;
|
|
107
|
+
let snapshotRoot;
|
|
107
108
|
for (let index = 1; index < argv.length; index += 1) {
|
|
108
109
|
const arg = argv[index];
|
|
109
|
-
if (arg === "--registry" || arg === "--observation" || arg === "--observation-root") {
|
|
110
|
+
if (arg === "--registry" || arg === "--observation" || arg === "--observation-root" || arg === "--snapshot-root") {
|
|
110
111
|
const value = argv[++index];
|
|
111
112
|
if (!value || (value.startsWith("--") && value !== "-"))
|
|
112
113
|
return `${arg} requires a path`;
|
|
@@ -114,8 +115,10 @@ function parseEmitArgs(argv) {
|
|
|
114
115
|
registryPath = value;
|
|
115
116
|
else if (arg === "--observation")
|
|
116
117
|
observationPath = value;
|
|
117
|
-
else
|
|
118
|
+
else if (arg === "--observation-root")
|
|
118
119
|
observationRoot = value;
|
|
120
|
+
else
|
|
121
|
+
snapshotRoot = value;
|
|
119
122
|
}
|
|
120
123
|
else if (arg.startsWith("--"))
|
|
121
124
|
return `Unknown option: ${arg}`;
|
|
@@ -128,7 +131,7 @@ function parseEmitArgs(argv) {
|
|
|
128
131
|
return "emit-drift requires a source id";
|
|
129
132
|
if (!observationPath)
|
|
130
133
|
return "emit-drift requires --observation <path|->";
|
|
131
|
-
return { command: "emit-drift", id, observationPath, registryPath, observationRoot };
|
|
134
|
+
return { command: "emit-drift", id, observationPath, registryPath, observationRoot, snapshotRoot };
|
|
132
135
|
}
|
|
133
136
|
async function readObservation(file) {
|
|
134
137
|
const maxBytes = 1024 * 1024;
|
|
@@ -164,10 +167,10 @@ function cliEntities(observation) {
|
|
|
164
167
|
}
|
|
165
168
|
return [...grouped].map(([key, proposals]) => ({ key, proposals }));
|
|
166
169
|
}
|
|
167
|
-
async function emitDrift(sourceId, value, store, observationRoot) {
|
|
170
|
+
async function emitDrift(sourceId, value, store, observationRoot, snapshotRoot) {
|
|
168
171
|
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
169
172
|
return { ok: false, error: { kind: "invalid-input", message: "Observation document must be an object" } };
|
|
170
173
|
const document = value;
|
|
171
174
|
const source = (await store.get(sourceId));
|
|
172
|
-
return createDriftEmitter({ store: createObservationStore({ root: observationRoot }) }).emit({ source, current: document.observation, check: document.check, callbacks: { selectEntities: cliEntities, entityIdentity: (entity) => entity.key, proposalsFor: (entity) => entity.proposals, fieldIdentity: (_entity, proposal) => proposal.fieldPath } });
|
|
175
|
+
return createDriftEmitter({ store: createObservationStore({ root: observationRoot }), snapshotStore: createLookoutSnapshotStore(snapshotRoot) }).emit({ source, current: document.observation, check: document.check, callbacks: { selectEntities: cliEntities, entityIdentity: (entity) => entity.key, proposalsFor: (entity) => entity.proposals, fieldIdentity: (_entity, proposal) => proposal.fieldPath } });
|
|
173
176
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { LookoutSource } from "./registry.js";
|
|
2
2
|
import { type ProposalDiffEvent, type ProposalSetDiff, type ProposalSetDiffInput, type ProposalSetFacts, type ProposalSetObservation } from "./proposal-diff.js";
|
|
3
3
|
import type { ObservationCheckAnchor, ObservationStore, StoredProposalObservationV1 } from "./observation-store.js";
|
|
4
|
+
import type { SnapshotStore } from "@kontourai/forage";
|
|
4
5
|
export interface BaselineEstablishedFact {
|
|
5
6
|
readonly kind: "baseline-established";
|
|
6
7
|
readonly sourceId: string;
|
|
@@ -51,6 +52,8 @@ export interface DriftEmitter<E> {
|
|
|
51
52
|
}
|
|
52
53
|
export interface CreateDriftEmitterOptions<E> {
|
|
53
54
|
readonly store: ObservationStore;
|
|
55
|
+
/** Required explicit capability for authenticating durable snapshot references. */
|
|
56
|
+
readonly snapshotStore: SnapshotStore;
|
|
54
57
|
readonly now?: () => string;
|
|
55
58
|
readonly diff?: (input: ProposalSetDiffInput<E>) => {
|
|
56
59
|
readonly ok: true;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { diffProposalSets } from "./proposal-diff.js";
|
|
2
|
+
import { admitProposalObservation } from "./observation-admission.js";
|
|
2
3
|
function stableJson(value) {
|
|
3
4
|
if (Array.isArray(value))
|
|
4
5
|
return `[${value.map(stableJson).join(",")}]`;
|
|
@@ -27,23 +28,40 @@ export function createDriftEmitter(options) {
|
|
|
27
28
|
return {
|
|
28
29
|
async emit(input) {
|
|
29
30
|
try {
|
|
30
|
-
|
|
31
|
+
// Copy the complete caller image before the first await. Callers retain
|
|
32
|
+
// their objects, so resolving a snapshot must not create a time window
|
|
33
|
+
// in which a mutated ref/proposal is later committed under an admitted one.
|
|
34
|
+
const invocation = captureInvocation(input);
|
|
35
|
+
if (!invocation || !invocation.source || invocation.source.id !== invocation.current?.sourceId || invocation.check?.currentSnapshotRef !== invocation.current?.snapshotRef) {
|
|
31
36
|
return { ok: false, error: { kind: "invalid-input", message: "Registry source, observation, and check anchor must agree" } };
|
|
32
37
|
}
|
|
33
|
-
|
|
38
|
+
// Admit the current reference before continuity I/O. In particular,
|
|
39
|
+
// Forage rejects a malformed digest before findExact/loadLatest runs.
|
|
40
|
+
const currentAdmission = await admitProposalObservation({ source: invocation.source, current: invocation.current, check: invocation.check, prior: null, snapshotStore: options.snapshotStore });
|
|
41
|
+
if (!currentAdmission.ok)
|
|
42
|
+
return { ok: false, error: { kind: "invalid-input", message: "Current observation could not be admitted" } };
|
|
43
|
+
const loaded = await options.store.loadLatest(invocation.source.id);
|
|
34
44
|
if (!loaded.ok)
|
|
35
45
|
return { ok: false, error: { kind: "prior-state-error", message: loaded.error.message, cause: loaded.error } };
|
|
46
|
+
const prior = loaded.value === null ? null : capture(loaded.value);
|
|
47
|
+
if (loaded.value !== null && prior === null)
|
|
48
|
+
return { ok: false, error: { kind: "prior-state-error", message: "Prior observation could not be captured" } };
|
|
49
|
+
const admission = await admitProposalObservation({ source: invocation.source, current: invocation.current, check: invocation.check, prior, snapshotStore: options.snapshotStore });
|
|
50
|
+
if (!admission.ok)
|
|
51
|
+
return { ok: false, error: { kind: admission.error.kind === "prior-unresolved" ? "prior-state-error" : "invalid-input", message: "Observation could not be admitted" } };
|
|
36
52
|
const recordedAt = now();
|
|
37
|
-
const priorObservationId =
|
|
53
|
+
const priorObservationId = prior?.observationId ?? null;
|
|
38
54
|
let events = [];
|
|
39
55
|
let facts;
|
|
40
|
-
if (
|
|
41
|
-
facts = [{ kind: "baseline-established", sourceId:
|
|
56
|
+
if (prior === null) {
|
|
57
|
+
facts = [{ kind: "baseline-established", sourceId: invocation.source.id, snapshotRef: invocation.current.snapshotRef, observedAt: invocation.current.observedAt, origin: invocation.source.kind, resolution: "observation", proposalCount: invocation.current.proposals.length }];
|
|
42
58
|
}
|
|
43
59
|
else {
|
|
44
60
|
let derived;
|
|
45
61
|
try {
|
|
46
|
-
|
|
62
|
+
// Diff callbacks receive their own clone, never the image used for
|
|
63
|
+
// durable commit below.
|
|
64
|
+
derived = diff({ prior: { sourceId: prior.sourceId, snapshotRef: prior.snapshotRef, observedAt: prior.observedAt, proposals: capture(prior.proposals) ?? [] }, current: capture(invocation.current), ...invocation.callbacks });
|
|
47
65
|
}
|
|
48
66
|
catch (cause) {
|
|
49
67
|
return { ok: false, error: { kind: "diff-error", message: "Proposal diff threw", cause } };
|
|
@@ -52,7 +70,7 @@ export function createDriftEmitter(options) {
|
|
|
52
70
|
return { ok: false, error: { kind: "diff-error", message: derived.error.message, cause: derived.error } };
|
|
53
71
|
const normalized = normalizeDiff(derived.value);
|
|
54
72
|
events = normalized.events;
|
|
55
|
-
facts = [{ kind: "proposal-set-facts", priorSnapshotRef:
|
|
73
|
+
facts = [{ kind: "proposal-set-facts", priorSnapshotRef: prior.snapshotRef, currentSnapshotRef: invocation.current.snapshotRef, origin: invocation.source.kind, resolution: "observation", value: normalized.facts }];
|
|
56
74
|
}
|
|
57
75
|
try {
|
|
58
76
|
JSON.stringify({ events, facts });
|
|
@@ -60,10 +78,10 @@ export function createDriftEmitter(options) {
|
|
|
60
78
|
catch (cause) {
|
|
61
79
|
return { ok: false, error: { kind: "serialization-error", message: "Drift result is not serializable", cause } };
|
|
62
80
|
}
|
|
63
|
-
const committed = await options.store.commit({ observation:
|
|
81
|
+
const committed = await options.store.commit({ observation: invocation.current, recordedAt, check: invocation.check }, prior?.observationId ?? null);
|
|
64
82
|
if (!committed.ok)
|
|
65
83
|
return { ok: false, error: { kind: "persistence-error", message: committed.error.message, cause: committed.error } };
|
|
66
|
-
return { ok: true, value: { sourceId:
|
|
84
|
+
return { ok: true, value: { sourceId: invocation.source.id, events, facts, priorObservationId, committedObservation: committed.value, warnings: committed.warnings ?? [] } };
|
|
67
85
|
}
|
|
68
86
|
catch (cause) {
|
|
69
87
|
return { ok: false, error: { kind: "unexpected", message: "Drift emission failed", cause } };
|
|
@@ -71,3 +89,22 @@ export function createDriftEmitter(options) {
|
|
|
71
89
|
},
|
|
72
90
|
};
|
|
73
91
|
}
|
|
92
|
+
function capture(value) { try {
|
|
93
|
+
return structuredClone(value);
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
return null;
|
|
97
|
+
} }
|
|
98
|
+
function captureInvocation(input) {
|
|
99
|
+
try {
|
|
100
|
+
if (!input || typeof input !== "object")
|
|
101
|
+
return null;
|
|
102
|
+
const image = capture({ source: input.source, current: input.current, check: input.check });
|
|
103
|
+
if (image === null || !input.callbacks || typeof input.callbacks !== "object")
|
|
104
|
+
return null;
|
|
105
|
+
return { ...image, callbacks: { ...input.callbacks } };
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
}
|
package/dist/src/index.d.ts
CHANGED
|
@@ -11,6 +11,8 @@ export type { SourceStore } from "./source-store.js";
|
|
|
11
11
|
export type { ExtractableLookoutSource, LookoutRegistryDocument, LookoutSource, LookoutSourceKind, RenderPolicy, StructuredFileFormat, StructuredFileLookoutSource, } from "./registry.js";
|
|
12
12
|
export { createLookoutSnapshotStore, resolveLookoutSnapshot, } from "./snapshot-store.js";
|
|
13
13
|
export type { ResolveLookoutSnapshotOptions } from "./snapshot-store.js";
|
|
14
|
+
export { admitProposalObservation } from "./observation-admission.js";
|
|
15
|
+
export type { AdmitProposalObservationInput, AdmittedProposalObservation, AdmittedSnapshotIdentity, ObservationAdmissionError, ObservationAdmissionErrorKind, ObservationAdmissionResult } from "./observation-admission.js";
|
|
14
16
|
export type { SnapshotSourceRefResolution } from "@kontourai/forage/fetch";
|
|
15
17
|
export { canonicalValueKey } from "./canonical-value.js";
|
|
16
18
|
export type { CanonicalValueKey, CanonicalValueFormatVersion, CanonicalValueResult, DiffKernelError, DiffKernelErrorKind, DiffResult, IdentityResult, } from "./canonical-value.js";
|
package/dist/src/index.js
CHANGED
|
@@ -4,6 +4,7 @@ export { defaultProviderResolver } from "./provider-resolution.js";
|
|
|
4
4
|
export { loadRegistry, LookoutRegistry, parseRegistry, RegistryValidationError, } from "./registry.js";
|
|
5
5
|
export { inMemorySourceStore } from "./source-store.js";
|
|
6
6
|
export { createLookoutSnapshotStore, resolveLookoutSnapshot, } from "./snapshot-store.js";
|
|
7
|
+
export { admitProposalObservation } from "./observation-admission.js";
|
|
7
8
|
export { canonicalValueKey } from "./canonical-value.js";
|
|
8
9
|
export { compareStructural, diffKeyedMultiset } from "./structural-diff.js";
|
|
9
10
|
export { diffProposalSets, extractionProposalIdentity } from "./proposal-diff.js";
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { SnapshotStore } from "@kontourai/forage";
|
|
2
|
+
import type { LookoutSource } from "./registry.js";
|
|
3
|
+
import type { StoredProposalObservationV1, ObservationCheckAnchor } from "./observation-store.js";
|
|
4
|
+
import type { ProposalSetObservation } from "./proposal-diff.js";
|
|
5
|
+
/** A resolved snapshot identity suitable for durable observation metadata. */
|
|
6
|
+
export interface AdmittedSnapshotIdentity {
|
|
7
|
+
readonly sourceId: string;
|
|
8
|
+
readonly snapshotRef: string;
|
|
9
|
+
readonly url: string;
|
|
10
|
+
readonly bodyHash: string;
|
|
11
|
+
readonly fetchedAt: string;
|
|
12
|
+
/** Present only for Forage envelope-integrity references; never synthesized for legacy refs. */
|
|
13
|
+
readonly snapshotDigest?: string;
|
|
14
|
+
readonly integrity: "snapshot-envelope" | "body-and-identity";
|
|
15
|
+
}
|
|
16
|
+
export interface AdmittedProposalObservation {
|
|
17
|
+
readonly current: AdmittedSnapshotIdentity;
|
|
18
|
+
readonly prior: AdmittedSnapshotIdentity | null;
|
|
19
|
+
}
|
|
20
|
+
export type ObservationAdmissionErrorKind = "invalid-input" | "current-unresolved" | "prior-unresolved" | "insufficient-binding";
|
|
21
|
+
export interface ObservationAdmissionError {
|
|
22
|
+
readonly kind: ObservationAdmissionErrorKind;
|
|
23
|
+
readonly classification: "invalid-reference" | "not-found" | "mismatch" | "corrupt" | "store" | "source-identity" | "url-binding" | "redirect-binding";
|
|
24
|
+
readonly message: string;
|
|
25
|
+
}
|
|
26
|
+
export type ObservationAdmissionResult = {
|
|
27
|
+
readonly ok: true;
|
|
28
|
+
readonly value: AdmittedProposalObservation;
|
|
29
|
+
} | {
|
|
30
|
+
readonly ok: false;
|
|
31
|
+
readonly error: ObservationAdmissionError;
|
|
32
|
+
};
|
|
33
|
+
export interface AdmitProposalObservationInput {
|
|
34
|
+
readonly source: LookoutSource;
|
|
35
|
+
readonly current: ProposalSetObservation;
|
|
36
|
+
readonly check: ObservationCheckAnchor;
|
|
37
|
+
/** The already-selected observation-store record; admission never selects or persists continuity. */
|
|
38
|
+
readonly prior: StoredProposalObservationV1 | null;
|
|
39
|
+
/** Explicit capability: admission never chooses a snapshot root or storage implementation. */
|
|
40
|
+
readonly snapshotStore: SnapshotStore;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Authenticate proposal-observation snapshot references before they can be
|
|
44
|
+
* diffed or committed. It is deliberately metadata-only: it neither exposes
|
|
45
|
+
* snapshot bodies nor reads/writes the observation store.
|
|
46
|
+
*/
|
|
47
|
+
export declare function admitProposalObservation(input: AdmitProposalObservationInput): Promise<ObservationAdmissionResult>;
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { resolveLookoutSnapshot } from "./snapshot-store.js";
|
|
2
|
+
/**
|
|
3
|
+
* Authenticate proposal-observation snapshot references before they can be
|
|
4
|
+
* diffed or committed. It is deliberately metadata-only: it neither exposes
|
|
5
|
+
* snapshot bodies nor reads/writes the observation store.
|
|
6
|
+
*/
|
|
7
|
+
export async function admitProposalObservation(input) {
|
|
8
|
+
try {
|
|
9
|
+
// This is a public asynchronous boundary. Capture every caller-owned
|
|
10
|
+
// value before admission starts I/O so a caller cannot splice a later
|
|
11
|
+
// reference, source identity, or anchor into the authenticated result.
|
|
12
|
+
const invocation = captureInvocation(input);
|
|
13
|
+
if (invocation === null)
|
|
14
|
+
return failure("invalid-input", "source-identity", "Observation admission input is malformed");
|
|
15
|
+
return await admit(invocation);
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return failure("invalid-input", "source-identity", "Observation admission input is malformed");
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
async function admit(invocation) {
|
|
22
|
+
if (!invocation || typeof invocation !== "object" || !invocation.source || typeof invocation.source !== "object" || !invocation.current || typeof invocation.current !== "object" || !invocation.check || typeof invocation.check !== "object" || !invocation.snapshotStore || typeof invocation.snapshotStore !== "object" ||
|
|
23
|
+
typeof invocation.source.id !== "string" || typeof invocation.source.url !== "string" || typeof invocation.current.sourceId !== "string" || typeof invocation.current.snapshotRef !== "string" || typeof invocation.check.currentSnapshotRef !== "string" ||
|
|
24
|
+
invocation.source.id !== invocation.current.sourceId || invocation.check.currentSnapshotRef !== invocation.current.snapshotRef) {
|
|
25
|
+
return failure("invalid-input", "source-identity", "Registry source, observation, and check anchor must agree");
|
|
26
|
+
}
|
|
27
|
+
const registeredUrl = normalizedHttpUrl(invocation.source.url);
|
|
28
|
+
if (registeredUrl === null)
|
|
29
|
+
return failure("invalid-input", "url-binding", "Registered source URL is not an admissible HTTP URL");
|
|
30
|
+
// resolveLookoutSnapshot delegates canonical reference validation to Forage;
|
|
31
|
+
// its resolver validates before calling findExact, so malformed refs perform
|
|
32
|
+
// no snapshot-store I/O.
|
|
33
|
+
const current = await resolveLookoutSnapshot(invocation.current.snapshotRef, { store: invocation.snapshotStore });
|
|
34
|
+
if (!current.ok)
|
|
35
|
+
return resolutionFailure("current", current.error.kind);
|
|
36
|
+
if (current.snapshot.sourceId !== invocation.source.id)
|
|
37
|
+
return failure("invalid-input", "source-identity", "Current snapshot identity does not match the registered source");
|
|
38
|
+
const currentBinding = bindCurrentSnapshot(current.snapshot.url, current.snapshot.redirects, current.integrity, registeredUrl);
|
|
39
|
+
if (currentBinding !== null)
|
|
40
|
+
return currentBinding;
|
|
41
|
+
if (invocation.prior === null)
|
|
42
|
+
return { ok: true, value: { current: identity(invocation.current.snapshotRef, current), prior: null } };
|
|
43
|
+
if (!invocation.prior || typeof invocation.prior !== "object" || typeof invocation.prior.sourceId !== "string" || typeof invocation.prior.snapshotRef !== "string" || !invocation.prior.check || typeof invocation.prior.check !== "object" || typeof invocation.prior.check.currentSnapshotRef !== "string" ||
|
|
44
|
+
invocation.prior.sourceId !== invocation.source.id || invocation.prior.check.currentSnapshotRef !== invocation.prior.snapshotRef) {
|
|
45
|
+
return failure("invalid-input", "source-identity", "Prior observation identity is not valid for the registered source");
|
|
46
|
+
}
|
|
47
|
+
const prior = await resolveLookoutSnapshot(invocation.prior.snapshotRef, { store: invocation.snapshotStore });
|
|
48
|
+
if (!prior.ok)
|
|
49
|
+
return resolutionFailure("prior", prior.error.kind);
|
|
50
|
+
if (prior.snapshot.sourceId !== invocation.source.id)
|
|
51
|
+
return failure("invalid-input", "source-identity", "Prior snapshot identity does not match the registered source");
|
|
52
|
+
// A historical capture may legitimately precede a registry URL change. It
|
|
53
|
+
// still needs durable identity authentication, but is not rebound to today’s URL.
|
|
54
|
+
if (prior.snapshot.redirects?.length && prior.integrity !== "snapshot-envelope") {
|
|
55
|
+
return failure("insufficient-binding", "redirect-binding", "Legacy snapshot references cannot authenticate redirect captures");
|
|
56
|
+
}
|
|
57
|
+
if (prior.snapshot.redirects?.length && !validRedirectChain(prior.snapshot.redirects, prior.snapshot.url)) {
|
|
58
|
+
return failure("insufficient-binding", "redirect-binding", "Snapshot redirect capture is not admissibly bound");
|
|
59
|
+
}
|
|
60
|
+
return { ok: true, value: { current: identity(invocation.current.snapshotRef, current), prior: identity(invocation.prior.snapshotRef, prior) } };
|
|
61
|
+
}
|
|
62
|
+
function identity(snapshotRef, resolved) {
|
|
63
|
+
return { sourceId: resolved.snapshot.sourceId, snapshotRef, url: resolved.snapshot.url, bodyHash: resolved.snapshot.bodyHash, fetchedAt: resolved.snapshot.fetchedAt, ...(resolved.reference.snapshotDigest === undefined ? {} : { snapshotDigest: resolved.reference.snapshotDigest }), integrity: resolved.integrity };
|
|
64
|
+
}
|
|
65
|
+
function resolutionFailure(side, kind) {
|
|
66
|
+
const classification = { "invalid-reference": "invalid-reference", "snapshot-not-found": "not-found", "snapshot-mismatch": "mismatch", "snapshot-corrupt": "corrupt", "snapshot-store-error": "store" }[kind];
|
|
67
|
+
return failure(side === "current" ? "current-unresolved" : "prior-unresolved", classification, `${side === "current" ? "Current" : "Prior"} snapshot reference could not be admitted`);
|
|
68
|
+
}
|
|
69
|
+
function failure(kind, classification, message) { return { ok: false, error: { kind, classification, message } }; }
|
|
70
|
+
function captureInvocation(input) {
|
|
71
|
+
try {
|
|
72
|
+
if (!input || typeof input !== "object")
|
|
73
|
+
return null;
|
|
74
|
+
const image = structuredClone({ source: input.source, current: input.current, check: input.check, prior: input.prior });
|
|
75
|
+
return { ...image, snapshotStore: input.snapshotStore };
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
function normalizedHttpUrl(value) {
|
|
82
|
+
try {
|
|
83
|
+
const url = new URL(value);
|
|
84
|
+
if ((url.protocol !== "http:" && url.protocol !== "https:") || url.username || url.password)
|
|
85
|
+
return null;
|
|
86
|
+
url.hash = "";
|
|
87
|
+
return url.href;
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
function bindCurrentSnapshot(finalUrl, redirects, integrity, registeredUrl) {
|
|
94
|
+
const normalizedFinal = normalizedHttpUrl(finalUrl);
|
|
95
|
+
if (normalizedFinal === null)
|
|
96
|
+
return failure("insufficient-binding", "url-binding", "Snapshot URL is not an admissible HTTP URL");
|
|
97
|
+
if (!redirects?.length)
|
|
98
|
+
return normalizedFinal === registeredUrl ? null : failure("insufficient-binding", "url-binding", "Snapshot URL is not bound to the registered source");
|
|
99
|
+
if (integrity !== "snapshot-envelope")
|
|
100
|
+
return failure("insufficient-binding", "redirect-binding", "Legacy snapshot references cannot authenticate redirect captures");
|
|
101
|
+
if (normalizedHttpUrl(redirects[0]) !== registeredUrl || !validRedirectChain(redirects, finalUrl))
|
|
102
|
+
return failure("insufficient-binding", "redirect-binding", "Snapshot redirect capture is not admissibly bound");
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
/** Forage records visited redirect URLs before the final URL. */
|
|
106
|
+
function validRedirectChain(redirects, finalUrl) {
|
|
107
|
+
const urls = [...redirects, finalUrl].map(normalizedHttpUrl);
|
|
108
|
+
if (urls.some((url) => url === null))
|
|
109
|
+
return false;
|
|
110
|
+
const concrete = urls;
|
|
111
|
+
const parsed = concrete.map((url) => new URL(url));
|
|
112
|
+
const host = parsed[0].host;
|
|
113
|
+
for (let index = 0; index < parsed.length; index += 1) {
|
|
114
|
+
const url = parsed[index];
|
|
115
|
+
if (url.host !== host || (index > 0 && parsed[index - 1].protocol === "https:" && url.protocol !== "https:"))
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
return true;
|
|
119
|
+
}
|