@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 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
- if (!input.source || input.source.id !== input.current?.sourceId || input.check?.currentSnapshotRef !== input.current?.snapshotRef) {
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
- const loaded = await options.store.loadLatest(input.source.id);
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 = loaded.value?.observationId ?? null;
53
+ const priorObservationId = prior?.observationId ?? null;
38
54
  let events = [];
39
55
  let facts;
40
- if (loaded.value === null) {
41
- facts = [{ kind: "baseline-established", sourceId: input.source.id, snapshotRef: input.current.snapshotRef, observedAt: input.current.observedAt, origin: input.source.kind, resolution: "observation", proposalCount: input.current.proposals.length }];
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
- derived = diff({ prior: { sourceId: loaded.value.sourceId, snapshotRef: loaded.value.snapshotRef, observedAt: loaded.value.observedAt, proposals: loaded.value.proposals }, current: input.current, ...input.callbacks });
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: loaded.value.snapshotRef, currentSnapshotRef: input.current.snapshotRef, origin: input.source.kind, resolution: "observation", value: normalized.facts }];
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: input.current, recordedAt, check: input.check }, loaded.value?.observationId ?? null);
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: input.source.id, events, facts, priorObservationId, committedObservation: committed.value, warnings: committed.warnings ?? [] } };
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
+ }
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kontourai/lookout",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "A small source registry and drift-check runner built on Forage snapshots.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",