@gmickel/gno 1.28.0 → 1.29.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 +46 -20
- package/assets/skill/SKILL.md +33 -0
- package/browser-extension/artifacts/gno-browser-clipper-v1.29.0.zip +0 -0
- package/browser-extension/artifacts/gno-browser-clipper-v1.29.0.zip.sha256 +1 -0
- package/browser-extension/dist/chunk-b2zm0jjd.js +50 -0
- package/browser-extension/dist/manifest.json +1 -1
- package/browser-extension/dist/preview.html +1 -1
- package/browser-extension/dist/service-worker.js +6 -6
- package/package.json +1 -1
- package/spec/cli.md +41 -1
- package/spec/db/schema.sql +19 -0
- package/spec/mcp.md +66 -0
- package/spec/output-schemas/ask.schema.json +31 -1
- package/spec/output-schemas/browser-clip-preview.schema.json +30 -0
- package/spec/output-schemas/collection-egress-check.schema.json +91 -0
- package/spec/output-schemas/collection-egress-policy-set.schema.json +56 -0
- package/spec/output-schemas/collection-egress-policy.schema.json +41 -0
- package/spec/output-schemas/context-capsule-v1.schema.json +89 -2
- package/spec/output-schemas/context-capsule-verification.schema.json +1 -1
- package/spec/output-schemas/egress-audit-management.schema.json +88 -0
- package/spec/output-schemas/mcp-http-error.schema.json +113 -2
- package/spec/output-schemas/publish-artifact.schema.json +32 -0
- package/spec/output-schemas/record-import.schema.json +30 -0
- package/spec/output-schemas/retrieval-trace-common.schema.json +31 -0
- package/spec/output-schemas/retrieval-trace-export.schema.json +3 -0
- package/spec/output-schemas/retrieval-trace-qrels.schema.json +3 -0
- package/spec/output-schemas/retrieval-trace-summary.schema.json +4 -0
- package/spec/output-schemas/search-results.schema.json +30 -0
- package/src/app/context-runtime-contract.ts +16 -7
- package/src/cli/commands/ask.ts +7 -0
- package/src/cli/commands/collection/index.ts +5 -0
- package/src/cli/commands/collection/policy.ts +126 -0
- package/src/cli/commands/context-build.ts +5 -0
- package/src/cli/commands/daemon.ts +45 -3
- package/src/cli/commands/egress-audit.ts +68 -0
- package/src/cli/commands/embed.ts +2 -0
- package/src/cli/commands/query.ts +12 -0
- package/src/cli/commands/replay.ts +13 -3
- package/src/cli/commands/vsearch.ts +3 -1
- package/src/cli/program.ts +123 -0
- package/src/config/index.ts +9 -0
- package/src/config/types.ts +50 -0
- package/src/core/browser-clip.ts +16 -1
- package/src/core/collection-egress-policy-projection.ts +28 -0
- package/src/core/collection-egress-policy-service.ts +443 -0
- package/src/core/collection-egress-policy-validation.ts +242 -0
- package/src/core/config-mutation.ts +31 -19
- package/src/core/context-capsule-schema.ts +111 -1
- package/src/core/context-capsule-validation.ts +4 -4
- package/src/core/context-capsule-verification.ts +5 -1
- package/src/core/context-capsule.ts +177 -132
- package/src/core/context-evidence.ts +86 -14
- package/src/core/destination-classifier.ts +402 -0
- package/src/core/egress-audit.ts +239 -0
- package/src/core/egress-authorization.ts +50 -0
- package/src/core/egress-enforcement.ts +264 -0
- package/src/core/egress-policy.ts +440 -0
- package/src/core/egress-provenance.ts +336 -0
- package/src/core/job-manager.ts +37 -0
- package/src/core/network-boundary-inventory.ts +261 -0
- package/src/core/retrieval-qrels.ts +6 -0
- package/src/core/retrieval-replay.ts +18 -4
- package/src/core/retrieval-trace-export.ts +23 -4
- package/src/core/retrieval-trace-management-helpers.ts +1 -0
- package/src/core/retrieval-trace-management-types.ts +3 -0
- package/src/core/retrieval-trace-management.ts +9 -1
- package/src/core/retrieval-trace-session.ts +94 -0
- package/src/core/retrieval-trace.ts +22 -0
- package/src/ingestion/record-adapter.ts +12 -1
- package/src/ingestion/record-container.ts +38 -22
- package/src/ingestion/sync.ts +15 -0
- package/src/ingestion/types.ts +2 -0
- package/src/llm/errors.ts +10 -0
- package/src/llm/http-inference.ts +175 -0
- package/src/llm/http-policy.ts +537 -0
- package/src/llm/httpEmbedding.ts +47 -28
- package/src/llm/httpGeneration.ts +31 -18
- package/src/llm/httpRerank.ts +30 -18
- package/src/llm/index.ts +1 -0
- package/src/llm/nodeLlamaCpp/adapter.ts +55 -20
- package/src/llm/pinned-http-connection.ts +177 -0
- package/src/mcp/context.ts +53 -1
- package/src/mcp/http-egress.ts +220 -0
- package/src/mcp/http-security.ts +32 -21
- package/src/mcp/http-session.ts +13 -2
- package/src/mcp/http-transport.ts +96 -6
- package/src/mcp/sync-egress.ts +24 -0
- package/src/mcp/tools/add-collection.ts +4 -0
- package/src/mcp/tools/ask.ts +1 -1
- package/src/mcp/tools/context.ts +9 -2
- package/src/mcp/tools/egress.ts +247 -0
- package/src/mcp/tools/embed.ts +1 -0
- package/src/mcp/tools/index-cmd.ts +7 -0
- package/src/mcp/tools/index.ts +98 -0
- package/src/mcp/tools/query.ts +10 -0
- package/src/mcp/tools/remove-collection.ts +4 -0
- package/src/mcp/tools/sync.ts +6 -0
- package/src/mcp/tools/trace.ts +7 -1
- package/src/mcp/tools/vsearch.ts +1 -0
- package/src/pipeline/egress-lineage.ts +124 -0
- package/src/pipeline/hybrid.ts +14 -0
- package/src/pipeline/search.ts +12 -0
- package/src/pipeline/types.ts +3 -0
- package/src/pipeline/vsearch.ts +41 -20
- package/src/publish/artifact.ts +31 -3
- package/src/publish/export-service.ts +25 -0
- package/src/sdk/client.ts +119 -2
- package/src/sdk/embed.ts +1 -0
- package/src/sdk/types.ts +33 -0
- package/src/serve/clipper-capture.ts +5 -1
- package/src/serve/closed-json.ts +61 -0
- package/src/serve/config-sync.ts +4 -1
- package/src/serve/context.ts +1 -0
- package/src/serve/doc-events.ts +110 -39
- package/src/serve/public/components/CollectionEgressCheckPanel.tsx +411 -0
- package/src/serve/public/components/CollectionEgressPolicyEditor.tsx +78 -0
- package/src/serve/public/components/CollectionModelDialog.tsx +93 -9
- package/src/serve/public/components/EgressAuditPanel.tsx +278 -0
- package/src/serve/public/globals.built.css +1 -1
- package/src/serve/public/pages/Collections.tsx +3 -0
- package/src/serve/resident-request.ts +80 -3
- package/src/serve/resident-runtime.ts +63 -1
- package/src/serve/routes/api.ts +174 -0
- package/src/serve/routes/clipper.ts +64 -1
- package/src/serve/routes/mcp.ts +6 -0
- package/src/serve/routes/traces.ts +27 -4
- package/src/serve/server.ts +118 -7
- package/src/store/migrations/023-collection-egress-policy.ts +61 -0
- package/src/store/migrations/024-egress-derived-lineage.ts +121 -0
- package/src/store/migrations/025-collection-egress-policy-revision.ts +40 -0
- package/src/store/migrations/index.ts +6 -0
- package/src/store/retrieval-trace-codec.ts +15 -0
- package/src/store/sqlite/adapter.ts +170 -7
- package/src/store/sqlite/change-journal-store.ts +41 -2
- package/src/store/sqlite/egress-audit-store.ts +485 -0
- package/src/store/sqlite/retrieval-trace-management-store.ts +47 -6
- package/src/store/sqlite/retrieval-trace-retention.ts +2 -0
- package/src/store/sqlite/retrieval-trace-rows.ts +8 -1
- package/src/store/sqlite/retrieval-trace-store.ts +104 -5
- package/src/store/types.ts +126 -1
- package/browser-extension/artifacts/gno-browser-clipper-v1.28.0.zip +0 -0
- package/browser-extension/artifacts/gno-browser-clipper-v1.28.0.zip.sha256 +0 -1
- package/browser-extension/dist/chunk-vn5f663b.js +0 -50
package/src/core/browser-clip.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
|
|
3
|
+
import type { EgressLineage } from "./egress-provenance";
|
|
4
|
+
|
|
3
5
|
import { canonicalize } from "../converters/canonicalize";
|
|
4
6
|
import {
|
|
5
7
|
BROWSER_CLIP_MAX_BYTES,
|
|
@@ -15,6 +17,10 @@ import {
|
|
|
15
17
|
type CaptureInput,
|
|
16
18
|
type CaptureSource,
|
|
17
19
|
} from "./capture";
|
|
20
|
+
import {
|
|
21
|
+
egressLineageSchema,
|
|
22
|
+
legacyLocalOnlyEgressLineage,
|
|
23
|
+
} from "./egress-provenance";
|
|
18
24
|
|
|
19
25
|
export {
|
|
20
26
|
BROWSER_CLIP_MAX_BYTES,
|
|
@@ -206,12 +212,14 @@ export type BrowserClipBlock = z.infer<typeof readerBlockSchema>;
|
|
|
206
212
|
export interface PreparedBrowserClip {
|
|
207
213
|
payload: BrowserClipPayload;
|
|
208
214
|
captureInput: CaptureInput;
|
|
215
|
+
egressLineage: EgressLineage;
|
|
209
216
|
provenance: BrowserClipProvenance;
|
|
210
217
|
preview: {
|
|
211
218
|
body: string;
|
|
212
219
|
digest: string;
|
|
213
220
|
source: CaptureSource;
|
|
214
221
|
destination: BrowserClipPayload["destination"];
|
|
222
|
+
egressLineage: EgressLineage;
|
|
215
223
|
tags: string[];
|
|
216
224
|
};
|
|
217
225
|
}
|
|
@@ -330,9 +338,13 @@ const dedupeWarnings = (
|
|
|
330
338
|
|
|
331
339
|
export const prepareBrowserClip = (
|
|
332
340
|
input: unknown,
|
|
333
|
-
options: { now?: Date } = {}
|
|
341
|
+
options: { now?: Date; egressLineage?: EgressLineage } = {}
|
|
334
342
|
): PreparedBrowserClip => {
|
|
335
343
|
const payload = browserClipPayloadSchema.parse(input);
|
|
344
|
+
const egressLineage = egressLineageSchema.parse(
|
|
345
|
+
options.egressLineage ??
|
|
346
|
+
legacyLocalOnlyEgressLineage(payload.destination.collection)
|
|
347
|
+
);
|
|
336
348
|
const capturedAt = (options.now ?? new Date()).toISOString();
|
|
337
349
|
const sourceUrl = normalizeUrl(payload.sourceUrl);
|
|
338
350
|
const canonicalUrl =
|
|
@@ -411,6 +423,7 @@ export const prepareBrowserClip = (
|
|
|
411
423
|
stableJson({
|
|
412
424
|
body,
|
|
413
425
|
destination: payload.destination,
|
|
426
|
+
egressLineage,
|
|
414
427
|
extraction: payload.extraction,
|
|
415
428
|
provenance: previewProvenance,
|
|
416
429
|
source: previewSource,
|
|
@@ -461,12 +474,14 @@ export const prepareBrowserClip = (
|
|
|
461
474
|
return {
|
|
462
475
|
payload,
|
|
463
476
|
captureInput,
|
|
477
|
+
egressLineage,
|
|
464
478
|
provenance,
|
|
465
479
|
preview: {
|
|
466
480
|
body,
|
|
467
481
|
digest: previewDigest,
|
|
468
482
|
source,
|
|
469
483
|
destination: payload.destination,
|
|
484
|
+
egressLineage,
|
|
470
485
|
tags: payload.tags,
|
|
471
486
|
},
|
|
472
487
|
};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/** Targeted config-to-store projection for one egress policy mutation. */
|
|
2
|
+
|
|
3
|
+
import type { Config } from "../config/types";
|
|
4
|
+
import type { SqliteAdapter } from "../store/sqlite/adapter";
|
|
5
|
+
|
|
6
|
+
import { parseCollectionName } from "./collection-egress-policy-validation";
|
|
7
|
+
|
|
8
|
+
export const projectCollectionEgressPolicy = async (
|
|
9
|
+
store: SqliteAdapter,
|
|
10
|
+
config: Config,
|
|
11
|
+
collectionValue: unknown
|
|
12
|
+
): Promise<{ ok: true } | { ok: false; error: string }> => {
|
|
13
|
+
const parsed = parseCollectionName(collectionValue);
|
|
14
|
+
if (!parsed.ok) return { ok: false, error: parsed.error };
|
|
15
|
+
const collection = config.collections.find(
|
|
16
|
+
({ name }) => name === parsed.value
|
|
17
|
+
);
|
|
18
|
+
if (!collection) {
|
|
19
|
+
return {
|
|
20
|
+
ok: false,
|
|
21
|
+
error: "Collection policy projection target is missing",
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
const projected = await store.upsertCollections([collection]);
|
|
25
|
+
return projected.ok
|
|
26
|
+
? { ok: true }
|
|
27
|
+
: { ok: false, error: projected.error.message };
|
|
28
|
+
};
|
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
/** Shared collection egress policy management and explanation contract. */
|
|
2
|
+
|
|
3
|
+
import type { Config, EgressPolicy, EgressPolicySource } from "../config/types";
|
|
4
|
+
import type { ApplyConfigResult, MutationResult } from "./config-mutation";
|
|
5
|
+
import type {
|
|
6
|
+
EgressAction,
|
|
7
|
+
EgressCallerContext,
|
|
8
|
+
EgressContentClass,
|
|
9
|
+
EgressDecision,
|
|
10
|
+
EgressDestinationZone,
|
|
11
|
+
} from "./egress-policy";
|
|
12
|
+
|
|
13
|
+
import { resolveConfiguredEgressPolicy } from "../config/types";
|
|
14
|
+
import { hashTraceCanonical } from "../store/retrieval-trace-codec";
|
|
15
|
+
import {
|
|
16
|
+
parseCollectionName,
|
|
17
|
+
parsePolicyCheckInput,
|
|
18
|
+
parsePolicySetInput,
|
|
19
|
+
} from "./collection-egress-policy-validation";
|
|
20
|
+
import { EgressDeniedError, planCollectionEgress } from "./egress-enforcement";
|
|
21
|
+
import { evaluateEgressPolicy } from "./egress-policy";
|
|
22
|
+
import {
|
|
23
|
+
EgressProvenanceError,
|
|
24
|
+
resolveEgressLineage,
|
|
25
|
+
} from "./egress-provenance";
|
|
26
|
+
|
|
27
|
+
const POLICY_ORDER: Readonly<Record<EgressPolicy, number>> = {
|
|
28
|
+
local_only: 0,
|
|
29
|
+
lan: 1,
|
|
30
|
+
remote: 2,
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export interface CollectionEgressPolicyState {
|
|
34
|
+
schemaVersion: "1.0";
|
|
35
|
+
collection: string;
|
|
36
|
+
configuredPolicy: EgressPolicy | null;
|
|
37
|
+
effectivePolicy: EgressPolicy;
|
|
38
|
+
source: Extract<EgressPolicySource, "explicit" | "config_default">;
|
|
39
|
+
revision: number;
|
|
40
|
+
version: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface EgressRelaxationConfirmation {
|
|
44
|
+
collection: string;
|
|
45
|
+
currentPolicy: EgressPolicy;
|
|
46
|
+
currentRevision: number;
|
|
47
|
+
targetPolicy: EgressPolicy;
|
|
48
|
+
acknowledged: true;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface CollectionEgressPolicySetResult {
|
|
52
|
+
schemaVersion: "1.0";
|
|
53
|
+
previous: CollectionEgressPolicyState;
|
|
54
|
+
current: CollectionEgressPolicyState;
|
|
55
|
+
change: "tightened" | "relaxed" | "source_changed" | "unchanged";
|
|
56
|
+
invalidation: null | {
|
|
57
|
+
policyEpoch: string;
|
|
58
|
+
queuedJobsInvalidated: number;
|
|
59
|
+
sessionsInvalidated: number;
|
|
60
|
+
staleWorkMustRetry: true;
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface CollectionEgressCheckInput {
|
|
65
|
+
collections?: readonly string[];
|
|
66
|
+
action: EgressAction;
|
|
67
|
+
destinationZone: EgressDestinationZone;
|
|
68
|
+
caller: EgressCallerContext;
|
|
69
|
+
contentClass: EgressContentClass;
|
|
70
|
+
partialResults?: "deny" | "explicit";
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface CollectionEgressCheckResult {
|
|
74
|
+
schemaVersion: "1.0";
|
|
75
|
+
mode: "complete" | "partial" | "denied";
|
|
76
|
+
allowedCollections: string[];
|
|
77
|
+
omittedCollections: Array<{
|
|
78
|
+
collection: string;
|
|
79
|
+
reason: EgressDecision["reason"];
|
|
80
|
+
}>;
|
|
81
|
+
disclosure: null | {
|
|
82
|
+
code: "EGRESS_PARTIAL_RESULT";
|
|
83
|
+
omittedCount: number;
|
|
84
|
+
omittedCollections: string[];
|
|
85
|
+
};
|
|
86
|
+
lineage: ReturnType<typeof resolveEgressLineage>;
|
|
87
|
+
decision: EgressDecision;
|
|
88
|
+
remediation: null | {
|
|
89
|
+
code: EgressDecision["reason"];
|
|
90
|
+
message: string;
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface CollectionEgressPolicyServiceDeps {
|
|
95
|
+
getConfig: () => Config;
|
|
96
|
+
mutateConfig?: (
|
|
97
|
+
mutate: (config: Config) => MutationResult<CollectionEgressPolicySetResult>
|
|
98
|
+
) => Promise<ApplyConfigResult<CollectionEgressPolicySetResult>>;
|
|
99
|
+
onPolicyChanged?: (
|
|
100
|
+
result: CollectionEgressPolicySetResult
|
|
101
|
+
) =>
|
|
102
|
+
| Promise<NonNullable<CollectionEgressPolicySetResult["invalidation"]>>
|
|
103
|
+
| NonNullable<CollectionEgressPolicySetResult["invalidation"]>;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export type CollectionEgressPolicyServiceResult<T> =
|
|
107
|
+
| { ok: true; value: T }
|
|
108
|
+
| { ok: false; code: string; error: string };
|
|
109
|
+
|
|
110
|
+
const stateVersion = (input: {
|
|
111
|
+
collection: string;
|
|
112
|
+
effectivePolicy: EgressPolicy;
|
|
113
|
+
source: EgressPolicySource;
|
|
114
|
+
revision: number;
|
|
115
|
+
}): string =>
|
|
116
|
+
`egress-policy-v1:${hashTraceCanonical({
|
|
117
|
+
collection: input.collection,
|
|
118
|
+
effectivePolicy: input.effectivePolicy,
|
|
119
|
+
source: input.source,
|
|
120
|
+
revision: input.revision,
|
|
121
|
+
})}`;
|
|
122
|
+
|
|
123
|
+
export const collectionEgressPolicyEpoch = (config: Config): string =>
|
|
124
|
+
`egress-epoch-v1:${hashTraceCanonical(
|
|
125
|
+
config.collections
|
|
126
|
+
.map((collection) => {
|
|
127
|
+
const effective = resolveConfiguredEgressPolicy(collection);
|
|
128
|
+
return {
|
|
129
|
+
collection: collection.name,
|
|
130
|
+
policy: effective.policy,
|
|
131
|
+
source: effective.source,
|
|
132
|
+
revision: collection.egressPolicyRevision ?? 0,
|
|
133
|
+
};
|
|
134
|
+
})
|
|
135
|
+
.sort((left, right) => left.collection.localeCompare(right.collection))
|
|
136
|
+
)}`;
|
|
137
|
+
|
|
138
|
+
/** Rebind immutable source membership to current policies at execution time. */
|
|
139
|
+
export const currentEgressSources = (
|
|
140
|
+
config: { collections: readonly Config["collections"][number][] },
|
|
141
|
+
collectionNames: readonly string[]
|
|
142
|
+
) =>
|
|
143
|
+
collectionNames.map((name) => {
|
|
144
|
+
const collection = config.collections.find(
|
|
145
|
+
(candidate) => candidate.name === name
|
|
146
|
+
);
|
|
147
|
+
const effective = collection
|
|
148
|
+
? resolveConfiguredEgressPolicy(collection)
|
|
149
|
+
: {
|
|
150
|
+
policy: "local_only" as const,
|
|
151
|
+
source: "legacy_default" as const,
|
|
152
|
+
};
|
|
153
|
+
return {
|
|
154
|
+
collection: name,
|
|
155
|
+
policy: effective.policy,
|
|
156
|
+
source: effective.source,
|
|
157
|
+
};
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
const policyState = (
|
|
161
|
+
config: Config,
|
|
162
|
+
name: string
|
|
163
|
+
): CollectionEgressPolicyState | null => {
|
|
164
|
+
const collection = config.collections.find(
|
|
165
|
+
(candidate) => candidate.name === name.toLowerCase()
|
|
166
|
+
);
|
|
167
|
+
if (!collection) return null;
|
|
168
|
+
const effective = resolveConfiguredEgressPolicy(collection);
|
|
169
|
+
return {
|
|
170
|
+
schemaVersion: "1.0",
|
|
171
|
+
collection: collection.name,
|
|
172
|
+
configuredPolicy: collection.egressPolicy ?? null,
|
|
173
|
+
effectivePolicy: effective.policy,
|
|
174
|
+
source: effective.source,
|
|
175
|
+
revision: collection.egressPolicyRevision ?? 0,
|
|
176
|
+
version: stateVersion({
|
|
177
|
+
collection: collection.name,
|
|
178
|
+
effectivePolicy: effective.policy,
|
|
179
|
+
source: effective.source,
|
|
180
|
+
revision: collection.egressPolicyRevision ?? 0,
|
|
181
|
+
}),
|
|
182
|
+
};
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
const remediationFor = (
|
|
186
|
+
decision: EgressDecision
|
|
187
|
+
): CollectionEgressCheckResult["remediation"] => {
|
|
188
|
+
if (decision.allowed) return null;
|
|
189
|
+
const messages: Partial<Record<EgressDecision["reason"], string>> = {
|
|
190
|
+
AUTHENTICATION_REQUIRED:
|
|
191
|
+
"Authenticate the destination without changing collection policy.",
|
|
192
|
+
CALLER_NOT_AUTHORIZED:
|
|
193
|
+
"Authorize this operation separately from collection policy.",
|
|
194
|
+
POLICY_LOCAL_ONLY:
|
|
195
|
+
"Keep the action local or explicitly relax every source collection.",
|
|
196
|
+
POLICY_LAN_ONLY:
|
|
197
|
+
"Use an authenticated LAN destination or explicitly allow remote egress.",
|
|
198
|
+
};
|
|
199
|
+
return {
|
|
200
|
+
code: decision.reason,
|
|
201
|
+
message:
|
|
202
|
+
messages[decision.reason] ??
|
|
203
|
+
"Correct the action, destination, caller, content class, or collection scope.",
|
|
204
|
+
};
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
export class CollectionEgressPolicyService {
|
|
208
|
+
constructor(private readonly deps: CollectionEgressPolicyServiceDeps) {}
|
|
209
|
+
|
|
210
|
+
get(
|
|
211
|
+
name: unknown
|
|
212
|
+
): CollectionEgressPolicyServiceResult<CollectionEgressPolicyState> {
|
|
213
|
+
const parsed = parseCollectionName(name);
|
|
214
|
+
if (!parsed.ok) return parsed;
|
|
215
|
+
const state = policyState(this.deps.getConfig(), parsed.value);
|
|
216
|
+
return state
|
|
217
|
+
? { ok: true, value: state }
|
|
218
|
+
: {
|
|
219
|
+
ok: false,
|
|
220
|
+
code: "NOT_FOUND",
|
|
221
|
+
error: `Collection not found: ${parsed.value}`,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async set(
|
|
226
|
+
input: unknown
|
|
227
|
+
): Promise<
|
|
228
|
+
CollectionEgressPolicyServiceResult<CollectionEgressPolicySetResult>
|
|
229
|
+
> {
|
|
230
|
+
const parsed = parsePolicySetInput(input);
|
|
231
|
+
if (!parsed.ok) return parsed;
|
|
232
|
+
if (!this.deps.mutateConfig) {
|
|
233
|
+
return {
|
|
234
|
+
ok: false,
|
|
235
|
+
code: "READ_ONLY",
|
|
236
|
+
error: "Collection policy mutation is not available",
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
const changed = await this.deps.mutateConfig((config) => {
|
|
240
|
+
const previous = policyState(config, parsed.value.collection);
|
|
241
|
+
if (!previous) {
|
|
242
|
+
return {
|
|
243
|
+
ok: false,
|
|
244
|
+
code: "NOT_FOUND",
|
|
245
|
+
error: `Collection not found: ${parsed.value.collection}`,
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
const orderDelta =
|
|
249
|
+
POLICY_ORDER[parsed.value.policy] -
|
|
250
|
+
POLICY_ORDER[previous.effectivePolicy];
|
|
251
|
+
const sourceChanged = previous.source !== "explicit";
|
|
252
|
+
const confirmation = parsed.value.confirmation;
|
|
253
|
+
const confirmationMatches =
|
|
254
|
+
confirmation?.acknowledged === true &&
|
|
255
|
+
confirmation.collection === previous.collection &&
|
|
256
|
+
confirmation.currentPolicy === previous.effectivePolicy &&
|
|
257
|
+
confirmation.currentRevision === previous.revision &&
|
|
258
|
+
confirmation.targetPolicy === parsed.value.policy;
|
|
259
|
+
if (
|
|
260
|
+
(confirmation && !confirmationMatches) ||
|
|
261
|
+
(orderDelta > 0 && !confirmationMatches)
|
|
262
|
+
) {
|
|
263
|
+
return {
|
|
264
|
+
ok: false,
|
|
265
|
+
code: "EGRESS_RELAXATION_CONFIRMATION_REQUIRED",
|
|
266
|
+
error:
|
|
267
|
+
"Relaxing collection egress requires confirmation bound to the current policy revision",
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
if (orderDelta === 0 && !sourceChanged) {
|
|
271
|
+
return {
|
|
272
|
+
ok: true,
|
|
273
|
+
config,
|
|
274
|
+
skipSave: true,
|
|
275
|
+
value: {
|
|
276
|
+
schemaVersion: "1.0",
|
|
277
|
+
previous,
|
|
278
|
+
current: previous,
|
|
279
|
+
change: "unchanged",
|
|
280
|
+
invalidation: null,
|
|
281
|
+
},
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
if (previous.revision >= Number.MAX_SAFE_INTEGER) {
|
|
285
|
+
return {
|
|
286
|
+
ok: false,
|
|
287
|
+
code: "CONSTRAINT_VIOLATION",
|
|
288
|
+
error: "Collection egress policy revision is exhausted",
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
const collections = config.collections.map((collection) =>
|
|
292
|
+
collection.name === previous.collection
|
|
293
|
+
? {
|
|
294
|
+
...collection,
|
|
295
|
+
egressPolicy: parsed.value.policy,
|
|
296
|
+
egressPolicyRevision: previous.revision + 1,
|
|
297
|
+
}
|
|
298
|
+
: collection
|
|
299
|
+
);
|
|
300
|
+
const nextConfig = { ...config, collections };
|
|
301
|
+
const current = policyState(nextConfig, previous.collection);
|
|
302
|
+
if (!current) {
|
|
303
|
+
return {
|
|
304
|
+
ok: false,
|
|
305
|
+
code: "NOT_FOUND",
|
|
306
|
+
error: `Collection not found: ${parsed.value.collection}`,
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
return {
|
|
310
|
+
ok: true,
|
|
311
|
+
config: nextConfig,
|
|
312
|
+
value: {
|
|
313
|
+
schemaVersion: "1.0",
|
|
314
|
+
previous,
|
|
315
|
+
current,
|
|
316
|
+
change:
|
|
317
|
+
orderDelta > 0
|
|
318
|
+
? "relaxed"
|
|
319
|
+
: orderDelta < 0
|
|
320
|
+
? "tightened"
|
|
321
|
+
: "source_changed",
|
|
322
|
+
invalidation: null,
|
|
323
|
+
},
|
|
324
|
+
};
|
|
325
|
+
});
|
|
326
|
+
if (!changed.ok) return changed;
|
|
327
|
+
const value = changed.value;
|
|
328
|
+
if (!value) {
|
|
329
|
+
return {
|
|
330
|
+
ok: false,
|
|
331
|
+
code: "RUNTIME",
|
|
332
|
+
error: "Policy mutation returned no result",
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
if (value.change !== "unchanged") {
|
|
336
|
+
const invalidation = await this.deps.onPolicyChanged?.(value);
|
|
337
|
+
value.invalidation = invalidation ?? {
|
|
338
|
+
policyEpoch: collectionEgressPolicyEpoch(changed.config),
|
|
339
|
+
queuedJobsInvalidated: 0,
|
|
340
|
+
sessionsInvalidated: 0,
|
|
341
|
+
staleWorkMustRetry: true,
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
return { ok: true, value };
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
check(
|
|
348
|
+
input: unknown
|
|
349
|
+
): CollectionEgressPolicyServiceResult<CollectionEgressCheckResult> {
|
|
350
|
+
const parsed = parsePolicyCheckInput(input);
|
|
351
|
+
if (!parsed.ok) return parsed;
|
|
352
|
+
const config = this.deps.getConfig();
|
|
353
|
+
const names =
|
|
354
|
+
parsed.value.collections ??
|
|
355
|
+
config.collections
|
|
356
|
+
.map(({ name }) => name)
|
|
357
|
+
.sort((a, b) => a.localeCompare(b));
|
|
358
|
+
let deniedLineage: ReturnType<typeof resolveEgressLineage> | null = null;
|
|
359
|
+
let deniedDecision: EgressDecision | null = null;
|
|
360
|
+
try {
|
|
361
|
+
const sources = names.map((name) => {
|
|
362
|
+
const state = policyState(config, name);
|
|
363
|
+
if (!state) {
|
|
364
|
+
return {
|
|
365
|
+
collection: name,
|
|
366
|
+
policy: "local_only" as const,
|
|
367
|
+
source: "legacy_default" as const,
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
return {
|
|
371
|
+
collection: state.collection,
|
|
372
|
+
policy: state.effectivePolicy,
|
|
373
|
+
source: state.source,
|
|
374
|
+
};
|
|
375
|
+
});
|
|
376
|
+
const lineage = resolveEgressLineage(sources, names);
|
|
377
|
+
const decision = evaluateEgressPolicy({
|
|
378
|
+
collections: lineage.sources,
|
|
379
|
+
action: parsed.value.action,
|
|
380
|
+
destination: { zone: parsed.value.destinationZone },
|
|
381
|
+
caller: parsed.value.caller,
|
|
382
|
+
contentClass: parsed.value.contentClass,
|
|
383
|
+
});
|
|
384
|
+
deniedLineage = lineage;
|
|
385
|
+
deniedDecision = decision;
|
|
386
|
+
const plan = planCollectionEgress({
|
|
387
|
+
collections: config.collections,
|
|
388
|
+
collectionNames: names,
|
|
389
|
+
action: parsed.value.action,
|
|
390
|
+
destinationZone: parsed.value.destinationZone,
|
|
391
|
+
caller: parsed.value.caller,
|
|
392
|
+
contentClass: parsed.value.contentClass,
|
|
393
|
+
partialResults: parsed.value.partialResults,
|
|
394
|
+
});
|
|
395
|
+
return {
|
|
396
|
+
ok: true,
|
|
397
|
+
value: {
|
|
398
|
+
schemaVersion: "1.0",
|
|
399
|
+
mode: plan.mode,
|
|
400
|
+
allowedCollections: plan.allowedCollections,
|
|
401
|
+
omittedCollections: plan.omittedCollections,
|
|
402
|
+
disclosure: plan.disclosure,
|
|
403
|
+
lineage,
|
|
404
|
+
decision,
|
|
405
|
+
remediation: remediationFor(decision),
|
|
406
|
+
},
|
|
407
|
+
};
|
|
408
|
+
} catch (error) {
|
|
409
|
+
if (error instanceof EgressProvenanceError) {
|
|
410
|
+
return {
|
|
411
|
+
ok: false,
|
|
412
|
+
code: "VALIDATION",
|
|
413
|
+
error: "Invalid collection egress scope",
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
if (!(error instanceof EgressDeniedError)) throw error;
|
|
417
|
+
if (!deniedLineage || !deniedDecision) throw error;
|
|
418
|
+
const decision = deniedDecision;
|
|
419
|
+
return {
|
|
420
|
+
ok: true,
|
|
421
|
+
value: {
|
|
422
|
+
schemaVersion: "1.0",
|
|
423
|
+
mode: "denied",
|
|
424
|
+
allowedCollections: [],
|
|
425
|
+
omittedCollections: deniedLineage.sources.map(({ collection }) => ({
|
|
426
|
+
collection,
|
|
427
|
+
reason: decision.reason,
|
|
428
|
+
})),
|
|
429
|
+
disclosure: null,
|
|
430
|
+
lineage: deniedLineage,
|
|
431
|
+
decision,
|
|
432
|
+
remediation: remediationFor(decision),
|
|
433
|
+
},
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
explain(
|
|
439
|
+
input: unknown
|
|
440
|
+
): CollectionEgressPolicyServiceResult<CollectionEgressCheckResult> {
|
|
441
|
+
return this.check(input);
|
|
442
|
+
}
|
|
443
|
+
}
|