Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5d181e7cb0 | ||
|
|
cf211f7418 | ||
|
|
04225886e0 | ||
|
|
1f0704add0 | ||
|
|
6569dbfc60 | ||
|
|
78b268cc39 | ||
|
|
3d5b7f0ea6 | ||
|
|
b4d2d4bc20 | ||
|
|
c90a6ca3b8 | ||
|
|
bf4b9b5443 | ||
|
|
5ce56b5168 | ||
|
|
f1af1a85e5 | ||
|
|
414a419f18 | ||
|
|
6a286a1f66 |
No files matched your search
@@ -155,9 +155,7 @@ jobs:
|
|||||||
cp -R packages/contracts/src "$STAGING/packages/contracts/"
|
cp -R packages/contracts/src "$STAGING/packages/contracts/"
|
||||||
cp backend/package.json backend/tsconfig.json backend/mcp-coverage.json "$STAGING/backend/"
|
cp backend/package.json backend/tsconfig.json backend/mcp-coverage.json "$STAGING/backend/"
|
||||||
cp -R backend/src "$STAGING/backend/"
|
cp -R backend/src "$STAGING/backend/"
|
||||||
mkdir -p "$STAGING/backend/scripts/writer-profile"
|
node backend/scripts/stage-database-operator.mjs "$STAGING/backend/scripts"
|
||||||
cp backend/scripts/build-database-operator.mjs backend/scripts/database-writer-bootstrap-operator.mjs backend/scripts/database-writer-profile-operator.mts "$STAGING/backend/scripts/"
|
|
||||||
cp backend/scripts/writer-profile/operator-files.mjs backend/scripts/writer-profile/operator-contract.mjs backend/scripts/writer-profile/publisher.mjs backend/scripts/writer-profile/context.mjs backend/scripts/writer-profile/catalogue.mts backend/scripts/writer-profile/catalogue-value-bridge.ts backend/scripts/writer-profile/catalogue-projection.ts backend/scripts/writer-profile/catalogue-leaves.ts backend/scripts/writer-profile/application-writer-profile-catalogue.ts backend/scripts/writer-profile/application-writer-profile-catalogue-values.ts backend/scripts/writer-profile/application-writer-profile-catalogue-parent-policy.ts backend/scripts/writer-profile/application-writer-profile-canonical.ts backend/scripts/writer-profile/fixed44-build-sql.ts backend/scripts/writer-profile/fixed44-build-definition.ts backend/scripts/writer-profile/expanded44-data-policy.ts "${STAGING}/backend/scripts/writer-profile/"
|
|
||||||
mkdir -p "$STAGING/deploy/netflow-agent"
|
mkdir -p "$STAGING/deploy/netflow-agent"
|
||||||
cp deploy/netflow-agent/install.sh "$STAGING/deploy/netflow-agent/"
|
cp deploy/netflow-agent/install.sh "$STAGING/deploy/netflow-agent/"
|
||||||
if [ -d backend/drizzle ]; then
|
if [ -d backend/drizzle ]; then
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ permissions:
|
|||||||
packages: write
|
packages: write
|
||||||
jobs:
|
jobs:
|
||||||
backend-preview:
|
backend-preview:
|
||||||
if: ${{ gitea.event_name == 'workflow_dispatch' }}
|
if: ${{ gitea.event_name == 'workflow_dispatch' && gitea.ref == 'refs/heads/codex/mcp-acceptance-current-20261103' }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 40
|
timeout-minutes: 40
|
||||||
env:
|
env:
|
||||||
@@ -24,6 +24,7 @@ jobs:
|
|||||||
EXPECTED_SHA: ${{ inputs.expected_sha }}
|
EXPECTED_SHA: ${{ inputs.expected_sha }}
|
||||||
DISPATCH_SHA: ${{ gitea.sha }}
|
DISPATCH_SHA: ${{ gitea.sha }}
|
||||||
DISPATCH_EVENT: ${{ gitea.event_name }}
|
DISPATCH_EVENT: ${{ gitea.event_name }}
|
||||||
|
DISPATCH_REF: ${{ gitea.ref }}
|
||||||
REPOSITORY: ${{ gitea.repository }}
|
REPOSITORY: ${{ gitea.repository }}
|
||||||
RUN_ID: ${{ gitea.run_id }}
|
RUN_ID: ${{ gitea.run_id }}
|
||||||
RUN_ATTEMPT: ${{ gitea.run_attempt }}
|
RUN_ATTEMPT: ${{ gitea.run_attempt }}
|
||||||
@@ -42,6 +43,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
test "$DISPATCH_EVENT" = workflow_dispatch
|
test "$DISPATCH_EVENT" = workflow_dispatch
|
||||||
|
test "$DISPATCH_REF" = refs/heads/codex/mcp-acceptance-current-20261103
|
||||||
[[ "$EXPECTED_SHA" =~ ^[0-9a-f]{40}$ ]]
|
[[ "$EXPECTED_SHA" =~ ^[0-9a-f]{40}$ ]]
|
||||||
test "$EXPECTED_SHA" = "$DISPATCH_SHA"
|
test "$EXPECTED_SHA" = "$DISPATCH_SHA"
|
||||||
test "$(git rev-parse HEAD)" = "$EXPECTED_SHA"
|
test "$(git rev-parse HEAD)" = "$EXPECTED_SHA"
|
||||||
@@ -88,9 +90,7 @@ jobs:
|
|||||||
if [ -d backend/drizzle ]; then
|
if [ -d backend/drizzle ]; then
|
||||||
cp -R backend/drizzle "$STAGING/backend/"
|
cp -R backend/drizzle "$STAGING/backend/"
|
||||||
fi
|
fi
|
||||||
mkdir -p "$STAGING/backend/scripts/writer-profile"
|
node backend/scripts/stage-database-operator.mjs "$STAGING/backend/scripts"
|
||||||
cp backend/scripts/build-database-operator.mjs backend/scripts/database-writer-bootstrap-operator.mjs backend/scripts/database-writer-profile-operator.mts "$STAGING/backend/scripts/"
|
|
||||||
cp backend/scripts/writer-profile/operator-files.mjs backend/scripts/writer-profile/operator-contract.mjs backend/scripts/writer-profile/publisher.mjs backend/scripts/writer-profile/context.mjs backend/scripts/writer-profile/catalogue.mts backend/scripts/writer-profile/catalogue-value-bridge.ts backend/scripts/writer-profile/catalogue-projection.ts backend/scripts/writer-profile/catalogue-leaves.ts backend/scripts/writer-profile/application-writer-profile-catalogue.ts backend/scripts/writer-profile/application-writer-profile-catalogue-values.ts backend/scripts/writer-profile/application-writer-profile-catalogue-parent-policy.ts backend/scripts/writer-profile/application-writer-profile-canonical.ts backend/scripts/writer-profile/fixed44-build-sql.ts backend/scripts/writer-profile/fixed44-build-definition.ts backend/scripts/writer-profile/expanded44-data-policy.ts "${STAGING}/backend/scripts/writer-profile/"
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
- name: Log in to existing Gitea registry
|
- name: Log in to existing Gitea registry
|
||||||
@@ -139,7 +139,7 @@ jobs:
|
|||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
retention-days: 14
|
retention-days: 14
|
||||||
frontend-preview:
|
frontend-preview:
|
||||||
if: ${{ gitea.event_name == 'workflow_dispatch' }}
|
if: ${{ gitea.event_name == 'workflow_dispatch' && gitea.ref == 'refs/heads/codex/mcp-acceptance-current-20261103' }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 40
|
timeout-minutes: 40
|
||||||
env:
|
env:
|
||||||
@@ -147,6 +147,7 @@ jobs:
|
|||||||
EXPECTED_SHA: ${{ inputs.expected_sha }}
|
EXPECTED_SHA: ${{ inputs.expected_sha }}
|
||||||
DISPATCH_SHA: ${{ gitea.sha }}
|
DISPATCH_SHA: ${{ gitea.sha }}
|
||||||
DISPATCH_EVENT: ${{ gitea.event_name }}
|
DISPATCH_EVENT: ${{ gitea.event_name }}
|
||||||
|
DISPATCH_REF: ${{ gitea.ref }}
|
||||||
REPOSITORY: ${{ gitea.repository }}
|
REPOSITORY: ${{ gitea.repository }}
|
||||||
RUN_ID: ${{ gitea.run_id }}
|
RUN_ID: ${{ gitea.run_id }}
|
||||||
RUN_ATTEMPT: ${{ gitea.run_attempt }}
|
RUN_ATTEMPT: ${{ gitea.run_attempt }}
|
||||||
@@ -178,6 +179,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
test "$DISPATCH_EVENT" = workflow_dispatch
|
test "$DISPATCH_EVENT" = workflow_dispatch
|
||||||
|
test "$DISPATCH_REF" = refs/heads/codex/mcp-acceptance-current-20261103
|
||||||
[[ "$EXPECTED_SHA" =~ ^[0-9a-f]{40}$ ]]
|
[[ "$EXPECTED_SHA" =~ ^[0-9a-f]{40}$ ]]
|
||||||
test "$EXPECTED_SHA" = "$DISPATCH_SHA"
|
test "$EXPECTED_SHA" = "$DISPATCH_SHA"
|
||||||
test "$(git rev-parse HEAD)" = "$EXPECTED_SHA"
|
test "$(git rev-parse HEAD)" = "$EXPECTED_SHA"
|
||||||
|
|||||||
@@ -924,18 +924,21 @@ export default function TrafficPage() {
|
|||||||
})
|
})
|
||||||
return () => { cancelled = true }
|
return () => { cancelled = true }
|
||||||
}
|
}
|
||||||
void Promise.all([
|
const loadFlowCards = async () => {
|
||||||
getTrafficFlows(backendUrl, range),
|
// These reads share the collector's constrained admission budget.
|
||||||
getFlowExporters(backendUrl, range),
|
const stats = await getTrafficFlows(backendUrl, range)
|
||||||
getFlowClients(backendUrl, range),
|
if (cancelled) return
|
||||||
]).then(([stats, exporters, clients]) => {
|
const exporters = await getFlowExporters(backendUrl, range)
|
||||||
|
if (cancelled) return
|
||||||
|
const clients = await getFlowClients(backendUrl, range)
|
||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
setFlowResult({ key: flowKey, receivedAt: Date.now(), stats, exporters: exporters.exporters, clients: clients.clients })
|
setFlowResult({ key: flowKey, receivedAt: Date.now(), stats, exporters: exporters.exporters, clients: clients.clients })
|
||||||
setSelectedId(prev => {
|
setSelectedId(prev => {
|
||||||
const list = flowScope === "users" ? clients.clients : exporters.exporters
|
const list = flowScope === "users" ? clients.clients : exporters.exporters
|
||||||
return list.some(x => x.id === prev) ? prev : (list[0]?.id ?? "")
|
return list.some(x => x.id === prev) ? prev : (list[0]?.id ?? "")
|
||||||
})
|
})
|
||||||
}).catch(e => {
|
}
|
||||||
|
void loadFlowCards().catch(e => {
|
||||||
if (!cancelled) setFlowResult({ key: flowKey, error: e instanceof Error ? e.message : "Не удалось загрузить потоки" })
|
if (!cancelled) setFlowResult({ key: flowKey, error: e instanceof Error ? e.message : "Не удалось загрузить потоки" })
|
||||||
})
|
})
|
||||||
return () => { cancelled = true }
|
return () => { cancelled = true }
|
||||||
|
|||||||
+10
-1
@@ -1,7 +1,16 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss" source(none);
|
||||||
@import "tw-animate-css";
|
@import "tw-animate-css";
|
||||||
@import "shadcn/tailwind.css";
|
@import "shadcn/tailwind.css";
|
||||||
|
|
||||||
|
/* Scan application sources only; local test archives and generated bundles
|
||||||
|
must not become utility classes in the production stylesheet. */
|
||||||
|
@source "./";
|
||||||
|
@source "../components";
|
||||||
|
@source "../entities";
|
||||||
|
@source "../hooks";
|
||||||
|
@source "../lib";
|
||||||
|
@source "../shared";
|
||||||
|
|
||||||
/* SVG focus follows the object shape, never the text/foreignObject bounding box. */
|
/* SVG focus follows the object shape, never the text/foreignObject bounding box. */
|
||||||
.network-map-focusable {
|
.network-map-focusable {
|
||||||
outline: none;
|
outline: none;
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
BEGIN;
|
||||||
|
CREATE TABLE public.mcp_operation_replay_tombstones (
|
||||||
|
operation_id uuid PRIMARY KEY,
|
||||||
|
plan_id uuid NOT NULL UNIQUE,
|
||||||
|
connection_id uuid NOT NULL REFERENCES public.mcp_connections(id),
|
||||||
|
idempotency_digest text NOT NULL CHECK (idempotency_digest ~ '^[0-9a-f]{64}$'),
|
||||||
|
action_id text NOT NULL CHECK (length(action_id) BETWEEN 1 AND 200),
|
||||||
|
target_server_ids bigint[] NOT NULL CHECK (array_position(target_server_ids, NULL) IS NULL),
|
||||||
|
target_global boolean NOT NULL,
|
||||||
|
UNIQUE (connection_id,idempotency_digest)
|
||||||
|
);
|
||||||
|
INSERT INTO public.schema_migrations(id) VALUES ('0045_mcp_operation_replay_tombstones') ON CONFLICT(id) DO NOTHING;
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
-- Inactive poll-history V1-to-V2 retirement. Application owner authenticates
|
||||||
|
-- both ciphertext versions; SQL independently fences identity and lifetime.
|
||||||
|
BEGIN;
|
||||||
|
LOCK TABLE public.server_poll_archives,public.server_poll_storage_admissions IN ACCESS EXCLUSIVE MODE;
|
||||||
|
ALTER TABLE public.server_poll_archives DROP CONSTRAINT server_poll_archives_version_check;
|
||||||
|
ALTER TABLE public.server_poll_archives ADD CONSTRAINT server_poll_archives_version_check
|
||||||
|
CHECK(version=1 OR (version=2 AND decoded_bytes<=24576 AND compressed_bytes<=24576 AND ciphertext_bytes<=33000));
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.server_poll_archive_immutable() RETURNS trigger
|
||||||
|
LANGUAGE plpgsql SET TimeZone='UTC' SET search_path=pg_catalog,public AS $$
|
||||||
|
BEGIN
|
||||||
|
IF TG_OP='DELETE' AND public.server_poll_storage_gc_allowed(OLD.receipt_id) THEN RETURN OLD; END IF;
|
||||||
|
IF TG_OP='UPDATE' AND OLD.version=1 AND NEW.version=2
|
||||||
|
AND current_setting('mm.poll_retire_receipt',true)=OLD.receipt_id::text
|
||||||
|
AND NEW.ciphertext_bytes<=OLD.ciphertext_bytes
|
||||||
|
AND NEW.ciphertext ~ '^[A-Za-z0-9+/]+={0,2}$'
|
||||||
|
AND replace(encode(decode(NEW.ciphertext,'base64'),'base64'),E'\n','')=NEW.ciphertext
|
||||||
|
AND octet_length(decode(NEW.ciphertext,'base64'))=NEW.compressed_bytes+28
|
||||||
|
AND (to_jsonb(OLD)-ARRAY['version','source_digest','source_sha256','decoded_bytes','compressed_bytes','ciphertext','ciphertext_bytes','archive_proof'])=
|
||||||
|
(to_jsonb(NEW)-ARRAY['version','source_digest','source_sha256','decoded_bytes','compressed_bytes','ciphertext','ciphertext_bytes','archive_proof'])
|
||||||
|
AND EXISTS(
|
||||||
|
SELECT 1 FROM public.server_poll_storage_admissions a JOIN public.mcp_operations o ON o.id=a.execution_id AND o.connection_id=a.connection_id
|
||||||
|
JOIN public.mcp_plans p ON p.id=o.plan_id AND p.connection_id=o.connection_id
|
||||||
|
JOIN public.mcp_operation_steps s ON s.operation_id=o.id AND s.step_index=0
|
||||||
|
JOIN public.mcp_operation_replay_tombstones t ON t.operation_id=o.id AND t.plan_id=p.id AND t.connection_id=o.connection_id
|
||||||
|
JOIN public.mcp_connections c ON c.id=o.connection_id AND c.owner_id=a.owner_id
|
||||||
|
WHERE a.receipt_id=OLD.receipt_id AND a.identity_kind='mcp' AND a.storage_state='compacted'
|
||||||
|
AND a.execution_id=OLD.execution_id AND a.connection_id=OLD.connection_id AND a.owner_id=OLD.owner_id
|
||||||
|
AND a.binding_proof=OLD.binding_proof AND a.context_proof=OLD.context_proof AND a.archive_bytes=OLD.ciphertext_bytes
|
||||||
|
AND a.original_activity_at=OLD.original_activity_at AND a.authority_expires_at=OLD.original_expires_at
|
||||||
|
AND o.state='applied' AND o.verification='passed' AND o.error_code IS NULL AND isfinite(o.finished_at)
|
||||||
|
AND o.finished_at<transaction_timestamp()-interval '180 days'
|
||||||
|
AND p.state='completed' AND p.operation_id='servers.poll' AND p.operation_version=1 AND p.approved_by IS NOT NULL
|
||||||
|
AND s.state='verified' AND s.finished_at IS NOT NULL AND t.action_id=p.operation_id
|
||||||
|
AND t.target_global AND cardinality(t.target_server_ids)>0
|
||||||
|
) THEN RETURN NEW; END IF;
|
||||||
|
RAISE EXCEPTION 'poll archive immutable outside exact retirement or original lifetime GC';
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
CREATE FUNCTION public.server_poll_retirement_final_guard() RETURNS trigger
|
||||||
|
LANGUAGE plpgsql SET search_path=pg_catalog,public AS $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS(SELECT 1 FROM public.mcp_operation_replay_tombstones t
|
||||||
|
WHERE t.operation_id=NEW.execution_id AND t.connection_id=NEW.connection_id AND t.action_id='servers.poll'
|
||||||
|
AND t.target_global AND NOT EXISTS(SELECT 1 FROM public.mcp_plans p WHERE p.id=t.plan_id))
|
||||||
|
OR EXISTS(SELECT 1 FROM public.mcp_operations o WHERE o.id=NEW.execution_id)
|
||||||
|
OR NOT public.server_poll_storage_accounting_valid()
|
||||||
|
THEN RAISE EXCEPTION 'poll retirement is incomplete'; END IF;
|
||||||
|
RETURN NULL;
|
||||||
|
END $$;
|
||||||
|
CREATE CONSTRAINT TRIGGER server_poll_retirement_final_guard AFTER UPDATE ON public.server_poll_archives
|
||||||
|
DEFERRABLE INITIALLY DEFERRED FOR EACH ROW WHEN(OLD.version=1 AND NEW.version=2)
|
||||||
|
EXECUTE FUNCTION public.server_poll_retirement_final_guard();
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION server_poll_storage_admission_guard() RETURNS trigger LANGUAGE plpgsql SET TimeZone='UTC' SET search_path=pg_catalog,public AS $$
|
||||||
|
DECLARE retention integer; charged integer; r public.server_poll_archives%ROWTYPE;
|
||||||
|
BEGIN
|
||||||
|
PERFORM pg_advisory_xact_lock(hashtextextended('server-poll-storage-v1',0));
|
||||||
|
IF TG_OP='DELETE' THEN
|
||||||
|
IF NOT public.server_poll_storage_gc_allowed(OLD.receipt_id) OR EXISTS(SELECT 1 FROM public.server_poll_executions WHERE receipt_id=OLD.receipt_id)
|
||||||
|
OR EXISTS(SELECT 1 FROM public.server_poll_intents WHERE receipt_id=OLD.receipt_id) OR EXISTS(SELECT 1 FROM public.server_poll_acks WHERE receipt_id=OLD.receipt_id)
|
||||||
|
OR EXISTS(SELECT 1 FROM public.server_poll_receipts WHERE receipt_id=OLD.receipt_id) OR EXISTS(SELECT 1 FROM public.server_poll_quarantines WHERE receipt_id=OLD.receipt_id)
|
||||||
|
OR EXISTS(SELECT 1 FROM public.server_poll_archives WHERE receipt_id=OLD.receipt_id) THEN RAISE EXCEPTION 'poll lifetime or children prevent admission deletion'; END IF;
|
||||||
|
UPDATE public.server_poll_storage_usage SET admissions=admissions-CASE WHEN OLD.storage_state='full' THEN 1 ELSE 0 END,
|
||||||
|
compacted_admissions=compacted_admissions-CASE WHEN OLD.storage_state='compacted' THEN 1 ELSE 0 END,
|
||||||
|
archive_bytes=archive_bytes-COALESCE(OLD.archive_bytes,0),
|
||||||
|
reserved_rows=reserved_rows-CASE WHEN OLD.storage_state='full' THEN 4019 ELSE 2 END,
|
||||||
|
reserved_bytes=reserved_bytes-CASE WHEN OLD.storage_state='full' THEN 8421376 ELSE 32768+OLD.archive_bytes END
|
||||||
|
WHERE scope IN('global','owner:'||OLD.owner_id);
|
||||||
|
GET DIAGNOSTICS charged=ROW_COUNT; IF charged<>2 THEN RAISE EXCEPTION 'poll quota refund mismatch'; END IF; RETURN OLD;
|
||||||
|
END IF;
|
||||||
|
IF TG_OP='UPDATE' THEN
|
||||||
|
IF OLD.storage_state='compacted' AND NEW.storage_state='compacted'
|
||||||
|
AND current_setting('mm.poll_retire_receipt',true)=OLD.receipt_id::text
|
||||||
|
AND (to_jsonb(OLD)-'archive_bytes')=(to_jsonb(NEW)-'archive_bytes') THEN
|
||||||
|
SELECT * INTO r FROM public.server_poll_archives WHERE receipt_id=OLD.receipt_id AND version=2;
|
||||||
|
IF NOT FOUND OR r.ciphertext_bytes<>NEW.archive_bytes OR NEW.archive_bytes>OLD.archive_bytes
|
||||||
|
OR r.execution_id<>OLD.execution_id OR r.connection_id<>OLD.connection_id OR r.owner_id<>OLD.owner_id
|
||||||
|
THEN RAISE EXCEPTION 'retired poll charge source mismatch'; END IF;
|
||||||
|
UPDATE public.server_poll_storage_usage
|
||||||
|
SET archive_bytes=archive_bytes-OLD.archive_bytes+NEW.archive_bytes,
|
||||||
|
reserved_bytes=reserved_bytes-OLD.archive_bytes+NEW.archive_bytes
|
||||||
|
WHERE scope IN ('global','owner:'||OLD.owner_id) AND compacted_admissions>=1 AND archive_bytes>=OLD.archive_bytes;
|
||||||
|
GET DIAGNOSTICS charged=ROW_COUNT;
|
||||||
|
IF charged<>2 THEN RAISE EXCEPTION 'retired poll charge adjustment mismatch'; END IF;
|
||||||
|
RETURN NEW;
|
||||||
|
END IF;
|
||||||
|
-- This durable one-shot marker is written and ACKed in its OWN transaction
|
||||||
|
-- before archive construction. A committed/unknown claim never resets and
|
||||||
|
-- the worker never retries destructive work against an already claimed full
|
||||||
|
-- source. Claim time is maintenance metadata, not renewed execution activity.
|
||||||
|
IF OLD.storage_state='full' AND NEW.storage_state='full' AND OLD.compaction_claim IS NULL AND OLD.compaction_claimed_at IS NULL AND OLD.compaction_claim_xid IS NULL
|
||||||
|
AND NEW.compaction_claim IS NOT NULL AND NEW.compaction_claim_xid IS NULL
|
||||||
|
AND(to_jsonb(OLD)-ARRAY['compaction_claim','compaction_claimed_at','compaction_claim_xid'])=(to_jsonb(NEW)-ARRAY['compaction_claim','compaction_claimed_at','compaction_claim_xid'])
|
||||||
|
AND public.server_poll_storage_compact_allowed(OLD.receipt_id)
|
||||||
|
THEN NEW.compaction_claimed_at:=clock_timestamp(); NEW.compaction_claim_xid:=pg_current_xact_id(); RETURN NEW; END IF;
|
||||||
|
IF(to_jsonb(OLD)-ARRAY['claim_token','claim_attempted_at','used_rows','used_bytes','storage_state','compact_at','archive_bytes','original_activity_at']) IS DISTINCT FROM
|
||||||
|
(to_jsonb(NEW)-ARRAY['claim_token','claim_attempted_at','used_rows','used_bytes','storage_state','compact_at','archive_bytes','original_activity_at']) THEN RAISE EXCEPTION 'poll admission identity is immutable'; END IF;
|
||||||
|
IF OLD.storage_state<>'full' THEN RAISE EXCEPTION 'compacted poll identity cannot reopen or change'; END IF;
|
||||||
|
IF NEW.storage_state='compacted' THEN
|
||||||
|
SELECT * INTO r FROM public.server_poll_archives WHERE receipt_id=OLD.receipt_id AND compaction_xid=pg_current_xact_id();
|
||||||
|
IF NOT FOUND OR NEW.used_rows<>OLD.used_rows OR NEW.used_bytes<>OLD.used_bytes OR NEW.claim_token IS DISTINCT FROM OLD.claim_token
|
||||||
|
OR NEW.claim_attempted_at IS DISTINCT FROM OLD.claim_attempted_at OR NEW.archive_bytes<>r.ciphertext_bytes OR NEW.original_activity_at<>r.original_activity_at
|
||||||
|
OR EXISTS(SELECT 1 FROM public.server_poll_executions WHERE receipt_id=OLD.receipt_id)
|
||||||
|
OR EXISTS(SELECT 1 FROM public.server_poll_intents WHERE receipt_id=OLD.receipt_id) OR EXISTS(SELECT 1 FROM public.server_poll_acks WHERE receipt_id=OLD.receipt_id)
|
||||||
|
OR EXISTS(SELECT 1 FROM public.server_poll_receipts WHERE receipt_id=OLD.receipt_id) OR EXISTS(SELECT 1 FROM public.server_poll_quarantines WHERE receipt_id=OLD.receipt_id)
|
||||||
|
THEN RAISE EXCEPTION 'poll compaction requires the complete current-transaction archive'; END IF;
|
||||||
|
NEW.compact_at:=r.created_at;
|
||||||
|
UPDATE public.server_poll_storage_usage SET admissions=admissions-1,compacted_admissions=compacted_admissions+1,
|
||||||
|
archive_bytes=archive_bytes+r.ciphertext_bytes,reserved_rows=reserved_rows-4019+2,reserved_bytes=reserved_bytes-8421376+32768+r.ciphertext_bytes
|
||||||
|
WHERE scope IN('global','owner:'||OLD.owner_id) AND admissions>=1;
|
||||||
|
GET DIAGNOSTICS charged=ROW_COUNT; IF charged<>2 THEN RAISE EXCEPTION 'poll compaction charge mismatch'; END IF;
|
||||||
|
RETURN NEW;
|
||||||
|
END IF;
|
||||||
|
IF NEW.compact_at IS NOT NULL OR NEW.archive_bytes IS NOT NULL OR NEW.original_activity_at IS NOT NULL THEN RAISE EXCEPTION 'full poll cannot carry archive metadata'; END IF;
|
||||||
|
IF OLD.claim_token IS NOT NULL AND NEW.claim_token IS NULL AND OLD.claim_attempted_at IS NULL AND NEW.claim_attempted_at IS NOT NULL
|
||||||
|
AND NEW.used_rows=OLD.used_rows AND NEW.used_bytes=OLD.used_bytes AND OLD.authority_expires_at>clock_timestamp()
|
||||||
|
THEN NEW.claim_attempted_at:=clock_timestamp(); RETURN NEW; END IF;
|
||||||
|
IF OLD.claim_token IS NOT DISTINCT FROM NEW.claim_token AND OLD.claim_attempted_at IS NOT DISTINCT FROM NEW.claim_attempted_at
|
||||||
|
AND NEW.used_rows=OLD.used_rows+1 AND NEW.used_bytes>OLD.used_bytes THEN RETURN NEW; END IF;
|
||||||
|
RAISE EXCEPTION 'poll admission cannot refresh, reopen or refund itself';
|
||||||
|
END IF;
|
||||||
|
IF NEW.storage_state<>'full' OR NEW.compact_at IS NOT NULL OR NEW.archive_bytes IS NOT NULL OR NEW.original_activity_at IS NOT NULL
|
||||||
|
OR NEW.compaction_claim IS NOT NULL OR NEW.compaction_claimed_at IS NOT NULL OR NEW.compaction_claim_xid IS NOT NULL
|
||||||
|
OR NOT public.server_poll_storage_accounting_valid() THEN RAISE EXCEPTION 'new poll requires full reservation and exact ledger'; END IF;
|
||||||
|
SELECT requests_retention_days INTO retention FROM public.mcp_settings WHERE id=1;
|
||||||
|
IF retention IS NULL OR retention NOT BETWEEN 1 AND 365 OR NEW.authority_issued_at>clock_timestamp() OR NEW.authority_expires_at<=clock_timestamp()
|
||||||
|
OR NEW.claim_token IS NULL OR NEW.claim_attempted_at IS NOT NULL OR NEW.used_rows<>0 OR NEW.used_bytes<>0 THEN RAISE EXCEPTION 'new poll admission needs original live authority'; END IF;
|
||||||
|
IF EXISTS(SELECT 1 FROM public.server_poll_storage_admissions WHERE receipt_id=NEW.receipt_id OR(identity_kind=NEW.identity_kind AND execution_id=NEW.execution_id)) THEN RAISE EXCEPTION 'poll identity already spent'; END IF;
|
||||||
|
NEW.created_at:=clock_timestamp(); NEW.retention_days:=retention; NEW.safety_ms:=60000;
|
||||||
|
NEW.gc_not_before:=NEW.authority_expires_at+make_interval(days=>retention)+interval '60 seconds';
|
||||||
|
INSERT INTO public.server_poll_storage_usage(scope) VALUES('owner:'||NEW.owner_id) ON CONFLICT DO NOTHING;
|
||||||
|
IF(SELECT count(*) FROM public.server_poll_storage_usage WHERE scope IN('global','owner:'||NEW.owner_id)
|
||||||
|
AND admissions<CASE WHEN scope='global' THEN 128 ELSE 8 END
|
||||||
|
AND reserved_rows<=CASE WHEN scope='global' THEN 514432 ELSE 32152 END-4019
|
||||||
|
AND reserved_bytes<=CASE WHEN scope='global' THEN 1077936128 ELSE 67371008 END-8421376)<>2 THEN RAISE EXCEPTION 'poll admission quota exhausted'; END IF;
|
||||||
|
UPDATE public.server_poll_storage_usage SET admissions=admissions+1,reserved_rows=reserved_rows+4019,reserved_bytes=reserved_bytes+8421376 WHERE scope IN('global','owner:'||NEW.owner_id);
|
||||||
|
GET DIAGNOSTICS charged=ROW_COUNT; IF charged<>2 THEN RAISE EXCEPTION 'poll admission quota unavailable'; END IF;
|
||||||
|
RETURN NEW;
|
||||||
|
END $$;
|
||||||
|
CREATE OR REPLACE FUNCTION server_poll_storage_gc_allowed(id uuid) RETURNS boolean LANGUAGE plpgsql SET TimeZone='UTC' SET search_path=pg_catalog,public AS $$
|
||||||
|
DECLARE a public.server_poll_storage_admissions%ROWTYPE; retention integer; activity timestamptz; terminal_at timestamptz;
|
||||||
|
BEGIN
|
||||||
|
IF current_setting('mm.poll_storage_gc',true) IS DISTINCT FROM 'on' THEN RETURN false; END IF;
|
||||||
|
PERFORM pg_advisory_xact_lock(hashtextextended('server-poll-storage-v1',0));
|
||||||
|
SELECT * INTO a FROM public.server_poll_storage_admissions WHERE receipt_id=id FOR UPDATE;
|
||||||
|
IF NOT FOUND OR a.authority_expires_at>=clock_timestamp() OR a.gc_not_before>=clock_timestamp() THEN RETURN false; END IF;
|
||||||
|
SELECT requests_retention_days INTO retention FROM public.mcp_settings WHERE mcp_settings.id=1 FOR SHARE;
|
||||||
|
IF retention IS NULL OR retention NOT BETWEEN 1 AND 365 THEN RETURN false; END IF;
|
||||||
|
terminal_at:=a.created_at;
|
||||||
|
IF a.identity_kind='mcp' THEN
|
||||||
|
IF NOT pg_try_advisory_xact_lock(hashtextextended('mcp-operation:'||a.execution_id::text,0)) THEN RETURN false; END IF;
|
||||||
|
BEGIN
|
||||||
|
SELECT GREATEST(o.finished_at,p.expires_at) INTO terminal_at FROM public.mcp_operations o
|
||||||
|
JOIN public.mcp_plans p ON p.id=o.plan_id AND p.connection_id=o.connection_id JOIN public.mcp_connections c ON c.id=o.connection_id
|
||||||
|
WHERE o.id=a.execution_id AND o.connection_id=a.connection_id AND c.owner_id=a.owner_id AND p.operation_id='servers.poll'
|
||||||
|
AND o.state IN('applied','failed','unknown','rejected','partial','cancelled') AND o.finished_at IS NOT NULL FOR UPDATE OF o,p NOWAIT;
|
||||||
|
IF NOT FOUND THEN
|
||||||
|
IF a.storage_state<>'compacted' OR EXISTS(SELECT 1 FROM public.mcp_operations o WHERE o.id=a.execution_id) OR NOT EXISTS (
|
||||||
|
SELECT 1 FROM public.mcp_operation_replay_tombstones t JOIN public.mcp_connections c ON c.id=t.connection_id
|
||||||
|
WHERE t.operation_id=a.execution_id AND t.connection_id=a.connection_id AND c.owner_id=a.owner_id
|
||||||
|
AND t.action_id='servers.poll' AND t.target_global AND cardinality(t.target_server_ids)>0
|
||||||
|
AND NOT EXISTS(SELECT 1 FROM public.mcp_plans WHERE mcp_plans.id=t.plan_id)
|
||||||
|
) OR EXISTS(SELECT 1 FROM public.server_poll_archives r WHERE r.receipt_id=id AND
|
||||||
|
(r.version<>2 OR r.execution_id<>a.execution_id OR r.connection_id<>a.connection_id OR r.owner_id<>a.owner_id
|
||||||
|
OR r.original_activity_at IS DISTINCT FROM a.original_activity_at OR r.original_expires_at<>a.authority_expires_at
|
||||||
|
OR r.binding_proof<>a.binding_proof OR r.context_proof<>a.context_proof OR r.ciphertext_bytes<>a.archive_bytes))
|
||||||
|
THEN RETURN false; END IF;
|
||||||
|
terminal_at:=a.original_activity_at;
|
||||||
|
END IF;
|
||||||
|
IF terminal_at IS NULL THEN RETURN false; END IF;
|
||||||
|
EXCEPTION WHEN lock_not_available THEN RETURN false; END;
|
||||||
|
END IF;
|
||||||
|
IF a.storage_state='compacted' THEN activity:=GREATEST(a.original_activity_at,a.authority_expires_at,terminal_at);
|
||||||
|
ELSE
|
||||||
|
SELECT GREATEST(a.authority_expires_at,a.created_at,COALESCE(a.claim_attempted_at,a.created_at),terminal_at,
|
||||||
|
COALESCE((SELECT created_at FROM public.server_poll_executions WHERE receipt_id=id),a.created_at),
|
||||||
|
COALESCE((SELECT max(created_at) FROM public.server_poll_intents WHERE receipt_id=id),a.created_at),
|
||||||
|
COALESCE((SELECT max(created_at) FROM public.server_poll_acks WHERE receipt_id=id),a.created_at),
|
||||||
|
COALESCE((SELECT created_at FROM public.server_poll_receipts WHERE receipt_id=id),a.created_at),
|
||||||
|
COALESCE((SELECT created_at FROM public.server_poll_quarantines WHERE receipt_id=id),a.created_at)) INTO activity;
|
||||||
|
END IF;
|
||||||
|
RETURN isfinite(activity) AND activity+make_interval(days=>GREATEST(a.retention_days,retention))+a.safety_ms*interval '1 millisecond'<clock_timestamp();
|
||||||
|
END $$;
|
||||||
|
INSERT INTO public.schema_migrations(id) VALUES ('0046_server_poll_operation_retirement') ON CONFLICT(id) DO NOTHING;
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
BEGIN;
|
||||||
|
CREATE TABLE public.server_poll_retirement_claims (
|
||||||
|
receipt_id uuid PRIMARY KEY REFERENCES public.server_poll_storage_admissions(receipt_id) ON DELETE CASCADE,
|
||||||
|
old_archive_proof text NOT NULL CHECK(old_archive_proof ~ '^[0-9a-f]{64}$'),
|
||||||
|
claim_token uuid NOT NULL,
|
||||||
|
claim_xid xid8 NOT NULL DEFAULT pg_current_xact_id()
|
||||||
|
);
|
||||||
|
CREATE FUNCTION public.server_poll_retirement_claim_immutable() RETURNS trigger
|
||||||
|
LANGUAGE plpgsql SET search_path=pg_catalog,public AS $$
|
||||||
|
BEGIN
|
||||||
|
IF TG_OP='INSERT' AND NEW.claim_xid=pg_current_xact_id() AND EXISTS(
|
||||||
|
SELECT 1 FROM public.server_poll_archives r WHERE r.receipt_id=NEW.receipt_id
|
||||||
|
AND r.version=1 AND r.archive_proof=NEW.old_archive_proof
|
||||||
|
) THEN RETURN NEW; END IF;
|
||||||
|
IF TG_OP='DELETE' AND pg_trigger_depth()>1 AND NOT EXISTS(
|
||||||
|
SELECT 1 FROM public.server_poll_storage_admissions a WHERE a.receipt_id=OLD.receipt_id
|
||||||
|
) THEN RETURN OLD; END IF;
|
||||||
|
RAISE EXCEPTION 'poll retirement claim cannot be replaced or reopened';
|
||||||
|
END $$;
|
||||||
|
CREATE TRIGGER server_poll_retirement_claim_immutable
|
||||||
|
BEFORE INSERT OR UPDATE OR DELETE ON public.server_poll_retirement_claims
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION public.server_poll_retirement_claim_immutable();
|
||||||
|
CREATE TRIGGER server_poll_retirement_claim_no_truncate
|
||||||
|
BEFORE TRUNCATE ON public.server_poll_retirement_claims
|
||||||
|
FOR EACH STATEMENT EXECUTE FUNCTION public.server_poll_retirement_claim_immutable();
|
||||||
|
CREATE FUNCTION public.server_poll_retirement_requires_claim() RETURNS trigger
|
||||||
|
LANGUAGE plpgsql SET search_path=pg_catalog,public AS $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS(SELECT 1 FROM public.server_poll_retirement_claims c
|
||||||
|
WHERE c.receipt_id=OLD.receipt_id AND c.old_archive_proof=OLD.archive_proof
|
||||||
|
AND c.claim_token::text=current_setting('mm.poll_retire_claim',true)
|
||||||
|
AND c.claim_xid<>pg_current_xact_id())
|
||||||
|
THEN RAISE EXCEPTION 'poll retirement requires prior exact claim'; END IF;
|
||||||
|
RETURN NEW;
|
||||||
|
END $$;
|
||||||
|
CREATE TRIGGER server_poll_retirement_requires_claim BEFORE UPDATE ON public.server_poll_archives
|
||||||
|
FOR EACH ROW WHEN(OLD.version=1 AND NEW.version=2)
|
||||||
|
EXECUTE FUNCTION public.server_poll_retirement_requires_claim();
|
||||||
|
INSERT INTO public.schema_migrations(id) VALUES ('0047_server_poll_retirement_claims') ON CONFLICT(id) DO NOTHING;
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
BEGIN;
|
||||||
|
CREATE TABLE public.mcp_ordinary_retirement_claims (
|
||||||
|
operation_id uuid PRIMARY KEY,
|
||||||
|
plan_id uuid NOT NULL UNIQUE,
|
||||||
|
connection_id uuid NOT NULL REFERENCES public.mcp_connections(id),
|
||||||
|
source_proof text NOT NULL CHECK(source_proof ~ '^[0-9a-f]{64}$'),
|
||||||
|
claim_token uuid NOT NULL,
|
||||||
|
claim_xid xid8 NOT NULL DEFAULT pg_current_xact_id()
|
||||||
|
);
|
||||||
|
CREATE FUNCTION public.mcp_ordinary_retirement_claim_immutable() RETURNS trigger
|
||||||
|
LANGUAGE plpgsql SET TimeZone='UTC' SET search_path=pg_catalog,public AS $$
|
||||||
|
BEGIN
|
||||||
|
IF TG_OP='INSERT' AND NEW.claim_xid=pg_current_xact_id() AND EXISTS(
|
||||||
|
SELECT 1 FROM public.mcp_operations o JOIN public.mcp_plans p ON p.id=o.plan_id AND p.connection_id=o.connection_id
|
||||||
|
WHERE o.id=NEW.operation_id AND o.plan_id=NEW.plan_id AND o.connection_id=NEW.connection_id
|
||||||
|
AND o.state='applied' AND o.verification='passed' AND o.error_code IS NULL
|
||||||
|
AND isfinite(o.finished_at) AND o.finished_at<transaction_timestamp()-interval '180 days'
|
||||||
|
AND p.operation_id='logs.settings.update' AND p.operation_version=1 AND p.state='completed'
|
||||||
|
) THEN RETURN NEW; END IF;
|
||||||
|
RAISE EXCEPTION 'ordinary operation retirement claim cannot be replaced or reopened';
|
||||||
|
END $$;
|
||||||
|
CREATE TRIGGER mcp_ordinary_retirement_claim_immutable BEFORE INSERT OR UPDATE OR DELETE
|
||||||
|
ON public.mcp_ordinary_retirement_claims FOR EACH ROW EXECUTE FUNCTION public.mcp_ordinary_retirement_claim_immutable();
|
||||||
|
CREATE TRIGGER mcp_ordinary_retirement_claim_no_truncate BEFORE TRUNCATE
|
||||||
|
ON public.mcp_ordinary_retirement_claims FOR EACH STATEMENT EXECUTE FUNCTION public.mcp_ordinary_retirement_claim_immutable();
|
||||||
|
INSERT INTO public.schema_migrations(id) VALUES ('0048_mcp_ordinary_retirement_claims') ON CONFLICT(id) DO NOTHING;
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
BEGIN;
|
||||||
|
CREATE OR REPLACE FUNCTION public.mcp_ordinary_retirement_claim_immutable() RETURNS trigger
|
||||||
|
LANGUAGE plpgsql SET TimeZone='UTC' SET search_path=pg_catalog,public AS $$
|
||||||
|
BEGIN
|
||||||
|
IF TG_OP='INSERT' AND NEW.claim_xid=pg_current_xact_id() AND EXISTS(
|
||||||
|
SELECT 1 FROM public.mcp_operations o JOIN public.mcp_plans p ON p.id=o.plan_id AND p.connection_id=o.connection_id
|
||||||
|
WHERE o.id=NEW.operation_id AND o.plan_id=NEW.plan_id AND o.connection_id=NEW.connection_id
|
||||||
|
AND o.state='applied' AND o.verification='passed' AND o.error_code IS NULL
|
||||||
|
AND isfinite(o.finished_at) AND o.finished_at<transaction_timestamp()-interval '180 days'
|
||||||
|
AND (p.operation_id,p.operation_version) IN (
|
||||||
|
('logs.settings.update',1),('traffic.settings.update',2),('uptime.settings.update',2),
|
||||||
|
('servers.api-ping.settings.update',2),('internet-path.settings.update',1)
|
||||||
|
) AND p.state='completed'
|
||||||
|
) THEN RETURN NEW; END IF;
|
||||||
|
RAISE EXCEPTION 'ordinary operation retirement claim cannot be replaced or reopened';
|
||||||
|
END $$;
|
||||||
|
INSERT INTO public.schema_migrations(id) VALUES ('0049_mcp_ordinary_telemetry_retirement') ON CONFLICT(id) DO NOTHING;
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
BEGIN;
|
||||||
|
CREATE TABLE public.server_probe_retired_sources (
|
||||||
|
receipt_id uuid PRIMARY KEY REFERENCES public.server_probe_storage_admissions(receipt_id),
|
||||||
|
operation_id uuid NOT NULL UNIQUE REFERENCES public.mcp_operation_replay_tombstones(operation_id),
|
||||||
|
source jsonb NOT NULL CHECK(jsonb_typeof(source)='object' AND octet_length(source::text) BETWEEN 1 AND 8192),
|
||||||
|
proof text NOT NULL CHECK(proof ~ '^[0-9a-f]{64}$'),
|
||||||
|
CHECK((source->>'version') IS NOT DISTINCT FROM '1'
|
||||||
|
AND (source->>'receiptId') IS NOT DISTINCT FROM receipt_id::text
|
||||||
|
AND (source->>'executionId') IS NOT DISTINCT FROM operation_id::text)
|
||||||
|
);
|
||||||
|
CREATE FUNCTION public.server_probe_retired_source_immutable() RETURNS trigger
|
||||||
|
LANGUAGE plpgsql SET search_path=pg_catalog,public AS $$
|
||||||
|
BEGIN
|
||||||
|
RAISE EXCEPTION 'retired diagnostic source is immutable';
|
||||||
|
END $$;
|
||||||
|
CREATE TRIGGER server_probe_retired_source_immutable
|
||||||
|
BEFORE UPDATE OR DELETE ON public.server_probe_retired_sources
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION public.server_probe_retired_source_immutable();
|
||||||
|
CREATE TRIGGER server_probe_retired_source_no_truncate BEFORE TRUNCATE ON public.server_probe_retired_sources
|
||||||
|
FOR EACH STATEMENT EXECUTE FUNCTION public.server_probe_retired_source_immutable();
|
||||||
|
INSERT INTO public.schema_migrations(id) VALUES ('0050_server_probe_retired_sources') ON CONFLICT(id) DO NOTHING;
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
BEGIN;
|
||||||
|
CREATE TABLE public.server_probe_retirement_claims (
|
||||||
|
receipt_id uuid PRIMARY KEY,
|
||||||
|
operation_id uuid NOT NULL UNIQUE,
|
||||||
|
plan_id uuid NOT NULL UNIQUE,
|
||||||
|
connection_id uuid NOT NULL,
|
||||||
|
source_proof text NOT NULL CHECK(source_proof ~ '^[a-f0-9]{64}$'),
|
||||||
|
claim_token uuid NOT NULL,
|
||||||
|
claim_xid xid8 NOT NULL DEFAULT pg_current_xact_id()
|
||||||
|
);
|
||||||
|
CREATE FUNCTION public.server_probe_retirement_claim_guard() RETURNS trigger
|
||||||
|
LANGUAGE plpgsql SET search_path=pg_catalog,public SET TimeZone='UTC' AS $$
|
||||||
|
BEGIN
|
||||||
|
IF TG_OP='INSERT' AND NEW.claim_xid=pg_current_xact_id() AND EXISTS(
|
||||||
|
SELECT 1 FROM public.server_probe_storage_admissions a
|
||||||
|
JOIN public.mcp_operations o ON o.id=a.execution_id
|
||||||
|
JOIN public.mcp_plans p ON p.id=o.plan_id AND p.connection_id=o.connection_id
|
||||||
|
JOIN public.mcp_connections c ON c.id=o.connection_id AND c.owner_id=a.owner_id
|
||||||
|
WHERE a.receipt_id=NEW.receipt_id AND a.identity_kind='mcp' AND a.claim_token IS NULL
|
||||||
|
AND a.claim_attempted_at IS NOT NULL AND a.cancelled_at IS NULL AND a.purged_at IS NULL
|
||||||
|
AND o.id=NEW.operation_id AND p.id=NEW.plan_id AND c.id=NEW.connection_id
|
||||||
|
AND o.state='applied' AND o.verification='inconclusive' AND o.error_code IS NULL
|
||||||
|
AND isfinite(o.finished_at) AND o.finished_at<transaction_timestamp()-interval '180 days'
|
||||||
|
AND p.operation_id='servers.probes.run' AND p.operation_version=1 AND p.state='completed'
|
||||||
|
) THEN RETURN NEW; END IF;
|
||||||
|
RAISE EXCEPTION 'diagnostic retirement claim cannot be replaced or reopened';
|
||||||
|
END $$;
|
||||||
|
CREATE TRIGGER server_probe_retirement_claim_guard BEFORE INSERT OR UPDATE OR DELETE
|
||||||
|
ON public.server_probe_retirement_claims FOR EACH ROW EXECUTE FUNCTION public.server_probe_retirement_claim_guard();
|
||||||
|
CREATE TRIGGER server_probe_retirement_claim_no_truncate BEFORE TRUNCATE
|
||||||
|
ON public.server_probe_retirement_claims FOR EACH STATEMENT EXECUTE FUNCTION public.server_probe_retirement_claim_guard();
|
||||||
|
CREATE FUNCTION public.server_probe_retired_source_insert_guard() RETURNS trigger
|
||||||
|
LANGUAGE plpgsql SET search_path=pg_catalog,public AS $$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS(
|
||||||
|
SELECT 1 FROM public.server_probe_retirement_claims r
|
||||||
|
JOIN public.mcp_operation_replay_tombstones t ON t.operation_id=r.operation_id AND t.plan_id=r.plan_id
|
||||||
|
AND t.connection_id=r.connection_id AND t.action_id='servers.probes.run' AND NOT t.target_global
|
||||||
|
JOIN public.mcp_operations o ON o.id=r.operation_id AND o.plan_id=r.plan_id AND o.connection_id=r.connection_id
|
||||||
|
JOIN public.server_probe_storage_admissions a ON a.receipt_id=r.receipt_id AND a.execution_id=r.operation_id
|
||||||
|
WHERE r.receipt_id=NEW.receipt_id AND r.operation_id=NEW.operation_id AND r.source_proof=NEW.proof
|
||||||
|
AND r.claim_xid<>pg_current_xact_id() AND r.claim_token::text=current_setting('mm.probe_retirement_claim',true)
|
||||||
|
AND NEW.source->>'planId'=r.plan_id::text AND NEW.source->>'connectionId'=r.connection_id::text
|
||||||
|
AND NEW.source->>'ownerId'=a.owner_id AND NEW.source->>'bindingProof'=a.binding_proof
|
||||||
|
AND NEW.source->'serverIds'=to_jsonb(t.target_server_ids)
|
||||||
|
) THEN RETURN NEW; END IF;
|
||||||
|
RAISE EXCEPTION 'diagnostic archive requires an exact committed retirement claim';
|
||||||
|
END $$;
|
||||||
|
CREATE TRIGGER server_probe_retired_source_insert_guard BEFORE INSERT ON public.server_probe_retired_sources
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION public.server_probe_retired_source_insert_guard();
|
||||||
|
INSERT INTO public.schema_migrations(id) VALUES ('0051_server_probe_retirement_claims') ON CONFLICT(id) DO NOTHING;
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
BEGIN;
|
||||||
|
ALTER TABLE public.server_probe_retired_sources
|
||||||
|
DROP CONSTRAINT server_probe_retired_sources_receipt_id_fkey,
|
||||||
|
ADD CONSTRAINT server_probe_retired_sources_receipt_id_fkey
|
||||||
|
FOREIGN KEY(receipt_id) REFERENCES public.server_probe_storage_admissions(receipt_id) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
CREATE FUNCTION public.server_probe_retired_gc_allowed(target_receipt uuid,current_retention integer) RETURNS boolean
|
||||||
|
LANGUAGE plpgsql SET TimeZone='UTC' SET search_path=pg_catalog,public AS $$
|
||||||
|
DECLARE activity timestamptz; retained_days integer; safety integer;
|
||||||
|
BEGIN
|
||||||
|
IF current_setting('mm.probe_storage_gc',true) IS DISTINCT FROM 'on'
|
||||||
|
OR current_setting('mm.probe_retired_gc_receipt',true) IS DISTINCT FROM target_receipt::text THEN RETURN false; END IF;
|
||||||
|
SELECT (r.source->>'activityAt')::timestamptz,a.retention_days,a.safety_ms
|
||||||
|
INTO activity,retained_days,safety
|
||||||
|
FROM public.server_probe_retired_sources r
|
||||||
|
JOIN public.server_probe_storage_admissions a ON a.receipt_id=r.receipt_id AND a.execution_id=r.operation_id
|
||||||
|
JOIN public.mcp_operation_replay_tombstones t ON t.operation_id=r.operation_id
|
||||||
|
JOIN public.mcp_connections c ON c.id=t.connection_id AND c.owner_id=a.owner_id
|
||||||
|
WHERE r.receipt_id=target_receipt AND r.proof=current_setting('mm.probe_retired_gc_proof',true)
|
||||||
|
AND a.identity_kind='mcp' AND a.cancelled_at IS NULL AND a.claim_token IS NULL AND isfinite(a.claim_attempted_at)
|
||||||
|
AND t.action_id='servers.probes.run' AND NOT t.target_global
|
||||||
|
AND r.source->>'planId'=t.plan_id::text AND r.source->>'connectionId'=c.id::text
|
||||||
|
AND r.source->'serverIds'=to_jsonb(t.target_server_ids) AND r.source->>'ownerId'=a.owner_id
|
||||||
|
AND r.source->>'bindingProof'=a.binding_proof
|
||||||
|
AND (r.source->>'authorityExpiresAt')::timestamptz=a.authority_expires_at
|
||||||
|
AND (r.source->>'gcNotBefore')::timestamptz=a.gc_not_before
|
||||||
|
AND (r.source->>'retentionDays')::int=a.retention_days AND (r.source->>'safetyMs')::int=a.safety_ms
|
||||||
|
AND isfinite(a.authority_expires_at) AND isfinite(a.gc_not_before)
|
||||||
|
AND a.authority_expires_at<clock_timestamp() AND a.gc_not_before<clock_timestamp()
|
||||||
|
AND NOT EXISTS(SELECT 1 FROM public.mcp_operations WHERE id=t.operation_id)
|
||||||
|
AND NOT EXISTS(SELECT 1 FROM public.mcp_plans WHERE id=t.plan_id)
|
||||||
|
AND NOT EXISTS(SELECT 1 FROM public.mcp_operation_steps WHERE operation_id=t.operation_id)
|
||||||
|
AND NOT EXISTS(SELECT 1 FROM public.mcp_operation_checkpoints WHERE operation_id=t.operation_id)
|
||||||
|
AND NOT EXISTS(SELECT 1 FROM public.mcp_diagnostic_cancellations WHERE operation_id=t.operation_id)
|
||||||
|
AND NOT EXISTS(SELECT 1 FROM public.server_probe_execution_quarantines q
|
||||||
|
WHERE q.receipt_id=r.receipt_id OR(q.identity_kind='mcp' AND q.execution_id=r.operation_id));
|
||||||
|
IF NOT FOUND OR activity IS NULL OR NOT isfinite(activity)
|
||||||
|
OR current_retention IS NULL OR current_retention NOT BETWEEN 1 AND 365 THEN RETURN false; END IF;
|
||||||
|
IF EXISTS(SELECT 1 FROM public.server_probe_executions j WHERE j.receipt_id=target_receipt
|
||||||
|
AND (j.finished_at IS NULL OR j.finished_at>activity))
|
||||||
|
OR EXISTS(SELECT 1 FROM public.server_probe_execution_stages s WHERE s.receipt_id=target_receipt
|
||||||
|
AND GREATEST(s.created_at,COALESCE(s.received_at,s.created_at))>activity)
|
||||||
|
THEN RETURN false; END IF;
|
||||||
|
RETURN activity+make_interval(days=>GREATEST(retained_days,current_retention))
|
||||||
|
+GREATEST(safety,60000)*interval '1 millisecond'<clock_timestamp();
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.server_probe_retired_source_immutable() RETURNS trigger
|
||||||
|
LANGUAGE plpgsql SET search_path=pg_catalog,public AS $$
|
||||||
|
BEGIN
|
||||||
|
IF TG_OP='DELETE' THEN
|
||||||
|
IF current_setting('mm.probe_storage_gc',true)='on'
|
||||||
|
AND current_setting('mm.probe_retired_gc_receipt',true)=OLD.receipt_id::text
|
||||||
|
AND current_setting('mm.probe_retired_gc_proof',true)=OLD.proof
|
||||||
|
AND NOT EXISTS(SELECT 1 FROM public.server_probe_storage_admissions WHERE receipt_id=OLD.receipt_id)
|
||||||
|
AND NOT EXISTS(SELECT 1 FROM public.server_probe_executions WHERE receipt_id=OLD.receipt_id)
|
||||||
|
AND NOT EXISTS(SELECT 1 FROM public.server_probe_execution_payloads WHERE receipt_id=OLD.receipt_id)
|
||||||
|
AND NOT EXISTS(SELECT 1 FROM public.server_probe_execution_stages WHERE receipt_id=OLD.receipt_id)
|
||||||
|
THEN RETURN OLD; END IF;
|
||||||
|
END IF;
|
||||||
|
RAISE EXCEPTION 'retired diagnostic source is immutable';
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.server_probe_storage_gc_allowed(id uuid) RETURNS boolean
|
||||||
|
LANGUAGE plpgsql SET TimeZone='UTC' AS $$
|
||||||
|
DECLARE a server_probe_storage_admissions%ROWTYPE; activity timestamptz; current_retention integer; terminal_at timestamptz;
|
||||||
|
BEGIN
|
||||||
|
IF current_setting('mm.probe_storage_gc',true) IS DISTINCT FROM 'on' THEN RETURN false; END IF;
|
||||||
|
PERFORM pg_advisory_xact_lock(hashtextextended('server-probe-storage-v2',0));
|
||||||
|
SELECT * INTO a FROM server_probe_storage_admissions WHERE receipt_id=id FOR UPDATE;
|
||||||
|
IF NOT FOUND OR a.gc_not_before>=clock_timestamp() OR a.authority_expires_at>=clock_timestamp() THEN RETURN false; END IF;
|
||||||
|
SELECT settings.requests_retention_days INTO current_retention FROM mcp_settings settings WHERE settings.id=1;
|
||||||
|
IF current_retention IS NULL OR current_retention NOT BETWEEN 1 AND 365 THEN RETURN false; END IF;
|
||||||
|
IF EXISTS(SELECT 1 FROM public.server_probe_retired_sources WHERE receipt_id=id) THEN
|
||||||
|
RETURN public.server_probe_retired_gc_allowed(id,current_retention);
|
||||||
|
END IF;
|
||||||
|
terminal_at:=a.created_at;
|
||||||
|
IF a.identity_kind='mcp' THEN
|
||||||
|
SELECT GREATEST(o.finished_at,p.expires_at,COALESCE(cancellation.created_at,o.finished_at)) INTO terminal_at
|
||||||
|
FROM mcp_operations o JOIN mcp_plans p ON p.id=o.plan_id AND p.connection_id=o.connection_id
|
||||||
|
JOIN mcp_connections owner_connection ON owner_connection.id=o.connection_id
|
||||||
|
LEFT JOIN mcp_diagnostic_cancellations cancellation ON cancellation.operation_id=o.id
|
||||||
|
WHERE o.id=a.execution_id AND owner_connection.owner_id=a.owner_id AND p.operation_id='servers.probes.run'
|
||||||
|
AND o.state IN ('applied','failed','unknown','rejected','partial','cancelled') AND o.finished_at IS NOT NULL
|
||||||
|
AND (cancellation.operation_id IS NULL OR(cancellation.connection_id=o.connection_id AND cancellation.actor_id=a.owner_id))
|
||||||
|
FOR UPDATE OF o,p,owner_connection;
|
||||||
|
IF NOT FOUND OR terminal_at IS NULL THEN RETURN false; END IF;
|
||||||
|
END IF;
|
||||||
|
SELECT GREATEST(a.created_at,a.authority_expires_at,terminal_at,COALESCE(a.cancelled_at,a.created_at),
|
||||||
|
COALESCE((SELECT finished_at FROM server_probe_executions WHERE receipt_id=id),a.created_at),
|
||||||
|
COALESCE((SELECT created_at FROM server_probe_execution_quarantines WHERE receipt_id=id),a.created_at),
|
||||||
|
COALESCE((SELECT max(GREATEST(created_at,COALESCE(received_at,created_at))) FROM server_probe_execution_stages WHERE receipt_id=id),a.created_at)) INTO activity;
|
||||||
|
RETURN activity+make_interval(days=>GREATEST(a.retention_days,current_retention))+a.safety_ms*interval '1 millisecond'<clock_timestamp();
|
||||||
|
END $$;
|
||||||
|
INSERT INTO public.schema_migrations(id) VALUES ('0052_server_probe_retired_gc') ON CONFLICT(id) DO NOTHING;
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
BEGIN;
|
||||||
|
CREATE TABLE public.server_probe_terminal_proofs (
|
||||||
|
operation_id uuid PRIMARY KEY REFERENCES public.mcp_operations(id),
|
||||||
|
receipt_id uuid NOT NULL UNIQUE,
|
||||||
|
source jsonb NOT NULL CHECK(jsonb_typeof(source)='object' AND octet_length(source::text) BETWEEN 1 AND 8192),
|
||||||
|
source_proof text NOT NULL CHECK(source_proof ~ '^[a-f0-9]{64}$'),
|
||||||
|
idempotency_digest text NOT NULL CHECK(idempotency_digest ~ '^[a-f0-9]{64}$'),
|
||||||
|
capture_proof text NOT NULL CHECK(capture_proof ~ '^[a-f0-9]{64}$'),
|
||||||
|
CHECK((source->>'version') IS NOT DISTINCT FROM '1'
|
||||||
|
AND (source->>'executionId') IS NOT DISTINCT FROM operation_id::text
|
||||||
|
AND (source->>'receiptId') IS NOT DISTINCT FROM receipt_id::text)
|
||||||
|
);
|
||||||
|
CREATE FUNCTION public.server_probe_terminal_proof_guard() RETURNS trigger
|
||||||
|
LANGUAGE plpgsql SET search_path=pg_catalog,public AS $$
|
||||||
|
BEGIN
|
||||||
|
IF TG_OP='INSERT' THEN
|
||||||
|
IF EXISTS(
|
||||||
|
SELECT 1 FROM public.mcp_operations o
|
||||||
|
JOIN public.mcp_plans p ON p.id=o.plan_id AND p.connection_id=o.connection_id
|
||||||
|
JOIN public.mcp_connections c ON c.id=o.connection_id
|
||||||
|
JOIN public.server_probe_storage_admissions a ON a.execution_id=o.id AND a.identity_kind='mcp' AND a.owner_id=c.owner_id
|
||||||
|
JOIN public.server_probe_executions j ON j.receipt_id=a.receipt_id
|
||||||
|
JOIN public.server_probe_execution_payloads b ON b.receipt_id=a.receipt_id AND b.receipt_proof=j.receipt_proof
|
||||||
|
WHERE o.id=NEW.operation_id AND a.receipt_id=NEW.receipt_id AND a.purged_at IS NULL AND a.cancelled_at IS NULL
|
||||||
|
AND a.claim_token IS NULL AND a.claim_attempted_at IS NOT NULL
|
||||||
|
AND o.state='applied' AND o.verification='inconclusive' AND o.error_code IS NULL
|
||||||
|
AND p.state='completed' AND p.operation_id='servers.probes.run' AND p.operation_version=1
|
||||||
|
AND j.state='completed' AND j.execution_id=o.id AND j.mcp_connection_id=c.id AND j.actor_id=c.owner_id
|
||||||
|
AND NEW.source->>'connectionId'=c.id::text AND NEW.source->>'planId'=p.id::text
|
||||||
|
AND NEW.source->>'ownerId'=a.owner_id AND NEW.source->>'bindingProof'=a.binding_proof
|
||||||
|
AND NEW.source->>'receiptProof'=b.receipt_proof
|
||||||
|
AND NEW.source->>'ciphertextSha256'=encode(sha256(convert_to(b.receipt_ciphertext,'UTF8')),'hex')
|
||||||
|
) THEN RETURN NEW; END IF;
|
||||||
|
ELSIF TG_OP='DELETE' THEN
|
||||||
|
IF EXISTS(
|
||||||
|
SELECT 1 FROM public.server_probe_retirement_claims r
|
||||||
|
JOIN public.mcp_operation_replay_tombstones t ON t.operation_id=r.operation_id AND t.plan_id=r.plan_id AND t.connection_id=r.connection_id
|
||||||
|
WHERE r.operation_id=OLD.operation_id AND r.receipt_id=OLD.receipt_id AND r.source_proof=OLD.source_proof
|
||||||
|
AND r.claim_xid<>pg_current_xact_id() AND r.claim_token::text=current_setting('mm.probe_retirement_claim',true)
|
||||||
|
AND t.action_id='servers.probes.run' AND NOT t.target_global AND t.idempotency_digest=OLD.idempotency_digest
|
||||||
|
AND OLD.source->'serverIds'=to_jsonb(t.target_server_ids)
|
||||||
|
) THEN RETURN OLD; END IF;
|
||||||
|
END IF;
|
||||||
|
RAISE EXCEPTION 'diagnostic terminal proof must match its original source or exact retirement claim';
|
||||||
|
END $$;
|
||||||
|
CREATE TRIGGER server_probe_terminal_proof_guard BEFORE INSERT OR UPDATE OR DELETE
|
||||||
|
ON public.server_probe_terminal_proofs FOR EACH ROW EXECUTE FUNCTION public.server_probe_terminal_proof_guard();
|
||||||
|
CREATE TRIGGER server_probe_terminal_proof_no_truncate BEFORE TRUNCATE
|
||||||
|
ON public.server_probe_terminal_proofs FOR EACH STATEMENT EXECUTE FUNCTION public.server_probe_terminal_proof_guard();
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.server_probe_retirement_claim_guard() RETURNS trigger
|
||||||
|
LANGUAGE plpgsql SET search_path=pg_catalog,public SET TimeZone='UTC' AS $$
|
||||||
|
BEGIN
|
||||||
|
IF TG_OP='INSERT' AND NEW.claim_xid=pg_current_xact_id() AND EXISTS(
|
||||||
|
SELECT 1 FROM public.mcp_operations o
|
||||||
|
JOIN public.mcp_plans p ON p.id=o.plan_id AND p.connection_id=o.connection_id
|
||||||
|
JOIN public.mcp_connections c ON c.id=o.connection_id
|
||||||
|
WHERE o.id=NEW.operation_id AND p.id=NEW.plan_id AND c.id=NEW.connection_id
|
||||||
|
AND o.state='applied' AND o.verification='inconclusive' AND o.error_code IS NULL
|
||||||
|
AND isfinite(o.finished_at) AND o.finished_at<transaction_timestamp()-interval '180 days'
|
||||||
|
AND p.operation_id='servers.probes.run' AND p.operation_version=1 AND p.state='completed'
|
||||||
|
AND (EXISTS(SELECT 1 FROM public.server_probe_storage_admissions a
|
||||||
|
WHERE a.receipt_id=NEW.receipt_id AND a.execution_id=o.id AND a.identity_kind='mcp' AND a.owner_id=c.owner_id
|
||||||
|
AND a.claim_token IS NULL AND a.claim_attempted_at IS NOT NULL AND a.cancelled_at IS NULL AND a.purged_at IS NULL)
|
||||||
|
OR(EXISTS(SELECT 1 FROM public.server_probe_terminal_proofs f
|
||||||
|
WHERE f.operation_id=o.id AND f.receipt_id=NEW.receipt_id AND f.source_proof=NEW.source_proof
|
||||||
|
AND f.source->>'ownerId'=c.owner_id AND f.source->>'connectionId'=c.id::text AND f.source->>'planId'=p.id::text)
|
||||||
|
AND NOT EXISTS(SELECT 1 FROM public.server_probe_storage_admissions a
|
||||||
|
WHERE a.receipt_id=NEW.receipt_id OR(a.identity_kind='mcp' AND a.execution_id=o.id))))
|
||||||
|
) THEN RETURN NEW; END IF;
|
||||||
|
RAISE EXCEPTION 'diagnostic retirement claim cannot be replaced or reopened';
|
||||||
|
END $$;
|
||||||
|
INSERT INTO public.schema_migrations(id) VALUES ('0053_server_probe_terminal_proofs') ON CONFLICT(id) DO NOTHING;
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
BEGIN;
|
||||||
|
CREATE OR REPLACE FUNCTION public.mcp_ordinary_retirement_claim_immutable() RETURNS trigger
|
||||||
|
LANGUAGE plpgsql SET TimeZone='UTC' SET search_path=pg_catalog,public AS $$
|
||||||
|
BEGIN
|
||||||
|
IF TG_OP='INSERT' AND NEW.claim_xid=pg_current_xact_id() AND EXISTS(
|
||||||
|
SELECT 1 FROM public.mcp_operations o JOIN public.mcp_plans p ON p.id=o.plan_id AND p.connection_id=o.connection_id
|
||||||
|
WHERE o.id=NEW.operation_id AND o.plan_id=NEW.plan_id AND o.connection_id=NEW.connection_id
|
||||||
|
AND o.state='applied' AND o.verification='passed' AND o.error_code IS NULL
|
||||||
|
AND isfinite(o.finished_at) AND o.finished_at<transaction_timestamp()-interval '180 days'
|
||||||
|
AND (p.operation_id,p.operation_version) IN (
|
||||||
|
('logs.settings.update',1),('traffic.settings.update',2),('uptime.settings.update',2),
|
||||||
|
('servers.api-ping.settings.update',2),('internet-path.settings.update',1),('geoip.settings.update',1)
|
||||||
|
) AND p.state='completed'
|
||||||
|
) THEN RETURN NEW; END IF;
|
||||||
|
RAISE EXCEPTION 'ordinary operation retirement claim cannot be replaced or reopened';
|
||||||
|
END $$;
|
||||||
|
INSERT INTO public.schema_migrations(id) VALUES ('0054_mcp_geoip_retirement') ON CONFLICT(id) DO NOTHING;
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
BEGIN;
|
||||||
|
CREATE OR REPLACE FUNCTION public.mcp_ordinary_retirement_claim_immutable() RETURNS trigger
|
||||||
|
LANGUAGE plpgsql SET TimeZone='UTC' SET search_path=pg_catalog,public AS $$
|
||||||
|
BEGIN
|
||||||
|
IF TG_OP='INSERT' AND NEW.claim_xid=pg_current_xact_id() AND EXISTS(
|
||||||
|
SELECT 1 FROM public.mcp_operations o JOIN public.mcp_plans p ON p.id=o.plan_id AND p.connection_id=o.connection_id
|
||||||
|
WHERE o.id=NEW.operation_id AND o.plan_id=NEW.plan_id AND o.connection_id=NEW.connection_id
|
||||||
|
AND o.state='applied' AND o.verification='passed' AND o.error_code IS NULL
|
||||||
|
AND isfinite(o.finished_at) AND o.finished_at<transaction_timestamp()-interval '180 days'
|
||||||
|
AND (p.operation_id,p.operation_version) IN (
|
||||||
|
('logs.settings.update',1),('traffic.settings.update',2),('uptime.settings.update',2),
|
||||||
|
('servers.api-ping.settings.update',2),('internet-path.settings.update',1),('geoip.settings.update',1),('backups.schedule.update',1)
|
||||||
|
) AND p.state='completed'
|
||||||
|
) THEN RETURN NEW; END IF;
|
||||||
|
RAISE EXCEPTION 'ordinary operation retirement claim cannot be replaced or reopened';
|
||||||
|
END $$;
|
||||||
|
INSERT INTO public.schema_migrations(id) VALUES ('0055_mcp_backup_schedule_retirement') ON CONFLICT(id) DO NOTHING;
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
-- Keep the live charge and intent fences in sync with recovery action v8.
|
||||||
|
-- Existing authenticated v6/v7 history remains unchanged.
|
||||||
|
BEGIN;
|
||||||
|
CREATE OR REPLACE FUNCTION public.mcp_routing_manual_inverse_charge_source() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS(
|
||||||
|
SELECT 1 FROM public.mcp_operations o
|
||||||
|
JOIN public.mcp_plans p ON p.id=o.plan_id AND p.connection_id=o.connection_id
|
||||||
|
JOIN public.mcp_routing_profiles r ON r.id=NEW.profile_id AND r.connection_id=o.connection_id
|
||||||
|
JOIN public.mcp_operations source ON source.id=NEW.source_operation_id AND source.connection_id=o.connection_id
|
||||||
|
JOIN public.mcp_plans sp ON sp.id=source.plan_id AND sp.connection_id=source.connection_id
|
||||||
|
JOIN public.mcp_connections c ON c.id=o.connection_id
|
||||||
|
WHERE o.id=NEW.recovery_operation_id AND p.id=NEW.recovery_plan_id
|
||||||
|
AND o.connection_id=NEW.connection_id AND c.owner_id=NEW.owner_id
|
||||||
|
AND r.version=NEW.profile_version AND o.state='pending' AND p.state='executing'
|
||||||
|
AND p.operation_id='operations.recover' AND p.operation_version=8
|
||||||
|
AND sp.id=NEW.source_plan_id AND sp.operation_id='routing.community.switch' AND sp.operation_version=7
|
||||||
|
AND source.state IN('applied','partial')
|
||||||
|
) THEN RAISE EXCEPTION 'manual inverse charge differs from pending admission'; END IF;
|
||||||
|
RETURN NEW;
|
||||||
|
END $$;
|
||||||
|
CREATE OR REPLACE FUNCTION public.mcp_scoped_recovery_manual_version() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||||
|
BEGIN
|
||||||
|
IF NEW.mode='manual' AND NOT pg_try_advisory_xact_lock(hashtextextended('mcp-runner-admission',0))
|
||||||
|
THEN RAISE EXCEPTION 'manual intent admission snapshot busy'; END IF;
|
||||||
|
IF NEW.mode='manual' AND NOT EXISTS(
|
||||||
|
SELECT 1 FROM public.mcp_operations o
|
||||||
|
JOIN public.mcp_plans p ON p.id=o.plan_id AND p.connection_id=o.connection_id
|
||||||
|
JOIN public.mcp_operation_steps s ON s.operation_id=o.id AND s.step_index=0
|
||||||
|
JOIN public.mcp_operations source ON source.id=NEW.source_operation_id AND source.connection_id=o.connection_id
|
||||||
|
WHERE o.id=NEW.recovery_operation_id AND o.state='running'
|
||||||
|
AND p.state='executing' AND p.operation_id='operations.recover' AND p.operation_version=8
|
||||||
|
AND NEW.source_step_index=0 AND s.state='intent'
|
||||||
|
AND s.intended_state->>'kind'='service'
|
||||||
|
AND s.intended_state->>'service'='routing.community.scoped.recover'
|
||||||
|
AND s.intended_state->'input'->>'mode'='manual'
|
||||||
|
AND s.intended_state->'input'->>'sourceOperationId'=NEW.source_operation_id::text
|
||||||
|
AND s.intended_state->'input'->>'receiptId'=NEW.recovery_receipt_id::text
|
||||||
|
) THEN RAISE EXCEPTION 'manual inverse requires current exact running recovery'; END IF;
|
||||||
|
RETURN NEW;
|
||||||
|
END $$;
|
||||||
|
INSERT INTO public.schema_migrations(id) VALUES('0056_mcp_manual_inverse_recovery_v8') ON CONFLICT(id) DO NOTHING;
|
||||||
|
COMMIT;
|
||||||
@@ -576,6 +576,13 @@
|
|||||||
"operations": [],
|
"operations": [],
|
||||||
"reason": "Управление самим MCP, транспорт или машинная авторизация: отдельного AI-инструмента нет."
|
"reason": "Управление самим MCP, транспорт или машинная авторизация: отдельного AI-инструмента нет."
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/mcp/database/receipts/:id/audit-status",
|
||||||
|
"status": "excluded",
|
||||||
|
"operations": [],
|
||||||
|
"reason": "Человеческая административная сверка аудита внешней квитанции восстановления: управление самим MCP, без отдельного AI-инструмента."
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"method": "GET",
|
"method": "GET",
|
||||||
"path": "/api/mcp/devices/:serverId/:resource",
|
"path": "/api/mcp/devices/:serverId/:resource",
|
||||||
@@ -590,6 +597,13 @@
|
|||||||
"operations": [],
|
"operations": [],
|
||||||
"reason": "Управление самим MCP, транспорт или машинная авторизация: отдельного AI-инструмента нет."
|
"reason": "Управление самим MCP, транспорт или машинная авторизация: отдельного AI-инструмента нет."
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/mcp/devices/:serverId/bridge-vlan/configuration",
|
||||||
|
"status": "mediated",
|
||||||
|
"operations": ["network.bridge-vlan.read"],
|
||||||
|
"reason": "Чтение связанной конфигурации bridge/VLAN через общую типизированную модель и проверку доступа MCP."
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"method": "GET",
|
"method": "GET",
|
||||||
"path": "/api/mcp/drift",
|
"path": "/api/mcp/drift",
|
||||||
@@ -1604,6 +1618,13 @@
|
|||||||
"operations": [],
|
"operations": [],
|
||||||
"reason": "Управление самим MCP, транспорт или машинная авторизация: отдельного AI-инструмента нет."
|
"reason": "Управление самим MCP, транспорт или машинная авторизация: отдельного AI-инструмента нет."
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/mcp/devices/:serverId/bridge-vlan/configuration/plan",
|
||||||
|
"status": "mediated",
|
||||||
|
"operations": ["network.bridge-vlan.configure"],
|
||||||
|
"reason": "Подготовка связанного плана bridge/VLAN через тот же MCP-инструмент; выполнение требует человеческого подтверждения."
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"method": "POST",
|
"method": "POST",
|
||||||
"path": "/api/mcp/management-channels",
|
"path": "/api/mcp/management-channels",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
"test:traffic-flow:rebuild:pg": "node scripts/test-mcp-postgres.mjs --flow-rebuild",
|
"test:traffic-flow:rebuild:pg": "node scripts/test-mcp-postgres.mjs --flow-rebuild",
|
||||||
"test:traffic-flow:pg": "node scripts/test-mcp-postgres.mjs --flow-writes",
|
"test:traffic-flow:pg": "node scripts/test-mcp-postgres.mjs --flow-writes",
|
||||||
"test:traffic-flow:quality:pg": "node scripts/test-mcp-postgres.mjs --flow-quality",
|
"test:traffic-flow:quality:pg": "node scripts/test-mcp-postgres.mjs --flow-quality",
|
||||||
|
"test:traffic-flow:chr-evidence": "tsx --test src/test/traffic-flow-chr-capture-check.test.ts src/test/traffic-flow-chr-wire.test.ts src/test/traffic-flow-chr-target-harness.test.ts src/test/traffic-flow-chr-owned-sequence.test.ts src/test/traffic-flow-chr-reconcile.test.ts",
|
||||||
"test:mcp:pg:all": "node scripts/test-mcp-postgres.mjs --all",
|
"test:mcp:pg:all": "node scripts/test-mcp-postgres.mjs --all",
|
||||||
"mcp:stdio": "node dist/scripts/mcp-stdio.js",
|
"mcp:stdio": "node dist/scripts/mcp-stdio.js",
|
||||||
"mcp:stdio:dev": "tsx src/scripts/mcp-stdio.ts",
|
"mcp:stdio:dev": "tsx src/scripts/mcp-stdio.ts",
|
||||||
@@ -34,7 +35,7 @@
|
|||||||
"test:exec": "tsx src/services/mikrotik-logs.test.ts && tsx src/services/terminal-diagnostics.test.ts && tsx src/routes/exec.test.ts && tsx src/plugins/auth.exec.test.ts",
|
"test:exec": "tsx src/services/mikrotik-logs.test.ts && tsx src/services/terminal-diagnostics.test.ts && tsx src/routes/exec.test.ts && tsx src/plugins/auth.exec.test.ts",
|
||||||
"test:auth": "tsx --test src/modules/mcp/routing-manual-inverse-history.test.ts && tsx src/db/application-writer-profile-registry-structure.test.ts && tsx src/db/application-writer-profile-registry-read.test.ts && tsx src/db/application-writer-profile-registry-read-parity.test.ts && tsx src/db/application-writer-profile-format.test.ts && tsx src/plugins/auth-path.test.ts && tsx src/lib/permissions.test.ts && tsx src/plugins/auth-provenance.test.ts && tsx src/services/bandwidth-rest-speed-original.test.ts && tsx src/plugins/auth.smoke.test.ts && tsx src/plugins/auth.ospf-optimizer.test.ts && npm run test:mcp",
|
"test:auth": "tsx --test src/modules/mcp/routing-manual-inverse-history.test.ts && tsx src/db/application-writer-profile-registry-structure.test.ts && tsx src/db/application-writer-profile-registry-read.test.ts && tsx src/db/application-writer-profile-registry-read-parity.test.ts && tsx src/db/application-writer-profile-format.test.ts && tsx src/plugins/auth-path.test.ts && tsx src/lib/permissions.test.ts && tsx src/plugins/auth-provenance.test.ts && tsx src/services/bandwidth-rest-speed-original.test.ts && tsx src/plugins/auth.smoke.test.ts && tsx src/plugins/auth.ospf-optimizer.test.ts && npm run test:mcp",
|
||||||
"test:mcp": "tsx --test src/services/server-poll-error-provenance.test.ts && tsx --test src/modules/mcp/plan-expiry.test.ts src/modules/mcp/result-budget.test.ts src/modules/mcp/result-delivery.test.ts src/modules/mcp/wire-admission.test.ts src/modules/mcp/flow-envelope-allowance.test.ts src/modules/mcp/sdk-terminal-budget.test.ts src/modules/mcp/inflight-owner.test.ts && npm run test:database-source-binding && tsx --test src/modules/mcp/integration-fixture-expiry.test.ts && npm run test:mcp:operation-origin && npm run test:mcp:service-context && npm run test:mcp:operation-locks && tsx --test src/modules/mcp/routing-community-public.test.ts src/modules/mcp/management-transport.test.ts src/modules/mcp/routing-community-context-model.test.ts src/modules/mcp/routing-community-context-native.test.ts ../packages/contracts/src/mcp-routing.test.ts && tsx src/modules/mcp/human-artifact-receipts.test.ts && tsx src/modules/mcp/chr-overlay-readiness.test.ts && tsx src/modules/mcp/chr-recovery-source.test.ts && tsx src/modules/mcp/chr-native-failure.test.ts && tsx src/modules/mcp/ospf-owner-graph.test.ts && tsx src/modules/mcp/routing-measurement-address.test.ts && tsx src/modules/mcp/routing-observation-source.test.ts && tsx src/services/traffic-flow-talkers-model.test.ts && tsx src/services/bgp-prefix-model.test.ts && tsx src/services/vrf-members.test.ts && tsx src/services/routing-gateway-reference.test.ts && tsx src/services/bgp-export-model.test.ts && tsx src/services/traffic-history-model.test.ts && tsx src/services/internet-path-snapshot.test.ts && tsx src/services/internet-path-observation.test.ts && tsx src/modules/mcp/coverage.test.ts && tsx src/modules/mcp/policy.test.ts && tsx src/modules/mcp/events-batch-model.test.ts && tsx src/modules/mcp/operation-revisions.test.ts && tsx src/modules/mcp/native-create-identity.test.ts && tsx src/modules/mcp/ros-step-verification.test.ts && tsx src/modules/mcp/ros-prefix.test.ts && tsx src/modules/mcp/native-conservation-http.test.ts && tsx src/modules/mcp/security.test.ts && tsx src/modules/mcp/state-file-publish.test.ts && tsx src/modules/mcp/routing-model.test.ts && tsx src/modules/mcp/routing-observations-model.test.ts && tsx src/modules/mcp/ospf-health.test.ts && tsx src/modules/mcp/ecmp-preflight.test.ts && tsx src/modules/mcp/recursive-recovery.test.ts && tsx src/modules/mcp/control-path-model.test.ts && tsx src/modules/mcp/routing-community-scoped-model.test.ts && tsx src/modules/mcp/routing-community-scoped-native.test.ts && tsx src/modules/mcp/path-verification.test.ts && tsx src/modules/mcp/database-maintenance.test.ts && tsx src/routes/mcp.test.ts && tsx ../lib/auth-mcp.test.ts && tsx --test src/modules/mcp/routing-controller-fence.test.ts && tsx src/test/chr-vrf-companion-proof.test.ts && tsx src/test/chr-vrf-companion-telemetry.test.ts && tsx src/test/chr-vrf-companion-address-space.test.ts && tsx src/modules/mcp/flow-facts-rebuild-actions.test.ts && tsx src/modules/mcp/ipsec-peer-create-dependencies.test.ts && tsx src/services/ipsec-certificate-update-model.test.ts && tsx src/modules/mcp/ipsec-native-dispatch.test.ts && tsx src/modules/mcp/ipsec-user-update-actions.test.ts && tsx src/modules/mcp/ipsec-server-delete-actions.test.ts && tsx src/services/ipsec-proof-telemetry.test.ts && tsx src/services/vrf-production-admission.test.ts && tsx src/modules/mcp/flow-host-keys-actions.test.ts && tsx src/services/vrf-production-read.test.ts && tsx src/modules/mcp/ipsec-user-create-actions.test.ts && tsx src/modules/mcp/flow-settings-actions.test.ts && tsx src/services/vrf-production-ticket-store.test.ts && npm run test:mcp:routing-baseline",
|
"test:mcp": "tsx --test src/services/server-poll-error-provenance.test.ts && tsx --test src/modules/mcp/plan-expiry.test.ts src/modules/mcp/result-budget.test.ts src/modules/mcp/result-delivery.test.ts src/modules/mcp/wire-admission.test.ts src/modules/mcp/flow-envelope-allowance.test.ts src/modules/mcp/sdk-terminal-budget.test.ts src/modules/mcp/inflight-owner.test.ts && npm run test:database-source-binding && tsx --test src/modules/mcp/integration-fixture-expiry.test.ts && npm run test:mcp:operation-origin && npm run test:mcp:service-context && npm run test:mcp:operation-locks && tsx --test src/modules/mcp/routing-community-public.test.ts src/modules/mcp/management-transport.test.ts src/modules/mcp/routing-community-context-model.test.ts src/modules/mcp/routing-community-context-native.test.ts ../packages/contracts/src/mcp-routing.test.ts && tsx src/modules/mcp/human-artifact-receipts.test.ts && tsx src/modules/mcp/chr-overlay-readiness.test.ts && tsx src/modules/mcp/chr-recovery-source.test.ts && tsx src/modules/mcp/chr-native-failure.test.ts && tsx src/modules/mcp/ospf-owner-graph.test.ts && tsx src/modules/mcp/routing-measurement-address.test.ts && tsx src/modules/mcp/routing-observation-source.test.ts && tsx src/services/traffic-flow-talkers-model.test.ts && tsx src/services/bgp-prefix-model.test.ts && tsx src/services/vrf-members.test.ts && tsx src/services/routing-gateway-reference.test.ts && tsx src/services/bgp-export-model.test.ts && tsx src/services/traffic-history-model.test.ts && tsx src/services/internet-path-snapshot.test.ts && tsx src/services/internet-path-observation.test.ts && tsx src/modules/mcp/coverage.test.ts && tsx src/modules/mcp/policy.test.ts && tsx src/modules/mcp/events-batch-model.test.ts && tsx src/modules/mcp/operation-revisions.test.ts && tsx src/modules/mcp/native-create-identity.test.ts && tsx src/modules/mcp/ros-step-verification.test.ts && tsx src/modules/mcp/ros-prefix.test.ts && tsx src/modules/mcp/native-conservation-http.test.ts && tsx src/modules/mcp/security.test.ts && tsx src/modules/mcp/state-file-publish.test.ts && tsx src/modules/mcp/routing-model.test.ts && tsx src/modules/mcp/routing-observations-model.test.ts && tsx src/modules/mcp/ospf-health.test.ts && tsx src/modules/mcp/ecmp-preflight.test.ts && tsx src/modules/mcp/recursive-recovery.test.ts && tsx src/modules/mcp/control-path-model.test.ts && tsx src/modules/mcp/routing-community-scoped-model.test.ts && tsx src/modules/mcp/routing-community-scoped-native.test.ts && tsx src/modules/mcp/path-verification.test.ts && tsx src/modules/mcp/database-maintenance.test.ts && tsx src/routes/mcp.test.ts && tsx ../lib/auth-mcp.test.ts && tsx --test src/modules/mcp/routing-controller-fence.test.ts && tsx src/test/chr-vrf-companion-proof.test.ts && tsx src/test/chr-vrf-companion-telemetry.test.ts && tsx src/test/chr-vrf-companion-address-space.test.ts && tsx src/modules/mcp/flow-facts-rebuild-actions.test.ts && tsx src/modules/mcp/ipsec-peer-create-dependencies.test.ts && tsx src/services/ipsec-certificate-update-model.test.ts && tsx src/modules/mcp/ipsec-native-dispatch.test.ts && tsx src/modules/mcp/ipsec-user-update-actions.test.ts && tsx src/modules/mcp/ipsec-server-delete-actions.test.ts && tsx src/services/ipsec-proof-telemetry.test.ts && tsx src/services/vrf-production-admission.test.ts && tsx src/modules/mcp/flow-host-keys-actions.test.ts && tsx src/services/vrf-production-read.test.ts && tsx src/modules/mcp/ipsec-user-create-actions.test.ts && tsx src/modules/mcp/flow-settings-actions.test.ts && tsx src/services/vrf-production-ticket-store.test.ts && npm run test:mcp:routing-baseline",
|
||||||
"test:wireguard": "tsx src/services/mikrotik-response-bounds.test.ts && tsx src/services/wireguard-interface-update.test.ts && tsx src/services/wireguard-peer-update.test.ts && npx tsx src/services/wireguard-config.test.ts && npx tsx src/services/wireguard-ipv6.test.ts && npx tsx --test src/services/wireguard-import.test.ts src/services/wireguard-import-apply.test.ts src/services/wireguard-import-revision.test.ts src/services/wireguard-delete.test.ts && npx tsx src/routes/wireguard-import.test.ts && tsx src/services/wireguard-interface-create.test.ts && tsx src/services/wireguard-peer-create.test.ts && tsx src/services/wireguard-import-plan.test.ts && tsx src/services/wireguard-import-execution.test.ts && tsx src/modules/mcp/wireguard-import-actions.test.ts && tsx --test src/services/wireguard-export.test.ts",
|
"test:wireguard": "tsx src/services/mikrotik-response-bounds.test.ts && tsx --test src/services/wireguard-dependency-read.test.ts && tsx src/services/wireguard-interface-update.test.ts && tsx src/services/wireguard-peer-update.test.ts && npx tsx src/services/wireguard-config.test.ts && npx tsx src/services/wireguard-ipv6.test.ts && npx tsx --test src/services/wireguard-import.test.ts src/services/wireguard-import-apply.test.ts src/services/wireguard-import-revision.test.ts src/services/wireguard-delete.test.ts && npx tsx src/routes/wireguard-import.test.ts && tsx src/services/wireguard-interface-create.test.ts && tsx src/services/wireguard-peer-create.test.ts && tsx src/services/wireguard-import-plan.test.ts && tsx src/services/wireguard-import-execution.test.ts && tsx src/modules/mcp/wireguard-import-actions.test.ts && tsx --test src/services/wireguard-export.test.ts",
|
||||||
"test:traffic-rate": "tsx src/services/traffic-rate.test.ts && tsx src/services/traffic-observation.test.ts && tsx src/services/traffic-freshness.test.ts && tsx src/services/traffic-live.test.ts && tsx src/services/traffic-observation-sql.test.ts && tsx src/services/traffic-history-sql.test.ts && tsx src/services/traffic-live-sql.test.ts && tsx src/services/traffic-volume-sql.test.ts && tsx src/services/traffic-binding-status.test.ts && tsx src/services/traffic-status-sql.test.ts && tsx src/services/traffic-gap.test.ts && tsx src/services/traffic-identity.test.ts && tsx src/services/traffic-membership.test.ts && tsx src/services/traffic-aggregate-peak.test.ts && tsx src/services/traffic-users-cache.test.ts && tsx src/services/traffic-collection-lock.test.ts && tsx src/services/traffic-concurrency-sql.test.ts",
|
"test:traffic-rate": "tsx src/services/traffic-rate.test.ts && tsx src/services/traffic-observation.test.ts && tsx src/services/traffic-freshness.test.ts && tsx src/services/traffic-live.test.ts && tsx src/services/traffic-observation-sql.test.ts && tsx src/services/traffic-history-sql.test.ts && tsx src/services/traffic-live-sql.test.ts && tsx src/services/traffic-volume-sql.test.ts && tsx src/services/traffic-binding-status.test.ts && tsx src/services/traffic-status-sql.test.ts && tsx src/services/traffic-gap.test.ts && tsx src/services/traffic-identity.test.ts && tsx src/services/traffic-membership.test.ts && tsx src/services/traffic-aggregate-peak.test.ts && tsx src/services/traffic-users-cache.test.ts && tsx src/services/traffic-collection-lock.test.ts && tsx src/services/traffic-concurrency-sql.test.ts",
|
||||||
"test:traffic-flow": "tsx --test src/services/traffic-flow-heartbeat.test.ts src/services/traffic-flow-heartbeat-wiring.test.ts src/services/traffic-flow-heartbeat-receiver.test.ts src/services/traffic-flow-ingest-quality.test.ts src/services/traffic-flow-quality-contract.test.ts && tsx src/services/traffic-flow-catalog-read.test.ts && tsx src/routes/traffic-flow-query.test.ts && tsx src/services/traffic-flow-month.test.ts && tsx src/services/traffic-flow-parse.test.ts && tsx src/services/traffic-flow-ipv6-pipeline.test.ts && tsx src/services/traffic-flow-map-exporter.test.ts && tsx src/services/traffic-flow-ifaces.test.ts && tsx src/services/traffic-flow-ifindex.test.ts && tsx src/services/traffic-flow-dedup.test.ts && tsx src/services/traffic-flow-planes.test.ts && tsx src/services/traffic-flow-ip.test.ts && tsx src/services/traffic-flow-dest.test.ts && tsx src/services/traffic-flow-classify.test.ts && tsx src/services/traffic-flow-ripe.test.ts && tsx src/services/traffic-flow-brands.test.ts && tsx src/services/traffic-flow-ingest.test.ts && tsx src/services/traffic-flow-analytics.test.ts && tsx src/services/traffic-flow-map-hops.test.ts && tsx src/services/traffic-flow-purge.test.ts && tsx src/services/geoip-download.test.ts && tsx src/services/traffic-flow-geoip.test.ts && tsx src/services/traffic-flow-fact-accumulator.test.ts && tsx src/services/traffic-flow-facts.test.ts && tsx src/services/traffic-flow-facts-filter.test.ts && tsx src/services/traffic-flow-facts-rebuild.test.ts && tsx src/services/statistics-aggregate.test.ts && tsx src/services/geoip-manual-update.test.ts && tsx src/services/traffic-flow-overlay-model.test.ts && npm run test:traffic-flow:ownership && tsx src/services/traffic-flow-worker-platform.test.ts && tsx src/services/traffic-flow-template-cache.test.ts && tsx --test src/services/traffic-flow-template-lifetime.test.ts && tsx src/services/traffic-flow-protocol-scope.test.ts && npm run test:traffic-flow:read-budget && npm run test:traffic-flow:resource-profile",
|
"test:traffic-flow": "tsx --test src/services/traffic-flow-heartbeat.test.ts src/services/traffic-flow-heartbeat-wiring.test.ts src/services/traffic-flow-heartbeat-receiver.test.ts src/services/traffic-flow-ingest-quality.test.ts src/services/traffic-flow-quality-contract.test.ts && tsx src/services/traffic-flow-catalog-read.test.ts && tsx src/routes/traffic-flow-query.test.ts && tsx src/services/traffic-flow-month.test.ts && tsx src/services/traffic-flow-parse.test.ts && tsx src/services/traffic-flow-ipv6-pipeline.test.ts && tsx src/services/traffic-flow-map-exporter.test.ts && tsx src/services/traffic-flow-ifaces.test.ts && tsx src/services/traffic-flow-ifindex.test.ts && tsx src/services/traffic-flow-dedup.test.ts && tsx src/services/traffic-flow-planes.test.ts && tsx src/services/traffic-flow-ip.test.ts && tsx src/services/traffic-flow-dest.test.ts && tsx src/services/traffic-flow-classify.test.ts && tsx src/services/traffic-flow-ripe.test.ts && tsx src/services/traffic-flow-brands.test.ts && tsx src/services/traffic-flow-ingest.test.ts && tsx src/services/traffic-flow-analytics.test.ts && tsx src/services/traffic-flow-map-hops.test.ts && tsx src/services/traffic-flow-purge.test.ts && tsx src/services/geoip-download.test.ts && tsx src/services/traffic-flow-geoip.test.ts && tsx src/services/traffic-flow-fact-accumulator.test.ts && tsx src/services/traffic-flow-facts.test.ts && tsx src/services/traffic-flow-facts-filter.test.ts && tsx src/services/traffic-flow-facts-rebuild.test.ts && tsx src/services/statistics-aggregate.test.ts && tsx src/services/geoip-manual-update.test.ts && tsx src/services/traffic-flow-overlay-model.test.ts && npm run test:traffic-flow:ownership && tsx src/services/traffic-flow-worker-platform.test.ts && tsx src/services/traffic-flow-template-cache.test.ts && tsx --test src/services/traffic-flow-template-lifetime.test.ts && tsx src/services/traffic-flow-protocol-scope.test.ts && npm run test:traffic-flow:read-budget && npm run test:traffic-flow:resource-profile",
|
||||||
"test:users": "tsx ../packages/contracts/src/users.test.ts && tsx src/modules/users/iface-type.test.ts && tsx src/modules/users/bindings.test.ts && tsx src/modules/users/interface-inventory.test.ts && tsx src/routes/users-catalog.test.ts",
|
"test:users": "tsx ../packages/contracts/src/users.test.ts && tsx src/modules/users/iface-type.test.ts && tsx src/modules/users/bindings.test.ts && tsx src/modules/users/interface-inventory.test.ts && tsx src/routes/users-catalog.test.ts",
|
||||||
@@ -54,7 +55,7 @@
|
|||||||
"test:ospf-optimizer": "tsx src/services/ospf-cost-optimization.test.ts && tsx src/modules/mcp/ospf-legacy-source.test.ts && tsx src/services/ospf-legacy-optimization.test.ts && tsx src/services/ospf-template-flags.test.ts && tsx src/services/ospf-route-proof.test.ts && tsx src/services/ospf-link-catalog.test.ts && tsx src/services/ospf-identity-catalog.test.ts && tsx src/services/ospf-template-cost-write.test.ts && tsx src/services/ospf-template-cost-http.test.ts && tsx src/services/ospf-optimizer-identity.test.ts && tsx src/services/ospf-optimizer-metrics.test.ts && tsx src/services/ospf-optimizer-plan.test.ts && tsx src/services/ospf-effective-disabled.test.ts && tsx src/routes/ospf-optimizer.test.ts",
|
"test:ospf-optimizer": "tsx src/services/ospf-cost-optimization.test.ts && tsx src/modules/mcp/ospf-legacy-source.test.ts && tsx src/services/ospf-legacy-optimization.test.ts && tsx src/services/ospf-template-flags.test.ts && tsx src/services/ospf-route-proof.test.ts && tsx src/services/ospf-link-catalog.test.ts && tsx src/services/ospf-identity-catalog.test.ts && tsx src/services/ospf-template-cost-write.test.ts && tsx src/services/ospf-template-cost-http.test.ts && tsx src/services/ospf-optimizer-identity.test.ts && tsx src/services/ospf-optimizer-metrics.test.ts && tsx src/services/ospf-optimizer-plan.test.ts && tsx src/services/ospf-effective-disabled.test.ts && tsx src/routes/ospf-optimizer.test.ts",
|
||||||
"test:containers": "tsx src/services/containers-live.test.ts && tsx src/services/containers-state.test.ts && tsx src/services/container-removal.test.ts && tsx src/routes/containers-state.test.ts",
|
"test:containers": "tsx src/services/containers-live.test.ts && tsx src/services/containers-state.test.ts && tsx src/services/container-removal.test.ts && tsx src/routes/containers-state.test.ts",
|
||||||
"test:certificates": "tsx src/services/certificate-installation.test.ts && tsx src/services/certificate-renew-collector.test.ts && tsx src/services/certificate-issue-execution.test.ts && tsx src/services/certificate-issue-request.test.ts && tsx src/routes/certificates-issue.test.ts && tsx src/services/certificate-issue-dto.test.ts && tsx src/services/certificate-parse.test.ts && tsx src/services/certificates-state.test.ts && tsx src/routes/certificates-state.test.ts && tsx src/services/acme-settings-test.test.ts && tsx src/services/acme-settings-update-model.test.ts && tsx src/modules/mcp/acme-settings-actions.test.ts",
|
"test:certificates": "tsx src/services/certificate-installation.test.ts && tsx src/services/certificate-renew-collector.test.ts && tsx src/services/certificate-issue-execution.test.ts && tsx src/services/certificate-issue-request.test.ts && tsx src/routes/certificates-issue.test.ts && tsx src/services/certificate-issue-dto.test.ts && tsx src/services/certificate-parse.test.ts && tsx src/services/certificates-state.test.ts && tsx src/routes/certificates-state.test.ts && tsx src/services/acme-settings-test.test.ts && tsx src/services/acme-settings-update-model.test.ts && tsx src/modules/mcp/acme-settings-actions.test.ts",
|
||||||
"test:system-database": "tsx src/services/bounded-database-dump.test.ts && tsx src/services/database-dump-connection.test.ts && tsx src/services/system-database-partition-restore.test.ts && tsx src/services/system-database-cache.test.ts && tsx src/services/database-background-work.test.ts && tsx src/services/database-request-gate.test.ts && tsx src/services/traffic-flow-worker-shutdown.test.ts && tsx src/services/system-database-restore-execution.test.ts && tsx src/services/system-database-operation.test.ts && tsx src/services/system-database-schema-readiness.test.ts && tsx src/routes/system-database.test.ts && tsx src/plugins/auth.system-database.test.ts && tsx src/services/database-control-archive-boundary.test.ts && tsx src/db/database-schema-control-model.test.ts && tsx src/db/database-schema-control.test.ts && tsx src/db/controlled-migrations.test.ts && tsx src/db/controlled-schema-verification.test.ts && tsx --test src/db/migration-data-inventory.test.ts",
|
"test:system-database": "tsx src/services/bounded-database-dump.test.ts && tsx src/services/database-dump-connection.test.ts && tsx src/services/system-database-partition-restore.test.ts && tsx src/services/system-database-cache.test.ts && tsx src/services/database-background-work.test.ts && tsx src/services/database-request-gate.test.ts && tsx --test src/services/detached-database-admission.test.ts && tsx src/services/traffic-flow-worker-shutdown.test.ts && tsx src/services/system-database-restore-execution.test.ts && tsx src/services/system-database-operation.test.ts && tsx src/services/system-database-schema-readiness.test.ts && tsx --test src/routes/mcp-oauth-database-admission.test.ts && tsx src/routes/system-database.test.ts && tsx src/plugins/auth.system-database.test.ts && tsx src/services/database-control-archive-boundary.test.ts && tsx src/db/database-schema-control-model.test.ts && tsx src/db/database-schema-control.test.ts && tsx src/db/controlled-migrations.test.ts && tsx src/db/controlled-schema-verification.test.ts && tsx --test src/db/migration-data-inventory.test.ts && tsx --test src/services/database-maintenance-startup.test.ts && tsx --test src/services/database-restore-audit.test.ts && tsx --test src/services/database-maintenance-resolution.test.ts && tsx --test scripts/database-maintenance-resolution.test.mts",
|
||||||
"test:all": "npm run test:logs && npm run test:probes && npm run test:exec && npm run test:alert-engine && npm run test:alerts-telegram && npm run test:auth && npm run test:wireguard && npm run test:ipsec && npm run test:traffic-rate && npm run test:traffic-flow && npm run test:users && npm run test:pg && npm run test:sidebar-counts && npm run test:backups && npm run test:scheduler && npm run test:live-maps && npm run test:ospf-optimizer && npm run test:recursive-routes && npm run test:config-sync && npm run test:ros-file && npm run test:containers && npm run test:certificates && npm run test:netflow-settings && npm run test:netflow-purge && npm run test:evolution && npm run test:system-database && npm run test:server-privacy && npm run test:mcp:module-completion && npm run test:mcp:loopback-agent && npm run test:server-probe-management && npm run test:server-poll-management && npm run test:mcp:routing-authority && npm run test:mcp:native-readers && npm run test:mcp:chr-acquisition && npm run test:mcp:shared-context && npm run test:mcp:telemetry-configuration",
|
"test:all": "npm run test:logs && npm run test:probes && npm run test:exec && npm run test:alert-engine && npm run test:alerts-telegram && npm run test:auth && npm run test:wireguard && npm run test:ipsec && npm run test:traffic-rate && npm run test:traffic-flow && npm run test:users && npm run test:pg && npm run test:sidebar-counts && npm run test:backups && npm run test:scheduler && npm run test:live-maps && npm run test:ospf-optimizer && npm run test:recursive-routes && npm run test:config-sync && npm run test:ros-file && npm run test:containers && npm run test:certificates && npm run test:netflow-settings && npm run test:netflow-purge && npm run test:evolution && npm run test:system-database && npm run test:server-privacy && npm run test:mcp:module-completion && npm run test:mcp:loopback-agent && npm run test:server-probe-management && npm run test:server-poll-management && npm run test:mcp:routing-authority && npm run test:mcp:native-readers && npm run test:mcp:chr-acquisition && npm run test:mcp:shared-context && npm run test:mcp:telemetry-configuration",
|
||||||
"test:server-privacy": "tsx ../packages/contracts/src/servers.test.ts && tsx src/routes/server-secrets-sql.test.ts && tsx --test src/services/server-connection-test.test.ts",
|
"test:server-privacy": "tsx ../packages/contracts/src/servers.test.ts && tsx src/routes/server-secrets-sql.test.ts && tsx --test src/services/server-connection-test.test.ts",
|
||||||
"test:mcp:flow-rebuild:pg": "node scripts/test-mcp-postgres.mjs --flow-rebuild-mcp",
|
"test:mcp:flow-rebuild:pg": "node scripts/test-mcp-postgres.mjs --flow-rebuild-mcp",
|
||||||
@@ -65,7 +66,7 @@
|
|||||||
"test:mcp:ipsec-export-journal:pg": "node scripts/test-mcp-postgres.mjs --ipsec-export-journal --restore",
|
"test:mcp:ipsec-export-journal:pg": "node scripts/test-mcp-postgres.mjs --ipsec-export-journal --restore",
|
||||||
"test:mcp:ipsec-export:http:pg": "node scripts/test-mcp-postgres.mjs --ipsec-export-http --restore",
|
"test:mcp:ipsec-export:http:pg": "node scripts/test-mcp-postgres.mjs --ipsec-export-http --restore",
|
||||||
"test:mcp:diagnostic-cancel-host:pg": "node scripts/test-mcp-postgres.mjs --diagnostic-cancel-host --restore",
|
"test:mcp:diagnostic-cancel-host:pg": "node scripts/test-mcp-postgres.mjs --diagnostic-cancel-host --restore",
|
||||||
"test:server-probe-management": "tsx src/services/server-probe-authority.test.ts && tsx src/services/server-probe-public-result.test.ts && tsx src/services/server-probe-execution.test.ts && tsx src/services/server-probe-rest-contract.test.ts && tsx src/services/server-probe-rest-execution.test.ts && tsx src/services/server-probe-journal.test.ts && tsx src/services/server-probe-journal-history.test.ts && tsx src/modules/mcp/server-probe-actions.test.ts && tsx src/modules/mcp/diagnostic-cancellation.test.ts && tsx src/modules/mcp/service-diagnostic-progress.test.ts && tsx src/services/mikrotik-get-admission.test.ts && tsx src/services/server-probe-storage-journal-adapter.test.ts && tsx --test src/services/server-probe-storage-history.test.ts && npm run test:server-probe-storage-models",
|
"test:server-probe-management": "tsx src/services/server-probe-authority.test.ts && tsx src/services/server-probe-public-result.test.ts && tsx src/services/server-probe-execution.test.ts && tsx src/services/server-probe-rest-contract.test.ts && tsx src/services/server-probe-rest-execution.test.ts && tsx src/services/server-probe-journal.test.ts && tsx src/services/server-probe-journal-history.test.ts && tsx src/modules/mcp/server-probe-actions.test.ts && tsx src/modules/mcp/diagnostic-cancellation.test.ts && tsx src/modules/mcp/service-diagnostic-progress.test.ts && tsx src/services/mikrotik-get-admission.test.ts && tsx src/services/server-probe-storage-journal-adapter.test.ts && npm run test:server-probe-history && npm run test:server-probe-storage-models",
|
||||||
"test:server-poll-http:pg": "node scripts/test-server-poll-http-owned.mjs",
|
"test:server-poll-http:pg": "node scripts/test-server-poll-http-owned.mjs",
|
||||||
"test:server-poll-approval:pg": "node scripts/test-server-poll-approval-owned.mjs",
|
"test:server-poll-approval:pg": "node scripts/test-server-poll-approval-owned.mjs",
|
||||||
"test:server-poll-atomic:pg": "node scripts/test-server-poll-atomic-owned.mjs",
|
"test:server-poll-atomic:pg": "node scripts/test-server-poll-atomic-owned.mjs",
|
||||||
@@ -111,6 +112,7 @@
|
|||||||
"test:mcp:routing-schema:pg": "node scripts/test-mcp-postgres.mjs --routing-reservation-schema",
|
"test:mcp:routing-schema:pg": "node scripts/test-mcp-postgres.mjs --routing-reservation-schema",
|
||||||
"test:mcp:reserved-step": "tsx --test src/modules/mcp/routing-reserved-step.test.ts",
|
"test:mcp:reserved-step": "tsx --test src/modules/mcp/routing-reserved-step.test.ts",
|
||||||
"test:mcp:routing-forward-rounds:pg": "node scripts/test-mcp-postgres.mjs --routing-network-forward-rounds",
|
"test:mcp:routing-forward-rounds:pg": "node scripts/test-mcp-postgres.mjs --routing-network-forward-rounds",
|
||||||
|
"test:mcp:routing-original-effects:pg": "node scripts/test-mcp-postgres.mjs --routing-original-effects",
|
||||||
"test:flow-active-services:pg": "node scripts/test-mcp-postgres.mjs --flow-active-services",
|
"test:flow-active-services:pg": "node scripts/test-mcp-postgres.mjs --flow-active-services",
|
||||||
"test:flow-active-rebuild:pg": "node scripts/test-mcp-postgres.mjs --flow-active-rebuild",
|
"test:flow-active-rebuild:pg": "node scripts/test-mcp-postgres.mjs --flow-active-rebuild",
|
||||||
"test:flow-worker-ipc:pg": "node scripts/test-mcp-postgres.mjs --flow-worker-ipc",
|
"test:flow-worker-ipc:pg": "node scripts/test-mcp-postgres.mjs --flow-worker-ipc",
|
||||||
@@ -140,7 +142,7 @@
|
|||||||
"test:server-poll-storage-fresh:pg": "node scripts/test-mcp-postgres.mjs --server-poll-storage-fresh --restore",
|
"test:server-poll-storage-fresh:pg": "node scripts/test-mcp-postgres.mjs --server-poll-storage-fresh --restore",
|
||||||
"test:partition-writers": "tsx src/services/partition-write-transaction.test.ts && tsx src/services/collector-partition-writes.test.ts && tsx src/db/partitions-writer.test.ts && tsx src/db/migrate-partition-admission.test.ts && tsx src/db/migration-manifest.test.ts && tsx src/db/sqlite-import-partition-admission.test.ts && tsx src/services/system-database-partition-restore.test.ts && tsx src/services/traffic-flow-write-transaction.test.ts && tsx src/services/traffic-flow-facts-rebuild-cli.test.ts",
|
"test:partition-writers": "tsx src/services/partition-write-transaction.test.ts && tsx src/services/collector-partition-writes.test.ts && tsx src/db/partitions-writer.test.ts && tsx src/db/migrate-partition-admission.test.ts && tsx src/db/migration-manifest.test.ts && tsx src/db/sqlite-import-partition-admission.test.ts && tsx src/services/system-database-partition-restore.test.ts && tsx src/services/traffic-flow-write-transaction.test.ts && tsx src/services/traffic-flow-facts-rebuild-cli.test.ts",
|
||||||
"test:mcp:routing-authority": "tsx --test src/modules/mcp/routing-switch-ledger-contract.test.ts && tsx src/modules/mcp/routing-profile-authority.test.ts && tsx src/modules/mcp/routing-recovery-authority.test.ts && tsx src/modules/mcp/routing-reservation.test.ts && tsx src/modules/mcp/routing-community-reserved-source.test.ts && tsx src/modules/mcp/routing-network-verifier-model.test.ts && tsx --test src/test/committed-scoped-checkpoint-observer.test.ts && node --test src/modules/mcp/routing-forward-transport-charge.test.cjs",
|
"test:mcp:routing-authority": "tsx --test src/modules/mcp/routing-switch-ledger-contract.test.ts && tsx src/modules/mcp/routing-profile-authority.test.ts && tsx src/modules/mcp/routing-recovery-authority.test.ts && tsx src/modules/mcp/routing-reservation.test.ts && tsx src/modules/mcp/routing-community-reserved-source.test.ts && tsx src/modules/mcp/routing-network-verifier-model.test.ts && tsx --test src/test/committed-scoped-checkpoint-observer.test.ts && node --test src/modules/mcp/routing-forward-transport-charge.test.cjs",
|
||||||
"test:mcp:native-readers": "tsx src/modules/mcp/routing-network-verifier-model.test.ts && tsx src/modules/mcp/routing-network-native-parser.test.ts && tsx src/modules/mcp/routing-network-native-session.test.ts && tsx src/db/migration-schema-inventory.test.ts && tsx src/modules/mcp/routing-network-observation-assembly.test.ts && tsx src/modules/mcp/routing-network-configuration-bridge.test.ts",
|
"test:mcp:native-readers": "tsx src/modules/mcp/routing-network-verifier-model.test.ts && tsx src/modules/mcp/routing-network-native-parser.test.ts && tsx src/modules/mcp/routing-network-native-session.test.ts && tsx src/db/migration-schema-inventory.test.ts && tsx src/modules/mcp/routing-network-observation-assembly.test.ts && tsx src/modules/mcp/routing-network-configuration-bridge.test.ts && tsx --test src/services/routing-table-fib.test.ts src/services/routing-topology-observation.test.ts && node --test src/modules/mcp/routing-original-effects.test.mjs src/modules/mcp/routing-original-effects-caller.test.mjs src/modules/mcp/routing-original-effects-controller.test.mjs src/modules/mcp/routing-network-execution-origin.test.mjs",
|
||||||
"test:mcp:native-readers:pg": "node scripts/test-mcp-postgres.mjs --routing-network-native --restore",
|
"test:mcp:native-readers:pg": "node scripts/test-mcp-postgres.mjs --routing-network-native --restore",
|
||||||
"test:mcp:schema-inventory:pg": "node scripts/test-mcp-postgres.mjs --migration-schema-inventory --restore",
|
"test:mcp:schema-inventory:pg": "node scripts/test-mcp-postgres.mjs --migration-schema-inventory --restore",
|
||||||
"test:mcp:native-tls": "tsx src/modules/mcp/routing-network-native-tls.test.ts",
|
"test:mcp:native-tls": "tsx src/modules/mcp/routing-network-native-tls.test.ts",
|
||||||
@@ -153,8 +155,9 @@
|
|||||||
"test:server-poll-durable:pg": "node scripts/test-mcp-postgres.mjs --server-poll-durable --restore",
|
"test:server-poll-durable:pg": "node scripts/test-mcp-postgres.mjs --server-poll-durable --restore",
|
||||||
"test:server-probe-legacy:pg": "node scripts/test-mcp-postgres.mjs --probe-legacy-orphans",
|
"test:server-probe-legacy:pg": "node scripts/test-mcp-postgres.mjs --probe-legacy-orphans",
|
||||||
"test:server-probe-storage:pg": "node scripts/test-mcp-postgres.mjs --probe-storage-journal",
|
"test:server-probe-storage:pg": "node scripts/test-mcp-postgres.mjs --probe-storage-journal",
|
||||||
"test:server-probe-history": "tsx --test src/services/server-probe-storage-history.test.ts",
|
"test:server-probe-history": "tsx --test src/services/server-probe-storage-history.test.ts src/services/server-probe-retired-source.test.ts",
|
||||||
"test:server-probe-history:pg": "node scripts/test-mcp-postgres.mjs --probe-storage-history",
|
"test:server-probe-history:pg": "node scripts/test-mcp-postgres.mjs --probe-storage-history",
|
||||||
|
"test:server-probe-retirement:pg": "node scripts/test-mcp-postgres.mjs --probe-operation-retirement",
|
||||||
"test:server-probe-storage-models": "tsx src/services/server-probe-journal-maintenance.test.ts && tsx src/modules/mcp/diagnostic-cancellation-source.test.ts && tsx src/services/server-probe-storage-source.test.ts && tsx src/services/server-probe-storage-connection.test.ts && tsx src/modules/mcp/server-probe-storage-host.test.ts && tsx src/services/server-probe-storage-rest.test.ts && tsx src/services/server-probe-storage-metadata.test.ts && tsx src/services/server-probe-legacy-verifier.test.ts && tsx src/services/server-probe-legacy-snapshot.test.ts && tsx src/services/server-probe-legacy-inspection.test.ts && tsx src/db/database-source-observation.test.ts && tsx src/services/server-probe-retention.test.ts && tsx src/modules/mcp/server-probe-registration.test.ts",
|
"test:server-probe-storage-models": "tsx src/services/server-probe-journal-maintenance.test.ts && tsx src/modules/mcp/diagnostic-cancellation-source.test.ts && tsx src/services/server-probe-storage-source.test.ts && tsx src/services/server-probe-storage-connection.test.ts && tsx src/modules/mcp/server-probe-storage-host.test.ts && tsx src/services/server-probe-storage-rest.test.ts && tsx src/services/server-probe-storage-metadata.test.ts && tsx src/services/server-probe-legacy-verifier.test.ts && tsx src/services/server-probe-legacy-snapshot.test.ts && tsx src/services/server-probe-legacy-inspection.test.ts && tsx src/db/database-source-observation.test.ts && tsx src/services/server-probe-retention.test.ts && tsx src/modules/mcp/server-probe-registration.test.ts",
|
||||||
"test:mcp:migration-data:pg": "node scripts/test-mcp-postgres.mjs --migration-data-inventory --restore",
|
"test:mcp:migration-data:pg": "node scripts/test-mcp-postgres.mjs --migration-data-inventory --restore",
|
||||||
"test:mcp:poll-memory:pg": "npm run build -w @mmapp/contracts && node scripts/acceptance/build-poll-memory.mjs && node scripts/test-mcp-postgres.mjs --poll-memory",
|
"test:mcp:poll-memory:pg": "npm run build -w @mmapp/contracts && node scripts/acceptance/build-poll-memory.mjs && node scripts/test-mcp-postgres.mjs --poll-memory",
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { execFileSync } from 'node:child_process'
|
||||||
|
import { randomBytes } from 'node:crypto'
|
||||||
|
import { lstat, mkdtemp, rm } from 'node:fs/promises'
|
||||||
|
import { createServer } from 'node:net'
|
||||||
|
import { tmpdir } from 'node:os'
|
||||||
|
import { isAbsolute, join, resolve } from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
import EmbeddedPostgres from 'embedded-postgres'
|
||||||
|
import pg from 'pg'
|
||||||
|
import { applySqlMigrations } from '../../src/db/migrate.ts'
|
||||||
|
import { MIGRATION_MANIFEST } from '../../src/db/migration-manifest.ts'
|
||||||
|
|
||||||
|
assert.deepEqual(process.argv.slice(2), ['--owned-embedded'])
|
||||||
|
const bin = process.env.MMAPP_PG_CLIENT_BIN
|
||||||
|
assert(bin && isAbsolute(bin) && resolve(bin) === bin, 'Set MMAPP_PG_CLIENT_BIN to the exact local PostgreSQL client directory')
|
||||||
|
const psql = join(bin, process.platform === 'win32' ? 'psql.exe' : 'psql')
|
||||||
|
const executable = await lstat(psql)
|
||||||
|
assert(executable.isFile() && !executable.isSymbolicLink(), 'PostgreSQL client must be a regular file')
|
||||||
|
const clientEnv = Object.fromEntries(Object.entries(process.env).filter(([name]) => !/^PG/i.test(name)))
|
||||||
|
const run = (command, args, options = {}) => execFileSync(command, args, {
|
||||||
|
timeout: 10000, maxBuffer: 16384, encoding: 'utf8', windowsHide: true, ...options,
|
||||||
|
})
|
||||||
|
assert.match(run(psql, ['--version'], { env: clientEnv }), /^psql \(PostgreSQL\) (?:1[6-9]|[2-9][0-9])\./)
|
||||||
|
const witness = fileURLToPath(new URL('../../../tmp/mcp-netflow-linux-topology-20261104/collector-drain-witness.py', import.meta.url))
|
||||||
|
const { IDENTITY_SQL, SQL } = JSON.parse(run('python', ['-I', '-B', '-c',
|
||||||
|
'import json,runpy,sys; m=runpy.run_path(sys.argv[1]); print(json.dumps({k:m[k] for k in ("IDENTITY_SQL","SQL")}))', witness]))
|
||||||
|
assert(typeof SQL === 'string' && typeof IDENTITY_SQL === 'string')
|
||||||
|
|
||||||
|
const directory = await mkdtemp(join(tmpdir(), 'mm-netflow-drain-pg-'))
|
||||||
|
const password = randomBytes(24).toString('hex')
|
||||||
|
const host = '127.0.0.1', database = 'postgres', user = 'mcp_test'
|
||||||
|
const listener = createServer()
|
||||||
|
let embedded, pool, started = false
|
||||||
|
try {
|
||||||
|
await new Promise((done, reject) => { listener.once('error', reject); listener.listen(0, host, done) })
|
||||||
|
const { port } = listener.address()
|
||||||
|
await new Promise((done, reject) => listener.close(error => error ? reject(error) : done()))
|
||||||
|
embedded = new EmbeddedPostgres({
|
||||||
|
databaseDir: join(directory, 'pg'), port, user, password, persistent: true, createPostgresUser: false,
|
||||||
|
initdbFlags: ['--encoding=UTF8'], postgresFlags: ['-h', host, '-c', 'unix_socket_directories='],
|
||||||
|
onLog: () => {}, onError: () => {},
|
||||||
|
})
|
||||||
|
await embedded.initialise()
|
||||||
|
await embedded.start()
|
||||||
|
started = true
|
||||||
|
pool = new pg.Pool({ host, port, database, user, password, max: 1, connectionTimeoutMillis: 5000 })
|
||||||
|
await applySqlMigrations(pool)
|
||||||
|
const { rows: [version] } = await pool.query("SELECT current_setting('server_version_num')::int AS number, (SELECT count(*)::int FROM schema_migrations) AS migrations")
|
||||||
|
assert(version.number >= 180000 && version.number <= 189999)
|
||||||
|
assert.equal(version.migrations, MIGRATION_MANIFEST.length)
|
||||||
|
|
||||||
|
const runId = 'a012bcde', owner = 'netflow-operator-' + runId, collectorPort = 51000
|
||||||
|
const read = (sql, probePort = collectorPort) => JSON.parse(run(psql, ['-X', '-w', '-qAt',
|
||||||
|
'-h', host, '-p', String(port), '-U', user, '-d', database, '-v', 'ON_ERROR_STOP=1',
|
||||||
|
'-v', 'database=' + database, '-v', 'socket_dir=/tmp/mcp-netflow-' + runId + '/pg-socket',
|
||||||
|
'-v', 'run_id=' + runId, '-v', 'port=' + probePort],
|
||||||
|
{ env: { ...clientEnv, PGCONNECT_TIMEOUT: '2', PGPASSWORD: password }, input: sql }).trim())
|
||||||
|
const identity = read(IDENTITY_SQL)
|
||||||
|
assert.deepEqual(Object.keys(identity).sort(), ['oid', 'source'])
|
||||||
|
assert.match(identity.oid, /^[1-9][0-9]{0,9}$/)
|
||||||
|
// The production source predicate deliberately rejects this TCP fixture.
|
||||||
|
assert.equal(identity.source, false)
|
||||||
|
const expect = (planCount, enabled, verified, ordered = true, sqlPort = collectorPort) =>
|
||||||
|
assert.deepEqual(read(SQL, sqlPort), { ...identity, connectionCount: 1, planCount, enabled, verified, ordered })
|
||||||
|
|
||||||
|
const uuid = index => 'a012bcde-0000-4000-8000-' + String(index).padStart(12, '0')
|
||||||
|
const connection = uuid(1)
|
||||||
|
await pool.query(`INSERT INTO mcp_connections(id,name,owner_id,owner_permissions,policy,generation)
|
||||||
|
VALUES($1,$2,$3,'[]','{}','inert-witness')`, [connection, 'Owned NetFlow ' + runId, owner])
|
||||||
|
await pool.query("UPDATE traffic_flow_settings SET collector_ip='198.18.242.3', flow_listen_port=$1, enabled=false WHERE id=1", [collectorPort])
|
||||||
|
expect(0, false, [])
|
||||||
|
|
||||||
|
async function seed(index, enabled) {
|
||||||
|
const plan = uuid(index), operation = uuid(index + 1), receipt = uuid(index + 2)
|
||||||
|
const base = Date.UTC(2026, 0, 1) + index * 1000
|
||||||
|
const at = offset => new Date(base + offset)
|
||||||
|
const result = { receiptId: receipt, configurationConfirmed: true, receiptConfirmed: true,
|
||||||
|
listener: 'request-acknowledged', automaticReplay: false }
|
||||||
|
await pool.query(`INSERT INTO mcp_plans(id,connection_id,operation_id,operation_version,policy_version,generation,payload,evidence,state,expires_at,approved_by,approved_at,created_at)
|
||||||
|
VALUES($1,$2,'netflow.settings.update',3,1,'inert-witness','{}','{}','completed',$3,$4,$5,$6)`,
|
||||||
|
[plan, connection, at(9000), owner, at(100), at(0)])
|
||||||
|
await pool.query(`INSERT INTO mcp_operations(id,plan_id,connection_id,idempotency_key,state,verification,created_at,finished_at)
|
||||||
|
VALUES($1,$2,$3,$4,'applied','passed',$5,$6)`, [operation, plan, connection, 'inert-' + index, at(200), at(400)])
|
||||||
|
await pool.query(`INSERT INTO mcp_operation_steps(operation_id,step_index,state,intended_state,result,created_at,finished_at)
|
||||||
|
VALUES($1,0,'verified',$2,$3,$4,$5)`,
|
||||||
|
[operation, { service: 'netflow.settings.update', input: { receiptId: receipt, enabled } }, result, at(300), at(400)])
|
||||||
|
await pool.query(`INSERT INTO mcp_audit(actor_id,action,connection_id,payload,created_at)
|
||||||
|
VALUES($1,'plan.approve',$2,$3,$4)`, [owner, connection, { planId: plan }, at(100)])
|
||||||
|
await pool.query(`INSERT INTO flow_settings_intents(receipt_id,phase,request_proof,state,result,created_at)
|
||||||
|
VALUES($1,'configuration','inert-proof','completed',$2,$3),($1,'listener','inert-proof','completed',$2,$3)`, [receipt, result, at(300)])
|
||||||
|
await pool.query('UPDATE traffic_flow_settings SET enabled=$1 WHERE id=1', [enabled])
|
||||||
|
return [plan, operation, receipt]
|
||||||
|
}
|
||||||
|
const enabled = await seed(10, true)
|
||||||
|
expect(1, true, [enabled])
|
||||||
|
const disabled = await seed(20, false)
|
||||||
|
expect(2, false, [enabled, disabled])
|
||||||
|
expect(2, null, [enabled, disabled], true, collectorPort + 1)
|
||||||
|
|
||||||
|
await pool.query("UPDATE flow_settings_intents SET request_proof='foreign' WHERE receipt_id=$1 AND phase='listener'", [disabled[2]])
|
||||||
|
expect(2, false, [enabled])
|
||||||
|
await pool.query("UPDATE flow_settings_intents SET request_proof='inert-proof' WHERE receipt_id=$1 AND phase='listener'", [disabled[2]])
|
||||||
|
const { rows: [duplicate] } = await pool.query(`INSERT INTO mcp_audit(actor_id,action,connection_id,payload)
|
||||||
|
VALUES($1,'plan.approve',$2,$3) RETURNING id`, [owner, connection, { planId: disabled[0] }])
|
||||||
|
expect(2, false, [enabled])
|
||||||
|
await pool.query('DELETE FROM mcp_audit WHERE id=$1', [duplicate.id])
|
||||||
|
const { rows: [original] } = await pool.query(`UPDATE mcp_operations SET finished_at=(SELECT created_at + interval '1 second' FROM mcp_plans WHERE id=$2)
|
||||||
|
WHERE id=$1 RETURNING created_at`, [enabled[1], disabled[0]])
|
||||||
|
expect(2, false, [enabled, disabled], false)
|
||||||
|
await pool.query("UPDATE mcp_operations SET finished_at=$2::timestamptz + interval '200 milliseconds' WHERE id=$1", [enabled[1], original.created_at])
|
||||||
|
expect(2, false, [enabled, disabled])
|
||||||
|
console.log(JSON.stringify({ migrations: version.migrations, exactPsqlQueries: true, inertEnableDisableAndRejections: true,
|
||||||
|
linuxSocketSourceAccepted: false, realMcpExecutionClaimed: false }))
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
await pool?.end()
|
||||||
|
} finally {
|
||||||
|
if (started) await embedded.stop()
|
||||||
|
if (listener.listening) await new Promise(done => listener.close(done))
|
||||||
|
await rm(directory, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,14 +7,9 @@ import path from 'node:path'
|
|||||||
import {fileURLToPath,pathToFileURL} from 'node:url'
|
import {fileURLToPath,pathToFileURL} from 'node:url'
|
||||||
import ts from 'typescript'
|
import ts from 'typescript'
|
||||||
|
|
||||||
export const OPERATOR_SOURCES=Object.freeze([
|
import {OPERATOR_SOURCES} from './database-operator-sources.mjs'
|
||||||
'database-writer-bootstrap-operator.mjs','database-writer-profile-operator.mts',
|
export {OPERATOR_SOURCES}
|
||||||
'writer-profile/operator-files.mjs','writer-profile/operator-contract.mjs','writer-profile/publisher.mjs','writer-profile/context.mjs',
|
|
||||||
'writer-profile/catalogue.mts','writer-profile/catalogue-value-bridge.ts','writer-profile/catalogue-projection.ts','writer-profile/catalogue-leaves.ts',
|
|
||||||
'writer-profile/application-writer-profile-catalogue.ts','writer-profile/application-writer-profile-catalogue-values.ts',
|
|
||||||
'writer-profile/application-writer-profile-catalogue-parent-policy.ts','writer-profile/application-writer-profile-canonical.ts',
|
|
||||||
'writer-profile/fixed44-build-sql.ts','writer-profile/fixed44-build-definition.ts','writer-profile/expanded44-data-policy.ts',
|
|
||||||
])
|
|
||||||
const emitted=source=>source.replace(/\.mts$/,'.mjs').replace(/\.ts$/,'.js')
|
const emitted=source=>source.replace(/\.mts$/,'.mjs').replace(/\.ts$/,'.js')
|
||||||
const digest=value=>createHash('sha256').update(value).digest('hex')
|
const digest=value=>createHash('sha256').update(value).digest('hex')
|
||||||
const packageName=specifier=>specifier.startsWith('@')?specifier.split('/').slice(0,2).join('/'):specifier.split('/')[0]
|
const packageName=specifier=>specifier.startsWith('@')?specifier.split('/').slice(0,2).join('/'):specifier.split('/')[0]
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {spawnSync} from 'node:child_process'
|
|||||||
import path from 'node:path'
|
import path from 'node:path'
|
||||||
import {fileURLToPath,pathToFileURL} from 'node:url'
|
import {fileURLToPath,pathToFileURL} from 'node:url'
|
||||||
import {buildDatabaseOperator,compileOperatorModule,moduleSpecifiers,OPERATOR_SOURCES} from './build-database-operator.mjs'
|
import {buildDatabaseOperator,compileOperatorModule,moduleSpecifiers,OPERATOR_SOURCES} from './build-database-operator.mjs'
|
||||||
|
import {stageDatabaseOperatorScripts} from './stage-database-operator.mjs'
|
||||||
|
|
||||||
const backend=process.env.OPERATOR_BUILD_TEST_BACKEND??path.resolve(path.dirname(fileURLToPath(import.meta.url)),'..')
|
const backend=process.env.OPERATOR_BUILD_TEST_BACKEND??path.resolve(path.dirname(fileURLToPath(import.meta.url)),'..')
|
||||||
test('AST rewrite changes static/export/dynamic module specifiers and preserves SQL/string values',()=>{
|
test('AST rewrite changes static/export/dynamic module specifiers and preserves SQL/string values',()=>{
|
||||||
@@ -19,7 +20,7 @@ test('fixed artifact is reproducible, resolves the compiled backend graph, and r
|
|||||||
const packageJson=JSON.parse(await readFile(path.join(backend,'package.json'),'utf8'))
|
const packageJson=JSON.parse(await readFile(path.join(backend,'package.json'),'utf8'))
|
||||||
await mkdir(projectBackend,{recursive:true});await writeFile(path.join(projectBackend,'package.json'),JSON.stringify({type:'module',dependencies:packageJson.dependencies}))
|
await mkdir(projectBackend,{recursive:true});await writeFile(path.join(projectBackend,'package.json'),JSON.stringify({type:'module',dependencies:packageJson.dependencies}))
|
||||||
const first=await buildDatabaseOperator({backendDirectory:backend,outputDirectory:output}),second=await buildDatabaseOperator({backendDirectory:backend,outputDirectory:output})
|
const first=await buildDatabaseOperator({backendDirectory:backend,outputDirectory:output}),second=await buildDatabaseOperator({backendDirectory:backend,outputDirectory:output})
|
||||||
assert.deepEqual(first,second);assert.equal(first.modules.length,OPERATOR_SOURCES.length);assert.equal(first.modules.length,17);assert.deepEqual(first.runtimeDependencies,['dotenv','pg','zod']);assert.equal(first.backendDistModules.length,9)
|
assert.deepEqual(first,second);assert.equal(first.modules.length,OPERATOR_SOURCES.length);assert.equal(first.modules.length,19);assert.deepEqual(first.runtimeDependencies,['dotenv','pg','zod']);assert.equal(first.backendDistModules.length,12)
|
||||||
const copied=new Set()
|
const copied=new Set()
|
||||||
async function copyDist(relative){
|
async function copyDist(relative){
|
||||||
if(copied.has(relative))return;copied.add(relative)
|
if(copied.has(relative))return;copied.add(relative)
|
||||||
@@ -34,10 +35,11 @@ test('fixed artifact is reproducible, resolves the compiled backend graph, and r
|
|||||||
for(const relative of first.backendDistImports)await copyDist(relative)
|
for(const relative of first.backendDistImports)await copyDist(relative)
|
||||||
assert.deepEqual([...copied].sort(),first.backendDistModules.map(item=>item.file).sort())
|
assert.deepEqual([...copied].sort(),first.backendDistModules.map(item=>item.file).sort())
|
||||||
// Exercise the real build entry point with its default backend/output paths.
|
// Exercise the real build entry point with its default backend/output paths.
|
||||||
for(const file of OPERATOR_SOURCES){const target=path.join(projectBackend,'scripts',file);await mkdir(path.dirname(target),{recursive:true});await copyFile(path.join(backend,'scripts',file),target)}
|
const scripts=path.join(projectBackend,'scripts')
|
||||||
const buildScript=path.join(projectBackend,'scripts','build-database-operator.mjs');await copyFile(fileURLToPath(new URL('./build-database-operator.mjs',import.meta.url)),buildScript)
|
assert.equal(await stageDatabaseOperatorScripts(scripts,path.join(backend,'scripts')),21)
|
||||||
|
const buildScript=path.join(scripts,'build-database-operator.mjs')
|
||||||
const built=spawnSync(process.execPath,[buildScript],{cwd:temporary,env:{...process.env,NODE_OPTIONS:''},encoding:'utf8',timeout:30000})
|
const built=spawnSync(process.execPath,[buildScript],{cwd:temporary,env:{...process.env,NODE_OPTIONS:''},encoding:'utf8',timeout:30000})
|
||||||
assert.equal(built.status,0,built.stderr);assert.equal(JSON.parse(built.stdout.trim()).modules,17)
|
assert.equal(built.status,0,built.stderr);assert.equal(JSON.parse(built.stdout.trim()).modules,19)
|
||||||
assert.deepEqual(JSON.parse(await readFile(path.join(output,'build-manifest.json'),'utf8')),first)
|
assert.deepEqual(JSON.parse(await readFile(path.join(output,'build-manifest.json'),'utf8')),first)
|
||||||
const configFile=path.join(projectBackend,'dist','config.js'),originalConfig=await readFile(configFile,'utf8')
|
const configFile=path.join(projectBackend,'dist','config.js'),originalConfig=await readFile(configFile,'utf8')
|
||||||
for(const injected of ["import 'tsx';","import '../src/config.js';","import '../outside.js';"]){
|
for(const injected of ["import 'tsx';","import '../src/config.js';","import '../outside.js';"]){
|
||||||
@@ -59,10 +61,10 @@ test('fixed artifact is reproducible, resolves the compiled backend graph, and r
|
|||||||
const modulesToImport=first.modules.map(item=>path.join(output,item.file))
|
const modulesToImport=first.modules.map(item=>path.join(output,item.file))
|
||||||
const importer=path.join(temporary,'import-all.mjs');await writeFile(importer,`import {pathToFileURL} from 'node:url';for(const file of ${JSON.stringify(modulesToImport)})await import(pathToFileURL(file).href);console.log(JSON.stringify({imported:${modulesToImport.length},readyAuthorized:false}));`)
|
const importer=path.join(temporary,'import-all.mjs');await writeFile(importer,`import {pathToFileURL} from 'node:url';for(const file of ${JSON.stringify(modulesToImport)})await import(pathToFileURL(file).href);console.log(JSON.stringify({imported:${modulesToImport.length},readyAuthorized:false}));`)
|
||||||
const imported=spawnSync(process.execPath,['--import',pathToFileURL(guard).href,importer],{cwd:temporary,env,encoding:'utf8',timeout:30000})
|
const imported=spawnSync(process.execPath,['--import',pathToFileURL(guard).href,importer],{cwd:temporary,env,encoding:'utf8',timeout:30000})
|
||||||
assert.equal(imported.status,0,imported.stderr);assert.equal(JSON.parse(imported.stdout.trim()).imported,17);assert.equal(JSON.parse(await readFile(telemetry,'utf8')).connections,0)
|
assert.equal(imported.status,0,imported.stderr);assert.equal(JSON.parse(imported.stdout.trim()).imported,19);assert.equal(JSON.parse(await readFile(telemetry,'utf8')).connections,0)
|
||||||
const refusals=[]
|
const refusals=[]
|
||||||
for(const [entry,event] of [['database-writer-bootstrap-operator.mjs','application-writer-bootstrap-refused'],['database-writer-profile-operator.mjs','application-writer-profile-publication-refused']]){
|
for(const [entry,event,args] of [['database-writer-bootstrap-operator.mjs','application-writer-bootstrap-refused',['invalid']],['database-writer-profile-operator.mjs','application-writer-profile-publication-refused',['invalid']],['database-writer-profile-operator.mjs','database-maintenance-command-refused',['maintenance-inspect']],['database-writer-profile-operator.mjs','database-maintenance-command-refused',['maintenance-resolve']]]){
|
||||||
const child=spawnSync(process.execPath,['--import',pathToFileURL(guard).href,path.join(output,entry),'invalid'],{cwd:temporary,env,encoding:'utf8',timeout:30000})
|
const child=spawnSync(process.execPath,['--import',pathToFileURL(guard).href,path.join(output,entry),...args],{cwd:temporary,env,encoding:'utf8',timeout:30000})
|
||||||
assert.equal(child.status,1,child.stderr);assert.equal(child.stdout,'');assert.equal(JSON.parse(child.stderr.trim()).event,event);assert.equal(JSON.parse(await readFile(telemetry,'utf8')).connections,0);refusals.push(event)
|
assert.equal(child.status,1,child.stderr);assert.equal(child.stdout,'');assert.equal(JSON.parse(child.stderr.trim()).event,event);assert.equal(JSON.parse(await readFile(telemetry,'utf8')).connections,0);refusals.push(event)
|
||||||
}
|
}
|
||||||
const receipt={node:process.version,projection:temporary,modules:first.modules.length,compiledBackendModules:copied.size,buildCliExitCode:built.status,importedModules:modulesToImport.length,invalidArgumentsRefused:refusals,developmentDependenciesBlocked:denied,networkConnections:0,productionActivated:false,readyAuthorized:false}
|
const receipt={node:process.version,projection:temporary,modules:first.modules.length,compiledBackendModules:copied.size,buildCliExitCode:built.status,importedModules:modulesToImport.length,invalidArgumentsRefused:refusals,developmentDependenciesBlocked:denied,networkConnections:0,productionActivated:false,readyAuthorized:false}
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import test from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import {mkdtemp,mkdir,writeFile,readFile,readdir,rm,unlink,lstat} from 'node:fs/promises'
|
||||||
|
import {createHash,randomUUID} from 'node:crypto'
|
||||||
|
import {rootCertificates} from 'node:tls'
|
||||||
|
import {EventEmitter} from 'node:events'
|
||||||
|
import path from 'node:path'
|
||||||
|
import {fileURLToPath,pathToFileURL} from 'node:url'
|
||||||
|
import {runInNewContext} from 'node:vm'
|
||||||
|
import {createRequire} from 'node:module'
|
||||||
|
import * as strictReader from '../src/services/database-maintenance-startup.js'
|
||||||
|
import * as grammar from '../src/services/database-maintenance-resolution.js'
|
||||||
|
import * as manifest from '../src/db/migration-manifest.js'
|
||||||
|
import * as contract from './writer-profile/operator-contract.mjs'
|
||||||
|
const backend=path.resolve(path.dirname(fileURLToPath(import.meta.url)),'..'),require=createRequire(import.meta.url),ts=require('typescript'),hash=(value:Buffer|string)=>createHash('sha256').update(value).digest('hex')
|
||||||
|
async function own(work:(root:string)=>Promise<void>){const temp=path.resolve(backend,'../tmp');await mkdir(temp,{recursive:true});const root=await mkdtemp(path.join(temp,'owned-resolution-command-'));try{await work(root)}finally{assert.equal(path.dirname(root),temp);assert(path.basename(root).startsWith('owned-resolution-command-'));await rm(root,{recursive:true,force:true})}}
|
||||||
|
async function model(root:string,localHost?:string){
|
||||||
|
let owners=0,sql=0,releaseFailure=false,poolEndFailure=false,published=0,wrongOwner=false,controlSchema=false,observationFailure:Error|undefined
|
||||||
|
const capturedScopes:unknown[]=[]
|
||||||
|
const state=path.join(root,'state');await mkdir(state);await mkdir(path.join(state,'restore-vault'));await writeFile(path.join(state,'access-generation'),Buffer.alloc(32,7));const uncertain=randomUUID(),at=new Date().toISOString(),record=(value:string)=>JSON.stringify({operationId:uncertain,state:value,at})+'\n';await writeFile(path.join(state,'database-maintenance.jsonl'),record('intent')+record('unconfirmed'))
|
||||||
|
const source={systemIdentifier:'1234567890',databaseOid:'12345',databaseName:'managed',serverVersionNum:180004,inRecovery:false}
|
||||||
|
const schema={sha256:'a'.repeat(64),observedRaw:{sha256:'b'.repeat(64)},observedPhysical:{sha256:'c'.repeat(64)},sections:{relations:manifest.CURRENT_REQUIRED_TABLES.map(name=>({namespace:'public',name,kind:'r',persistence:'p',row_security:false,force_row_security:false})),columns:[{namespace:'public',relation:'schema_migrations',name:'id',dropped:false,type:'text'}]}}
|
||||||
|
class Pool {on(){};constructor(options:unknown){const ssl=(options as {ssl:false|{rejectUnauthorized:boolean}}).ssl;if(ssl===false)assert(['127.0.0.1','::1'].includes((options as {host:string}).host));else assert(ssl.rejectUnauthorized)}async connect(){owners++;const client=new EventEmitter();return Object.assign(client,{release:()=>{owners--;if(releaseFailure)throw Error('Synthetic release uncertainty')},query:async(text:string)=>{sql++;if(text.includes('pg_try_advisory_lock'))return {rows:[{locked:true}],command:'SELECT'};if(text.includes('pg_advisory_unlock'))return {rows:[{unlocked:true}],command:'SELECT'};if(text.includes('pg_namespace'))return {rows:controlSchema?[{nspname:'mmapp_control'}]:[],command:'SELECT'};if(text.includes('pg_control_system')){if(observationFailure)throw observationFailure;return {rows:[source],command:'SELECT'}};if(text.startsWith('SELECT id FROM public.schema_migrations'))return {rows:manifest.MIGRATION_MANIFEST.map(pin=>({id:pin.file.slice(0,-4)})),command:'SELECT'};return {rows:[],command:text.split(' ')[0]}}})}async end(){assert.equal(owners,0);if(poolEndFailure)throw Error('Synthetic pool retirement uncertainty')}}
|
||||||
|
const protectedSource=await readFile(path.join(backend,'scripts/writer-profile/operator-files.mjs'),'utf8'),protectedExports:Record<string,unknown>={},actualUid=(await lstat(state)).uid
|
||||||
|
const protectedCode=ts.transpileModule(protectedSource,{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2022,esModuleInterop:true}}).outputText
|
||||||
|
runInNewContext(protectedCode,{exports:protectedExports,require,Buffer,process:{platform:'linux',getuid:()=>actualUid+1},Object,Error})
|
||||||
|
const protectedDirectory=async(directory:string)=>{assert(path.resolve(directory).startsWith(root+path.sep)||path.resolve(directory)===root);if(wrongOwner)await (protectedExports.protectedDirectory as (path:string)=>Promise<void>)(directory);await readdir(directory)}
|
||||||
|
// Explicit MODELED protection adapter: native POSIX mode/ownership and hardlink
|
||||||
|
// fsync publication remain separate Linux acceptance, never a Windows claim.
|
||||||
|
const files={sha:hash,protectedDirectory,readProtected:async(file:string,maximum:number,pin?:string)=>{assert(file.startsWith(root+path.sep));const raw=await readFile(file);assert(raw.length>0&&raw.length<=maximum);if(pin)assert.equal(hash(raw),pin);return raw},writeFile:async(file:string,raw:Buffer)=>{assert(file.startsWith(root+path.sep));await writeFile(file,raw,{flag:'wx'});published++},withJournalLease:async(directory:string,work:()=>Promise<unknown>)=>{const lock=path.join(directory,'publisher-lease.lock');await writeFile(lock,'owned',{flag:'wx'});try{return await work()}finally{await unlink(lock)}}}
|
||||||
|
const readRecords=async(directory:string)=>{const records=path.join(directory,'maintenance-acceptances');let names:string[];try{names=await readdir(records)}catch(error){if((error as NodeJS.ErrnoException).code==='ENOENT')return [];throw error};assert(names.every(name=>/^[a-f0-9-]{36}\.json$/.test(name)));return Promise.all(names.sort().map(name=>readFile(path.join(records,name))))}
|
||||||
|
const moduleFile=path.join(backend,'scripts/writer-profile/maintenance-resolution.mts'),sourceText=(await readFile(moduleFile,'utf8')).replaceAll('import.meta.url',JSON.stringify(pathToFileURL(moduleFile).href)),exports:Record<string,unknown>={}
|
||||||
|
const dependencies:Record<string,unknown>={'./operator-files.mjs':files,'./operator-contract.mjs':contract,'../../src/services/database-maintenance-startup.js':{...strictReader,readMaintenanceAcceptanceRecords:readRecords},'../../src/services/database-maintenance-resolution.js':grammar,'../../src/db/migration-schema-inventory.js':{captureMigrationSchema:async(_client:unknown,options:{scope:unknown})=>{capturedScopes.push(options.scope);return schema}},'../../src/db/migration-manifest.js':manifest,pg:{Pool}}
|
||||||
|
const code=ts.transpileModule(sourceText,{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2022,esModuleInterop:true}}).outputText
|
||||||
|
runInNewContext(code,{exports,require:(name:string)=>name in dependencies?dependencies[name]:require(name),Buffer,URL,process:{env:{}},Date,Object,Map,Set,Error,console})
|
||||||
|
const command=exports.maintenanceMain as (args:string[])=>Promise<Record<string,unknown>>
|
||||||
|
const tlsTransport={format:'database-maintenance-transport-v1',connection:{host:'db.example.invalid',port:5432,user:'existing-maintenance-user',password:'synthetic-only',database:'managed'},tls:{serverName:'db.example.invalid',spkiSha256:'f'.repeat(64)}},localTransport=(host:string)=>({format:'database-maintenance-transport-v1',mode:'local-loopback',connection:{...tlsTransport.connection,host}})
|
||||||
|
const configs={transport:localHost===undefined?tlsTransport:localTransport(localHost),'runtime-target':{format:'database-maintenance-runtime-target-v1',databaseUrl:'postgresql://db.example.invalid:5432/managed?sslmode=require'}}
|
||||||
|
const args=['--state-dir',state]
|
||||||
|
for(const [name,value]of Object.entries(configs)){const raw=Buffer.from(JSON.stringify(value)),file=path.join(root,name+'.json');await writeFile(file,raw);args.push('--'+name,file,'--'+name+'-sha256',hash(raw))}
|
||||||
|
const ca=Buffer.from(rootCertificates[0]),caFile=path.join(root,'ca.pem');await writeFile(caFile,ca);if(localHost===undefined)args.push('--ca',caFile,'--ca-sha256',hash(ca))
|
||||||
|
const output=path.join(root,'inspection.json')
|
||||||
|
async function inspect(destination=output){return command(['maintenance-inspect',...args,'--output',destination])}
|
||||||
|
async function approve(change?:(value:Record<string,unknown>)=>void){const raw=await readFile(output),inspection=JSON.parse(raw.toString()),approval={format:'database-maintenance-current-state-approval-v1',resolutionId:randomUUID(),decision:'accept-observed-current-state',actorId:'host-human-reviewer',inspectionSha256:hash(raw),issuedAt:new Date().toISOString(),expiresAt:new Date(Date.now()+60000).toISOString(),managedBackendStopped:true,historicOutcomesRemainUnknown:true,automaticRestore:false};assert(inspection.createdAt<=approval.issuedAt);change?.(approval);const bytes=Buffer.from(JSON.stringify(approval)),file=path.join(root,'approval.json');await writeFile(file,bytes);return ['maintenance-resolve',...args,'--inspection',output,'--inspection-sha256',hash(raw),'--approval',file,'--approval-sha256',hash(bytes)]}
|
||||||
|
const toLocal=async(host='127.0.0.1')=>{const raw=Buffer.from(JSON.stringify(localTransport(host)));await writeFile(path.join(root,'transport.json'),raw);args[args.indexOf('--transport-sha256')+1]=hash(raw);for(const flag of ['--ca','--ca-sha256']){const at=args.indexOf(flag);if(at>=0)args.splice(at,2)}}
|
||||||
|
const configuration=exports.maintenancePoolConfiguration as (value:unknown,ca?:Buffer,pin?:string)=>{options:Record<string,unknown>;transportBinding:{mode:string;endpointSha256:string}}
|
||||||
|
return {command,inspect,approve,toLocal,configuration,readRecords,state,output,sql:()=>sql,published:()=>published,failRelease:()=>{releaseFailure=true},failEnd:()=>{poolEndFailure=true},driftSchema:()=>{schema.sha256='0'.repeat(64)},driftTarget:()=>{source.databaseOid='54321'},wrongOwner:()=>{wrongOwner=true},withControlSchema:()=>{controlSchema=true},scopes:()=>capturedScopes,failObservation:(error:Error)=>{observationFailure=error}}
|
||||||
|
}
|
||||||
|
test('actual operator commands inspect then separately approved resolve; old uncertain bytes never rewritten',()=>own(async root=>{
|
||||||
|
const fixture=await model(root),journal=path.join(fixture.state,'database-maintenance.jsonl'),before=await readFile(journal),inspected=await fixture.inspect();assert.equal(inspected.humanApprovalRequired,true);assert.equal((await fixture.readRecords(fixture.state)).length,0)
|
||||||
|
const result=await fixture.command(await fixture.approve());assert.equal(result.outcome,'committed');assert.equal(result.historicOutcomesChanged,false);assert.equal(result.archiveDataEquivalenceProved,false);assert.equal(result.automaticRestore,false)
|
||||||
|
assert.deepEqual(await readFile(journal),before);const records=await fixture.readRecords(fixture.state),facts=await strictReader.readMaintenanceRecoveryFacts(fixture.state);assert.equal(records.length,1);const accepted=grammar.evaluateMaintenanceAcceptances(records,facts.journal,facts.vault.evidence,grammar.runtimeDatabaseTargetSha256('postgresql://db.example.invalid:5432/managed?sslmode=require'));assert.equal(accepted.count,1)
|
||||||
|
fixture.driftSchema();const afterStatus=await fixture.inspect(path.join(root,'after-inspection.json'));assert.equal(afterStatus.state,'historically-accepted');assert.equal(afterStatus.humanApprovalRequired,false);assert.equal(afterStatus.historicOutcomesChanged,false)
|
||||||
|
await assert.rejects(fixture.command(await fixture.approve()),/No new unresolved|already|assert|Assertion/i)
|
||||||
|
}))
|
||||||
|
for(const type of ['expired','not-stopped','wrong-inspection'])test('actual resolve refuses '+type+' approval before connecting SQL',()=>own(async root=>{
|
||||||
|
const fixture=await model(root);await fixture.inspect();const args=await fixture.approve(value=>{if(type==='expired')value.expiresAt=new Date(Date.now()-1).toISOString();if(type==='not-stopped')value.managedBackendStopped=false;if(type==='wrong-inspection')value.inspectionSha256='0'.repeat(64)}),before=fixture.sql();await assert.rejects(fixture.command(args));assert.equal(fixture.sql(),before);assert.equal((await fixture.readRecords(fixture.state)).length,0)
|
||||||
|
}))
|
||||||
|
for(const type of ['release','pool-end','schema-drift','target-drift','external-drift'])test('actual resolve refuses '+type+' before acceptance publication',()=>own(async root=>{
|
||||||
|
const fixture=await model(root);await fixture.inspect();const args=await fixture.approve();if(type==='release')fixture.failRelease();if(type==='pool-end')fixture.failEnd()
|
||||||
|
if(type==='schema-drift')fixture.driftSchema();if(type==='target-drift')fixture.driftTarget()
|
||||||
|
if(type==='external-drift')await appendFileSafe(path.join(fixture.state,'database-maintenance.jsonl'))
|
||||||
|
await assert.rejects(fixture.command(args));assert.equal((await fixture.readRecords(fixture.state)).length,0)
|
||||||
|
}))
|
||||||
|
async function appendFileSafe(file:string){await writeFile(file,await readFile(file,'utf8')+JSON.stringify({operationId:randomUUID(),state:'intent',at:new Date().toISOString()})+'\n')}
|
||||||
|
test('existing shipped main dispatches both commands before writer-profile role/source flags',async()=>{
|
||||||
|
const source=await readFile(path.join(backend,'scripts/database-writer-profile-operator.mts'),'utf8');assert(source.indexOf("['maintenance-inspect','maintenance-resolve']")<source.indexOf("const flags="));assert(source.includes("import('./writer-profile/maintenance-resolution.mjs')"))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('existing protectedDirectory owner contract refuses a foreign CLI UID before SQL or writes',()=>own(async root=>{
|
||||||
|
const fixture=await model(root);fixture.wrongOwner();await assert.rejects(fixture.inspect());assert.equal(fixture.sql(),0);assert.equal(fixture.published(),0);assert.equal((await fixture.readRecords(fixture.state)).length,0)
|
||||||
|
}))
|
||||||
|
|
||||||
|
test('explicit local-loopback supports both IP families and binds selected mode in actual inspect/resolve',async()=>{
|
||||||
|
for(const host of ['127.0.0.1','::1'])await own(async root=>{const fixture=await model(root,host);await fixture.inspect();const result=await fixture.command(await fixture.approve());assert.equal(result.outcome,'committed');const records=await fixture.readRecords(fixture.state);assert.equal(JSON.parse(records[0].toString()).knownCurrent.transport.mode,'local-loopback')})
|
||||||
|
})
|
||||||
|
test('plaintext remote/DNS/unspecified/mapped hosts refuse before SQL and never fall back',async()=>{
|
||||||
|
for(const host of ['db.example.invalid','localhost','0.0.0.0','192.0.2.1','[::1]','::ffff:127.0.0.1'])await own(async root=>{const fixture=await model(root,host);await assert.rejects(fixture.inspect());assert.equal(fixture.sql(),0);assert.equal(fixture.published(),0)})
|
||||||
|
})
|
||||||
|
test('an approval for TLS observation cannot resolve through a changed local-loopback transport',()=>own(async root=>{
|
||||||
|
const fixture=await model(root);await fixture.inspect();await fixture.toLocal();await assert.rejects(fixture.command(await fixture.approve()));assert.equal((await fixture.readRecords(fixture.state)).length,0)
|
||||||
|
}))
|
||||||
|
test('local mode explicitly disables SSL/environment substitution; TLS remains verified default without fallback',()=>own(async root=>{
|
||||||
|
const fixture=await model(root),ConnectionParameters=require('pg/lib/connection-parameters'),raw={format:'database-maintenance-transport-v1',mode:'local-loopback',connection:{host:'127.0.0.1',port:5432,user:'existing-user',password:'synthetic-only',database:'managed'}}
|
||||||
|
const previous={PGHOST:process.env.PGHOST,PGSSLMODE:process.env.PGSSLMODE,PGOPTIONS:process.env.PGOPTIONS};process.env.PGHOST='remote.invalid';process.env.PGSSLMODE='require';process.env.PGOPTIONS='-c role=foreign'
|
||||||
|
try{const config=fixture.configuration(raw),actual=new ConnectionParameters(config.options);assert.equal(actual.host,'127.0.0.1');assert.equal(actual.ssl,false);assert.equal(actual.options,contract.STARTUP_OPTIONS);assert.equal(config.transportBinding.mode,'local-loopback')}
|
||||||
|
finally{for(const [key,value]of Object.entries(previous)){if(value===undefined)delete process.env[key];else process.env[key]=value}}
|
||||||
|
const ca=Buffer.from(rootCertificates[0]),tls={format:'database-maintenance-transport-v1',connection:{...raw.connection,host:'db.example.invalid'},tls:{serverName:'db.example.invalid',spkiSha256:'f'.repeat(64)}}
|
||||||
|
assert.throws(()=>fixture.configuration(tls));const secure=fixture.configuration(tls,ca,hash(ca));assert.equal(secure.transportBinding.mode,'tls');assert.equal((secure.options.ssl as {rejectUnauthorized:boolean}).rejectUnauthorized,true)
|
||||||
|
assert.throws(()=>fixture.configuration({...raw,mode:'plaintext'}));assert.throws(()=>fixture.configuration({...tls,mode:null},ca,hash(ca)));assert.throws(()=>fixture.configuration(raw,ca,hash(ca)))
|
||||||
|
}))
|
||||||
|
|
||||||
|
// A normal installation has no optional writer-profile schema. Observation must
|
||||||
|
// select its exact namespace set without requiring installation of that system.
|
||||||
|
test('maintenance observes with and without the optional control namespace',async()=>{
|
||||||
|
for(const present of [false,true])await own(async root=>{const fixture=await model(root);if(present)fixture.withControlSchema();await fixture.inspect();assert.equal(JSON.stringify(fixture.scopes()),JSON.stringify([{applicationNamespaces:['public'],excludedControlNamespaces:present?['mmapp_control']:[]}]))})
|
||||||
|
})
|
||||||
|
test('original observation failure survives confirmed rollback/unlock/release',()=>own(async root=>{
|
||||||
|
const fixture=await model(root),failure=Error('Synthetic original observation failure');fixture.failObservation(failure);await assert.rejects(fixture.inspect(),error=>error===failure);assert.equal(fixture.published(),0);assert.equal((await fixture.readRecords(fixture.state)).length,0)
|
||||||
|
}))
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
/** The fixed build inputs shared by the operator and Docker staging. */
|
||||||
|
export const OPERATOR_SOURCES=Object.freeze([
|
||||||
|
'database-writer-bootstrap-operator.mjs','database-writer-profile-operator.mts',
|
||||||
|
'writer-profile/operator-files.mjs','writer-profile/operator-contract.mjs','writer-profile/publisher.mjs','writer-profile/context.mjs',
|
||||||
|
'writer-profile/protected-source-registry.mts','writer-profile/maintenance-resolution.mts',
|
||||||
|
'writer-profile/catalogue.mts','writer-profile/catalogue-value-bridge.ts','writer-profile/catalogue-projection.ts','writer-profile/catalogue-leaves.ts',
|
||||||
|
'writer-profile/application-writer-profile-catalogue.ts','writer-profile/application-writer-profile-catalogue-values.ts',
|
||||||
|
'writer-profile/application-writer-profile-catalogue-parent-policy.ts','writer-profile/application-writer-profile-canonical.ts',
|
||||||
|
'writer-profile/fixed44-build-sql.ts','writer-profile/fixed44-build-definition.ts','writer-profile/expanded44-data-policy.ts',
|
||||||
|
])
|
||||||
@@ -9,6 +9,7 @@ import {deploymentRequest} from './database-writer-bootstrap-operator.mjs'
|
|||||||
const hex=(v:unknown)=>typeof v==='string'&&/^[a-f0-9]{64}$/.test(v)
|
const hex=(v:unknown)=>typeof v==='string'&&/^[a-f0-9]{64}$/.test(v)
|
||||||
const parse=(b:Buffer)=>JSON.parse(new TextDecoder('utf-8',{fatal:true}).decode(b))
|
const parse=(b:Buffer)=>JSON.parse(new TextDecoder('utf-8',{fatal:true}).decode(b))
|
||||||
export async function main(args:string[]){
|
export async function main(args:string[]){
|
||||||
|
if(['maintenance-inspect','maintenance-resolve'].includes(args[0]))return (await import('./writer-profile/maintenance-resolution.mjs')).maintenanceMain(args)
|
||||||
const flags=['bootstrap','bootstrap-sha256','approval','approval-sha256','definition','definition-sha256','transport','transport-sha256','ca','source-registry','source-spki-sha256','journal']
|
const flags=['bootstrap','bootstrap-sha256','approval','approval-sha256','definition','definition-sha256','transport','transport-sha256','ca','source-registry','source-spki-sha256','journal']
|
||||||
assert(['publish','reconcile','preflight'].includes(args[0])&&args.length===1+2*flags.length)
|
assert(['publish','reconcile','preflight'].includes(args[0])&&args.length===1+2*flags.length)
|
||||||
assertJsDriverEnvironment(process.env)
|
assertJsDriverEnvironment(process.env)
|
||||||
@@ -38,7 +39,12 @@ export async function main(args:string[]){
|
|||||||
assertJsDriverEnvironment(process.env)
|
assertJsDriverEnvironment(process.env)
|
||||||
assert.equal(approvedDefinition(definition),pinned.definitionSha256)
|
assert.equal(approvedDefinition(definition),pinned.definitionSha256)
|
||||||
const installation=Object.freeze({...subject,roles:Object.fromEntries(roleKeys.map(k=>[k,request.roles[k].signedOid])),roleBindings:request.roles,expectedSource:request.source})
|
const installation=Object.freeze({...subject,roles:Object.fromEntries(roleKeys.map(k=>[k,request.roles[k].signedOid])),roleBindings:request.roles,expectedSource:request.source})
|
||||||
const verifier=createDatabaseSourceBindingVerifier({registryDirectory:get('source-registry'),installationId:subject.installationId,publicKeySpkiSha256:get('source-spki-sha256'),currentHeadSha256:request.sourceHeadSha256})
|
const sourcePin={registryDirectory:get('source-registry'),installationId:subject.installationId,publicKeySpkiSha256:get('source-spki-sha256'),currentHeadSha256:request.sourceHeadSha256}
|
||||||
|
// Preflight is the read-only lifecycle entry for protected registry evidence.
|
||||||
|
// A refused protection check never falls back to the signature-only verifier.
|
||||||
|
const verifier=args[0]==='preflight'
|
||||||
|
?(await import('./writer-profile/protected-source-registry.mjs')).createProtectedDatabaseSourceBindingVerifier(sourcePin)
|
||||||
|
:createDatabaseSourceBindingVerifier(sourcePin)
|
||||||
const publisher=createConnectedPublisher(installation,(s:Parameters<typeof collectSource44Catalogue>[0])=>collectSource44Catalogue(s,installation),verifier,pinned)
|
const publisher=createConnectedPublisher(installation,(s:Parameters<typeof collectSource44Catalogue>[0])=>collectSource44Catalogue(s,installation),verifier,pinned)
|
||||||
const pool=new pg.Pool(options)
|
const pool=new pg.Pool(options)
|
||||||
// A pool-level async error never becomes raw stdout or a retry signal.
|
// A pool-level async error never becomes raw stdout or a retry signal.
|
||||||
@@ -47,7 +53,7 @@ export async function main(args:string[]){
|
|||||||
try{result=args[0]==='preflight'?await publisher.preflightCurrentProfile(pool,{deadlineAt:Date.now()+120000}):args[0]==='reconcile'?await publisher.reconcilePendingPublication(pool,{journalDirectory:get('journal'),deadlineAt:Date.now()+120000}):await publisher.publishCurrentProfile(pool,{privateKeyFile,expectedHeadSha256:pinned.expectedHeadSha256,journalDirectory:get('journal'),validityMs:pinned.validityMs,deadlineAt:Date.now()+120000})}
|
try{result=args[0]==='preflight'?await publisher.preflightCurrentProfile(pool,{deadlineAt:Date.now()+120000}):args[0]==='reconcile'?await publisher.reconcilePendingPublication(pool,{journalDirectory:get('journal'),deadlineAt:Date.now()+120000}):await publisher.publishCurrentProfile(pool,{privateKeyFile,expectedHeadSha256:pinned.expectedHeadSha256,journalDirectory:get('journal'),validityMs:pinned.validityMs,deadlineAt:Date.now()+120000})}
|
||||||
catch(error){primary=error;failed=true}
|
catch(error){primary=error;failed=true}
|
||||||
try{await pool.end()}catch(error){primary??=error;failed=true}
|
try{await pool.end()}catch(error){primary??=error;failed=true}
|
||||||
if(args[0]==='preflight'&&result?.state==='matched'&&!failed&&!asyncFailure)return {event:'application-writer-profile-preflight',outcome:'matched',epoch:result.epoch,headSha256:result.headSha256,notAfter:result.notAfter,sourceMatched:true,rolesMatched:true,catalogueMatched:true,writerAuthorized:false,cleanupUnconfirmed:false,automaticReplay:false,generationAuthenticated:false,readyAuthorized:false}
|
if(args[0]==='preflight'&&result?.state==='matched'&&result.platformProtectionVerified===true&&!failed&&!asyncFailure)return {event:'application-writer-profile-preflight',outcome:'matched',epoch:result.epoch,headSha256:result.headSha256,notAfter:result.notAfter,sourceMatched:true,rolesMatched:true,catalogueMatched:true,platformProtectionVerified:true,writerAuthorized:false,cleanupUnconfirmed:false,automaticReplay:false,generationAuthenticated:false,readyAuthorized:false}
|
||||||
if(result?.outcome==='committed')return {event:'application-writer-profile-publication',outcome:'committed',epoch:result.epoch,headSha256:result.headSha256,notAfter:result.notAfter,
|
if(result?.outcome==='committed')return {event:'application-writer-profile-publication',outcome:'committed',epoch:result.epoch,headSha256:result.headSha256,notAfter:result.notAfter,
|
||||||
cleanupUnconfirmed:result.cleanupUnconfirmed||asyncFailure||failed,journalUnconfirmed:!!result.journalUnconfirmed,automaticReplay:false,generationAuthenticated:false,readyAuthorized:false}
|
cleanupUnconfirmed:result.cleanupUnconfirmed||asyncFailure||failed,journalUnconfirmed:!!result.journalUnconfirmed,automaticReplay:false,generationAuthenticated:false,readyAuthorized:false}
|
||||||
if(args[0]==='reconcile'&&result&&!failed&&!asyncFailure&&['idle','confirmed','not-committed'].includes(result.state))return {event:'application-writer-profile-reconciliation',outcome:result.state,headSha256:result.headSha256??null,cleanupUnconfirmed:false,journalUnconfirmed:false,automaticReplay:false,generationAuthenticated:false,readyAuthorized:false}
|
if(args[0]==='reconcile'&&result&&!failed&&!asyncFailure&&['idle','confirmed','not-committed'].includes(result.state))return {event:'application-writer-profile-reconciliation',outcome:result.state,headSha256:result.headSha256??null,cleanupUnconfirmed:false,journalUnconfirmed:false,automaticReplay:false,generationAuthenticated:false,readyAuthorized:false}
|
||||||
@@ -55,5 +61,5 @@ export async function main(args:string[]){
|
|||||||
}
|
}
|
||||||
if(process.argv[1]&&import.meta.url===pathToFileURL(path.resolve(process.argv[1])).href){
|
if(process.argv[1]&&import.meta.url===pathToFileURL(path.resolve(process.argv[1])).href){
|
||||||
try{const result=await main(process.argv.slice(2));console.log(JSON.stringify(result));if(result.cleanupUnconfirmed||result.journalUnconfirmed)process.exitCode=2}
|
try{const result=await main(process.argv.slice(2));console.log(JSON.stringify(result));if(result.cleanupUnconfirmed||result.journalUnconfirmed)process.exitCode=2}
|
||||||
catch{console.error(JSON.stringify({event:'application-writer-profile-publication-refused',automaticReplay:false,inspectPendingBeforeRetry:true,readyAuthorized:false}));process.exitCode=1}
|
catch{console.error(JSON.stringify({event:['maintenance-inspect','maintenance-resolve'].includes(process.argv[2])?'database-maintenance-command-refused':'application-writer-profile-publication-refused',automaticReplay:false,inspectPendingBeforeRetry:true,readyAuthorized:false}));process.exitCode=1}
|
||||||
}
|
}
|
||||||
@@ -11,9 +11,9 @@ import {readCurrentApplicationWriterProfile} from '../src/db/application-writer-
|
|||||||
import {syntheticFixture,stagedModelClient} from '../src/db/application-writer-profile-registry-read-model.test.ts'
|
import {syntheticFixture,stagedModelClient} from '../src/db/application-writer-profile-registry-read-model.test.ts'
|
||||||
const require=createRequire(new URL('../package.json',import.meta.url)),ts=require('typescript'),pg=require('pg')
|
const require=createRequire(new URL('../package.json',import.meta.url)),ts=require('typescript'),pg=require('pg')
|
||||||
const candidate=await fs.readFile(new URL('./writer-profile/publisher.mjs',import.meta.url),'utf8')
|
const candidate=await fs.readFile(new URL('./writer-profile/publisher.mjs',import.meta.url),'utf8')
|
||||||
interface PreflightDiagnostics {state:string;headSha256:string;sourceMatched:boolean;rolesMatched:boolean;catalogueMatched:boolean;writerAuthorized:boolean;generationAuthenticated:boolean;readyAuthorized:boolean}
|
interface PreflightDiagnostics {state:string;headSha256:string;sourceMatched:boolean;rolesMatched:boolean;catalogueMatched:boolean;platformProtectionVerified:boolean;writerAuthorized:boolean;generationAuthenticated:boolean;readyAuthorized:boolean}
|
||||||
interface PublisherModule {createConnectedPublisher(installation:unknown,collect:()=>Promise<unknown>,sourceVerifier:{verify:()=>Promise<void>},approval:unknown):{preflightCurrentProfile(pool:unknown,options:{deadlineAt:number}):Promise<PreflightDiagnostics>}}
|
interface PublisherModule {createConnectedPublisher(installation:unknown,collect:()=>Promise<unknown>,sourceVerifier:{verify:()=>Promise<{platformProtectionVerified:boolean}>},approval:unknown):{preflightCurrentProfile(pool:unknown,options:{deadlineAt:number}):Promise<PreflightDiagnostics>}}
|
||||||
interface OperatorModule {main(args:string[]):Promise<{event:string;outcome:string;writerAuthorized:boolean;readyAuthorized:boolean}>}
|
interface OperatorModule {main(args:string[]):Promise<{event:string;outcome:string;platformProtectionVerified:boolean;writerAuthorized:boolean;readyAuthorized:boolean}>}
|
||||||
function compile<T>(source:string,modules:Record<string,unknown>):T{
|
function compile<T>(source:string,modules:Record<string,unknown>):T{
|
||||||
const exports:Record<string,unknown>={};const compiled=ts.transpileModule(source.split('\nif(process.argv[1]')[0].replaceAll('import.meta.url',JSON.stringify('file:///candidate-not-cli.mts')),{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2022,esModuleInterop:true}}).outputText
|
const exports:Record<string,unknown>={};const compiled=ts.transpileModule(source.split('\nif(process.argv[1]')[0].replaceAll('import.meta.url',JSON.stringify('file:///candidate-not-cli.mts')),{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2022,esModuleInterop:true}}).outputText
|
||||||
runInNewContext(compiled,{exports,require:(name:string)=>{assert(name in modules,'Unapproved import '+name);return modules[name]},Buffer,URL,TextDecoder,Date,setTimeout,clearTimeout,Object,Error,AggregateError,Promise,Map,Set,process:{env:{},argv:[]}}, {filename:'candidate.mjs'})
|
runInNewContext(compiled,{exports,require:(name:string)=>{assert(name in modules,'Unapproved import '+name);return modules[name]},Buffer,URL,TextDecoder,Date,setTimeout,clearTimeout,Object,Error,AggregateError,Promise,Map,Set,process:{env:{},argv:[]}}, {filename:'candidate.mjs'})
|
||||||
@@ -56,17 +56,23 @@ function fixture(fault=''){
|
|||||||
'../../src/db/application-writer-profile-format.js':format,
|
'../../src/db/application-writer-profile-format.js':format,
|
||||||
'./context.mjs':{ownedContext:async()=>{contexts++;if(fault==='late-actor'&&contexts===2)throw Error('Actor changed');return {pid:1,source}},sameOwned:(a:unknown,b:unknown)=>assert.deepEqual(a,b),sourceCoordinates:(v:{source:typeof source})=>v.source,protectedStructure:async()=>{events.push('roles');if(fault==='role-structure')throw Error('Unsafe role');return security},readCurrent:readCurrentApplicationWriterProfile,HISTORY:'history',CURRENT:'current'},
|
'./context.mjs':{ownedContext:async()=>{contexts++;if(fault==='late-actor'&&contexts===2)throw Error('Actor changed');return {pid:1,source}},sameOwned:(a:unknown,b:unknown)=>assert.deepEqual(a,b),sourceCoordinates:(v:{source:typeof source})=>v.source,protectedStructure:async()=>{events.push('roles');if(fault==='role-structure')throw Error('Unsafe role');return security},readCurrent:readCurrentApplicationWriterProfile,HISTORY:'history',CURRENT:'current'},
|
||||||
}
|
}
|
||||||
const api=compile<PublisherModule>(candidate,modules).createConnectedPublisher(installation,async()=>{events.push('catalogue');if(fault==='catalogue-eof')throw Error('Incomplete EOF');return catalogue},{verify:async()=>{verifications++;events.push('verify-source');if(fault==='source-verifier')throw Error('Unsigned source');if(fault==='late-expiry'&&verifications===2)approval.expiresAt=1}},approval)
|
const api=compile<PublisherModule>(candidate,modules).createConnectedPublisher(installation,async()=>{events.push('catalogue');if(fault==='catalogue-eof')throw Error('Incomplete EOF');return catalogue},{verify:async()=>{verifications++;events.push('verify-source');if(fault==='source-verifier')throw Error('Unsigned source');if(fault==='late-expiry'&&verifications===2)approval.expiresAt=1;return {platformProtectionVerified:!(fault==='initial-unprotected'&&verifications===1||fault==='terminal-unprotected'&&verifications===2)}}},approval)
|
||||||
return {run:()=>api.preflightCurrentProfile(pool,{deadlineAt:Date.now()+30000}),queries,events,f}
|
return {run:()=>api.preflightCurrentProfile(pool,{deadlineAt:Date.now()+30000}),queries,events,f}
|
||||||
}
|
}
|
||||||
test('current44 preflight authenticates signed current and actual component inputs on one read-only lease',async()=>{
|
test('current44 preflight authenticates signed current and actual component inputs on one read-only lease',async()=>{
|
||||||
const f=fixture(),result=await f.run();assert.equal(result.state,'matched');assert.equal(result.headSha256,f.f.state.current[0].head_sha256)
|
const f=fixture(),result=await f.run();assert.equal(result.state,'matched');assert.equal(result.headSha256,f.f.state.current[0].head_sha256)
|
||||||
assert.equal(result.sourceMatched,true);assert.equal(result.rolesMatched,true);assert.equal(result.catalogueMatched,true)
|
assert.equal(result.sourceMatched,true);assert.equal(result.rolesMatched,true);assert.equal(result.catalogueMatched,true)
|
||||||
|
assert.equal(result.platformProtectionVerified,true)
|
||||||
assert.equal(result.writerAuthorized,false);assert.equal(result.generationAuthenticated,false);assert.equal(result.readyAuthorized,false)
|
assert.equal(result.writerAuthorized,false);assert.equal(result.generationAuthenticated,false);assert.equal(result.readyAuthorized,false)
|
||||||
assert.equal(f.queries.filter(q=>q.startsWith('BEGIN')).length,1);assert(f.queries.includes('BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY'));assert.equal(f.queries.filter(q=>q==='ROLLBACK').length,1)
|
assert.equal(f.queries.filter(q=>q.startsWith('BEGIN')).length,1);assert(f.queries.includes('BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY'));assert.equal(f.queries.filter(q=>q==='ROLLBACK').length,1)
|
||||||
assert(!f.queries.some(q=>/^(?:INSERT|UPDATE|DELETE|COMMIT|CREATE|ALTER|DROP)\b/.test(q)));assert.equal(f.events.at(-1),'release:false')
|
assert(!f.queries.some(q=>/^(?:INSERT|UPDATE|DELETE|COMMIT|CREATE|ALTER|DROP)\b/.test(q)));assert.equal(f.events.at(-1),'release:false')
|
||||||
assert.deepEqual(f.events.slice(0,6),['capture-source','verify-source','roles','catalogue','verify-source','fresh-source'])
|
assert.deepEqual(f.events.slice(0,6),['capture-source','verify-source','roles','catalogue','verify-source','fresh-source'])
|
||||||
})
|
})
|
||||||
|
for(const fault of ['initial-unprotected','terminal-unprotected'])test('preflight cannot promote '+fault+' into protected evidence',async()=>{
|
||||||
|
const f=fixture(fault),result=await f.run();assert.equal(result.platformProtectionVerified,false)
|
||||||
|
assert.equal(result.writerAuthorized,false);assert.equal(result.readyAuthorized,false)
|
||||||
|
assert(!f.queries.some(q=>/^(?:INSERT|UPDATE|DELETE|COMMIT|CREATE|ALTER|DROP)\b/.test(q)))
|
||||||
|
})
|
||||||
for(const fault of ['tamper','empty','expired','signed-source','signed-role','signed-catalogue','signed-definition','signed-manifest','role-structure','catalogue-eof','source-verifier','late-source','late-actor','late-expiry','late-clock','rollback-ack'])test('preflight refuses '+fault+' without any write or replay',async()=>{
|
for(const fault of ['tamper','empty','expired','signed-source','signed-role','signed-catalogue','signed-definition','signed-manifest','role-structure','catalogue-eof','source-verifier','late-source','late-actor','late-expiry','late-clock','rollback-ack'])test('preflight refuses '+fault+' without any write or replay',async()=>{
|
||||||
const f=fixture(fault);await assert.rejects(f.run());assert(!f.queries.some(q=>/^(?:INSERT|UPDATE|DELETE|COMMIT|CREATE|ALTER|DROP)\b/.test(q)));assert.equal(f.queries.filter(q=>q.startsWith('BEGIN')).length,1)
|
const f=fixture(fault);await assert.rejects(f.run());assert(!f.queries.some(q=>/^(?:INSERT|UPDATE|DELETE|COMMIT|CREATE|ALTER|DROP)\b/.test(q)));assert.equal(f.queries.filter(q=>q.startsWith('BEGIN')).length,1)
|
||||||
})
|
})
|
||||||
@@ -74,23 +80,28 @@ test('existing operator main dispatches preflight only after protected input val
|
|||||||
const cli=await fs.readFile(new URL('./database-writer-profile-operator.mts',import.meta.url),'utf8'),f=syntheticFixture('1'),reads:string[]=[],events:string[]=[]
|
const cli=await fs.readFile(new URL('./database-writer-profile-operator.mts',import.meta.url),'utf8'),f=syntheticFixture('1'),reads:string[]=[],events:string[]=[]
|
||||||
const request={installationId:f.subject.installationId,roles:Object.fromEntries(['runtime','operator','capture','publisher','control'].map((k,i)=>[k,{name:k,signedOid:String(i+11)}])),source:f.policy.source,sourceHeadSha256:'a'.repeat(64),tls:{caSha256:'a'.repeat(64)}}
|
const request={installationId:f.subject.installationId,roles:Object.fromEntries(['runtime','operator','capture','publisher','control'].map((k,i)=>[k,{name:k,signedOid:String(i+11)}])),source:f.policy.source,sourceHeadSha256:'a'.repeat(64),tls:{caSha256:'a'.repeat(64)}}
|
||||||
const files:Record<string,unknown>={bootstrap:{format:'application-writer-bootstrap-v1',publicationAuthorized:false,readyAuthorized:false,deploymentRequest:request,verificationSubject:f.subject},approval:{expiresAt:Date.now()+60000,definitionSha256:'b'.repeat(64)},definition:{},transport:{},ca:'fixture'}
|
const files:Record<string,unknown>={bootstrap:{format:'application-writer-bootstrap-v1',publicationAuthorized:false,readyAuthorized:false,deploymentRequest:request,verificationSubject:f.subject},approval:{expiresAt:Date.now()+60000,definitionSha256:'b'.repeat(64)},definition:{},transport:{},ca:'fixture'}
|
||||||
let failEnd=false,asyncError=false
|
let failEnd=false,asyncError=false,protectedRefused=false
|
||||||
const retainedPools:Pool[]=[]
|
const retainedPools:Pool[]=[]
|
||||||
class Pool extends EventEmitter{constructor(){super();retainedPools.push(this)}async end(){events.push('pool-end');if(failEnd)throw Error('Pool cleanup unconfirmed')}}
|
class Pool extends EventEmitter{constructor(){super();retainedPools.push(this)}async end(){events.push('pool-end');if(failEnd)throw Error('Pool cleanup unconfirmed')}}
|
||||||
const result={state:'matched',epoch:'1',headSha256:'c'.repeat(64),notAfter:Date.now()+60000}
|
const result={state:'matched',epoch:'1',headSha256:'c'.repeat(64),notAfter:Date.now()+60000,platformProtectionVerified:true}
|
||||||
|
const protectedVerifier={verify:async()=>({platformProtectionVerified:true})}
|
||||||
const modules={
|
const modules={
|
||||||
'node:assert/strict':assert,'node:path':require('node:path'),'node:url':require('node:url'),'node:crypto':require('node:crypto'),
|
'node:assert/strict':assert,'node:path':require('node:path'),'node:url':require('node:url'),'node:crypto':require('node:crypto'),
|
||||||
'./writer-profile/operator-files.mjs':{readProtected:async(file:string)=>{reads.push(file);assert(file in files);return Buffer.from(JSON.stringify(files[file]))},protectedDirectory:async()=>{},sha:format.sha},
|
'./writer-profile/operator-files.mjs':{readProtected:async(file:string)=>{reads.push(file);assert(file in files);return Buffer.from(JSON.stringify(files[file]))},protectedDirectory:async()=>{},sha:format.sha},
|
||||||
'./writer-profile/operator-contract.mjs':{approval:(value:unknown)=>{events.push('approval');return value},reconciliationApproval:()=>{throw Error('No expired reconciliation bypass')},poolOptions:()=>{events.push('transport');return {}},roleKeys:['runtime','operator','capture','publisher','control'],assertJsDriverEnvironment:()=>{}},
|
'./writer-profile/operator-contract.mjs':{approval:(value:unknown)=>{events.push('approval');return value},reconciliationApproval:()=>{throw Error('No expired reconciliation bypass')},poolOptions:()=>{events.push('transport');return {}},roleKeys:['runtime','operator','capture','publisher','control'],assertJsDriverEnvironment:()=>{}},
|
||||||
'./database-writer-bootstrap-operator.mjs':{deploymentRequest:(value:unknown)=>value},'../src/db/database-source-binding.js':{createDatabaseSourceBindingVerifier:()=>({})},
|
'./database-writer-bootstrap-operator.mjs':{deploymentRequest:(value:unknown)=>value},'../src/db/database-source-binding.js':{createDatabaseSourceBindingVerifier:()=>{throw Error('Preflight must never fall back to unprotected source verification')}},
|
||||||
pg:{Pool},'./writer-profile/publisher.mjs':{createConnectedPublisher:()=>({preflightCurrentProfile:async()=>{events.push('preflight');if(asyncError){const activePool=retainedPools.at(-1);assert(activePool);activePool.emit('error',Error('Async pool fault'))}return result},publishCurrentProfile:()=>{throw Error('No publish')},reconcilePendingPublication:()=>{throw Error('No reconcile')}})},
|
'./writer-profile/protected-source-registry.mjs':{createProtectedDatabaseSourceBindingVerifier:(pin:{registryDirectory:string;currentHeadSha256:string})=>{events.push('protected-source');assert.equal(pin.registryDirectory,'source-registry');assert.equal(pin.currentHeadSha256,request.sourceHeadSha256);if(protectedRefused)throw Error('Protected source refused');return protectedVerifier}},
|
||||||
|
pg:{Pool},'./writer-profile/publisher.mjs':{createConnectedPublisher:(_installation:unknown,_collect:unknown,verifier:unknown)=>{assert.equal(verifier,protectedVerifier);return {preflightCurrentProfile:async()=>{events.push('preflight');if(asyncError){const activePool=retainedPools.at(-1);assert(activePool);activePool.emit('error',Error('Async pool fault'))}return result},publishCurrentProfile:()=>{throw Error('No publish')},reconcilePendingPublication:()=>{throw Error('No reconcile')}}}},
|
||||||
'./writer-profile/catalogue.mjs':{collectSource44Catalogue:()=>{},approvedDefinition:()=> 'b'.repeat(64)},
|
'./writer-profile/catalogue.mjs':{collectSource44Catalogue:()=>{},approvedDefinition:()=> 'b'.repeat(64)},
|
||||||
}
|
}
|
||||||
const flags=['bootstrap','bootstrap-sha256','approval','approval-sha256','definition','definition-sha256','transport','transport-sha256','ca','source-registry','source-spki-sha256','journal'],args=['preflight',...flags.flatMap(flag=>['--'+flag,flag.endsWith('sha256')?'a'.repeat(64):flag])]
|
const flags=['bootstrap','bootstrap-sha256','approval','approval-sha256','definition','definition-sha256','transport','transport-sha256','ca','source-registry','source-spki-sha256','journal'],args=['preflight',...flags.flatMap(flag=>['--'+flag,flag.endsWith('sha256')?'a'.repeat(64):flag])]
|
||||||
const output=await compile<OperatorModule>(cli,modules).main(args)
|
const output=await compile<OperatorModule>(cli,modules).main(args)
|
||||||
assert.equal(output.event,'application-writer-profile-preflight');assert.equal(output.outcome,'matched');assert.equal(output.writerAuthorized,false);assert.equal(output.readyAuthorized,false)
|
assert.equal(output.event,'application-writer-profile-preflight');assert.equal(output.outcome,'matched');assert.equal(output.platformProtectionVerified,true);assert.equal(output.writerAuthorized,false);assert.equal(output.readyAuthorized,false)
|
||||||
assert.deepEqual(reads,['bootstrap','approval','definition','transport','ca']);assert.deepEqual(events,['approval','transport','preflight','pool-end'])
|
assert.deepEqual(reads,['bootstrap','approval','definition','transport','ca']);assert.deepEqual(events,['approval','transport','protected-source','preflight','pool-end'])
|
||||||
failEnd=true;await assert.rejects(compile<OperatorModule>(cli,modules).main(args),/Pool cleanup unconfirmed/)
|
failEnd=true;await assert.rejects(compile<OperatorModule>(cli,modules).main(args),/Pool cleanup unconfirmed/)
|
||||||
failEnd=false;asyncError=true;await assert.rejects(compile<OperatorModule>(cli,modules).main(args),/Publisher terminal unconfirmed/)
|
failEnd=false;asyncError=true;await assert.rejects(compile<OperatorModule>(cli,modules).main(args),/Publisher terminal unconfirmed/)
|
||||||
|
asyncError=false;result.platformProtectionVerified=false;await assert.rejects(compile<OperatorModule>(cli,modules).main(args),/Publisher terminal unconfirmed/)
|
||||||
|
result.platformProtectionVerified=true;protectedRefused=true;const before=retainedPools.length
|
||||||
|
await assert.rejects(compile<OperatorModule>(cli,modules).main(args),/Protected source refused/);assert.equal(retainedPools.length,before,'Protection failure must not open a Pool or fall back')
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
/** External SDK consumer. No application/store/runtime/database imports. */
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { randomUUID } from 'node:crypto'
|
||||||
|
import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'
|
||||||
|
|
||||||
|
const endpoint = new URL(process.env.OWN_MCP_ENDPOINT)
|
||||||
|
assert.equal(process.env.OWN_EXTERNAL_CLIENT, 'true')
|
||||||
|
assert.equal(endpoint.hostname, '127.0.0.1')
|
||||||
|
assert.notEqual(process.pid, Number(process.env.OWN_SERVER_PID))
|
||||||
|
const client = new Client({ name: 'owned-linked-bridge-vlan-sdk', version: '1.0.0' })
|
||||||
|
const boundedFetch = async (input, init) => {
|
||||||
|
const url = new URL(typeof input === 'string' || input instanceof URL ? input : input.url)
|
||||||
|
assert.equal(url.origin, endpoint.origin)
|
||||||
|
return fetch(input, { ...init, redirect: 'manual', signal: AbortSignal.timeout(10000) })
|
||||||
|
}
|
||||||
|
const payload = result => {
|
||||||
|
const text = result.content.find(item => item.type === 'text')?.text
|
||||||
|
assert(text && Buffer.byteLength(text) < 131072)
|
||||||
|
return JSON.parse(text)
|
||||||
|
}
|
||||||
|
const call = async (name, args) => {
|
||||||
|
const result = await client.callTool({ name, arguments: args }, undefined, { timeout: 10000 })
|
||||||
|
assert(!result.isError, JSON.stringify(payload(result)))
|
||||||
|
return payload(result)
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await client.connect(new StreamableHTTPClientTransport(endpoint, {
|
||||||
|
requestInit: { headers: { authorization: 'Bearer ' + process.env.OWN_MCP_TOKEN } }, fetch: boundedFetch,
|
||||||
|
}), { timeout: 10000 })
|
||||||
|
const tools = (await client.listTools({}, { timeout: 10000 })).tools
|
||||||
|
const read = tools.find(tool => tool.name === 'mm_bridge_vlan_configuration_get')
|
||||||
|
const configure = tools.find(tool => tool.name === 'mm_bridge_vlan_configuration_plan')
|
||||||
|
assert(read && configure)
|
||||||
|
assert.match(read.description, /Полная конфигурация bridge VLAN/)
|
||||||
|
assert.match(read.description, /Возвращает данные только из разрешённой области/)
|
||||||
|
assert.match(configure.description, /Связанный план VLAN membership, PVID и bridge filtering/)
|
||||||
|
assert.match(configure.description, /Подготавливает неизменяемый план/)
|
||||||
|
assert.equal(read.annotations.readOnlyHint, true)
|
||||||
|
assert.equal(configure.annotations.readOnlyHint, false)
|
||||||
|
assert.deepEqual(read.inputSchema.required, ['serverId'])
|
||||||
|
assert.deepEqual(configure.inputSchema.required, ['serverId', 'bridgeId', 'desired'])
|
||||||
|
const desiredSchema = configure.inputSchema.properties.desired
|
||||||
|
for (const name of ['ports', 'vlans', 'removePortIds', 'removeVlanIds']) assert(desiredSchema.properties[name])
|
||||||
|
const serverId = Number(process.env.OWN_ALLOWED_SERVER)
|
||||||
|
const state = (await call(read.name, { serverId })).state
|
||||||
|
assert.equal(state.bridges[0]['.id'], '*1')
|
||||||
|
assert(state.interfaces.some(item => item.resource === 'ethernet' && item.id === '*3' && item.name === 'ether3'))
|
||||||
|
assert.equal(state.vlans[0]['vlan-ids'], '10')
|
||||||
|
const cpu = { resource: 'bridge', id: '*1' }, access = { resource: 'ethernet', id: '*2' }, trunk = { resource: 'ethernet', id: '*3' }
|
||||||
|
const plan = await call(configure.name, { serverId, bridgeId: '*1', desired: {
|
||||||
|
filtering: true, pvid: 1,
|
||||||
|
ports: [{ id: '*10', interface: access, pvid: 10, ingressFiltering: true, frameTypes: 'admit-all', disabled: false },
|
||||||
|
{ interface: trunk, pvid: 1, ingressFiltering: true, frameTypes: 'admit-only-vlan-tagged', disabled: false }],
|
||||||
|
vlans: [{ id: '*11', vlanIds: '10', tagged: [cpu], untagged: [access], disabled: false },
|
||||||
|
{ vlanIds: '20', tagged: [cpu, trunk], untagged: [], disabled: false }], removePortIds: [], removeVlanIds: [],
|
||||||
|
} })
|
||||||
|
assert.equal(plan.state, 'awaiting-approval')
|
||||||
|
assert.equal(typeof plan.planId, 'string')
|
||||||
|
assert.equal(plan.changes.requiresHuman, true)
|
||||||
|
assert.deepEqual(plan.changes.steps.map(item => [item.step.resource, item.step.action]),
|
||||||
|
[['bridge_vlan', 'create'], ['bridge_port', 'create'], ['bridge', 'update']])
|
||||||
|
const denied = await client.callTool({ name: 'mm_change_apply', arguments: { planId: plan.planId, idempotencyKey: randomUUID() } }, undefined, { timeout: 10000 })
|
||||||
|
assert.equal(denied.isError, true)
|
||||||
|
const deniedPayload = payload(denied)
|
||||||
|
assert(JSON.stringify(deniedPayload).includes('APPROVAL_REQUIRED'))
|
||||||
|
const readback = (await call(read.name, { serverId })).state
|
||||||
|
assert.deepEqual(readback, state)
|
||||||
|
process.send({ kind: 'result', result: { externalSdkProcess: true, transport: 'streamable-http', clientPid: process.pid,
|
||||||
|
listedTools: [read, configure], state, plan, applyDenial: deniedPayload, unchangedReadback: true } })
|
||||||
|
} finally {
|
||||||
|
await client.close()
|
||||||
|
process.disconnect?.()
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { spawn } from 'node:child_process'
|
||||||
|
import { createServer } from 'node:http'
|
||||||
|
import { writeFile } from 'node:fs/promises'
|
||||||
|
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||||
|
import { isAbsolute, resolve } from 'node:path'
|
||||||
|
|
||||||
|
assert.equal(process.env.OWN_EXTERNAL_PROCESS_PG, 'true')
|
||||||
|
assert.equal(process.env.NODE_ENV, 'test')
|
||||||
|
assert(isAbsolute(process.env.OWN_EXTERNAL_SOURCE_ROOT!))
|
||||||
|
assert(isAbsolute(process.env.OWN_EXTERNAL_RESULT_FILE!))
|
||||||
|
const database = new URL(process.env.DATABASE_URL!)
|
||||||
|
assert.equal(database.hostname, '127.0.0.1'); assert.equal(database.username, 'mcp_test')
|
||||||
|
assert.equal(database.pathname, '/postgres'); assert.notEqual(database.port, '5432')
|
||||||
|
const source = (path: string) => pathToFileURL(resolve(process.env.OWN_EXTERNAL_SOURCE_ROOT!, 'backend/src', path)).href
|
||||||
|
const rows: Record<string, Record<string, string>[]> = {
|
||||||
|
'/interface/ethernet': [{ '.id': '*2', name: 'ether2', disabled: 'false' }, { '.id': '*3', name: 'ether3', disabled: 'false' }, { '.id': '*4', name: 'ether-mgmt', disabled: 'false' }],
|
||||||
|
'/interface/bridge': [{ '.id': '*1', name: 'br-users', pvid: '1', 'vlan-filtering': 'false', 'frame-types': 'admit-all', 'ingress-filtering': 'true', mvrp: 'false', disabled: 'false' }],
|
||||||
|
'/interface/bridge/port': [{ '.id': '*10', bridge: 'br-users', interface: 'ether2', pvid: '10', 'ingress-filtering': 'true', 'frame-types': 'admit-all', disabled: 'false' }],
|
||||||
|
'/interface/bridge/vlan': [{ '.id': '*11', bridge: 'br-users', 'vlan-ids': '10', tagged: 'br-users', untagged: 'ether2', disabled: 'false', dynamic: 'false' }],
|
||||||
|
'/interface/vlan': [], '/interface/gre': [], '/interface/wireguard': [], '/interface/vxlan': [],
|
||||||
|
'/ip/address': [{ '.id': '*20', interface: 'ether-mgmt', address: '127.0.0.1/8', disabled: 'false', dynamic: 'false' }], '/ipv6/address': [],
|
||||||
|
}
|
||||||
|
const requests: string[] = []
|
||||||
|
let writeAttempts = 0
|
||||||
|
const router = createServer((req, res) => {
|
||||||
|
res.setHeader('content-type', 'application/json')
|
||||||
|
const path = decodeURIComponent(new URL(req.url!, 'http://127.0.0.1').pathname.replace(/^\/rest/, ''))
|
||||||
|
requests.push(req.method + ' ' + path)
|
||||||
|
if (req.method !== 'GET') { writeAttempts++; res.writeHead(405); res.end('{}'); return }
|
||||||
|
if (req.headers.authorization !== 'Basic ' + Buffer.from('fixture:fixture-only').toString('base64')) { res.writeHead(401); res.end('{}'); return }
|
||||||
|
if (!Object.hasOwn(rows, path)) { res.writeHead(404); res.end('{}'); return }
|
||||||
|
res.end(JSON.stringify(rows[path]))
|
||||||
|
})
|
||||||
|
await new Promise<void>(done => router.listen(0, '127.0.0.1', done))
|
||||||
|
const { pool, closePool } = await import(source('db/index.js'))
|
||||||
|
const { applySqlMigrations } = await import(source('db/migrate.js'))
|
||||||
|
const { mcpConnectionCreateSchema } = await import('@mmapp/contracts/mcp')
|
||||||
|
const { createMcpConnection, updateMcpSettings } = await import(source('modules/mcp/store.js'))
|
||||||
|
const { buildApp } = await import(source('index.js'))
|
||||||
|
const actor = { id: 'owned-bridge-vlan-sdk-owner', email: 'bridge-sdk@example.invalid', name: 'Owned bridge VLAN SDK owner', apps: ['mm'], permissions: ['mm:settings:admin', 'mm:mcp:admin', 'mm:network:admin', 'mm:servers:admin'], expiresAt: Date.now() + 3600000, issuedAt: Date.now() - 1000 }
|
||||||
|
let app: Awaited<ReturnType<typeof buildApp>> | undefined
|
||||||
|
let child: ReturnType<typeof spawn> | undefined
|
||||||
|
let childExit: Promise<number> | undefined
|
||||||
|
try {
|
||||||
|
await applySqlMigrations(pool)
|
||||||
|
const serverId = (await pool.query("INSERT INTO servers(name,host,port,use_ssl,enabled,username,password) VALUES('owned bridge VLAN SDK','127.0.0.1',$1,false,true,'fixture','fixture-only') RETURNING id", [(router.address() as { port: number }).port])).rows[0].id
|
||||||
|
await updateMcpSettings({ enabled: true }, actor)
|
||||||
|
app = await buildApp({ startScheduler: false, logger: false })
|
||||||
|
await app.listen({ host: '127.0.0.1', port: 0 })
|
||||||
|
const endpoint = `http://127.0.0.1:${(app.server.address() as { port: number }).port}/mcp`
|
||||||
|
const issued = await createMcpConnection(mcpConnectionCreateSchema.parse({ name: 'Owned bridge VLAN external SDK', mode: 'autonomous',
|
||||||
|
expiresAt: new Date(Date.now() + 1200000).toISOString(), permissions: ['mm:network:write'],
|
||||||
|
scope: { serverIds: [serverId], operationIds: ['network.bridge-vlan.read', 'network.bridge-vlan.configure', 'changes.apply'], allowGlobal: false },
|
||||||
|
limits: { callsPerMinute: 120, mutationsPerHour: 100 } }), actor)
|
||||||
|
let result: unknown, stdout = '', stderr = ''
|
||||||
|
child = spawn(process.execPath, [fileURLToPath(new URL('./bridge-vlan-client.mjs', import.meta.url))], { cwd: process.cwd(), windowsHide: true,
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe', 'ipc'], env: { ...Object.fromEntries(['SystemRoot', 'WINDIR', 'TEMP', 'TMP', 'PATH', 'Path'].filter(k => process.env[k]).map(k => [k, process.env[k]!])),
|
||||||
|
OWN_EXTERNAL_CLIENT: 'true', OWN_MCP_ENDPOINT: endpoint, OWN_MCP_TOKEN: issued.token, OWN_SERVER_PID: String(process.pid), OWN_ALLOWED_SERVER: String(serverId) } })
|
||||||
|
child.on('message', (message: { kind: string; result?: unknown }) => { if (message.kind === 'result') result = message.result })
|
||||||
|
child.stdout?.on('data', chunk => { stdout += String(chunk); if (stdout.length > 1048576) child?.kill() })
|
||||||
|
child.stderr?.on('data', chunk => { stderr += String(chunk); if (stderr.length > 1048576) child?.kill() })
|
||||||
|
childExit = new Promise<number>((done, reject) => { child!.once('exit', code => done(code ?? 1)); child!.once('error', reject) })
|
||||||
|
const timeout = setTimeout(() => child?.kill(), 60000)
|
||||||
|
const code = await childExit; clearTimeout(timeout)
|
||||||
|
assert.equal(code, 0, stderr.slice(0, 3000)); assert(result)
|
||||||
|
assert.equal(writeAttempts, 0)
|
||||||
|
assert.equal((await pool.query('SELECT count(*)::int AS count FROM mcp_operations')).rows[0].count, 0)
|
||||||
|
const plans = (await pool.query('SELECT operation_id,state,approved_by FROM mcp_plans')).rows
|
||||||
|
assert.equal(plans.length, 1); assert.equal(plans[0].operation_id, 'network.bridge-vlan.configure')
|
||||||
|
assert.equal(plans[0].state, 'awaiting-approval'); assert.equal(plans[0].approved_by, null)
|
||||||
|
const audit = (await pool.query('SELECT tool,status,error_code FROM mcp_requests ORDER BY created_at')).rows
|
||||||
|
assert(audit.some(row => row.error_code === 'APPROVAL_REQUIRED'))
|
||||||
|
const serialized = JSON.stringify({ result, stdout, stderr, audit })
|
||||||
|
assert(!serialized.includes(issued.token)); assert(!serialized.includes('fixture-only'))
|
||||||
|
const final = { ...(result as object), actualOwnPg: true, actualProductionHttpServer: true, serverFixturePid: process.pid,
|
||||||
|
connectionMode: 'autonomous', plans, audit, nativeReadRequests: requests, nativeWriteAttempts: writeAttempts,
|
||||||
|
appliedOperations: 0, syntheticRouterOnly: true, productionDatabaseTouched: false, portalContacted: false }
|
||||||
|
await writeFile(process.env.OWN_EXTERNAL_RESULT_FILE!, JSON.stringify(final, null, 2))
|
||||||
|
console.log('Owned bridge VLAN SDK passed:', JSON.stringify({ listedTools: 2, planState: plans[0].state, nativeReadRequests: requests.length, nativeWriteAttempts: writeAttempts }))
|
||||||
|
} finally {
|
||||||
|
if (child && child.exitCode === null && child.signalCode === null) { child.kill(); await childExit }
|
||||||
|
try { await app?.close() } finally { try { await closePool() } finally { router.closeIdleConnections(); await new Promise<void>((done, reject) => router.close(error => error ? reject(error) : done())) } }
|
||||||
|
}
|
||||||
@@ -6,10 +6,14 @@ import { randomBytes, createHash } from 'node:crypto'
|
|||||||
import { createServer } from 'node:net'
|
import { createServer } from 'node:net'
|
||||||
import { spawn } from 'node:child_process'
|
import { spawn } from 'node:child_process'
|
||||||
const sourceRoot = resolve(process.argv[2] ?? fileURLToPath(new URL('../../../', import.meta.url)))
|
const sourceRoot = resolve(process.argv[2] ?? fileURLToPath(new URL('../../../', import.meta.url)))
|
||||||
const output = resolve(sourceRoot, 'tmp/mcp-external-process-acceptance'); await mkdir(output, { recursive: true })
|
const scenario = process.argv[3] ?? 'acceptance'
|
||||||
|
if (!['acceptance', 'bridge-vlan'].includes(scenario)) throw Error('Unsupported owned external scenario')
|
||||||
|
const output = resolve(sourceRoot, scenario === 'bridge-vlan' ? 'tmp/mcp-bridge-vlan-sdk-20261105' : 'tmp/mcp-external-process-acceptance'); await mkdir(output, { recursive: true })
|
||||||
const paths = ['backend/src/scripts/mcp-stdio.ts', 'backend/src/routes/mcp-transport.ts', 'backend/src/routes/mcp-oauth.ts', 'backend/src/modules/mcp/store.ts',
|
const paths = ['backend/src/scripts/mcp-stdio.ts', 'backend/src/routes/mcp-transport.ts', 'backend/src/routes/mcp-oauth.ts', 'backend/src/modules/mcp/store.ts',
|
||||||
'backend/src/modules/mcp/runtime.ts', 'backend/src/modules/mcp/actions.ts', 'backend/src/modules/mcp/security.ts', 'backend/src/modules/mcp/protocol-limits.ts',
|
'backend/src/modules/mcp/runtime.ts', 'backend/src/modules/mcp/actions.ts', 'backend/src/modules/mcp/security.ts', 'backend/src/modules/mcp/protocol-limits.ts',
|
||||||
'backend/src/modules/mcp/portal-revocation.ts', 'backend/src/plugins/auth.ts', 'backend/src/index.ts', 'node_modules/@modelcontextprotocol/client/package.json', 'node_modules/@modelcontextprotocol/server/package.json']
|
'backend/src/modules/mcp/portal-revocation.ts', 'backend/src/plugins/auth.ts', 'backend/src/index.ts', 'node_modules/@modelcontextprotocol/client/package.json', 'node_modules/@modelcontextprotocol/server/package.json']
|
||||||
|
if (scenario === 'bridge-vlan') paths.push('backend/scripts/mcp-external-process/bridge-vlan-client.mjs', 'backend/scripts/mcp-external-process/bridge-vlan-fixture.ts',
|
||||||
|
'backend/src/modules/mcp/bridge-vlan-actions.ts', 'backend/src/services/bridge-vlan-configuration.ts', 'packages/contracts/src/bridge-vlan.ts')
|
||||||
async function pins() { const result = []; for (const path of paths) result.push({ path, sha256: createHash('sha256').update(await readFile(resolve(sourceRoot, path))).digest('hex') }); return result }
|
async function pins() { const result = []; for (const path of paths) result.push({ path, sha256: createHash('sha256').update(await readFile(resolve(sourceRoot, path))).digest('hex') }); return result }
|
||||||
const before = await pins(), directory = await mkdtemp(join(resolve(sourceRoot, 'tmp'), 'mm-mcp-external-')), state = join(directory, 'state'); await mkdir(state)
|
const before = await pins(), directory = await mkdtemp(join(resolve(sourceRoot, 'tmp'), 'mm-mcp-external-')), state = join(directory, 'state'); await mkdir(state)
|
||||||
const listener = createServer(); await new Promise(resolve => listener.listen(0, '127.0.0.1', resolve)); const port = listener.address().port; await new Promise(resolve => listener.close(resolve))
|
const listener = createServer(); await new Promise(resolve => listener.listen(0, '127.0.0.1', resolve)); const port = listener.address().port; await new Promise(resolve => listener.close(resolve))
|
||||||
@@ -19,7 +23,7 @@ const postgres = new EmbeddedPostgres({ databaseDir: join(directory, 'pg'), port
|
|||||||
try {
|
try {
|
||||||
await postgres.initialise(); await postgres.start()
|
await postgres.initialise(); await postgres.start()
|
||||||
process.exitCode = await new Promise((done, reject) => {
|
process.exitCode = await new Promise((done, reject) => {
|
||||||
const child = spawn(process.execPath, ['--import', import.meta.resolve('tsx'), fileURLToPath(new URL('./server-fixture.ts', import.meta.url))], { cwd: directory,
|
const child = spawn(process.execPath, ['--import', import.meta.resolve('tsx'), fileURLToPath(new URL(scenario === 'bridge-vlan' ? './bridge-vlan-fixture.ts' : './server-fixture.ts', import.meta.url))], { cwd: directory,
|
||||||
windowsHide: true, stdio: 'inherit', env: { ...Object.fromEntries(['SystemRoot', 'WINDIR', 'TEMP', 'TMP', 'PATH', 'Path'].filter(k => process.env[k]).map(k => [k, process.env[k]])),
|
windowsHide: true, stdio: 'inherit', env: { ...Object.fromEntries(['SystemRoot', 'WINDIR', 'TEMP', 'TMP', 'PATH', 'Path'].filter(k => process.env[k]).map(k => [k, process.env[k]])),
|
||||||
OWN_EXTERNAL_CLEANUP_FAULT: process.env.OWN_EXTERNAL_CLEANUP_FAULT, OWN_EXTERNAL_SOURCE_ROOT: sourceRoot, OWN_EXTERNAL_RESULT_FILE: join(output, 'result.json'), NODE_ENV: 'test', OWN_EXTERNAL_PROCESS_PG: 'true', DATABASE_URL: `postgresql://mcp_test:${password}@127.0.0.1:${port}/postgres`, DATABASE_PATH: join(directory, 'no-legacy.sqlite'),
|
OWN_EXTERNAL_CLEANUP_FAULT: process.env.OWN_EXTERNAL_CLEANUP_FAULT, OWN_EXTERNAL_SOURCE_ROOT: sourceRoot, OWN_EXTERNAL_RESULT_FILE: join(output, 'result.json'), NODE_ENV: 'test', OWN_EXTERNAL_PROCESS_PG: 'true', DATABASE_URL: `postgresql://mcp_test:${password}@127.0.0.1:${port}/postgres`, DATABASE_PATH: join(directory, 'no-legacy.sqlite'),
|
||||||
MCP_STATE_DIR: state, AUTH_REQUIRED: 'true', AUTH_JWT_SECRET: 'owned-external-synthetic-issuer-test-only', AUTH_ISSUER: 'https://synthetic-portal.invalid', AUTH_PORTAL_URL: 'https://synthetic-portal.invalid' } })
|
MCP_STATE_DIR: state, AUTH_REQUIRED: 'true', AUTH_JWT_SECRET: 'owned-external-synthetic-issuer-test-only', AUTH_ISSUER: 'https://synthetic-portal.invalid', AUTH_PORTAL_URL: 'https://synthetic-portal.invalid' } })
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import assert from "node:assert/strict"
|
import assert from "node:assert/strict"
|
||||||
|
import { createHash } from "node:crypto"
|
||||||
import { spawn } from "node:child_process"
|
import { spawn } from "node:child_process"
|
||||||
import { createServer } from "node:net"
|
import { createServer } from "node:net"
|
||||||
import { fileURLToPath, pathToFileURL } from "node:url"
|
import { fileURLToPath, pathToFileURL } from "node:url"
|
||||||
@@ -49,6 +50,61 @@ try {
|
|||||||
await updateMcpSettings({ enabled: true }, actor)
|
await updateMcpSettings({ enabled: true }, actor)
|
||||||
app = await buildApp({ startScheduler: false, logger: false }); await app.listen({ port, host: "127.0.0.1" })
|
app = await buildApp({ startScheduler: false, logger: false }); await app.listen({ port, host: "127.0.0.1" })
|
||||||
const jwt = await new SignJWT({ sub: actor.id, apps: actor.apps, permissions: actor.permissions }).setProtectedHeader({ alg: "HS256" }).setIssuer(env.authIssuer).setIssuedAt().setExpirationTime("1h").sign(new TextEncoder().encode(env.jwtSecret))
|
const jwt = await new SignJWT({ sub: actor.id, apps: actor.apps, permissions: actor.permissions }).setProtectedHeader({ alg: "HS256" }).setIssuer(env.authIssuer).setIssuedAt().setExpirationTime("1h").sign(new TextEncoder().encode(env.jwtSecret))
|
||||||
|
const portalHeaders = { authorization: `Bearer ${jwt}`, "content-type": "application/json" }
|
||||||
|
assert.equal((await pool.query("SELECT count(*)::int AS count FROM mcp_connections WHERE owner_id=$1", [actor.id])).rows[0].count, 0)
|
||||||
|
const callback = "http://localhost:45678/callback?registered=1"
|
||||||
|
const registration = await fetch(origin + "/api/mcp/oauth/clients", { method: "POST", headers: portalHeaders,
|
||||||
|
body: JSON.stringify({ name: "Owned denial fixture", redirectUris: [callback] }) })
|
||||||
|
assert.equal(registration.status, 200)
|
||||||
|
const { clientId } = await registration.json() as { clientId: string }
|
||||||
|
const consent = (payload: Record<string, unknown>, headers = portalHeaders) => fetch(origin + "/api/mcp/oauth/consent", { method: "POST", headers, body: JSON.stringify(payload) })
|
||||||
|
const authorize = async (state: string) => {
|
||||||
|
const parameters = new URLSearchParams({ client_id: clientId, redirect_uri: callback, response_type: "code", resource: endpoint,
|
||||||
|
code_challenge_method: "S256", code_challenge: createHash("sha256").update("v".repeat(64)).digest("base64url"), state })
|
||||||
|
const response = await fetch(`${origin}/oauth/authorize?${parameters}`, { redirect: "manual" })
|
||||||
|
assert.equal(response.status, 302)
|
||||||
|
const requestId = new URL(response.headers.get("location")!).searchParams.get("oauthRequest")
|
||||||
|
assert.ok(requestId)
|
||||||
|
return requestId
|
||||||
|
}
|
||||||
|
const requestId = await authorize("deny-without-connection")
|
||||||
|
assert.equal((await consent({ requestId, approve: false }, { ...portalHeaders, authorization: "" })).status, 401)
|
||||||
|
assert.equal((await consent({ requestId, approve: true })).status, 400)
|
||||||
|
const refusals = await Promise.all([consent({ requestId, approve: false }), consent({ requestId, approve: false })])
|
||||||
|
assert.deepEqual(refusals.map(response => response.status).sort(), [200, 404])
|
||||||
|
const { redirectUrl } = await refusals.find(response => response.status === 200)!.json() as { redirectUrl: string }
|
||||||
|
const redirect = new URL(redirectUrl)
|
||||||
|
const expected = new URL(callback)
|
||||||
|
expected.searchParams.set("state", "deny-without-connection")
|
||||||
|
expected.searchParams.set("iss", origin)
|
||||||
|
expected.searchParams.set("error", "access_denied")
|
||||||
|
assert.equal(redirect.href, expected.href)
|
||||||
|
const otherOwner = { ...actor, id: "owned-external-process-other-owner" }
|
||||||
|
const unrelated = await createMcpConnection(mcpConnectionCreateSchema.parse({ name: "Unrelated paused connection", mode: "observer",
|
||||||
|
expiresAt: new Date(Date.now() + 600000).toISOString(), permissions: ["mm:servers:read"],
|
||||||
|
scope: { serverIds: [Number(allowed)], operationIds: ["servers.list"], allowGlobal: false } }), otherOwner)
|
||||||
|
await setConnectionStatus(unrelated.connection.id, "paused", otherOwner)
|
||||||
|
const legacyRequestId = await authorize("legacy-denial")
|
||||||
|
const legacy = await consent({ requestId: legacyRequestId, connectionId: unrelated.connection.id, approve: false })
|
||||||
|
assert.equal(legacy.status, 200)
|
||||||
|
assert.equal((await consent({ requestId: legacyRequestId, connectionId: unrelated.connection.id, approve: false })).status, 404)
|
||||||
|
const expired = await createMcpConnection(mcpConnectionCreateSchema.parse({ name: "Expired connection", mode: "observer",
|
||||||
|
expiresAt: new Date(Date.now() + 600000).toISOString(), permissions: ["mm:servers:read"],
|
||||||
|
scope: { serverIds: [Number(allowed)], operationIds: ["servers.list"], allowGlobal: false } }), actor)
|
||||||
|
await pool.query("UPDATE mcp_connections SET policy=jsonb_set(policy,'{expiresAt}',to_jsonb($2::text)) WHERE id=$1", [expired.connection.id, "2000-01-01T00:00:00Z"])
|
||||||
|
const expiredRequestId = await authorize("expired-approval")
|
||||||
|
assert.equal((await consent({ requestId: expiredRequestId, connectionId: expired.connection.id, approve: true })).status, 403)
|
||||||
|
const expiredRequest = (await pool.query("SELECT consumed_at,code_digest,connection_id FROM mcp_oauth_requests WHERE id=$1", [expiredRequestId])).rows[0]
|
||||||
|
assert.deepEqual(expiredRequest, { consumed_at: null, code_digest: null, connection_id: null })
|
||||||
|
assert.equal((await consent({ requestId: expiredRequestId, approve: false })).status, 200)
|
||||||
|
const deniedRows = (await pool.query("SELECT consumed_at,code_digest,connection_id FROM mcp_oauth_requests WHERE client_id=$1", [clientId])).rows
|
||||||
|
assert.equal(deniedRows.length, 3)
|
||||||
|
assert(deniedRows.every(row => row.consumed_at instanceof Date && row.code_digest === null && row.connection_id === null))
|
||||||
|
assert.equal((await pool.query("SELECT count(*)::int AS count FROM mcp_credentials WHERE client_id=$1", [clientId])).rows[0].count, 0)
|
||||||
|
const denialAudit = (await pool.query("SELECT actor_id,connection_id,payload FROM mcp_audit WHERE action='oauth.consent' AND payload->>'clientId'=$1", [clientId])).rows
|
||||||
|
assert.equal(denialAudit.length, 3)
|
||||||
|
assert(denialAudit.every(row => row.actor_id === actor.id && row.connection_id === null && row.payload.approved === false))
|
||||||
|
console.log("OAuth consent: expired approval refused before code issuance, denial uses exact redirect and one-use request without credentials")
|
||||||
const transcript: unknown[] = []; let failure: unknown, result: unknown
|
const transcript: unknown[] = []; let failure: unknown, result: unknown
|
||||||
child = spawn(process.execPath, [fileURLToPath(new URL("./client.mjs", import.meta.url))], { cwd: process.cwd(), windowsHide: true, stdio: ["ignore", "pipe", "pipe", "ipc"],
|
child = spawn(process.execPath, [fileURLToPath(new URL("./client.mjs", import.meta.url))], { cwd: process.cwd(), windowsHide: true, stdio: ["ignore", "pipe", "pipe", "ipc"],
|
||||||
env: { ...Object.fromEntries(["SystemRoot", "WINDIR", "TEMP", "TMP", "PATH", "Path"].filter(k => process.env[k]).map(k => [k, process.env[k]!])),
|
env: { ...Object.fromEntries(["SystemRoot", "WINDIR", "TEMP", "TMP", "PATH", "Path"].filter(k => process.env[k]).map(k => [k, process.env[k]!])),
|
||||||
@@ -90,7 +146,8 @@ try {
|
|||||||
const logs = JSON.stringify((await pool.query("SELECT tool,input,result,error_code FROM mcp_requests ORDER BY created_at")).rows)
|
const logs = JSON.stringify((await pool.query("SELECT tool,input,result,error_code FROM mcp_requests ORDER BY created_at")).rows)
|
||||||
for (const token of [...credentials, jwt, secret]) { assert(!logs.includes(token)); assert(!stdout.includes(token)); assert(!stderr.includes(token)) }
|
for (const token of [...credentials, jwt, secret]) { assert(!logs.includes(token)); assert(!stdout.includes(token)); assert(!stderr.includes(token)) }
|
||||||
const final = { ...(result as object), actualOwnPg: true, actualProductionHttpServer: true, serverFixturePid: process.pid,
|
const final = { ...(result as object), actualOwnPg: true, actualProductionHttpServer: true, serverFixturePid: process.pid,
|
||||||
controlTransitions: transcript, auditCredentialLeakAbsent: true, nativeRouterCalls: 0, productionDatabaseTouched: false }
|
controlTransitions: transcript, auditCredentialLeakAbsent: true, denialWithoutConnection: true,
|
||||||
|
legacyDenialIgnoresPausedForeignConnection: true, nativeRouterCalls: 0, productionDatabaseTouched: false }
|
||||||
await writeFile(process.env.OWN_EXTERNAL_RESULT_FILE!, JSON.stringify(final, null, 2))
|
await writeFile(process.env.OWN_EXTERNAL_RESULT_FILE!, JSON.stringify(final, null, 2))
|
||||||
console.log("Owned external process SDK result:", JSON.stringify(final))
|
console.log("Owned external process SDK result:", JSON.stringify(final))
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
/** Build-time only: copy the exact operator inputs into a scoped Docker context. */
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import {constants} from 'node:fs'
|
||||||
|
import {copyFile,lstat,mkdir} from 'node:fs/promises'
|
||||||
|
import path from 'node:path'
|
||||||
|
import {fileURLToPath,pathToFileURL} from 'node:url'
|
||||||
|
import {OPERATOR_SOURCES} from './database-operator-sources.mjs'
|
||||||
|
|
||||||
|
const scriptsDirectory=path.dirname(fileURLToPath(import.meta.url))
|
||||||
|
const buildInputs=Object.freeze(['build-database-operator.mjs','database-operator-sources.mjs',...OPERATOR_SOURCES])
|
||||||
|
|
||||||
|
export async function stageDatabaseOperatorScripts(destination,source=scriptsDirectory){
|
||||||
|
const from=path.resolve(source),to=path.resolve(destination)
|
||||||
|
assert(from!==to,'A separate operator staging directory is required')
|
||||||
|
// Refuse an incomplete source tree before writing any staged files.
|
||||||
|
for(const file of buildInputs)assert((await lstat(path.join(from,file))).isFile(),'Regular operator build input required: '+file)
|
||||||
|
for(const file of buildInputs){
|
||||||
|
const target=path.join(to,file)
|
||||||
|
await mkdir(path.dirname(target),{recursive:true})
|
||||||
|
await copyFile(path.join(from,file),target,constants.COPYFILE_EXCL)
|
||||||
|
}
|
||||||
|
return buildInputs.length
|
||||||
|
}
|
||||||
|
|
||||||
|
if(process.argv[1]&&import.meta.url===pathToFileURL(path.resolve(process.argv[1])).href){
|
||||||
|
assert(process.argv.length===3&&process.argv[2],'A single Docker scripts destination is required')
|
||||||
|
const files=await stageDatabaseOperatorScripts(process.argv[2])
|
||||||
|
console.log(JSON.stringify({event:'database-operator-inputs-staged',files}))
|
||||||
|
}
|
||||||
@@ -65,8 +65,9 @@ const telemetrySettingsConfiguration = process.argv.includes('--telemetry-settin
|
|||||||
if (telemetrySettingsConfiguration && process.argv.slice(2).some(arg => arg !== '--telemetry-settings-configuration')) throw new Error('Telemetry settings configuration requires its own disposable cluster')
|
if (telemetrySettingsConfiguration && process.argv.slice(2).some(arg => arg !== '--telemetry-settings-configuration')) throw new Error('Telemetry settings configuration requires its own disposable cluster')
|
||||||
const routingReservationSchema = process.argv.includes('--routing-reservation-schema')
|
const routingReservationSchema = process.argv.includes('--routing-reservation-schema')
|
||||||
if (routingReservationSchema && process.argv.slice(2).some(arg => arg !== '--routing-reservation-schema')) throw new Error('Routing reservation schema requires its own disposable cluster')
|
if (routingReservationSchema && process.argv.slice(2).some(arg => arg !== '--routing-reservation-schema')) throw new Error('Routing reservation schema requires its own disposable cluster')
|
||||||
const routingForwardRounds = process.argv.includes('--routing-network-forward-rounds')
|
const routingOriginalEffects = process.argv.includes('--routing-original-effects')
|
||||||
if (routingForwardRounds && process.argv.slice(2).some(arg => arg !== '--routing-network-forward-rounds')) throw new Error('Routing forward rounds requires its own disposable cluster')
|
const routingForwardRounds = routingOriginalEffects || process.argv.includes('--routing-network-forward-rounds')
|
||||||
|
if (routingForwardRounds && process.argv.slice(2).some(arg => arg !== (routingOriginalEffects ? '--routing-original-effects' : '--routing-network-forward-rounds'))) throw new Error('Routing forward rounds requires its own disposable cluster')
|
||||||
const flowActiveServices = process.argv.includes('--flow-active-services')
|
const flowActiveServices = process.argv.includes('--flow-active-services')
|
||||||
if (flowActiveServices && process.argv.slice(2).some(arg => arg !== '--flow-active-services')) throw new Error('Flow active services requires its own disposable cluster')
|
if (flowActiveServices && process.argv.slice(2).some(arg => arg !== '--flow-active-services')) throw new Error('Flow active services requires its own disposable cluster')
|
||||||
const flowActiveRebuild = process.argv.includes('--flow-active-rebuild')
|
const flowActiveRebuild = process.argv.includes('--flow-active-rebuild')
|
||||||
@@ -93,8 +94,11 @@ const operatorPeerMetadata = process.argv.includes('--operator-peer-metadata')
|
|||||||
if (operatorPeerMetadata && process.argv.slice(2).some(arg => arg !== '--operator-peer-metadata')) throw new Error('Operator peer metadata requires a separate disposable cluster without restore')
|
if (operatorPeerMetadata && process.argv.slice(2).some(arg => arg !== '--operator-peer-metadata')) throw new Error('Operator peer metadata requires a separate disposable cluster without restore')
|
||||||
const externalPeerMetadata = process.argv.includes('--external-peer-metadata')
|
const externalPeerMetadata = process.argv.includes('--external-peer-metadata')
|
||||||
if (externalPeerMetadata && process.argv.slice(2).some(arg => arg !== '--external-peer-metadata')) throw new Error('Guarded external peer metadata requires a separate disposable cluster without restore')
|
if (externalPeerMetadata && process.argv.slice(2).some(arg => arg !== '--external-peer-metadata')) throw new Error('Guarded external peer metadata requires a separate disposable cluster without restore')
|
||||||
const mcpOperationOrigin = process.argv.includes('--mcp-operation-origin')
|
const pollOperationRetirement = process.argv.includes('--poll-operation-retirement')
|
||||||
if (mcpOperationOrigin && process.argv.slice(2).some(arg => arg !== '--mcp-operation-origin')) throw new Error('MCP operation root requires a separate disposable cluster without restore')
|
const ordinaryOperationRetirement = process.argv.includes('--ordinary-operation-retirement')
|
||||||
|
const probeOperationRetirement = process.argv.includes('--probe-operation-retirement')
|
||||||
|
const mcpOperationOrigin = process.argv.includes('--mcp-operation-origin') || pollOperationRetirement || ordinaryOperationRetirement || probeOperationRetirement
|
||||||
|
if (mcpOperationOrigin && process.argv.slice(2).some(arg => arg !== (probeOperationRetirement ? '--probe-operation-retirement' : ordinaryOperationRetirement ? '--ordinary-operation-retirement' : pollOperationRetirement ? '--poll-operation-retirement' : '--mcp-operation-origin'))) throw new Error('MCP operation root requires a separate disposable cluster without restore')
|
||||||
const mcpSpeedOriginal = process.argv.includes('--mcp-speed-original')
|
const mcpSpeedOriginal = process.argv.includes('--mcp-speed-original')
|
||||||
if (mcpSpeedOriginal && process.argv.slice(2).some(arg => arg !== '--mcp-speed-original')) throw new Error('MCP speed component requires a separate disposable cluster without restore')
|
if (mcpSpeedOriginal && process.argv.slice(2).some(arg => arg !== '--mcp-speed-original')) throw new Error('MCP speed component requires a separate disposable cluster without restore')
|
||||||
const routerConfigurationGuard = process.argv.includes('--router-configuration-guard')
|
const routerConfigurationGuard = process.argv.includes('--router-configuration-guard')
|
||||||
@@ -242,12 +246,13 @@ try {
|
|||||||
if (regressionOnly) console.log('Selected regression only: primary integration and terminal restore are not run')
|
if (regressionOnly) console.log('Selected regression only: primary integration and terminal restore are not run')
|
||||||
if (profilesOnly) console.log('Profiles-only regression matrix: primary integration NOT RUN; terminal restore NOT RUN; full acceptance is not claimed')
|
if (profilesOnly) console.log('Profiles-only regression matrix: primary integration NOT RUN; terminal restore NOT RUN; full acceptance is not claimed')
|
||||||
const result = regressionOnly || profilesOnly ? 0 : await new Promise((resolve, reject) => {
|
const result = regressionOnly || profilesOnly ? 0 : await new Promise((resolve, reject) => {
|
||||||
const child = spawn(process.execPath, pollMemory ? ['--max-old-space-size=192', '--max-semi-space-size=16', fileURLToPath(new URL('../dist-mcp-memory/services/server-poll-memory.owned.test.js', import.meta.url))] : ['--import', 'tsx', principalSecuritySource ? 'src/modules/mcp/principal-security-source.owned.test.ts' : pollJournalSnapshot ? 'src/services/server-poll-journal-snapshot.owned.test.ts' : pollTerminalArchive ? 'src/services/server-poll-terminal-archive.owned.test.ts' : pollArchiveSchema ? 'src/db/server-poll-terminal-archives.sql.test.ts' : telemetrySettingsConfiguration ? 'src/modules/mcp/telemetry-settings-configuration.owned.test.ts' : routingReservationSchema ? 'src/db/routing-reservation-schema.owned.test.ts' : routingForwardRounds ? 'src/modules/mcp/routing-network-forward-rounds.owned.test.ts' : flowActiveServices ? 'src/services/traffic-flow-active-services.owned.test.ts' : flowActiveRebuild ? 'src/services/traffic-flow-active-rebuild.owned.test.ts' : flowQuality ? 'src/services/traffic-flow-quality.owned.test.ts' : flowWorkerIpc ? 'src/services/traffic-flow-worker-ipc.owned.test.ts' : manualUptime ? 'src/modules/mcp/manual-uptime-http.test.ts' : manualPingGroup ? 'src/modules/mcp/manual-ping-group-http.test.ts' : manualTraffic ? 'src/modules/mcp/manual-traffic-http.test.ts' : manualApiPing ? 'src/modules/mcp/manual-api-ping-http.test.ts' : simpleSpeedChr ? 'src/modules/mcp/simple-speed-chr.test.ts' : simpleSpeed ? 'src/modules/mcp/simple-speed-http.test.ts' : externalPeerMetadata ? 'src/services/bandwidth-external-peer-metadata.sql.test.ts' : operatorPeerMetadata ? 'src/services/bandwidth-operator-metadata.sql.test.ts' : publicRegistryMetadata ? 'src/services/bandwidth-public-registry.sql.test.ts' : operatorDockerSql ? 'src/services/bandwidth-operator-docker-stop.sql.test.ts' : mcpOperationOrigin ? 'src/modules/mcp/operation-execution-origin.sql.test.ts' : mcpSpeedOriginal ? 'src/modules/mcp/speed-bandwidth-original.sql.test.ts' : routerConfigurationGuard ? 'src/services/router-configuration-guard.sql.test.ts' : scheduledSpeedOriginal ? 'src/services/bandwidth-scheduled-speed-original.sql.test.ts' : bandwidthSpeedWorkflow ? 'src/services/bandwidth-speed-workflow.sql.test.ts' : restSpeedOriginal ? 'src/services/bandwidth-rest-speed-original.sql.test.ts' : operationLocks ? 'src/services/operation-config-lock-lease.sql.test.ts' : serviceExecutionOrigin ? 'src/modules/mcp/service-execution-origin.sql.test.ts' : bandwidthNodeSource ? 'src/services/bandwidth-node-source.sql.test.ts' : bandwidthExternalRegistry ? 'src/services/bandwidth-external-node-registry.sql.test.ts' : bandwidthNodeRegistry ? 'src/services/bandwidth-node-registry.sql.test.ts' : bandwidthControlSource ? 'src/services/bandwidth-control-source.sql.test.ts' : bandwidthAdmission ? 'src/services/bandwidth-admission.sql.test.ts' : serverPollStorageFresh ? 'src/services/server-poll-storage-fresh.sql.test.ts' : serverPollStorage ? 'src/services/server-poll-storage.sql.test.ts' : probeStorageFresh ? 'src/services/server-probe-storage-fresh.sql.test.ts' : probeLegacyOrphans ? 'src/services/server-probe-legacy-orphans.sql.test.ts' : diagnosticCancelHost ? 'src/modules/mcp/diagnostic-cancellation-storage.sql.test.ts' : ipsecExportHttp ? 'src/modules/mcp/ipsec-existing-certificate-export-http.sql.test.ts' : ipsecExportJournal ? 'src/services/ipsec-existing-certificate-export-rest-journal.sql.test.ts' : migrationDataInventory ? 'src/db/migration-data-inventory.sql.test.ts' : probeStorageHistory ? 'src/services/server-probe-storage-history.sql.test.ts' : probeStorageJournal ? 'src/services/server-probe-storage-journal.sql.test.ts' : controlledSchemaReference ? 'src/db/controlled-schema-reference.sql.test.ts' : controlledMigrations ? 'src/db/controlled-migrations.sql.test.ts' : databaseSchemaControl ? 'src/db/database-schema-control.sql.test.ts' : migrationSchemaInventory ? 'src/db/migration-schema-inventory.sql.test.ts' : routingNetworkNative ? 'src/modules/mcp/routing-network-native.sql.test.ts' : routingReservationRuntime ? 'src/modules/mcp/routing-reservation-runtime.sql.test.ts' : routingReservations ? 'src/modules/mcp/routing-reservation.sql.test.ts' : collectorPartitionWriters ? 'src/services/collector-partition-writes.sql.test.ts' : serverPollDurable ? 'src/services/server-poll-durable-session.sql.test.ts' : routingRecoveryAuthority ? 'src/modules/mcp/routing-recovery-authority.sql.test.ts' : legacyFlowRebuild ? 'src/test/legacy-flow-rebuild-sql.ts' : probeJournal ? 'src/services/server-probe-journal.sql.test.ts' : flowHostKeys ? 'src/modules/mcp/flow-host-keys-http.test.ts' : flowRebuildMcp ? 'src/modules/mcp/flow-facts-rebuild-http.test.ts' : flowRebuild ? 'src/test/flow-facts-rebuild-sql.ts' : flowWrites ? 'src/services/traffic-flow-write-transaction.pg.test.ts' : nativeConservation ? 'src/modules/mcp/native-conservation-sql.test.ts' : nativeCreateId ? 'src/modules/mcp/native-create-id-http.test.ts' : ipsecAdapters ? "src/mLine truncated
|
const child = spawn(process.execPath, pollMemory ? ['--max-old-space-size=192', '--max-semi-space-size=16', fileURLToPath(new URL('../dist-mcp-memory/services/server-poll-memory.owned.test.js', import.meta.url))] : ['--import', 'tsx', principalSecuritySource ? 'src/modules/mcp/principal-security-source.owned.test.ts' : pollJournalSnapshot ? 'src/services/server-poll-journal-snapshot.owned.test.ts' : pollTerminalArchive ? 'src/services/server-poll-terminal-archive.owned.test.ts' : pollArchiveSchema ? 'src/db/server-poll-terminal-archives.sql.test.ts' : telemetrySettingsConfiguration ? 'src/modules/mcp/telemetry-settings-configuration.owned.test.ts' : routingReservationSchema ? 'src/db/routing-reservation-schema.owned.test.ts' : routingForwardRounds ? 'src/modules/mcp/routing-network-forward-rounds.owned.test.ts' : flowActiveServices ? 'src/services/traffic-flow-active-services.owned.test.ts' : flowActiveRebuild ? 'src/services/traffic-flow-active-rebuild.owned.test.ts' : flowQuality ? 'src/services/traffic-flow-quality.owned.test.ts' : flowWorkerIpc ? 'src/services/traffic-flow-worker-ipc.owned.test.ts' : manualUptime ? 'src/modules/mcp/manual-uptime-http.test.ts' : manualPingGroup ? 'src/modules/mcp/manual-ping-group-http.test.ts' : manualTraffic ? 'src/modules/mcp/manual-traffic-http.test.ts' : manualApiPing ? 'src/modules/mcp/manual-api-ping-http.test.ts' : simpleSpeedChr ? 'src/modules/mcp/simple-speed-chr.test.ts' : simpleSpeed ? 'src/modules/mcp/simple-speed-http.test.ts' : externalPeerMetadata ? 'src/services/bandwidth-external-peer-metadata.sql.test.ts' : operatorPeerMetadata ? 'src/services/bandwidth-operator-metadata.sql.test.ts' : publicRegistryMetadata ? 'src/services/bandwidth-public-registry.sql.test.ts' : operatorDockerSql ? 'src/services/bandwidth-operator-docker-stop.sql.test.ts' : probeOperationRetirement ? 'src/services/server-probe-operation-retirement.owned.test.ts' : ordinaryOperationRetirement ? 'src/modules/mcp/ordinary-operation-retirement.owned.test.ts' : pollOperationRetirement ? 'src/services/server-poll-operation-retirement.owned.test.ts' : mcpOperationOrigin ? 'src/modules/mcp/operation-execution-origin.sql.test.ts' : mcpSpeedOriginal ? 'src/modules/mcp/speed-bandwidth-original.sql.test.ts' : routerConfigurationGuard ? 'src/services/router-configuration-guard.sql.test.ts' : scheduledSpeedOriginal ? 'src/services/bandwidth-scheduled-speed-original.sql.test.ts' : bandwidthSpeedWorkflow ? 'src/services/bandwidth-speed-workflow.sql.test.ts' : restSpeedOriginal ? 'src/services/bandwidth-rest-speed-original.sql.test.ts' : operationLocks ? 'src/services/operation-config-lock-lease.sql.test.ts' : serviceExecutionOrigin ? 'src/modules/mcp/service-execution-origin.sql.test.ts' : bandwidthNodeSource ? 'src/services/bandwidth-node-source.sql.test.ts' : bandwidthExternalRegistry ? 'src/services/bandwidth-external-node-registry.sql.test.ts' : bandwidthNodeRegistry ? 'src/services/bandwidth-node-registry.sql.test.ts' : bandwidthControlSource ? 'src/services/bandwidth-control-source.sql.test.ts' : bandwidthAdmission ? 'src/services/bandwidth-admission.sql.test.ts' : serverPollStorageFresh ? 'src/services/server-poll-storage-fresh.sql.test.ts' : serverPollStorage ? 'src/services/server-poll-storage.sql.test.ts' : probeStorageFresh ? 'src/services/server-probe-storage-fresh.sql.test.ts' : probeLegacyOrphans ? 'src/services/server-probe-legacy-orphans.sql.test.ts' : diagnosticCancelHost ? 'src/modules/mcp/diagnostic-cancellation-storage.sql.test.ts' : ipsecExportHttp ? 'src/modules/mcp/ipsec-existing-certificate-export-http.sql.test.ts' : ipsecExportJournal ? 'src/services/ipsec-existing-certificate-export-rest-journal.sql.test.ts' : migrationDataInventory ? 'src/db/migration-data-inventory.sql.test.ts' : probeStorageHistory ? 'src/services/server-probe-storage-history.sql.test.ts' : probeStorageJournal ? 'src/services/server-probe-storage-journal.sql.test.ts' : controlledSchemaReference ? 'src/db/controlled-schema-reference.sql.test.ts' : controlledMigrations ? 'src/db/controlled-migrations.sql.test.ts' : databaseSchemaControl ? 'src/db/database-schema-control.sql.test.ts' : migrationSchemaInventory ? 'src/db/migration-schema-inventory.sql.test.ts' : routingNetworkNative ? 'src/modules/mcp/routing-network-native.sql.test.ts' : routingReservationRuntime ? 'src/modules/mcp/routing-reservation-runtime.sql.test.ts' : routingReservations ? 'src/modules/mcp/routing-reservation.sql.test.ts' : collectorPartitionWriters ? 'src/services/collector-partition-writes.sql.test.ts' : serverPollDurable ? 'src/services/server-poll-durable-session.sql.test.ts' : routingRecoveryAuthority ? 'src/modules/mcp/routing-recovery-authority.sql.test.ts' : legacyFlowRebuild ? 'src/test/legacy-flow-rebuild-sql.ts' : probeJournal ? 'src/services/server-probe-journal.sql.test.ts' : flowHostKeys ? 'src/modules/mcp/flow-host-keys-http.test.ts' : flowRebuildMcp ? 'src/modules/mcp/flow-facts-rebuild-http.test.ts' : flowRebuildLine truncated
|
||||||
cwd: pollMemory ? directory : new URL('../', import.meta.url), stdio: 'inherit', env: { ...(pollMemory ? pollMemoryEnvironment : process.env),
|
cwd: pollMemory ? directory : new URL('../', import.meta.url), stdio: 'inherit', env: { ...(pollMemory ? pollMemoryEnvironment : process.env),
|
||||||
|
...(routingOriginalEffects ? { MMAPP_OWN_ROUTING_ORIGINAL_EFFECTS: 'true' } : {}),
|
||||||
DATABASE_URL: `postgresql://mcp_test:${password}@127.0.0.1:${port}/postgres`,
|
DATABASE_URL: `postgresql://mcp_test:${password}@127.0.0.1:${port}/postgres`,
|
||||||
DATABASE_PATH: join(directory, 'no-legacy-database.sqlite'), SQLITE_IMPORT_FULL_HISTORY: 'false',
|
DATABASE_PATH: join(directory, 'no-legacy-database.sqlite'), SQLITE_IMPORT_FULL_HISTORY: 'false',
|
||||||
MCP_STATE_DIR: join(directory, 'state'), AUTH_REQUIRED: 'true', AUTH_JWT_SECRET: 'mcp-integration-test-only', MMAPP_TEST_WG_EXPORT_ONLY: String(wireguardExport),
|
MCP_STATE_DIR: join(directory, 'state'), AUTH_REQUIRED: 'true', AUTH_JWT_SECRET: 'mcp-integration-test-only', MMAPP_TEST_WG_EXPORT_ONLY: String(wireguardExport),
|
||||||
MCP_PUBLIC_URL: 'http://localhost:8000/mcp', NODE_ENV: 'test', PATH: testPath, NODE_OPTIONS: pollMemory ? '' : process.env.NODE_OPTIONS, MMAPP_OWN_PRINCIPAL_SECURITY_SOURCE: String(principalSecuritySource), MMAPP_OWN_POLL_JOURNAL_SNAPSHOT: String(pollJournalSnapshot), MMAPP_OWN_POLL_MEMORY: String(pollMemory), MMAPP_OWN_POLL_BOOTSTRAP: String(pollMemoryBootstrap), MMAPP_OWN_POLL_COST_DIAGNOSTICS: String(pollMemoryCost), MMAPP_OWN_POLL_ARCHIVE_CLAIM_LOSS: String(pollArchiveClaimLoss), MMAPP_OWN_POLL_ARCHIVE_HISTORY: String(pollArchiveHistory), MMAPP_OWN_POLL_ARCHIVE_COLD: String(pollArchiveCold), MMAPP_OWN_POLL_ARCHIVE_LOST_ACK: String(pollArchiveLostAck), MMAPP_OWN_POLL_TERMINAL_ARCHIVE: String(pollTerminalArchive), MMAPP_OWN_POLL_ARCHIVE_SCHEMA: String(pollArchiveSchema), MMAPP_OWN_TELEMETRY_SETTINGS_CONFIGURATION: String(telemetrySettingsConfiguration), MMAPP_OWN_ROUTING_RESERVATION_SCHEMA: String(routingReservationSchema), MMAPP_OWN_ROUTING_FORWARD_ROUNDS: String(routingForwardRounds), MMAPP_OWN_FLOW_ACTIVE_SERVICES: String(flowActiveServices), MMAPP_OWN_FLOW_ACTIVE_REBUILD: String(flowActiveRebuild), MMAPP_OWN_FLOW_QUALITY: String(flowQuality), MMAPP_OWN_FLOW_WORKER_IPC: String(flowWorkerIpc), MMAPP_OWN_MANUAL_UPTIME: String(manualUptime), MMAPP_OWN_MANUAL_PING_GROUP: String(manualPingGroup), MMAPP_OWN_MANUAL_TRAFFIC: String(manualTraffic), MMAPP_OWN_MANUAL_API_PING: String(manualApiPing), MMAPP_OWN_SIMPLE_SPEED: String(simpleSpeed), MMAPP_OWN_SIMPLE_SPEED_CHR: String(simpleSpeedChr), MMAPP_OWN_DIAGNOSTIC_CANCEL_HOST: String(diagnosticCancelHost), MMAPP_OWN_OPERATOR_DOCKER_SQL: String(operatorDockerSql), MMAPP_OWN_PUBLIC_REGISTRY_METADATA: String(publicRegistryMetadata), MMAPP_OWN_OPERATOR_PEER_METADATA: String(operatorPeerMetadata), MMAPP_OWN_EXTERNAL_PEER_METADATA: String(externalPeerMetadata), MMAPP_OWN_BANDWIDTH_EXTERNAL_REGISTRY: String(externalPeerMetadata || operatorPeerMetadata || publicRegistryMetadata || operatorDockerSql || mcpSpeedOriginal || bandwidthSpeedWorkflow || bandwidthExternalRegistry), MMAPP_OWN_BANDWIDTH_SPEED_WORKFLOW: String(bandwidthSpeedWorkflow), MMAPP_OWN_REST_SPEED_ORIGINAL: String(restSpeedOriginal), MMAPP_OWN_MCP_OPERATION_ORIGIN: String(mcpOperationOrigin), MMAPP_OWN_MCP_SPEED_ORIGINAL: String(mcpSpeedOriginal), MMAPP_OWN_SCHEDULED_SPEED_ORIGINAL: String(scheduledSpeedOriginal), MMAPP_TEST_ROUTER_GUARD_OWNED: String(routerConfigurationGuard), MMAPP_TEST_ROUTER_GUARD_CLUSTER_DISPOSABLE: String(routerConfigurationGuard), MMAPP_TEST_ROUTER_GUARD_ADMIN_URL: routerConfigurationGuard ? `postgresql://mcp_test:${password}@127.0.0.1:${port}/postgres` : '', MMAPP_OWN_BANDWIDTH_ADMISSION: String(externalPeerMetadata || operatorPeerMetadata || publicRegistryMetadata || operatorDockerSql || mcpOperationOrigin || mcpSpeedOriginal || scheduledSpeedOriginal || bandwidthSpeedWorkflow || restSpeedOriginal || bandwidthExternalRegistry || operationLocks || serviceExecutionOrigin || bandwidthAdmission || bandwidthControlSource || bandwidthNodeRegistry || bandwidthNodeSource), MMAPP_OWN_BANDWIDTH_RESTORE: String((bandwidthAdmission || bandwidthControlSource || bandwidthNodeRegistry || bandwidthNodeSource) && process.argv.includes('--restore')), MMAPP_OWN_IPSEC_EXPORT_HTTP: String(ipsecExportHttp), MMAPP_OWN_IPSEC_EXPORT_JOURNAL: String(ipsecExportJournal), MMAPP_OWN_SQL_DB: 'postgres', MMAPP_OWN_PROBE_LEGACY_ORPHANS: String(probeLegacyOrphans), MMAPP_OWN_PROBE_STORAGE_HISTORY: String(probeStorageHistory), MMAPP_OWN_PROBE_STORAGE_FRESH: String(probeStorageFresh), MMAPP_OWN_PROBE_STORAGE_JOURNAL: String(probeStorageJournal || probeStorageFresh), MMAPP_OWN_CONTROLLED_SCHEMA_REFERENCE: String(controlledSchemaReference), MMAPP_OWN_CONTROLLED_MIGRATIONS: String(controlledMigrations), MMAPP_OWN_DATABASE_SCHEMA_CONTROL: String(databaseSchemaControl), MMAPP_OWN_MIGRATION_DATA_INVENTORY: String(migrationDataInventory), MMAPP_OWN_MIGRATION_SCHEMA_INVENTORY: String(migrationSchemaInventory), MMAPP_OWN_ROUTING_NETWORK_NATIVE: String(routingNetworkNative), MMAPP_OWN_ROUTING_RESERVATIONS: String(routingReservations || routingReservationRuntime), MMAPP_OWN_COLLECTOR_PARTITION_WRITERS: String(collectorPartitionWriters), MMAPP_OWN_SERVER_POLL_STORAGE_FRESH: String(serverPollStorageFresh), MMAPP_OWN_SERVER_POLL_STORAGE: String(serverPollStorage), MMAPP_OWN_SERVER_POLL_DURABLE: String(serverPollDurable), MMAPP_OWN_ROUTING_RECOVERY_AUTHORITY: String(routingRecoveryAuthority), MMAPP_OWN_LEGACY_FLOW_REBUILD: String(legacyFlowRebuild), MMAPP_OWN_PROBE_JOURNAL: String(probeJournal), MMAPP_MODULE_COMPLETION_ONLY: String(moduleCompletion), MMAPP_MODULE_COMPLETION_TARGET: completionTarget, MMAPP_OWN_FLOW_HOST_KEYS: String(flowHostKeys), MMAPP_OWN_MCP_FLOW_REBUILD: String(flowRebuildMcp), MMAPP_OWN_FLOW_WRITES: String(flowWrites), MMAPP_OWN_FLOW_REBUILD: String(flowRebuild) },
|
MCP_PUBLIC_URL: 'http://localhost:8000/mcp', NODE_ENV: 'test', PATH: testPath, NODE_OPTIONS: pollMemory ? '' : process.env.NODE_OPTIONS, MMAPP_OWN_PRINCIPAL_SECURITY_SOURCE: String(principalSecuritySource), MMAPP_OWN_POLL_JOURNAL_SNAPSHOT: String(pollJournalSnapshot), MMAPP_OWN_POLL_MEMORY: String(pollMemory), MMAPP_OWN_POLL_BOOTSTRAP: String(pollMemoryBootstrap), MMAPP_OWN_POLL_COST_DIAGNOSTICS: String(pollMemoryCost), MMAPP_OWN_POLL_ARCHIVE_CLAIM_LOSS: String(pollArchiveClaimLoss), MMAPP_OWN_POLL_ARCHIVE_HISTORY: String(pollArchiveHistory), MMAPP_OWN_POLL_ARCHIVE_COLD: String(pollArchiveCold), MMAPP_OWN_POLL_ARCHIVE_LOST_ACK: String(pollArchiveLostAck), MMAPP_OWN_POLL_TERMINAL_ARCHIVE: String(pollTerminalArchive), MMAPP_OWN_POLL_ARCHIVE_SCHEMA: String(pollArchiveSchema), MMAPP_OWN_TELEMETRY_SETTINGS_CONFIGURATION: String(telemetrySettingsConfiguration), MMAPP_OWN_ROUTING_RESERVATION_SCHEMA: String(routingReservationSchema), MMAPP_OWN_ROUTING_FORWARD_ROUNDS: String(routingForwardRounds), MMAPP_OWN_FLOW_ACTIVE_SERVICES: String(flowActiveServices), MMAPP_OWN_FLOW_ACTIVE_REBUILD: String(flowActiveRebuild), MMAPP_OWN_FLOW_QUALITY: String(flowQuality), MMAPP_OWN_FLOW_WORKER_IPC: String(flowWorkerIpc), MMAPP_OWN_MANUAL_UPTIME: String(manualUptime), MMAPP_OWN_MANUAL_PING_GROUP: String(manualPingGroup), MMAPP_OWN_MANUAL_TRAFFIC: String(manualTraffic), MMAPP_OWN_MANUAL_API_PING: String(manualApiPing), MMAPP_OWN_SIMPLE_SPEED: String(simpleSpeed), MMAPP_OWN_SIMPLE_SPEED_CHR: String(simpleSpeedChr), MMAPP_OWN_DIAGNOSTIC_CANCEL_HOST: String(diagnosticCancelHost), MMAPP_OWN_OPERATOR_DOCKER_SQL: String(operatorDockerSql), MMAPP_OWN_PUBLIC_REGISTRY_METADATA: String(publicRegistryMetadata), MMAPP_OWN_OPERATOR_PEER_METADATA: String(operatorPeerMetadata), MMAPP_OWN_EXTERNAL_PEER_METADATA: String(externalPeerMetadata), MMAPP_OWN_BANDWIDTH_EXTERNAL_REGISTRY: String(externalPeerMetadata || operatorPeerMetadata || publicRegistryMetadata || operatorDockerSql || mcpSpeedOriginal || bandwidthSpeedWorkflow || bandwidthExternalRegistry), MMAPP_OWN_BANDWIDTH_SPEED_WORKFLOW: String(bandwidthSpeedWorkflow), MMAPP_OWN_REST_SPEED_ORIGINAL: String(restSpeedOriginal), MMAPP_OWN_MCP_OPERATION_ORIGIN: String(mcpOperationOrigin), MMAPP_OWN_PROBE_OPERATION_RETIREMENT: String(probeOperationRetirement), MMAPP_OWN_MCP_SPEED_ORIGINAL: String(mcpSpeedOriginal), MMAPP_OWN_SCHEDULED_SPEED_ORIGINAL: String(scheduledSpeedOriginal), MMAPP_TEST_ROUTER_GUARD_OWNED: String(routerConfigurationGuard), MMAPP_TEST_ROUTER_GUARD_CLUSTER_DISPOSABLE: String(routerConfigurationGuard), MMAPP_TEST_ROUTER_GUARD_ADMIN_URL: routerConfigurationGuard ? `postgresql://mcp_test:${password}@127.0.0.1:${port}/postgres` : '', MMAPP_OWN_BANDWIDTH_ADMISSION: String(externalPeerMetadata || operatorPeerMetadata || publicRegistryMetadata || operatorDockerSql || mcpOperationOrigin || mcpSpeedOriginal || scheduledSpeedOriginal || bandwidthSpeedWorkflow || restSpeedOriginal || bandwidthExternalRegistry || operationLocks || serviceExecutionOrigin || bandwidthAdmission || bandwidthControlSource || bandwidthNodeRegistry || bandwidthNodeSource), MMAPP_OWN_BANDWIDTH_RESTORE: String((bandwidthAdmission || bandwidthControlSource || bandwidthNodeRegistry || bandwidthNodeSource) && process.argv.includes('--restore')), MMAPP_OWN_IPSEC_EXPORT_HTTP: String(ipsecExportHttp), MMAPP_OWN_IPSEC_EXPORT_JOURNAL: String(ipsecExportJournal), MMAPP_OWN_SQL_DB: 'postgres', MMAPP_OWN_PROBE_LEGACY_ORPHANS: String(probeLegacyOrphans), MMAPP_OWN_PROBE_STORAGE_HISTORY: String(probeStorageHistory), MMAPP_OWN_PROBE_STORAGE_FRESH: String(probeStorageFresh), MMAPP_OWN_PROBE_STORAGE_JOURNAL: String(probeStorageJournal || probeStorageFresh), MMAPP_OWN_CONTROLLED_SCHEMA_REFERENCE: String(controlledSchemaReference), MMAPP_OWN_CONTROLLED_MIGRATIONS: String(controlledMigrations), MMAPP_OWN_DATABASE_SCHEMA_CONTROL: String(databaseSchemaControl), MMAPP_OWN_MIGRATION_DATA_INVENTORY: String(migrationDataInventory), MMAPP_OWN_MIGRATION_SCHEMA_INVENTORY: String(migrationSchemaInventory), MMAPP_OWN_ROUTING_NETWORK_NATIVE: String(routingNetworkNative), MMAPP_OWN_ROUTING_RESERVATIONS: String(routingReservations || routingReservationRuntime), MMAPP_OWN_COLLECTOR_PARTITION_WRITERS: String(collectorPartitionWriters), MMAPP_OWN_SERVER_POLL_STORAGE_FRESH: String(serverPollStorageFresh), MMAPP_OWN_SERVER_POLL_STORAGE: String(serverPollStorage), MMAPP_OWN_SERVER_POLL_DURABLE: String(serverPollDurable), MMAPP_OWN_ROUTING_RECOVERY_AUTHORITY: String(routingRecoveryAuthority), MMAPP_OWN_LEGACY_FLOW_REBUILD: String(legacyFlowRebuild), MMAPP_OWN_PROBE_JOURNAL: String(probeJournal), MMAPP_MODULE_COMPLETION_ONLY: String(moduleCompletion), MMAPP_MODULE_COMPLETION_TARGET: completionTarget, MMAPP_OWN_FLOW_HOST_KEYS: String(flowHostKeys), MMAPP_OWN_MCP_FLOW_REBUILD: String(flowRebuildMcp), MMAPP_OWN_FLOW_WRITES: String(flowWrites), MMAPP_OWN_FLOW_REBUILD: String(flowRebuild) },
|
||||||
})
|
})
|
||||||
child.once('error', reject); child.once('exit', code => resolve(code ?? 1))
|
child.once('error', reject); child.once('exit', code => resolve(code ?? 1))
|
||||||
})
|
})
|
||||||
@@ -268,6 +273,7 @@ try {
|
|||||||
else if (result === 0 && pollArchiveSchema) console.log('Poll archive schema: owned nonempty40 preservation, numbered41 and structural refusal guards; no positive MCP archive claim')
|
else if (result === 0 && pollArchiveSchema) console.log('Poll archive schema: owned nonempty40 preservation, numbered41 and structural refusal guards; no positive MCP archive claim')
|
||||||
else if (result === 0 && telemetrySettingsConfiguration) console.log('Telemetry settings: genuine MCP/SQL configuration binding, collector metadata tolerated; no native or external sources')
|
else if (result === 0 && telemetrySettingsConfiguration) console.log('Telemetry settings: genuine MCP/SQL configuration binding, collector metadata tolerated; no native or external sources')
|
||||||
else if (result === 0 && routingReservationSchema) console.log('Routing reservation numbered0040: ownSQL fresh/legacy/manual preservation; no READY, backfill, capabilities or public native activation')
|
else if (result === 0 && routingReservationSchema) console.log('Routing reservation numbered0040: ownSQL fresh/legacy/manual preservation; no READY, backfill, capabilities or public native activation')
|
||||||
|
else if (result === 0 && routingOriginalEffects) console.log('Routing original effects: approved manual revision 2 and original effects checked against disposable PostgreSQL and synthetic RouterOS HTTP')
|
||||||
else if (result === 0 && routingForwardRounds) console.log('Routing forward rounds: genuine approved running CONFIG_APPLIED, shared cumulative native budget; logs/security/autoRecovery remain unaccepted')
|
else if (result === 0 && routingForwardRounds) console.log('Routing forward rounds: genuine approved running CONFIG_APPLIED, shared cumulative native budget; logs/security/autoRecovery remain unaccepted')
|
||||||
else if (result === 0 && flowActiveServices) console.log('Flow active settings/purge: production services and main Worker; disposable SQL/localhost only')
|
else if (result === 0 && flowActiveServices) console.log('Flow active settings/purge: production services and main Worker; disposable SQL/localhost only')
|
||||||
else if (result === 0 && flowActiveRebuild) console.log('Flow active rebuild: production shared service and genuine Worker drain; disposable SQL/localhost only')
|
else if (result === 0 && flowActiveRebuild) console.log('Flow active rebuild: production shared service and genuine Worker drain; disposable SQL/localhost only')
|
||||||
@@ -283,6 +289,9 @@ try {
|
|||||||
else if (result === 0 && operatorPeerMetadata) console.log('Operator peer metadata: actual managed endpoint SQL and genuine localhost license origin; synthetic Q, external metadata and full operator window remain open')
|
else if (result === 0 && operatorPeerMetadata) console.log('Operator peer metadata: actual managed endpoint SQL and genuine localhost license origin; synthetic Q, external metadata and full operator window remain open')
|
||||||
else if (result === 0 && publicRegistryMetadata) console.log('Public registry metadata: actual owned PostgreSQL and same physical lease; no secret/Q/native/write authority or backend-window acceptance')
|
else if (result === 0 && publicRegistryMetadata) console.log('Public registry metadata: actual owned PostgreSQL and same physical lease; no secret/Q/native/write authority or backend-window acceptance')
|
||||||
else if (result === 0 && operatorDockerSql) console.log('Operator Docker SQL: actual owned PostgreSQL with mocked Docker HTTP only; not a real Docker/backend-window acceptance')
|
else if (result === 0 && operatorDockerSql) console.log('Operator Docker SQL: actual owned PostgreSQL with mocked Docker HTTP only; not a real Docker/backend-window acceptance')
|
||||||
|
else if (result === 0 && ordinaryOperationRetirement) console.log('Ordinary operation retirement: owned actual logs-settings apply and worker')
|
||||||
|
else if (result === 0 && pollOperationRetirement) console.log('Poll operation retirement: owned authenticated historical fixtures and current reader/GC')
|
||||||
|
else if (result === 0 && probeOperationRetirement) console.log('Diagnostic retirement: actual owned worker tick, PostgreSQL and product read/replay; no live scheduling')
|
||||||
else if (result === 0 && mcpOperationOrigin) console.log('MCP operation root: owned PostgreSQL ordinary runner provenance; no restore/recovery, global writer activation, CHR or deployment')
|
else if (result === 0 && mcpOperationOrigin) console.log('MCP operation root: owned PostgreSQL ordinary runner provenance; no restore/recovery, global writer activation, CHR or deployment')
|
||||||
else if (result === 0 && mcpSpeedOriginal) console.log('MCP speed: genuine trusted service context with real connection/grant/SQL, host receipt and localhost native; synthetic Q/mapping; not a public MCP runner/action acceptance')
|
else if (result === 0 && mcpSpeedOriginal) console.log('MCP speed: genuine trusted service context with real connection/grant/SQL, host receipt and localhost native; synthetic Q/mapping; not a public MCP runner/action acceptance')
|
||||||
else if (result === 0 && routerConfigurationGuard) console.log('Router configuration guard: dedicated owned databases/roles and localhost native fixture; no application migration, production activation or real CHR')
|
else if (result === 0 && routerConfigurationGuard) console.log('Router configuration guard: dedicated owned databases/roles and localhost native fixture; no application migration, production activation or real CHR')
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import EmbeddedPostgres from 'embedded-postgres'
|
import EmbeddedPostgres from 'embedded-postgres'
|
||||||
import { createRequire } from 'node:module'
|
import { createRequire } from 'node:module'
|
||||||
import { mkdir, mkdtemp, readdir, readFile, writeFile, cp, rm } from 'node:fs/promises'
|
import { mkdir, mkdtemp, readdir, readFile, writeFile, cp, rm } from 'node:fs/promises'
|
||||||
import { resolve, join, relative, dirname } from 'node:path'
|
import { resolve, join, relative, dirname, basename } from 'node:path'
|
||||||
import { randomBytes, createHash } from 'node:crypto'
|
import { randomBytes, createHash } from 'node:crypto'
|
||||||
import { createServer } from 'node:net'
|
import { createServer } from 'node:net'
|
||||||
import { spawn } from 'node:child_process'
|
import { spawn } from 'node:child_process'
|
||||||
@@ -16,7 +16,8 @@ await readFile(join(root, 'backend/src/modules/mcp/routing-main-transition.ts'))
|
|||||||
await readFile(join(root, 'backend/src/modules/mcp/routing-controller-baseline-acceptance.ts'))
|
await readFile(join(root, 'backend/src/modules/mcp/routing-controller-baseline-acceptance.ts'))
|
||||||
if (process.argv.includes('--check-only')) { console.log(JSON.stringify({ mode, sourcePresent: true, acceptanceExecuted: false, productionApplied: false })); process.exit(0) }
|
if (process.argv.includes('--check-only')) { console.log(JSON.stringify({ mode, sourcePresent: true, acceptanceExecuted: false, productionApplied: false })); process.exit(0) }
|
||||||
await mkdir(join(root, 'tmp'), { recursive: true })
|
await mkdir(join(root, 'tmp'), { recursive: true })
|
||||||
const directory = await mkdtemp(join(root, 'tmp/routing-controller-baseline-owned-')), output = join(directory, 'dist'), sourcePins = []
|
const ownedPrefix = 'mm-mcp-test-routing-controller-baseline-'
|
||||||
|
const directory = await mkdtemp(join(root, 'tmp', ownedPrefix)), output = join(directory, 'dist'), sourcePins = []
|
||||||
const hash = buffer => createHash('sha256').update(buffer).digest('hex')
|
const hash = buffer => createHash('sha256').update(buffer).digest('hex')
|
||||||
async function compile(sourceDirectory) {
|
async function compile(sourceDirectory) {
|
||||||
for (const entry of await readdir(sourceDirectory, { withFileTypes: true })) {
|
for (const entry of await readdir(sourceDirectory, { withFileTypes: true })) {
|
||||||
@@ -59,7 +60,7 @@ try {
|
|||||||
// embedded-postgres waits for a new exit event; avoid waiting again if its
|
// embedded-postgres waits for a new exit event; avoid waiting again if its
|
||||||
// owned child has already exited. This never targets another database.
|
// owned child has already exited. This never targets another database.
|
||||||
if (postgres.process?.exitCode === null) await postgres.stop()
|
if (postgres.process?.exitCode === null) await postgres.stop()
|
||||||
if (dirname(resolve(directory)) !== resolve(root, 'tmp') || !directory.includes('routing-controller-baseline-owned-')) throw new Error('Owned routing cleanup boundary changed')
|
if (dirname(resolve(directory)) !== resolve(root, 'tmp') || !basename(directory).startsWith(ownedPrefix)) throw new Error('Owned routing cleanup boundary changed')
|
||||||
await rm(join(directory, 'pg'), { recursive: true, force: true, maxRetries: 20, retryDelay: 100 })
|
await rm(join(directory, 'pg'), { recursive: true, force: true, maxRetries: 20, retryDelay: 100 })
|
||||||
console.log('Owned routing PostgreSQL stopped and its data removed; snapshot/log evidence retained')
|
console.log('Owned routing PostgreSQL stopped and its data removed; snapshot/log evidence retained')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ export function projectVrfDiagnostics(log, privateValues = [], expectedSlice = n
|
|||||||
if (Array.isArray(value.accepted)) { row.finalCaseCount = value.accepted.length; if (expectedSlice !== null && value.slice === expectedSlice) finalComponentPresent = true }
|
if (Array.isArray(value.accepted)) { row.finalCaseCount = value.accepted.length; if (expectedSlice !== null && value.slice === expectedSlice) finalComponentPresent = true }
|
||||||
if (['planned','queued','running','applied','failed','cancelled','unknown','expired','rejected','unconfirmed'].includes(value.operationState)) row.operationState = value.operationState
|
if (['planned','queued','running','applied','failed','cancelled','unknown','expired','rejected','unconfirmed'].includes(value.operationState)) row.operationState = value.operationState
|
||||||
for (const key of ['phase', 'elapsedMs', 'nativeReads', 'nativeWrites', 'roundTrips', 'lastSqlMs', 'totalSqlMs', 'maxSqlMs']) if (Number.isSafeInteger(value[key]) && value[key] >= 0) row[key] = value[key]
|
for (const key of ['phase', 'elapsedMs', 'nativeReads', 'nativeWrites', 'roundTrips', 'lastSqlMs', 'totalSqlMs', 'maxSqlMs']) if (Number.isSafeInteger(value[key]) && value[key] >= 0) row[key] = value[key]
|
||||||
|
if (Number.isSafeInteger(value.wireguardPeerTelemetryTicks) && value.wireguardPeerTelemetryTicks > 0 && value.wireguardPeerTelemetryTicks <= 10000) row.wireguardPeerTelemetryTicks = value.wireguardPeerTelemetryTicks
|
||||||
if (value.diagnosticWaitSql === true) row.diagnosticWaitSql = true
|
if (value.diagnosticWaitSql === true) row.diagnosticWaitSql = true
|
||||||
if (value.diagnosticError === true) { row.diagnosticError = true; row.safeName = diagnosticNames.has(value.safeName) ? value.safeName : 'other'; row.safeCode = diagnosticCodes.has(value.safeCode) ? value.safeCode : 'other' }
|
if (value.diagnosticError === true) { row.diagnosticError = true; row.safeName = diagnosticNames.has(value.safeName) ? value.safeName : 'other'; row.safeCode = diagnosticCodes.has(value.safeCode) ? value.safeCode : 'other' }
|
||||||
if (!Object.keys(row).length) { omittedLines++; continue }
|
if (!Object.keys(row).length) { omittedLines++; continue }
|
||||||
@@ -47,10 +48,10 @@ export function projectVrfDiagnostics(log, privateValues = [], expectedSlice = n
|
|||||||
}
|
}
|
||||||
export function vrfSliceDiagnostic(result, log, childBudgetMs, privateValues = []) {
|
export function vrfSliceDiagnostic(result, log, childBudgetMs, privateValues = []) {
|
||||||
assert(result && typeof result === 'object')
|
assert(result && typeof result === 'object')
|
||||||
assert(['baseline','physical','vlan','disabled','mixed','reconcile','sql-journal','guards','coexistence','alias'].includes(result.slice))
|
assert(['baseline','physical','vlan','disabled','mixed','reconcile','sql-journal','guards','coexistence','alias','gre'].includes(result.slice))
|
||||||
assert(Number.isSafeInteger(result.code) && typeof result.childExitConfirmed === 'boolean')
|
assert(Number.isSafeInteger(result.code) && typeof result.childExitConfirmed === 'boolean')
|
||||||
assert(Number.isSafeInteger(result.caseCount) && result.caseCount >= 0)
|
assert(Number.isSafeInteger(result.caseCount) && result.caseCount >= 0)
|
||||||
assert.equal(childBudgetMs, result.slice === 'baseline' ? 240000 : 120000)
|
assert.equal(childBudgetMs, result.slice === 'baseline' ? 240000 : result.slice === 'disabled' ? 180000 : 120000)
|
||||||
assert(result.failure === null || ['child-deadline','child-log-limit','child-signal','child-error','child-error-after-pid','child-exit-unconfirmed','missing-component-receipt'].includes(result.failure))
|
assert(result.failure === null || ['child-deadline','child-log-limit','child-signal','child-error','child-error-after-pid','child-exit-unconfirmed','missing-component-receipt'].includes(result.failure))
|
||||||
assert(result.accepted === (result.code === 0 && result.failure === null && result.childExitConfirmed))
|
assert(result.accepted === (result.code === 0 && result.failure === null && result.childExitConfirmed))
|
||||||
return { ...result, capMs: childBudgetMs, diagnostics: projectVrfDiagnostics(log, privateValues, result.slice), scope: 'same-owned-PG-test-only', actualRouter: false, forwardingVerified: false }
|
return { ...result, capMs: childBudgetMs, diagnostics: projectVrfDiagnostics(log, privateValues, result.slice), scope: 'same-owned-PG-test-only', actualRouter: false, forwardingVerified: false }
|
||||||
@@ -88,10 +89,14 @@ export async function runOwnedVrfProductionProfile({ directory, database, databa
|
|||||||
const report = fileURLToPath(new URL('../../docs/mcp-chr-vrf-companion-native-v5.json', import.meta.url))
|
const report = fileURLToPath(new URL('../../docs/mcp-chr-vrf-companion-native-v5.json', import.meta.url))
|
||||||
const loader = pathToFileURL(createRequire(import.meta.url).resolve('tsx')).href
|
const loader = pathToFileURL(createRequire(import.meta.url).resolve('tsx')).href
|
||||||
const safe = Object.fromEntries(['SystemRoot', 'WINDIR', 'TEMP', 'TMP', 'LANG', 'LC_ALL'].filter(key => typeof process.env[key] === 'string').map(key => [key, process.env[key]]))
|
const safe = Object.fromEntries(['SystemRoot', 'WINDIR', 'TEMP', 'TMP', 'LANG', 'LC_ALL'].filter(key => typeof process.env[key] === 'string').map(key => [key, process.env[key]]))
|
||||||
for (const slice of ['baseline', 'physical', 'vlan', 'disabled', 'mixed', 'reconcile', 'sql-journal', 'guards', 'coexistence', 'alias']) {
|
// Diagnose the disabled lifecycle first, then the synthetic GRE extension.
|
||||||
|
// Every original case remains required by the same full74 gate and caps.
|
||||||
|
for (const slice of ['disabled', 'gre', 'baseline', 'physical', 'vlan', 'mixed', 'reconcile', 'sql-journal', 'guards', 'coexistence', 'alias']) {
|
||||||
currentSlice = slice; currentChildExitConfirmed = false; lastSliceResult = null
|
currentSlice = slice; currentChildExitConfirmed = false; lastSliceResult = null
|
||||||
await persist(slice + '-started.json', { version: 1, slice, status: 'running', accepted: false, childExitConfirmed: false })
|
await persist(slice + '-started.json', { version: 1, slice, status: 'running', accepted: false, childExitConfirmed: false })
|
||||||
const childBudgetMs = slice === 'baseline' ? 240000 : 120000
|
// Nine disabled lifecycle operations need their own aggregate child cap;
|
||||||
|
// measured seven operations alone consumed 105.5s under the original120s.
|
||||||
|
const childBudgetMs = slice === 'baseline' ? 240000 : slice === 'disabled' ? 180000 : 120000
|
||||||
const cwd = join(owned, 'vrf-production-' + slice), rel = relative(owned, cwd)
|
const cwd = join(owned, 'vrf-production-' + slice), rel = relative(owned, cwd)
|
||||||
assert(rel && !rel.startsWith('..') && !isAbsolute(rel)); await mkdir(cwd)
|
assert(rel && !rel.startsWith('..') && !isAbsolute(rel)); await mkdir(cwd)
|
||||||
assert.equal(await realpath(cwd), cwd); assert(!(await lstat(cwd)).isSymbolicLink())
|
assert.equal(await realpath(cwd), cwd); assert(!(await lstat(cwd)).isSymbolicLink())
|
||||||
@@ -121,9 +126,10 @@ export async function runOwnedVrfProductionProfile({ directory, database, databa
|
|||||||
unknownTimer = setTimeout(() => { if (!exited) { stop('child-exit-unconfirmed'); resolveExit(1) } }, childBudgetMs + 15000)
|
unknownTimer = setTimeout(() => { if (!exited) { stop('child-exit-unconfirmed'); resolveExit(1) } }, childBudgetMs + 15000)
|
||||||
})
|
})
|
||||||
clearTimeout(deadline); clearTimeout(stopTimer); clearTimeout(unknownTimer)
|
clearTimeout(deadline); clearTimeout(stopTimer); clearTimeout(unknownTimer)
|
||||||
const expectedCases = { baseline: 11, physical: 7, vlan: 9, disabled: 9, mixed: 7, reconcile: 6, 'sql-journal': 1, guards: 11, coexistence: 3, alias: 4 }
|
const expectedCases = { baseline: 11, physical: 7, vlan: 9, disabled: 9, mixed: 7, reconcile: 6, 'sql-journal': 1, guards: 11, coexistence: 3, alias: 4, gre: 6 }
|
||||||
const component = log.trim().split(/\r?\n/).flatMap(line => { try { return [JSON.parse(line)] } catch { return [] } }).findLast(value => value.slice === slice && Array.isArray(value.accepted))
|
const component = log.trim().split(/\r?\n/).flatMap(line => { try { return [JSON.parse(line)] } catch { return [] } }).findLast(value => value.slice === slice && Array.isArray(value.accepted))
|
||||||
if (!component || component.accepted.length !== expectedCases[slice] || component.actualSql !== true || component.actualCrypto !== true || component.actualAuthPolicyApprovalRuntime !== true || component.fakeVerifier !== false || component.actualRouter !== false || component.forwardingVerified !== false) failure ??= 'missing-component-receipt'
|
if (!component || component.accepted.length !== expectedCases[slice] || component.actualSql !== true || component.actualCrypto !== true || component.actualAuthPolicyApprovalRuntime !== true || component.fakeVerifier !== false || component.actualRouter !== false || component.forwardingVerified !== false) failure ??= 'missing-component-receipt'
|
||||||
|
if (slice === 'gre' && (!Number.isSafeInteger(component?.wireguardPeerTelemetryTicks) || component.wireguardPeerTelemetryTicks <= 6 || component.wireguardPeerTelemetryTicks > 10000)) failure ??= 'missing-component-receipt'
|
||||||
const result = { slice, accepted: code === 0 && !failure && exited, caseCount: component?.accepted?.length ?? 0, code, failure, childPid: child.pid ?? null, childExitConfirmed: exited }
|
const result = { slice, accepted: code === 0 && !failure && exited, caseCount: component?.accepted?.length ?? 0, code, failure, childPid: child.pid ?? null, childExitConfirmed: exited }
|
||||||
lastSliceResult = result; currentChildExitConfirmed = exited
|
lastSliceResult = result; currentChildExitConfirmed = exited
|
||||||
const diagnostic = vrfSliceDiagnostic(result, log, childBudgetMs, [databaseUrl, target.password, decodeURIComponent(target.password), authSecret])
|
const diagnostic = vrfSliceDiagnostic(result, log, childBudgetMs, [databaseUrl, target.password, decodeURIComponent(target.password), authSecret])
|
||||||
@@ -135,8 +141,8 @@ export async function runOwnedVrfProductionProfile({ directory, database, databa
|
|||||||
console.log(JSON.stringify({ profile: 'test:vrf:production', ...result }))
|
console.log(JSON.stringify({ profile: 'test:vrf:production', ...result }))
|
||||||
if (!result.accepted) break
|
if (!result.accepted) break
|
||||||
}
|
}
|
||||||
const receipt = { accepted: results.length === 10 && results.every(result => result.accepted), childExitConfirmed: results.every(result => result.childExitConfirmed), sameOwnedPg: true, actualRouter: false, forwardingVerified: false, results }
|
const receipt = { accepted: results.length === 11 && results.every(result => result.accepted), childExitConfirmed: results.every(result => result.childExitConfirmed), sameOwnedPg: true, actualRouter: false, forwardingVerified: false, results }
|
||||||
await persist('terminal.json', { version: 1, status: 'terminal', ...receipt, requiredCases: 68, completedCases: results.reduce((sum, value) => sum + value.caseCount, 0), diagnosticDirectory: diagnosticPath })
|
await persist('terminal.json', { version: 1, status: 'terminal', ...receipt, requiredCases: 74, completedCases: results.reduce((sum, value) => sum + value.caseCount, 0), diagnosticDirectory: diagnosticPath })
|
||||||
await writeFile(join(owned, 'vrf-production-acceptance.json'), JSON.stringify(receipt, null, 2))
|
await writeFile(join(owned, 'vrf-production-acceptance.json'), JSON.stringify(receipt, null, 2))
|
||||||
return receipt
|
return receipt
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { test } from 'node:test'
|
||||||
|
import { vrfSliceDiagnostic } from './vrf-production-profile.mjs'
|
||||||
|
|
||||||
|
test('only the nine-operation disabled slice receives its measured aggregate budget', () => {
|
||||||
|
for (const [slice, capMs] of [['disabled', 180000], ['gre', 120000], ['baseline', 240000]]) {
|
||||||
|
const result = { slice, accepted: false, caseCount: 0, code: 1, failure: 'child-deadline', childExitConfirmed: true }
|
||||||
|
assert.equal(vrfSliceDiagnostic(result, '', capMs).capMs, capMs)
|
||||||
|
for (const otherCap of [120000, 180000, 240000].filter(value => value !== capMs)) assert.throws(() => vrfSliceDiagnostic(result, '', otherCap))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('GRE lifecycle diagnostic retains the six-case receipt with existing privacy bounds', () => {
|
||||||
|
const accepted = ['vrf-create', 'vrf-rename', 'vrf-disable', 'vrf-reenable', 'vrf-membership-transfer-a-to-b', 'vrf-delete-addresses-remain']
|
||||||
|
const result = { slice: 'gre', accepted: true, caseCount: 6, code: 0, failure: null, childExitConfirmed: true }
|
||||||
|
const log = accepted.map((acceptedCase, index) => JSON.stringify({ slice: 'gre', acceptedCase, nativeWrites: 1, wireguardPeerTelemetryTicks: (index + 1) * 4, password: 'fixture-private-value' }))
|
||||||
|
log.push(JSON.stringify({ slice: 'gre', accepted, wireguardPeerTelemetryTicks: 24, rawShape: 'synthetic GRE lifecycle', password: 'fixture-private-value' }))
|
||||||
|
const diagnostic = vrfSliceDiagnostic(result, log.join('\n'), 120000, ['fixture-private-value'])
|
||||||
|
assert.deepEqual(diagnostic.diagnostics.acceptedCaseSequence, accepted)
|
||||||
|
assert.equal(diagnostic.diagnostics.finalComponentPresent, true)
|
||||||
|
assert.equal(diagnostic.diagnostics.rawLogRetained, false)
|
||||||
|
assert.deepEqual(diagnostic.diagnostics.records.map(row => row.wireguardPeerTelemetryTicks), [4, 8, 12, 16, 20, 24, 24])
|
||||||
|
assert.equal(diagnostic.actualRouter, false)
|
||||||
|
assert.equal(diagnostic.forwardingVerified, false)
|
||||||
|
assert.equal(JSON.stringify(diagnostic).includes('fixture-private-value'), false)
|
||||||
|
assert.throws(() => vrfSliceDiagnostic(result, '', 240000))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('peer telemetry diagnostic rejects nonnumeric and out-of-budget values', () => {
|
||||||
|
const result = { slice: 'gre', accepted: false, caseCount: 0, code: 1, failure: 'missing-component-receipt', childExitConfirmed: true }
|
||||||
|
for (const wireguardPeerTelemetryTicks of [0, -1, 10001, 1.5, 'private-raw-value', { secret: 'private-raw-value' }]) {
|
||||||
|
const diagnostic = vrfSliceDiagnostic(result, JSON.stringify({ slice: 'gre', wireguardPeerTelemetryTicks }), 120000)
|
||||||
|
assert.equal(diagnostic.diagnostics.records.some(row => Object.hasOwn(row, 'wireguardPeerTelemetryTicks')), false)
|
||||||
|
assert.equal(JSON.stringify(diagnostic).includes('private-raw-value'), false)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a partial GRE lifecycle is diagnostic evidence without a final component receipt', () => {
|
||||||
|
const result = { slice: 'gre', accepted: false, caseCount: 0, code: 1, failure: 'child-deadline', childExitConfirmed: true }
|
||||||
|
const diagnostic = vrfSliceDiagnostic(result, JSON.stringify({ slice: 'gre', acceptedCase: 'vrf-create' }), 120000)
|
||||||
|
assert.equal(diagnostic.accepted, false)
|
||||||
|
assert.equal(diagnostic.diagnostics.finalComponentPresent, false)
|
||||||
|
assert.equal(diagnostic.diagnostics.lastAcceptedCase, 'vrf-create')
|
||||||
|
assert.equal(diagnostic.capMs, 120000)
|
||||||
|
})
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
/** Explicit host-operator inspection/acceptance. No restore, service restart, role
|
||||||
|
* enrollment or source/READY issuer. Populated data is accepted, not certified. */
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import path from 'node:path'
|
||||||
|
import {fileURLToPath} from 'node:url'
|
||||||
|
import {mkdir,readdir} from 'node:fs/promises'
|
||||||
|
import {readProtected,protectedDirectory,writeFile,withJournalLease,sha} from './operator-files.mjs'
|
||||||
|
import {poolOptions,STARTUP_OPTIONS,assertJsDriverEnvironment} from './operator-contract.mjs'
|
||||||
|
import {readMaintenanceRecoveryFacts,readMaintenanceAcceptanceRecords} from '../../src/services/database-maintenance-startup.js'
|
||||||
|
import {parseMaintenanceJSON,runtimeDatabaseTargetSha256,evaluateMaintenanceAcceptances,validateMaintenanceKnownCurrent,validateMaintenanceAcceptance,MAINTENANCE_ACCEPTANCE_FORMAT,type MaintenanceKnownCurrent} from '../../src/services/database-maintenance-resolution.js'
|
||||||
|
import {captureMigrationSchema} from '../../src/db/migration-schema-inventory.js'
|
||||||
|
import {MIGRATION_MANIFEST,CURRENT_REQUIRED_TABLES,migrationManifestDigest,inventoryMigrationDirectory} from '../../src/db/migration-manifest.js'
|
||||||
|
import type {Pool,PoolClient} from 'pg'
|
||||||
|
const hex=(v:unknown):v is string=>typeof v==='string'&&/^[a-f0-9]{64}$/.test(v)
|
||||||
|
function object(v:unknown):asserts v is Record<string,unknown>{assert(v&&typeof v==='object'&&!Array.isArray(v))}
|
||||||
|
function exact(v:unknown,keys:string[]):asserts v is Record<string,unknown>{object(v);assert.equal(Object.keys(v).sort().join('\0'),keys.sort().join('\0'))}
|
||||||
|
const stamp=(v:unknown):v is string=>typeof v==='string'&&Number.isFinite(Date.parse(v))&&new Date(v).toISOString()===v
|
||||||
|
const stable=(v:unknown)=>JSON.stringify(v)
|
||||||
|
const flags=['state-dir','transport','transport-sha256','runtime-target','runtime-target-sha256']
|
||||||
|
const validityMs=900000
|
||||||
|
/** Explicit local-container transport never falls back from TLS or environment.
|
||||||
|
* Caller launch review supplies --network container:<exact reviewed PG id>;
|
||||||
|
* this module has no Docker socket/API and claims only loopback + physical SQL proof. */
|
||||||
|
export function maintenancePoolConfiguration(value:unknown,ca?:Buffer,caSha256?:string){
|
||||||
|
object(value);const mode=value.mode===undefined?'tls':value.mode;assert(mode==='tls'||mode==='local-loopback')
|
||||||
|
exact(value,mode==='local-loopback'?['format','mode','connection']:value.mode===undefined?['format','connection','tls']:['format','mode','connection','tls'])
|
||||||
|
assert.equal(value.format,'database-maintenance-transport-v1');exact(value.connection,['host','port','user','password','database']);const connection=value.connection
|
||||||
|
assert(typeof connection.host==='string'&&typeof connection.user==='string'&&/^[^\x00-\x1f\x7f]{1,63}$/.test(connection.user)&&typeof connection.password==='string'&&connection.password.length>0&&connection.password.length<=4096&&!connection.password.includes('\0')&&typeof connection.database==='string'&&/^[^\x00-\x1f\x7f]{1,63}$/.test(connection.database)&&typeof connection.port==='number'&&Number.isSafeInteger(connection.port)&&Number(connection.port)>0&&Number(connection.port)<=65535)
|
||||||
|
let options,identity:unknown
|
||||||
|
if(mode==='tls'){
|
||||||
|
exact(value.tls,['serverName','spkiSha256']);assert(typeof value.tls.serverName==='string'&&/^[a-zA-Z0-9][a-zA-Z0-9.-]{0,252}$/.test(value.tls.serverName)&&hex(value.tls.spkiSha256));assert(ca&&hex(caSha256))
|
||||||
|
options=poolOptions(connection,{roles:{publisher:{name:connection.user}},source:{databaseName:connection.database},tls:{...value.tls,caSha256}},ca)
|
||||||
|
identity={mode,host:connection.host,port:connection.port,database:connection.database,tls:{...value.tls,caSha256}}
|
||||||
|
}else{
|
||||||
|
assert(connection.host==='127.0.0.1'||connection.host==='::1','Plaintext maintenance requires exact loopback and reviewed PostgreSQL container namespace');assert(ca===undefined&&caSha256===undefined)
|
||||||
|
options={host:connection.host,port:connection.port,user:connection.user,password:connection.password,database:connection.database,max:1,connectionTimeoutMillis:5000,idleTimeoutMillis:5000,statement_timeout:10000,lock_timeout:5000,options:STARTUP_OPTIONS,application_name:'mmapp-maintenance-acceptance',client_encoding:'UTF8',ssl:false}
|
||||||
|
identity={mode,host:connection.host,port:connection.port,database:connection.database}
|
||||||
|
}
|
||||||
|
return {options,transportBinding:{mode,endpointSha256:sha(stable(identity))} as MaintenanceKnownCurrent['transport']}
|
||||||
|
}
|
||||||
|
export function maintenanceApproval(value:unknown,inspectionSha256:string,inspectionCreatedAt:string){
|
||||||
|
exact(value,['format','resolutionId','decision','actorId','inspectionSha256','issuedAt','expiresAt','managedBackendStopped','historicOutcomesRemainUnknown','automaticRestore'])
|
||||||
|
assert(value.format==='database-maintenance-current-state-approval-v1'&&value.decision==='accept-observed-current-state'&&typeof value.resolutionId==='string'&&/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/.test(value.resolutionId))
|
||||||
|
assert(typeof value.actorId==='string'&&/^[^\x00-\x1f\x7f]{1,160}$/.test(value.actorId)&&value.inspectionSha256===inspectionSha256&&stamp(value.issuedAt)&&stamp(value.expiresAt))
|
||||||
|
const now=Date.now();assert(Date.parse(value.issuedAt)>=Date.parse(inspectionCreatedAt)&&Date.parse(value.issuedAt)<=now&&now<Date.parse(value.expiresAt)&&Date.parse(value.expiresAt)-Date.parse(value.issuedAt)<=validityMs)
|
||||||
|
assert(value.managedBackendStopped===true&&value.historicOutcomesRemainUnknown===true&&value.automaticRestore===false)
|
||||||
|
return value as unknown as {resolutionId:string;actorId:string;issuedAt:string;expiresAt:string}
|
||||||
|
}
|
||||||
|
/** Genuine observation, not a supplied snapshot factory. Owner/transaction ends
|
||||||
|
* must be confirmed before a caller can publish an acceptance. Exclusive key
|
||||||
|
* protects cooperative observations; stopped-backend assumption spans publication. */
|
||||||
|
export async function observeMaintenanceCurrent(pool:Pick<Pool,'connect'>,transport:MaintenanceKnownCurrent['transport']){
|
||||||
|
let client:PoolClient|undefined,locked=false,begun=false,bad=false,faulted=false
|
||||||
|
const fault=()=>{faulted=true},live=()=>{assert(!faulted)}
|
||||||
|
try{
|
||||||
|
client=await pool.connect();client.on('error',fault);client.on('end',fault)
|
||||||
|
const query=async(text:string,values:unknown[]=[])=>{live();const result=await client!.query(text,values);live();return result}
|
||||||
|
await query("SET statement_timeout='10s'");await query("SET lock_timeout='5s'")
|
||||||
|
const lease=await query('SELECT pg_catalog.pg_try_advisory_lock(8723110::bigint) AS locked');assert(lease.rows.length===1&&lease.rows[0].locked===true);locked=true
|
||||||
|
await query('BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY');begun=true
|
||||||
|
for(const sql of ["SET LOCAL search_path=pg_catalog","SET LOCAL TimeZone='UTC'","SET LOCAL DateStyle='ISO, YMD'","SET LOCAL standard_conforming_strings=on","SET LOCAL statement_timeout='10s'"])await query(sql)
|
||||||
|
const identity=await query(`SELECT c.system_identifier::text AS "systemIdentifier",d.oid::text AS "databaseOid",d.datname AS "databaseName",pg_catalog.current_setting('server_version_num')::int AS "serverVersionNum",pg_catalog.pg_is_in_recovery() AS "inRecovery" FROM pg_catalog.pg_database d CROSS JOIN pg_catalog.pg_control_system() c WHERE d.datname=pg_catalog.current_database()`)
|
||||||
|
assert.equal(identity.rows.length,1)
|
||||||
|
// The optional writer-profile control schema is not installed by ordinary
|
||||||
|
// application migrations. Observe its presence in this same transaction;
|
||||||
|
// detailed capture still refuses every other unexpected namespace.
|
||||||
|
const control=await query("SELECT nspname FROM pg_catalog.pg_namespace WHERE nspname='mmapp_control'")
|
||||||
|
assert(control.rows.length<=1&&control.rows.every(row=>row.nspname==='mmapp_control'))
|
||||||
|
const schema=await captureMigrationSchema({query:async(text,values)=>query(text,values)},{scope:{applicationNamespaces:['public'],excludedControlNamespaces:control.rows.length?['mmapp_control']:[]},deadlineAt:Date.now()+60000})
|
||||||
|
const marker=schema.sections.relations.find(row=>row.namespace==='public'&&row.name==='schema_migrations'),column=schema.sections.columns.find(row=>row.namespace==='public'&&row.relation==='schema_migrations'&&row.name==='id'&&row.dropped===false)
|
||||||
|
assert(marker?.kind==='r'&&marker.persistence==='p'&&marker.row_security===false&&marker.force_row_security===false&&column?.type==='text')
|
||||||
|
for(const table of CURRENT_REQUIRED_TABLES)assert(schema.sections.relations.some(row=>row.namespace==='public'&&row.name===table&&['r','p'].includes(String(row.kind))&&row.row_security===false&&row.force_row_security===false))
|
||||||
|
const markers=(await query('SELECT id FROM public.schema_migrations ORDER BY id LIMIT $1',[MIGRATION_MANIFEST.length+1])).rows
|
||||||
|
assert.equal(stable(markers),stable(MIGRATION_MANIFEST.map(entry=>({id:entry.file.slice(0,-4)}))))
|
||||||
|
const knownCurrent={transport,source:identity.rows[0],schemaSha256:schema.sha256,rawSchemaSha256:schema.observedRaw.sha256,physicalSchemaSha256:schema.observedPhysical.sha256,manifestSha256:migrationManifestDigest(),markersSha256:sha(stable(markers))}
|
||||||
|
validateMaintenanceKnownCurrent(knownCurrent)
|
||||||
|
const end=await query('ROLLBACK');assert.equal(end.command,'ROLLBACK');begun=false
|
||||||
|
const unlock=await query('SELECT pg_catalog.pg_advisory_unlock(8723110::bigint) AS unlocked');assert(unlock.rows.length===1&&unlock.rows[0].unlocked===true);locked=false
|
||||||
|
return {knownCurrent,schema}
|
||||||
|
}catch(error){bad=true;throw error}finally{
|
||||||
|
if(client){let cleanupFailed=false
|
||||||
|
try{if(begun&&!faulted){const end=await client.query('ROLLBACK');assert.equal(end.command,'ROLLBACK')}if(locked&&!faulted){const end=await client.query('SELECT pg_catalog.pg_advisory_unlock(8723110::bigint) AS unlocked');assert(end.rows[0]?.unlocked===true)}}catch{cleanupFailed=true}
|
||||||
|
try{client.off('error',fault);client.off('end',fault)}catch{cleanupFailed=true}
|
||||||
|
try{client.release(bad||faulted||cleanupFailed)}catch{cleanupFailed=true}
|
||||||
|
assert(!cleanupFailed&&!faulted,'Read-only maintenance owner cleanup unconfirmed')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function snapshot(stateDirectory:string,target:string,records:readonly Buffer[]){
|
||||||
|
const state=await readMaintenanceRecoveryFacts(stateDirectory);assert(state.kind==='journal'&&state.generation)
|
||||||
|
const accepted=evaluateMaintenanceAcceptances(records,state.journal,state.vault.evidence,target)
|
||||||
|
const unresolvedOperations=state.operations.filter(op=>op.state!=='applied'),vaultCoverage=state.vault.evidence.filter(marker=>state.vault.unresolvedFiles.includes(marker.file))
|
||||||
|
const newUnresolved=unresolvedOperations.some(op=>!accepted.acceptedCoreIds.has(op.operationId))||state.vault.unresolvedFiles.some(file=>!accepted.acceptedVaultFiles.has(file))
|
||||||
|
return {newUnresolved,external:{journalCoverage:{prefixBytes:state.journal.length,sha256:sha(state.journal),unresolvedOperations},vaultCoverage,generationObservedSha256:sha(state.generation),previousResolutionSha256:accepted.headSha256}}
|
||||||
|
}
|
||||||
|
export async function maintenanceMain(args:string[]){
|
||||||
|
assertJsDriverEnvironment(process.env);const resolve=args[0]==='maintenance-resolve';assert(resolve||args[0]==='maintenance-inspect')
|
||||||
|
const required=[...flags,...(resolve?['inspection','inspection-sha256','approval','approval-sha256']:['output'])],names=[...required,'ca','ca-sha256'],values=new Map<string,string>()
|
||||||
|
assert(args.length===1+required.length*2||args.length===1+(required.length+2)*2)
|
||||||
|
for(let i=1;i<args.length;i+=2){const name=args[i].slice(2);assert(args[i].startsWith('--')&&names.includes(name)&&!values.has(name)&&args[i+1]);values.set(name,args[i+1])}
|
||||||
|
for(const name of required)assert(values.has(name));assert(values.has('ca')===values.has('ca-sha256'));const get=(name:string)=>values.get(name)!
|
||||||
|
for(const name of [...values.keys()].filter(name=>name.endsWith('sha256')))assert(hex(get(name)))
|
||||||
|
const stateDirectory=get('state-dir');await protectedDirectory(stateDirectory)
|
||||||
|
const transport=parseMaintenanceJSON(await readProtected(get('transport'),16384,get('transport-sha256')));object(transport);const mode=transport.mode===undefined?'tls':transport.mode;assert(mode==='tls'||mode==='local-loopback');assert(values.has('ca')===(mode==='tls'))
|
||||||
|
const ca=mode==='tls'?await readProtected(get('ca'),65536,get('ca-sha256')):undefined
|
||||||
|
const runtime=parseMaintenanceJSON(await readProtected(get('runtime-target'),16384,get('runtime-target-sha256')));exact(runtime,['format','databaseUrl']);assert(runtime.format==='database-maintenance-runtime-target-v1'&&typeof runtime.databaseUrl==='string');const runtimeUrl=new URL(runtime.databaseUrl);assert(!runtimeUrl.username&&!runtimeUrl.password)
|
||||||
|
const target=runtimeDatabaseTargetSha256(runtime.databaseUrl),{options,transportBinding}=maintenancePoolConfiguration(transport,ca,mode==='tls'?get('ca-sha256'):undefined)
|
||||||
|
exact(transport.connection,['host','port','user','password','database']);assert.equal(decodeURIComponent(runtimeUrl.pathname.slice(1)),transport.connection.database)
|
||||||
|
const records=await readMaintenanceAcceptanceRecords(stateDirectory);if(resolve)assert(records.length<64,'Maintenance acceptance history bound reached');const currentState=await snapshot(stateDirectory,target,records),before=currentState.external;if(resolve)assert(currentState.newUnresolved,'No new unresolved history to accept')
|
||||||
|
await inventoryMigrationDirectory(path.join(path.dirname(path.dirname(path.dirname(fileURLToPath(import.meta.url)))),'drizzle'))
|
||||||
|
let inspection:Record<string,unknown>|undefined,approved:ReturnType<typeof maintenanceApproval>|undefined
|
||||||
|
if(resolve){inspection=parseMaintenanceJSON(await readProtected(get('inspection'),67108864,get('inspection-sha256'))) as Record<string,unknown>;exact(inspection,['format','createdAt','expiresAt','runtimeTargetSha256','acceptanceRequired','external','knownCurrent','schema']);assert(inspection.format==='database-maintenance-current-state-inspection-v1'&&stamp(inspection.createdAt)&&stamp(inspection.expiresAt)&&Date.parse(inspection.createdAt)<=Date.now()&&Date.now()<Date.parse(inspection.expiresAt)&&Date.parse(inspection.expiresAt)-Date.parse(inspection.createdAt)<=validityMs&&inspection.runtimeTargetSha256===target&&inspection.acceptanceRequired===true);validateMaintenanceKnownCurrent(inspection.knownCurrent);assert.equal(stable(inspection.external),stable(before));approved=maintenanceApproval(parseMaintenanceJSON(await readProtected(get('approval'),16384,get('approval-sha256'))),get('inspection-sha256'),inspection.createdAt)}
|
||||||
|
const {default:pg}=await import('pg');assertJsDriverEnvironment(process.env)
|
||||||
|
const runProof=async()=>{const pool=new pg.Pool(options);let failed=false;pool.on('error',()=>{failed=true});try{const result=await observeMaintenanceCurrent(pool,transportBinding);await pool.end();assert(!failed);return result}catch(error){try{await pool.end()}catch{/* preserve refusal */}throw error}}
|
||||||
|
if(!resolve){const result=await runProof();assert.equal(stable((await snapshot(stateDirectory,target,await readMaintenanceAcceptanceRecords(stateDirectory))).external),stable(before));const nowMs=Date.now(),now=new Date(nowMs).toISOString(),out={format:'database-maintenance-current-state-inspection-v1',createdAt:now,expiresAt:new Date(nowMs+validityMs).toISOString(),runtimeTargetSha256:target,acceptanceRequired:currentState.newUnresolved,external:before,...result};const raw=Buffer.from(stable(out));assert(raw.length<=67108864);await writeFile(get('output'),raw,{flag:'wx',mode:0o600});return {event:'database-maintenance-inspection',inspectionSha256:sha(raw),unresolvedCoreOperations:before.journalCoverage.unresolvedOperations.length,unresolvedVaultFiles:before.vaultCoverage.length,state:currentState.newUnresolved?'inspection-required':before.previousResolutionSha256?'historically-accepted':'no-unresolved-history',historicalAcceptanceHeadSha256:before.previousResolutionSha256,humanApprovalRequired:currentState.newUnresolved,knownCurrentObserved:true,archiveDataEquivalenceProved:false,historicOutcomesChanged:false,automaticRestore:false}}
|
||||||
|
const resolutionDirectory=path.join(stateDirectory,'maintenance-acceptances');await mkdir(resolutionDirectory,{recursive:true,mode:0o700});await protectedDirectory(resolutionDirectory)
|
||||||
|
return withJournalLease(resolutionDirectory,async()=>{
|
||||||
|
// Existing immutable records are reread while our proven O_EXCL lease is held.
|
||||||
|
// Runtime reader refuses this lease; never infer stale ownership from PID/time.
|
||||||
|
const expected=records.map(raw=>{const v=parseMaintenanceJSON(raw) as {resolutionId:string};return v.resolutionId+'.json'}).sort()
|
||||||
|
assert.equal((await readdir(resolutionDirectory)).sort().join('\0'),[...expected,'publisher-lease.lock'].sort().join('\0'))
|
||||||
|
for(let i=0;i<records.length;i++){const value=parseMaintenanceJSON(records[i]) as {resolutionId:string};assert.equal(sha(await readProtected(path.join(resolutionDirectory,value.resolutionId+'.json'),1048576)),sha(records[i]))}
|
||||||
|
const observed=await runProof();assert.equal(stable(observed.knownCurrent),stable(inspection!.knownCurrent));assert.equal(stable(observed.schema),stable(inspection!.schema))
|
||||||
|
assert.equal(stable((await snapshot(stateDirectory,target,records)).external),stable(before));assert(Date.now()<Date.parse(approved!.expiresAt)&&Date.now()<Date.parse(inspection!.expiresAt as string))
|
||||||
|
const record={format:MAINTENANCE_ACCEPTANCE_FORMAT,resolutionId:approved!.resolutionId,decision:'accept-observed-current-state',historicOutcomeChanged:false,automaticRestore:false,actor:{kind:'host-operator',id:approved!.actorId},approvalSha256:get('approval-sha256'),inspectionSha256:get('inspection-sha256'),approvedAt:approved!.issuedAt,approvalExpiresAt:approved!.expiresAt,acceptedAt:new Date().toISOString(),runtimeTargetSha256:target,...before,knownCurrent:observed.knownCurrent}
|
||||||
|
validateMaintenanceAcceptance(record);const raw=Buffer.from(stable(record));assert(raw.length<=1048576,'Maintenance acceptance byte bound exceeded');await writeFile(path.join(resolutionDirectory,record.resolutionId+'.json'),raw,{flag:'wx',mode:0o600})
|
||||||
|
return {event:'database-maintenance-current-state-accepted',outcome:'committed',resolutionId:record.resolutionId,acceptanceSha256:sha(raw),archiveDataEquivalenceProved:false,historicOutcomesChanged:false,automaticRestore:false}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
/** Explicit operator/lifecycle read component. No startup, grants or READY. */
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import {lstat} from 'node:fs/promises'
|
||||||
|
import path from 'node:path'
|
||||||
|
import {protectedDirectory,readProtected} from './operator-files.mjs'
|
||||||
|
import {createDatabaseSourceBindingVerifier,readDatabaseSourceBindingRegistry,
|
||||||
|
type DatabaseSourceDeploymentPin} from '../../src/db/database-source-binding.js'
|
||||||
|
import type {DatabaseSourceObservation,DatabaseSourceObservationOptions} from '../../src/db/database-source-observation.js'
|
||||||
|
import type {ProbeStorageSql} from '../../src/services/server-probe-journal-maintenance.js'
|
||||||
|
|
||||||
|
const hex=(value:unknown):value is string=>typeof value==='string'&&/^[a-f0-9]{64}$/.test(value)
|
||||||
|
function fence(options:DatabaseSourceObservationOptions){
|
||||||
|
assert(Number.isSafeInteger(options.deadlineAt)&&Date.now()<options.deadlineAt&&!options.signal?.aborted,'Protected source lifetime refused')
|
||||||
|
}
|
||||||
|
async function identity(filename:string,content=false){
|
||||||
|
const s=await lstat(filename,{bigint:true})
|
||||||
|
// BigInt identity is never narrowed to Number. Content changes/permission
|
||||||
|
// changes are checked around the whole verified read, not just open().
|
||||||
|
return [s.dev,s.ino,s.mode,s.uid,s.gid,s.nlink,...(content?[s.size,s.mtimeNs,s.ctimeNs]:[])].map(String).join(':')
|
||||||
|
}
|
||||||
|
async function protectedState(pin:DatabaseSourceDeploymentPin){
|
||||||
|
// Proven POSIX protection refuses unsupported Windows ACL BEFORE file reads.
|
||||||
|
await protectedDirectory(pin.registryDirectory)
|
||||||
|
const directories:Record<string,string>={}
|
||||||
|
for(let p=pin.registryDirectory;;p=path.dirname(p)){
|
||||||
|
directories[p]=await identity(p,p===pin.registryDirectory)
|
||||||
|
if(p===path.dirname(p))break
|
||||||
|
}
|
||||||
|
const names=['operator-public.pem','head-'+pin.currentHeadSha256+'.json']
|
||||||
|
const content:Record<string,Buffer>={},identities:Record<string,string>={}
|
||||||
|
const collect=async(name:string,maximum:number,expected?:string)=>{
|
||||||
|
const filename=path.join(pin.registryDirectory,name)
|
||||||
|
const before=await identity(filename,true)
|
||||||
|
content[name]=await readProtected(filename,maximum,expected)
|
||||||
|
assert.equal(await identity(filename,true),before,'Protected source file changed during read')
|
||||||
|
identities[name]=before
|
||||||
|
}
|
||||||
|
await collect(names[0],4096)
|
||||||
|
await collect(names[1],8192,pin.currentHeadSha256)
|
||||||
|
const head=JSON.parse(new TextDecoder('utf8',{fatal:true}).decode(content[names[1]]))
|
||||||
|
assert(hex(head?.body?.bindingSha256),'Pinned source binding digest required')
|
||||||
|
const bindingName='binding-'+head.body.bindingSha256+'.json'
|
||||||
|
await collect(bindingName,8192,head.body.bindingSha256)
|
||||||
|
await protectedDirectory(pin.registryDirectory)
|
||||||
|
for(const [directory,wanted]of Object.entries(directories))assert.equal(await identity(directory,directory===pin.registryDirectory),wanted,'Protected source directory changed')
|
||||||
|
return {directories,identities,content}
|
||||||
|
}
|
||||||
|
function same(before:Awaited<ReturnType<typeof protectedState>>,after:Awaited<ReturnType<typeof protectedState>>){
|
||||||
|
assert.deepEqual(after.directories,before.directories,'Protected source directory drift')
|
||||||
|
assert.deepEqual(after.identities,before.identities,'Protected source identity/mode drift')
|
||||||
|
assert.deepEqual(after.content,before.content,'Protected source content drift')
|
||||||
|
}
|
||||||
|
function captured(raw:DatabaseSourceDeploymentPin){
|
||||||
|
const pin=Object.freeze({...raw})
|
||||||
|
// Existing strict deployment schema rejects unknown/malformed pin fields.
|
||||||
|
const verifier=createDatabaseSourceBindingVerifier(pin)
|
||||||
|
return {pin,verifier}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Signature/head/source DATA plus actual POSIX protection of the pinned files.
|
||||||
|
* The supplied pin still needs independent deployment approval; protection
|
||||||
|
* cannot distinguish a restored clone/generation or authenticate all writers. */
|
||||||
|
export async function readProtectedDatabaseSourceBindingRegistry(raw:DatabaseSourceDeploymentPin,options:DatabaseSourceObservationOptions){
|
||||||
|
const {pin}=captured(raw);const lifetime={...options};fence(lifetime)
|
||||||
|
const before=await protectedState(pin);fence(lifetime)
|
||||||
|
const result=await readDatabaseSourceBindingRegistry(pin);fence(lifetime)
|
||||||
|
const after=await protectedState(pin);fence(lifetime);same(before,after)
|
||||||
|
return Object.freeze({...result,platformProtectionVerified:true as const,
|
||||||
|
generationAuthenticated:false as const,allWriterDrainAuthenticated:false as const,readyAuthorized:false as const})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Same-facade source verification is retained; filesystem JSON is never a
|
||||||
|
* source observation, writer capability or independent generation authority. */
|
||||||
|
export function createProtectedDatabaseSourceBindingVerifier(raw:DatabaseSourceDeploymentPin){
|
||||||
|
const {pin,verifier}=captured(raw)
|
||||||
|
return Object.freeze({async verify(sql:ProbeStorageSql,observation:DatabaseSourceObservation,options:DatabaseSourceObservationOptions){
|
||||||
|
const lifetime={...options};fence(lifetime)
|
||||||
|
const before=await protectedState(pin);fence(lifetime)
|
||||||
|
const result=await verifier.verify(sql,observation,lifetime);fence(lifetime)
|
||||||
|
const after=await protectedState(pin);fence(lifetime);same(before,after)
|
||||||
|
return Object.freeze({...result,platformProtectionVerified:true as const,
|
||||||
|
generationAuthenticated:false as const,allWriterDrainAuthenticated:false as const,readyAuthorized:false as const})
|
||||||
|
}})
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
/** Genuine Ed25519/files; POSIX permission metadata is modeled on Windows.
|
||||||
|
* No SQL, service, deployment or real POSIX acceptance is claimed. */
|
||||||
|
import test from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import fs from 'node:fs/promises'
|
||||||
|
import path from 'node:path'
|
||||||
|
import os from 'node:os'
|
||||||
|
import crypto from 'node:crypto'
|
||||||
|
import {createRequire} from 'node:module'
|
||||||
|
import type {Stats,BigIntStats} from 'node:fs'
|
||||||
|
import type {DatabaseSourceDeploymentPin} from '../../src/db/database-source-binding.ts'
|
||||||
|
import type * as ProtectedApi from './protected-source-registry.mts'
|
||||||
|
import {runInNewContext} from 'node:vm'
|
||||||
|
import {readProtectedDatabaseSourceBindingRegistry} from './protected-source-registry.mts'
|
||||||
|
import {readDatabaseSourceBindingRegistry,sourceBindingBytes,sourceBindingCanonical,sourceBindingSha256,DATABASE_SOURCE_SIGNATURE_DOMAINS} from '../../src/db/database-source-binding.ts'
|
||||||
|
const require=createRequire(import.meta.url),ts=require('typescript')
|
||||||
|
const helperSource=await fs.readFile(new URL('./operator-files.mjs',import.meta.url),'utf8')
|
||||||
|
const source=await fs.readFile(new URL('./protected-source-registry.mts',import.meta.url),'utf8')
|
||||||
|
const deadline=()=>({deadlineAt:Date.now()+10000})
|
||||||
|
function compile(text:string){return ts.transpileModule(text,{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2022,esModuleInterop:true}}).outputText}
|
||||||
|
function evaluate<T=Record<string,unknown>>(text:string,imports:Record<string,unknown>,processModel:unknown=process):T{
|
||||||
|
const exports:Record<string,unknown>={}
|
||||||
|
runInNewContext(compile(text),{exports,require:(name:string)=>imports[name]??require(name),process:processModel,Buffer,TextDecoder,console})
|
||||||
|
return exports as T
|
||||||
|
}
|
||||||
|
interface Fixture {pin:DatabaseSourceDeploymentPin;faults:Record<string,boolean>;api:typeof ProtectedApi;verificationReads:()=>number;root:string}
|
||||||
|
async function own(work:(f:Fixture)=>Promise<void>){
|
||||||
|
const root=await fs.mkdtemp(path.join(os.tmpdir(),'mm-protected-source-'))
|
||||||
|
try{
|
||||||
|
const key=crypto.generateKeyPairSync('ed25519'),installationId=crypto.randomUUID()
|
||||||
|
const signed=(body:unknown,domain:string)=>sourceBindingBytes({body,signature:crypto.sign(null,Buffer.from(domain+sourceBindingCanonical(body)),key.privateKey).toString('base64url')})
|
||||||
|
const body={format:'database-source-binding-v1',installationId,sourceIdentityId:crypto.randomUUID(),generation:crypto.randomUUID(),ordinal:1,
|
||||||
|
source:{systemIdentifier:'123',databaseOid:16384,databaseName:'synthetic',sessionUser:'capture',currentUser:'capture',inRecovery:false,serverVersionNum:180004}}
|
||||||
|
const binding=signed(body,DATABASE_SOURCE_SIGNATURE_DOMAINS.binding),bindingSha256=sourceBindingSha256(binding)
|
||||||
|
const head=signed({format:'database-source-registry-head-v1',installationId,sourceIdentityId:body.sourceIdentityId,generation:body.generation,ordinal:1,bindingSha256,previousHeadSha256:null},DATABASE_SOURCE_SIGNATURE_DOMAINS.head)
|
||||||
|
const pin={registryDirectory:root,installationId,publicKeySpkiSha256:sourceBindingSha256(key.publicKey.export({format:'der',type:'spki'})),currentHeadSha256:sourceBindingSha256(head)}
|
||||||
|
await fs.writeFile(path.join(root,'operator-public.pem'),key.publicKey.export({format:'pem',type:'spki'}),{mode:0o600})
|
||||||
|
await fs.writeFile(path.join(root,'head-'+pin.currentHeadSha256+'.json'),head,{mode:0o600})
|
||||||
|
await fs.writeFile(path.join(root,'binding-'+bindingSha256+'.json'),binding,{mode:0o600})
|
||||||
|
const faults={modeFile:false,modeDirectory:false,symlink:false,replacement:false,lateMode:false,changed:false};let verificationReads=0
|
||||||
|
const metadata=(s:Stats|BigIntStats,filename:string,big:boolean)=>{
|
||||||
|
const n=(value:number)=>big?BigInt(value):value,isRoot=filename===root,isFile=s.isFile()
|
||||||
|
const mode=(isFile?0o100600:0o40700)|((isFile&&faults.modeFile||isRoot&&faults.modeDirectory||isFile&&faults.lateMode&&faults.changed)?0o022:0)
|
||||||
|
return {...s,mode:n(mode),uid:n(1000),gid:n(1000),ino:faults.replacement&&faults.changed&&isFile?(typeof s.ino==='bigint'?s.ino+1n:s.ino+1):s.ino,
|
||||||
|
isFile:()=>isFile,isDirectory:()=>s.isDirectory(),isSymbolicLink:()=>faults.symlink&&isRoot}
|
||||||
|
}
|
||||||
|
const modelFs={...fs,lstat:async(filename:string,options?:{bigint?:boolean})=>metadata(await fs.lstat(filename,{bigint:options?.bigint===true}),filename,options?.bigint===true),open:async(filename:string,flags:number)=>{
|
||||||
|
const file=await fs.open(filename,flags)
|
||||||
|
return {stat:async()=>metadata(await file.stat(),filename,false),read:file.read.bind(file),close:file.close.bind(file)}
|
||||||
|
}}
|
||||||
|
const processModel={platform:'linux',getuid:()=>1000}
|
||||||
|
const proven=evaluate(helperSource,{'node:fs/promises':modelFs},processModel)
|
||||||
|
const bindingModule={readDatabaseSourceBindingRegistry:async(p:DatabaseSourceDeploymentPin)=>{
|
||||||
|
verificationReads++;const result=await readDatabaseSourceBindingRegistry(p);faults.changed=true;return result
|
||||||
|
},createDatabaseSourceBindingVerifier:()=>({verify:async()=>{verificationReads++;faults.changed=true;return {genuineSourceFactsMatched:true,generationAuthenticated:false,readyAuthorized:false}}})}
|
||||||
|
const api=evaluate<typeof ProtectedApi>(source,{'node:fs/promises':modelFs,'./operator-files.mjs':proven,'../../src/db/database-source-binding.js':bindingModule},processModel)
|
||||||
|
await work({pin,faults,api,verificationReads:()=>verificationReads,root})
|
||||||
|
}finally{const rel=path.relative(os.tmpdir(),root);assert(rel&&!rel.startsWith('..')&&!path.isAbsolute(rel));await fs.rm(root,{recursive:true,force:true})}
|
||||||
|
}
|
||||||
|
|
||||||
|
test('unsupported Windows refuses before registry signature reads',{skip:process.platform!=='win32'},async()=>{
|
||||||
|
await assert.rejects(readProtectedDatabaseSourceBindingRegistry({registryDirectory:path.resolve('not-created-protected-source'),installationId:crypto.randomUUID(),publicKeySpkiSha256:'a'.repeat(64),currentHeadSha256:'b'.repeat(64)},deadline()),/Windows ACL/)
|
||||||
|
})
|
||||||
|
test('proven POSIX helpers plus genuine signature/head bytes retain false generation/READY',()=>own(async f=>{
|
||||||
|
const value=await f.api.readProtectedDatabaseSourceBindingRegistry(f.pin,deadline())
|
||||||
|
assert.equal(value.platformProtectionVerified,true);assert.equal(value.generationAuthenticated,false);assert.equal(value.allWriterDrainAuthenticated,false);assert.equal(value.readyAuthorized,false)
|
||||||
|
assert.equal(value.headSha256,f.pin.currentHeadSha256);assert.equal(f.verificationReads(),1)
|
||||||
|
}))
|
||||||
|
for(const fault of ['modeFile','modeDirectory','symlink'])test(fault+' refuses before cryptographic registry read',()=>own(async f=>{
|
||||||
|
f.faults[fault]=true;await assert.rejects(f.api.readProtectedDatabaseSourceBindingRegistry(f.pin,deadline()));assert.equal(f.verificationReads(),0)
|
||||||
|
}))
|
||||||
|
for(const fault of ['replacement','lateMode'])test(fault+' during genuine signature verification refuses protected output',()=>own(async f=>{
|
||||||
|
f.faults[fault]=true;await assert.rejects(f.api.readProtectedDatabaseSourceBindingRegistry(f.pin,deadline()));assert.equal(f.verificationReads(),1)
|
||||||
|
}))
|
||||||
|
test('expired/aborted lifetime refuses before filesystem or source access',()=>own(async f=>{
|
||||||
|
const abort=new AbortController();abort.abort()
|
||||||
|
for(const options of [{deadlineAt:Date.now()-1},{deadlineAt:Date.now()+1000,signal:abort.signal}])await assert.rejects(f.api.readProtectedDatabaseSourceBindingRegistry(f.pin,options))
|
||||||
|
assert.equal(f.verificationReads(),0)
|
||||||
|
}))
|
||||||
|
test('source verifier drift cannot grant protected output',()=>own(async f=>{
|
||||||
|
f.faults.lateMode=true
|
||||||
|
const verify=f.api.createProtectedDatabaseSourceBindingVerifier(f.pin).verify
|
||||||
|
await assert.rejects(verify({} as Parameters<typeof verify>[0],{} as Parameters<typeof verify>[1],deadline()))
|
||||||
|
assert.equal(f.verificationReads(),1)
|
||||||
|
}))
|
||||||
@@ -141,14 +141,14 @@ async function preflightCurrentProfile(pool,{deadlineAt=Date.now()+120000}={}){
|
|||||||
const first=await ownedContext(s,installation,PUBLISHER,'publisher-read')
|
const first=await ownedContext(s,installation,PUBLISHER,'publisher-read')
|
||||||
if(canonical(sourceCoordinates(first))!==canonical(installation.expectedSource))throw Error('Pinned preflight source mismatch')
|
if(canonical(sourceCoordinates(first))!==canonical(installation.expectedSource))throw Error('Pinned preflight source mismatch')
|
||||||
const facade=Object.freeze({query:(sql,values)=>s.query(sql,values)}),options={deadlineAt}
|
const facade=Object.freeze({query:(sql,values)=>s.query(sql,values)}),options={deadlineAt}
|
||||||
const observation=await captureDatabaseSourceObservation(facade,options);await sourceVerifier.verify(facade,observation,options)
|
const observation=await captureDatabaseSourceObservation(facade,options),initialSource=await sourceVerifier.verify(facade,observation,options)
|
||||||
const security=await protectedStructure(s,installation),catalogue=await collectFixedCatalogue(s)
|
const security=await protectedStructure(s,installation),catalogue=await collectFixedCatalogue(s)
|
||||||
const current=await readCurrent(s,installation)
|
const current=await readCurrent(s,installation)
|
||||||
if(!current)throw Error('Current signed policy required for preflight')
|
if(!current)throw Error('Current signed policy required for preflight')
|
||||||
const catalogSha256=sha(canonical({base:catalogue.catalogSha256,security:security.sha256})),manifestSha256=migrationManifestDigest()
|
const catalogSha256=sha(canonical({base:catalogue.catalogSha256,security:security.sha256})),manifestSha256=migrationManifestDigest()
|
||||||
if(approval.expiresAt<=Date.now()||approval.manifestSha256!==manifestSha256||approval.definitionSha256!==catalogue.definitionSha256||approval.catalogSha256!==catalogSha256)throw Error('Independent preflight approval differs from actual source44 catalogue')
|
if(approval.expiresAt<=Date.now()||approval.manifestSha256!==manifestSha256||approval.definitionSha256!==catalogue.definitionSha256||approval.catalogSha256!==catalogSha256)throw Error('Independent preflight approval differs from actual source44 catalogue')
|
||||||
if(canonical(current.policy.source)!==canonical(sourceCoordinates(first))||canonical(current.policy.roles)!==canonical(security.roles)||current.policy.manifestSha256!==manifestSha256||current.policy.definitionSha256!==catalogue.definitionSha256||current.policy.catalogSha256!==catalogSha256)throw Error('Current signed policy differs from actual source/roles/catalogue')
|
if(canonical(current.policy.source)!==canonical(sourceCoordinates(first))||canonical(current.policy.roles)!==canonical(security.roles)||current.policy.manifestSha256!==manifestSha256||current.policy.definitionSha256!==catalogue.definitionSha256||current.policy.catalogSha256!==catalogSha256)throw Error('Current signed policy differs from actual source/roles/catalogue')
|
||||||
await sourceVerifier.verify(facade,observation,options);await assertDatabaseSourceObservationCurrent(facade,observation,options)
|
const terminalSource=await sourceVerifier.verify(facade,observation,options);await assertDatabaseSourceObservationCurrent(facade,observation,options)
|
||||||
sameOwned(first,await ownedContext(s,installation,PUBLISHER,'publisher-read'))
|
sameOwned(first,await ownedContext(s,installation,PUBLISHER,'publisher-read'))
|
||||||
// Recheck the live interval after the potentially lengthy catalogue walk.
|
// Recheck the live interval after the potentially lengthy catalogue walk.
|
||||||
const clock=(await s.query('SELECT (EXTRACT(EPOCH FROM pg_catalog.clock_timestamp())*1000)::bigint::text AS now')).rows
|
const clock=(await s.query('SELECT (EXTRACT(EPOCH FROM pg_catalog.clock_timestamp())*1000)::bigint::text AS now')).rows
|
||||||
@@ -157,7 +157,7 @@ async function preflightCurrentProfile(pool,{deadlineAt=Date.now()+120000}={}){
|
|||||||
if(approval.expiresAt<=Date.now())throw Error('Preflight approval expired before terminal')
|
if(approval.expiresAt<=Date.now())throw Error('Preflight approval expired before terminal')
|
||||||
const terminal=await s.query('ROLLBACK');if(terminal.command!=='ROLLBACK')throw Error('Preflight terminal unconfirmed');s.begun(false)
|
const terminal=await s.query('ROLLBACK');if(terminal.command!=='ROLLBACK')throw Error('Preflight terminal unconfirmed');s.begun(false)
|
||||||
return Object.freeze({state:'matched',epoch:current.epoch,headSha256:current.headSha256,notAfter:current.policy.notAfter,
|
return Object.freeze({state:'matched',epoch:current.epoch,headSha256:current.headSha256,notAfter:current.policy.notAfter,
|
||||||
sourceMatched:true,rolesMatched:true,catalogueMatched:true,writerAuthorized:false,generationAuthenticated:false,readyAuthorized:false})
|
sourceMatched:true,rolesMatched:true,catalogueMatched:true,platformProtectionVerified:initialSource?.platformProtectionVerified===true&&terminalSource?.platformProtectionVerified===true,writerAuthorized:false,generationAuthenticated:false,readyAuthorized:false})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
async function boundedPending(filename){
|
async function boundedPending(filename){
|
||||||
|
|||||||
@@ -3,10 +3,14 @@ import { applySqlMigrations } from "./migrate.js"
|
|||||||
import { dropExpiredPartitions, ensurePartitionsAround } from "./partitions.js"
|
import { dropExpiredPartitions, ensurePartitionsAround } from "./partitions.js"
|
||||||
import { importSqliteToPostgres, shouldImportSqlite, sqliteFileLooksPresent } from "./sqlite-import.js"
|
import { importSqliteToPostgres, shouldImportSqlite, sqliteFileLooksPresent } from "./sqlite-import.js"
|
||||||
import { env } from "../config.js"
|
import { env } from "../config.js"
|
||||||
|
import { mcpStateDirectory } from "../modules/mcp/security.js"
|
||||||
|
import { assertDatabaseMaintenanceStartup } from "../services/database-maintenance-startup.js"
|
||||||
|
import { reconcileDatabaseRestoreAudits } from "../services/database-restore-audit.js"
|
||||||
|
|
||||||
const ETL_LOCK = 8723101
|
const ETL_LOCK = 8723101
|
||||||
|
|
||||||
export async function initDatabase(): Promise<void> {
|
export async function initDatabase(): Promise<void> {
|
||||||
|
await assertDatabaseMaintenanceStartup(mcpStateDirectory(), env.DATABASE_URL)
|
||||||
await pool.query("SELECT 1")
|
await pool.query("SELECT 1")
|
||||||
await applySqlMigrations(pool)
|
await applySqlMigrations(pool)
|
||||||
await ensurePartitionsAround(pool)
|
await ensurePartitionsAround(pool)
|
||||||
@@ -39,4 +43,5 @@ export async function initDatabase(): Promise<void> {
|
|||||||
}
|
}
|
||||||
await dropExpiredPartitions(pool)
|
await dropExpiredPartitions(pool)
|
||||||
await ensurePartitionsAround(pool)
|
await ensurePartitionsAround(pool)
|
||||||
|
await reconcileDatabaseRestoreAudits(pool, mcpStateDirectory())
|
||||||
}
|
}
|
||||||
@@ -12,10 +12,10 @@ const reviewed = (source: string, extra: Partial<MigrationReview> = {}) => {
|
|||||||
const bytes = Buffer.from(source), file = "9000_fixture.sql"
|
const bytes = Buffer.from(source), file = "9000_fixture.sql"
|
||||||
return () => analyzeMigration(file, bytes, { file, sha256: createHash("sha256").update(bytes).digest("hex"), transaction: "owner-transaction", selfMarker: false, opaqueBodies: 0, bodyReview: [], effects: ["Explicit inert fixture review"], ...extra })
|
return () => analyzeMigration(file, bytes, { file, sha256: createHash("sha256").update(bytes).digest("hex"), transaction: "owner-transaction", selfMarker: false, opaqueBodies: 0, bodyReview: [], effects: ["Explicit inert fixture review"], ...extra })
|
||||||
}
|
}
|
||||||
await test("all44 exact file pins and every top-level schema operation are inventoried", () => {
|
await test("all 56 exact file pins and every top-level schema operation are inventoried", () => {
|
||||||
assert.equal(inventory.length, 44); assert.equal(MIGRATION_MANIFEST.length, 44)
|
assert.equal(inventory.length, 56); assert.equal(MIGRATION_MANIFEST.length, 56)
|
||||||
assert.equal(inventory.filter(file => file.transaction === "owner-transaction").length, 17)
|
assert.equal(inventory.filter(file => file.transaction === "owner-transaction").length, 17)
|
||||||
assert.equal(inventory.filter(file => file.transaction === "self-managed-single-transaction").length, 27)
|
assert.equal(inventory.filter(file => file.transaction === "self-managed-single-transaction").length, 39)
|
||||||
assert(inventory.every(file => file.operations.length === file.statementCount && file.effects.length > 0))
|
assert(inventory.every(file => file.operations.length === file.statementCount && file.effects.length > 0))
|
||||||
let tables = new Set<string>()
|
let tables = new Set<string>()
|
||||||
for (const file of inventory) {
|
for (const file of inventory) {
|
||||||
@@ -32,8 +32,8 @@ await test("DO BEGIN and stored function BEGIN are never transaction boundaries"
|
|||||||
assert.equal(endpoint.topLevelTransactionControls.length, 2); assert.equal(endpoint.opaqueBodies.length, 2)
|
assert.equal(endpoint.topLevelTransactionControls.length, 2); assert.equal(endpoint.opaqueBodies.length, 2)
|
||||||
assert.deepEqual(endpoint.opaqueBodies.map(body => body.kind), ["stored-function-body", "executed-do-body"])
|
assert.deepEqual(endpoint.opaqueBodies.map(body => body.kind), ["stored-function-body", "executed-do-body"])
|
||||||
})
|
})
|
||||||
await test("0035 through0044 have exact atomic file-owned terminal markers", () => {
|
await test("0035 through 0056 have exact atomic file-owned terminal markers", () => {
|
||||||
assert.deepEqual(inventory.filter(file => file.selfMarker).map(file => file.id), ["0035_server_probe_journal", "0036_diagnostic_cancellations", "0037_server_probe_storage_v2", "0038_server_poll_journal_and_writer_guards", "0039_server_poll_storage", "0040_mcp_routing_reservations", "0041_server_poll_terminal_archives", "0042_server_poll_settings_epoch", "0043_mcp_routing_forward_charges", "0044_mcp_routing_manual_inverse_charges"])
|
assert.deepEqual(inventory.filter(file => file.selfMarker).map(file => file.id), ["0035_server_probe_journal", "0036_diagnostic_cancellations", "0037_server_probe_storage_v2", "0038_server_poll_journal_and_writer_guards", "0039_server_poll_storage", "0040_mcp_routing_reservations", "0041_server_poll_terminal_archives", "0042_server_poll_settings_epoch", "0043_mcp_routing_forward_charges", "0044_mcp_routing_manual_inverse_charges", "0045_mcp_operation_replay_tombstones", "0046_server_poll_operation_retirement", "0047_server_poll_retirement_claims", "0048_mcp_ordinary_retirement_claims", "0049_mcp_ordinary_telemetry_retirement", "0050_server_probe_retired_sources", "0051_server_probe_retirement_claims", "0052_server_probe_retired_gc", "0053_server_probe_terminal_proofs", "0054_mcp_geoip_retirement", "0055_mcp_backup_schedule_retirement", "0056_mcp_manual_inverse_recovery_v8"])
|
||||||
for (const file of inventory.filter(file => file.selfMarker)) { assert.equal(file.selfMarker!.atomicWithAllFileEffects, true); assert.equal(file.selfMarker!.statement, file.statementCount - 2); assert.equal(file.currentOuterMarkerGap, false) }
|
for (const file of inventory.filter(file => file.selfMarker)) { assert.equal(file.selfMarker!.atomicWithAllFileEffects, true); assert.equal(file.selfMarker!.statement, file.statementCount - 2); assert.equal(file.currentOuterMarkerGap, false) }
|
||||||
assert.equal(inventory.filter(file => file.currentOuterMarkerGap).length, 34)
|
assert.equal(inventory.filter(file => file.currentOuterMarkerGap).length, 34)
|
||||||
})
|
})
|
||||||
@@ -134,7 +134,8 @@ await test("poll storage enrollment refuses history before any change and preser
|
|||||||
for(const name of ["server_poll_executions","server_poll_intents","server_poll_acks","server_poll_receipts","server_poll_quarantines"])assert(guard.includes(`EXISTS(SELECT 1 FROM ${name})`))
|
for(const name of ["server_poll_executions","server_poll_intents","server_poll_acks","server_poll_receipts","server_poll_quarantines"])assert(guard.includes(`EXISTS(SELECT 1 FROM ${name})`))
|
||||||
for(const name of ["server_poll_storage_admissions","server_poll_storage_usage"])assert(guard.includes("'"+name+"'")&&CURRENT_REQUIRED_TABLES.includes(name))
|
for(const name of ["server_poll_storage_admissions","server_poll_storage_usage"])assert(guard.includes("'"+name+"'")&&CURRENT_REQUIRED_TABLES.includes(name))
|
||||||
const bodies=(list:ReturnType<typeof parseMigrationStatements>)=>list.filter(statement=>statement.tokens[0].value==="CREATE").flatMap(statement=>statement.tokens.filter(token=>token.kind==="dollar").map(token=>token.value))
|
const bodies=(list:ReturnType<typeof parseMigrationStatements>)=>list.filter(statement=>statement.tokens[0].value==="CREATE").flatMap(statement=>statement.tokens.filter(token=>token.kind==="dollar").map(token=>token.value))
|
||||||
assert.deepEqual(bodies(statements),bodies(historical));assert.equal(bodies(statements).length,5)
|
const comparableBodies=(list:ReturnType<typeof parseMigrationStatements>)=>bodies(list).map(body=>body.replace(/\r\n/g,"\n"))
|
||||||
|
assert.deepEqual(comparableBodies(statements),comparableBodies(historical));assert.equal(bodies(statements).length,5)
|
||||||
assert(bodies(statements)[0].includes("mcp_settings.id=1 FOR SHARE"),"Predicate itself fences current retention, including direct guarded deletes")
|
assert(bodies(statements)[0].includes("mcp_settings.id=1 FOR SHARE"),"Predicate itself fences current retention, including direct guarded deletes")
|
||||||
assert.deepEqual(migration.operations.filter(operation=>operation.command==="INSERT").map(operation=>operation.target),["server_poll_storage_usage","schema_migrations"])
|
assert.deepEqual(migration.operations.filter(operation=>operation.command==="INSERT").map(operation=>operation.target),["server_poll_storage_usage","schema_migrations"])
|
||||||
assert.equal(migration.operations.some(operation=>["UPDATE","DELETE","DROP"].includes(operation.command)),false)
|
assert.equal(migration.operations.some(operation=>["UPDATE","DELETE","DROP"].includes(operation.command)),false)
|
||||||
@@ -227,4 +228,20 @@ await test("0044 adds only a fixed manual inverse ledger and current-version int
|
|||||||
assert(!pin.operations.some(operation => operation.command === "UPDATE" || operation.command === "DELETE"))
|
assert(!pin.operations.some(operation => operation.command === "UPDATE" || operation.command === "DELETE"))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
await test("0056 changes only live manual inverse recovery version guards", async () => {
|
||||||
|
const migration = inventory.find(file => file.id === "0056_mcp_manual_inverse_recovery_v8")!
|
||||||
|
assert.equal(migration.statementCount, 5)
|
||||||
|
assert.equal(migration.opaqueBodies.length, 2)
|
||||||
|
assert.equal(migration.selfMarker?.atomicWithAllFileEffects, true)
|
||||||
|
const oldSql = await readFile(new URL("../../drizzle/0044_mcp_routing_manual_inverse_charges.sql", import.meta.url), "utf8")
|
||||||
|
const newSql = await readFile(new URL("../../drizzle/0056_mcp_manual_inverse_recovery_v8.sql", import.meta.url), "utf8")
|
||||||
|
for (const name of ["mcp_routing_manual_inverse_charge_source", "mcp_scoped_recovery_manual_version"]) {
|
||||||
|
const definition = new RegExp(`CREATE(?: OR REPLACE)? FUNCTION public\\.${name}\\(\\)[\\s\\S]*?END \\$\\$;`)
|
||||||
|
const original = oldSql.match(definition)?.[0]?.replaceAll("\r\n", "\n")
|
||||||
|
assert.ok(original)
|
||||||
|
assert.equal(newSql.match(definition)?.[0]?.replaceAll("\r\n", "\n"), original.replace("CREATE FUNCTION", "CREATE OR REPLACE FUNCTION")
|
||||||
|
.replace("p.operation_id='operations.recover' AND p.operation_version=7", "p.operation_id='operations.recover' AND p.operation_version=8"))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
console.log(`migration-manifest: ${cases} inert parser/inventory/baseline scenarios passed; ${inventory.length} exact files`)
|
console.log(`migration-manifest: ${cases} inert parser/inventory/baseline scenarios passed; ${inventory.length} exact files`)
|
||||||
@@ -702,6 +702,172 @@ const classifiedMigrationPins: readonly MigrationReview[] = [
|
|||||||
"Adds one one-unit immutable manual inverse charge table, two charge indexes, one manual-history page index, and four triggers. Existing43 bytes, reserved2 and generic debit ownership remain unchanged.",
|
"Adds one one-unit immutable manual inverse charge table, two charge indexes, one manual-history page index, and four triggers. Existing43 bytes, reserved2 and generic debit ownership remain unchanged.",
|
||||||
"No inferred/backfilled identity, legacy timestamp debit, permission change, source enrollment, public controller activation or READY. Full RT3/all-writer/family-deletion proof remains open."
|
"No inferred/backfilled identity, legacy timestamp debit, permission change, source enrollment, public controller activation or READY. Full RT3/all-writer/family-deletion proof remains open."
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "0045_mcp_operation_replay_tombstones.sql",
|
||||||
|
"sha256": "8edf5b074c7eef49dad7835c44ac2539df4347f19eb9dd9b2aa7005bafaadbc0",
|
||||||
|
"transaction": "self-managed-single-transaction",
|
||||||
|
"selfMarker": true,
|
||||||
|
"opaqueBodies": 0,
|
||||||
|
"bodyReview": [],
|
||||||
|
"effects": [
|
||||||
|
"Adds one empty replay identity table with plan/operation uniqueness and connection-scoped idempotency digest uniqueness.",
|
||||||
|
"Table creation refuses an unmarked collision; the exact marker commits with the schema. No backfill, operation deletion, or worker activation."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "0046_server_poll_operation_retirement.sql",
|
||||||
|
"sha256": "75d7956ea8134b01bcc88da083b77eb52f6e1ad70ad854918ed3380cb0e8cf88",
|
||||||
|
"transaction": "self-managed-single-transaction",
|
||||||
|
"selfMarker": true,
|
||||||
|
"opaqueBodies": 4,
|
||||||
|
"bodyReview": [
|
||||||
|
"Archive guard permits only fixed receipt-scoped V1 to V2 updates with matching approved terminal operation, tombstone and SQL 180-day cutoff.",
|
||||||
|
"Deferred guard independently requires the tombstone, physically absent operation and plan, and exact ledger accounting.",
|
||||||
|
"Admission guard retains existing paths and applies exact V2 archive-byte delta to both owners without changing row charges or lifetime fields.",
|
||||||
|
"Existing GC retains its authority/activity cutoff and admits retired identity only with exact poll tombstone and compatible V2 archive when present."
|
||||||
|
],
|
||||||
|
"effects": [
|
||||||
|
"Extends the same archive table to a bounded version 2, changes three guards and adds one deferred retirement guard.",
|
||||||
|
"No automatic conversion, worker activation, operation deletion or retention-window adjustment in migration."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "0047_server_poll_retirement_claims.sql",
|
||||||
|
"sha256": "8c01ab14d241f715af021e8a7d7f5bb273b450b32f566140359260282524e01d",
|
||||||
|
"transaction": "self-managed-single-transaction",
|
||||||
|
"selfMarker": true,
|
||||||
|
"opaqueBodies": 2,
|
||||||
|
"bodyReview": [
|
||||||
|
"Protects exact current-transaction V1 claim insertion and permits deletion only as the owning admission is cascaded by GC.",
|
||||||
|
"Requires exact receipt, V1 archive proof and claim token already committed in another transaction before V2 update."
|
||||||
|
],
|
||||||
|
"effects": [
|
||||||
|
"Adds one durable one-shot poll retirement claim per admission, cascading only with that admission's existing guarded GC.",
|
||||||
|
"No migration-time conversion, backfill, dispatch, or quota change."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "0048_mcp_ordinary_retirement_claims.sql",
|
||||||
|
"sha256": "5bc3598c9f5d09611e98d05345accda6d39b199a583bba789b97a24140fafbfd",
|
||||||
|
"transaction": "self-managed-single-transaction",
|
||||||
|
"selfMarker": true,
|
||||||
|
"opaqueBodies": 1,
|
||||||
|
"bodyReview": [
|
||||||
|
"Makes ordinary claims immutable and allows insert only for the exact linked settled and aged logs.settings.update v1 operation."
|
||||||
|
],
|
||||||
|
"effects": [
|
||||||
|
"Adds one durable ordinary-operation claim with an authenticated source digest. No migration-time data purge or backfill."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "0049_mcp_ordinary_telemetry_retirement.sql",
|
||||||
|
"sha256": "68993e924d20db6b08973846a0da367fe89fe2948c414c810e9b171dbbae6761",
|
||||||
|
"transaction": "self-managed-single-transaction",
|
||||||
|
"selfMarker": true,
|
||||||
|
"opaqueBodies": 1,
|
||||||
|
"bodyReview": [
|
||||||
|
"Extends the same immutable ordinary claim guard to four explicitly versioned settled telemetry settings families."
|
||||||
|
],
|
||||||
|
"effects": [
|
||||||
|
"Replaces only the ordinary claim guard function. No backfill, data purge, or changes to poll or diagnostic storage."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "0050_server_probe_retired_sources.sql",
|
||||||
|
"sha256": "0468ac7e53638b532557273f55d065ff4cde092d8b08073f5860bab35263aead",
|
||||||
|
"transaction": "self-managed-single-transaction",
|
||||||
|
"selfMarker": true,
|
||||||
|
"opaqueBodies": 1,
|
||||||
|
"bodyReview": [
|
||||||
|
"Refuses update, delete, and truncate of a signed retired diagnostic source pending a separately reviewed lifecycle."
|
||||||
|
],
|
||||||
|
"effects": [
|
||||||
|
"Creates empty bounded storage linking exact diagnostic admission and replay tombstone IDs. No backfill, retirement, or GC activation."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "0051_server_probe_retirement_claims.sql",
|
||||||
|
"sha256": "b38876b5da44cf14e20a9df0baaa233ea4de81e471b38dddd370e149c8116243",
|
||||||
|
"transaction": "self-managed-single-transaction",
|
||||||
|
"selfMarker": true,
|
||||||
|
"opaqueBodies": 2,
|
||||||
|
"bodyReview": [
|
||||||
|
"Restricts diagnostic claims to their original settled 180-day source and makes each claim permanent.",
|
||||||
|
"Restricts archival insertion to the exact committed claim and current original source and tombstone identity."
|
||||||
|
],
|
||||||
|
"effects": [
|
||||||
|
"Creates empty diagnostic retirement claims and guards the archive insert. No conversion, backfill, or GC activation."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "0052_server_probe_retired_gc.sql",
|
||||||
|
"sha256": "ea56a1f1bdabfce606bdfec56b6a7b31d45b0187fbea4c900bc656ae320f2fdd",
|
||||||
|
"transaction": "self-managed-single-transaction",
|
||||||
|
"selfMarker": true,
|
||||||
|
"opaqueBodies": 3,
|
||||||
|
"bodyReview": [
|
||||||
|
"Validates the exact retired diagnostic identity and the later of current and persisted retention plus the original safety window.",
|
||||||
|
"Only permits archive removal during an exact guarded admission cascade after journal removal.",
|
||||||
|
"Adds the exact retired GC branch while retaining the live admission eligibility predicate."
|
||||||
|
],
|
||||||
|
"effects": [
|
||||||
|
"Changes only the retired source admission FK to guarded cascade and adds an explicit exact GC path. No collection runs during migration."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "0053_server_probe_terminal_proofs.sql",
|
||||||
|
"sha256": "759ec8c349fcb24c43af410ad6ade714432678b3a021c94bd982080d6a8986be",
|
||||||
|
"transaction": "self-managed-single-transaction",
|
||||||
|
"selfMarker": true,
|
||||||
|
"opaqueBodies": 2,
|
||||||
|
"bodyReview": [
|
||||||
|
"Pins terminal proof to the current retained original on insert and an exact committed retirement claim on delete.",
|
||||||
|
"Extends claims only to the identical signed terminal proof when the original diagnostic admission is gone."
|
||||||
|
],
|
||||||
|
"effects": [
|
||||||
|
"Creates empty bounded private terminal proof storage. Existing payload retention, scheduler, and archive GC remain unchanged."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "0054_mcp_geoip_retirement.sql",
|
||||||
|
"sha256": "680390f98076e3047dbb5a34eab417b253853703932d16c17e4fa3a11060accc",
|
||||||
|
"transaction": "self-managed-single-transaction",
|
||||||
|
"selfMarker": true,
|
||||||
|
"opaqueBodies": 1,
|
||||||
|
"bodyReview": [
|
||||||
|
"Adds only exact settled GeoIP settings version 1 to the existing immutable ordinary retirement claim guard."
|
||||||
|
],
|
||||||
|
"effects": [
|
||||||
|
"Replaces the ordinary claim guard function without changing settings or retiring existing rows during migration."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "0055_mcp_backup_schedule_retirement.sql",
|
||||||
|
"sha256": "07e63c1245ea547176d613933a81ad354e734fa64218dadced67ee8f965c01b6",
|
||||||
|
"transaction": "self-managed-single-transaction",
|
||||||
|
"selfMarker": true,
|
||||||
|
"opaqueBodies": 1,
|
||||||
|
"bodyReview": [
|
||||||
|
"Adds only exact settled backup schedule settings version 1 to the existing immutable ordinary retirement claim guard."
|
||||||
|
],
|
||||||
|
"effects": [
|
||||||
|
"Replaces the ordinary claim guard function without changing backup settings, metadata or entries during migration."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "0056_mcp_manual_inverse_recovery_v8.sql",
|
||||||
|
"sha256": "1dece1d9c5a9cf69e51336f35f868561dad9da6d21556ea31ef5b102634ed9ee",
|
||||||
|
"transaction": "self-managed-single-transaction",
|
||||||
|
"selfMarker": true,
|
||||||
|
"opaqueBodies": 2,
|
||||||
|
"bodyReview": [
|
||||||
|
"The manual inverse charge-source guard requires recovery version 8 and preserves its pending operation, source version, owner and profile checks.",
|
||||||
|
"The manual inverse intent guard requires recovery version 8 and preserves its advisory lock and exact running service intent checks."
|
||||||
|
],
|
||||||
|
"effects": [
|
||||||
|
"Replaces the two live version guard functions and preserves existing charges, intents, and plans."
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -724,7 +890,7 @@ export const migrationManifestDigest = () => hash(JSON.stringify(MIGRATION_MANIF
|
|||||||
// resetting the accumulated public table set at the reviewed destructive0002.
|
// resetting the accumulated public table set at the reviewed destructive0002.
|
||||||
// This is a necessary presence check, not a substitute for exact live column,
|
// This is a necessary presence check, not a substitute for exact live column,
|
||||||
// constraint/index/function/trigger comparison by the control protocol.
|
// constraint/index/function/trigger comparison by the control protocol.
|
||||||
export const CURRENT_REQUIRED_TABLES: readonly string[] = Object.freeze(["acme_settings","alert_engine_confirm_pending","alert_engine_cursor","alert_engine_prev_live","alert_engine_state","alert_groups","alert_history","alert_outbox","alert_rule_conditions","alert_rule_targets","alert_rules","alert_telegram_settings","app_users","backup_entries","backup_schedule_settings","backup_storage_settings","certificate_issue_jobs","certificate_renew_settings","config_revisions","data_migration","events","evobgp_settings","filter_rules","flow_asn_meta","flow_buckets","flow_daily_dims","flow_daily_facts","flow_hour_facts","flow_ip_meta","flow_minute_dims","flow_minute_stats","flow_settings_intents","flow_write_receipts","geoip_settings","internet_path_settings","internet_path_snapshots","mcp_artifacts","mcp_audit","mcp_budget_buckets","mcp_connections","mcp_credentials","mcp_diagnostic_cancellations","mcp_drift","mcp_grants","mcp_management_channels","mcp_oauth_clients","mcp_oauth_requests","mcp_operation_checkpoints","mcp_operation_steps","mcp_operations","mcp_owner_revocations","mcp_path_checks","mcp_plans","mcp_portal_events","mcp_protections","mcp_read_leases","mcp_recovery_intents","mcp_requests","mcp_routing_forward_charges","mcp_routing_manual_inverse_charges","mcp_routing_observations","mcp_routing_profiles","mcp_routing_reservation_slots","mcp_routing_reservations","mcp_scoped_recovery_intents","mcp_secrets","mcp_settings","mcp_unauthorized_buckets","recursive_routes","router_log_collection_state","router_log_entries","router_log_settings","scheduler_runs","schema_migrations","server_poll_acks","server_poll_archives","server_poll_executions","server_poll_intents","server_poll_quarantines","server_poll_receipts","server_poll_storage_admissions","server_poll_storage_usage","server_probe_execution_payloads","server_probe_execution_quarantines","server_probe_execution_stages","server_probe_executions","server_probe_storage_admissions","server_probe_storage_usage","server_snapshots","servers","servers_api_ping_settings","servers_rest_ping_samples","traffic_collection_state","traffic_flow_settings","traffic_samples","traffic_settings","uptime_probe_samples","uptime_probes","uptime_resource_samples","uptime_settings","uptime_speed_probes","uptime_speed_test_runs","user_interface_bindings","vrf_production_tickets"])
|
export const CURRENT_REQUIRED_TABLES: readonly string[] = Object.freeze(["acme_settings","alert_engine_confirm_pending","alert_engine_cursor","alert_engine_prev_live","alert_engine_state","alert_groups","alert_history","alert_outbox","alert_rule_conditions","alert_rule_targets","alert_rules","alert_telegram_settings","app_users","backup_entries","backup_schedule_settings","backup_storage_settings","certificate_issue_jobs","certificate_renew_settings","config_revisions","data_migration","events","evobgp_settings","filter_rules","flow_asn_meta","flow_buckets","flow_daily_dims","flow_daily_facts","flow_hour_facts","flow_ip_meta","flow_minute_dims","flow_minute_stats","flow_settings_intents","flow_write_receipts","geoip_settings","internet_path_settings","internet_path_snapshots","mcp_artifacts","mcp_audit","mcp_budget_buckets","mcp_connections","mcp_credentials","mcp_diagnostic_cancellations","mcp_drift","mcp_grants","mcp_management_channels","mcp_oauth_clients","mcp_oauth_requests","mcp_operation_checkpoints","mcp_operation_replay_tombstones","mcp_operation_steps","mcp_operations","mcp_ordinary_retirement_claims","mcp_owner_revocations","mcp_path_checks","mcp_plans","mcp_portal_events","mcp_protections","mcp_read_leases","mcp_recovery_intents","mcp_requests","mcp_routing_forward_charges","mcp_routing_manual_inverse_charges","mcp_routing_observations","mcp_routing_profiles","mcp_routing_reservation_slots","mcp_routing_reservations","mcp_scoped_recovery_intents","mcp_secrets","mcp_settings","mcp_unauthorized_buckets","recursive_routes","router_log_collection_state","router_log_entries","router_log_settings","scheduler_runs","schema_migrations","server_poll_acks","server_poll_archives","server_poll_executions","server_poll_intents","server_poll_quarantines","server_poll_receipts","server_poll_retirement_claims","server_poll_storage_admissions","server_poll_storage_usage","server_probe_execution_payloads","server_probe_execution_quarantines","server_probe_execution_stages","server_probe_executions","server_probe_retired_sources","server_probe_retirement_claims","server_probe_storage_admissions","server_probe_storage_usage","server_probe_terminal_proofs","server_snapshots","servers","servers_api_ping_settings","servers_rest_ping_samples","traffic_collection_state","traffic_flow_settings","traffic_samples","traffic_settings","uptime_probe_samples","uptime_probes","uptime_resource_samples","uptime_settings","uptime_speed_probes","uptime_speed_test_runs","user_interface_bindings","vrf_production_tickets"])
|
||||||
|
|
||||||
export interface LegacyBaselineEvidence {
|
export interface LegacyBaselineEvidence {
|
||||||
inventoryComplete: boolean; databaseGeneration: string; unresolvedAttemptIds: readonly string[]
|
inventoryComplete: boolean; databaseGeneration: string; unresolvedAttemptIds: readonly string[]
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { serializerCompiler, validatorCompiler } from "@fastify/type-provider-zo
|
|||||||
import { monitorEventLoopDelay } from "node:perf_hooks"
|
import { monitorEventLoopDelay } from "node:perf_hooks"
|
||||||
import { env } from "./config.js"
|
import { env } from "./config.js"
|
||||||
import { initDatabase } from "./db/bootstrap.js"
|
import { initDatabase } from "./db/bootstrap.js"
|
||||||
|
import { mcpStateDirectory } from "./modules/mcp/security.js"
|
||||||
|
import { assertDatabaseMaintenanceStartup } from "./services/database-maintenance-startup.js"
|
||||||
import { closePool } from "./db/index.js"
|
import { closePool } from "./db/index.js"
|
||||||
import authPlugin, { requireApiAuth } from "./plugins/auth.js"
|
import authPlugin, { requireApiAuth } from "./plugins/auth.js"
|
||||||
import { installDatabaseRequestGate } from "./plugins/database-request-gate.js"
|
import { installDatabaseRequestGate } from "./plugins/database-request-gate.js"
|
||||||
@@ -186,6 +188,8 @@ const isMain =
|
|||||||
|
|
||||||
if (isMain) {
|
if (isMain) {
|
||||||
try {
|
try {
|
||||||
|
// Resolve external maintenance before init/migrations or consumer startup.
|
||||||
|
await assertDatabaseMaintenanceStartup(mcpStateDirectory(), env.DATABASE_URL)
|
||||||
await initDatabase()
|
await initDatabase()
|
||||||
const app = await buildApp()
|
const app = await buildApp()
|
||||||
let shuttingDown = false
|
let shuttingDown = false
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import type { ConnectionRow, McpPrincipal } from "./store.js"
|
|||||||
import type { RosSnapshot, RosStep } from "./resources.js"
|
import type { RosSnapshot, RosStep } from "./resources.js"
|
||||||
|
|
||||||
export interface ActionContext {
|
export interface ActionContext {
|
||||||
connection: ConnectionRow; principal: McpPrincipal; signal?: AbortSignal
|
connection: ConnectionRow; principal: McpPrincipal; signal?: AbortSignal; logsFollowMaintenance?: AbortSignal
|
||||||
/** Process-internal persistent controller identity; never parsed from tool JSON. */
|
/** Process-internal persistent controller identity; never parsed from tool JSON. */
|
||||||
persistentRoutingProfile?: { id: string; version: number }
|
persistentRoutingProfile?: { id: string; version: number }
|
||||||
/** Available only during target resolution, never retained by the read/prepare handler. */
|
/** Available only during target resolution, never retained by the read/prepare handler. */
|
||||||
|
|||||||
@@ -9,14 +9,17 @@ import { registerIpsecExistingCertificateExportActions } from "./ipsec-existing-
|
|||||||
import { registerIpsecUserUpdateActions } from "./ipsec-user-update-actions.js"
|
import { registerIpsecUserUpdateActions } from "./ipsec-user-update-actions.js"
|
||||||
import { prepareIpsecNativeWrite } from "./ipsec-native-write-actions.js"
|
import { prepareIpsecNativeWrite } from "./ipsec-native-write-actions.js"
|
||||||
import { isIP } from "node:net"
|
import { isIP } from "node:net"
|
||||||
|
import { setTimeout as sleep } from "node:timers/promises"
|
||||||
|
import { logsFollowCancellation } from "./logs-follow-cancellation.js"
|
||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
import { logFiltersSchema, logSettingsPatchSchema } from "@mmapp/contracts"
|
import { logFiltersSchema, logSettingsPatchSchema } from "@mmapp/contracts"
|
||||||
import { appUserCreateSchema, appUserUpdateSchema, appUserRoleSchema, sectionPermSchema, serverPermSchema, userBindingCreateSchema } from "@mmapp/contracts/users"
|
import { appUserCreateSchema, appUserUpdateSchema, appUserRoleSchema, sectionPermSchema, serverPermSchema, userBindingCreateSchema } from "@mmapp/contracts/users"
|
||||||
import type { McpOperationDescriptor } from "@mmapp/contracts/mcp"
|
import type { McpOperationDescriptor } from "@mmapp/contracts/mcp"
|
||||||
import { hasPermission } from "../../lib/permissions.js"
|
import { hasPermission } from "../../lib/permissions.js"
|
||||||
|
import { assertOperationReplayScope, findOperationReplayTombstone, operationHistoryExpired } from "./operation-replay-tombstones.js"
|
||||||
import { pool } from "../../db/index.js"
|
import { pool } from "../../db/index.js"
|
||||||
import { getServerReadById } from "../servers/service/servers-service.js"
|
import { getServerReadById } from "../servers/service/servers-service.js"
|
||||||
import { listUsers, getUserById, listInterfaceCatalog, getInterfaceCatalogState } from "../users/service/users-service.js"
|
import { listUsers, getUserById, listInterfaceCatalog, getInterfaceCatalogState, validateUserBindingCreate, UsersServiceError } from "../users/service/users-service.js"
|
||||||
import { getLogSettings } from "../../services/router-logs-collector.js"
|
import { getLogSettings } from "../../services/router-logs-collector.js"
|
||||||
import { readLogPage, readLogSummary, readLogContext, readLogEntry } from "../../services/router-logs-query.js"
|
import { readLogPage, readLogSummary, readLogContext, readLogEntry } from "../../services/router-logs-query.js"
|
||||||
import { logWhere } from "../../services/router-logs-model.js"
|
import { logWhere } from "../../services/router-logs-model.js"
|
||||||
@@ -103,6 +106,7 @@ import { registerRecursiveRecovery } from "./recursive-recovery.js"
|
|||||||
import { registerNetworkHostActions } from "./network-host-actions.js"
|
import { registerNetworkHostActions } from "./network-host-actions.js"
|
||||||
import { registerBgpFilterBindingActions } from "./bgp-filter-binding-actions.js"
|
import { registerBgpFilterBindingActions } from "./bgp-filter-binding-actions.js"
|
||||||
import { registerBgpCommunityPolicyActions } from "./bgp-community-policy-actions.js"
|
import { registerBgpCommunityPolicyActions } from "./bgp-community-policy-actions.js"
|
||||||
|
import { registerBridgeVlanActions, prepareGenericBridgeVlanChange } from "./bridge-vlan-actions.js"
|
||||||
|
|
||||||
const serverId = z.number().int().positive().max(Number.MAX_SAFE_INTEGER)
|
const serverId = z.number().int().positive().max(Number.MAX_SAFE_INTEGER)
|
||||||
const rowId = z.string().trim().min(1).max(200)
|
const rowId = z.string().trim().min(1).max(200)
|
||||||
@@ -126,6 +130,7 @@ registerRecursiveRecovery(add)
|
|||||||
registerNetworkHostActions(add)
|
registerNetworkHostActions(add)
|
||||||
registerBgpFilterBindingActions(add)
|
registerBgpFilterBindingActions(add)
|
||||||
registerBgpCommunityPolicyActions(add)
|
registerBgpCommunityPolicyActions(add)
|
||||||
|
registerBridgeVlanActions(add)
|
||||||
registerTrafficHistoryActions(add)
|
registerTrafficHistoryActions(add)
|
||||||
registerSidebarCountsActions(add)
|
registerSidebarCountsActions(add)
|
||||||
registerLogArtifactActions(add)
|
registerLogArtifactActions(add)
|
||||||
@@ -153,7 +158,7 @@ for (const resource of rosResources) {
|
|||||||
const delegated = resource.id === "wireguard" && action !== "create" || resource.id === "wireguard_peer" || resource.id === "gre" && action !== "create" || resource.id === "ipsec_peer" && action !== "create" || resource.id === "certificate" && action === "delete" || resource.id === "container" && action === "delete"
|
const delegated = resource.id === "wireguard" && action !== "create" || resource.id === "wireguard_peer" || resource.id === "gre" && action !== "create" || resource.id === "ipsec_peer" && action !== "create" || resource.id === "certificate" && action === "delete" || resource.id === "container" && action === "delete"
|
||||||
const previousVersion = action === "create" ? (["ospf_template", "wireguard_peer", "wireguard"].includes(resource.id) ? 3 : 2) : resource.id === "ipsec_peer" || resource.id === "certificate" && action === "delete" ? 2 : resource.id === "wireguard" && action === "update" ? 3 : resource.id === "ospf_template" && action !== "delete" || resource.id === "wireguard_peer" || resource.id === "wireguard" ? 2 : 1
|
const previousVersion = action === "create" ? (["ospf_template", "wireguard_peer", "wireguard"].includes(resource.id) ? 3 : 2) : resource.id === "ipsec_peer" || resource.id === "certificate" && action === "delete" ? 2 : resource.id === "wireguard" && action === "update" ? 3 : resource.id === "ospf_template" && action !== "delete" || resource.id === "wireguard_peer" || resource.id === "wireguard" ? 2 : 1
|
||||||
add(`ros.${resource.id}.${action}`, `mm_${resource.id}_${action}_plan`, `${resource.title}: ${action}`, resource.permission.split(":")[1], resource.id === "certificate" && action === "update" ? ["mm:certificates:write", "mm:network:write"] : resource.permission + ":write", "write", "servers", schema, {
|
add(`ros.${resource.id}.${action}`, `mm_${resource.id}_${action}_plan`, `${resource.title}: ${action}`, resource.permission.split(":")[1], resource.id === "certificate" && action === "update" ? ["mm:certificates:write", "mm:network:write"] : resource.permission + ":write", "write", "servers", schema, {
|
||||||
version: resource.id === "ipsec_peer" ? (action === "create" ? 5 : 3) : resource.id === "certificate" ? (action === "update" ? 4 : 3) : previousVersion + (delegated ? 0 : 1) + (resource.id === "ospf_template" ? 1 : 0) + (resource.id === "vrf" ? 1 : 0),
|
version: resource.id === "ipsec_peer" ? (action === "create" ? 5 : 3) : resource.id === "certificate" ? (action === "update" ? 4 : 3) : previousVersion + (delegated ? 0 : 1) + (resource.id === "ospf_template" ? 1 : 0) + (resource.id === "vrf" ? (action === "delete" ? 3 : 2) : 0) + (["bridge", "bridge_port", "bridge_vlan"].includes(resource.id) ? 1 : 0),
|
||||||
prepare: async (input, context) => {
|
prepare: async (input, context) => {
|
||||||
if (resource.id === "vrf" && input.productionAdmission !== true) throw new McpError("UNSUPPORTED_CAPABILITY", "VRF требует явного productionAdmission:true для проверенного native executor; raw план недоступен", 409)
|
if (resource.id === "vrf" && input.productionAdmission !== true) throw new McpError("UNSUPPORTED_CAPABILITY", "VRF требует явного productionAdmission:true для проверенного native executor; raw план недоступен", 409)
|
||||||
const sid = Number(input.serverId), id = input.id as string | undefined
|
const sid = Number(input.serverId), id = input.id as string | undefined
|
||||||
@@ -249,13 +254,24 @@ for (const resource of rosResources) {
|
|||||||
if (dependent) throw new McpError("STATE_CONFLICT", "Объект используется: сначала подготовьте явное изменение зависимостей", 409)
|
if (dependent) throw new McpError("STATE_CONFLICT", "Объект используется: сначала подготовьте явное изменение зависимостей", 409)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const linkedVlan = await prepareGenericBridgeVlanChange({ serverId: sid, resource: resource.id, action, id, fields }, context)
|
||||||
|
if (linkedVlan) {
|
||||||
|
const captured = new Map(snapshots.map(snapshot => [snapshot.resource, snapshot]))
|
||||||
|
for (const snapshot of linkedVlan.snapshots) {
|
||||||
|
const previous = captured.get(snapshot.resource)
|
||||||
|
if (previous && (previous.fingerprint !== snapshot.fingerprint || previous.sourceProof !== snapshot.sourceProof)) throw new McpError("STATE_CONFLICT", "Зависимости VLAN изменились во время подготовки", 409)
|
||||||
|
captured.set(snapshot.resource, snapshot)
|
||||||
|
}
|
||||||
|
const objects = new Map([...references, ...linkedVlan.objects].map(object => [`${object.serverId}/${object.kind}/${object.id}`, object]))
|
||||||
|
return { ...linkedVlan, snapshots: [...captured.values()], objects: [...objects.values()] }
|
||||||
|
}
|
||||||
const placeBeforeId = resource.id === "vrf" && action === "create" ? before.rows[0]?.[".id"] : undefined
|
const placeBeforeId = resource.id === "vrf" && action === "create" ? before.rows[0]?.[".id"] : undefined
|
||||||
if (resource.id === "vrf" && action === "create" && typeof placeBeforeId !== "string") throw new McpError("STATE_CONFLICT", "Не подтверждён порядок существующих VRF", 409)
|
if (resource.id === "vrf" && action === "create" && typeof placeBeforeId !== "string") throw new McpError("STATE_CONFLICT", "Не подтверждён порядок существующих VRF", 409)
|
||||||
return { target: { scope: "servers", serverIds: [sid] }, objects: references, snapshots,
|
return { target: { scope: "servers", serverIds: [sid] }, objects: references, snapshots,
|
||||||
...(resource.id === "vrf" && input.productionAdmission === true ? { vrfProductionAdmission: true as const } : {}),
|
...(resource.id === "vrf" && input.productionAdmission === true ? { vrfProductionAdmission: true as const } : {}),
|
||||||
steps: splitOspfPassiveUpdate({ serverId: sid, resource: resource.id, action, id, fields, ...(typeof placeBeforeId === "string" ? { placeBeforeId } : {}) }).map(step => ({ kind: "ros" as const, step })),
|
steps: splitOspfPassiveUpdate({ serverId: sid, resource: resource.id, action, id, fields, ...(typeof placeBeforeId === "string" ? { placeBeforeId } : {}) }).map(step => ({ kind: "ros" as const, step })),
|
||||||
requiresHuman: true,
|
requiresHuman: true,
|
||||||
warnings: ["Нативное изменение может затронуть управление устройством и требует подтверждения человеком.", "Автоматическое каскадное изменение зависимостей не выполняется.", ...(placeBeforeId ? [`VRF создаётся перед записью ${placeBeforeId}; порядок определяет принадлежность интерфейсов.`] : [])],
|
warnings: ["Нативное изменение может затронуть управление устройством и требует подтверждения человеком.", "Автоматическое каскадное изменение зависимостей не выполняется.", ...(resource.id === "vrf" && action === "delete" ? ["При удалении VRF RouterOS может удалить автоматический IPv6 link-local и его connected-маршрут пустого bridge; статические адреса сохраняются."] : []), ...(placeBeforeId ? [`VRF создаётся перед записью ${placeBeforeId}; порядок определяет принадлежность интерфейсов.`] : [])],
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -267,7 +283,7 @@ for (const definition of [
|
|||||||
{ id: "firewall.update", tool: "mm_firewall_rule_update_plan", title: "План изменения правила firewall", resources: ["ip_filter", "ip_nat", "ip_mangle", "ip_raw", "ipv6_filter", "ipv6_nat", "ipv6_mangle", "ipv6_raw"] },
|
{ id: "firewall.update", tool: "mm_firewall_rule_update_plan", title: "План изменения правила firewall", resources: ["ip_filter", "ip_nat", "ip_mangle", "ip_raw", "ipv6_filter", "ipv6_nat", "ipv6_mangle", "ipv6_raw"] },
|
||||||
] as const) {
|
] as const) {
|
||||||
add(definition.id, definition.tool, definition.title, "network", "mm:network:write", "write", "servers", z.object({ serverId, resource: z.enum(definition.resources), id: rowId, fields: z.record(z.string().max(100), z.union([z.string().max(1000), z.number().finite(), z.boolean()])) }).strict(), {
|
add(definition.id, definition.tool, definition.title, "network", "mm:network:write", "write", "servers", z.object({ serverId, resource: z.enum(definition.resources), id: rowId, fields: z.record(z.string().max(100), z.union([z.string().max(1000), z.number().finite(), z.boolean()])) }).strict(), {
|
||||||
version: definition.id === "interfaces.update" ? 4 : 2,
|
version: definition.id === "interfaces.update" ? 5 : 2,
|
||||||
prepare: async (input, context) => {
|
prepare: async (input, context) => {
|
||||||
const resource = resourceById(String(input.resource)), fields = resource.fields.safeParse(input.fields)
|
const resource = resourceById(String(input.resource)), fields = resource.fields.safeParse(input.fields)
|
||||||
if (!fields.success) throw new McpError("INVALID_INPUT", "Поля не соответствуют типизированной схеме выбранного объекта", 400)
|
if (!fields.success) throw new McpError("INVALID_INPUT", "Поля не соответствуют типизированной схеме выбранного объекта", 400)
|
||||||
@@ -321,6 +337,21 @@ for (const action of ["create", "update", "delete", "binding_add", "binding_remo
|
|||||||
const ids = [...new Set([...(user?.servers ?? []).map(server => Number(server.serverId)), ...(binding?.serverId ? [binding.serverId] : []),
|
const ids = [...new Set([...(user?.servers ?? []).map(server => Number(server.serverId)), ...(binding?.serverId ? [binding.serverId] : []),
|
||||||
...(before?.bindings ?? []).map(binding => binding.serverId), ...(before?.servers ?? []).map(server => Number(server.serverId))])]
|
...(before?.bindings ?? []).map(binding => binding.serverId), ...(before?.servers ?? []).map(server => Number(server.serverId))])]
|
||||||
if (ids.some(id => !context.connection.policy.scope.serverIds.includes(id))) throw new McpError("SERVER_DENIED", "Сервер не разрешён")
|
if (ids.some(id => !context.connection.policy.scope.serverIds.includes(id))) throw new McpError("SERVER_DENIED", "Сервер не разрешён")
|
||||||
|
if (action === "binding_remove" && !before?.bindings.some(binding => binding.id === input.bindingId)) {
|
||||||
|
throw new McpError("NOT_FOUND", "Привязка не найдена", 404)
|
||||||
|
}
|
||||||
|
if (action === "binding_add") {
|
||||||
|
try {
|
||||||
|
await validateUserBindingCreate(String(input.id), userBindingCreateSchema.parse(input.binding), { signal: context.signal })
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof UsersServiceError) {
|
||||||
|
if (error.status === 409) throw new McpError("STATE_CONFLICT", error.message, 409)
|
||||||
|
if (error.status === 400) throw new McpError("INVALID_INPUT", error.message, 400)
|
||||||
|
if (error.status === 404) throw new McpError("NOT_FOUND", error.message, 404)
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
return { target: { scope: "global" }, objects: [{ kind: "user", id: String(input.id ?? "new") }, ...ids.map(id => ({ kind: "server", id: String(id), serverId: id }))], snapshots: [], before,
|
return { target: { scope: "global" }, objects: [{ kind: "user", id: String(input.id ?? "new") }, ...ids.map(id => ({ kind: "server", id: String(id), serverId: id }))], snapshots: [], before,
|
||||||
steps: [{ kind: "service", service: `users.${action}`, input }], warnings: ["Изменение /users не меняет JWT-права портала."] }
|
steps: [{ kind: "service", service: `users.${action}`, input }], warnings: ["Изменение /users не меняет JWT-права портала."] }
|
||||||
},
|
},
|
||||||
@@ -349,16 +380,17 @@ function logFilters(input: Record<string, unknown>, context: ActionContext) {
|
|||||||
}
|
}
|
||||||
add("logs.query", "mm_logs_query", "История логов", "logs", "mm:servers:read", "read", "servers", logInput, {
|
add("logs.query", "mm_logs_query", "История логов", "logs", "mm:servers:read", "read", "servers", logInput, {
|
||||||
read: async (input, context) => {
|
read: async (input, context) => {
|
||||||
const filters = logFilters(input, context), generation = await accessGeneration()
|
const generation = await accessGeneration()
|
||||||
|
let filters: ReturnType<typeof logFilters>
|
||||||
let cursor: string | undefined, watermark: string | undefined
|
let cursor: string | undefined, watermark: string | undefined
|
||||||
if (input.cursor) {
|
if (input.cursor) {
|
||||||
try {
|
try {
|
||||||
const state = JSON.parse(await decryptSecret(String(input.cursor))) as { generation: string; scope: string; cursor: string; watermark: string; from: string }
|
const state = JSON.parse(await decryptSecret(String(input.cursor))) as { generation: string; scope: string; cursor: string; watermark: string; from: string }
|
||||||
if (input.from === undefined) filters.from = state.from
|
filters = logFilters({ ...input, from: input.from ?? state.from }, context)
|
||||||
if (state.generation !== generation || state.scope !== fingerprint({ filters, connection: context.connection.id })) throw new Error("cursor")
|
if (state.generation !== generation || state.scope !== fingerprint({ filters, connection: context.connection.id })) throw new Error("cursor")
|
||||||
cursor = state.cursor; watermark = state.watermark
|
cursor = state.cursor; watermark = state.watermark
|
||||||
} catch { throw new McpError("RESET_REQUIRED", "Курсор устарел или относится к другим фильтрам", 409) }
|
} catch { throw new McpError("RESET_REQUIRED", "Курсор устарел или относится к другим фильтрам", 409) }
|
||||||
}
|
} else filters = logFilters(input, context)
|
||||||
const result = await readLogPage(filters, { cursor, watermark, limit: Number(input.limit) })
|
const result = await readLogPage(filters, { cursor, watermark, limit: Number(input.limit) })
|
||||||
return { ...result, nextCursor: result.nextCursor ? await encryptSecret(JSON.stringify({ generation, scope: fingerprint({ filters, connection: context.connection.id }), cursor: result.nextCursor, watermark: result.watermark, from: filters.from })) : null }
|
return { ...result, nextCursor: result.nextCursor ? await encryptSecret(JSON.stringify({ generation, scope: fingerprint({ filters, connection: context.connection.id }), cursor: result.nextCursor, watermark: result.watermark, from: filters.from })) : null }
|
||||||
},
|
},
|
||||||
@@ -373,30 +405,41 @@ for (const context of [false, true]) add(context ? "logs.context" : "logs.get",
|
|||||||
return context ? readLogContext(String(input.id)) : entry
|
return context ? readLogContext(String(input.id)) : entry
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
add("logs.follow", "mm_logs_follow", "Новые события", "logs", "mm:servers:read", "read", "servers", logInput.safeExtend({ serverId, cursor: z.string().max(8192).optional(), waitSeconds: z.number().int().min(0).max(30).default(30) }), {
|
add("logs.follow", "mm_logs_follow", "Новые события", "logs", "mm:servers:read", "read", "servers", logInput.safeExtend({ serverId, limit: logInput.shape.limit.default(200), cursor: z.string().max(8192).optional(), waitSeconds: z.number().int().min(0).max(30).default(30) }), {
|
||||||
read: async (input, context) => {
|
read: async (input, context) => {
|
||||||
|
const maintenance = context.logsFollowMaintenance
|
||||||
|
const signal = context.signal
|
||||||
|
const waitSignal = maintenance && signal ? AbortSignal.any([maintenance, signal]) : maintenance ?? signal
|
||||||
|
const assertActive = () => { const failure = logsFollowCancellation(maintenance, signal); if (failure) throw failure }
|
||||||
|
assertActive()
|
||||||
const { server } = await routerClient(Number(input.serverId)); void server
|
const { server } = await routerClient(Number(input.serverId)); void server
|
||||||
|
assertActive()
|
||||||
const release = subscribeLogDevice(Number(input.serverId)), until = Date.now() + Number(input.waitSeconds) * 1000
|
const release = subscribeLogDevice(Number(input.serverId)), until = Date.now() + Number(input.waitSeconds) * 1000
|
||||||
try {
|
try {
|
||||||
const filters = logFilters(input, context), generation = await accessGeneration()
|
const generation = await accessGeneration()
|
||||||
|
let filters: ReturnType<typeof logFilters>
|
||||||
let cursor = "0"
|
let cursor = "0"
|
||||||
if (input.cursor) {
|
if (input.cursor) {
|
||||||
try {
|
try {
|
||||||
const saved = JSON.parse(await decryptSecret(String(input.cursor)))
|
const saved = JSON.parse(await decryptSecret(String(input.cursor)))
|
||||||
filters.from = saved.from
|
filters = logFilters({ ...input, from: input.from ?? saved.from }, context)
|
||||||
if (saved.kind !== "follow" || saved.generation !== generation || saved.scope !== fingerprint({ filters, connection: context.connection.id }) || !/^\d{1,20}$/.test(saved.cursor)) throw new Error("cursor")
|
if (saved.kind !== "follow" || saved.generation !== generation || saved.scope !== fingerprint({ filters, connection: context.connection.id }) || !/^\d{1,20}$/.test(saved.cursor)) throw new Error("cursor")
|
||||||
cursor = saved.cursor
|
cursor = saved.cursor
|
||||||
} catch { throw new McpError("RESET_REQUIRED", "Курсор follow устарел или относится к другим фильтрам", 409) }
|
} catch { throw new McpError("RESET_REQUIRED", "Курсор follow устарел или относится к другим фильтрам", 409) }
|
||||||
} else {
|
} else {
|
||||||
|
filters = logFilters(input, context)
|
||||||
cursor = (await pool.query("SELECT COALESCE(max(id),0)::text AS cursor FROM router_log_entries WHERE server_id=$1", [input.serverId])).rows[0].cursor
|
cursor = (await pool.query("SELECT COALESCE(max(id),0)::text AS cursor FROM router_log_entries WHERE server_id=$1", [input.serverId])).rows[0].cursor
|
||||||
}
|
}
|
||||||
while (true) {
|
while (true) {
|
||||||
|
assertActive()
|
||||||
const current = await validatePrincipal(context.principal)
|
const current = await validatePrincipal(context.principal)
|
||||||
|
assertActive()
|
||||||
if (current.policy.version !== context.connection.policy.version) throw new McpError("RESET_REQUIRED", "Права или область доступа изменились; начните новое чтение", 409)
|
if (current.policy.version !== context.connection.policy.version) throw new McpError("RESET_REQUIRED", "Права или область доступа изменились; начните новое чтение", 409)
|
||||||
const batch = await readLogStream(filters, cursor)
|
const batch = await readLogStream(filters, cursor, Number(input.limit))
|
||||||
|
assertActive()
|
||||||
cursor = batch.cursor
|
cursor = batch.cursor
|
||||||
if (batch.entries.length || !batch.enabled || Date.now() >= until || context.signal?.aborted) return { ...batch, cursor: await encryptSecret(JSON.stringify({ kind: "follow", generation, scope: fingerprint({ filters, connection: context.connection.id }), cursor, from: filters.from })) }
|
if (batch.entries.length || !batch.enabled || Date.now() >= until) return { ...batch, cursor: await encryptSecret(JSON.stringify({ kind: "follow", generation, scope: fingerprint({ filters, connection: context.connection.id }), cursor, from: filters.from })) }
|
||||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
await sleep(1000, undefined, { signal: waitSignal })
|
||||||
}
|
}
|
||||||
} finally { release() }
|
} finally { release() }
|
||||||
},
|
},
|
||||||
@@ -473,7 +516,12 @@ add("changes.cancel", "mm_change_cancel", "Отменить свой незап
|
|||||||
add("operations.read", "mm_operation_get", "Результат своей операции", "mcp", null, "read", "connection", z.object({ operationId: z.string().uuid() }).strict(), {
|
add("operations.read", "mm_operation_get", "Результат своей операции", "mcp", null, "read", "connection", z.object({ operationId: z.string().uuid() }).strict(), {
|
||||||
read: async (input, context) => {
|
read: async (input, context) => {
|
||||||
const { rows: [operation] } = await pool.query("SELECT * FROM mcp_operations WHERE id=$1 AND connection_id=$2", [input.operationId, context.connection.id])
|
const { rows: [operation] } = await pool.query("SELECT * FROM mcp_operations WHERE id=$1 AND connection_id=$2", [input.operationId, context.connection.id])
|
||||||
if (!operation) throw new McpError("NOT_FOUND", "Операция не найдена", 404)
|
if (!operation) {
|
||||||
|
const tombstone = await findOperationReplayTombstone(pool, context.connection.id, String(input.operationId))
|
||||||
|
if (!tombstone) throw new McpError("NOT_FOUND", "Операция не найдена", 404)
|
||||||
|
assertOperationReplayScope(tombstone, context.connection)
|
||||||
|
operationHistoryExpired()
|
||||||
|
}
|
||||||
await assertPlanReadScope(await (await import("./runtime.js")).loadPlan(operation.plan_id), context)
|
await assertPlanReadScope(await (await import("./runtime.js")).loadPlan(operation.plan_id), context)
|
||||||
const { rows: steps } = await pool.query("SELECT * FROM mcp_operation_steps WHERE operation_id=$1 ORDER BY step_index", [input.operationId])
|
const { rows: steps } = await pool.query("SELECT * FROM mcp_operation_steps WHERE operation_id=$1 ORDER BY step_index", [input.operationId])
|
||||||
return { ...operation, steps }
|
return { ...operation, steps }
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ try{
|
|||||||
await pool.query("INSERT INTO backup_storage_settings(id) VALUES(1) ON CONFLICT(id) DO NOTHING")
|
await pool.query("INSERT INTO backup_storage_settings(id) VALUES(1) ON CONFLICT(id) DO NOTHING")
|
||||||
await updateMcpSettings({enabled:true},actor)
|
await updateMcpSettings({enabled:true},actor)
|
||||||
app=await buildApp({startScheduler:false});await app.ready()
|
app=await buildApp({startScheduler:false});await app.ready()
|
||||||
|
await (await import("../../test/mcp-users-bindings.js")).verifyMcpUserBindings(actor)
|
||||||
await (await import("./routing-community-context-acceptance.js")).acceptRouteCommunityContextMcp(actor)
|
await (await import("./routing-community-context-acceptance.js")).acceptRouteCommunityContextMcp(actor)
|
||||||
await (await import("../../test/mcp-acme-test.js")).verifyMcpAcmeTest(actor)
|
await (await import("../../test/mcp-acme-test.js")).verifyMcpAcmeTest(actor)
|
||||||
await (await import("../../test/mcp-acme-settings.js")).verifyMcpAcmeSettings(actor)
|
await (await import("../../test/mcp-acme-settings.js")).verifyMcpAcmeSettings(actor)
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import { z } from "zod"
|
||||||
|
import { isIP } from "node:net"
|
||||||
|
import { bridgeVlanConfigurationRequestSchema } from "@mmapp/contracts/bridge-vlan"
|
||||||
|
import { buildBridgeVlanConfiguration, bridgeVlanCurrentDesired, BridgeVlanConfigurationError, type BridgeVlanState } from "../../services/bridge-vlan-configuration.js"
|
||||||
|
import { readRosSnapshot, routerClient, rosSnapshotSourceProof, type RosSnapshot, type RosStep } from "./resources.js"
|
||||||
|
import { verifyBridgeManagementChannel } from "./management.js"
|
||||||
|
import { McpError } from "./security.js"
|
||||||
|
import { canonicalBridgeVlanValue } from "../../services/bridge-vlan-values.js"
|
||||||
|
import type { ActionContext, ChangeEvidence } from "./action-types.js"
|
||||||
|
import type { add } from "./actions.js"
|
||||||
|
|
||||||
|
const interfaceResources = ["ethernet", "bridge", "vlan", "gre", "wireguard", "vxlan"]
|
||||||
|
const resources = [...interfaceResources, "bridge_port", "bridge_vlan", "ipv4_address", "ipv6_address"]
|
||||||
|
export async function readBridgeVlanState(serverId: number, context: Pick<ActionContext, "signal">): Promise<{ state: BridgeVlanState; snapshots: RosSnapshot[] }> {
|
||||||
|
const snapshots: RosSnapshot[] = []
|
||||||
|
const active = async () => { if (context.signal?.aborted) throw new McpError("READ_CANCELLED", "Чтение конфигурации VLAN отменено", 409) }
|
||||||
|
// Bind every catalogue to the same configured device source. The executor
|
||||||
|
// rechecks these complete configuration snapshots before each native write.
|
||||||
|
for (const resource of resources) snapshots.push(await readRosSnapshot(serverId, resource, active, snapshots[0]?.sourceProof))
|
||||||
|
const rows = (resource: string) => snapshots.find(snapshot => snapshot.resource === resource)!.rows
|
||||||
|
const state: BridgeVlanState = {
|
||||||
|
bridges: rows("bridge"), ports: rows("bridge_port"), vlans: rows("bridge_vlan"),
|
||||||
|
interfaces: snapshots.filter(snapshot => interfaceResources.includes(snapshot.resource)).flatMap(snapshot => snapshot.rows.map(row => ({
|
||||||
|
resource: snapshot.resource, id: String(row[".id"]), name: String(row.name), disabled: row.disabled,
|
||||||
|
}))),
|
||||||
|
vlanInterfaces: rows("vlan"), ipv4Addresses: rows("ipv4_address"), ipv6Addresses: rows("ipv6_address"),
|
||||||
|
}
|
||||||
|
return { state, snapshots }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function prepareBridgeVlanConfiguration(raw: unknown, context: ActionContext): Promise<ChangeEvidence> {
|
||||||
|
const input = bridgeVlanConfigurationRequestSchema.parse(raw)
|
||||||
|
let management: Awaited<ReturnType<typeof verifyBridgeManagementChannel>> | undefined
|
||||||
|
try { management = await verifyBridgeManagementChannel(input.serverId) }
|
||||||
|
catch (error) {
|
||||||
|
// Only lack of a current alternate channel permits primary-path admission.
|
||||||
|
// Identity, transport and other verification failures stay fail-closed.
|
||||||
|
if (!(error instanceof McpError) || error.code !== "APPROVAL_REQUIRED") throw error
|
||||||
|
}
|
||||||
|
const { state, snapshots } = await readBridgeVlanState(input.serverId, context)
|
||||||
|
const primaryManagement = management === undefined
|
||||||
|
if (!management) {
|
||||||
|
const { server } = await routerClient(input.serverId)
|
||||||
|
if (!isIP(server.host)) throw new McpError("MANAGEMENT_UNSAFE", "Основной адрес управления должен быть буквальным IP, подтверждённым в конфигурации устройства", 409)
|
||||||
|
if (!snapshots[0]?.sourceProof || await rosSnapshotSourceProof(server) !== snapshots[0].sourceProof) throw new McpError("STATE_CONFLICT", "Источник основного управления изменился во время подготовки VLAN", 409)
|
||||||
|
const expiresAt = Math.min(Date.parse(context.connection.policy.expiresAt), context.connection.owner_valid_until.getTime(), Date.parse(context.principal.credentialExpiresAt))
|
||||||
|
if (!Number.isFinite(expiresAt) || expiresAt <= Date.now()) throw new McpError("APPROVAL_REQUIRED", "Нет действующего допуска для основного канала управления", 409)
|
||||||
|
management = { channelId: "primary", serverId: input.serverId, host: server.host, expiresAt: new Date(expiresAt).toISOString() }
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const plan = buildBridgeVlanConfiguration({ ...input, state, management, now: Date.now() })
|
||||||
|
if (!plan.steps.length) throw new McpError("STATE_CONFLICT", "Конфигурация VLAN уже соответствует плану", 409)
|
||||||
|
return {
|
||||||
|
target: { scope: "servers", serverIds: [input.serverId] }, objects: plan.objects,
|
||||||
|
snapshots, steps: plan.steps.map(step => ({ kind: "ros" as const, step })),
|
||||||
|
requiresHuman: true, validUntil: management.expiresAt,
|
||||||
|
...(primaryManagement ? {} : { managementChannels: [{ channelId: management.channelId, serverId: management.serverId, expiresAt: management.expiresAt }] }),
|
||||||
|
warnings: ["Связанный план изменяет VLAN memberships и PVID в указанном порядке; включение filtering выполняется последним.",
|
||||||
|
"Каждая подтверждённая запись проверяется до следующей. При неизвестном результате дальнейшие записи останавливаются.",
|
||||||
|
primaryManagement ? "Используется основной адрес управления; независимость канала не подтверждена. Обязательно подтверждение человеком; каждый префикс плана сохраняет подтверждённый management-доступ."
|
||||||
|
: "Проверяется конфигурация и доступность зарегистрированного management-канала. Передача пользовательского трафика требует отдельной сетевой проверки."],
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof BridgeVlanConfigurationError) throw new McpError(error.code, error.message, 409)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A legacy single-row tool must pass the same effective-state checks, but may
|
||||||
|
* not acquire authority to edit other resource kinds through this delegation. */
|
||||||
|
export async function prepareGenericBridgeVlanChange(step: RosStep, context: ActionContext): Promise<ChangeEvidence | undefined> {
|
||||||
|
const sensitive = step.resource === "bridge" ? ["pvid", "vlan-filtering", "frame-types", "ingress-filtering"] : step.resource === "bridge_port"
|
||||||
|
? ["bridge", "interface", "pvid", "ingress-filtering", "frame-types", "disabled"] : ["bridge", "vlan-ids", "tagged", "untagged", "disabled"]
|
||||||
|
if (!["bridge", "bridge_port", "bridge_vlan"].includes(step.resource) || step.resource === "bridge" && step.action !== "update"
|
||||||
|
|| step.action === "update" && !Object.keys(step.fields).some(key => sensitive.includes(key))) return undefined
|
||||||
|
const { state } = await readBridgeVlanState(step.serverId, context)
|
||||||
|
const rows = step.resource === "bridge" ? state.bridges : step.resource === "bridge_port" ? state.ports : state.vlans
|
||||||
|
const row = rows.find(item => item[".id"] === step.id)
|
||||||
|
const bridgeName = step.resource === "bridge" ? row?.name : step.fields.bridge ?? row?.bridge
|
||||||
|
const bridges = state.bridges.filter(item => item.name === bridgeName)
|
||||||
|
if (bridges.length !== 1 || step.resource !== "bridge" && row && row.bridge !== bridgeName) throw new McpError("STATE_CONFLICT", "Смена bridge требует отдельных явных планов удаления и добавления порта", 409)
|
||||||
|
const bridgeId = String(bridges[0][".id"])
|
||||||
|
try {
|
||||||
|
const desired = bridgeVlanCurrentDesired(state, bridgeId)
|
||||||
|
const bool = (value: unknown): boolean => {
|
||||||
|
if (value === "true" || value === true) return true
|
||||||
|
if (value === "false" || value === false) return false
|
||||||
|
throw new McpError("INVALID_INPUT", "Нужны явные параметры порта VLAN", 400)
|
||||||
|
}
|
||||||
|
const ref = (name: unknown) => {
|
||||||
|
const matches = state.interfaces.filter(iface => iface.name === name)
|
||||||
|
if (matches.length !== 1) throw new McpError("INVALID_INPUT", "Не подтверждён стабильный ID участника VLAN", 400)
|
||||||
|
return { resource: matches[0].resource, id: matches[0].id }
|
||||||
|
}
|
||||||
|
if (step.resource === "bridge") {
|
||||||
|
if (Object.keys(step.fields).some(key => !sensitive.includes(key))) throw new McpError("INVALID_INPUT", "Подготовьте свойства bridge и VLAN filtering отдельными планами", 400)
|
||||||
|
if (step.fields.pvid !== undefined) desired.pvid = Number(step.fields.pvid)
|
||||||
|
if (step.fields["vlan-filtering"] !== undefined) desired.filtering = bool(step.fields["vlan-filtering"])
|
||||||
|
if (step.fields["frame-types"] !== undefined) desired.frameTypes = bridgeVlanConfigurationRequestSchema.shape.desired.shape.frameTypes.parse(step.fields["frame-types"])
|
||||||
|
if (step.fields["ingress-filtering"] !== undefined) desired.ingressFiltering = bool(step.fields["ingress-filtering"])
|
||||||
|
} else if (step.resource === "bridge_port") {
|
||||||
|
const index = desired.ports.findIndex(port => port.id === step.id)
|
||||||
|
if (step.action === "delete") {
|
||||||
|
if (index < 0) throw new McpError("NOT_FOUND", "Статический порт не найден", 404)
|
||||||
|
desired.ports.splice(index, 1); desired.removePortIds.push(step.id!)
|
||||||
|
} else {
|
||||||
|
const effective = { ...row, ...step.fields }
|
||||||
|
const port = { ...(step.action === "update" ? { id: step.id } : {}), interface: ref(effective.interface), pvid: Number(effective.pvid),
|
||||||
|
ingressFiltering: bool(effective["ingress-filtering"]), frameTypes: effective["frame-types"], disabled: bool(effective.disabled),
|
||||||
|
...(effective.comment === undefined ? {} : { comment: effective.comment }) }
|
||||||
|
const parsed = bridgeVlanConfigurationRequestSchema.shape.desired.shape.ports.element.parse(port)
|
||||||
|
if (step.action === "create") desired.ports.push(parsed)
|
||||||
|
else { if (index < 0) throw new McpError("NOT_FOUND", "Статический порт не найден", 404); desired.ports[index] = parsed }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const index = desired.vlans.findIndex(vlan => vlan.id === step.id)
|
||||||
|
if (step.action === "delete") {
|
||||||
|
if (index < 0) throw new McpError("NOT_FOUND", "Статическая VLAN membership не найдена", 404)
|
||||||
|
desired.vlans.splice(index, 1); desired.removeVlanIds.push(step.id!)
|
||||||
|
} else {
|
||||||
|
const effective = { ...row, ...step.fields }
|
||||||
|
const members = (value: unknown) => value === undefined || value === "" ? [] : String(value).split(",").map(ref)
|
||||||
|
const vlan = { ...(step.action === "update" ? { id: step.id } : {}), vlanIds: effective["vlan-ids"], tagged: members(effective.tagged), untagged: members(effective.untagged),
|
||||||
|
disabled: effective.disabled === undefined && step.action === "create" ? false : bool(effective.disabled), ...(effective.comment === undefined ? {} : { comment: effective.comment }) }
|
||||||
|
const parsed = bridgeVlanConfigurationRequestSchema.shape.desired.shape.vlans.element.parse(vlan)
|
||||||
|
if (step.action === "create") desired.vlans.push(parsed)
|
||||||
|
else { if (index < 0) throw new McpError("NOT_FOUND", "Статическая VLAN membership не найдена", 404); desired.vlans[index] = parsed }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const evidence = await prepareBridgeVlanConfiguration({ serverId: step.serverId, bridgeId, desired }, context)
|
||||||
|
if (evidence.steps.length !== 1 || evidence.steps[0].kind !== "ros" || evidence.steps[0].step.resource !== step.resource || evidence.steps[0].step.action !== step.action || evidence.steps[0].step.id !== step.id) {
|
||||||
|
throw new McpError("APPROVAL_REQUIRED", "Для связанных изменений используйте явный план mm_bridge_vlan_configuration_plan с соответствующим разрешением", 409)
|
||||||
|
}
|
||||||
|
const native = evidence.steps[0].step
|
||||||
|
if (step.action === "update" && Object.entries(native.fields).some(([key, value]) => !Object.hasOwn(step.fields, key)
|
||||||
|
|| (step.resource === "bridge_vlan" ? canonicalBridgeVlanValue(key, value) !== canonicalBridgeVlanValue(key, step.fields[key]) : value !== step.fields[key]))) {
|
||||||
|
throw new McpError("STATE_CONFLICT", "Состояние VLAN изменилось во время подготовки; незапрошенные поля не будут перезаписаны", 409)
|
||||||
|
}
|
||||||
|
return evidence
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof BridgeVlanConfigurationError) throw new McpError(error.code, error.message, 409)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerBridgeVlanActions(register: typeof add) {
|
||||||
|
register("network.bridge-vlan.read", "mm_bridge_vlan_configuration_get", "Полная конфигурация bridge VLAN и стабильные ID участников", "network", "mm:network:read", "read", "servers", z.object({ serverId: z.number().int().positive() }).strict(), {
|
||||||
|
read: async (input, context) => ({ state: (await readBridgeVlanState(Number(input.serverId), context)).state }),
|
||||||
|
})
|
||||||
|
register("network.bridge-vlan.configure", "mm_bridge_vlan_configuration_plan", "Связанный план VLAN membership, PVID и bridge filtering", "network", "mm:network:write", "write", "servers", bridgeVlanConfigurationRequestSchema, {
|
||||||
|
prepare: prepareBridgeVlanConfiguration,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import assert from "node:assert/strict"
|
||||||
|
process.env.AUTH_REQUIRED = "true"
|
||||||
|
process.env.AUTH_JWT_SECRET = "bridge-vlan-owned-acceptance-secret"
|
||||||
|
process.env.AUTH_ISSUER = "https://auth.test.local"
|
||||||
|
const { pool, closePool } = await import("../../db/index.js")
|
||||||
|
const { applySqlMigrations } = await import("../../db/migrate.js")
|
||||||
|
const { updateMcpSettings } = await import("./store.js")
|
||||||
|
const { buildApp } = await import("../../index.js")
|
||||||
|
const actor = { id: "bridge-vlan-owner", email: "bridge@example.invalid", name: "Bridge VLAN owner", apps: ["mm"], permissions: ["mm:settings:admin", "mm:network:admin"], expiresAt: Date.now() + 3600000 }
|
||||||
|
let app: Awaited<ReturnType<typeof buildApp>> | undefined
|
||||||
|
try {
|
||||||
|
await applySqlMigrations(pool)
|
||||||
|
assert.equal((await pool.query("SELECT count(*)::int AS n FROM servers")).rows[0].n, 0, "Disposable empty database required")
|
||||||
|
await updateMcpSettings({ enabled: true }, actor)
|
||||||
|
app = await buildApp({ startScheduler: false }); await app.ready()
|
||||||
|
await (await import("../../test/mcp-bridge-vlan-configuration.js")).verifyMcpBridgeVlanConfiguration(actor, app)
|
||||||
|
} finally {
|
||||||
|
await (await import("../../services/scheduler.js")).stopSchedulerForDatabaseOperation()
|
||||||
|
await (await import("../../services/traffic-flow-ingest.js")).stopTrafficFlowListenerForDatabaseOperation()
|
||||||
|
await app?.close(); await closePool()
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import assert from "node:assert/strict"
|
||||||
|
import test from "node:test"
|
||||||
|
import { configurationRows, resourceById, rosSnapshotVersion, type ConfigurationRow, type RosSnapshot, type RosStep } from "./resources.js"
|
||||||
|
import { createRosPrefix } from "./ros-prefix.js"
|
||||||
|
import { verifyRosStepSnapshots } from "./ros-step-verification.js"
|
||||||
|
import { fingerprint, McpError } from "./security.js"
|
||||||
|
|
||||||
|
const snapshot = (resource: string, raw: ConfigurationRow[]): RosSnapshot => {
|
||||||
|
const rows = configurationRows(resourceById(resource), raw)
|
||||||
|
return { serverId: 1, resource, rows, fingerprint: fingerprint(rows), sourceProof: "a".repeat(64), projectionVersion: rosSnapshotVersion(resource) }
|
||||||
|
}
|
||||||
|
const membership = { ".id": "*1", bridge: "br", "vlan-ids": "20-22", tagged: "ether2,ether3", dynamic: "false", disabled: "false" }
|
||||||
|
|
||||||
|
test("VLAN readback accepts equivalent VID/member sets and omitted empty fields, retaining exact native identity", () => {
|
||||||
|
const before = snapshot("bridge_vlan", [membership])
|
||||||
|
const after = snapshot("bridge_vlan", [{ ...membership, "vlan-ids": "22,20,21", tagged: "ether3,ether2" }])
|
||||||
|
const step: RosStep = { serverId: 1, resource: "bridge_vlan", action: "update", id: "*1", fields: { "vlan-ids": "20-22", tagged: "ether3,ether2", untagged: "", comment: "" } }
|
||||||
|
assert.equal(verifyRosStepSnapshots(step, {}, before, after), true)
|
||||||
|
assert.equal(verifyRosStepSnapshots(step, {}, before, snapshot("bridge_vlan", [{ ...membership, "vlan-ids": "20-23" }])), false)
|
||||||
|
assert.equal(verifyRosStepSnapshots(step, {}, before, snapshot("bridge_vlan", [{ ...membership, ".id": "*2" }])), false)
|
||||||
|
assert.throws(() => snapshot("bridge_vlan", [{ ...membership, "vlan-ids": "20,20-22" }]), /Недостоверная/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("PVID-generated rows may change while the sealed static VLAN configuration remains exact", async () => {
|
||||||
|
const port = { ".id": "*2", bridge: "br", interface: "ether2", pvid: "10", disabled: "false" }
|
||||||
|
const dynamic = { ".id": "*D", bridge: "br", "vlan-ids": "10", untagged: "ether2", dynamic: "true" }
|
||||||
|
const raw = new Map<string, ConfigurationRow[]>([["bridge_port", [port]], ["bridge_vlan", [membership, dynamic]]])
|
||||||
|
const initial = [...raw].map(([resource, rows]) => snapshot(resource, rows))
|
||||||
|
const read = async (_server: number, resource: string, active: () => Promise<unknown> = async () => {}) => { await active(); return snapshot(resource, raw.get(resource)!) }
|
||||||
|
const prefix = createRosPrefix(initial, read)
|
||||||
|
await prefix.check()
|
||||||
|
raw.set("bridge_port", [{ ...port, pvid: "20" }])
|
||||||
|
raw.set("bridge_vlan", [membership, { ...dynamic, ".id": "*E", "vlan-ids": "20" }])
|
||||||
|
const step: RosStep = { serverId: 1, resource: "bridge_port", action: "update", id: "*2", fields: { pvid: "20" } }
|
||||||
|
await prefix.advance(step, {}, await read(1, "bridge_port"))
|
||||||
|
await prefix.check()
|
||||||
|
raw.set("bridge_vlan", [{ ...membership, tagged: "ether2" }, dynamic])
|
||||||
|
await assert.rejects(prefix.check(), error => error instanceof McpError && error.code === "STATE_CONFLICT")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("full VLAN ranges and long sparse VID sets stay representable in plans and inverse snapshots", () => {
|
||||||
|
const resource = resourceById("bridge_vlan")
|
||||||
|
const expanded = Array.from({ length: 4094 }, (_, index) => String(index + 1)).join(",")
|
||||||
|
const sparse = Array.from({ length: 2047 }, (_, index) => String(index * 2 + 1)).join(",")
|
||||||
|
assert.ok(sparse.length > 100)
|
||||||
|
for (const value of ["1-4094", sparse]) assert.equal(resource.fields.safeParse({ "vlan-ids": value }).success, true)
|
||||||
|
for (const value of ["0", "4095", "10-9", "10,10-12"]) assert.equal(resource.fields.safeParse({ "vlan-ids": value }).success, false)
|
||||||
|
const before = snapshot("bridge_vlan", [membership])
|
||||||
|
const after = snapshot("bridge_vlan", [{ ...membership, "vlan-ids": expanded }])
|
||||||
|
assert.equal(after.rows[0]["vlan-ids"], "1-4094", "Native expansion must not overflow the public inverse DTO")
|
||||||
|
assert.equal(verifyRosStepSnapshots({ serverId: 1, resource: "bridge_vlan", action: "update", id: "*1", fields: { "vlan-ids": "1-4094" } }, {}, before, after), true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("unknown dynamic flags are retained; only explicit RouterOS-generated VLAN rows are excluded", () => {
|
||||||
|
assert.equal(snapshot("bridge_vlan", [{ ...membership, dynamic: "unknown" }]).rows.length, 1)
|
||||||
|
for (const dynamic of [true, "true", "yes"]) assert.equal(snapshot("bridge_vlan", [{ ...membership, dynamic }]).rows.length, 0)
|
||||||
|
assert.equal(snapshot("bridge_vlan", [{ ...membership, dynamic: "false" }]).rows.length, 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("old bridge CPU, port and VLAN projection contracts cannot execute after semantic projection changes", () => {
|
||||||
|
for (const resource of ["bridge", "bridge_port", "bridge_vlan"]) {
|
||||||
|
const current = snapshot(resource, resource === "bridge_vlan" ? [membership] : [{ ".id": "*2", bridge: "br", interface: "ether2", pvid: "10" }])
|
||||||
|
assert.equal(current.projectionVersion, 4)
|
||||||
|
assert.throws(() => createRosPrefix([{ ...current, projectionVersion: 3 }]), error => error instanceof McpError && error.code === "PLAN_EXPIRED")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
test("CPU frame admission and MVRP configuration are sealed, with no MVRP write permission", () => {
|
||||||
|
const bridge = { ".id": "*1", name: "br", pvid: "10", "vlan-filtering": "true", "frame-types": "admit-all", "ingress-filtering": "true", mvrp: "false", running: "true" }
|
||||||
|
const original = snapshot("bridge", [bridge])
|
||||||
|
for (const field of ["frame-types", "ingress-filtering", "mvrp"]) {
|
||||||
|
const changed = snapshot("bridge", [{ ...bridge, [field]: field === "frame-types" ? "admit-only-vlan-tagged" : "false" === bridge[field as keyof typeof bridge] ? "true" : "false" }])
|
||||||
|
assert.notEqual(changed.fingerprint, original.fingerprint)
|
||||||
|
}
|
||||||
|
assert.equal(snapshot("bridge", [{ ...bridge, running: "false" }]).fingerprint, original.fingerprint)
|
||||||
|
assert.equal(resourceById("bridge").fields.safeParse({ mvrp: true }).success, false)
|
||||||
|
})
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import { bridgeVlanStateSchema, type BridgeVlanDesired, type BridgeVlanState } from "@mmapp/contracts/bridge-vlan"
|
||||||
|
import { bridgeVlanCurrentDesired } from "../../services/bridge-vlan-configuration.js"
|
||||||
|
import type { ActionContext, ChangeEvidence } from "./action-types.js"
|
||||||
|
import { prepareBridgeVlanConfiguration, readBridgeVlanState } from "./bridge-vlan-actions.js"
|
||||||
|
import { acknowledgedCreateId, assertNoCallerCreateId, recoveryRosStepId } from "./native-create-identity.js"
|
||||||
|
import { readRosSnapshot, rosSnapshotVersion, type RosSnapshot, type RosStep } from "./resources.js"
|
||||||
|
import { verifyRosStepSnapshots } from "./ros-step-verification.js"
|
||||||
|
import { decryptSecret, fingerprint, McpError } from "./security.js"
|
||||||
|
|
||||||
|
type Checkpoint = { step_index: number; before_sealed: string; after_sealed: string | null }
|
||||||
|
type Intended = { step_index: number; state: string; result: unknown }
|
||||||
|
const key = (snapshot: Pick<RosSnapshot, "serverId" | "resource">) => `${snapshot.serverId}:${snapshot.resource}`
|
||||||
|
function conflict(message: string): never { throw new McpError("STATE_CONFLICT", message, 409) }
|
||||||
|
|
||||||
|
export function needsBridgeVlanRecovery(sourceAction: string, evidence: ChangeEvidence): boolean {
|
||||||
|
if (sourceAction === "network.bridge-vlan.configure") return true
|
||||||
|
return evidence.steps.some(change => {
|
||||||
|
if (change.kind !== "ros") return false
|
||||||
|
const step = change.step
|
||||||
|
if (step.resource === "bridge") return step.action === "update" && Object.keys(step.fields).some(field => ["pvid", "vlan-filtering", "frame-types", "ingress-filtering"].includes(field))
|
||||||
|
const sensitive = step.resource === "bridge_port" ? ["bridge", "interface", "pvid", "ingress-filtering", "frame-types", "disabled"]
|
||||||
|
: step.resource === "bridge_vlan" ? ["bridge", "vlan-ids", "tagged", "untagged", "disabled"] : []
|
||||||
|
return sensitive.length > 0 && (step.action !== "update" || Object.keys(step.fields).some(field => sensitive.includes(field)))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function validSnapshot(snapshot: RosSnapshot): boolean {
|
||||||
|
return snapshot.projectionVersion === rosSnapshotVersion(snapshot.resource) && !!snapshot.sourceProof && Array.isArray(snapshot.rows) && snapshot.fingerprint === fingerprint(snapshot.rows)
|
||||||
|
}
|
||||||
|
function sameSnapshot(left: RosSnapshot, right: RosSnapshot): boolean {
|
||||||
|
return validSnapshot(left) && validSnapshot(right) && key(left) === key(right) && left.fingerprint === right.fingerprint && left.sourceProof === right.sourceProof
|
||||||
|
}
|
||||||
|
function stateFromSnapshots(serverId: number, snapshots: Map<string, RosSnapshot>): BridgeVlanState {
|
||||||
|
const rows = (resource: string) => {
|
||||||
|
const snapshot = snapshots.get(key({ serverId, resource }))
|
||||||
|
if (!snapshot || !validSnapshot(snapshot)) conflict("Нет полного исходного снимка bridge VLAN с актуальной проекцией")
|
||||||
|
return snapshot.rows
|
||||||
|
}
|
||||||
|
return bridgeVlanStateSchema.parse({ bridges: rows("bridge"), ports: rows("bridge_port"), vlans: rows("bridge_vlan"),
|
||||||
|
vlanInterfaces: rows("vlan"), ipv4Addresses: rows("ipv4_address"), ipv6Addresses: rows("ipv6_address"),
|
||||||
|
interfaces: ["ethernet", "bridge", "vlan", "gre", "wireguard", "vxlan"].flatMap(resource => rows(resource).map(row => ({ resource, id: String(row[".id"]), name: String(row.name), disabled: row.disabled }))),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fresh typed inverse of a completely verified transition. Deleted port/VLAN
|
||||||
|
* rows are recreated without their old IDs; new rows are removed only by ACK. */
|
||||||
|
export async function prepareBridgeVlanRecovery(input: {
|
||||||
|
operationId: string; evidence: ChangeEvidence; checkpoints: Checkpoint[]; intended: Intended[];
|
||||||
|
}, context: ActionContext): Promise<ChangeEvidence> {
|
||||||
|
const { operationId, evidence, checkpoints, intended } = input
|
||||||
|
if (!evidence.steps.length || evidence.steps.some(change => change.kind !== "ros" || !["bridge", "bridge_port", "bridge_vlan"].includes(change.step.resource))) {
|
||||||
|
throw new McpError("UNSUPPORTED_CAPABILITY", "Обратный VLAN план не поддерживает смешанные ресурсы", 409)
|
||||||
|
}
|
||||||
|
const native = evidence.steps.map(change => (change as { kind: "ros"; step: RosStep }).step)
|
||||||
|
if (native.some(step => step.resource === "bridge" && step.action !== "update")) {
|
||||||
|
throw new McpError("UNSUPPORTED_CAPABILITY", "Создание или удаление самого bridge не поддерживает типизированный обратный VLAN план", 409)
|
||||||
|
}
|
||||||
|
const serverIds = [...new Set(native.map(step => step.serverId))]
|
||||||
|
if (serverIds.length !== 1 || evidence.target.scope !== "servers" || evidence.target.serverIds.length !== 1 || evidence.target.serverIds[0] !== serverIds[0]) conflict("Нет однозначного сервера исходного VLAN плана")
|
||||||
|
const serverId = serverIds[0]
|
||||||
|
if (!context.connection.policy.scope.serverIds.includes(serverId)) throw new McpError("SERVER_DENIED", "VLAN сервер вне текущего допуска")
|
||||||
|
if (intended.length !== native.length || checkpoints.length !== native.length || new Set(intended.map(item => item.step_index)).size !== native.length || new Set(checkpoints.map(item => item.step_index)).size !== native.length) conflict("Нет полного набора подтверждённых шагов VLAN")
|
||||||
|
const baseline = new Map<string, RosSnapshot>()
|
||||||
|
for (const snapshot of evidence.snapshots) {
|
||||||
|
if (snapshot.serverId !== serverId || !validSnapshot(snapshot) || baseline.has(key(snapshot))) conflict("Исходные VLAN снимки или их версии не подтверждены")
|
||||||
|
baseline.set(key(snapshot), snapshot)
|
||||||
|
}
|
||||||
|
const baselineState = stateFromSnapshots(serverId, baseline), expected = new Map(baseline)
|
||||||
|
const created: Array<{ resource: "bridge_port" | "bridge_vlan"; id: string }> = []
|
||||||
|
const deleted: Array<{ resource: "bridge_port" | "bridge_vlan"; id: string }> = []
|
||||||
|
for (let index = 0; index < native.length; index++) {
|
||||||
|
const step = native[index], checkpoint = checkpoints.find(item => item.step_index === index), receipt = intended.find(item => item.step_index === index)
|
||||||
|
assertNoCallerCreateId(step)
|
||||||
|
if (!checkpoint?.before_sealed || !checkpoint.after_sealed || receipt?.state !== "verified") conflict("Нет полного проверенного checkpoint VLAN; требуется ручная сверка")
|
||||||
|
const before = JSON.parse(await decryptSecret(checkpoint.before_sealed)) as RosSnapshot
|
||||||
|
const after = JSON.parse(await decryptSecret(checkpoint.after_sealed)) as RosSnapshot
|
||||||
|
const previous = expected.get(key({ serverId, resource: step.resource }))
|
||||||
|
if (!previous || !sameSnapshot(previous, before) || !validSnapshot(after) || before.sourceProof !== after.sourceProof || !verifyRosStepSnapshots(step, receipt.result, before, after)) conflict("Исходный переход VLAN или источник не подтверждён")
|
||||||
|
if (step.action === "create") {
|
||||||
|
const ack = acknowledgedCreateId(receipt.result), actual = recoveryRosStepId(step, before.rows, after.rows)
|
||||||
|
if (!ack || actual !== ack || !["bridge_port", "bridge_vlan"].includes(step.resource)) conflict("Нет точного ACK и checkpoint ID созданного VLAN объекта")
|
||||||
|
created.push({ resource: step.resource as "bridge_port" | "bridge_vlan", id: ack })
|
||||||
|
} else if (step.action === "delete") {
|
||||||
|
if (!step.id || !["bridge_port", "bridge_vlan"].includes(step.resource)) conflict("Не подтверждён ID удалённого VLAN объекта")
|
||||||
|
deleted.push({ resource: step.resource as "bridge_port" | "bridge_vlan", id: step.id })
|
||||||
|
}
|
||||||
|
expected.set(key(after), after)
|
||||||
|
}
|
||||||
|
const bridgeNames = new Set(native.map(step => {
|
||||||
|
const rows = step.resource === "bridge" ? baselineState.bridges : step.resource === "bridge_port" ? baselineState.ports : baselineState.vlans
|
||||||
|
const row = rows.find(item => item[".id"] === step.id)
|
||||||
|
const name = step.resource === "bridge" ? row?.name : step.fields.bridge ?? row?.bridge
|
||||||
|
if (typeof name !== "string" || !name) conflict("Не подтверждён исходный bridge VLAN")
|
||||||
|
return name
|
||||||
|
}))
|
||||||
|
const bridges = baselineState.bridges.filter(row => bridgeNames.has(String(row.name)))
|
||||||
|
if (bridgeNames.size !== 1 || bridges.length !== 1) conflict("Обратный VLAN план требует один однозначный bridge")
|
||||||
|
const bridgeId = String(bridges[0][".id"]), desired: BridgeVlanDesired = bridgeVlanCurrentDesired(baselineState, bridgeId)
|
||||||
|
for (const item of deleted) {
|
||||||
|
const terminal = expected.get(key({ serverId, resource: item.resource }))
|
||||||
|
const original = item.resource === "bridge_port" ? desired.ports.find(row => row.id === item.id) : desired.vlans.find(row => row.id === item.id)
|
||||||
|
if (!original || !terminal || terminal.rows.some(row => row[".id"] === item.id)) conflict("Нет подтверждённого удаления исходной VLAN identity")
|
||||||
|
delete original.id
|
||||||
|
}
|
||||||
|
for (const item of created) {
|
||||||
|
const originalRows = baseline.get(key({ serverId, resource: item.resource }))!.rows
|
||||||
|
const terminalRows = expected.get(key({ serverId, resource: item.resource }))!.rows
|
||||||
|
if (originalRows.some(row => row[".id"] === item.id) || !terminalRows.some(row => row[".id"] === item.id)) conflict("ACK ID VLAN не является подтверждённым новым объектом")
|
||||||
|
if (item.resource === "bridge_port") desired.removePortIds.push(item.id)
|
||||||
|
else desired.removeVlanIds.push(item.id)
|
||||||
|
}
|
||||||
|
const compare = async (snapshots: RosSnapshot[]): Promise<RosSnapshot[]> => {
|
||||||
|
const captured = new Map(snapshots.map(snapshot => [key(snapshot), snapshot]))
|
||||||
|
for (const snapshot of snapshots) {
|
||||||
|
const terminal = expected.get(key(snapshot))
|
||||||
|
if (!terminal || !sameSnapshot(terminal, snapshot)) conflict("После операции изменились VLAN конфигурация, зависимости или источник; обратный план запрещён")
|
||||||
|
}
|
||||||
|
for (const [resourceKey, terminal] of expected) {
|
||||||
|
const current = captured.get(resourceKey) ?? await readRosSnapshot(serverId, terminal.resource, undefined, terminal.sourceProof)
|
||||||
|
if (!sameSnapshot(terminal, current)) conflict("После операции обнаружен drift зависимости VLAN; обратный план запрещён")
|
||||||
|
captured.set(resourceKey, current)
|
||||||
|
}
|
||||||
|
return [...captured.values()]
|
||||||
|
}
|
||||||
|
await compare((await readBridgeVlanState(serverId, context)).snapshots)
|
||||||
|
// Reconstruct through the same model, with a freshly verified management
|
||||||
|
// channel. Old receipts never confer authority to run the inverse.
|
||||||
|
const inverse = await prepareBridgeVlanConfiguration({ serverId, bridgeId, desired }, context)
|
||||||
|
const snapshots = await compare(inverse.snapshots)
|
||||||
|
const objects = new Map([...evidence.objects, ...inverse.objects].map(object => [`${object.serverId}/${object.kind}/${object.id}`, object]))
|
||||||
|
return { ...inverse, snapshots, objects: [...objects.values()], requiresHuman: true,
|
||||||
|
warnings: [`Типизированный обратный VLAN план операции ${operationId}; все исходные шаги и ACK проверены.`, ...inverse.warnings,
|
||||||
|
"Удалённые port/VLAN rows создаются с новыми ID RouterOS. Unknown операции не повторяются; восстановление самого bridge не поддерживается."],
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { isConfirmedRoutingFibTable } from "../../services/routing-table-fib.js"
|
||||||
import type { RouteAiProfile } from "@mmapp/contracts/mcp-routing"
|
import type { RouteAiProfile } from "@mmapp/contracts/mcp-routing"
|
||||||
import { nativeBgpRows, type NativeBgpRow } from "../../services/bgp-export-model.js"
|
import { nativeBgpRows, type NativeBgpRow } from "../../services/bgp-export-model.js"
|
||||||
import { stableBgpPolicyRow } from "../../services/bgp-filter-binding-model.js"
|
import { stableBgpPolicyRow } from "../../services/bgp-filter-binding-model.js"
|
||||||
@@ -56,7 +57,7 @@ export async function readControlPathContext(profile: RouteAiProfile, candidateI
|
|||||||
return matching[0]
|
return matching[0]
|
||||||
}
|
}
|
||||||
const sourceId = profile.serverId, table = unique(await get(sourceId, "/routing/table"), profile.probeVrf)
|
const sourceId = profile.serverId, table = unique(await get(sourceId, "/routing/table"), profile.probeVrf)
|
||||||
if (![true, "true", "yes"].includes(table.fib as string | boolean) || [true, "true", "yes"].includes(table.disabled as string | boolean) || [true, "true", "yes"].includes(table.invalid as string | boolean)) throw new McpError("STATE_UNAVAILABLE", "FIB probe VRF контрольного пути не подтверждён", 409)
|
if (!isConfirmedRoutingFibTable(table)) throw new McpError("STATE_UNAVAILABLE", "FIB probe VRF контрольного пути не подтверждён", 409)
|
||||||
const vrfs = await get(sourceId, "/ip/vrf"), effectiveVrfs: NativeBgpRow[] = []
|
const vrfs = await get(sourceId, "/ip/vrf"), effectiveVrfs: NativeBgpRow[] = []
|
||||||
await enabled(sourceId, "/ip/vrf", unique(vrfs, profile.probeVrf))
|
await enabled(sourceId, "/ip/vrf", unique(vrfs, profile.probeVrf))
|
||||||
for (const row of vrfs) effectiveVrfs.push([true, "true", "yes"].includes(row.disabled as string | boolean) ? row : await enabled(sourceId, "/ip/vrf", row))
|
for (const row of vrfs) effectiveVrfs.push([true, "true", "yes"].includes(row.disabled as string | boolean) ? row : await enabled(sourceId, "/ip/vrf", row))
|
||||||
|
|||||||
@@ -31,6 +31,15 @@ const verifyData = process.env.MMAPP_OWN_TERMINAL_DATA_EQUIVALENCE === "true"
|
|||||||
let dataExpectation: Awaited<ReturnType<typeof import("./database-restore-data-equivalence.sql-helper.js")["prepareOwnedRestoreDataExpectation"]>> | undefined
|
let dataExpectation: Awaited<ReturnType<typeof import("./database-restore-data-equivalence.sql-helper.js")["prepareOwnedRestoreDataExpectation"]>> | undefined
|
||||||
const originalConnect = pool.connect
|
const originalConnect = pool.connect
|
||||||
let migrationCaptures = 0
|
let migrationCaptures = 0
|
||||||
|
async function waitForDatabaseRetirement() {
|
||||||
|
let timer: ReturnType<typeof setTimeout> | undefined
|
||||||
|
try {
|
||||||
|
await Promise.race([sharedDatabaseGate.whenOpen(), new Promise<never>((_, reject) => {
|
||||||
|
timer = setTimeout(() => reject(Error("Terminal restore did not retire its database admission")), 10000)
|
||||||
|
})])
|
||||||
|
} finally { clearTimeout(timer) }
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Every owned restore profile archives inert domain collectors, not only the
|
// Every owned restore profile archives inert domain collectors, not only the
|
||||||
// data-equivalence profile. Otherwise restart can launch unrelated downloads
|
// data-equivalence profile. Otherwise restart can launch unrelated downloads
|
||||||
@@ -138,6 +147,10 @@ try {
|
|||||||
await assert.rejects(assertPortalClaimsCurrent(revokedOwner))
|
await assert.rejects(assertPortalClaimsCurrent(revokedOwner))
|
||||||
await assert.rejects(authenticateMcpToken(issued.token))
|
await assert.rejects(authenticateMcpToken(issued.token))
|
||||||
await assert.rejects(readRestoreReceipt(operation.id, { ...actor, permissions: ["mm:mcp:read"] }))
|
await assert.rejects(readRestoreReceipt(operation.id, { ...actor, permissions: ["mm:mcp:read"] }))
|
||||||
|
// The applied receipt is durable before the terminal callback releases its
|
||||||
|
// last SQL lease/exclusive admission. Await that existing owner, rather than
|
||||||
|
// racing its cleanup with the stale-callback/no-replay assertion below.
|
||||||
|
await waitForDatabaseRetirement()
|
||||||
// A stale callback or process replay cannot invoke pg_restore a second time.
|
// A stale callback or process replay cannot invoke pg_restore a second time.
|
||||||
await pool.query("UPDATE mcp_restore_acceptance_marker SET value='after-receipt'")
|
await pool.query("UPDATE mcp_restore_acceptance_marker SET value='after-receipt'")
|
||||||
await executeMcpDatabaseRestore(operation.id, principal)
|
await executeMcpDatabaseRestore(operation.id, principal)
|
||||||
@@ -165,6 +178,7 @@ try {
|
|||||||
await delay(100)
|
await delay(100)
|
||||||
}
|
}
|
||||||
assert.equal(receipt.state, "blocked", JSON.stringify(receipt))
|
assert.equal(receipt.state, "blocked", JSON.stringify(receipt))
|
||||||
|
await waitForDatabaseRetirement()
|
||||||
assert.equal(await accessGeneration(), generationBeforeCorruption)
|
assert.equal(await accessGeneration(), generationBeforeCorruption)
|
||||||
assert.equal((await pool.query("SELECT value FROM mcp_restore_acceptance_marker")).rows[0].value, "after-receipt")
|
assert.equal((await pool.query("SELECT value FROM mcp_restore_acceptance_marker")).rows[0].value, "after-receipt")
|
||||||
console.log("✓ Tampered archive is blocked before data replacement")
|
console.log("✓ Tampered archive is blocked before data replacement")
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { z } from "zod"
|
|||||||
import { pool } from "../../db/index.js"
|
import { pool } from "../../db/index.js"
|
||||||
import { hasPermission, type AuthUser } from "../../lib/permissions.js"
|
import { hasPermission, type AuthUser } from "../../lib/permissions.js"
|
||||||
import { validateSystemDatabaseRestoreArchive } from "../../services/system-database-restore-execution.js"
|
import { validateSystemDatabaseRestoreArchive } from "../../services/system-database-restore-execution.js"
|
||||||
import { restoreSystemDatabaseBackup, assertSystemDatabaseRestoreTools } from "../../services/system-database-backup.js"
|
import { restoreSystemDatabaseBackup, assertSystemDatabaseRestoreTools, withSystemDatabaseRestoreAdmission } from "../../services/system-database-backup.js"
|
||||||
import { withVerifiedConfigurationChange } from "../../services/config-mutation-policy.js"
|
import { withVerifiedConfigurationChange } from "../../services/config-mutation-policy.js"
|
||||||
import { accessGeneration, decryptBinary, encryptBinary, decryptSecret, McpError, mcpStateDirectory } from "./security.js"
|
import { accessGeneration, decryptBinary, encryptBinary, decryptSecret, McpError, mcpStateDirectory } from "./security.js"
|
||||||
import { audit, validatePrincipal, type McpPrincipal } from "./store.js"
|
import { audit, validatePrincipal, type McpPrincipal } from "./store.js"
|
||||||
@@ -14,6 +14,7 @@ import type { add } from "./actions.js"
|
|||||||
import type { ChangeEvidence } from "./action-types.js"
|
import type { ChangeEvidence } from "./action-types.js"
|
||||||
import { withApplicationMutationLock } from "../../services/config-mutation-lock.js"
|
import { withApplicationMutationLock } from "../../services/config-mutation-lock.js"
|
||||||
import {assertOriginalMcpTemporaryGrant,type OriginalServiceGrant} from "./temporary-grant-origin.js"
|
import {assertOriginalMcpTemporaryGrant,type OriginalServiceGrant} from "./temporary-grant-origin.js"
|
||||||
|
import {reconcileRestoreReceiptAudit,inspectRestoreReceiptAudit} from "../../services/database-restore-audit.js"
|
||||||
|
|
||||||
export const MAX_RESTORE_ARCHIVE_BYTES = 64 * 1024 * 1024
|
export const MAX_RESTORE_ARCHIVE_BYTES = 64 * 1024 * 1024
|
||||||
const metadataSchema = z.object({ id: z.string().uuid(), ownerId: z.string().min(1), sha256: z.string().regex(/^[a-f0-9]{64}$/), sizeBytes: z.number().int().positive().max(MAX_RESTORE_ARCHIVE_BYTES), generation: z.string(), expiresAt: z.string().datetime(), createdAt: z.string().datetime() }).strict()
|
const metadataSchema = z.object({ id: z.string().uuid(), ownerId: z.string().min(1), sha256: z.string().regex(/^[a-f0-9]{64}$/), sizeBytes: z.number().int().positive().max(MAX_RESTORE_ARCHIVE_BYTES), generation: z.string(), expiresAt: z.string().datetime(), createdAt: z.string().datetime() }).strict()
|
||||||
@@ -76,6 +77,10 @@ export async function readRestoreReceipt(id: string, actor: AuthUser) {
|
|||||||
try { return JSON.parse(await readFile(filename(id, ".receipt.json"), "utf8")) as Record<string, unknown> }
|
try { return JSON.parse(await readFile(filename(id, ".receipt.json"), "utf8")) as Record<string, unknown> }
|
||||||
catch { throw new McpError("NOT_FOUND", "Внешняя квитанция восстановления не найдена", 404) }
|
catch { throw new McpError("NOT_FOUND", "Внешняя квитанция восстановления не найдена", 404) }
|
||||||
}
|
}
|
||||||
|
export async function readRestoreReceiptAuditStatus(id:string,actor:AuthUser){
|
||||||
|
await readRestoreReceipt(id,actor)
|
||||||
|
return inspectRestoreReceiptAudit(pool,mcpStateDirectory(),id)
|
||||||
|
}
|
||||||
export function registerDatabaseRestore(register: typeof add) {
|
export function registerDatabaseRestore(register: typeof add) {
|
||||||
register("database.restore", "mm_database_restore_plan", "Восстановить БД из загруженного человеком архива", "settings", "mm:settings:admin", "write", "global", z.object({ archiveRef: z.string().uuid(), expectedSha256: z.string().regex(/^[a-f0-9]{64}$/) }).strict(), {
|
register("database.restore", "mm_database_restore_plan", "Восстановить БД из загруженного человеком архива", "settings", "mm:settings:admin", "write", "global", z.object({ archiveRef: z.string().uuid(), expectedSha256: z.string().regex(/^[a-f0-9]{64}$/) }).strict(), {
|
||||||
prepare: async (input, context) => {
|
prepare: async (input, context) => {
|
||||||
@@ -91,6 +96,7 @@ export function registerDatabaseRestore(register: typeof add) {
|
|||||||
}
|
}
|
||||||
/** This terminal operation must never hold a database-request admission while asking for an exclusive restore. */
|
/** This terminal operation must never hold a database-request admission while asking for an exclusive restore. */
|
||||||
export async function executeMcpDatabaseRestore(operationId: string, principal: McpPrincipal,originalGrant?:OriginalServiceGrant) {
|
export async function executeMcpDatabaseRestore(operationId: string, principal: McpPrincipal,originalGrant?:OriginalServiceGrant) {
|
||||||
|
return withSystemDatabaseRestoreAdmission(async () => {
|
||||||
const lease = await pool.connect()
|
const lease = await pool.connect()
|
||||||
let locked = false, unsafe = false
|
let locked = false, unsafe = false
|
||||||
try {
|
try {
|
||||||
@@ -100,6 +106,7 @@ export async function executeMcpDatabaseRestore(operationId: string, principal:
|
|||||||
if (locked) try { await lease.query("SELECT pg_advisory_unlock(hashtextextended($1,0))", ["mcp-operation:" + operationId]) } catch { unsafe = true }
|
if (locked) try { await lease.query("SELECT pg_advisory_unlock(hashtextextended($1,0))", ["mcp-operation:" + operationId]) } catch { unsafe = true }
|
||||||
lease.release(unsafe)
|
lease.release(unsafe)
|
||||||
}
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
async function executeRestore(operationId: string, principal: McpPrincipal,originalGrant?:OriginalServiceGrant) {
|
async function executeRestore(operationId: string, principal: McpPrincipal,originalGrant?:OriginalServiceGrant) {
|
||||||
const { loadPlan } = await import("./runtime.js")
|
const { loadPlan } = await import("./runtime.js")
|
||||||
@@ -110,7 +117,7 @@ async function executeRestore(operationId: string, principal: McpPrincipal,origi
|
|||||||
let receipt: Record<string, unknown> = { operationId, archiveRef: payload.archiveRef, state: "running", startedAt: new Date().toISOString(), automaticRetry: false }
|
let receipt: Record<string, unknown> = { operationId, archiveRef: payload.archiveRef, state: "running", startedAt: new Date().toISOString(), automaticRetry: false }
|
||||||
try { await durableWrite(filename(operationId, ".receipt.json"), JSON.stringify(receipt), true) }
|
try { await durableWrite(filename(operationId, ".receipt.json"), JSON.stringify(receipt), true) }
|
||||||
catch (error) { if (error instanceof Error && "code" in error && error.code === "EEXIST") return; throw error }
|
catch (error) { if (error instanceof Error && "code" in error && error.code === "EEXIST") return; throw error }
|
||||||
let attempted = false, stage = "authority"
|
let attempted = false, stage = "authority", restoreActorId:string|undefined
|
||||||
const assertAuthority = async () => {
|
const assertAuthority = async () => {
|
||||||
await assertOriginalMcpTemporaryGrant(pool,principal.connectionId,principal.credentialId,originalGrant)
|
await assertOriginalMcpTemporaryGrant(pool,principal.connectionId,principal.credentialId,originalGrant)
|
||||||
const connection = await validatePrincipal(principal)
|
const connection = await validatePrincipal(principal)
|
||||||
@@ -124,6 +131,7 @@ async function executeRestore(operationId: string, principal: McpPrincipal,origi
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const { connection, ids } = await assertAuthority(), source = await archive(payload.archiveRef, connection.owner_id)
|
const { connection, ids } = await assertAuthority(), source = await archive(payload.archiveRef, connection.owner_id)
|
||||||
|
restoreActorId=connection.owner_id
|
||||||
stage = "archive-read"
|
stage = "archive-read"
|
||||||
if (source.sha256 !== payload.expectedSha256) throw new McpError("STATE_CONFLICT", "Архив подменён", 409)
|
if (source.sha256 !== payload.expectedSha256) throw new McpError("STATE_CONFLICT", "Архив подменён", 409)
|
||||||
const buffer = await decryptBinary(await readFile(filename(source.id, ".archive.bin")))
|
const buffer = await decryptBinary(await readFile(filename(source.id, ".archive.bin")))
|
||||||
@@ -139,7 +147,6 @@ async function executeRestore(operationId: string, principal: McpPrincipal,origi
|
|||||||
receipt = { ...receipt, state: "applied", finishedAt: new Date().toISOString(), accessRevoked: true, warnings: Object.keys(outcome).filter(key => key.endsWith("Error") && outcome[key as keyof typeof outcome]) }
|
receipt = { ...receipt, state: "applied", finishedAt: new Date().toISOString(), accessRevoked: true, warnings: Object.keys(outcome).filter(key => key.endsWith("Error") && outcome[key as keyof typeof outcome]) }
|
||||||
stage = "receipt-publish"
|
stage = "receipt-publish"
|
||||||
await durableWrite(filename(operationId, ".receipt.json"), JSON.stringify(receipt))
|
await durableWrite(filename(operationId, ".receipt.json"), JSON.stringify(receipt))
|
||||||
await audit(pool, connection.owner_id, "database.restore.receipt", null, receipt).catch(() => {})
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// The isolated destructive acceptance runner can print bounded diagnostics
|
// The isolated destructive acceptance runner can print bounded diagnostics
|
||||||
// without archive SQL, server credentials, or arbitrary error text.
|
// without archive SQL, server credentials, or arbitrary error text.
|
||||||
@@ -154,4 +161,9 @@ async function executeRestore(operationId: string, principal: McpPrincipal,origi
|
|||||||
await durableWrite(filename(operationId, ".receipt.json"), JSON.stringify(receipt))
|
await durableWrite(filename(operationId, ".receipt.json"), JSON.stringify(receipt))
|
||||||
if (!attempted) await pool.query("UPDATE mcp_operations SET state='rejected',error_code=$2,finished_at=now() WHERE id=$1", [operationId, receipt.errorCode]).catch(() => {})
|
if (!attempted) await pool.query("UPDATE mcp_operations SET state='rejected',error_code=$2,finished_at=now() WHERE id=$1", [operationId, receipt.errorCode]).catch(() => {})
|
||||||
}
|
}
|
||||||
|
// Audit failure is a separate pending/unconfirmed import AFTER durable
|
||||||
|
// applied receipt publication. It must never relabel confirmed restore.
|
||||||
|
if(receipt.state==="applied"){
|
||||||
|
try{await reconcileRestoreReceiptAudit(pool,mcpStateDirectory(),operationId,restoreActorId)}catch{/* Startup reconciles the existing external receipt; never replay restore. */}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
import assert from "node:assert/strict"
|
import assert from "node:assert/strict"
|
||||||
import { createHash, randomUUID } from "node:crypto"
|
import { createHash, randomUUID } from "node:crypto"
|
||||||
import { createDiagnosticCancellationSourceForTests, createReadServerProbeExecutionSourceForTests } from "./diagnostic-cancellation-source.js"
|
import { mkdtemp, readdir, rm, rmdir } from "node:fs/promises"
|
||||||
|
import { tmpdir } from "node:os"
|
||||||
|
import { join } from "node:path"
|
||||||
|
import { createDiagnosticCancellationSourceForTests, createReadServerProbeExecutionSourceForTests, readServerProbeExecutionSource } from "./diagnostic-cancellation-source.js"
|
||||||
import { fingerprint, redact } from "./security.js"
|
import { fingerprint, redact } from "./security.js"
|
||||||
|
|
||||||
const proof = async (value: unknown) => createHash("sha256").update(fingerprint(value)).digest("hex")
|
const proof = async (value: unknown) => createHash("sha256").update(fingerprint(value)).digest("hex")
|
||||||
@@ -25,6 +28,7 @@ function fixture() {
|
|||||||
return { rows }
|
return { rows }
|
||||||
} }
|
} }
|
||||||
return { load: () => load(client, operationId, owner), read: () => read(snapshot, operationId, owner), readSql,
|
return { load: () => load(client, operationId, owner), read: () => read(snapshot, operationId, owner), readSql,
|
||||||
|
readWithExistingKey: () => readServerProbeExecutionSource(snapshot, operationId, owner),
|
||||||
refresh, binding, input, evidence, row, sealed, setRows: (value: typeof rows) => { rows=value }, calls: () => calls, operationId, credentialId }
|
refresh, binding, input, evidence, row, sealed, setRows: (value: typeof rows) => { rows=value }, calls: () => calls, operationId, credentialId }
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
@@ -100,4 +104,22 @@ for (const mutate of mutations) {
|
|||||||
assert.equal(source.originalContext.tool, "bandwidth"); assert.equal(source.stageBudget, 1)
|
assert.equal(source.originalContext.tool, "bandwidth"); assert.equal(source.stageBudget, 1)
|
||||||
assert.equal(source.originalContext.identity.credentialId, f.credentialId)
|
assert.equal(source.originalContext.identity.credentialId, f.credentialId)
|
||||||
}
|
}
|
||||||
console.log(`diagnostic-cancellation-source: locked/read-only source, expired original contexts and ${mutations.length} shared tamper checks passed`)
|
{
|
||||||
|
const state = await mkdtemp(join(tmpdir(), "mm-legacy-source-test-"))
|
||||||
|
const previousState = process.env.MCP_STATE_DIR
|
||||||
|
process.env.MCP_STATE_DIR = state
|
||||||
|
try {
|
||||||
|
const f = fixture()
|
||||||
|
await f.refresh()
|
||||||
|
f.row.evidence = { sealed: Buffer.alloc(32).toString("base64") }
|
||||||
|
await assert.rejects(f.readWithExistingKey, { code: "STATE_CONFLICT" })
|
||||||
|
assert.equal(f.readSql.length, 1)
|
||||||
|
assert.deepEqual(await readdir(state), [], "Historical source reads must leave missing encryption state untouched")
|
||||||
|
} finally {
|
||||||
|
if (previousState === undefined) delete process.env.MCP_STATE_DIR
|
||||||
|
else process.env.MCP_STATE_DIR = previousState
|
||||||
|
await rm(join(state, "encryption-key"), { force: true })
|
||||||
|
await rmdir(state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(`diagnostic-cancellation-source: locked/read-only source, missing key, expired original contexts and ${mutations.length} shared tamper checks passed`)
|
||||||
@@ -3,7 +3,7 @@ import type { ProbeStorageScope, ProbeStorageSql } from "../../services/server-p
|
|||||||
import type { ServerProbeJournalContext } from "../../services/server-probe-journal.js"
|
import type { ServerProbeJournalContext } from "../../services/server-probe-journal.js"
|
||||||
import { SERVER_PROBE_OPERATION } from "../../services/server-probe-authority.js"
|
import { SERVER_PROBE_OPERATION } from "../../services/server-probe-authority.js"
|
||||||
import { openServerProbeMcpBinding } from "./server-probe-actions.js"
|
import { openServerProbeMcpBinding } from "./server-probe-actions.js"
|
||||||
import { decryptSecret, fingerprint, privateSecretFingerprint, redact, McpError } from "./security.js"
|
import { decryptSecret, decryptSecretExisting, fingerprint, privateFingerprinterExisting, privateSecretFingerprint, redact, McpError } from "./security.js"
|
||||||
|
|
||||||
const MAX_BYTES = 2097152
|
const MAX_BYTES = 2097152
|
||||||
const uuid = z.string().uuid()
|
const uuid = z.string().uuid()
|
||||||
@@ -113,5 +113,14 @@ export const loadDiagnosticCancellationSource = sourceLoader(productionCrypto)
|
|||||||
/** Test-only crypto seam. Never accept these dependencies from tool JSON. */
|
/** Test-only crypto seam. Never accept these dependencies from tool JSON. */
|
||||||
export const createDiagnosticCancellationSourceForTests = sourceLoader
|
export const createDiagnosticCancellationSourceForTests = sourceLoader
|
||||||
/** Historical authentication only; confers no execution/cancellation grant. */
|
/** Historical authentication only; confers no execution/cancellation grant. */
|
||||||
export const readServerProbeExecutionSource = readSourceLoader(productionCrypto)
|
export const readServerProbeExecutionSource = readSourceLoader({
|
||||||
|
open: decryptSecretExisting,
|
||||||
|
proof: async value => (await privateFingerprinterExisting("server-probe-mcp-binding-v1"))(fingerprint(value)),
|
||||||
|
})
|
||||||
export const createReadServerProbeExecutionSourceForTests = readSourceLoader
|
export const createReadServerProbeExecutionSourceForTests = readSourceLoader
|
||||||
|
/** Exact previously locked source projection for private history conversion. */
|
||||||
|
export function authenticateServerProbeExecutionSourceRow(row: Record<string, unknown>, operationId: string,
|
||||||
|
owner: Readonly<DiagnosticCancellationOwner>) {
|
||||||
|
checkLookup(operationId, owner)
|
||||||
|
return validateSourceRow(row, operationId, owner, productionCrypto)
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import assert from "node:assert/strict"
|
||||||
|
import { randomUUID } from "node:crypto"
|
||||||
|
import { test } from "node:test"
|
||||||
|
import type { McpAction, ActionContext } from "./action-types.js"
|
||||||
|
import { registerGlobalReconciliationActionsForTests } from "./global-reconciliation-actions.js"
|
||||||
|
|
||||||
|
type Register = Parameters<typeof registerGlobalReconciliationActionsForTests>[0]
|
||||||
|
type Dependencies = Parameters<typeof registerGlobalReconciliationActionsForTests>[1]
|
||||||
|
|
||||||
|
function fixture() {
|
||||||
|
const connectionId = randomUUID(), operationId = randomUUID(), ownerId = "current-retired-owner"
|
||||||
|
const context = { principal: { connectionId }, connection: { id: connectionId, owner_id: ownerId, owner_permissions: ["mm:servers:write"],
|
||||||
|
policy: { permissions: ["mm:servers:write"], scope: { allowGlobal: true, operationIds: ["servers.poll", "operations.global.reconcile"], serverIds: [1, 2] } } } } as ActionContext
|
||||||
|
let action!: Pick<McpAction, "target" | "read">
|
||||||
|
let history: { affectedServerIds: number[] } | undefined = { affectedServerIds: [1, 2] }
|
||||||
|
let revoke = false, retiredCalls = 0, live = false
|
||||||
|
const deps = {
|
||||||
|
connection: async () => {
|
||||||
|
if (revoke) throw Error("revoked-current-grant")
|
||||||
|
return structuredClone(context.connection)
|
||||||
|
},
|
||||||
|
query: async (sql: string) => {
|
||||||
|
if (sql.startsWith("SELECT id FROM servers")) return [{ id: 1 }]
|
||||||
|
if (live) return [{ id: operationId, state: "applied", operation_id: "servers.poll", evidence: { sealed: "live" } }]
|
||||||
|
return []
|
||||||
|
},
|
||||||
|
operation: (id: string) => ({ id, scope: "global", requiredPermissions: ["mm:servers:write"] }),
|
||||||
|
service: () => ({ reconcile: async () => ({ matchesDesiredState: true, matchesOriginalState: false }) }),
|
||||||
|
decrypt: async () => JSON.stringify({ target: { scope: "global" }, objects: [{ serverId: 1 }], steps: [{ kind: "service", service: "servers.poll", input: {} }] }),
|
||||||
|
retired: async (identity, authorize) => {
|
||||||
|
retiredCalls++
|
||||||
|
assert.deepEqual(identity, { operationId, connectionId, ownerId })
|
||||||
|
await authorize()
|
||||||
|
return history
|
||||||
|
},
|
||||||
|
} satisfies Omit<Dependencies, "operation" | "service"> & Partial<Record<"operation" | "service", unknown>>
|
||||||
|
registerGlobalReconciliationActionsForTests(((...args: Parameters<Register>) => { action = args[8] }) as Register, deps as unknown as Dependencies)
|
||||||
|
return { context, operationId, history: (value: typeof history) => { history = value }, live: () => { live = true },
|
||||||
|
revoke: () => { revoke = true }, calls: () => retiredCalls, read: () => action.read!({ operationId }, context),
|
||||||
|
target: () => action.target({ operationId }, context) }
|
||||||
|
}
|
||||||
|
|
||||||
|
test("global action reconciles retired poll only under current ownership and returns no archived material", async () => {
|
||||||
|
const f = fixture()
|
||||||
|
assert.deepEqual(await f.target(), { scope: "global" })
|
||||||
|
const result = await f.read() as { observedAt: string }
|
||||||
|
const { observedAt, ...rest } = result
|
||||||
|
assert(Number.isFinite(Date.parse(observedAt)))
|
||||||
|
assert.deepEqual(rest, { operationId: f.operationId, state: "applied", automaticRetry: false,
|
||||||
|
results: [{ stepIndex: 0, service: "servers.poll", matchesDesiredState: true, matchesOriginalState: false }] })
|
||||||
|
assert.equal(f.calls(), 2)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("global action refuses missing or inaccessible retired history", async () => {
|
||||||
|
const f = fixture()
|
||||||
|
f.history(undefined)
|
||||||
|
await assert.rejects(f.read(), { code: "NOT_FOUND" })
|
||||||
|
f.history({ affectedServerIds: [1, 2] })
|
||||||
|
f.context.connection.policy.scope.serverIds = [1]
|
||||||
|
await assert.rejects(f.read(), { code: "SERVER_DENIED" })
|
||||||
|
f.context.connection.policy.scope.serverIds = [1, 2]
|
||||||
|
f.context.connection.policy.scope.operationIds = ["operations.global.reconcile"]
|
||||||
|
await assert.rejects(f.read(), { code: "PERMISSION_DENIED" })
|
||||||
|
f.revoke()
|
||||||
|
await assert.rejects(f.read(), /revoked-current-grant/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("global action uses the live reconciliation path while original source exists", async () => {
|
||||||
|
const f = fixture()
|
||||||
|
f.live()
|
||||||
|
await f.read()
|
||||||
|
assert.equal(f.calls(), 0)
|
||||||
|
})
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
import { pool } from "../../db/index.js"
|
import { pool } from "../../db/index.js"
|
||||||
|
import { readRetiredServerPollHistory } from "../../services/server-poll-history-reader.js"
|
||||||
import { hasPermission } from "../../lib/permissions.js"
|
import { hasPermission } from "../../lib/permissions.js"
|
||||||
import { effectiveMcpConnection } from "./store.js"
|
import { effectiveMcpConnection } from "./store.js"
|
||||||
import { decryptSecret, fingerprint, McpError } from "./security.js"
|
import { decryptSecret, fingerprint, McpError } from "./security.js"
|
||||||
@@ -7,40 +8,84 @@ import { findMcpOperation } from "./registry.js"
|
|||||||
import { findMcpService } from "./service-registry.js"
|
import { findMcpService } from "./service-registry.js"
|
||||||
import type { add } from "./actions.js"
|
import type { add } from "./actions.js"
|
||||||
import type { ActionContext, ChangeEvidence } from "./action-types.js"
|
import type { ActionContext, ChangeEvidence } from "./action-types.js"
|
||||||
|
import type { ConnectionRow } from "./store.js"
|
||||||
|
|
||||||
async function authorizedSource(operationId: string, context: ActionContext) {
|
const production = {
|
||||||
|
connection: effectiveMcpConnection,
|
||||||
|
query: async (sql: string, values: unknown[] = []) => (await pool.query(sql, values)).rows,
|
||||||
|
operation: findMcpOperation,
|
||||||
|
service: findMcpService,
|
||||||
|
decrypt: decryptSecret,
|
||||||
|
retired: readRetiredServerPollHistory,
|
||||||
|
}
|
||||||
|
type Dependencies = typeof production
|
||||||
|
const notFound = () => new McpError("NOT_FOUND", "Операция не найдена", 404)
|
||||||
|
|
||||||
|
async function currentAccess(context: ActionContext, deps: Dependencies) {
|
||||||
context.signal?.throwIfAborted()
|
context.signal?.throwIfAborted()
|
||||||
const current = await effectiveMcpConnection(context.principal)
|
const current = await deps.connection(context.principal)
|
||||||
if (!current.policy.scope.allowGlobal || fingerprint(current.policy) !== fingerprint(context.connection.policy) || fingerprint(current.owner_permissions) !== fingerprint(context.connection.owner_permissions)) throw new McpError("PERMISSION_DENIED", "Глобальный допуск сверки изменился", 403)
|
if (!current.policy.scope.allowGlobal || fingerprint(current.policy) !== fingerprint(context.connection.policy) || fingerprint(current.owner_permissions) !== fingerprint(context.connection.owner_permissions)) throw new McpError("PERMISSION_DENIED", "Глобальный допуск сверки изменился", 403)
|
||||||
const ids = (await pool.query("SELECT id FROM servers ORDER BY id LIMIT 1001")).rows.map(row => Number(row.id))
|
const ids = (await deps.query("SELECT id FROM servers ORDER BY id LIMIT 1001")).map(row => Number(row.id))
|
||||||
if (ids.length > 1000) throw new McpError("LIMIT_EXCEEDED", "Каталог глобальной сверки превышает лимит", 409)
|
if (ids.length > 1000) throw new McpError("LIMIT_EXCEEDED", "Каталог глобальной сверки превышает лимит", 409)
|
||||||
if (ids.some(id => !current.policy.scope.serverIds.includes(id))) throw new McpError("SERVER_DENIED", "Глобальная сверка требует доступа ко всему каталогу серверов", 403)
|
if (ids.some(id => !current.policy.scope.serverIds.includes(id))) throw new McpError("SERVER_DENIED", "Глобальная сверка требует доступа ко всему каталогу серверов", 403)
|
||||||
const row = (await pool.query("SELECT o.id,o.state,p.operation_id,p.evidence FROM mcp_operations o JOIN mcp_plans p ON p.id=o.plan_id AND p.connection_id=o.connection_id WHERE o.id=$1 AND o.connection_id=$2", [operationId, current.id])).rows[0]
|
return current
|
||||||
if (!row) throw new McpError("NOT_FOUND", "Операция не найдена", 404)
|
}
|
||||||
const original = findMcpOperation(row.operation_id)
|
|
||||||
|
function requireOriginal(actionId: string, current: ConnectionRow, deps: Dependencies) {
|
||||||
|
const original = deps.operation(actionId)
|
||||||
if (!original || original.scope !== "global" || !current.policy.scope.operationIds.includes(original.id) || original.requiredPermissions.some(permission => !hasPermission(current.policy.permissions, permission) || !hasPermission(current.owner_permissions, permission))) throw new McpError("PERMISSION_DENIED", "Утрачено право на исходную глобальную операцию", 403)
|
if (!original || original.scope !== "global" || !current.policy.scope.operationIds.includes(original.id) || original.requiredPermissions.some(permission => !hasPermission(current.policy.permissions, permission) || !hasPermission(current.owner_permissions, permission))) throw new McpError("PERMISSION_DENIED", "Утрачено право на исходную глобальную операцию", 403)
|
||||||
const evidence = JSON.parse(await decryptSecret(row.evidence.sealed)) as ChangeEvidence
|
}
|
||||||
if (evidence.target.scope !== "global" || !evidence.steps.length || evidence.steps.some(step => step.kind !== "service" || !findMcpService(step.service)?.reconcile)) throw new McpError("UNSUPPORTED_CAPABILITY", "Для глобальной операции нет безопасной read-only сверки", 409)
|
|
||||||
const declared = evidence.objects.flatMap(object => object.serverId === undefined ? [] : [object.serverId])
|
function requireServerScope(declared: number[], current: ConnectionRow) {
|
||||||
if (declared.some(id => !current.policy.scope.serverIds.includes(id))) throw new McpError("SERVER_DENIED", "Зависимость операции вне текущего глобального допуска", 403)
|
if (declared.some(id => !current.policy.scope.serverIds.includes(id))) throw new McpError("SERVER_DENIED", "Зависимость операции вне текущего глобального допуска", 403)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function authorizedSource(operationId: string, context: ActionContext, deps: Dependencies) {
|
||||||
|
const current = await currentAccess(context, deps)
|
||||||
|
const row = (await deps.query("SELECT o.id,o.state,p.operation_id,p.evidence FROM mcp_operations o JOIN mcp_plans p ON p.id=o.plan_id AND p.connection_id=o.connection_id WHERE o.id=$1 AND o.connection_id=$2", [operationId, current.id]))[0]
|
||||||
|
if (!row) {
|
||||||
|
const authorize = async () => {
|
||||||
|
const fresh = await currentAccess(context, deps)
|
||||||
|
if (fresh.id !== current.id || fresh.owner_id !== current.owner_id) throw notFound()
|
||||||
|
}
|
||||||
|
const archived = await deps.retired({ operationId, connectionId: current.id, ownerId: current.owner_id }, authorize,
|
||||||
|
{ deadlineAt: Date.now() + 10000, signal: context.signal })
|
||||||
|
context.signal?.throwIfAborted()
|
||||||
|
if (!archived) throw notFound()
|
||||||
|
requireOriginal("servers.poll", current, deps)
|
||||||
|
requireServerScope(archived.affectedServerIds, current)
|
||||||
|
return { kind: "retired-poll" as const }
|
||||||
|
}
|
||||||
|
requireOriginal(row.operation_id, current, deps)
|
||||||
|
const evidence = JSON.parse(await deps.decrypt(row.evidence.sealed)) as ChangeEvidence
|
||||||
|
if (evidence.target.scope !== "global" || !evidence.steps.length || evidence.steps.some(step => step.kind !== "service" || !deps.service(step.service)?.reconcile)) throw new McpError("UNSUPPORTED_CAPABILITY", "Для глобальной операции нет безопасной read-only сверки", 409)
|
||||||
|
requireServerScope(evidence.objects.flatMap(object => object.serverId === undefined ? [] : [object.serverId]), current)
|
||||||
if (["pending", "running"].includes(row.state)) throw new McpError("STATE_CONFLICT", "Операция ещё выполняется", 409)
|
if (["pending", "running"].includes(row.state)) throw new McpError("STATE_CONFLICT", "Операция ещё выполняется", 409)
|
||||||
context.signal?.throwIfAborted()
|
context.signal?.throwIfAborted()
|
||||||
return { row, evidence }
|
return { kind: "live" as const, row, evidence }
|
||||||
}
|
}
|
||||||
export function registerGlobalReconciliationActions(register: typeof add) {
|
|
||||||
|
function registerActions(register: typeof add, deps: Dependencies) {
|
||||||
register("operations.global.reconcile", "mm_global_operation_reconcile", "Сверить глобальную сервисную операцию без повторной записи", "mcp", null, "read", "global", z.object({ operationId: z.string().uuid() }).strict(), {
|
register("operations.global.reconcile", "mm_global_operation_reconcile", "Сверить глобальную сервисную операцию без повторной записи", "mcp", null, "read", "global", z.object({ operationId: z.string().uuid() }).strict(), {
|
||||||
target: async (input, context) => { await authorizedSource(String(input.operationId), context); return { scope: "global" } },
|
target: async (input, context) => { await authorizedSource(String(input.operationId), context, deps); return { scope: "global" } },
|
||||||
read: async (input, context) => {
|
read: async (input, context) => {
|
||||||
const { row, evidence } = await authorizedSource(String(input.operationId), context)
|
const operationId = String(input.operationId)
|
||||||
const authorize = async () => { await authorizedSource(String(input.operationId), context) }
|
const source = await authorizedSource(operationId, context, deps)
|
||||||
|
if (source.kind === "retired-poll") return { operationId, state: "applied", observedAt: new Date().toISOString(),
|
||||||
|
results: [{ stepIndex: 0, service: "servers.poll", matchesDesiredState: true, matchesOriginalState: false }], automaticRetry: false }
|
||||||
|
const { row, evidence } = source
|
||||||
|
const authorize = async () => { if ((await authorizedSource(operationId, context, deps)).kind !== "live") throw notFound() }
|
||||||
const results = []
|
const results = []
|
||||||
for (const [index, step] of evidence.steps.entries()) {
|
for (const [index, step] of evidence.steps.entries()) {
|
||||||
if (step.kind !== "service") throw new McpError("UNSUPPORTED_CAPABILITY", "Нативная глобальная сверка недоступна", 409)
|
if (step.kind !== "service") throw new McpError("UNSUPPORTED_CAPABILITY", "Нативная глобальная сверка недоступна", 409)
|
||||||
await authorize()
|
await authorize()
|
||||||
results.push({ stepIndex: index, service: step.service, ...await findMcpService(step.service)!.reconcile!(step.input, authorize) })
|
results.push({ stepIndex: index, service: step.service, ...await deps.service(step.service)!.reconcile!(step.input, authorize) })
|
||||||
}
|
}
|
||||||
await authorize()
|
await authorize()
|
||||||
return { operationId: row.id, state: row.state, observedAt: new Date().toISOString(), results, automaticRetry: false }
|
return { operationId: row.id, state: row.state, observedAt: new Date().toISOString(), results, automaticRetry: false }
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const registerGlobalReconciliationActions = (register: typeof add) => registerActions(register, production)
|
||||||
|
export const registerGlobalReconciliationActionsForTests = registerActions
|
||||||
@@ -2889,6 +2889,53 @@ try {
|
|||||||
assert.ok(!result.isError)
|
assert.ok(!result.isError)
|
||||||
console.log("✓ Official SDK HTTP initialize, filtered tools/list and tools/call")
|
console.log("✓ Official SDK HTTP initialize, filtered tools/list and tools/call")
|
||||||
} finally { await client.close() }
|
} finally { await client.close() }
|
||||||
|
{
|
||||||
|
const access = await createMcpConnection(mcpConnectionCreateSchema.parse({
|
||||||
|
...input, name: "Logs follow maintenance", permissions: ["mm:servers:read"],
|
||||||
|
scope: { serverIds: [1], operationIds: ["logs.follow"], allowGlobal: false },
|
||||||
|
}), actor)
|
||||||
|
const follower = new Client({ name: "logs-follow-maintenance", version: "1.0.0" })
|
||||||
|
const { sharedDatabaseGate } = await import("../../services/shared-database-gate.js")
|
||||||
|
const original = pool.query
|
||||||
|
let arrived!: () => void, release!: () => void
|
||||||
|
const reading = new Promise<void>(resolve => { arrived = resolve })
|
||||||
|
const held = new Promise<void>(resolve => { release = resolve })
|
||||||
|
try {
|
||||||
|
await follower.connect(new StreamableHTTPClientTransport(new URL(endpoint), { requestInit: { headers: { Authorization: `Bearer ${access.token}` } } }))
|
||||||
|
pool.query = (async (...args: Parameters<typeof original>) => {
|
||||||
|
const text = typeof args[0] === "string" ? args[0] : args[0]?.text
|
||||||
|
const result = await Reflect.apply(original, pool, args)
|
||||||
|
if (typeof text === "string" && text.startsWith("SELECT GREATEST(COALESCE(max(id),0)")) {
|
||||||
|
arrived()
|
||||||
|
await held
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}) as typeof original
|
||||||
|
const following = follower.callTool({ name: "mm_logs_follow", arguments: { serverId: 1, waitSeconds: 30 } })
|
||||||
|
try {
|
||||||
|
const observation = new AbortController()
|
||||||
|
try {
|
||||||
|
await Promise.race([reading, following.then(() => { throw new Error("Follow finished before its stream query") }),
|
||||||
|
delay(5000, undefined, { signal: observation.signal }).then(() => { throw new Error("Follow stream query was not observed") })])
|
||||||
|
} finally { observation.abort() }
|
||||||
|
let exclusiveEntered = false
|
||||||
|
const maintenance = sharedDatabaseGate.exclusive(async () => { exclusiveEntered = true }, 5000)
|
||||||
|
try {
|
||||||
|
await delay(25)
|
||||||
|
assert.equal(exclusiveEntered, false, "maintenance must wait for the pending database read")
|
||||||
|
} finally {
|
||||||
|
release()
|
||||||
|
await maintenance
|
||||||
|
}
|
||||||
|
const result = await following
|
||||||
|
assert.equal(result.isError, true)
|
||||||
|
assert.match(JSON.stringify(result), /RESET_REQUIRED/)
|
||||||
|
assert.doesNotMatch(JSON.stringify(result), /"cursor"/)
|
||||||
|
assert.equal((await pool.query("SELECT count(*)::int AS count FROM mcp_read_leases WHERE connection_id=$1", [access.connection.id])).rows[0].count, 0)
|
||||||
|
} finally { release(); await following.catch(() => undefined) }
|
||||||
|
} finally { pool.query = original; await follower.close() }
|
||||||
|
}
|
||||||
|
await (await import("./logs-follow-sdk-cancellation-acceptance.js")).verifyLogsFollowSdkCancellation(app, endpoint, actor)
|
||||||
const stdio = new Client({ name: "stdio-integration", version: "1.0.0" })
|
const stdio = new Client({ name: "stdio-integration", version: "1.0.0" })
|
||||||
try {
|
try {
|
||||||
await stdio.connect(new StdioClientTransport({ command: process.execPath, args: ["--import", "tsx", fileURLToPath(new URL("../../scripts/mcp-stdio.ts", import.meta.url))], env: { MCP_URL: endpoint, MCP_TOKEN: rotated.token }, stderr: "pipe" }))
|
await stdio.connect(new StdioClientTransport({ command: process.execPath, args: ["--import", "tsx", fileURLToPath(new URL("../../scripts/mcp-stdio.ts", import.meta.url))], env: { MCP_URL: endpoint, MCP_TOKEN: rotated.token }, stderr: "pipe" }))
|
||||||
@@ -2907,17 +2954,41 @@ try {
|
|||||||
await (await import("../../test/mcp-host-files.js")).verifyMcpHostFiles(actor,app)
|
await (await import("../../test/mcp-host-files.js")).verifyMcpHostFiles(actor,app)
|
||||||
const readinessAccess = await createMcpConnection(mcpConnectionCreateSchema.parse({ ...input, name: "Actual readiness handshake", scope: { ...input.scope, operationIds: ["servers.list"] } }), actor)
|
const readinessAccess = await createMcpConnection(mcpConnectionCreateSchema.parse({ ...input, name: "Actual readiness handshake", scope: { ...input.scope, operationIds: ["servers.list"] } }), actor)
|
||||||
const readinessHeaders = { authorization: "Bearer " + jwt }
|
const readinessHeaders = { authorization: "Bearer " + jwt }
|
||||||
|
const simulatorHeaders = { authorization: "Bearer " + await new SignJWT({ sub: "policy-preview-admin", apps: ["mm"], permissions: ["mm:mcp:admin"] })
|
||||||
|
.setProtectedHeader({ alg: "HS256" }).setIssuer(env.authIssuer).setExpirationTime("1h").sign(new TextEncoder().encode(env.jwtSecret)) }
|
||||||
|
const policyPreview = (payload: Record<string, unknown>) => app!.inject({ method: "POST", url: "/api/mcp/policy/preview", headers: simulatorHeaders, payload })
|
||||||
|
const simulatedServerRead = { connectionId: readinessAccess.connection.id, operationId: "servers.list", target: { scope: "servers", serverIds: [1] } }
|
||||||
|
const simulation = await policyPreview(simulatedServerRead)
|
||||||
|
assert.equal(simulation.statusCode, 200, simulation.body)
|
||||||
|
assert.equal(simulation.json().result.decision, "allowed", "an administrator simulates the stored owner's rights, not the administrator's rights")
|
||||||
|
assert.equal(simulation.json().executionAvailable, false)
|
||||||
|
assert.equal((await policyPreview({ ...simulatedServerRead, ownerPermissions: ["mm:settings:admin"] })).statusCode, 400)
|
||||||
const ready = await app.inject({ method: "POST", url: `/api/mcp/connections/${readinessAccess.connection.id}/check`, headers: readinessHeaders, payload: {} })
|
const ready = await app.inject({ method: "POST", url: `/api/mcp/connections/${readinessAccess.connection.id}/check`, headers: readinessHeaders, payload: {} })
|
||||||
assert.equal(ready.statusCode, 200, ready.body)
|
assert.equal(ready.statusCode, 200, ready.body)
|
||||||
assert.equal(ready.json().ok, true, ready.body); assert.equal(ready.json().checks.protocolHandshake, true)
|
assert.equal(ready.json().ok, true, ready.body); assert.equal(ready.json().checks.protocolHandshake, true)
|
||||||
assert.equal(ready.json().protocolProbe.status, "passed"); assert.equal(ready.json().protocolProbe.clientCredentialsVerified, false)
|
assert.equal(ready.json().protocolProbe.status, "passed"); assert.equal(ready.json().protocolProbe.clientCredentialsVerified, false)
|
||||||
|
assert.equal(ready.json().protocolProbe.capabilitiesRead, "skipped")
|
||||||
assert.equal((await pool.query("SELECT count(*)::int AS n FROM mcp_credentials WHERE connection_id=$1 AND client_id='mcp-readiness-probe'", [readinessAccess.connection.id])).rows[0].n, 0)
|
assert.equal((await pool.query("SELECT count(*)::int AS n FROM mcp_credentials WHERE connection_id=$1 AND client_id='mcp-readiness-probe'", [readinessAccess.connection.id])).rows[0].n, 0)
|
||||||
assert.equal((await pool.query("SELECT count(*)::int AS n FROM mcp_grants WHERE connection_id=$1", [readinessAccess.connection.id])).rows[0].n, 0)
|
assert.equal((await pool.query("SELECT count(*)::int AS n FROM mcp_grants WHERE connection_id=$1", [readinessAccess.connection.id])).rows[0].n, 0)
|
||||||
const duplicateReady = await app.inject({ method: "POST", url: `/api/mcp/connections/${readinessAccess.connection.id}/check`, headers: readinessHeaders, payload: {} })
|
const duplicateReady = await app.inject({ method: "POST", url: `/api/mcp/connections/${readinessAccess.connection.id}/check`, headers: readinessHeaders, payload: {} })
|
||||||
assert.equal(duplicateReady.statusCode, 429)
|
assert.equal(duplicateReady.statusCode, 429)
|
||||||
await setConnectionStatus(readinessAccess.connection.id, "paused", actor)
|
await setConnectionStatus(readinessAccess.connection.id, "paused", actor)
|
||||||
|
const pausedSimulation = await policyPreview(simulatedServerRead)
|
||||||
|
assert.equal(pausedSimulation.statusCode, 200, pausedSimulation.body)
|
||||||
|
assert.equal(pausedSimulation.json().result.decision, "denied")
|
||||||
|
assert.ok(pausedSimulation.json().result.reasons.some((reason: { code: string }) => reason.code === "CONNECTION_INACTIVE"))
|
||||||
const pausedReady = await app.inject({ method: "POST", url: `/api/mcp/connections/${readinessAccess.connection.id}/check`, headers: readinessHeaders, payload: {} })
|
const pausedReady = await app.inject({ method: "POST", url: `/api/mcp/connections/${readinessAccess.connection.id}/check`, headers: readinessHeaders, payload: {} })
|
||||||
assert.equal(pausedReady.statusCode, 200); assert.equal(pausedReady.json().protocolProbe.status, "skipped")
|
assert.equal(pausedReady.statusCode, 200); assert.equal(pausedReady.json().protocolProbe.status, "skipped")
|
||||||
|
const readableAccess = await createMcpConnection(mcpConnectionCreateSchema.parse({ ...input, name: "Actual readiness capability read", scope: { ...input.scope, operationIds: ["servers.list", "capabilities.read"] } }), actor)
|
||||||
|
const readable = await app.inject({ method: "POST", url: `/api/mcp/connections/${readableAccess.connection.id}/check`, headers: readinessHeaders, payload: {} })
|
||||||
|
assert.equal(readable.statusCode, 200, readable.body)
|
||||||
|
assert.equal(readable.json().ok, true, readable.body)
|
||||||
|
assert.equal(readable.json().protocolProbe.capabilitiesRead, "passed")
|
||||||
|
assert.equal(readable.json().protocolProbe.credentialType, "temporary-capabilities-read")
|
||||||
|
const readinessReads = (await pool.query("SELECT tool FROM mcp_requests WHERE connection_id=$1 AND tool NOT LIKE 'protocol:%'", [readableAccess.connection.id])).rows
|
||||||
|
assert.deepEqual(readinessReads.map(row => row.tool), ["mm_capabilities_get"])
|
||||||
|
assert.equal((await pool.query("SELECT count(*)::int AS n FROM mcp_credentials WHERE connection_id=$1 AND client_id='mcp-readiness-probe'", [readableAccess.connection.id])).rows[0].n, 0)
|
||||||
|
assert.equal((await pool.query("SELECT count(*)::int AS n FROM mcp_grants WHERE connection_id=$1", [readableAccess.connection.id])).rows[0].n, 0)
|
||||||
const emptyGrantAccess = await createMcpConnection(mcpConnectionCreateSchema.parse({ ...input, name: "Readiness has no operation authority" }), actor)
|
const emptyGrantAccess = await createMcpConnection(mcpConnectionCreateSchema.parse({ ...input, name: "Readiness has no operation authority" }), actor)
|
||||||
const { withMcpProbeCredential } = await import("./readiness.js")
|
const { withMcpProbeCredential } = await import("./readiness.js")
|
||||||
let endedProbeToken = ""
|
let endedProbeToken = ""
|
||||||
@@ -2933,7 +3004,7 @@ try {
|
|||||||
assert.equal((await pool.query("SELECT count(*)::int AS n FROM mcp_grants WHERE connection_id=$1", [emptyGrantAccess.connection.id])).rows[0].n, 0)
|
assert.equal((await pool.query("SELECT count(*)::int AS n FROM mcp_grants WHERE connection_id=$1", [emptyGrantAccess.connection.id])).rows[0].n, 0)
|
||||||
const readinessAudit = (await pool.query("SELECT payload FROM mcp_audit WHERE connection_id=$1", [emptyGrantAccess.connection.id])).rows
|
const readinessAudit = (await pool.query("SELECT payload FROM mcp_audit WHERE connection_id=$1", [emptyGrantAccess.connection.id])).rows
|
||||||
assert.ok(!JSON.stringify(readinessAudit).includes(endedProbeToken))
|
assert.ok(!JSON.stringify(readinessAudit).includes(endedProbeToken))
|
||||||
console.log("✓ Actual readiness initialize/tools-list over public HTTP: temporary empty grant, no tool authority, cleanup on success/error, cooldown and paused precheck skip")
|
console.log("✓ Actual readiness initialize/tools-list and optional capabilities read over public HTTP: narrow grant, cleanup on success/error, cooldown and paused precheck skip")
|
||||||
const deviceConnection = await createMcpConnection(mcpConnectionCreateSchema.parse({ ...input, name: "Web interfaces", scope: { ...input.scope, operationIds: ["ros.bridge.list", "ros.bridge.create"] } }), actor)
|
const deviceConnection = await createMcpConnection(mcpConnectionCreateSchema.parse({ ...input, name: "Web interfaces", scope: { ...input.scope, operationIds: ["ros.bridge.list", "ros.bridge.create"] } }), actor)
|
||||||
const deviceHeaders = { authorization: "Bearer " + jwt }
|
const deviceHeaders = { authorization: "Bearer " + jwt }
|
||||||
const deviceCatalog = await app.inject({ url: "/api/mcp/devices/catalog?connectionId=" + deviceConnection.connection.id, headers: deviceHeaders })
|
const deviceCatalog = await app.inject({ url: "/api/mcp/devices/catalog?connectionId=" + deviceConnection.connection.id, headers: deviceHeaders })
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { McpError } from "./security.js"
|
||||||
|
|
||||||
|
export function logsFollowCancellation(maintenance?: AbortSignal, caller?: AbortSignal): McpError | undefined {
|
||||||
|
if (maintenance?.aborted) return maintenance.reason
|
||||||
|
if (caller?.aborted) return new McpError("READ_CANCELLED", "Ожидание новых событий отменено", 409)
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import assert from "node:assert/strict"
|
||||||
|
import { randomUUID } from "node:crypto"
|
||||||
|
import type { IncomingMessage, ServerResponse } from "node:http"
|
||||||
|
import { setTimeout as delay } from "node:timers/promises"
|
||||||
|
import type { FastifyInstance } from "fastify"
|
||||||
|
import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/client"
|
||||||
|
import { mcpConnectionCreateSchema } from "@mmapp/contracts/mcp"
|
||||||
|
import { pool } from "../../db/index.js"
|
||||||
|
import type { AuthUser } from "../../lib/permissions.js"
|
||||||
|
import { createMcpConnection } from "./store.js"
|
||||||
|
|
||||||
|
export async function verifyLogsFollowSdkCancellation(app: FastifyInstance, endpoint: string, actor: AuthUser) {
|
||||||
|
const access = await createMcpConnection(mcpConnectionCreateSchema.parse({
|
||||||
|
name: "Logs follow SDK cancellation", mode: "approval", permissions: ["mm:servers:read"],
|
||||||
|
expiresAt: new Date(Math.min(actor.expiresAt ?? Infinity, Date.now() + 600_000)).toISOString(),
|
||||||
|
scope: { serverIds: [1], operationIds: ["logs.follow", "logs.query"], allowGlobal: false },
|
||||||
|
}), actor)
|
||||||
|
const client = new Client({ name: "logs-follow-sdk-cancellation", version: "1.0.0" }, {
|
||||||
|
versionNegotiation: { mode: { pin: "2026-07-28" } },
|
||||||
|
})
|
||||||
|
const caller = new AbortController(), bound = new AbortController()
|
||||||
|
const deadline = delay(5000, undefined, { signal: bound.signal }).then(() => { throw new Error("SDK cancellation fixture deadline") })
|
||||||
|
void deadline.catch(() => undefined)
|
||||||
|
let entered!: () => void, release!: () => void, closed!: () => void
|
||||||
|
const enteredRead = new Promise<void>(resolve => { entered = resolve })
|
||||||
|
const holdRead = new Promise<void>(resolve => { release = resolve })
|
||||||
|
const responseClosed = new Promise<void>(resolve => { closed = resolve })
|
||||||
|
let readCount = 0
|
||||||
|
const originalQuery = pool.query
|
||||||
|
const observeClose = (request: IncomingMessage, response: ServerResponse) => {
|
||||||
|
if (request.method === "POST" && request.url === "/mcp") {
|
||||||
|
response.once("close", () => { if (!response.writableFinished) closed() })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await client.connect(new StreamableHTTPClientTransport(new URL(endpoint), {
|
||||||
|
requestInit: { headers: { Authorization: `Bearer ${access.token}` } },
|
||||||
|
}))
|
||||||
|
app.server.on("request", observeClose)
|
||||||
|
pool.query = (async (...args: unknown[]) => {
|
||||||
|
const query = args[0]
|
||||||
|
const sql = typeof query === "object" && query !== null && "text" in query ? query.text : query
|
||||||
|
const result = await Reflect.apply(originalQuery, pool, args)
|
||||||
|
if (typeof sql === "string" && sql.startsWith("SELECT GREATEST(COALESCE(max(id),0)")) {
|
||||||
|
readCount++
|
||||||
|
entered()
|
||||||
|
await holdRead
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}) as typeof originalQuery
|
||||||
|
const follow = client.callTool({ name: "mm_logs_follow", arguments: { serverId: 1, waitSeconds: 30 } }, { signal: caller.signal })
|
||||||
|
const refused = assert.rejects(follow)
|
||||||
|
try {
|
||||||
|
await Promise.race([enteredRead, deadline])
|
||||||
|
caller.abort()
|
||||||
|
await Promise.race([Promise.all([refused, responseClosed]), deadline])
|
||||||
|
const live = await pool.query("SELECT count(*)::int AS count FROM mcp_read_leases WHERE connection_id=$1", [access.connection.id])
|
||||||
|
assert.equal(live.rows[0].count, 1, "caller cancellation must wait for the pending read before releasing the lease")
|
||||||
|
release()
|
||||||
|
for (;;) {
|
||||||
|
const { rows: [audit] } = await pool.query(
|
||||||
|
"SELECT status,error_code,result FROM mcp_requests WHERE connection_id=$1 AND tool='mm_logs_follow'", [access.connection.id],
|
||||||
|
)
|
||||||
|
const protocol = await pool.query("SELECT count(*)::int AS count FROM mcp_requests WHERE connection_id=$1 AND tool='protocol:tools/call' AND finished_at IS NOT NULL", [access.connection.id])
|
||||||
|
if (audit && audit.status !== "running" && protocol.rows[0].count === 1) {
|
||||||
|
assert.equal(audit.status, "error")
|
||||||
|
assert.equal(audit.error_code, "READ_CANCELLED")
|
||||||
|
assert.doesNotMatch(JSON.stringify(audit.result), /"cursor"/)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
await Promise.race([delay(25), deadline])
|
||||||
|
}
|
||||||
|
assert.equal((await pool.query("SELECT count(*)::int AS count FROM mcp_read_leases WHERE connection_id=$1", [access.connection.id])).rows[0].count, 0)
|
||||||
|
assert.equal(readCount, 1, "cancelled follow does not dispatch a second read")
|
||||||
|
} finally {
|
||||||
|
caller.abort()
|
||||||
|
release()
|
||||||
|
await refused.catch(() => undefined)
|
||||||
|
}
|
||||||
|
pool.query = originalQuery
|
||||||
|
const from = "2000-01-01T00:00:00Z", to = "2000-01-01T00:30:00Z"
|
||||||
|
const followOnce = (filters: { from?: string; cursor?: string }) => client.callTool({
|
||||||
|
name: "mm_logs_follow", arguments: { serverId: 1, waitSeconds: 0, to, ...filters },
|
||||||
|
})
|
||||||
|
const initial = await followOnce({ from })
|
||||||
|
assert.ok(!initial.isError)
|
||||||
|
const cursor = (initial.structuredContent as { result?: { cursor?: unknown } } | undefined)?.result?.cursor
|
||||||
|
assert.ok(typeof cursor === "string" && cursor.length > 0)
|
||||||
|
assert.ok(!(await followOnce({ from, cursor })).isError)
|
||||||
|
assert.ok(!(await followOnce({ cursor })).isError, "omitting from must resume the historical cursor's period")
|
||||||
|
const changed = await followOnce({ from: "2000-01-01T00:01:00Z", cursor })
|
||||||
|
assert.equal(changed.isError, true)
|
||||||
|
assert.match(JSON.stringify(changed), /RESET_REQUIRED/)
|
||||||
|
assert.doesNotMatch(JSON.stringify(changed), /"cursor"/)
|
||||||
|
console.log("✓ MCP logs follow: historical cursor resumes, changed from requires reset")
|
||||||
|
|
||||||
|
const marker = `mcp-logs-cursor-${randomUUID()}`
|
||||||
|
const readFollowPage = async (options: { cursor?: string; limit?: number }) => {
|
||||||
|
const result = await client.callTool({ name: "mm_logs_follow", arguments: { serverId: 1, from, to, q: marker, waitSeconds: 0, ...options } })
|
||||||
|
assert.ok(!result.isError)
|
||||||
|
return (result.structuredContent as { result: { entries: { id: string }[]; cursor: string } }).result
|
||||||
|
}
|
||||||
|
const start = await readFollowPage({})
|
||||||
|
assert.deepEqual(start.entries, [])
|
||||||
|
try {
|
||||||
|
await pool.query(`INSERT INTO router_log_entries(server_id,server_name,host,router_id,raw_time,received_at,effective_at,topics,level,message,generation)
|
||||||
|
SELECT 1,'cursor fixture','127.0.0.1','fixture','00:02:00',now(),$1::timestamptz+n*interval '2 minutes',ARRAY['system'],'info',$2,0
|
||||||
|
FROM generate_series(1,2) AS n`, [from, marker])
|
||||||
|
const defaultFollow = await readFollowPage({ cursor: start.cursor })
|
||||||
|
assert.equal(defaultFollow.entries.length, 2)
|
||||||
|
const firstFollow = await readFollowPage({ cursor: start.cursor, limit: 1 })
|
||||||
|
const secondFollow = await readFollowPage({ cursor: firstFollow.cursor, limit: 1 })
|
||||||
|
assert.equal(firstFollow.entries.length, 1)
|
||||||
|
assert.equal(secondFollow.entries.length, 1)
|
||||||
|
assert.deepEqual([...firstFollow.entries, ...secondFollow.entries].map(row => row.id), defaultFollow.entries.map(row => row.id))
|
||||||
|
assert.deepEqual((await readFollowPage({ cursor: secondFollow.cursor, limit: 1 })).entries, [])
|
||||||
|
console.log("✓ MCP logs follow: requested batch limit and lossless cursor progression")
|
||||||
|
const query = (filters: { from?: string; cursor?: string }) => client.callTool({
|
||||||
|
name: "mm_logs_query", arguments: { serverId: 1, q: marker, limit: 1, to, ...filters },
|
||||||
|
})
|
||||||
|
type Page = { entries: { id: string }[]; nextCursor: string | null }
|
||||||
|
const first = await query({ from })
|
||||||
|
assert.ok(!first.isError)
|
||||||
|
const firstPage = (first.structuredContent as { result: Page }).result
|
||||||
|
assert.equal(firstPage.entries.length, 1)
|
||||||
|
assert.ok(firstPage.nextCursor)
|
||||||
|
const next = await query({ cursor: firstPage.nextCursor })
|
||||||
|
assert.ok(!next.isError)
|
||||||
|
const nextPage = (next.structuredContent as { result: Page }).result
|
||||||
|
assert.equal(nextPage.entries.length, 1)
|
||||||
|
assert.notEqual(nextPage.entries[0].id, firstPage.entries[0].id)
|
||||||
|
assert.equal(nextPage.nextCursor, null)
|
||||||
|
console.log("✓ MCP logs query: historical pagination resumes without resending from")
|
||||||
|
} finally {
|
||||||
|
await pool.query("DELETE FROM router_log_entries WHERE server_id=1 AND message=$1", [marker])
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
bound.abort()
|
||||||
|
pool.query = originalQuery
|
||||||
|
app.server.off("request", observeClose)
|
||||||
|
await client.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -41,7 +41,7 @@ export async function registerManagementChannel(raw: unknown, actor: AuthUser) {
|
|||||||
})
|
})
|
||||||
return { id, serverId: input.serverId, expiresAt: input.expiresAt, verification: "identity-and-reachability", independence: "human-attestation" }
|
return { id, serverId: input.serverId, expiresAt: input.expiresAt, verification: "identity-and-reachability", independence: "human-attestation" }
|
||||||
}
|
}
|
||||||
export async function verifyManagementChannel(serverId: number, requiredId?: string) {
|
async function verifiedManagementChannel(serverId: number, requiredId?: string) {
|
||||||
const { rows: [row] } = await pool.query("SELECT * FROM mcp_management_channels WHERE server_id=$1 AND revoked_at IS NULL AND expires_at>now()", [serverId])
|
const { rows: [row] } = await pool.query("SELECT * FROM mcp_management_channels WHERE server_id=$1 AND revoked_at IS NULL AND expires_at>now()", [serverId])
|
||||||
if (!row || requiredId && row.id !== requiredId || row.generation !== await accessGeneration()) throw new McpError("APPROVAL_REQUIRED", "Нет актуального проверенного независимого канала управления", 409)
|
if (!row || requiredId && row.id !== requiredId || row.generation !== await accessGeneration()) throw new McpError("APPROVAL_REQUIRED", "Нет актуального проверенного независимого канала управления", 409)
|
||||||
const config = JSON.parse(await decryptSecret(row.config_sealed)) as MikrotikConnectParams
|
const config = JSON.parse(await decryptSecret(row.config_sealed)) as MikrotikConnectParams
|
||||||
@@ -50,7 +50,17 @@ export async function verifyManagementChannel(serverId: number, requiredId?: str
|
|||||||
if (hashes.some(value => value !== row.identity_hash)) throw new McpError("STATE_CONFLICT", "Идентичность основного или management-канала изменилась", 409)
|
if (hashes.some(value => value !== row.identity_hash)) throw new McpError("STATE_CONFLICT", "Идентичность основного или management-канала изменилась", 409)
|
||||||
const changed = await pool.query("UPDATE mcp_management_channels SET verified_at=now() WHERE id=$1 AND revoked_at IS NULL AND expires_at>now() RETURNING id", [row.id])
|
const changed = await pool.query("UPDATE mcp_management_channels SET verified_at=now() WHERE id=$1 AND revoked_at IS NULL AND expires_at>now() RETURNING id", [row.id])
|
||||||
if (!changed.rowCount) throw new McpError("APPROVAL_REQUIRED", "Допуск management-канала был отозван во время проверки", 409)
|
if (!changed.rowCount) throw new McpError("APPROVAL_REQUIRED", "Допуск management-канала был отозван во время проверки", 409)
|
||||||
return { channelId: row.id as string, serverId, expiresAt: row.expires_at.toISOString() as string }
|
return { channelId: row.id as string, serverId, expiresAt: row.expires_at.toISOString() as string, host: config.host }
|
||||||
|
}
|
||||||
|
export async function verifyManagementChannel(serverId: number, requiredId?: string) {
|
||||||
|
const { host: _host, ...receipt } = await verifiedManagementChannel(serverId, requiredId)
|
||||||
|
return receipt
|
||||||
|
}
|
||||||
|
/** Internal VLAN preparation uses the verified endpoint to trace its actual
|
||||||
|
* address/interface. It is not accepted from tool input or returned in the
|
||||||
|
* public channel receipt. */
|
||||||
|
export async function verifyBridgeManagementChannel(serverId: number, requiredId?: string) {
|
||||||
|
return verifiedManagementChannel(serverId, requiredId)
|
||||||
}
|
}
|
||||||
export async function managementEvidence(serverIds: number[]) {
|
export async function managementEvidence(serverIds: number[]) {
|
||||||
try { return { requiresHuman: false, managementChannels: await Promise.all([...new Set(serverIds)].map(serverId => verifyManagementChannel(serverId))) } }
|
try { return { requiresHuman: false, managementChannels: await Promise.all([...new Set(serverIds)].map(serverId => verifyManagementChannel(serverId))) } }
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ const creates = rosResources.filter(resource => !resource.readOnly && resource.c
|
|||||||
assert.equal(creates.length, 30)
|
assert.equal(creates.length, 30)
|
||||||
for (const resource of creates) {
|
for (const resource of creates) {
|
||||||
const action = findAction(`mm_${resource.id}_create_plan`)
|
const action = findAction(`mm_${resource.id}_create_plan`)
|
||||||
assert.equal(action.operation.version, ["ospf_template", "ipsec_peer"].includes(resource.id) ? 5 : ["wireguard", "vrf"].includes(resource.id) ? 4 : 3)
|
assert.equal(action.operation.version, ["ospf_template", "ipsec_peer", "vrf"].includes(resource.id) ? 5 : ["wireguard", "bridge", "bridge_port", "bridge_vlan"].includes(resource.id) ? 4 : 3)
|
||||||
assert.equal(action.schema.safeParse({ serverId: 1, fields: {} }).success, true)
|
assert.equal(action.schema.safeParse({ serverId: 1, fields: {} }).success, true)
|
||||||
for (const id of ["*OLD", undefined, null, ""]) {
|
for (const id of ["*OLD", undefined, null, ""]) {
|
||||||
const rejected = action.schema.safeParse({ serverId: 1, fields: {}, id })
|
const rejected = action.schema.safeParse({ serverId: 1, fields: {}, id })
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ await prepareOwnedBandwidthState()
|
|||||||
const { pool, closePool } = await import("../../db/index.js")
|
const { pool, closePool } = await import("../../db/index.js")
|
||||||
try {
|
try {
|
||||||
const { applySqlMigrations } = await import("../../db/migrate.js"); await applySqlMigrations(pool)
|
const { applySqlMigrations } = await import("../../db/migrate.js"); await applySqlMigrations(pool)
|
||||||
|
const { acceptOperationReplayTombstones } = await import("./operation-replay-tombstones.sql-helper.js")
|
||||||
|
await acceptOperationReplayTombstones()
|
||||||
const { acceptOwnedMcpOperationRoot } = await import("./operation-execution-origin.sql-helper.js")
|
const { acceptOwnedMcpOperationRoot } = await import("./operation-execution-origin.sql-helper.js")
|
||||||
console.log("Owned actual MCP operation root:", JSON.stringify(await acceptOwnedMcpOperationRoot()))
|
console.log("Owned actual MCP operation root:", JSON.stringify(await acceptOwnedMcpOperationRoot()))
|
||||||
} finally { await closePool() }
|
} finally { await closePool() }
|
||||||
@@ -9,6 +9,7 @@ import { performance } from "node:perf_hooks"
|
|||||||
import type { QueryConfig, PoolClient } from "pg"
|
import type { QueryConfig, PoolClient } from "pg"
|
||||||
import { mcpTemporaryGrantSchema, type McpObjectRef } from "@mmapp/contracts/mcp"
|
import { mcpTemporaryGrantSchema, type McpObjectRef } from "@mmapp/contracts/mcp"
|
||||||
import { pool } from "../../db/index.js"
|
import { pool } from "../../db/index.js"
|
||||||
|
import { sharedDatabaseGate } from "../../services/shared-database-gate.js"
|
||||||
import { assertNoConfigurationLockContext } from "../../services/config-mutation-lock.js"
|
import { assertNoConfigurationLockContext } from "../../services/config-mutation-lock.js"
|
||||||
import { assertNoOperationConfigurationLockContext } from "../../services/operation-config-lock-lease.js"
|
import { assertNoOperationConfigurationLockContext } from "../../services/operation-config-lock-lease.js"
|
||||||
import { validatePrincipal, audit, type McpPrincipal, type StoredOperation } from "./store.js"
|
import { validatePrincipal, audit, type McpPrincipal, type StoredOperation } from "./store.js"
|
||||||
@@ -200,7 +201,10 @@ async function interrupt(entry: Admission) {
|
|||||||
const stopped = new Promise<never>((_yes, no) => { timeout = setTimeout(() => { retired = true; client?.release(true); client = undefined; no(Error("Operation root cleanup deadline")) }, 2500) })
|
const stopped = new Promise<never>((_yes, no) => { timeout = setTimeout(() => { retired = true; client?.release(true); client = undefined; no(Error("Operation root cleanup deadline")) }, 2500) })
|
||||||
const work = async () => {
|
const work = async () => {
|
||||||
const owned = await connect
|
const owned = await connect
|
||||||
const query = (text: string, values: unknown[] = []) => owned.query({ text, values, query_timeout: Math.max(1, Math.min(1000, deadline - Date.now())) } as QueryConfig)
|
const query = (text: string, values: unknown[] = []) => {
|
||||||
|
if (retired || Date.now() >= deadline || !sharedDatabaseGate.hasActiveAdmission()) throw Error("Late cleanup SQL")
|
||||||
|
return owned.query({ text, values, query_timeout: Math.max(1, Math.min(1000, deadline - Date.now())) } as QueryConfig)
|
||||||
|
}
|
||||||
if ((await query("BEGIN")).command !== "BEGIN") throw Error("No cleanup BEGIN ACK")
|
if ((await query("BEGIN")).command !== "BEGIN") throw Error("No cleanup BEGIN ACK")
|
||||||
const result = await query(`UPDATE public.mcp_operations SET state='unknown',verification='inconclusive',error_code='PROCESS_INTERRUPTED',
|
const result = await query(`UPDATE public.mcp_operations SET state='unknown',verification='inconclusive',error_code='PROCESS_INTERRUPTED',
|
||||||
result=jsonb_build_object('needsReview',true,'automaticRetry',false),finished_at=now() WHERE id=$1 AND plan_id=$2 AND connection_id=$3
|
result=jsonb_build_object('needsReview',true,'automaticRetry',false),finished_at=now() WHERE id=$1 AND plan_id=$2 AND connection_id=$3
|
||||||
@@ -217,11 +221,21 @@ async function interrupt(entry: Admission) {
|
|||||||
try { await Promise.race([work(), stopped]) } catch { console.error("MCP operation root cleanup deferred", { operationId: entry.operationId, retryAllowed: false }) }
|
try { await Promise.race([work(), stopped]) } catch { console.error("MCP operation root cleanup deferred", { operationId: entry.operationId, retryAllowed: false }) }
|
||||||
finally { retired = true; clearTimeout(timeout); if (client) { client.release(!safe); client = undefined } }
|
finally { retired = true; clearTimeout(timeout); if (client) { client.release(!safe); client = undefined } }
|
||||||
}
|
}
|
||||||
|
/** Cleanup SQL owns a live request/exclusive admission. Waiting for repair is
|
||||||
|
* detached from runner slots; only admission is retried, never native work. */
|
||||||
|
async function interruptWithinDatabaseAdmission(entry: Admission): Promise<void> {
|
||||||
|
if (sharedDatabaseGate.hasActiveAdmission()) return sharedDatabaseGate.withinAdmission(() => interrupt(entry))
|
||||||
|
if (!sharedDatabaseGate.isPaused()) return sharedDatabaseGate.request(() => interrupt(entry))
|
||||||
|
void (async () => {
|
||||||
|
while (sharedDatabaseGate.isPaused()) await sharedDatabaseGate.whenOpen()
|
||||||
|
await sharedDatabaseGate.request(() => interrupt(entry))
|
||||||
|
})().catch(() => console.error("MCP operation root cleanup deferred", { operationId: entry.operationId, retryAllowed: false }))
|
||||||
|
}
|
||||||
/** Aborted admission/queue ownership only. A serialized/foreign cap is inert. */
|
/** Aborted admission/queue ownership only. A serialized/foreign cap is inert. */
|
||||||
export async function retireMcpOperationAdmission(admission: unknown): Promise<void> {
|
export async function retireMcpOperationAdmission(admission: unknown): Promise<void> {
|
||||||
const entry = admission && typeof admission === "object" ? admissions.get(admission) : undefined
|
const entry = admission && typeof admission === "object" ? admissions.get(admission) : undefined
|
||||||
if (!entry) return
|
if (!entry) return
|
||||||
admissions.delete(admission as object); await interrupt(entry)
|
admissions.delete(admission as object); await interruptWithinDatabaseAdmission(entry)
|
||||||
}
|
}
|
||||||
/** Called exactly by the ordinary runtime before its first pool checkout/lock.
|
/** Called exactly by the ordinary runtime before its first pool checkout/lock.
|
||||||
* The callback is fixed runner code; callers supply no metadata/authorizer. */
|
* The callback is fixed runner code; callers supply no metadata/authorizer. */
|
||||||
@@ -270,7 +284,7 @@ export async function withMcpOperationExecutionOrigin(admission: unknown, work:
|
|||||||
await roots.run(entry, work)
|
await roots.run(entry, work)
|
||||||
} finally {
|
} finally {
|
||||||
if (entry) { entry.active = false; clearTimeout(entry.timer); entry.controller.abort() }
|
if (entry) { entry.active = false; clearTimeout(entry.timer); entry.controller.abort() }
|
||||||
if (interruptOwn) await interrupt(admitted)
|
if (interruptOwn) await interruptWithinDatabaseAdmission(admitted)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
export async function captureMcpOperationExecutionOrigin(): Promise<McpOperationExecutionOrigin> {
|
export async function captureMcpOperationExecutionOrigin(): Promise<McpOperationExecutionOrigin> {
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import assert from "node:assert/strict"
|
||||||
|
import { randomUUID } from "node:crypto"
|
||||||
|
import { assertOwnedBandwidthSql } from "../../services/bandwidth-admission-process.sql-helper.js"
|
||||||
|
|
||||||
|
export async function acceptOperationReplayTombstones() {
|
||||||
|
assertOwnedBandwidthSql()
|
||||||
|
assert.equal(process.env.MMAPP_OWN_MCP_OPERATION_ORIGIN, "true")
|
||||||
|
const { pool } = await import("../../db/index.js")
|
||||||
|
const { mcpConnectionCreateSchema } = await import("@mmapp/contracts/mcp")
|
||||||
|
const { createMcpConnection, authenticateMcpToken } = await import("./store.js")
|
||||||
|
const { applyPlan, invokeMcpTool } = await import("./runtime.js")
|
||||||
|
const { operationIdempotencyDigest } = await import("./operation-replay-tombstones.js")
|
||||||
|
const { encryptSecret, McpError } = await import("./security.js")
|
||||||
|
const { default: Fastify } = await import("fastify")
|
||||||
|
const { default: authPlugin } = await import("../../plugins/auth.js")
|
||||||
|
const { createMcpRoutes } = await import("../../routes/mcp.js")
|
||||||
|
const { env } = await import("../../config.js")
|
||||||
|
const { SignJWT } = await import("jose")
|
||||||
|
const makeConnection = async () => {
|
||||||
|
const actor = { id: randomUUID(), email: "owned-tombstone@example.test", name: "Owned replay fixture", apps: ["mm"],
|
||||||
|
permissions: ["mm:users:write", "mm:mcp:read"], issuedAt: Date.now()-1000, expiresAt: Date.now()+300000 }
|
||||||
|
const issued = await createMcpConnection(mcpConnectionCreateSchema.parse({ name: actor.name, mode: "approval",
|
||||||
|
permissions: actor.permissions, expiresAt: new Date(actor.expiresAt).toISOString(),
|
||||||
|
scope: { serverIds: [], operationIds: ["users.create", "operations.read", "changes.apply"], allowGlobal: true },
|
||||||
|
limits: { callsPerMinute: 100, mutationsPerHour: 10 } }), actor)
|
||||||
|
return { actor, principal: await authenticateMcpToken(issued.token) }
|
||||||
|
}
|
||||||
|
const app = Fastify()
|
||||||
|
const ids: string[] = []
|
||||||
|
const matches = (code: string, status: number) => (error: unknown) =>
|
||||||
|
error instanceof McpError && error.code === code && error.statusCode === status
|
||||||
|
await pool.query("UPDATE public.mcp_settings SET enabled=true WHERE id=1")
|
||||||
|
try {
|
||||||
|
const owner = await makeConnection(), foreign = await makeConnection()
|
||||||
|
// A real completed plan has already initialized the owned encryption state.
|
||||||
|
// The fixture seeds its tombstone directly, so exercise the same initializer.
|
||||||
|
await encryptSecret("owned replay fixture state")
|
||||||
|
const connectionId = owner.principal.connectionId
|
||||||
|
ids.push(connectionId, foreign.principal.connectionId)
|
||||||
|
const operationId = randomUUID(), planId = randomUUID(), key = "owned-replay-" + randomUUID()
|
||||||
|
await pool.query(`INSERT INTO public.mcp_operation_replay_tombstones
|
||||||
|
(operation_id,plan_id,connection_id,idempotency_digest,action_id,target_server_ids,target_global)
|
||||||
|
VALUES($1,$2,$3,$4,'users.create','{}',true)`, [operationId, planId, connectionId, operationIdempotencyDigest(connectionId,key)])
|
||||||
|
await assert.rejects(applyPlan(owner.principal, planId, key), matches("OPERATION_HISTORY_EXPIRED",410))
|
||||||
|
await assert.rejects(applyPlan(owner.principal, planId, randomUUID()), matches("OPERATION_HISTORY_EXPIRED",410))
|
||||||
|
await assert.rejects(applyPlan(owner.principal, randomUUID(), key), matches("STATE_CONFLICT",409))
|
||||||
|
await assert.rejects(applyPlan(foreign.principal, planId, key), matches("NOT_FOUND",404))
|
||||||
|
await assert.rejects(invokeMcpTool(owner.principal,"mm_change_apply",{ planId,idempotencyKey:key }), matches("OPERATION_HISTORY_EXPIRED",410))
|
||||||
|
await assert.rejects(invokeMcpTool(owner.principal,"mm_operation_get",{ operationId }), matches("OPERATION_HISTORY_EXPIRED",410))
|
||||||
|
await assert.rejects(invokeMcpTool(foreign.principal,"mm_operation_get",{ operationId }), matches("NOT_FOUND",404))
|
||||||
|
await app.register(authPlugin)
|
||||||
|
await app.register(createMcpRoutes(async () => {}))
|
||||||
|
for (const subject of [owner, foreign]) {
|
||||||
|
const token = await new SignJWT({ sub: subject.actor.id, email: subject.actor.email, name: subject.actor.name,
|
||||||
|
apps: subject.actor.apps, permissions: subject.actor.permissions })
|
||||||
|
.setProtectedHeader({ alg:"HS256" }).setIssuer(env.authIssuer).setIssuedAt().setExpirationTime("4m")
|
||||||
|
.sign(new TextEncoder().encode(env.jwtSecret))
|
||||||
|
const reply = await app.inject({ method: "POST", url: "/mcp/plans/"+planId+"/apply", headers: { authorization:"Bearer "+token } })
|
||||||
|
assert.equal(reply.statusCode, subject === owner ? 410 : 404)
|
||||||
|
assert.equal(reply.json().code, subject === owner ? "OPERATION_HISTORY_EXPIRED" : "NOT_FOUND")
|
||||||
|
}
|
||||||
|
await pool.query("UPDATE public.mcp_connections SET policy=jsonb_set(policy,'{scope,allowGlobal}','false') WHERE id=$1", [connectionId])
|
||||||
|
await assert.rejects(applyPlan(owner.principal, planId, key), matches("NOT_FOUND",404))
|
||||||
|
await assert.rejects(invokeMcpTool(owner.principal,"mm_operation_get",{ operationId }), matches("NOT_FOUND",404))
|
||||||
|
const { rows:[proof] } = await pool.query(`SELECT
|
||||||
|
NOT EXISTS(SELECT 1 FROM public.mcp_operations WHERE connection_id=ANY($1::uuid[])) AS no_operation,
|
||||||
|
NOT EXISTS(SELECT 1 FROM public.mcp_budget_buckets WHERE connection_id=ANY($1::uuid[]) AND kind='mutations') AS no_write_budget`, [ids])
|
||||||
|
assert.deepEqual(proof,{no_operation:true,no_write_budget:true})
|
||||||
|
console.log("Owned operation tombstones: scoped MCP and Web 410, replay conflict and no dispatch")
|
||||||
|
} finally {
|
||||||
|
await app.close()
|
||||||
|
await pool.query("DELETE FROM public.mcp_operation_replay_tombstones WHERE connection_id=ANY($1::uuid[])", [ids])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { createHash } from "node:crypto"
|
||||||
|
import type { PoolClient } from "pg"
|
||||||
|
import { hasPermission } from "../../lib/permissions.js"
|
||||||
|
import { listActions } from "./actions.js"
|
||||||
|
import { McpError } from "./security.js"
|
||||||
|
import { effectiveMcpConnection, type ConnectionRow, type McpPrincipal } from "./store.js"
|
||||||
|
|
||||||
|
type Query = Pick<PoolClient, "query">
|
||||||
|
export interface OperationReplayTombstone {
|
||||||
|
operation_id: string
|
||||||
|
plan_id: string
|
||||||
|
connection_id: string
|
||||||
|
action_id: string
|
||||||
|
target_server_ids: string[]
|
||||||
|
target_global: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
// Idempotency tokens are caller input and already stored in plaintext on live
|
||||||
|
// operations. This domain-separated digest avoids copying that plaintext and
|
||||||
|
// survives DB restore and access-key rotation. It is not a keyed secret proof.
|
||||||
|
export function operationIdempotencyDigest(connectionId: string, key: string): string {
|
||||||
|
return createHash("sha256").update("mcp-operation-replay-v1\0").update(connectionId).update("\0").update(key).digest("hex")
|
||||||
|
}
|
||||||
|
|
||||||
|
export function operationHistoryExpired(): never {
|
||||||
|
throw new McpError("OPERATION_HISTORY_EXPIRED", "Срок хранения завершённой операции истёк. Повторное применение запрещено.", 410)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertOperationReplayScope(row: OperationReplayTombstone, connection: ConnectionRow): void {
|
||||||
|
const scope = connection.policy.scope
|
||||||
|
if (row.connection_id !== connection.id || !scope.operationIds.includes(row.action_id)) throw operationReplayNotFound()
|
||||||
|
const action = listActions().find(candidate => candidate.operation.id === row.action_id)
|
||||||
|
if (!action || action.operation.requiredPermissions.some(permission =>
|
||||||
|
!hasPermission(connection.policy.permissions, permission) || !hasPermission(connection.owner_permissions, permission))) throw operationReplayNotFound()
|
||||||
|
if (row.target_global && !scope.allowGlobal || row.target_server_ids.some(id => !scope.serverIds.some(server => String(server) === id))) throw operationReplayNotFound()
|
||||||
|
}
|
||||||
|
|
||||||
|
function operationReplayNotFound(): McpError {
|
||||||
|
return new McpError("NOT_FOUND", "Операция не найдена", 404)
|
||||||
|
}
|
||||||
|
|
||||||
|
const projection = "t.operation_id,t.plan_id,t.connection_id,t.action_id,t.target_server_ids::text[] AS target_server_ids,t.target_global"
|
||||||
|
|
||||||
|
export async function findOperationReplayTombstone(db: Query, connectionId: string, operationId: string) {
|
||||||
|
return (await db.query<OperationReplayTombstone>(`SELECT ${projection} FROM mcp_operation_replay_tombstones t
|
||||||
|
WHERE t.connection_id=$1 AND t.operation_id=$2`, [connectionId, operationId])).rows[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function findOwnerPlanReplayTombstone(db: Query, ownerId: string, planId: string) {
|
||||||
|
return (await db.query<OperationReplayTombstone>(`SELECT ${projection} FROM mcp_operation_replay_tombstones t
|
||||||
|
JOIN mcp_connections c ON c.id=t.connection_id WHERE c.owner_id=$1 AND t.plan_id=$2`, [ownerId, planId])).rows[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function refuseExpiredOperationReplay(db: Query, principal: McpPrincipal, connectionId: string, planId: string, key: string): Promise<void> {
|
||||||
|
const rows = (await db.query<OperationReplayTombstone>(`SELECT ${projection} FROM mcp_operation_replay_tombstones t
|
||||||
|
WHERE t.connection_id=$1 AND (t.plan_id=$2 OR t.idempotency_digest=$3)`,
|
||||||
|
[connectionId, planId, operationIdempotencyDigest(connectionId, key)])).rows
|
||||||
|
if (!rows.length) return
|
||||||
|
const conflict = rows.find(row => row.plan_id !== planId.toLowerCase())
|
||||||
|
const current = await effectiveMcpConnection(principal, db)
|
||||||
|
assertOperationReplayScope(conflict ?? rows[0], current)
|
||||||
|
if (conflict) throw new McpError("STATE_CONFLICT", "Ключ уже используется другим планом", 409)
|
||||||
|
operationHistoryExpired()
|
||||||
|
}
|
||||||
@@ -38,7 +38,7 @@ assert.throws(() => action.schema.parse({ serverId: 1, expectedServerProof: "for
|
|||||||
assert.throws(() => action.schema.parse({ serverId: 1, version: 1 }))
|
assert.throws(() => action.schema.parse({ serverId: 1, version: 1 }))
|
||||||
assert.equal(action.schema.parse({ serverId: 1 }).family, "ipv4")
|
assert.equal(action.schema.parse({ serverId: 1 }).family, "ipv4")
|
||||||
assert.equal(findAction("mm_routing_measurement_plan").operation.version, 2)
|
assert.equal(findAction("mm_routing_measurement_plan").operation.version, 2)
|
||||||
assert.equal(findAction("mm_operation_recovery_plan").operation.version, 7)
|
assert.equal(findAction("mm_operation_recovery_plan").operation.version, 8)
|
||||||
assert.equal(findAction("mm_ospf_template_create_plan").operation.version, 5)
|
assert.equal(findAction("mm_ospf_template_create_plan").operation.version, 5)
|
||||||
assert.equal(findAction("mm_ospf_template_update_plan").operation.version, 4)
|
assert.equal(findAction("mm_ospf_template_update_plan").operation.version, 4)
|
||||||
assert.equal(findAction("mm_ospf_template_delete_plan").operation.version, 3)
|
assert.equal(findAction("mm_ospf_template_delete_plan").operation.version, 3)
|
||||||
@@ -49,11 +49,11 @@ assert.equal(findAction("mm_wireguard_update_plan").operation.version, 3)
|
|||||||
assert.equal(findAction("mm_wireguard_interface_edit_plan").operation.version, 3)
|
assert.equal(findAction("mm_wireguard_interface_edit_plan").operation.version, 3)
|
||||||
assert.equal(findAction("mm_wireguard_interface_create_plan").operation.version, 3)
|
assert.equal(findAction("mm_wireguard_interface_create_plan").operation.version, 3)
|
||||||
assert.equal(findAction("mm_wireguard_create_plan").operation.version, 4)
|
assert.equal(findAction("mm_wireguard_create_plan").operation.version, 4)
|
||||||
assert.equal(findAction("mm_interfaces_update_plan").operation.version, 4)
|
assert.equal(findAction("mm_interfaces_update_plan").operation.version, 5)
|
||||||
assert.equal(findAction("mm_ipsec_peer_delete_plan").operation.version, 3)
|
assert.equal(findAction("mm_ipsec_peer_delete_plan").operation.version, 3)
|
||||||
assert.equal(findAction("mm_ipsec_peer_update_plan").operation.version, 3)
|
assert.equal(findAction("mm_ipsec_peer_update_plan").operation.version, 3)
|
||||||
assert.equal(findAction("mm_certificate_delete_plan").operation.version, 3)
|
assert.equal(findAction("mm_certificate_delete_plan").operation.version, 3)
|
||||||
for (const [tool, version] of [["mm_route_optimizer_plan", 7], ["mm_route_profile_save_plan", 4], ["mm_route_path_verify", 2], ["mm_operation_recovery_plan", 7], ["mm_ecmp_plan", 2], ["mm_rpf_plan", 2], ["mm_vrf_plan", 3], ["mm_firewall_rule_update_plan", 2]] as const) {
|
for (const [tool, version] of [["mm_route_optimizer_plan", 7], ["mm_route_profile_save_plan", 4], ["mm_route_path_verify", 2], ["mm_operation_recovery_plan", 8], ["mm_ecmp_plan", 2], ["mm_rpf_plan", 2], ["mm_vrf_plan", 4], ["mm_firewall_rule_update_plan", 2]] as const) {
|
||||||
const routed = findAction(tool)
|
const routed = findAction(tool)
|
||||||
assert.equal(routed.operation.version, version)
|
assert.equal(routed.operation.version, version)
|
||||||
assert.equal(findMcpOperation(routed.operation.id)?.version, version)
|
assert.equal(findMcpOperation(routed.operation.id)?.version, version)
|
||||||
|
|||||||
@@ -0,0 +1,226 @@
|
|||||||
|
import assert from "node:assert/strict"
|
||||||
|
import { randomUUID } from "node:crypto"
|
||||||
|
import { setTimeout as delay } from "node:timers/promises"
|
||||||
|
import { assertOwnedBandwidthSql,prepareOwnedBandwidthState } from "../../services/bandwidth-admission-process.sql-helper.js"
|
||||||
|
|
||||||
|
assertOwnedBandwidthSql()
|
||||||
|
assert.equal(process.env.MMAPP_OWN_MCP_OPERATION_ORIGIN,"true")
|
||||||
|
await prepareOwnedBandwidthState()
|
||||||
|
const {pool,closePool}=await import("../../db/index.js")
|
||||||
|
const {applySqlMigrations}=await import("../../db/migrate.js")
|
||||||
|
const {mcpConnectionCreateSchema}=await import("@mmapp/contracts/mcp")
|
||||||
|
const {createMcpConnection,authenticateMcpToken}=await import("./store.js")
|
||||||
|
const {invokeMcpTool,approvePlan,applyPlan}=await import("./runtime.js")
|
||||||
|
const {OrdinaryRetirementCommitUnknown,retireOrdinaryMcpOperations}=await import("./ordinary-operation-retirement.js")
|
||||||
|
const {tickMcpWorkers}=await import("./workers.js")
|
||||||
|
const {pauseSchedulerJobsAndWait,resumeSchedulerJobs}=await import("../../services/scheduler-running.js")
|
||||||
|
const {Client}=await import("pg")
|
||||||
|
const {McpError}=await import("./security.js")
|
||||||
|
const {touchGeoipRunMeta}=await import("../../services/geoip-settings.js")
|
||||||
|
const {touchBackupScheduleRunMeta}=await import("../../services/backup-service.js")
|
||||||
|
const errorIs=(code:string,status:number)=>(error:unknown)=>error instanceof McpError&&error.code===code&&error.statusCode===status
|
||||||
|
|
||||||
|
try {
|
||||||
|
await applySqlMigrations(pool)
|
||||||
|
await pauseSchedulerJobsAndWait()
|
||||||
|
await pool.query("UPDATE public.mcp_settings SET enabled=true WHERE id=1")
|
||||||
|
const serverId=Number((await pool.query("INSERT INTO servers(name,host,enabled) VALUES('owned logs retention','127.0.0.1',false) RETURNING id")).rows[0].id)
|
||||||
|
async function access(){
|
||||||
|
const actor={id:randomUUID(),email:"owned-logs-retirement@example.test",name:"Owned settings retention",apps:["mm"],
|
||||||
|
permissions:["mm:settings:admin","mm:mcp:approve","mm:traffic:write","mm:uptime:write","mm:servers:write","mm:network:write","mm:backups:write"],
|
||||||
|
issuedAt:Date.now()-1000,expiresAt:Date.now()+300000}
|
||||||
|
const issued=await createMcpConnection(mcpConnectionCreateSchema.parse({name:actor.name,mode:"approval",
|
||||||
|
permissions:actor.permissions,expiresAt:new Date(actor.expiresAt).toISOString(),
|
||||||
|
scope:{serverIds:[serverId],operationIds:["logs.settings.update","traffic.settings.update","uptime.settings.update",
|
||||||
|
"servers.api-ping.settings.update","internet-path.settings.update","geoip.settings.update","backups.schedule.update","operations.read","changes.apply"],allowGlobal:true},
|
||||||
|
limits:{callsPerMinute:100,mutationsPerHour:20}}),actor)
|
||||||
|
return {actor,principal:await authenticateMcpToken(issued.token)}
|
||||||
|
}
|
||||||
|
const {actor,principal}=await access(),foreign=(await access()).principal
|
||||||
|
async function operation(retentionDays:number,ageMs:number,tool="mm_logs_settings_update_plan",
|
||||||
|
settings:Record<string,unknown>={retentionDays}){
|
||||||
|
const plan=await invokeMcpTool(principal,tool,{settings}) as {planId:string;state:string}
|
||||||
|
if(tool==="mm_geoip_settings_update_plan"||tool==="mm_backup_schedule_update_plan")assert.equal(plan.state,"awaiting-approval")
|
||||||
|
if(plan.state==="awaiting-approval")await approvePlan(plan.planId,actor,true)
|
||||||
|
const key=randomUUID(),applied=await applyPlan(principal,plan.planId,key)
|
||||||
|
let terminal
|
||||||
|
for(const deadline=Date.now()+15000;Date.now()<deadline;){
|
||||||
|
terminal=(await pool.query("SELECT state,verification,error_code FROM mcp_operations WHERE id=$1",[applied.id])).rows[0]
|
||||||
|
if(terminal&&!["pending","running"].includes(terminal.state))break
|
||||||
|
await delay(50)
|
||||||
|
}
|
||||||
|
assert.deepEqual(terminal,{state:"applied",verification:"passed",error_code:null})
|
||||||
|
// Only the disposable fixture ages rows, after real authenticated apply.
|
||||||
|
// No cutoff override or production encryption/approval bypass.
|
||||||
|
const finished=new Date(Date.now()-ageMs),created=new Date(finished.getTime()-1000)
|
||||||
|
await pool.query("UPDATE mcp_plans SET created_at=$2,approved_at=$2,expires_at=$3 WHERE id=$1",
|
||||||
|
[plan.planId,created,new Date(finished.getTime()+1000)])
|
||||||
|
await pool.query("UPDATE mcp_operations SET created_at=$2,finished_at=$3 WHERE id=$1",[applied.id,created,finished])
|
||||||
|
await pool.query("UPDATE mcp_operation_steps SET created_at=$2,finished_at=$3 WHERE operation_id=$1",[applied.id,created,finished])
|
||||||
|
return {id:applied.id,planId:plan.planId,key}
|
||||||
|
}
|
||||||
|
const age=180*86400000
|
||||||
|
const held=await operation(51,age+180000),spent=await operation(52,age+120000)
|
||||||
|
const due=await operation(53,age+60000),young=await operation(54,age-60000)
|
||||||
|
const boundary=await pool.connect()
|
||||||
|
try{
|
||||||
|
await boundary.query("BEGIN")
|
||||||
|
await boundary.query("UPDATE mcp_operations SET finished_at=transaction_timestamp()-interval '180 days' WHERE id=$1",[young.id])
|
||||||
|
await assert.rejects(boundary.query(`INSERT INTO mcp_ordinary_retirement_claims
|
||||||
|
(operation_id,plan_id,connection_id,source_proof,claim_token) VALUES($1,$2,$3,$4,$5)`,
|
||||||
|
[young.id,young.planId,principal.connectionId,"a".repeat(64),randomUUID()]),/cannot be replaced or reopened/)
|
||||||
|
}finally{await boundary.query("ROLLBACK");boundary.release()}
|
||||||
|
await pool.query("INSERT INTO mcp_drift(id,operation_id,expected,observed) VALUES($1,$2,'{}','{}')",[randomUUID(),held.id])
|
||||||
|
const ids=[held.id,spent.id,due.id,young.id]
|
||||||
|
const projection=async()=>(await pool.query(`SELECT i.id,o.state,p.state AS plan_state,s.state AS step_state,
|
||||||
|
k.operation_id AS checkpoint,t.operation_id AS tombstone,c.claim_token
|
||||||
|
FROM unnest($1::uuid[]) i(id)
|
||||||
|
LEFT JOIN mcp_operations o ON o.id=i.id LEFT JOIN mcp_plans p ON p.id=o.plan_id
|
||||||
|
LEFT JOIN mcp_operation_steps s ON s.operation_id=i.id LEFT JOIN mcp_operation_checkpoints k ON k.operation_id=i.id
|
||||||
|
LEFT JOIN mcp_operation_replay_tombstones t ON t.operation_id=i.id
|
||||||
|
LEFT JOIN mcp_ordinary_retirement_claims c ON c.operation_id=i.id ORDER BY i.id`,[ids])).rows
|
||||||
|
const originalQuery=Client.prototype.query
|
||||||
|
let commits=0
|
||||||
|
try{
|
||||||
|
Client.prototype.query=function(this:InstanceType<typeof Client>,...args:unknown[]){
|
||||||
|
if((args[0] as {text?:string})?.text==="COMMIT"&&++commits===3)
|
||||||
|
return Promise.reject(Error("owned ordinary destructive COMMIT transport loss"))
|
||||||
|
return Reflect.apply(originalQuery,this,args)
|
||||||
|
} as typeof Client.prototype.query
|
||||||
|
await assert.rejects(retireOrdinaryMcpOperations(),
|
||||||
|
(error:unknown)=>error instanceof OrdinaryRetirementCommitUnknown&&error.automaticReplay===false)
|
||||||
|
}finally{Client.prototype.query=originalQuery}
|
||||||
|
assert.equal(commits,3)
|
||||||
|
const before=await projection(),spentBefore=before.find(row=>row.id===spent.id)
|
||||||
|
assert.equal(spentBefore?.state,"applied");assert(spentBefore?.claim_token);assert.equal(spentBefore?.tombstone,null)
|
||||||
|
await tickMcpWorkers()
|
||||||
|
const after=await projection()
|
||||||
|
assert.deepEqual(after.filter(row=>row.id!==due.id),before.filter(row=>row.id!==due.id))
|
||||||
|
const retired=after.find(row=>row.id===due.id)
|
||||||
|
assert(retired?.claim_token);assert.equal(retired?.tombstone,due.id)
|
||||||
|
for(const field of ["state","plan_state","step_state","checkpoint"])assert.equal(retired[field],null)
|
||||||
|
assert.equal((await pool.query("SELECT 1 FROM mcp_plans WHERE id=$1",[due.planId])).rows.length,0)
|
||||||
|
assert.equal((await pool.query("SELECT retention_days FROM router_log_settings WHERE id=1")).rows[0].retention_days,54)
|
||||||
|
await tickMcpWorkers()
|
||||||
|
assert.deepEqual(await projection(),after)
|
||||||
|
await assert.rejects(applyPlan(principal,due.planId,due.key),errorIs("OPERATION_HISTORY_EXPIRED",410))
|
||||||
|
await assert.rejects(applyPlan(principal,randomUUID(),due.key),errorIs("STATE_CONFLICT",409))
|
||||||
|
await assert.rejects(invokeMcpTool(foreign,"mm_operation_get",{operationId:due.id}),errorIs("NOT_FOUND",404))
|
||||||
|
const telemetry=[
|
||||||
|
{action:"traffic.settings.update",version:2,record:await operation(55,age+60000,"mm_traffic_settings_update_plan")},
|
||||||
|
{action:"uptime.settings.update",version:2,record:await operation(56,age+60000,"mm_uptime_settings_update_plan",{enabled:false,retentionDays:56})},
|
||||||
|
{action:"servers.api-ping.settings.update",version:2,record:await operation(0,age+60000,"mm_servers_api_ping_settings_update_plan",{intervalSec:123})},
|
||||||
|
{action:"internet-path.settings.update",version:1,record:await operation(57,age+60000,"mm_internet_path_settings_update_plan")},
|
||||||
|
]
|
||||||
|
const telemetryRows=async()=>(await pool.query(`SELECT p.operation_id,p.operation_version,o.id
|
||||||
|
FROM mcp_operations o JOIN mcp_plans p ON p.id=o.plan_id WHERE o.id=ANY($1::uuid[]) ORDER BY p.operation_id`,
|
||||||
|
[telemetry.map(item=>item.record.id)])).rows
|
||||||
|
assert.deepEqual((await telemetryRows()).map(row=>[row.operation_id,row.operation_version]),
|
||||||
|
telemetry.map(item=>[item.action,item.version]).sort((a,b)=>String(a[0]).localeCompare(String(b[0]))))
|
||||||
|
const telemetrySettings=async()=>(await pool.query(`SELECT to_jsonb(t) AS traffic,to_jsonb(u) AS uptime,
|
||||||
|
to_jsonb(a) AS api_ping,to_jsonb(i) AS internet FROM traffic_settings t,uptime_settings u,
|
||||||
|
servers_api_ping_settings a,internet_path_settings i WHERE t.id=1 AND u.id=1 AND a.id=1 AND i.id=1`)).rows[0]
|
||||||
|
const settingsBefore=await telemetrySettings()
|
||||||
|
const traffic=telemetry[0].record
|
||||||
|
const {rows:[originalStep]}=await pool.query("SELECT result FROM mcp_operation_steps WHERE operation_id=$1",[traffic.id])
|
||||||
|
await pool.query("UPDATE mcp_operation_steps SET result=jsonb_set(result,'{retentionDays}','999') WHERE operation_id=$1",[traffic.id])
|
||||||
|
await tickMcpWorkers()
|
||||||
|
assert.equal((await pool.query("SELECT 1 FROM mcp_ordinary_retirement_claims WHERE operation_id=$1",[traffic.id])).rows.length,0)
|
||||||
|
assert.equal((await pool.query("SELECT 1 FROM mcp_operations WHERE id=$1",[traffic.id])).rows.length,1)
|
||||||
|
await pool.query("UPDATE mcp_operation_steps SET result=$2 WHERE operation_id=$1",[traffic.id,originalStep.result])
|
||||||
|
await tickMcpWorkers()
|
||||||
|
await tickMcpWorkers()
|
||||||
|
// The keyset owner may need one tick to wrap after the rejected oldest row.
|
||||||
|
await tickMcpWorkers()
|
||||||
|
assert.deepEqual(await telemetryRows(),[])
|
||||||
|
assert.deepEqual(await telemetrySettings(),settingsBefore)
|
||||||
|
for(const {action,record} of telemetry){
|
||||||
|
const {rows:[proof]}=await pool.query(`SELECT t.action_id,t.target_global,t.target_server_ids::text[] AS server_ids,
|
||||||
|
NOT EXISTS(SELECT 1 FROM mcp_plans WHERE id=$2) AS plan_gone,
|
||||||
|
NOT EXISTS(SELECT 1 FROM mcp_operation_steps WHERE operation_id=$1) AS step_gone,
|
||||||
|
NOT EXISTS(SELECT 1 FROM mcp_operation_checkpoints WHERE operation_id=$1) AS checkpoint_gone
|
||||||
|
FROM mcp_operation_replay_tombstones t WHERE t.operation_id=$1 AND t.plan_id=$2`,[record.id,record.planId])
|
||||||
|
assert.deepEqual(proof,{action_id:action,target_global:true,server_ids:[String(serverId)],plan_gone:true,step_gone:true,checkpoint_gone:true})
|
||||||
|
await assert.rejects(applyPlan(principal,record.planId,record.key),errorIs("OPERATION_HISTORY_EXPIRED",410))
|
||||||
|
await assert.rejects(invokeMcpTool(foreign,"mm_operation_get",{operationId:record.id}),errorIs("NOT_FOUND",404))
|
||||||
|
}
|
||||||
|
// Keep the owned fixture offline even if a product scheduler refresh occurs.
|
||||||
|
await pool.query("UPDATE geoip_settings SET enabled=false WHERE id=1")
|
||||||
|
await touchGeoipRunMeta({lastCheckAt:new Date().toISOString(),lastError:"owned GeoIP metadata",etags:{"owned-test":"unchanged"}})
|
||||||
|
const geoip=await operation(0,age+60000,"mm_geoip_settings_update_plan",{enabled:false,updateIntervalSec:86400})
|
||||||
|
const geoipState=async()=>(await pool.query("SELECT to_jsonb(g) AS row FROM geoip_settings g WHERE id=1")).rows[0].row
|
||||||
|
const geoipBefore=await geoipState()
|
||||||
|
assert.equal(geoipBefore.enabled,false);assert.equal(geoipBefore.update_interval_sec,86400)
|
||||||
|
assert.equal(geoipBefore.last_error,"owned GeoIP metadata");assert.deepEqual(geoipBefore.etags_json,{"owned-test":"unchanged"})
|
||||||
|
const {rows:[geoipStep]}=await pool.query("SELECT result FROM mcp_operation_steps WHERE operation_id=$1",[geoip.id])
|
||||||
|
const assertGeoipUnclaimed=async()=>{
|
||||||
|
assert.equal((await pool.query("SELECT 1 FROM mcp_ordinary_retirement_claims WHERE operation_id=$1",[geoip.id])).rows.length,0)
|
||||||
|
assert.equal((await pool.query("SELECT 1 FROM mcp_operations WHERE id=$1",[geoip.id])).rows.length,1)
|
||||||
|
assert.deepEqual(await geoipState(),geoipBefore)
|
||||||
|
}
|
||||||
|
await pool.query("UPDATE mcp_operation_steps SET result=jsonb_set(result,'{settings,updateIntervalSec}','172800') WHERE operation_id=$1",[geoip.id])
|
||||||
|
await tickMcpWorkers();await tickMcpWorkers();await assertGeoipUnclaimed()
|
||||||
|
await pool.query("UPDATE mcp_operation_steps SET result=$2 WHERE operation_id=$1",[geoip.id,geoipStep.result])
|
||||||
|
const geoipHold=randomUUID()
|
||||||
|
await pool.query("INSERT INTO mcp_drift(id,operation_id,expected,observed) VALUES($1,$2,'{}','{}')",[geoipHold,geoip.id])
|
||||||
|
await tickMcpWorkers();await assertGeoipUnclaimed()
|
||||||
|
await pool.query("DELETE FROM mcp_drift WHERE id=$1",[geoipHold])
|
||||||
|
await tickMcpWorkers();await tickMcpWorkers()
|
||||||
|
const {rows:[geoipTombstone]}=await pool.query(`SELECT t.action_id,t.target_global,t.target_server_ids::text[] AS server_ids,
|
||||||
|
NOT EXISTS(SELECT 1 FROM mcp_operations WHERE id=$1) AS operation_gone,
|
||||||
|
NOT EXISTS(SELECT 1 FROM mcp_plans WHERE id=$2) AS plan_gone,
|
||||||
|
NOT EXISTS(SELECT 1 FROM mcp_operation_steps WHERE operation_id=$1) AS step_gone,
|
||||||
|
NOT EXISTS(SELECT 1 FROM mcp_operation_checkpoints WHERE operation_id=$1) AS checkpoint_gone
|
||||||
|
FROM mcp_operation_replay_tombstones t WHERE t.operation_id=$1 AND t.plan_id=$2`,[geoip.id,geoip.planId])
|
||||||
|
assert.deepEqual(geoipTombstone,{action_id:"geoip.settings.update",target_global:true,server_ids:[String(serverId)],
|
||||||
|
operation_gone:true,plan_gone:true,step_gone:true,checkpoint_gone:true})
|
||||||
|
await assert.rejects(applyPlan(principal,geoip.planId,geoip.key),errorIs("OPERATION_HISTORY_EXPIRED",410))
|
||||||
|
await assert.rejects(applyPlan(principal,randomUUID(),geoip.key),errorIs("STATE_CONFLICT",409))
|
||||||
|
await assert.rejects(invokeMcpTool(foreign,"mm_operation_get",{operationId:geoip.id}),errorIs("NOT_FOUND",404))
|
||||||
|
assert.deepEqual(await geoipState(),geoipBefore)
|
||||||
|
await pool.query("UPDATE backup_schedule_settings SET enabled=false,server_ids_json='[]' WHERE id=1")
|
||||||
|
await touchBackupScheduleRunMeta({lastRunAt:new Date().toISOString(),lastDurationMs:123,lastError:"owned backup scheduler metadata"})
|
||||||
|
await pool.query(`INSERT INTO backup_entries(id,server_id,server_name,filename,size_bytes,kind,notes,created_at)
|
||||||
|
VALUES($1,$2,'owned fixture','owned-retention.rsc',123,'auto','keep',clock_timestamp())`,[randomUUID(),serverId])
|
||||||
|
const backupState=async()=>(await pool.query(`SELECT to_jsonb(s) AS schedule,
|
||||||
|
(SELECT jsonb_agg(to_jsonb(b) ORDER BY id) FROM backup_entries b) AS entries
|
||||||
|
FROM backup_schedule_settings s WHERE id=1`)).rows[0]
|
||||||
|
const backupOutside=await operation(0,age+120000,"mm_backup_schedule_update_plan",{enabled:false,serverIds:[String(serverId+1000)]})
|
||||||
|
const outsideState=await backupState()
|
||||||
|
await tickMcpWorkers();await tickMcpWorkers()
|
||||||
|
assert.equal((await pool.query("SELECT 1 FROM mcp_ordinary_retirement_claims WHERE operation_id=$1",[backupOutside.id])).rows.length,0)
|
||||||
|
assert.equal((await pool.query("SELECT 1 FROM mcp_operations WHERE id=$1",[backupOutside.id])).rows.length,1)
|
||||||
|
assert.deepEqual(await backupState(),outsideState)
|
||||||
|
await pool.query("UPDATE backup_schedule_settings SET server_ids_json='[]' WHERE id=1")
|
||||||
|
const backup=await operation(0,age+60000,"mm_backup_schedule_update_plan",{enabled:false,keepCount:9,serverIds:[String(serverId)]})
|
||||||
|
const backupBefore=await backupState()
|
||||||
|
assert.equal(backupBefore.schedule.enabled,false);assert.equal(backupBefore.schedule.keep_count,9)
|
||||||
|
assert.deepEqual(backupBefore.schedule.server_ids_json,[String(serverId)])
|
||||||
|
assert.equal(backupBefore.schedule.last_duration_ms,123);assert.equal(backupBefore.schedule.last_error,"owned backup scheduler metadata")
|
||||||
|
assert.deepEqual(backupBefore.entries,outsideState.entries)
|
||||||
|
const {rows:[backupStep]}=await pool.query("SELECT result FROM mcp_operation_steps WHERE operation_id=$1",[backup.id])
|
||||||
|
await pool.query("UPDATE mcp_operation_steps SET result=jsonb_set(result,'{keepCount}','10') WHERE operation_id=$1",[backup.id])
|
||||||
|
await tickMcpWorkers();await tickMcpWorkers()
|
||||||
|
assert.equal((await pool.query("SELECT 1 FROM mcp_ordinary_retirement_claims WHERE operation_id=$1",[backup.id])).rows.length,0)
|
||||||
|
assert.deepEqual(await backupState(),backupBefore)
|
||||||
|
await pool.query("UPDATE mcp_operation_steps SET result=$2 WHERE operation_id=$1",[backup.id,backupStep.result])
|
||||||
|
await tickMcpWorkers();await tickMcpWorkers()
|
||||||
|
const {rows:[backupTombstone]}=await pool.query(`SELECT t.action_id,t.target_global,t.target_server_ids::text[] AS server_ids,
|
||||||
|
NOT EXISTS(SELECT 1 FROM mcp_operations WHERE id=$1) AS operation_gone,
|
||||||
|
NOT EXISTS(SELECT 1 FROM mcp_plans WHERE id=$2) AS plan_gone,
|
||||||
|
NOT EXISTS(SELECT 1 FROM mcp_operation_steps WHERE operation_id=$1) AS step_gone,
|
||||||
|
NOT EXISTS(SELECT 1 FROM mcp_operation_checkpoints WHERE operation_id=$1) AS checkpoint_gone
|
||||||
|
FROM mcp_operation_replay_tombstones t WHERE t.operation_id=$1 AND t.plan_id=$2`,[backup.id,backup.planId])
|
||||||
|
assert.deepEqual(backupTombstone,{action_id:"backups.schedule.update",target_global:true,server_ids:[String(serverId)],
|
||||||
|
operation_gone:true,plan_gone:true,step_gone:true,checkpoint_gone:true})
|
||||||
|
await assert.rejects(applyPlan(principal,backup.planId,backup.key),errorIs("OPERATION_HISTORY_EXPIRED",410))
|
||||||
|
await assert.rejects(applyPlan(principal,randomUUID(),backup.key),errorIs("STATE_CONFLICT",409))
|
||||||
|
await assert.rejects(invokeMcpTool(foreign,"mm_operation_get",{operationId:backup.id}),errorIs("NOT_FOUND",404))
|
||||||
|
assert.deepEqual(await backupState(),backupBefore)
|
||||||
|
await pool.query("UPDATE mcp_connections SET policy=jsonb_set(policy,'{scope,serverIds}','[]') WHERE id=$1",[principal.connectionId])
|
||||||
|
await assert.rejects(applyPlan(principal,due.planId,due.key),errorIs("NOT_FOUND",404))
|
||||||
|
for(const {record} of telemetry)await assert.rejects(applyPlan(principal,record.planId,record.key),errorIs("NOT_FOUND",404))
|
||||||
|
await assert.rejects(applyPlan(principal,geoip.planId,geoip.key),errorIs("NOT_FOUND",404))
|
||||||
|
await assert.rejects(applyPlan(principal,backup.planId,backup.key),errorIs("NOT_FOUND",404))
|
||||||
|
console.log("Owned settings operations: seven exact families, authenticated apply, physical 180-day purge, preserved settings and scoped replay")
|
||||||
|
}finally{resumeSchedulerJobs();await closePool()}
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
import { randomUUID } from "node:crypto"
|
||||||
|
import { logSettingsPatchSchema, logSettingsSchema } from "@mmapp/contracts"
|
||||||
|
import { geoipSettingsDtoSchema, geoipSettingsPatchSchema } from "@mmapp/contracts/geoip"
|
||||||
|
import { backupScheduleSettingsDtoSchema, putBackupScheduleSettingsSchema } from "@mmapp/contracts/backups"
|
||||||
|
import { z } from "zod"
|
||||||
|
import { connectProbeStorage } from "../../services/server-probe-storage-connection.js"
|
||||||
|
import type { ProbeStorageSql } from "../../services/server-probe-journal-maintenance.js"
|
||||||
|
import { sharedDatabaseGate } from "../../services/shared-database-gate.js"
|
||||||
|
import { operationIdempotencyDigest } from "./operation-replay-tombstones.js"
|
||||||
|
import { decryptSecretExisting, fingerprint, privateFingerprinterExisting, redact } from "./security.js"
|
||||||
|
|
||||||
|
const uuid = z.string().uuid()
|
||||||
|
const positive = z.number().int().positive().safe()
|
||||||
|
const object = z.record(z.string(),z.unknown())
|
||||||
|
const sealed = z.object({sealed:z.string().min(1).max(262144)}).strict()
|
||||||
|
const ids = z.array(positive).max(1000)
|
||||||
|
const retention = z.number().int().min(1).max(365)
|
||||||
|
const bool = z.boolean()
|
||||||
|
const interval = (min:number,max:number)=>z.number().int().min(min).max(max)
|
||||||
|
const backupServerIds=z.array(z.string().regex(/^[1-9]\d{0,15}$/).refine(value=>Number.isSafeInteger(Number(value))))
|
||||||
|
.max(1000).refine(values=>new Set(values).size===values.length)
|
||||||
|
const backupSchedule=backupScheduleSettingsDtoSchema.extend({serverIds:backupServerIds}).strict()
|
||||||
|
const backupBefore=z.object({value:backupSchedule,serverIds:ids}).strict()
|
||||||
|
// Drizzle's string-mode timestamptz keeps PostgreSQL's space and hour-only
|
||||||
|
// offset. Accept that exact wire form as well as an ISO timestamptz.
|
||||||
|
const timestamp = z.string().regex(/^\d{4}-\d\d-\d\d[ T]\d\d:\d\d:\d\d(?:\.\d{1,6})?(?:Z|[+-]\d\d(?::\d\d)?)$/)
|
||||||
|
.refine(value=>Number.isFinite(Date.parse(value.replace(" ","T").replace(/([+-]\d\d)$/,"$1:00"))))
|
||||||
|
const metadata = {
|
||||||
|
id:z.literal(1),createdAt:timestamp,updatedAt:timestamp,
|
||||||
|
lastCollectedAt:timestamp.nullable(),lastDurationMs:z.number().int().safe().nullable(),
|
||||||
|
lastError:z.string().max(16384).nullable(),
|
||||||
|
}
|
||||||
|
function telemetryFamily(version:number,domain:string,settings:z.ZodObject,hasEpoch:boolean,hasConfigurationProof:boolean){
|
||||||
|
const originalSettings=hasConfigurationProof?settings.extend({configurationProof:z.string().regex(/^[0-9a-f]{64}$/)}):settings
|
||||||
|
const effect=settings.extend({...metadata,...(hasEpoch?{pollConfigurationEpoch:uuid}:{})}).strict()
|
||||||
|
return {version,domain,patch:settings.partial().strict().refine(value=>Object.keys(value).length>0),
|
||||||
|
before:z.object({value:originalSettings.strict(),serverIds:ids}).strict(),effect}
|
||||||
|
}
|
||||||
|
const families={
|
||||||
|
"logs.settings.update":{version:1,domain:null,patch:logSettingsPatchSchema,before:logSettingsSchema.strict(),effect:logSettingsSchema.strict()},
|
||||||
|
"traffic.settings.update":telemetryFamily(2,"traffic-settings",z.object({enabled:bool,intervalSec:interval(5,3600),retentionDays:retention}),true,true),
|
||||||
|
"uptime.settings.update":telemetryFamily(2,"uptime-settings",z.object({enabled:bool,resourcesEnabled:bool,pingEnabled:bool,
|
||||||
|
speedEnabled:bool,intervalSec:interval(5,86400),probeIntervalSec:interval(5,86400),speedIntervalSec:interval(10,86400),retentionDays:retention}),true,true),
|
||||||
|
"servers.api-ping.settings.update":telemetryFamily(2,"servers-api-ping-settings",z.object({enabled:bool,intervalSec:interval(10,86400)}),false,true),
|
||||||
|
"internet-path.settings.update":telemetryFamily(1,"internet-path-settings",z.object({enabled:bool,intervalSec:interval(30,86400),retentionDays:retention}),true,false),
|
||||||
|
"geoip.settings.update":{
|
||||||
|
version:1,domain:"geoip-settings",
|
||||||
|
patch:geoipSettingsPatchSchema.strict().refine(value=>Object.keys(value).length>0),
|
||||||
|
before:z.object({value:z.object({enabled:bool,updateIntervalSec:positive}).strict(),serverIds:ids}).strict(),
|
||||||
|
effect:z.object({settings:geoipSettingsDtoSchema.strict(),verificationScope:z.literal("configuration-persistence")})
|
||||||
|
.strict().transform(result=>result.settings),
|
||||||
|
},
|
||||||
|
"backups.schedule.update":{
|
||||||
|
version:1,domain:"backup-schedule",
|
||||||
|
patch:putBackupScheduleSettingsSchema.extend({serverIds:backupServerIds.optional()}).strict().refine(value=>Object.keys(value).length>0),
|
||||||
|
before:backupBefore,effect:backupSchedule,
|
||||||
|
},
|
||||||
|
} as const
|
||||||
|
const planEvidence = z.object({
|
||||||
|
target:z.object({scope:z.literal("global")}).strict(),
|
||||||
|
objects:z.array(z.object({kind:z.string(),id:z.string(),serverId:positive.optional()}).strict()).max(1001),
|
||||||
|
steps:z.tuple([z.object({kind:z.literal("service"),service:z.string(),input:z.object({settings:object}).strict()}).strict()]),
|
||||||
|
snapshots:z.tuple([]),before:object,warnings:z.array(z.string()).max(8),
|
||||||
|
verificationScope:z.literal("configuration").optional(),
|
||||||
|
requiresHuman:z.literal(true).optional(),
|
||||||
|
}).strict()
|
||||||
|
|
||||||
|
class RetirementRefused extends Error {}
|
||||||
|
export class OrdinaryRetirementCommitUnknown extends Error {
|
||||||
|
readonly automaticReplay = false
|
||||||
|
constructor() { super("Ordinary operation retirement outcome unknown; automatic replay forbidden") }
|
||||||
|
}
|
||||||
|
const requireThat: (value: unknown) => asserts value = value => {
|
||||||
|
if (!value) throw new RetirementRefused()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fixed reviewed settings families without artifact, historic receipt or
|
||||||
|
// inverse-plan consumers. Other action families need separate review.
|
||||||
|
const ELIGIBLE = `FROM public.mcp_operations o
|
||||||
|
JOIN public.mcp_plans p ON p.id=o.plan_id AND p.connection_id=o.connection_id
|
||||||
|
JOIN public.mcp_operation_steps s ON s.operation_id=o.id AND s.step_index=0
|
||||||
|
JOIN public.mcp_operation_checkpoints k ON k.operation_id=o.id AND k.step_index=0
|
||||||
|
WHERE (p.operation_id,p.operation_version) IN (
|
||||||
|
('logs.settings.update',1),('traffic.settings.update',2),('uptime.settings.update',2),
|
||||||
|
('servers.api-ping.settings.update',2),('internet-path.settings.update',1),('geoip.settings.update',1),('backups.schedule.update',1)
|
||||||
|
) AND p.state='completed'
|
||||||
|
AND o.state='applied' AND o.verification='passed' AND o.error_code IS NULL
|
||||||
|
AND isfinite(o.finished_at) AND o.finished_at<transaction_timestamp()-interval '180 days'
|
||||||
|
AND isfinite(p.expires_at) AND p.expires_at<transaction_timestamp()
|
||||||
|
AND s.state='verified' AND s.server_id IS NULL AND isfinite(s.finished_at)
|
||||||
|
AND k.after_sealed IS NOT NULL
|
||||||
|
AND (SELECT count(*) FROM public.mcp_operation_steps WHERE operation_id=o.id)=1
|
||||||
|
AND (SELECT count(*) FROM public.mcp_operation_checkpoints WHERE operation_id=o.id)=1
|
||||||
|
AND NOT EXISTS(SELECT 1 FROM public.mcp_drift WHERE operation_id=o.id)
|
||||||
|
AND NOT EXISTS(SELECT 1 FROM public.mcp_recovery_intents WHERE operation_id=o.id)
|
||||||
|
AND NOT EXISTS(SELECT 1 FROM public.mcp_scoped_recovery_intents WHERE source_operation_id=o.id OR recovery_operation_id=o.id)
|
||||||
|
AND NOT EXISTS(SELECT 1 FROM public.mcp_routing_reservations WHERE source_operation_id=o.id OR source_plan_id=p.id)
|
||||||
|
AND NOT EXISTS(SELECT 1 FROM public.mcp_routing_forward_charges WHERE source_operation_id=o.id OR source_plan_id=p.id)
|
||||||
|
AND NOT EXISTS(SELECT 1 FROM public.mcp_routing_manual_inverse_charges WHERE source_operation_id=o.id OR recovery_operation_id=o.id OR source_plan_id=p.id OR recovery_plan_id=p.id)
|
||||||
|
AND NOT EXISTS(SELECT 1 FROM public.mcp_diagnostic_cancellations WHERE operation_id=o.id)
|
||||||
|
AND NOT EXISTS(SELECT 1 FROM public.server_probe_storage_admissions WHERE identity_kind='mcp' AND execution_id=o.id)
|
||||||
|
AND NOT EXISTS(SELECT 1 FROM public.server_poll_storage_admissions WHERE identity_kind='mcp' AND execution_id=o.id)
|
||||||
|
AND NOT EXISTS(SELECT 1 FROM public.vrf_production_tickets WHERE plan_id=p.id)`
|
||||||
|
const CANDIDATES = `SELECT o.id,o.connection_id,o.finished_at::text AS finished_at ${ELIGIBLE}
|
||||||
|
AND NOT EXISTS(SELECT 1 FROM public.mcp_ordinary_retirement_claims WHERE operation_id=o.id)
|
||||||
|
AND ($1::timestamptz IS NULL OR (o.finished_at,o.id)>($1::timestamptz,$2::uuid))
|
||||||
|
ORDER BY o.finished_at,o.id LIMIT 8`
|
||||||
|
const SOURCE = `SELECT to_jsonb(o) AS operation,to_jsonb(p) AS plan,to_jsonb(s) AS step,to_jsonb(k) AS checkpoint ${ELIGIBLE}
|
||||||
|
AND o.id=$1 AND o.connection_id=$2
|
||||||
|
AND octet_length(to_jsonb(o)::text)+octet_length(to_jsonb(p)::text)+octet_length(to_jsonb(s)::text)+octet_length(to_jsonb(k)::text)<=1048576
|
||||||
|
FOR UPDATE OF o,p,s,k NOWAIT`
|
||||||
|
|
||||||
|
async function transaction<T>(deadlineAt:number, work:(sql:ProbeStorageSql)=>Promise<T>):Promise<T> {
|
||||||
|
const sql = await connectProbeStorage({deadlineAt})
|
||||||
|
let committing=false,committed=false
|
||||||
|
try {
|
||||||
|
await sql.query("BEGIN")
|
||||||
|
await sql.query("SELECT set_config('TimeZone','UTC',true),set_config('statement_timeout','5000ms',true),set_config('lock_timeout','1000ms',true)")
|
||||||
|
const result=await work(sql)
|
||||||
|
committing=true
|
||||||
|
await sql.query("COMMIT")
|
||||||
|
committed=true
|
||||||
|
return result
|
||||||
|
} catch(error) {
|
||||||
|
if(committing)throw new OrdinaryRetirementCommitUnknown()
|
||||||
|
try{await sql.query("ROLLBACK")}catch{/* Destroy this checkout. */}
|
||||||
|
throw error
|
||||||
|
} finally {
|
||||||
|
sql.release(!committed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function prepare(sql:ProbeStorageSql, operationId:string, connectionId:string) {
|
||||||
|
requireThat((await sql.query("SELECT id FROM public.mcp_connections WHERE id=$1 FOR UPDATE NOWAIT",[connectionId])).rows.length===1)
|
||||||
|
requireThat((await sql.query("SELECT pg_try_advisory_xact_lock(hashtextextended($1,0)) AS locked",["mcp-operation:"+operationId])).rows[0]?.locked===true)
|
||||||
|
const rows=(await sql.query(SOURCE,[operationId,connectionId])).rows
|
||||||
|
requireThat(rows.length===1)
|
||||||
|
const raw=rows[0], operation=object.parse(raw.operation), plan=object.parse(raw.plan)
|
||||||
|
const step=object.parse(raw.step),checkpoint=object.parse(raw.checkpoint)
|
||||||
|
const open=async(value:unknown)=>JSON.parse(await decryptSecretExisting(sealed.parse(value).sealed))
|
||||||
|
const original=planEvidence.parse(await open(plan.evidence))
|
||||||
|
const action=z.enum(Object.keys(families) as [keyof typeof families,...(keyof typeof families)[]]).parse(plan.operation_id)
|
||||||
|
const family=families[action],service=original.steps[0]
|
||||||
|
requireThat(plan.operation_version===family.version&&service.service===action)
|
||||||
|
requireThat(fingerprint(await open(plan.payload))===fingerprint(service.input))
|
||||||
|
requireThat(fingerprint(step.intended_state)===fingerprint(redact(service)))
|
||||||
|
const patch=object.parse(family.patch.parse(service.input.settings))
|
||||||
|
const effect=object.parse(family.effect.parse(step.result))
|
||||||
|
requireThat(Object.entries(patch).every(([key,value])=>fingerprint(effect[key])===fingerprint(value)))
|
||||||
|
requireThat(fingerprint(operation.result)===fingerprint({confirmedWrites:1,verificationScope:"configuration"}))
|
||||||
|
for(const value of [checkpoint.before_sealed,checkpoint.after_sealed]){
|
||||||
|
requireThat(typeof value==="string"&&value.length<=1024&&fingerprint(await open({sealed:value}))===fingerprint([]))
|
||||||
|
}
|
||||||
|
const serverObjects=original.objects.filter(item=>item.kind==="server")
|
||||||
|
const serverIds=ids.parse(serverObjects.map(item=>item.serverId))
|
||||||
|
requireThat(new Set(serverIds).size===serverIds.length&&serverObjects.every(item=>item.id===String(item.serverId)))
|
||||||
|
const domainObjects=original.objects.filter(item=>item.kind!=="server")
|
||||||
|
const before=family.before.parse(original.before)
|
||||||
|
if(family.domain===null){
|
||||||
|
requireThat(domainObjects.length===0&&original.requiresHuman===undefined)
|
||||||
|
}else{
|
||||||
|
requireThat(original.requiresHuman===true&&original.verificationScope==="configuration"
|
||||||
|
&&domainObjects.length===1&&domainObjects[0].kind===family.domain&&domainObjects[0].id==="1"&&domainObjects[0].serverId===undefined)
|
||||||
|
requireThat("serverIds" in before&&fingerprint(before.serverIds)===fingerprint(serverIds))
|
||||||
|
}
|
||||||
|
if(action==="backups.schedule.update"){
|
||||||
|
const scope=new Set(serverIds.map(String))
|
||||||
|
requireThat([...backupBefore.parse(before).value.serverIds,...backupSchedule.parse(effect).serverIds].every(id=>scope.has(id)))
|
||||||
|
}
|
||||||
|
const signer=await privateFingerprinterExisting("mcp-ordinary-retirement-source-v1")
|
||||||
|
return {planId:uuid.parse(plan.id),action,serverIds,key:z.string().min(1).max(200).parse(operation.idempotency_key),proof:signer(fingerprint(raw))}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function retire(operationId:string,connectionId:string,deadlineAt:number) {
|
||||||
|
const token=randomUUID()
|
||||||
|
await transaction(deadlineAt,async sql=>{
|
||||||
|
const source=await prepare(sql,operationId,connectionId)
|
||||||
|
const claim=await sql.query(`INSERT INTO public.mcp_ordinary_retirement_claims(operation_id,plan_id,connection_id,source_proof,claim_token)
|
||||||
|
VALUES($1,$2,$3,$4,$5) ON CONFLICT DO NOTHING RETURNING operation_id`,[operationId,source.planId,connectionId,source.proof,token])
|
||||||
|
requireThat(claim.rows.length===1&&claim.rows[0].operation_id===operationId)
|
||||||
|
})
|
||||||
|
await transaction(deadlineAt,async sql=>{
|
||||||
|
const claim=(await sql.query(`SELECT source_proof,plan_id FROM public.mcp_ordinary_retirement_claims
|
||||||
|
WHERE operation_id=$1 AND connection_id=$2 AND claim_token=$3 AND claim_xid<>pg_current_xact_id()`,[operationId,connectionId,token])).rows
|
||||||
|
requireThat(claim.length===1)
|
||||||
|
const source=await prepare(sql,operationId,connectionId)
|
||||||
|
requireThat(claim[0].source_proof===source.proof&&claim[0].plan_id===source.planId)
|
||||||
|
const tombstone=await sql.query(`INSERT INTO public.mcp_operation_replay_tombstones
|
||||||
|
(operation_id,plan_id,connection_id,idempotency_digest,action_id,target_server_ids,target_global)
|
||||||
|
VALUES($1,$2,$3,$4,$5,$6,true) RETURNING operation_id`,
|
||||||
|
[operationId,source.planId,connectionId,operationIdempotencyDigest(connectionId,source.key),source.action,source.serverIds])
|
||||||
|
requireThat(tombstone.rows.length===1&&tombstone.rows[0].operation_id===operationId)
|
||||||
|
const checkpoint=await sql.query("DELETE FROM public.mcp_operation_checkpoints WHERE operation_id=$1 AND step_index=0 RETURNING operation_id",[operationId])
|
||||||
|
const step=await sql.query("DELETE FROM public.mcp_operation_steps WHERE operation_id=$1 AND step_index=0 RETURNING operation_id",[operationId])
|
||||||
|
const operation=await sql.query("DELETE FROM public.mcp_operations WHERE id=$1 AND connection_id=$2 AND plan_id=$3 RETURNING id AS operation_id",[operationId,connectionId,source.planId])
|
||||||
|
const plan=await sql.query("DELETE FROM public.mcp_plans WHERE id=$1 AND connection_id=$2 RETURNING id AS plan_id",[source.planId,connectionId])
|
||||||
|
requireThat([checkpoint,step,operation].every(result=>result.rows.length===1&&result.rows[0].operation_id===operationId)
|
||||||
|
&&plan.rows.length===1&&plan.rows[0].plan_id===source.planId)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
let cursor:{finishedAt:string;operationId:string}|undefined
|
||||||
|
export function retireOrdinaryMcpOperations() {
|
||||||
|
return sharedDatabaseGate.request(async()=>{
|
||||||
|
const deadlineAt=Date.now()+15000
|
||||||
|
const rows=await transaction(deadlineAt,async sql=>(await sql.query(CANDIDATES,[cursor?.finishedAt??null,cursor?.operationId??null])).rows)
|
||||||
|
requireThat(rows.length<=8)
|
||||||
|
let scanned=0,retired=0
|
||||||
|
for(const row of rows){
|
||||||
|
if(Date.now()>=deadlineAt-2000||retired>=2)break
|
||||||
|
const id=uuid.parse(row.id),connectionId=uuid.parse(row.connection_id)
|
||||||
|
requireThat(typeof row.finished_at==="string"&&Number.isFinite(Date.parse(row.finished_at)))
|
||||||
|
cursor={finishedAt:row.finished_at,operationId:id}
|
||||||
|
scanned++
|
||||||
|
try{await retire(id,connectionId,deadlineAt);retired++}
|
||||||
|
catch(error){if(error instanceof OrdinaryRetirementCommitUnknown)throw error}
|
||||||
|
}
|
||||||
|
if(scanned===rows.length&&rows.length<8)cursor=undefined
|
||||||
|
return {scanned,retired,automaticReplay:false as const}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ import assert from "node:assert/strict"
|
|||||||
import { createOspfOwnerSourceGuard, verifyOspfOwnerGraph, verifyOspfOwnerGraphAtPhase } from "./ospf-owner-graph.js"
|
import { createOspfOwnerSourceGuard, verifyOspfOwnerGraph, verifyOspfOwnerGraphAtPhase } from "./ospf-owner-graph.js"
|
||||||
import { fingerprint, McpError, redact } from "./security.js"
|
import { fingerprint, McpError, redact } from "./security.js"
|
||||||
import { createRosPrefix } from "./ros-prefix.js"
|
import { createRosPrefix } from "./ros-prefix.js"
|
||||||
import { ROS_SNAPSHOT_VERSION, type RosSnapshot } from "./resources.js"
|
import { rosSnapshotVersion, type RosSnapshot } from "./resources.js"
|
||||||
|
|
||||||
const ids = [1, 2, 3]
|
const ids = [1, 2, 3]
|
||||||
const graph = { family: "ipv4" as const, sourceSecretProofs: ids.map(serverId => ({ serverId, proof: fingerprint({ serverId }) })), instances: ids.map(serverId => ({ serverId, fingerprint: fingerprint({ serverId, native: "instances" }) })) }
|
const graph = { family: "ipv4" as const, sourceSecretProofs: ids.map(serverId => ({ serverId, proof: fingerprint({ serverId }) })), instances: ids.map(serverId => ({ serverId, fingerprint: fingerprint({ serverId, native: "instances" }) })) }
|
||||||
@@ -54,7 +54,7 @@ const catalogAuthorize = async () => {
|
|||||||
await sourceGuard()
|
await sourceGuard()
|
||||||
}
|
}
|
||||||
const catalogRows = [{ ".id": "*A", name: "keep", disabled: "false" }]
|
const catalogRows = [{ ".id": "*A", name: "keep", disabled: "false" }]
|
||||||
const catalog: RosSnapshot = { serverId: 1, resource: "bridge", rows: catalogRows, fingerprint: fingerprint(catalogRows), projectionVersion: ROS_SNAPSHOT_VERSION, sourceProof: "a".repeat(64) }
|
const catalog: RosSnapshot = { serverId: 1, resource: "bridge", rows: catalogRows, fingerprint: fingerprint(catalogRows), projectionVersion: rosSnapshotVersion("bridge"), sourceProof: "a".repeat(64) }
|
||||||
reads = []
|
reads = []
|
||||||
const catalogPrefix = createRosPrefix([catalog], async (_id, _resource, beforeRead = async () => {}) => {
|
const catalogPrefix = createRosPrefix([catalog], async (_id, _resource, beforeRead = async () => {}) => {
|
||||||
// Match readRosSnapshot's normal three grant/source fences without polling a
|
// Match readRosSnapshot's normal three grant/source fences without polling a
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { z } from "zod"
|
|||||||
export const pathProofPurposeSchema = z.enum(["community", "control-path"])
|
export const pathProofPurposeSchema = z.enum(["community", "control-path"])
|
||||||
export type PathProofPurpose = z.infer<typeof pathProofPurposeSchema>
|
export type PathProofPurpose = z.infer<typeof pathProofPurposeSchema>
|
||||||
export const PATH_PROOF_VERSION = 2
|
export const PATH_PROOF_VERSION = 2
|
||||||
|
export const COMMUNITY_PATH_MAX_AGE_MS = 90000
|
||||||
export const pathProofTag = (purpose: PathProofPurpose) => "path-check:" + purpose + ":v" + PATH_PROOF_VERSION + ":"
|
export const pathProofTag = (purpose: PathProofPurpose) => "path-check:" + purpose + ":v" + PATH_PROOF_VERSION + ":"
|
||||||
export function isCommunityPathEvidence(value: Record<string, unknown>) {
|
export function isCommunityPathEvidence(value: Record<string, unknown>) {
|
||||||
return value.proofPurpose === "community" && value.proofVersion === PATH_PROOF_VERSION
|
return value.proofPurpose === "community" && value.proofVersion === PATH_PROOF_VERSION
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { fingerprint, McpError } from "./security.js"
|
|||||||
import { readRouteCommunityContext, routeContextAccess, type RouteContextOptions } from "./routing-community-context.js"
|
import { readRouteCommunityContext, routeContextAccess, type RouteContextOptions } from "./routing-community-context.js"
|
||||||
import type { add } from "./actions.js"
|
import type { add } from "./actions.js"
|
||||||
import { readControlPathContext } from "./control-path-context.js"
|
import { readControlPathContext } from "./control-path-context.js"
|
||||||
import { isCommunityPathEvidence, PATH_PROOF_VERSION, pathProofPurposeSchema, type PathProofPurpose } from "./path-proof-purpose.js"
|
import { COMMUNITY_PATH_MAX_AGE_MS, isCommunityPathEvidence, PATH_PROOF_VERSION, pathProofPurposeSchema, type PathProofPurpose } from "./path-proof-purpose.js"
|
||||||
|
|
||||||
export function inspectControlPing(rows: Parameters<typeof parsePing>[0]) {
|
export function inspectControlPing(rows: Parameters<typeof parsePing>[0]) {
|
||||||
const parsed = parsePing(rows)
|
const parsed = parsePing(rows)
|
||||||
@@ -107,10 +107,10 @@ export function registerPathActions(register: typeof add) {
|
|||||||
export async function requireRecentPath(profile: RouteAiProfile, candidateId: string, connectionId: string, options: RouteContextOptions = {}) {
|
export async function requireRecentPath(profile: RouteAiProfile, candidateId: string, connectionId: string, options: RouteContextOptions = {}) {
|
||||||
// Compare the database timestamp with the database clock. The signed
|
// Compare the database timestamp with the database clock. The signed
|
||||||
// observedAt below is independently checked against the application clock.
|
// observedAt below is independently checked against the application clock.
|
||||||
const { rows: [row] } = await pool.query("SELECT id,status,created_at,evidence,created_at BETWEEN clock_timestamp()-interval '90 seconds' AND clock_timestamp() AS database_fresh FROM mcp_path_checks WHERE connection_id=$1 AND profile_hash=$2 AND candidate_id=$3 AND evidence->>'proofPurpose'='community' AND evidence->>'proofVersion'='2' ORDER BY created_at DESC,id DESC LIMIT 1", [connectionId, fingerprint(profile), candidateId])
|
const { rows: [row] } = await pool.query("SELECT id,status,created_at,evidence,created_at BETWEEN clock_timestamp()-$4::int * interval '1 millisecond' AND clock_timestamp() AS database_fresh FROM mcp_path_checks WHERE connection_id=$1 AND profile_hash=$2 AND candidate_id=$3 AND evidence->>'proofPurpose'='community' AND evidence->>'proofVersion'='2' ORDER BY created_at DESC,id DESC LIMIT 1", [connectionId, fingerprint(profile), candidateId, COMMUNITY_PATH_MAX_AGE_MS])
|
||||||
if (!row || row.status !== "passed" || row.database_fresh !== true) throw new McpError("MEASUREMENT_UNKNOWN", "Нет свежего подтверждения контрольного пути", 409)
|
if (!row || row.status !== "passed" || row.database_fresh !== true) throw new McpError("MEASUREMENT_UNKNOWN", "Нет свежего подтверждения контрольного пути", 409)
|
||||||
const { proof, ...evidence } = row.evidence ?? {}
|
const { proof, ...evidence } = row.evidence ?? {}
|
||||||
if (!isCommunityPathEvidence(evidence) || evidence.id !== row.id || evidence.connectionId !== connectionId || evidence.profileHash !== fingerprint(profile) || evidence.candidateId !== candidateId || evidence.status !== "passed" || !Array.isArray(evidence.sourceProofs) || !evidence.sourceProofs.length || !Number.isFinite(Date.parse(evidence.observedAt)) || Date.now() - Date.parse(evidence.observedAt) > 90000 || Date.parse(evidence.observedAt) > Date.now()) throw new McpError("MEASUREMENT_UNKNOWN", "Подтверждение пути не связано с Community purpose, источником и контекстом", 409)
|
if (!isCommunityPathEvidence(evidence) || evidence.id !== row.id || evidence.connectionId !== connectionId || evidence.profileHash !== fingerprint(profile) || evidence.candidateId !== candidateId || evidence.status !== "passed" || !Array.isArray(evidence.sourceProofs) || !evidence.sourceProofs.length || !Number.isFinite(Date.parse(evidence.observedAt)) || Date.now() - Date.parse(evidence.observedAt) > COMMUNITY_PATH_MAX_AGE_MS || Date.parse(evidence.observedAt) > Date.now()) throw new McpError("MEASUREMENT_UNKNOWN", "Подтверждение пути не связано с Community purpose, источником и контекстом", 409)
|
||||||
const current = await readRouteCommunityContext(profile, { ...options, selectedCandidateId: candidateId, expectedSources: evidence.sourceProofs })
|
const current = await readRouteCommunityContext(profile, { ...options, selectedCandidateId: candidateId, expectedSources: evidence.sourceProofs })
|
||||||
if (!current.verifyEvidence(evidence, proof)) throw new McpError("MEASUREMENT_UNKNOWN", "Зависимости пути или сохранённое подтверждение изменились", 409)
|
if (!current.verifyEvidence(evidence, proof)) throw new McpError("MEASUREMENT_UNKNOWN", "Зависимости пути или сохранённое подтверждение изменились", 409)
|
||||||
return row.id as string
|
return row.id as string
|
||||||
|
|||||||
@@ -5,6 +5,14 @@ import { preparedPlanExpiry } from "./plan-expiry.js"
|
|||||||
const iso = (milliseconds: number) => new Date(Date.now() + milliseconds).toISOString()
|
const iso = (milliseconds: number) => new Date(Date.now() + milliseconds).toISOString()
|
||||||
const poll = (validUntil?: string) => preparedPlanExpiry("servers.poll", "servers", { validUntil })
|
const poll = (validUntil?: string) => preparedPlanExpiry("servers.poll", "servers", { validUntil })
|
||||||
|
|
||||||
|
test("Community honors sealed validity but keeps the ordinary lifetime without a bound", () => {
|
||||||
|
const bound = iso(90000)
|
||||||
|
assert.equal(preparedPlanExpiry("routing.community.switch", "network", { validUntil: bound })?.toISOString(), bound)
|
||||||
|
assert.equal(preparedPlanExpiry("routing.community.switch", "network", { validUntil: iso(-1000) }), null)
|
||||||
|
const before = Date.now(), manual = preparedPlanExpiry("routing.community.switch", "network", {})!.getTime()
|
||||||
|
assert(manual >= before + 300000 && manual <= Date.now() + 300000)
|
||||||
|
})
|
||||||
|
|
||||||
test("Poll uses its existing sealed five-minute validity as the nominal window", () => {
|
test("Poll uses its existing sealed five-minute validity as the nominal window", () => {
|
||||||
const bound = iso(300000)
|
const bound = iso(300000)
|
||||||
assert.equal(poll(bound)?.toISOString(), bound)
|
assert.equal(poll(bound)?.toISOString(), bound)
|
||||||
|
|||||||
@@ -3,11 +3,13 @@ import type { ChangeEvidence } from "./action-types.js"
|
|||||||
|
|
||||||
/** Internal value calculation only; evidence comes from action.prepare, never
|
/** Internal value calculation only; evidence comes from action.prepare, never
|
||||||
* caller JSON. Generic plans may outlive their preparation access credential.
|
* caller JSON. Generic plans may outlive their preparation access credential.
|
||||||
* A missing result makes the runtime refuse Poll before storing a new plan. */
|
* A missing result refuses a time-bound plan before storage. */
|
||||||
export function preparedPlanExpiry(operationId: string, section: string, evidence: Pick<ChangeEvidence, "validUntil">): Date | null {
|
export function preparedPlanExpiry(operationId: string, section: string, evidence: Pick<ChangeEvidence, "validUntil">): Date | null {
|
||||||
const now = Date.now(), ordinaryExpiry = now + (section === "network" ? 300000 : 900000)
|
const now = Date.now(), ordinaryExpiry = now + (section === "network" ? 300000 : 900000)
|
||||||
if (operationId !== "servers.poll") return new Date(ordinaryExpiry)
|
if (operationId !== "servers.poll" && operationId !== "routing.community.switch") return new Date(ordinaryExpiry)
|
||||||
const validity = z.string().datetime({ offset: true }).safeParse(evidence.validUntil)
|
const validUntil = evidence.validUntil
|
||||||
|
if (operationId === "routing.community.switch" && validUntil === undefined) return new Date(ordinaryExpiry)
|
||||||
|
const validity = z.string().datetime({ offset: true }).safeParse(validUntil)
|
||||||
const bound = validity.success ? Date.parse(validity.data) : NaN
|
const bound = validity.success ? Date.parse(validity.data) : NaN
|
||||||
if (!Number.isFinite(bound) || bound <= now) return null
|
if (!Number.isFinite(bound) || bound <= now) return null
|
||||||
return new Date(Math.min(ordinaryExpiry, bound))
|
return new Date(Math.min(ordinaryExpiry, bound))
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ test("failed/malformed/redirected and stalled protocol probes are bounded and ne
|
|||||||
try {
|
try {
|
||||||
for (const state of ["malformed", "redirect", "large", "announced", "stalled"]) {
|
for (const state of ["malformed", "redirect", "large", "announced", "stalled"]) {
|
||||||
mode = state
|
mode = state
|
||||||
const started = performance.now(), result = await runMcpProtocolProbe(endpoint, "private-readiness-fixture", 300)
|
const started = performance.now(), result = await runMcpProtocolProbe(endpoint, "private-readiness-fixture", { timeoutMs: 300 })
|
||||||
assert.equal(result.status, "failed", state)
|
assert.equal(result.status, "failed", state)
|
||||||
assert.ok(performance.now() - started < 3000, state)
|
assert.ok(performance.now() - started < 3000, state)
|
||||||
assert.ok(!JSON.stringify(result).includes("private-readiness-fixture"))
|
assert.ok(!JSON.stringify(result).includes("private-readiness-fixture"))
|
||||||
@@ -29,3 +29,49 @@ test("failed/malformed/redirected and stalled protocol probes are bounded and ne
|
|||||||
assert.equal(forwarded, 0); assert.ok(requests >= 5)
|
assert.equal(forwarded, 0); assert.ok(requests >= 5)
|
||||||
} finally { server.closeAllConnections(); await new Promise<void>(resolve => server.close(() => resolve())) }
|
} finally { server.closeAllConnections(); await new Promise<void>(resolve => server.close(() => resolve())) }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("capabilities readiness refuses widened scope and checks the tool result", async () => {
|
||||||
|
const capability = { id: "capabilities.read", toolName: "mm_capabilities_get" }
|
||||||
|
const tool = { name: capability.toolName, inputSchema: { type: "object" } }
|
||||||
|
let mode = "extra-tool", calls = 0
|
||||||
|
const server = createServer(async (req, res) => {
|
||||||
|
if (req.method !== "POST") { res.writeHead(405).end(); return }
|
||||||
|
const chunks: Buffer[] = []
|
||||||
|
for await (const chunk of req) chunks.push(chunk)
|
||||||
|
const { id, method, params } = JSON.parse(Buffer.concat(chunks).toString())
|
||||||
|
if (id === undefined) { res.writeHead(202).end(); return }
|
||||||
|
let result: unknown
|
||||||
|
if (method === "initialize") result = { protocolVersion: params.protocolVersion, capabilities: { tools: {} }, serverInfo: { name: "fixture", version: "1.0.0" } }
|
||||||
|
if (method === "tools/list") result = { tools: mode === "extra-tool" ? [tool, { ...tool, name: "mm_servers_list" }] : [tool] }
|
||||||
|
if (method === "tools/call") {
|
||||||
|
calls++
|
||||||
|
assert.equal(params.name, capability.toolName)
|
||||||
|
if (mode === "timeout") { res.writeHead(200, { "content-type": "application/json" }).write("{"); return }
|
||||||
|
const operations = mode === "extra-operation" ? [capability, { id: "servers.list" }] : [capability]
|
||||||
|
result = { content: [], isError: mode === "tool-error", structuredContent: { result: { schemaVersion: 1, operations } } }
|
||||||
|
}
|
||||||
|
res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({ jsonrpc: "2.0", id, result }))
|
||||||
|
})
|
||||||
|
await new Promise<void>(resolve => server.listen(0, "127.0.0.1", resolve))
|
||||||
|
const endpoint = new URL(`http://127.0.0.1:${(server.address() as { port: number }).port}/mcp`)
|
||||||
|
const probe = () => runMcpProtocolProbe(endpoint, "private-readiness-fixture", { timeoutMs: 400, readCapabilities: true })
|
||||||
|
try {
|
||||||
|
const widened = await probe()
|
||||||
|
assert.equal(widened.reason, "SCOPE_NOT_ENFORCED")
|
||||||
|
assert.equal(widened.capabilitiesRead, "skipped")
|
||||||
|
assert.equal(calls, 0)
|
||||||
|
for (const state of ["tool-error", "extra-operation", "timeout"]) {
|
||||||
|
mode = state
|
||||||
|
const result = await probe()
|
||||||
|
assert.equal(result.status, "failed", state)
|
||||||
|
assert.equal(result.capabilitiesRead, "failed", state)
|
||||||
|
assert.equal(result.reason, state === "timeout" ? "TIMEOUT" : "CAPABILITIES_READ_FAILED", state)
|
||||||
|
}
|
||||||
|
mode = "valid"
|
||||||
|
const valid = await probe()
|
||||||
|
assert.equal(valid.status, "passed")
|
||||||
|
assert.equal(valid.capabilitiesRead, "passed")
|
||||||
|
assert.equal(valid.clientCredentialsVerified, false)
|
||||||
|
assert.equal(calls, 4)
|
||||||
|
} finally { server.closeAllConnections(); await new Promise<void>(resolve => server.close(() => resolve())) }
|
||||||
|
})
|
||||||
@@ -9,15 +9,19 @@ export interface ProtocolProbe {
|
|||||||
status: "passed" | "failed" | "skipped";
|
status: "passed" | "failed" | "skipped";
|
||||||
elapsedMs: number;
|
elapsedMs: number;
|
||||||
reason?: string;
|
reason?: string;
|
||||||
credentialType: "temporary-no-operations";
|
credentialType: "temporary-no-operations" | "temporary-capabilities-read";
|
||||||
|
capabilitiesRead: "passed" | "failed" | "skipped";
|
||||||
clientCredentialsVerified: false;
|
clientCredentialsVerified: false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A temporary access credential may negotiate metadata, but its empty grant
|
const capabilitiesOperation = "capabilities.read"
|
||||||
* permits no tools. The ordinary connection credentials are never recovered. */
|
const capabilitiesTool = "mm_capabilities_get"
|
||||||
export async function withMcpProbeCredential<T>(connectionId: string, actor: AuthUser, work: (token: string) => Promise<T>): Promise<T> {
|
|
||||||
|
/** The probe may read its own narrowed capabilities when the connection grants
|
||||||
|
* that operation. The ordinary connection credentials are never recovered. */
|
||||||
|
export async function withMcpProbeCredential<T>(connectionId: string, actor: AuthUser, work: (token: string, readCapabilities: boolean) => Promise<T>): Promise<T> {
|
||||||
const credentialId = randomUUID(), grantId = randomUUID(), token = randomToken("access")
|
const credentialId = randomUUID(), grantId = randomUUID(), token = randomToken("access")
|
||||||
await transaction(async client => {
|
const readCapabilities = await transaction(async client => {
|
||||||
await client.query("SELECT id FROM mcp_connections WHERE id=$1 FOR UPDATE", [connectionId])
|
await client.query("SELECT id FROM mcp_connections WHERE id=$1 FOR UPDATE", [connectionId])
|
||||||
const connection = await getConnection(connectionId, client)
|
const connection = await getConnection(connectionId, client)
|
||||||
assertConnectionVisible(connection, actor)
|
assertConnectionVisible(connection, actor)
|
||||||
@@ -27,14 +31,16 @@ export async function withMcpProbeCredential<T>(connectionId: string, actor: Aut
|
|||||||
const expired = (await client.query("DELETE FROM mcp_credentials WHERE connection_id=$1 AND client_id='mcp-readiness-probe' AND expires_at<=now() RETURNING id", [connectionId])).rows.map(row => row.id)
|
const expired = (await client.query("DELETE FROM mcp_credentials WHERE connection_id=$1 AND client_id='mcp-readiness-probe' AND expires_at<=now() RETURNING id", [connectionId])).rows.map(row => row.id)
|
||||||
if (expired.length) await client.query("DELETE FROM mcp_grants WHERE connection_id=$1 AND payload->>'credentialId'=ANY($2::text[])", [connectionId, expired])
|
if (expired.length) await client.query("DELETE FROM mcp_grants WHERE connection_id=$1 AND payload->>'credentialId'=ANY($2::text[])", [connectionId, expired])
|
||||||
const expiresAt = new Date(Math.min(Date.now() + 20000, Date.parse(connection.policy.expiresAt), connection.owner_valid_until.getTime())).toISOString()
|
const expiresAt = new Date(Math.min(Date.now() + 20000, Date.parse(connection.policy.expiresAt), connection.owner_valid_until.getTime())).toISOString()
|
||||||
const grant = mcpTemporaryGrantSchema.parse({ id: grantId, startsAt: new Date(Date.now() - 1000).toISOString(), expiresAt, scope: { serverIds: [], operationIds: [], allowGlobal: false } })
|
const allowed = connection.policy.scope.operationIds.includes(capabilitiesOperation)
|
||||||
|
const grant = mcpTemporaryGrantSchema.parse({ id: grantId, startsAt: new Date(Date.now() - 1000).toISOString(), expiresAt, scope: { serverIds: [], operationIds: allowed ? [capabilitiesOperation] : [], allowGlobal: false } })
|
||||||
await client.query("INSERT INTO mcp_credentials(id,connection_id,digest,kind,expires_at,audience,client_id) VALUES($1,$2,$3,'access',$4,$5,'mcp-readiness-probe')", [credentialId, connectionId, tokenDigest(token), expiresAt, canonicalMcpUrl().href])
|
await client.query("INSERT INTO mcp_credentials(id,connection_id,digest,kind,expires_at,audience,client_id) VALUES($1,$2,$3,'access',$4,$5,'mcp-readiness-probe')", [credentialId, connectionId, tokenDigest(token), expiresAt, canonicalMcpUrl().href])
|
||||||
await client.query("INSERT INTO mcp_grants(id,connection_id,payload) VALUES($1,$2,$3::jsonb)", [grantId, connectionId, JSON.stringify({ ...grant, credentialId })])
|
await client.query("INSERT INTO mcp_grants(id,connection_id,payload) VALUES($1,$2,$3::jsonb)", [grantId, connectionId, JSON.stringify({ ...grant, credentialId })])
|
||||||
await validatePrincipal({ connectionId, credentialId, credentialExpiresAt: expiresAt }, client)
|
await validatePrincipal({ connectionId, credentialId, credentialExpiresAt: expiresAt }, client)
|
||||||
await audit(client, actor.id, "connection.handshake.start", connectionId, { credentialId, expiresAt })
|
await audit(client, actor.id, "connection.handshake.start", connectionId, { credentialId, expiresAt })
|
||||||
|
return allowed
|
||||||
})
|
})
|
||||||
let completed = false
|
let completed = false
|
||||||
try { const result = await work(token); completed = true; return result }
|
try { const result = await work(token, readCapabilities); completed = true; return result }
|
||||||
finally {
|
finally {
|
||||||
await transaction(async client => {
|
await transaction(async client => {
|
||||||
await client.query("DELETE FROM mcp_grants WHERE id=$1 AND connection_id=$2", [grantId, connectionId])
|
await client.query("DELETE FROM mcp_grants WHERE id=$1 AND connection_id=$2", [grantId, connectionId])
|
||||||
@@ -45,11 +51,12 @@ export async function withMcpProbeCredential<T>(connectionId: string, actor: Aut
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** One deadline covers negotiation, metadata and HTTP response bodies. */
|
/** One deadline covers negotiation, metadata and HTTP response bodies. */
|
||||||
export async function runMcpProtocolProbe(endpoint: URL, token: string, timeoutMs = 8000): Promise<ProtocolProbe> {
|
export async function runMcpProtocolProbe(endpoint: URL, token: string, { timeoutMs = 8000, readCapabilities = false } = {}): Promise<ProtocolProbe> {
|
||||||
const started = performance.now(), controller = new AbortController()
|
const started = performance.now(), controller = new AbortController()
|
||||||
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
||||||
const client = new Client({ name: "MikrotikManager readiness", version: "1.0.0" })
|
const client = new Client({ name: "MikrotikManager readiness", version: "1.0.0" })
|
||||||
const result = (status: ProtocolProbe["status"], reason?: string): ProtocolProbe => ({ status, reason, elapsedMs: Math.round(performance.now() - started), credentialType: "temporary-no-operations", clientCredentialsVerified: false })
|
let capabilitiesRead: ProtocolProbe["capabilitiesRead"] = "skipped"
|
||||||
|
const result = (status: ProtocolProbe["status"], reason?: string): ProtocolProbe => ({ status, reason, elapsedMs: Math.round(performance.now() - started), credentialType: readCapabilities ? "temporary-capabilities-read" : "temporary-no-operations", capabilitiesRead, clientCredentialsVerified: false })
|
||||||
try {
|
try {
|
||||||
const transport = new StreamableHTTPClientTransport(endpoint, { requestInit: { headers: { Authorization: `Bearer ${token}` }, redirect: "error" }, redirectPolicy: "follow",
|
const transport = new StreamableHTTPClientTransport(endpoint, { requestInit: { headers: { Authorization: `Bearer ${token}` }, redirect: "error" }, redirectPolicy: "follow",
|
||||||
fetch: async (url, init) => {
|
fetch: async (url, init) => {
|
||||||
@@ -67,13 +74,22 @@ export async function runMcpProtocolProbe(endpoint: URL, token: string, timeoutM
|
|||||||
} })
|
} })
|
||||||
await client.connect(transport, { timeout: timeoutMs })
|
await client.connect(transport, { timeout: timeoutMs })
|
||||||
const tools = await client.listTools({}, { timeout: timeoutMs })
|
const tools = await client.listTools({}, { timeout: timeoutMs })
|
||||||
if (tools.tools.length !== 0 || tools.nextCursor) return result("failed", "SCOPE_NOT_ENFORCED")
|
const expectedTools = readCapabilities ? [capabilitiesTool] : []
|
||||||
|
if (tools.nextCursor || tools.tools.length !== expectedTools.length || tools.tools.some((tool, index) => tool.name !== expectedTools[index])) return result("failed", "SCOPE_NOT_ENFORCED")
|
||||||
|
if (readCapabilities) {
|
||||||
|
capabilitiesRead = "failed"
|
||||||
|
const call = await client.callTool({ name: capabilitiesTool, arguments: {} }, { timeout: timeoutMs })
|
||||||
|
const output = (call.structuredContent as { result?: { schemaVersion?: unknown; operations?: unknown } } | undefined)?.result
|
||||||
|
const operations = output?.operations
|
||||||
|
if (call.isError || output?.schemaVersion !== 1 || !Array.isArray(operations) || operations.length !== 1 || operations[0]?.id !== capabilitiesOperation || operations[0]?.toolName !== capabilitiesTool) return result("failed", "CAPABILITIES_READ_FAILED")
|
||||||
|
capabilitiesRead = "passed"
|
||||||
|
}
|
||||||
return result("passed")
|
return result("passed")
|
||||||
} catch { return result("failed", controller.signal.aborted ? "TIMEOUT" : "TRANSPORT_FAILED") }
|
} catch { return result("failed", controller.signal.aborted ? "TIMEOUT" : capabilitiesRead === "failed" ? "CAPABILITIES_READ_FAILED" : "TRANSPORT_FAILED") }
|
||||||
finally { clearTimeout(timer); controller.abort(); await client.close().catch(() => {}) }
|
finally { clearTimeout(timer); controller.abort(); await client.close().catch(() => {}) }
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function checkMcpProtocol(connectionId: string, actor: AuthUser) {
|
export async function checkMcpProtocol(connectionId: string, actor: AuthUser) {
|
||||||
const endpoint = canonicalMcpUrl()
|
const endpoint = canonicalMcpUrl()
|
||||||
return withMcpProbeCredential(connectionId, actor, token => runMcpProtocolProbe(endpoint, token))
|
return withMcpProbeCredential(connectionId, actor, (token, readCapabilities) => runMcpProtocolProbe(endpoint, token, { readCapabilities }))
|
||||||
}
|
}
|
||||||
@@ -6,13 +6,14 @@ import { decryptSecret, fingerprint, McpError } from "./security.js"
|
|||||||
import { effectiveMcpConnection } from "./store.js"
|
import { effectiveMcpConnection } from "./store.js"
|
||||||
import { findMcpService } from "./service-registry.js"
|
import { findMcpService } from "./service-registry.js"
|
||||||
import { findMcpOperation } from "./registry.js"
|
import { findMcpOperation } from "./registry.js"
|
||||||
import { readRosSnapshot, resourceById, routerClient, validateRosStep, splitOspfPassiveUpdate, ROS_SNAPSHOT_VERSION, type RosSnapshot, type RosStep } from "./resources.js"
|
import { readRosSnapshot, resourceById, routerClient, validateRosStep, splitOspfPassiveUpdate, rosSnapshotVersion, type RosSnapshot, type RosStep } from "./resources.js"
|
||||||
import { verifyRosStepSnapshots } from "./ros-step-verification.js"
|
import { verifyRosStepSnapshots } from "./ros-step-verification.js"
|
||||||
import type { add } from "./actions.js"
|
import type { add } from "./actions.js"
|
||||||
import type { ActionContext, ChangeEvidence, ChangeStep } from "./action-types.js"
|
import type { ActionContext, ChangeEvidence, ChangeStep } from "./action-types.js"
|
||||||
import { loadScopedRecoverySource } from "./routing-community-scoped-recovery-source.js"
|
import { loadScopedRecoverySource } from "./routing-community-scoped-recovery-source.js"
|
||||||
import { prepareScopedRecovery, registerScopedRecoveryService } from "./routing-community-scoped-recovery.js"
|
import { prepareScopedRecovery, registerScopedRecoveryService } from "./routing-community-scoped-recovery.js"
|
||||||
import { reconcileVrfProductionStep } from "../../services/vrf-production-execution.js"
|
import { reconcileVrfProductionStep } from "../../services/vrf-production-execution.js"
|
||||||
|
import { needsBridgeVlanRecovery, prepareBridgeVlanRecovery } from "./bridge-vlan-recovery.js"
|
||||||
|
|
||||||
async function source(operationId: string, context: ActionContext, allowServices = false) {
|
async function source(operationId: string, context: ActionContext, allowServices = false) {
|
||||||
const { rows: [row] } = await pool.query("SELECT o.*,p.operation_id AS source_action,p.operation_version AS source_version,p.evidence FROM mcp_operations o JOIN mcp_plans p ON p.id=o.plan_id WHERE o.id=$1 AND o.connection_id=$2", [operationId, context.connection.id])
|
const { rows: [row] } = await pool.query("SELECT o.*,p.operation_id AS source_action,p.operation_version AS source_version,p.evidence FROM mcp_operations o JOIN mcp_plans p ON p.id=o.plan_id WHERE o.id=$1 AND o.connection_id=$2", [operationId, context.connection.id])
|
||||||
@@ -86,7 +87,7 @@ export function registerRecoveryActions(register: typeof add) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
register("operations.recover", "mm_operation_recovery_plan", "Подготовить отдельный обратный план сетевой операции", "network", "mm:network:write", "write", "servers", z.object({ operationId: z.string().uuid() }).strict(), {
|
register("operations.recover", "mm_operation_recovery_plan", "Подготовить отдельный обратный план сетевой операции", "network", "mm:network:write", "write", "servers", z.object({ operationId: z.string().uuid() }).strict(), {
|
||||||
version: 7,
|
version: 8,
|
||||||
target: async (input, context) => {
|
target: async (input, context) => {
|
||||||
const { evidence } = await source(String(input.operationId), context, true)
|
const { evidence } = await source(String(input.operationId), context, true)
|
||||||
if (evidence.steps.some(step => step.kind === "ros" && step.step.resource === "vrf")) throw new McpError("UNSUPPORTED_CAPABILITY", "VRF не имеет проверенной обратной ревизии; используйте read-only сверку и отдельный approved план", 409)
|
if (evidence.steps.some(step => step.kind === "ros" && step.step.resource === "vrf")) throw new McpError("UNSUPPORTED_CAPABILITY", "VRF не имеет проверенной обратной ревизии; используйте read-only сверку и отдельный approved план", 409)
|
||||||
@@ -106,6 +107,7 @@ export function registerRecoveryActions(register: typeof add) {
|
|||||||
const checkpoints = (await pool.query("SELECT step_index,before_sealed,after_sealed FROM mcp_operation_checkpoints WHERE operation_id=$1 ORDER BY step_index DESC", [row.id])).rows
|
const checkpoints = (await pool.query("SELECT step_index,before_sealed,after_sealed FROM mcp_operation_checkpoints WHERE operation_id=$1 ORDER BY step_index DESC", [row.id])).rows
|
||||||
const intended = (await pool.query("SELECT step_index,state,result FROM mcp_operation_steps WHERE operation_id=$1 ORDER BY step_index DESC", [row.id])).rows
|
const intended = (await pool.query("SELECT step_index,state,result FROM mcp_operation_steps WHERE operation_id=$1 ORDER BY step_index DESC", [row.id])).rows
|
||||||
if (!intended.length || intended.some(step => step.state !== "verified") || intended.length !== checkpoints.length || checkpoints.some(checkpoint => !checkpoint.after_sealed)) throw new McpError("STATE_CONFLICT", "Нет полного набора подтверждённых снимков; требуется ручная сверка", 409)
|
if (!intended.length || intended.some(step => step.state !== "verified") || intended.length !== checkpoints.length || checkpoints.some(checkpoint => !checkpoint.after_sealed)) throw new McpError("STATE_CONFLICT", "Нет полного набора подтверждённых снимков; требуется ручная сверка", 409)
|
||||||
|
if (needsBridgeVlanRecovery(row.source_action, evidence)) return prepareBridgeVlanRecovery({ operationId: row.id, evidence, checkpoints, intended }, context)
|
||||||
const snapshots = new Map<string, RosSnapshot>(), steps: ChangeStep[] = [], objects: ChangeEvidence["objects"] = [...evidence.objects]
|
const snapshots = new Map<string, RosSnapshot>(), steps: ChangeStep[] = [], objects: ChangeEvidence["objects"] = [...evidence.objects]
|
||||||
for (const checkpoint of checkpoints) {
|
for (const checkpoint of checkpoints) {
|
||||||
const original = evidence.steps[checkpoint.step_index]
|
const original = evidence.steps[checkpoint.step_index]
|
||||||
@@ -113,7 +115,7 @@ export function registerRecoveryActions(register: typeof add) {
|
|||||||
const step = original.step, resource = resourceById(step.resource)
|
const step = original.step, resource = resourceById(step.resource)
|
||||||
if (!context.connection.policy.scope.serverIds.includes(step.serverId)) throw new McpError("SERVER_DENIED", "Зависимость вне допуска")
|
if (!context.connection.policy.scope.serverIds.includes(step.serverId)) throw new McpError("SERVER_DENIED", "Зависимость вне допуска")
|
||||||
const before = JSON.parse(await decryptSecret(checkpoint.before_sealed)) as RosSnapshot, after = JSON.parse(await decryptSecret(checkpoint.after_sealed)) as RosSnapshot
|
const before = JSON.parse(await decryptSecret(checkpoint.before_sealed)) as RosSnapshot, after = JSON.parse(await decryptSecret(checkpoint.after_sealed)) as RosSnapshot
|
||||||
if (before.projectionVersion !== ROS_SNAPSHOT_VERSION || after.projectionVersion !== ROS_SNAPSHOT_VERSION || !before.sourceProof || before.sourceProof !== after.sourceProof || before.fingerprint !== fingerprint(before.rows) || after.fingerprint !== fingerprint(after.rows) || !verifyRosStepSnapshots(step, intended.find(item => item.step_index === checkpoint.step_index)?.result, before, after)) throw new McpError("STATE_CONFLICT", "Исходный переход каталога или источник не подтверждён; обратный план запрещён", 409)
|
if (before.projectionVersion !== rosSnapshotVersion(step.resource) || after.projectionVersion !== rosSnapshotVersion(step.resource) || !before.sourceProof || before.sourceProof !== after.sourceProof || before.fingerprint !== fingerprint(before.rows) || after.fingerprint !== fingerprint(after.rows) || !verifyRosStepSnapshots(step, intended.find(item => item.step_index === checkpoint.step_index)?.result, before, after)) throw new McpError("STATE_CONFLICT", "Исходный переход каталога или источник не подтверждён; обратный план запрещён", 409)
|
||||||
const key = `${step.serverId}:${step.resource}`
|
const key = `${step.serverId}:${step.resource}`
|
||||||
if (!snapshots.has(key)) {
|
if (!snapshots.has(key)) {
|
||||||
const current = await readRosSnapshot(step.serverId, step.resource, undefined, after.sourceProof)
|
const current = await readRosSnapshot(step.serverId, step.resource, undefined, after.sourceProof)
|
||||||
@@ -143,7 +145,7 @@ export function registerRecoveryActions(register: typeof add) {
|
|||||||
const key = `${dependency.serverId}:${dependency.resource}`
|
const key = `${dependency.serverId}:${dependency.resource}`
|
||||||
if (snapshots.has(key)) continue
|
if (snapshots.has(key)) continue
|
||||||
const current = await readRosSnapshot(dependency.serverId, dependency.resource, undefined, dependency.sourceProof)
|
const current = await readRosSnapshot(dependency.serverId, dependency.resource, undefined, dependency.sourceProof)
|
||||||
if (current.fingerprint !== dependency.fingerprint || current.sourceProof !== dependency.sourceProof || dependency.projectionVersion !== ROS_SNAPSHOT_VERSION) throw new McpError("STATE_CONFLICT", "Зависимость или источник исходного плана изменились", 409)
|
if (current.fingerprint !== dependency.fingerprint || current.sourceProof !== dependency.sourceProof || dependency.projectionVersion !== rosSnapshotVersion(dependency.resource)) throw new McpError("STATE_CONFLICT", "Зависимость или источник исходного плана изменились", 409)
|
||||||
snapshots.set(key, current)
|
snapshots.set(key, current)
|
||||||
}
|
}
|
||||||
objects.push(...[...new Set([...snapshots.values()].map(snapshot => snapshot.serverId))].map(serverId => ({ kind: "server", id: String(serverId), serverId })))
|
objects.push(...[...new Set([...snapshots.values()].map(snapshot => snapshot.serverId))].map(serverId => ({ kind: "server", id: String(serverId), serverId })))
|
||||||
|
|||||||
@@ -5,14 +5,19 @@ import { eq } from "drizzle-orm"
|
|||||||
import { db } from "../../db/index.js"
|
import { db } from "../../db/index.js"
|
||||||
import { servers } from "../../db/schema.js"
|
import { servers } from "../../db/schema.js"
|
||||||
import { MikrotikClient } from "../../services/mikrotik.js"
|
import { MikrotikClient } from "../../services/mikrotik.js"
|
||||||
|
import { readRouterOsSnapshotGet } from "../../services/routeros-snapshot-read.js"
|
||||||
import { McpError, fingerprint, privateSecretFingerprint } from "./security.js"
|
import { McpError, fingerprint, privateSecretFingerprint } from "./security.js"
|
||||||
import { readOspfEffectiveDisabled } from "../../services/ospf-effective-disabled.js"
|
import { readOspfEffectiveDisabled } from "../../services/ospf-effective-disabled.js"
|
||||||
import { greNativeFieldsSchema } from "../../services/gre-mutation-input.js"
|
import { greNativeFieldsSchema } from "../../services/gre-mutation-input.js"
|
||||||
import { canonicalVrfMembers } from "../../services/vrf-members.js"
|
import { canonicalVrfMembers } from "../../services/vrf-members.js"
|
||||||
|
import { canonicalBridgeVlanValue } from "../../services/bridge-vlan-values.js"
|
||||||
|
|
||||||
const text = z.string().max(1000)
|
const text = z.string().max(1000)
|
||||||
const name = z.string().trim().min(1).max(100)
|
const name = z.string().trim().min(1).max(100)
|
||||||
const bool = z.boolean()
|
const bool = z.boolean()
|
||||||
|
const vlanIds = z.string().min(1).max(16384).refine(value => {
|
||||||
|
try { canonicalBridgeVlanValue("vlan-ids", value); return true } catch { return false }
|
||||||
|
}, "Требуется непересекающийся список VLAN ID/диапазонов 1..4094")
|
||||||
const integer = (min: number, max: number) => z.number().int().min(min).max(max)
|
const integer = (min: number, max: number) => z.number().int().min(min).max(max)
|
||||||
const common = { name: name.optional(), comment: text.optional(), disabled: bool.optional() }
|
const common = { name: name.optional(), comment: text.optional(), disabled: bool.optional() }
|
||||||
const ipv4 = name.refine(value => isIP(value) === 4, "Требуется IPv4 router/area ID")
|
const ipv4 = name.refine(value => isIP(value) === 4, "Требуется IPv4 router/area ID")
|
||||||
@@ -31,13 +36,15 @@ const route = {
|
|||||||
export interface RosResourceSpec {
|
export interface RosResourceSpec {
|
||||||
id: string; path: string; title: string; permission: string; fields: z.ZodObject;
|
id: string; path: string; title: string; permission: string; fields: z.ZodObject;
|
||||||
create: boolean; remove: boolean; requiredOnCreate?: string[]; readOnly?: boolean
|
create: boolean; remove: boolean; requiredOnCreate?: string[]; readOnly?: boolean
|
||||||
|
/** Native configuration sealed for drift checks, never accepted as writes. */
|
||||||
|
observeFields?: readonly string[]
|
||||||
}
|
}
|
||||||
export const rosResources: RosResourceSpec[] = [
|
export const rosResources: RosResourceSpec[] = [
|
||||||
{ id: "routing_filter", path: "/routing/filter/rule", title: "Фильтры маршрутизации", permission: "mm:filters", fields: z.object({ chain: text.optional(), rule: text.optional(), comment: text.optional(), disabled: bool.optional() }).strict(), create: false, remove: false, readOnly: true },
|
{ id: "routing_filter", path: "/routing/filter/rule", title: "Фильтры маршрутизации", permission: "mm:filters", fields: z.object({ chain: text.optional(), rule: text.optional(), comment: text.optional(), disabled: bool.optional() }).strict(), create: false, remove: false, readOnly: true },
|
||||||
{ id: "ethernet", path: "/interface/ethernet", title: "Ethernet", permission: "mm:network", fields: z.object({ ...common, mtu: integer(68, 65535).optional(), "auto-negotiation": bool.optional() }).strict(), create: false, remove: false },
|
{ id: "ethernet", path: "/interface/ethernet", title: "Ethernet", permission: "mm:network", fields: z.object({ ...common, mtu: integer(68, 65535).optional(), "auto-negotiation": bool.optional() }).strict(), create: false, remove: false },
|
||||||
{ id: "bridge", path: "/interface/bridge", title: "Bridge", permission: "mm:network", fields: z.object({ ...common, mtu: integer(68, 65535).optional(), "vlan-filtering": bool.optional(), pvid: integer(1, 4094).optional(), "protocol-mode": z.enum(["none", "stp", "rstp", "mstp"]).optional() }).strict(), create: true, remove: true, requiredOnCreate: ["name"] },
|
{ id: "bridge", path: "/interface/bridge", title: "Bridge", permission: "mm:network", fields: z.object({ ...common, mtu: integer(68, 65535).optional(), "vlan-filtering": bool.optional(), pvid: integer(1, 4094).optional(), "frame-types": z.enum(["admit-all", "admit-only-vlan-tagged", "admit-only-untagged-and-priority-tagged"]).optional(), "ingress-filtering": bool.optional(), "protocol-mode": z.enum(["none", "stp", "rstp", "mstp"]).optional() }).strict(), observeFields: ["mvrp"], create: true, remove: true, requiredOnCreate: ["name"] },
|
||||||
{ id: "bridge_port", path: "/interface/bridge/port", title: "Порты bridge", permission: "mm:network", fields: z.object({ bridge: name.optional(), interface: name.optional(), pvid: integer(1, 4094).optional(), "ingress-filtering": bool.optional(), "frame-types": z.enum(["admit-all", "admit-only-vlan-tagged", "admit-only-untagged-and-priority-tagged"]).optional(), disabled: bool.optional(), comment: text.optional() }).strict(), create: true, remove: true, requiredOnCreate: ["bridge", "interface"] },
|
{ id: "bridge_port", path: "/interface/bridge/port", title: "Порты bridge", permission: "mm:network", fields: z.object({ bridge: name.optional(), interface: name.optional(), pvid: integer(1, 4094).optional(), "ingress-filtering": bool.optional(), "frame-types": z.enum(["admit-all", "admit-only-vlan-tagged", "admit-only-untagged-and-priority-tagged"]).optional(), disabled: bool.optional(), comment: text.optional() }).strict(), create: true, remove: true, requiredOnCreate: ["bridge", "interface"] },
|
||||||
{ id: "bridge_vlan", path: "/interface/bridge/vlan", title: "Bridge VLAN membership", permission: "mm:network", fields: z.object({ bridge: name.optional(), "vlan-ids": name.optional(), tagged: text.optional(), untagged: text.optional(), disabled: bool.optional(), comment: text.optional() }).strict(), create: true, remove: true, requiredOnCreate: ["bridge", "vlan-ids"] },
|
{ id: "bridge_vlan", path: "/interface/bridge/vlan", title: "Bridge VLAN membership", permission: "mm:network", fields: z.object({ bridge: name.optional(), "vlan-ids": vlanIds.optional(), tagged: text.optional(), untagged: text.optional(), disabled: bool.optional(), comment: text.optional() }).strict(), create: true, remove: true, requiredOnCreate: ["bridge", "vlan-ids"] },
|
||||||
{ id: "vlan", path: "/interface/vlan", title: "VLAN", permission: "mm:network", fields: z.object({ ...common, interface: name.optional(), "vlan-id": integer(1, 4094).optional(), mtu: integer(68, 65535).optional(), "use-service-tag": bool.optional() }).strict(), create: true, remove: true, requiredOnCreate: ["name", "interface", "vlan-id"] },
|
{ id: "vlan", path: "/interface/vlan", title: "VLAN", permission: "mm:network", fields: z.object({ ...common, interface: name.optional(), "vlan-id": integer(1, 4094).optional(), mtu: integer(68, 65535).optional(), "use-service-tag": bool.optional() }).strict(), create: true, remove: true, requiredOnCreate: ["name", "interface", "vlan-id"] },
|
||||||
{ id: "ipv4_address", path: "/ip/address", title: "IPv4-адреса", permission: "mm:network", fields: z.object({ address: name.optional(), interface: name.optional(), network: name.optional(), comment: text.optional(), disabled: bool.optional() }).strict(), create: true, remove: true, requiredOnCreate: ["address", "interface"] },
|
{ id: "ipv4_address", path: "/ip/address", title: "IPv4-адреса", permission: "mm:network", fields: z.object({ address: name.optional(), interface: name.optional(), network: name.optional(), comment: text.optional(), disabled: bool.optional() }).strict(), create: true, remove: true, requiredOnCreate: ["address", "interface"] },
|
||||||
{ id: "ipv6_address", path: "/ipv6/address", title: "IPv6-адреса", permission: "mm:network", fields: z.object({ address: name.optional(), interface: name.optional(), advertise: bool.optional(), comment: text.optional(), disabled: bool.optional() }).strict(), create: true, remove: true, requiredOnCreate: ["address", "interface"] },
|
{ id: "ipv6_address", path: "/ipv6/address", title: "IPv6-адреса", permission: "mm:network", fields: z.object({ address: name.optional(), interface: name.optional(), advertise: bool.optional(), comment: text.optional(), disabled: bool.optional() }).strict(), create: true, remove: true, requiredOnCreate: ["address", "interface"] },
|
||||||
@@ -70,6 +77,7 @@ export const rosResources: RosResourceSpec[] = [
|
|||||||
export type ConfigurationRow = Record<string, unknown>
|
export type ConfigurationRow = Record<string, unknown>
|
||||||
export interface RosStep { serverId: number; resource: string; action: "create" | "update" | "delete"; id?: string; fields: Record<string, string>; placeBeforeId?: string; expectedCost?: number; expectedServerProof?: string }
|
export interface RosStep { serverId: number; resource: string; action: "create" | "update" | "delete"; id?: string; fields: Record<string, string>; placeBeforeId?: string; expectedCost?: number; expectedServerProof?: string }
|
||||||
export const ROS_SNAPSHOT_VERSION = 3
|
export const ROS_SNAPSHOT_VERSION = 3
|
||||||
|
export const rosSnapshotVersion = (resource: string): number => ["bridge", "bridge_port", "bridge_vlan"].includes(resource) ? 4 : ROS_SNAPSHOT_VERSION
|
||||||
export interface RosSnapshot { serverId: number; resource: string; rows: ConfigurationRow[]; fingerprint: string; projectionVersion?: number; sourceProof?: string }
|
export interface RosSnapshot { serverId: number; resource: string; rows: ConfigurationRow[]; fingerprint: string; projectionVersion?: number; sourceProof?: string }
|
||||||
export function resourceById(id: string): RosResourceSpec {
|
export function resourceById(id: string): RosResourceSpec {
|
||||||
const resource = rosResources.find(item => item.id === id)
|
const resource = rosResources.find(item => item.id === id)
|
||||||
@@ -93,13 +101,21 @@ export function configurationRows(resource: RosResourceSpec, payload: unknown):
|
|||||||
// RouterOS creates/removes IPv6 link-local rows with interface runtime state.
|
// RouterOS creates/removes IPv6 link-local rows with interface runtime state.
|
||||||
// They cannot be configured independently and must not invalidate a sealed
|
// They cannot be configured independently and must not invalidate a sealed
|
||||||
// inverse plan when its own GRE is disabled. Explicit addresses remain sealed.
|
// inverse plan when its own GRE is disabled. Explicit addresses remain sealed.
|
||||||
const configuration = rows.filter(row => !(resource.id === "ipv6_address" && [true, "true"].includes((row as ConfigurationRow).dynamic as boolean | string) && /^fe80:/i.test(String((row as ConfigurationRow).address))))
|
const configuration = rows.filter(row => !(resource.id === "ipv6_address" && [true, "true"].includes((row as ConfigurationRow).dynamic as boolean | string) && /^fe80:/i.test(String((row as ConfigurationRow).address)))
|
||||||
|
// These rows are generated from bridge/port PVID, not independently
|
||||||
|
// configurable VLAN entries. Their inputs remain sealed in bridge/ports.
|
||||||
|
&& !(resource.id === "bridge_vlan" && [true, "true", "yes"].includes((row as ConfigurationRow).dynamic as boolean | string)))
|
||||||
const selectedRows = configuration.map(row => {
|
const selectedRows = configuration.map(row => {
|
||||||
const selected = Object.fromEntries(Object.entries(row as ConfigurationRow).filter(([key]) => [".id", "dynamic", "builtin", "default", ...fields].includes(key)))
|
const selected = Object.fromEntries(Object.entries(row as ConfigurationRow).filter(([key]) => [".id", "dynamic", "builtin", "default", ...fields, ...(resource.observeFields ?? [])].includes(key)))
|
||||||
// RouterOS 7.24.5 returns an explicit empty fib flag for non-FIB tables.
|
// RouterOS 7.24.5 returns an explicit empty fib flag for non-FIB tables.
|
||||||
// Normalize only this observed field; missing values must remain unknown.
|
// Normalize only this observed field; missing values must remain unknown.
|
||||||
if (resource.id === "routing_table" && selected.fib === "") selected.fib = "false"
|
if (resource.id === "routing_table" && selected.fib === "") selected.fib = "false"
|
||||||
if (resource.id === "vrf" && selected.interfaces !== undefined) selected.interfaces = canonicalVrfMembers(selected.interfaces)
|
if (resource.id === "vrf" && selected.interfaces !== undefined) selected.interfaces = canonicalVrfMembers(selected.interfaces)
|
||||||
|
if (["bridge_port", "bridge_vlan"].includes(resource.id) && selected.comment === undefined) selected.comment = ""
|
||||||
|
if (resource.id === "bridge_vlan") {
|
||||||
|
try { for (const field of ["vlan-ids", "tagged", "untagged"]) selected[field] = canonicalBridgeVlanValue(field, selected[field]) }
|
||||||
|
catch { throw new McpError("STATE_CONFLICT", "Недостоверная конфигурация VLAN membership", 409) }
|
||||||
|
}
|
||||||
return selected
|
return selected
|
||||||
})
|
})
|
||||||
// Reordering an ordered catalog changes configuration even with unchanged rows.
|
// Reordering an ordered catalog changes configuration even with unchanged rows.
|
||||||
@@ -118,10 +134,10 @@ export async function readRosSnapshot(serverId: number, resourceId: string, befo
|
|||||||
const payload = resourceId === "ospf_template" ? await client.getOspfInterfaceTemplates(checkSource) : ["ospf_instance", "ospf_area", "bgp_instance", "bgp_connection"].includes(resourceId) ? await readOspfEffectiveDisabled(await client.get<unknown>(resource.path), async id => {
|
const payload = resourceId === "ospf_template" ? await client.getOspfInterfaceTemplates(checkSource) : ["ospf_instance", "ospf_area", "bgp_instance", "bgp_connection"].includes(resourceId) ? await readOspfEffectiveDisabled(await client.get<unknown>(resource.path), async id => {
|
||||||
await checkSource()
|
await checkSource()
|
||||||
return client.post(resource.path + "/get", { ".id": id, "value-name": "disabled" })
|
return client.post(resource.path + "/get", { ".id": id, "value-name": "disabled" })
|
||||||
}) : await client.get<unknown>(resource.path)
|
}) : await readRouterOsSnapshotGet<unknown>({ client, path: resource.path, assertCurrent: checkSource })
|
||||||
await checkSource()
|
await checkSource()
|
||||||
const rows = configurationRows(resource, payload)
|
const rows = configurationRows(resource, payload)
|
||||||
return { serverId, resource: resourceId, rows, fingerprint: fingerprint(rows), projectionVersion: ROS_SNAPSHOT_VERSION, sourceProof }
|
return { serverId, resource: resourceId, rows, fingerprint: fingerprint(rows), projectionVersion: rosSnapshotVersion(resourceId), sourceProof }
|
||||||
}
|
}
|
||||||
export async function rosSnapshotSourceProof(server: Awaited<ReturnType<typeof routerClient>>["server"]): Promise<string> {
|
export async function rosSnapshotSourceProof(server: Awaited<ReturnType<typeof routerClient>>["server"]): Promise<string> {
|
||||||
return privateSecretFingerprint(fingerprint({ id: server.id, enabled: server.enabled, host: server.host, port: server.port, useSsl: server.useSsl, verifySsl: server.verifySsl, username: server.username, password: server.password, epoch: server.routingEndpointEpoch }), "mcp-ros-snapshot-source-v3")
|
return privateSecretFingerprint(fingerprint({ id: server.id, enabled: server.enabled, host: server.host, port: server.port, useSsl: server.useSsl, verifySsl: server.verifySsl, username: server.username, password: server.password, epoch: server.routingEndpointEpoch }), "mcp-ros-snapshot-source-v3")
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { fingerprint, McpError } from "./security.js"
|
import { fingerprint, McpError } from "./security.js"
|
||||||
import { readRosSnapshot, ROS_SNAPSHOT_VERSION, type RosSnapshot, type RosStep } from "./resources.js"
|
import { readRosSnapshot, rosSnapshotVersion, type RosSnapshot, type RosStep } from "./resources.js"
|
||||||
import { verifyRosStepSnapshots } from "./ros-step-verification.js"
|
import { verifyRosStepSnapshots } from "./ros-step-verification.js"
|
||||||
import { acknowledgedCreateId } from "./native-create-identity.js"
|
import { acknowledgedCreateId } from "./native-create-identity.js"
|
||||||
|
|
||||||
const key = (snapshot: Pick<RosSnapshot, "serverId" | "resource">) => `${snapshot.serverId}:${snapshot.resource}`
|
const key = (snapshot: Pick<RosSnapshot, "serverId" | "resource">) => `${snapshot.serverId}:${snapshot.resource}`
|
||||||
function valid(snapshot: RosSnapshot): boolean {
|
function valid(snapshot: RosSnapshot): boolean {
|
||||||
if (snapshot.projectionVersion !== ROS_SNAPSHOT_VERSION || typeof snapshot.sourceProof !== "string" || !/^[a-f0-9]{64}$/.test(snapshot.sourceProof) || !Array.isArray(snapshot.rows) || snapshot.fingerprint !== fingerprint(snapshot.rows)) return false
|
if (snapshot.projectionVersion !== rosSnapshotVersion(snapshot.resource) || typeof snapshot.sourceProof !== "string" || !/^[a-f0-9]{64}$/.test(snapshot.sourceProof) || !Array.isArray(snapshot.rows) || snapshot.fingerprint !== fingerprint(snapshot.rows)) return false
|
||||||
if (["ip_settings", "ipv6_settings"].includes(snapshot.resource)) return snapshot.rows.length === 1 && !!snapshot.rows[0] && typeof snapshot.rows[0] === "object" && !Array.isArray(snapshot.rows[0])
|
if (["ip_settings", "ipv6_settings"].includes(snapshot.resource)) return snapshot.rows.length === 1 && !!snapshot.rows[0] && typeof snapshot.rows[0] === "object" && !Array.isArray(snapshot.rows[0])
|
||||||
const ids = snapshot.rows.map(acknowledgedCreateId)
|
const ids = snapshot.rows.map(acknowledgedCreateId)
|
||||||
return ids.every(id => id !== undefined) && new Set(ids).size === ids.length
|
return ids.every(id => id !== undefined) && new Set(ids).size === ids.length
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { isDeepStrictEqual } from "node:util"
|
import { isDeepStrictEqual } from "node:util"
|
||||||
import { acknowledgedCreateId } from "./native-create-identity.js"
|
import { acknowledgedCreateId } from "./native-create-identity.js"
|
||||||
|
import { canonicalBridgeVlanValue } from "../../services/bridge-vlan-values.js"
|
||||||
import { isOrderedRosResource, resourceById, type ConfigurationRow, type RosResourceSpec, type RosSnapshot, type RosStep } from "./resources.js"
|
import { isOrderedRosResource, resourceById, type ConfigurationRow, type RosResourceSpec, type RosSnapshot, type RosStep } from "./resources.js"
|
||||||
|
|
||||||
function booleanValue(value: unknown): unknown {
|
function booleanValue(value: unknown): unknown {
|
||||||
@@ -23,6 +24,9 @@ function requested(resource: RosResourceSpec, fields: RosStep["fields"], row: Co
|
|||||||
const schema = resource.fields.shape[key]
|
const schema = resource.fields.shape[key]
|
||||||
if (!schema || typeof value !== "string" || !Object.hasOwn(row, key) || row[key] === undefined || row[key] === null) return false
|
if (!schema || typeof value !== "string" || !Object.hasOwn(row, key) || row[key] === undefined || row[key] === null) return false
|
||||||
if (schema.safeParse(true).success) return ["true", "false"].includes(value) && booleanValue(row[key]) === (value === "true")
|
if (schema.safeParse(true).success) return ["true", "false"].includes(value) && booleanValue(row[key]) === (value === "true")
|
||||||
|
if (resource.id === "bridge_vlan" && ["vlan-ids", "tagged", "untagged"].includes(key)) {
|
||||||
|
try { return canonicalBridgeVlanValue(key, row[key]) === canonicalBridgeVlanValue(key, value) } catch { return false }
|
||||||
|
}
|
||||||
return String(row[key]) === value
|
return String(row[key]) === value
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { isConfirmedRoutingFibTable } from "../../services/routing-table-fib.js"
|
||||||
|
import { prepareOriginalEffectSelection } from "./routing-original-effects.js"
|
||||||
import type { RouteAiProfile } from "@mmapp/contracts/mcp-routing"
|
import type { RouteAiProfile } from "@mmapp/contracts/mcp-routing"
|
||||||
import { pool } from "../../db/index.js"
|
import { pool } from "../../db/index.js"
|
||||||
import { nativeBgpRows, bgpTemplateReferences, type NativeBgpRow } from "../../services/bgp-export-model.js"
|
import { nativeBgpRows, bgpTemplateReferences, type NativeBgpRow } from "../../services/bgp-export-model.js"
|
||||||
@@ -96,7 +98,7 @@ async function readContext(profile: RouteAiProfile, options: RouteContextOptions
|
|||||||
}
|
}
|
||||||
const sourceId = profile.serverId, tables = await get(sourceId, "/routing/table"), vrfs = await get(sourceId, "/ip/vrf")
|
const sourceId = profile.serverId, tables = await get(sourceId, "/routing/table"), vrfs = await get(sourceId, "/ip/vrf")
|
||||||
const table = unique(tables, profile.table)
|
const table = unique(tables, profile.table)
|
||||||
if (![true, "true", "yes"].includes(table.fib as string | boolean) || [true, "true", "yes"].includes(table.disabled as string | boolean) || [true, "true", "yes"].includes(table.invalid as string | boolean)) throw new McpError("STATE_UNAVAILABLE", "FIB таблицы назначения не подтверждён", 409)
|
if (!isConfirmedRoutingFibTable(table)) throw new McpError("STATE_UNAVAILABLE", "FIB таблицы назначения не подтверждён", 409)
|
||||||
await enabled(sourceId, "/ip/vrf", unique(vrfs, profile.probeVrf))
|
await enabled(sourceId, "/ip/vrf", unique(vrfs, profile.probeVrf))
|
||||||
// Keep ordered VRF membership (first matching row wins), never equate an
|
// Keep ordered VRF membership (first matching row wins), never equate an
|
||||||
// arbitrary policy table with the diagnostic VRF's same-name table.
|
// arbitrary policy table with the diagnostic VRF's same-name table.
|
||||||
@@ -140,7 +142,7 @@ async function readContext(profile: RouteAiProfile, options: RouteContextOptions
|
|||||||
for (const property of ["vrf", "routing-table"]) if (!values[property] && instance) values[property] = bgpFilterName(await post(sourceId, "/routing/bgp/instance/get", { ".id": instance[".id"], "value-name": property }))
|
for (const property of ["vrf", "routing-table"]) if (!values[property] && instance) values[property] = bgpFilterName(await post(sourceId, "/routing/bgp/instance/get", { ".id": instance[".id"], "value-name": property }))
|
||||||
if (!values.vrf || !values["routing-table"] || !values.afi || values["input.filter"] === null) throw new McpError("STATE_UNAVAILABLE", "Не подтверждены effective BGP table/VRF/AFI/input binding", 409)
|
if (!values.vrf || !values["routing-table"] || !values.afi || values["input.filter"] === null) throw new McpError("STATE_UNAVAILABLE", "Не подтверждены effective BGP table/VRF/AFI/input binding", 409)
|
||||||
const boundTable = unique(tables, values["routing-table"])
|
const boundTable = unique(tables, values["routing-table"])
|
||||||
if (![true, "true", "yes"].includes(boundTable.fib as string | boolean) || [true, "true", "yes"].includes(boundTable.disabled as string | boolean) || [true, "true", "yes"].includes(boundTable.invalid as string | boolean)) throw new McpError("STATE_UNAVAILABLE", "FIB BGP таблицы не подтверждён", 409)
|
if (!isConfirmedRoutingFibTable(boundTable)) throw new McpError("STATE_UNAVAILABLE", "FIB BGP таблицы не подтверждён", 409)
|
||||||
await enabled(sourceId, "/ip/vrf", unique(vrfs, values.vrf))
|
await enabled(sourceId, "/ip/vrf", unique(vrfs, values.vrf))
|
||||||
bindings.push({ connectionId: connection[".id"], instance: instance ? String(instance.name) : null, vrf: values.vrf, table: values["routing-table"]!, afi: values.afi, inputChain: values["input.filter"]!, outputChain: values["output.filter-chain"] ?? "" })
|
bindings.push({ connectionId: connection[".id"], instance: instance ? String(instance.name) : null, vrf: values.vrf, table: values["routing-table"]!, afi: values.afi, inputChain: values["input.filter"]!, outputChain: values["output.filter-chain"] ?? "" })
|
||||||
}
|
}
|
||||||
@@ -177,7 +179,7 @@ async function readContext(profile: RouteAiProfile, options: RouteContextOptions
|
|||||||
let gateway: ReturnType<typeof routeGatewayContext>
|
let gateway: ReturnType<typeof routeGatewayContext>
|
||||||
try { gateway = routeGatewayContext(candidate.gateway) } catch { throw new McpError("STATE_UNAVAILABLE", "Gateway кандидата не подтверждён", 409) }
|
try { gateway = routeGatewayContext(candidate.gateway) } catch { throw new McpError("STATE_UNAVAILABLE", "Gateway кандидата не подтверждён", 409) }
|
||||||
const resolver = unique(tables, gateway.resolverTable)
|
const resolver = unique(tables, gateway.resolverTable)
|
||||||
if (![true, "true", "yes"].includes(resolver.fib as string | boolean) || [true, "true", "yes"].includes(resolver.disabled as string | boolean) || [true, "true", "yes"].includes(resolver.invalid as string | boolean)) throw new McpError("STATE_UNAVAILABLE", "FIB resolver таблицы не подтверждён", 409)
|
if (!isConfirmedRoutingFibTable(resolver)) throw new McpError("STATE_UNAVAILABLE", "FIB resolver таблицы не подтверждён", 409)
|
||||||
if (unavailableIds.includes(candidate.jumpHostId) || unavailableIds.includes(candidate.exitNodeId)) throw new McpError("DEVICE_UNAVAILABLE", "Источник кандидата отсутствует или отключён", 409)
|
if (unavailableIds.includes(candidate.jumpHostId) || unavailableIds.includes(candidate.exitNodeId)) throw new McpError("DEVICE_UNAVAILABLE", "Источник кандидата отсутствует или отключён", 409)
|
||||||
} catch (error) { unavailableCandidate(candidate.id, error) }
|
} catch (error) { unavailableCandidate(candidate.id, error) }
|
||||||
}
|
}
|
||||||
@@ -250,6 +252,14 @@ async function readContext(profile: RouteAiProfile, options: RouteContextOptions
|
|||||||
nativeRuleId: selected && current ? filters.find(row => row.chain === selected.inputChain)![".id"] : null, ownedCompilerRecognized: !!current,
|
nativeRuleId: selected && current ? filters.find(row => row.chain === selected.inputChain)![".id"] : null, ownedCompilerRecognized: !!current,
|
||||||
sharedConnectionIds, bindings, disabledConnectionIds: disabledBindings.map(binding => binding.connectionId), externalReferences, executionAvailable: reason === null, executionReason: reason, scopedExecutionAvailable: scopedReason === null, scopedExecutionReason: scopedReason, targetRuleProof, dependencyProof, sourceProofs: sources.expected }
|
sharedConnectionIds, bindings, disabledConnectionIds: disabledBindings.map(binding => binding.connectionId), externalReferences, executionAvailable: reason === null, executionReason: reason, scopedExecutionAvailable: scopedReason === null, scopedExecutionReason: scopedReason, targetRuleProof, dependencyProof, sourceProofs: sources.expected }
|
||||||
return { before, clients, fence, probeEndpoints, ownedTarget,
|
return { before, clients, fence, probeEndpoints, ownedTarget,
|
||||||
|
captureOriginalEffectSelection: async (candidateId: string) => {
|
||||||
|
try {
|
||||||
|
const routes = await get(sourceId, "/routing/route"), sessions = await get(sourceId, "/routing/bgp/session")
|
||||||
|
await fence()
|
||||||
|
return prepareOriginalEffectSelection(profile, candidateId, selected, { routes, connections, sessions,
|
||||||
|
candidate: candidateRows.get(candidateId), probe: probeEndpoints.get(candidateId) })
|
||||||
|
} catch { await fence(); return { status: "unknown" as const, reason: "prepared-effect-catalogue-unavailable" } }
|
||||||
|
},
|
||||||
signEvidence: (value: unknown) => sign(pathProofTag("community") + fingerprint({ dependencyProof, targetRuleProof, value })),
|
signEvidence: (value: unknown) => sign(pathProofTag("community") + fingerprint({ dependencyProof, targetRuleProof, value })),
|
||||||
verifyEvidence: (value: unknown, proof: unknown) => typeof proof === "string" && constantEqual(sign(pathProofTag("community") + fingerprint({ dependencyProof, targetRuleProof, value })), proof),
|
verifyEvidence: (value: unknown, proof: unknown) => typeof proof === "string" && constantEqual(sign(pathProofTag("community") + fingerprint({ dependencyProof, targetRuleProof, value })), proof),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import assert from "node:assert/strict"
|
import assert from "node:assert/strict"
|
||||||
|
import { readFile } from "node:fs/promises"
|
||||||
import { test } from "node:test"
|
import { test } from "node:test"
|
||||||
|
import { runInNewContext } from "node:vm"
|
||||||
|
import ts from "typescript"
|
||||||
import type { ActionContext } from "./action-types.js"
|
import type { ActionContext } from "./action-types.js"
|
||||||
import { loadReservedScopedRecoverySource } from "./routing-community-scoped-recovery-source.js"
|
import { loadReservedScopedRecoverySource } from "./routing-community-scoped-recovery-source.js"
|
||||||
import { executeReservedScopedRecovery, scopedRecoveryIntentFromCheckpoint, validateReservedInverseVerified } from "./routing-community-scoped-recovery.js"
|
import { executeReservedScopedRecovery, scopedRecoveryIntentFromCheckpoint, validateReservedInverseVerified } from "./routing-community-scoped-recovery.js"
|
||||||
@@ -16,6 +19,67 @@ test("a serialized allocation cannot enter the shared inverse engine", async ()
|
|||||||
await assert.rejects(executeReservedScopedRecovery(forged, {} as Parameters<typeof executeReservedScopedRecovery>[1]), /capability/)
|
await assert.rejects(executeReservedScopedRecovery(forged, {} as Parameters<typeof executeReservedScopedRecovery>[1]), /capability/)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("confirmed inverse spend quarantines the exact slot on post-spend refusal without replay", async () => {
|
||||||
|
// Execute the actual boundary body with only its I/O dependencies substituted.
|
||||||
|
// This does not mint a production capability or claim native/SQL acceptance.
|
||||||
|
const source = ts.createSourceFile("routing-community-scoped-recovery.ts",
|
||||||
|
await readFile(new URL("./routing-community-scoped-recovery.ts", import.meta.url), "utf8"), ts.ScriptTarget.ES2022)
|
||||||
|
const declaration = source.statements.find(node => ts.isFunctionDeclaration(node) && node.name?.text === "executeReservedScopedRecovery")
|
||||||
|
assert(declaration)
|
||||||
|
const compiled = ts.transpileModule(declaration.getText(source), { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } }).outputText
|
||||||
|
const allocation = Object.freeze({ kind: "routing-reserved-inverse-allocation" })
|
||||||
|
const execution = Object.freeze({ kind: "routing-reserved-inverse-execution" })
|
||||||
|
const principal = { connectionId: "owned", credentialId: "original" }
|
||||||
|
const original = new Error("original authority failure"), result = Object.freeze({ outcome: "recovered" })
|
||||||
|
const scenarios = {
|
||||||
|
spend: ["spend"],
|
||||||
|
inspect: ["spend", "inspect", "quarantine"],
|
||||||
|
authority: ["spend", "inspect", "authority", "quarantine"],
|
||||||
|
source: ["spend", "inspect", "authority", "source", "quarantine"],
|
||||||
|
proof: ["spend", "inspect", "authority", "source", "proof", "quarantine"],
|
||||||
|
engine: ["spend", "inspect", "authority", "source", "proof", "engine", "quarantine"],
|
||||||
|
success: ["spend", "inspect", "authority", "source", "proof", "engine"],
|
||||||
|
} as const
|
||||||
|
for (const [scenario, expected] of Object.entries(scenarios)) {
|
||||||
|
const events: string[] = [], exports: Record<string, unknown> = {}, capabilities = new WeakMap()
|
||||||
|
let request: object | undefined
|
||||||
|
const at = (phase: string) => { events.push(phase); if (phase === scenario) throw original }
|
||||||
|
runInNewContext(compiled, {
|
||||||
|
exports, reservedExecutions: capabilities, fingerprint: JSON.stringify,
|
||||||
|
createRoutingReservationStore: () => ({
|
||||||
|
async beginInverseAttempt(value: unknown) {
|
||||||
|
assert.equal(value, allocation); at("spend"); return { reservationId: "spent-slot", execution }
|
||||||
|
},
|
||||||
|
async inspectInverseExecution(value: unknown) {
|
||||||
|
assert.equal(value, execution); at("inspect")
|
||||||
|
return { principal, sourceOperationId: "source", deadline: "2026-10-29T01:00:00.000Z" }
|
||||||
|
},
|
||||||
|
async markUnknown(id: string, lane: string) {
|
||||||
|
assert.equal(id, "spent-slot"); assert.equal(lane, "inverse"); at("quarantine")
|
||||||
|
throw new Error("quarantine persistence unavailable")
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
effectiveMcpConnection: async () => { at("authority"); return { owner_id: "owner", policy: {}, owner_permissions: [] } },
|
||||||
|
loadReservedScopedRecoverySource: async () => {
|
||||||
|
at("source"); return { profile: { serverId: 1 }, input: {}, configuration: { packet: {} } }
|
||||||
|
},
|
||||||
|
accessGeneration: async () => "generation",
|
||||||
|
deadline: () => Date.parse("2026-10-29T01:00:00.000Z"),
|
||||||
|
sealedProof: async () => { at("proof"); return "proof" },
|
||||||
|
executeScopedRecovery: async (value: object) => {
|
||||||
|
request = value; assert.equal(capabilities.get(value), execution); at("engine"); return result
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const execute = exports.executeReservedScopedRecovery as
|
||||||
|
(allocation: unknown, expected: { sourceOperationId: string; principal: typeof principal }) => Promise<unknown>
|
||||||
|
const outcome = execute(allocation, { sourceOperationId: "source", principal })
|
||||||
|
if (scenario === "success") assert.equal(await outcome, result)
|
||||||
|
else await assert.rejects(outcome, error => error === original)
|
||||||
|
assert.deepEqual(events, expected, scenario)
|
||||||
|
if (request) assert.equal(capabilities.has(request), false)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
test("configuration equality or generic VERIFIED cannot replace reserved signed receipts", async () => {
|
test("configuration equality or generic VERIFIED cannot replace reserved signed receipts", async () => {
|
||||||
const expected = {} as Parameters<typeof validateReservedInverseVerified>[1]
|
const expected = {} as Parameters<typeof validateReservedInverseVerified>[1]
|
||||||
for (const row of [{}, { state: "verified", phase: "RECOVERY_VERIFIED", configurationVerified: true }, { inverse_intent_sealed: "x".repeat(524289) }]) {
|
for (const row of [{}, { state: "verified", phase: "RECOVERY_VERIFIED", configurationVerified: true }, { inverse_intent_sealed: "x".repeat(524289) }]) {
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -1,3 +1,4 @@
|
|||||||
|
import { originalEffectReportSchema, type OriginalEffectReport } from "./routing-original-effects.js"
|
||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
import type { PoolClient } from "pg"
|
import type { PoolClient } from "pg"
|
||||||
import { pool } from "../../db/index.js"
|
import { pool } from "../../db/index.js"
|
||||||
@@ -19,7 +20,7 @@ const checkpointSchema = z.union([manualCheckpointSchema, manualCheckpointSchema
|
|||||||
export type ScopedConfigurationPacket = z.infer<typeof checkpointSchema>
|
export type ScopedConfigurationPacket = z.infer<typeof checkpointSchema>
|
||||||
type Checkpoint = ScopedConfigurationPacket
|
type Checkpoint = ScopedConfigurationPacket
|
||||||
const networkSchema = z.object({ receiptId: z.string().uuid(), requestSeal: proof, configurationCheckpointProof: proof,
|
const networkSchema = z.object({ receiptId: z.string().uuid(), requestSeal: proof, configurationCheckpointProof: proof,
|
||||||
check: z.object({ id: z.string().uuid(), candidateId: z.string(), status: z.enum(["passed", "failed"]), observedAt: z.string().datetime() }).strict(), networkSeal: proof }).strict()
|
check: z.object({ id: z.string().uuid(), candidateId: z.string(), status: z.enum(["passed", "failed"]), observedAt: z.string().datetime() }).strict(), originalEffects: originalEffectReportSchema.optional(), networkSeal: proof }).strict()
|
||||||
const intended = (input: Record<string, unknown>) => JSON.stringify(redact({ kind: "service", service: "routing.community.scoped", input }))
|
const intended = (input: Record<string, unknown>) => JSON.stringify(redact({ kind: "service", service: "routing.community.scoped", input }))
|
||||||
const connection = (input: Record<string, unknown>) => z.object({ connectionId: z.string().uuid() }).parse(input.principal).connectionId
|
const connection = (input: Record<string, unknown>) => z.object({ connectionId: z.string().uuid() }).parse(input.principal).connectionId
|
||||||
const seal = (value: Checkpoint) => privateSecretFingerprint(fingerprint(value), `route-ai-scoped-config-checkpoint-v${value.revision}`)
|
const seal = (value: Checkpoint) => privateSecretFingerprint(fingerprint(value), `route-ai-scoped-config-checkpoint-v${value.revision}`)
|
||||||
@@ -72,12 +73,13 @@ export async function loadScopedConfigurationCheckpoint(input: Record<string, un
|
|||||||
return value ? { checkpointProof: value.checkpointProof, observedAt: value.observedAt } : null
|
return value ? { checkpointProof: value.checkpointProof, observedAt: value.observedAt } : null
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function saveScopedNetworkCheckpoint(input: Record<string, unknown>, requestSeal: string, check: { id: string; candidateId: string; status: "passed" | "failed"; observedAt: string }) {
|
export async function saveScopedNetworkCheckpoint(input: Record<string, unknown>, requestSeal: string, check: { id: string; candidateId: string; status: "passed" | "failed"; observedAt: string }, originalEffects?: OriginalEffectReport) {
|
||||||
await assertPersistentScopedProfileAuthority(input)
|
await assertPersistentScopedProfileAuthority(input)
|
||||||
const configuration = await loadScopedConfigurationCheckpoint(input, requestSeal)
|
const configuration = await loadScopedConfigurationCheckpoint(input, requestSeal)
|
||||||
if (!configuration) throw new McpError("STATE_CONFLICT", "Нет подтверждённого CONFIG_APPLIED", 409)
|
if (!configuration) throw new McpError("STATE_CONFLICT", "Нет подтверждённого CONFIG_APPLIED", 409)
|
||||||
const packet = { receiptId: input.receiptId, requestSeal, configurationCheckpointProof: configuration.checkpointProof,
|
const packet = { receiptId: input.receiptId, requestSeal, configurationCheckpointProof: configuration.checkpointProof,
|
||||||
check: { id: check.id, candidateId: check.candidateId, status: check.status, observedAt: check.observedAt } }
|
check: { id: check.id, candidateId: check.candidateId, status: check.status, observedAt: check.observedAt },
|
||||||
|
...(originalEffects === undefined ? {} : { originalEffects: originalEffectReportSchema.parse(originalEffects) }) }
|
||||||
const networkSeal = await privateSecretFingerprint(fingerprint(packet), "route-ai-scoped-network-checkpoint-v1")
|
const networkSeal = await privateSecretFingerprint(fingerprint(packet), "route-ai-scoped-network-checkpoint-v1")
|
||||||
await assertPersistentScopedProfileAuthority(input)
|
await assertPersistentScopedProfileAuthority(input)
|
||||||
await audit(pool, String(input.ownerId), "routing.scoped.network-check", connection(input), { ...packet, networkSeal })
|
await audit(pool, String(input.ownerId), "routing.scoped.network-check", connection(input), { ...packet, networkSeal })
|
||||||
@@ -92,5 +94,5 @@ export async function loadScopedNetworkCheckpoint(input: Record<string, unknown>
|
|||||||
if (!parsed.success) return null
|
if (!parsed.success) return null
|
||||||
const { networkSeal, ...packet } = parsed.data
|
const { networkSeal, ...packet } = parsed.data
|
||||||
if (packet.configurationCheckpointProof !== configuration.checkpointProof || packet.check.candidateId !== input.candidateId || Date.parse(packet.check.observedAt) < Date.parse(configuration.observedAt) || Date.parse(packet.check.observedAt) > Date.now() || networkSeal !== await privateSecretFingerprint(fingerprint(packet), "route-ai-scoped-network-checkpoint-v1")) return null
|
if (packet.configurationCheckpointProof !== configuration.checkpointProof || packet.check.candidateId !== input.candidateId || Date.parse(packet.check.observedAt) < Date.parse(configuration.observedAt) || Date.parse(packet.check.observedAt) > Date.now() || networkSeal !== await privateSecretFingerprint(fingerprint(packet), "route-ai-scoped-network-checkpoint-v1")) return null
|
||||||
return { status: packet.check.status, networkSeal }
|
return { status: packet.check.status, networkSeal, ...(packet.originalEffects === undefined ? {} : { originalEffects: packet.originalEffects }) }
|
||||||
}
|
}
|
||||||
@@ -257,6 +257,7 @@ async function checkpoint(value: Session, intent: Intent, phase: Checkpoint["pha
|
|||||||
export async function executeReservedScopedRecovery(allocation: RoutingReservedInverseAllocation, expected: { sourceOperationId: string; principal: McpPrincipal }) {
|
export async function executeReservedScopedRecovery(allocation: RoutingReservedInverseAllocation, expected: { sourceOperationId: string; principal: McpPrincipal }) {
|
||||||
const store = createRoutingReservationStore(), spent = await store.beginInverseAttempt(allocation)
|
const store = createRoutingReservationStore(), spent = await store.beginInverseAttempt(allocation)
|
||||||
const execution = spent.execution
|
const execution = spent.execution
|
||||||
|
try {
|
||||||
const binding = await store.inspectInverseExecution(execution)
|
const binding = await store.inspectInverseExecution(execution)
|
||||||
if (binding.sourceOperationId !== expected.sourceOperationId || fingerprint(binding.principal) !== fingerprint(expected.principal)) throw fail("Reserved runner identity differs")
|
if (binding.sourceOperationId !== expected.sourceOperationId || fingerprint(binding.principal) !== fingerprint(expected.principal)) throw fail("Reserved runner identity differs")
|
||||||
const current = await effectiveMcpConnection(binding.principal)
|
const current = await effectiveMcpConnection(binding.principal)
|
||||||
@@ -273,8 +274,13 @@ export async function executeReservedScopedRecovery(allocation: RoutingReservedI
|
|||||||
input.requestProof = await sealedProof(input, "request")
|
input.requestProof = await sealedProof(input, "request")
|
||||||
reservedExecutions.set(input, execution)
|
reservedExecutions.set(input, execution)
|
||||||
try { return await executeScopedRecovery(input) }
|
try { return await executeScopedRecovery(input) }
|
||||||
catch (error) { await store.markUnknown(binding.reservationId, "inverse").catch(() => {}); throw error }
|
|
||||||
finally { reservedExecutions.delete(input) }
|
finally { reservedExecutions.delete(input) }
|
||||||
|
} catch (error) {
|
||||||
|
// Acknowledged spend owns this exact slot even when its authority can no
|
||||||
|
// longer be inspected. A refused or uncertain spend never enters here.
|
||||||
|
await store.markUnknown(spent.reservationId, "inverse").catch(() => {})
|
||||||
|
throw error
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Fixed-domain receipt verifier used by the reservation store. It recognizes
|
/** Fixed-domain receipt verifier used by the reservation store. It recognizes
|
||||||
|
|||||||
@@ -1,5 +1,18 @@
|
|||||||
|
import type { PoolClient } from "pg"
|
||||||
|
import { COMMUNITY_PATH_MAX_AGE_MS } from "./path-proof-purpose.js"
|
||||||
import { fingerprint, McpError, privateSecretFingerprint } from "./security.js"
|
import { fingerprint, McpError, privateSecretFingerprint } from "./security.js"
|
||||||
|
|
||||||
|
export async function readScopedPathCheckpoint(db: Pick<PoolClient, "query">, id: string, connectionId: string, phase: "admission" | "configuration" = "admission") {
|
||||||
|
const { rows: [row] } = await db.query("SELECT id,connection_id,profile_hash,candidate_id,status,created_at,evidence FROM mcp_path_checks WHERE id=$1 AND connection_id=$2", [id, connectionId])
|
||||||
|
const now = Date.now(), createdAt = row?.created_at.getTime(), observedAt = Date.parse(row?.evidence?.observedAt)
|
||||||
|
const validUntil = Math.min(createdAt, observedAt) + COMMUNITY_PATH_MAX_AGE_MS
|
||||||
|
if (!row || row.status !== "passed" || createdAt > now || phase === "admission" &&
|
||||||
|
(!Number.isFinite(validUntil) || observedAt > now || validUntil <= now)) {
|
||||||
|
throw new McpError("MEASUREMENT_UNKNOWN", "Контрольное подтверждение пути устарело", 409)
|
||||||
|
}
|
||||||
|
return { proof: await privateSecretFingerprint(fingerprint(row), "route-ai-scoped-path-checkpoint-v1"), validUntil }
|
||||||
|
}
|
||||||
|
|
||||||
/** One request domain for forward execution and trusted recovery resolution. */
|
/** One request domain for forward execution and trusted recovery resolution. */
|
||||||
export async function assertScopedCommunityRequest(input: Record<string, unknown>) {
|
export async function assertScopedCommunityRequest(input: Record<string, unknown>) {
|
||||||
const { requestProof: _proof, ...request } = input
|
const { requestProof: _proof, ...request } = input
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
import { createRoutingNetworkSessionForCurrentExecution } from "./routing-network-execution-session.js"
|
||||||
|
import { readRoutingNetworkScopedCommunityContext } from "./routing-network-scoped-context.js"
|
||||||
|
import { resolveRoutingNetworkForwardRoundConfigurationContext, closeRoutingNetworkForwardRoundConfigurationContext } from "./routing-network-configuration-bridge.js"
|
||||||
|
import { originalEffectReportSchema, originalEffectSelectionSchema, unknownOriginalEffects, originalEffectBaselineMatches, inspectOriginalEffectRounds, type OriginalEffectReport } from "./routing-original-effects.js"
|
||||||
import { randomUUID } from "node:crypto"
|
import { randomUUID } from "node:crypto"
|
||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
import { routeAiProfileSchema } from "@mmapp/contracts/mcp-routing"
|
import { routeAiProfileSchema } from "@mmapp/contracts/mcp-routing"
|
||||||
@@ -8,7 +12,7 @@ import { withApplicationMutationLock, withConfigMutationLock } from "../../servi
|
|||||||
import { effectiveMcpConnection, audit } from "./store.js"
|
import { effectiveMcpConnection, audit } from "./store.js"
|
||||||
import { accessGeneration, decryptSecret, encryptSecret, fingerprint, McpError, privateSecretFingerprint, redact } from "./security.js"
|
import { accessGeneration, decryptSecret, encryptSecret, fingerprint, McpError, privateSecretFingerprint, redact } from "./security.js"
|
||||||
import { saveScopedConfigurationCheckpoint, loadScopedConfigurationCheckpoint, saveScopedNetworkCheckpoint, loadScopedNetworkCheckpoint } from "./routing-community-scoped-checkpoint.js"
|
import { saveScopedConfigurationCheckpoint, loadScopedConfigurationCheckpoint, saveScopedNetworkCheckpoint, loadScopedNetworkCheckpoint } from "./routing-community-scoped-checkpoint.js"
|
||||||
import { assertScopedCommunityRequest as sealedInput } from "./routing-community-scoped-request.js"
|
import { assertScopedCommunityRequest as sealedInput, readScopedPathCheckpoint } from "./routing-community-scoped-request.js"
|
||||||
import { prepareManagementTransportBinding } from "./management-transport.js"
|
import { prepareManagementTransportBinding } from "./management-transport.js"
|
||||||
import { readRouteCommunityContext } from "./routing-community-context.js"
|
import { readRouteCommunityContext } from "./routing-community-context.js"
|
||||||
import { candidateMatchesGateway } from "./routing-community-context-model.js"
|
import { candidateMatchesGateway } from "./routing-community-context-model.js"
|
||||||
@@ -25,15 +29,14 @@ import { createRoutingReservationStore } from "./routing-reservation-store.js"
|
|||||||
|
|
||||||
const principalSchema = z.object({ connectionId: z.string().uuid(), credentialId: z.string().uuid(), credentialExpiresAt: z.string() }).strict()
|
const principalSchema = z.object({ connectionId: z.string().uuid(), credentialId: z.string().uuid(), credentialExpiresAt: z.string() }).strict()
|
||||||
const fail = (message: string, code = "STATE_CONFLICT") => new McpError(code, message, 409)
|
const fail = (message: string, code = "STATE_CONFLICT") => new McpError(code, message, 409)
|
||||||
|
const effectReadFailure = (stage: "original-effect-rounds-unavailable" | "effect-publication-revalidation-unavailable", error: unknown) => {
|
||||||
|
const code = error instanceof McpError && ["READ_CANCELLED", "PLAN_EXPIRED", "LIMIT_EXCEEDED", "STATE_CONFLICT", "RESET_REQUIRED", "STATE_UNAVAILABLE"].includes(error.code)
|
||||||
|
? error.code : "STATE_UNAVAILABLE"
|
||||||
|
return unknownOriginalEffects(stage + ":" + code)
|
||||||
|
}
|
||||||
export const scopedProfileIds = (profile: z.infer<typeof routeAiProfileSchema>) => [...new Set([profile.serverId, ...profile.candidates.flatMap(candidate => [candidate.jumpHostId, candidate.exitNodeId])])].sort((a, b) => a - b)
|
export const scopedProfileIds = (profile: z.infer<typeof routeAiProfileSchema>) => [...new Set([profile.serverId, ...profile.candidates.flatMap(candidate => [candidate.jumpHostId, candidate.exitNodeId])])].sort((a, b) => a - b)
|
||||||
const publicRule = (rule: { community: string; gateway: string; outIface: string }) => ({ community: rule.community, action: "route", gateway: rule.gateway, gatewayTunnelId: rule.outIface })
|
const publicRule = (rule: { community: string; gateway: string; outIface: string }) => ({ community: rule.community, action: "route", gateway: rule.gateway, gatewayTunnelId: rule.outIface })
|
||||||
type ObservationPhase = "admission" | "configuration"
|
type ObservationPhase = "admission" | "configuration"
|
||||||
async function pathCheckpoint(id: string, connectionId: string, phase: ObservationPhase = "admission") {
|
|
||||||
const row = (await pool.query("SELECT id,connection_id,profile_hash,candidate_id,status,created_at,evidence FROM mcp_path_checks WHERE id=$1 AND connection_id=$2", [id, connectionId])).rows[0]
|
|
||||||
if (!row || row.status !== "passed" || row.created_at.getTime() > Date.now() || phase === "admission" && Date.now() - row.created_at.getTime() > 90000) throw fail("Контрольное подтверждение пути устарело", "MEASUREMENT_UNKNOWN")
|
|
||||||
return privateSecretFingerprint(fingerprint(row), "route-ai-scoped-path-checkpoint-v1")
|
|
||||||
}
|
|
||||||
|
|
||||||
async function access(input: Record<string, unknown>, signal?: AbortSignal, verified = false, phase: ObservationPhase = "admission") {
|
async function access(input: Record<string, unknown>, signal?: AbortSignal, verified = false, phase: ObservationPhase = "admission") {
|
||||||
if (signal?.aborted) throw fail("Операция отменена", "READ_CANCELLED")
|
if (signal?.aborted) throw fail("Операция отменена", "READ_CANCELLED")
|
||||||
if (verified) await assertVerifiedConfigurationAccess()
|
if (verified) await assertVerifiedConfigurationAccess()
|
||||||
@@ -49,7 +52,7 @@ async function access(input: Record<string, unknown>, signal?: AbortSignal, veri
|
|||||||
const ranked = rankRoutingCandidates(profile, await readRoutingObservations(ids))
|
const ranked = rankRoutingCandidates(profile, await readRoutingObservations(ids))
|
||||||
if (!ranked.find(row => row.candidate.id === input.candidateId)?.eligible) throw fail("Нет свежего SLA выбранного пути", "MEASUREMENT_UNKNOWN")
|
if (!ranked.find(row => row.candidate.id === input.candidateId)?.eligible) throw fail("Нет свежего SLA выбранного пути", "MEASUREMENT_UNKNOWN")
|
||||||
}
|
}
|
||||||
if (input.pathCheckId !== undefined && await pathCheckpoint(String(input.pathCheckId), principal.connectionId, phase) !== input.pathCheckpointProof) throw fail("Подтверждение пути изменилось", "MEASUREMENT_UNKNOWN")
|
if (input.pathCheckId !== undefined && (await readScopedPathCheckpoint(pool, String(input.pathCheckId), principal.connectionId, phase)).proof !== input.pathCheckpointProof) throw fail("Подтверждение пути изменилось", "MEASUREMENT_UNKNOWN")
|
||||||
return { principal, current, profile, ids }
|
return { principal, current, profile, ids }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,7 +83,7 @@ async function receipt(input: Record<string, unknown>, action: string) {
|
|||||||
await assertPersistentScopedProfileAuthority(input)
|
await assertPersistentScopedProfileAuthority(input)
|
||||||
await audit(pool, String(input.ownerId), action, principalSchema.parse(input.principal).connectionId, { receiptId: input.receiptId, requestSeal, revision: input.proofRevision })
|
await audit(pool, String(input.ownerId), action, principalSchema.parse(input.principal).connectionId, { receiptId: input.receiptId, requestSeal, revision: input.proofRevision })
|
||||||
}
|
}
|
||||||
const matches = (input: Record<string, unknown>, context: Awaited<ReturnType<typeof readRouteCommunityContext>>) => scopedConfigurationMatches({ dependencyProof: input.dependencyProof, originalTargetProof: input.originalTargetProof, desiredTargetProof: input.desiredTargetProof, candidateId: input.candidateId }, context.before)
|
const matches = (input: Record<string, unknown>, context: { before: Parameters<typeof scopedConfigurationMatches>[1] }) => scopedConfigurationMatches({ dependencyProof: input.dependencyProof, originalTargetProof: input.originalTargetProof, desiredTargetProof: input.desiredTargetProof, candidateId: input.candidateId }, context.before)
|
||||||
async function original(input: Record<string, unknown>, verified: boolean) {
|
async function original(input: Record<string, unknown>, verified: boolean) {
|
||||||
await sealedInput(input)
|
await sealedInput(input)
|
||||||
const value = await observe(input, undefined, verified)
|
const value = await observe(input, undefined, verified)
|
||||||
@@ -108,7 +111,8 @@ export async function prepareScopedCommunitySwitch(input: Record<string, unknown
|
|||||||
await access(request, action.signal)
|
await access(request, action.signal)
|
||||||
}
|
}
|
||||||
const pathCheckId = await requireRecentPath(profile, String(input.candidateId), action.connection.id, { authorize: async () => { await access(request, action.signal) }, signal: action.signal })
|
const pathCheckId = await requireRecentPath(profile, String(input.candidateId), action.connection.id, { authorize: async () => { await access(request, action.signal) }, signal: action.signal })
|
||||||
const withPath = { ...request, pathCheckId, pathCheckpointProof: await pathCheckpoint(pathCheckId, action.connection.id) }
|
const checkpoint = await readScopedPathCheckpoint(pool, pathCheckId, action.connection.id)
|
||||||
|
const withPath = { ...request, pathCheckId, pathCheckpointProof: checkpoint.proof }
|
||||||
const value = await observe(withPath, action.signal), owned = value.context.ownedTarget!
|
const value = await observe(withPath, action.signal), owned = value.context.ownedTarget!
|
||||||
if (!value.candidate.controlProbe) throw fail("Scoped переключение требует свежий control probe", "MEASUREMENT_UNKNOWN")
|
if (!value.candidate.controlProbe) throw fail("Scoped переключение требует свежий control probe", "MEASUREMENT_UNKNOWN")
|
||||||
await requireRecentPath(value.profile, value.candidate.id, action.connection.id, { authorize: value.authorize, signal: action.signal })
|
await requireRecentPath(value.profile, value.candidate.id, action.connection.id, { authorize: value.authorize, signal: action.signal })
|
||||||
@@ -128,7 +132,10 @@ export async function prepareScopedCommunitySwitch(input: Record<string, unknown
|
|||||||
try { managementBindingCiphertext = await encryptSecret(JSON.stringify(await prepareManagementTransportBinding(profile.serverId, { authorize: value.authorize, signal: action.signal, deadline: Math.min(Date.now() + 120000, Date.parse(action.principal.credentialExpiresAt), action.connection.owner_valid_until.getTime(), Date.parse(action.connection.policy.expiresAt)) }))) }
|
try { managementBindingCiphertext = await encryptSecret(JSON.stringify(await prepareManagementTransportBinding(profile.serverId, { authorize: value.authorize, signal: action.signal, deadline: Math.min(Date.now() + 120000, Date.parse(action.principal.credentialExpiresAt), action.connection.owner_valid_until.getTime(), Date.parse(action.connection.policy.expiresAt)) }))) }
|
||||||
catch (error) { if (!(error instanceof McpError) || error.code !== "APPROVAL_REQUIRED") throw error }
|
catch (error) { if (!(error instanceof McpError) || error.code !== "APPROVAL_REQUIRED") throw error }
|
||||||
}
|
}
|
||||||
const sealed: Record<string, unknown> = { ...withPath, nativeId: owned.nativeId, outIface, contextSources: value.context.before.sourceProofs, dependencyProof: value.context.before.dependencyProof,
|
const originalEffectSelection = await value.context.captureOriginalEffectSelection(String(input.candidateId))
|
||||||
|
const sealed: Record<string, unknown> = { ...withPath, originalEffectSelection,
|
||||||
|
...(!persistent && !profile.autoRecovery && originalEffectSelection.status === "prepared" ? { manualEffectPolicy: "observational-v1" } : {}),
|
||||||
|
nativeId: owned.nativeId, outIface, contextSources: value.context.before.sourceProofs, dependencyProof: value.context.before.dependencyProof,
|
||||||
originalTargetProof: value.context.before.targetRuleProof, desiredTargetProof: owned.desiredTargetProof(compiled.compiledBody), receiptId: randomUUID(), proofRevision: persistent ? 3 : 2,
|
originalTargetProof: value.context.before.targetRuleProof, desiredTargetProof: owned.desiredTargetProof(compiled.compiledBody), receiptId: randomUUID(), proofRevision: persistent ? 3 : 2,
|
||||||
originalCandidateId: value.context.before.currentCandidateId, originalRuleCiphertext: await encryptSecret(String(owned.rawRow.rule)), desiredRuleCiphertext: await encryptSecret(compiled.compiledBody), managementBindingCiphertext,
|
originalCandidateId: value.context.before.currentCandidateId, originalRuleCiphertext: await encryptSecret(String(owned.rawRow.rule)), desiredRuleCiphertext: await encryptSecret(compiled.compiledBody), managementBindingCiphertext,
|
||||||
rules: [publicRule(compiled.proposed)], beforeRules: [publicRule(compiled.before)] }
|
rules: [publicRule(compiled.proposed)], beforeRules: [publicRule(compiled.before)] }
|
||||||
@@ -137,9 +144,12 @@ export async function prepareScopedCommunitySwitch(input: Record<string, unknown
|
|||||||
sealed.requestProof = await privateSecretFingerprint(fingerprint(sealed), `route-ai-scoped-request-v${sealed.proofRevision}`)
|
sealed.requestProof = await privateSecretFingerprint(fingerprint(sealed), `route-ai-scoped-request-v${sealed.proofRevision}`)
|
||||||
await value.context.fence()
|
await value.context.fence()
|
||||||
await access(sealed, action.signal)
|
await access(sealed, action.signal)
|
||||||
|
const authorityValidUntil = persistent ? routingProfileAuthorityBindingSchema.parse(sealed.persistentProfileAuthority).packet.validUntil : undefined
|
||||||
|
const validUntil = authorityValidUntil && !profile.autoRecovery
|
||||||
|
? new Date(Math.min(Date.parse(authorityValidUntil), checkpoint.validUntil)).toISOString() : authorityValidUntil
|
||||||
return { target: { scope: "servers", serverIds: scopedProfileIds(value.profile) }, objects: [{ kind: "routing_filter", serverId: value.profile.serverId, id: owned.nativeId }, ...scopedProfileIds(value.profile).map(serverId => ({ kind: "server" as const, serverId, id: String(serverId) }))], snapshots: [], before: sealed.beforeRules,
|
return { target: { scope: "servers", serverIds: scopedProfileIds(value.profile) }, objects: [{ kind: "routing_filter", serverId: value.profile.serverId, id: owned.nativeId }, ...scopedProfileIds(value.profile).map(serverId => ({ kind: "server" as const, serverId, id: String(serverId) }))], snapshots: [], before: sealed.beforeRules,
|
||||||
steps: [{ kind: "service", service: "routing.community.scoped", input: sealed }], requiresHuman: true, mutationCost: 2, verificationScope: "configuration",
|
steps: [{ kind: "service", service: "routing.community.scoped", input: sealed }], requiresHuman: true, mutationCost: 2, verificationScope: "configuration",
|
||||||
...(persistent ? { validUntil: routingProfileAuthorityBindingSchema.parse(sealed.persistentProfileAuthority).packet.validUntil } : {}),
|
...(validUntil ? { validUntil } : {}),
|
||||||
warnings: ["Одна запись owned фильтра; SQL метаданные фильтров сохраняются.", "SLA и свежая preflight-проба обязательны до отправки PATCH. После записи подтверждение конфигурации сохраняется независимо от новых SLA-измерений; контрольная проба и её результат проверяются отдельно.", "Подтверждается конфигурация и control probe; сквозной и обратный трафик требуют отдельной приёмки.", managementBindingCiphertext ? "Заранее закреплён независимый транспорт для отдельного обратного плана; автоматический inverse не включён." : "Независимый transport не закреплён; отдельный обратный план потребует доступного primary.", "Неизвестный результат не повторяется; сверка только читает состояние."] }
|
warnings: ["Одна запись owned фильтра; SQL метаданные фильтров сохраняются.", "SLA и свежая preflight-проба обязательны до отправки PATCH. После записи подтверждение конфигурации сохраняется независимо от новых SLA-измерений; контрольная проба и её результат проверяются отдельно.", "Подтверждается конфигурация и control probe; сквозной и обратный трафик требуют отдельной приёмки.", managementBindingCiphertext ? "Заранее закреплён независимый транспорт для отдельного обратного плана; автоматический inverse не включён." : "Независимый transport не закреплён; отдельный обратный план потребует доступного primary.", "Неизвестный результат не повторяется; сверка только читает состояние."] }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,9 +164,35 @@ export function registerScopedCommunityService() {
|
|||||||
}
|
}
|
||||||
await original(input, true)
|
await original(input, true)
|
||||||
if (await receiptExists(input, "routing.scoped.attempt")) throw fail("Повтор PATCH запрещён", "MUTATION_OUTCOME_UNKNOWN")
|
if (await receiptExists(input, "routing.scoped.attempt")) throw fail("Повтор PATCH запрещён", "MUTATION_OUTCOME_UNKNOWN")
|
||||||
await receipt(input, "routing.scoped.attempt")
|
const requiresEffectBaseline = input.proofRevision === 3 && !routeAiProfileSchema.parse(input.profile).autoRecovery
|
||||||
|
let attemptRecorded = !requiresEffectBaseline
|
||||||
|
if (attemptRecorded) await receipt(input, "routing.scoped.attempt")
|
||||||
const before = await original(input, true), owned = before.context.ownedTarget!
|
const before = await original(input, true), owned = before.context.ownedTarget!
|
||||||
|
let effectSession: Awaited<ReturnType<typeof createRoutingNetworkSessionForCurrentExecution>> | undefined
|
||||||
|
let effectBaseline: Parameters<typeof originalEffectBaselineMatches>[1] | undefined
|
||||||
|
let originalEffects: OriginalEffectReport | undefined
|
||||||
|
const effectSelection = originalEffectSelectionSchema.safeParse(input.originalEffectSelection)
|
||||||
|
if (effectSelection.success) originalEffects = unknownOriginalEffects(effectSelection.data.status === "unknown" ? effectSelection.data.reason : "effect-acquisition-unavailable")
|
||||||
try {
|
try {
|
||||||
|
// The persistent controller needs a baseline it can advance after the
|
||||||
|
// write. Manual plans still treat the effect vector as observational.
|
||||||
|
if (context?.phase === "forward" && (input.proofRevision === 3 || input.proofRevision === 2 && input.manualEffectPolicy === "observational-v1") && effectSelection.success && effectSelection.data.status === "prepared") {
|
||||||
|
try {
|
||||||
|
const selected = effectSelection.data
|
||||||
|
effectSession = await createRoutingNetworkSessionForCurrentExecution({ servers: scopedProfileIds(before.profile),
|
||||||
|
probes: [{ key: selected.probe.key, serverId: selected.probe.serverId, destination: selected.probe.destination,
|
||||||
|
source: selected.probe.source, interface: selected.probe.interface, probeVrf: selected.probe.probeVrf }] })
|
||||||
|
effectBaseline = await effectSession.readSnapshot("before")
|
||||||
|
if (!originalEffectBaselineMatches(selected, effectBaseline)) {
|
||||||
|
effectSession.close(); effectSession = undefined; originalEffects = unknownOriginalEffects("prepared-effect-baseline-drift")
|
||||||
|
}
|
||||||
|
} catch { effectSession?.close(); effectSession = undefined }
|
||||||
|
}
|
||||||
|
if (requiresEffectBaseline) {
|
||||||
|
if (!effectSession) throw fail("Исходное состояние изменяемого префикса не подтверждено", "MEASUREMENT_UNKNOWN")
|
||||||
|
await receipt(input, "routing.scoped.attempt")
|
||||||
|
attemptRecorded = true
|
||||||
|
}
|
||||||
await executeGuardedConfigurationWrite(before.profile.serverId, "PATCH", "/routing/filter/rule/" + owned.nativeId, async () => {
|
await executeGuardedConfigurationWrite(before.profile.serverId, "PATCH", "/routing/filter/rule/" + owned.nativeId, async () => {
|
||||||
// This runs AFTER the durable configuration.intent, immediately before transport.
|
// This runs AFTER the durable configuration.intent, immediately before transport.
|
||||||
const fresh = await original(input, true); await before.context.fence()
|
const fresh = await original(input, true); await before.context.fence()
|
||||||
@@ -170,13 +206,48 @@ export function registerScopedCommunityService() {
|
|||||||
await saveScopedConfigurationCheckpoint(input, await sealedInput(input))
|
await saveScopedConfigurationCheckpoint(input, await sealedInput(input))
|
||||||
const check = await checkCandidatePath(after.profile, after.candidate.id, principalSchema.parse(input.principal).connectionId, { authorize: after.authorize })
|
const check = await checkCandidatePath(after.profile, after.candidate.id, principalSchema.parse(input.principal).connectionId, { authorize: after.authorize })
|
||||||
if (check.status === "unknown") throw fail("Control probe неизвестен", "MUTATION_OUTCOME_UNKNOWN")
|
if (check.status === "unknown") throw fail("Control probe неизвестен", "MUTATION_OUTCOME_UNKNOWN")
|
||||||
// A probe can be long: prove the whole configuration again before a durable VERIFIED receipt.
|
if (effectSession && effectBaseline && effectSelection.success && effectSelection.data.status === "prepared") {
|
||||||
|
let roundContext: Awaited<ReturnType<typeof resolveRoutingNetworkForwardRoundConfigurationContext>> | undefined
|
||||||
|
try {
|
||||||
|
const principal = principalSchema.parse(input.principal), action = { principal, connection: await effectiveMcpConnection(principal) }
|
||||||
|
roundContext = await resolveRoutingNetworkForwardRoundConfigurationContext(effectSession.sharedContext, action)
|
||||||
|
const first = await effectSession.readConfigurationBoundForwardSnapshot(roundContext)
|
||||||
|
const second = await effectSession.readConfigurationBoundForwardSnapshot(roundContext)
|
||||||
|
originalEffects = await inspectOriginalEffectRounds(effectSelection.data, [first, second], effectBaseline)
|
||||||
|
} catch (error) { originalEffects = effectReadFailure("original-effect-rounds-unavailable", error) }
|
||||||
|
finally { if (roundContext) closeRoutingNetworkForwardRoundConfigurationContext(roundContext) }
|
||||||
|
}
|
||||||
|
// Publication revalidation uses the existing native owner's fixed
|
||||||
|
// context reader and therefore its SAME counters, without a snapshot.
|
||||||
|
let sharedPublication = false
|
||||||
|
if (effectSession && !effectSession.readBudget().closed) {
|
||||||
|
try {
|
||||||
|
const published = await readRoutingNetworkScopedCommunityContext(effectSession.sharedContext)
|
||||||
|
if (!matches(input, published).configurationMatchesDesired) throw fail("Состояние изменилось после effect-check")
|
||||||
|
await published.fence()
|
||||||
|
sharedPublication = true
|
||||||
|
} catch (error) { originalEffects = effectReadFailure("effect-publication-revalidation-unavailable", error) }
|
||||||
|
}
|
||||||
|
if (!sharedPublication) {
|
||||||
|
// This is the OLD manual final read, relocated after optional work,
|
||||||
|
// never duplicated or counted as a completed effect-owner read.
|
||||||
|
// Closed/exhausted effect owners cannot waive this fresh guard.
|
||||||
const final = await observe(input, undefined, true, undefined, "configuration")
|
const final = await observe(input, undefined, true, undefined, "configuration")
|
||||||
if (!matches(input, final.context).configurationMatchesDesired) throw fail("Состояние изменилось после probe")
|
if (!matches(input, final.context).configurationMatchesDesired) throw fail("Состояние изменилось после probe")
|
||||||
await final.context.fence()
|
await final.context.fence()
|
||||||
await saveScopedNetworkCheckpoint(input, await sealedInput(input), { ...check, status: check.status })
|
}
|
||||||
|
if (effectSession && originalEffects) {
|
||||||
|
const { deadline, calls, bytes, rows } = effectSession.readBudget()
|
||||||
|
const budget = originalEffectReportSchema.shape.budget.safeParse({ deadline, calls, bytes, rows })
|
||||||
|
if (budget.success) originalEffects = { ...originalEffects, budget: budget.data }
|
||||||
|
}
|
||||||
|
await saveScopedNetworkCheckpoint(input, await sealedInput(input), { ...check, status: check.status }, originalEffects)
|
||||||
return verifiedResult(input)
|
return verifiedResult(input)
|
||||||
} catch { throw fail("Результат переключения не подтверждён; повтор запрещён, требуется сверка", "MUTATION_OUTCOME_UNKNOWN") }
|
} catch (error) {
|
||||||
|
if (!attemptRecorded) throw error
|
||||||
|
throw fail("Результат переключения не подтверждён; повтор запрещён, требуется сверка", "MUTATION_OUTCOME_UNKNOWN")
|
||||||
|
}
|
||||||
|
finally { effectSession?.close() }
|
||||||
})),
|
})),
|
||||||
verify: async (input, result) => {
|
verify: async (input, result) => {
|
||||||
const expected = await verifiedResult(input)
|
const expected = await verifiedResult(input)
|
||||||
@@ -200,6 +271,7 @@ async function verifiedResult(input: Record<string, unknown>) {
|
|||||||
const network = await loadScopedNetworkCheckpoint(input, await sealedInput(input))
|
const network = await loadScopedNetworkCheckpoint(input, await sealedInput(input))
|
||||||
const result = { outcome: network?.status === "passed" ? "applied" : "configuration-applied-network-unverified", nativeWrites: 1, forwardingVerified: false, configurationVerified: true, receiptId: input.receiptId, proofRevision: input.proofRevision,
|
const result = { outcome: network?.status === "passed" ? "applied" : "configuration-applied-network-unverified", nativeWrites: 1, forwardingVerified: false, configurationVerified: true, receiptId: input.receiptId, proofRevision: input.proofRevision,
|
||||||
configurationCheckpointAvailable: !!checkpoint, configurationCheckpointProof: checkpoint?.checkpointProof ?? null, controlProbeVerified: network?.status === "passed", postProbeStatus: network?.status ?? "unknown", networkCheckpointProof: network?.networkSeal ?? null,
|
configurationCheckpointAvailable: !!checkpoint, configurationCheckpointProof: checkpoint?.checkpointProof ?? null, controlProbeVerified: network?.status === "passed", postProbeStatus: network?.status ?? "unknown", networkCheckpointProof: network?.networkSeal ?? null,
|
||||||
|
...(network?.originalEffects === undefined ? {} : { originalEffects: network.originalEffects }),
|
||||||
nativeVerificationSeal: await privateSecretFingerprint(fingerprint({ request: await sealedInput(input), dependency: input.dependencyProof, desired: input.desiredTargetProof, candidate: input.candidateId, configuration: checkpoint?.checkpointProof ?? null, network: network?.networkSeal ?? null }), `route-ai-scoped-verified-v${input.proofRevision}`) }
|
nativeVerificationSeal: await privateSecretFingerprint(fingerprint({ request: await sealedInput(input), dependency: input.dependencyProof, desired: input.desiredTargetProof, candidate: input.candidateId, configuration: checkpoint?.checkpointProof ?? null, network: network?.networkSeal ?? null }), `route-ai-scoped-verified-v${input.proofRevision}`) }
|
||||||
await assertPersistentScopedProfileAuthority(input)
|
await assertPersistentScopedProfileAuthority(input)
|
||||||
return result
|
return result
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { findMcpOperation } from "./registry.js"
|
|||||||
import { assertScopedCommunityRequest } from "./routing-community-scoped-request.js"
|
import { assertScopedCommunityRequest } from "./routing-community-scoped-request.js"
|
||||||
import { assertPersistentScopedProfileAuthority, routingProfileAuthorityBindingSchema } from "./routing-profile-authority.js"
|
import { assertPersistentScopedProfileAuthority, routingProfileAuthorityBindingSchema } from "./routing-profile-authority.js"
|
||||||
import { loadScopedConfigurationPacket, loadScopedNetworkCheckpoint } from "./routing-community-scoped-checkpoint.js"
|
import { loadScopedConfigurationPacket, loadScopedNetworkCheckpoint } from "./routing-community-scoped-checkpoint.js"
|
||||||
|
import { originalEffectSelectionSchema } from "./routing-original-effects.js"
|
||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
import { routeAiProfileSchema, type RouteAiProfile } from "@mmapp/contracts/mcp-routing"
|
import { routeAiProfileSchema, type RouteAiProfile } from "@mmapp/contracts/mcp-routing"
|
||||||
import { hasPermission } from "../../lib/permissions.js"
|
import { hasPermission } from "../../lib/permissions.js"
|
||||||
@@ -166,6 +167,13 @@ export async function advanceRoutingControllerBaseline(rawBaseline: unknown, pro
|
|||||||
const configuration = await loadScopedConfigurationPacket(input, requestSeal, { operationId, stepIndex: 0 })
|
const configuration = await loadScopedConfigurationPacket(input, requestSeal, { operationId, stepIndex: 0 })
|
||||||
const network = await loadScopedNetworkCheckpoint(input, requestSeal)
|
const network = await loadScopedNetworkCheckpoint(input, requestSeal)
|
||||||
if (!configuration || configuration.packet.revision !== 2 || !network || network.status !== "passed") throw fail()
|
if (!configuration || configuration.packet.revision !== 2 || !network || network.status !== "passed") throw fail()
|
||||||
|
// A genuinely prepared effect check may pause controller advancement without
|
||||||
|
// erasing the separate successful manual configuration/control-probe receipt.
|
||||||
|
if (Object.hasOwn(input, "originalEffectSelection")) {
|
||||||
|
const effects = originalEffectSelectionSchema.safeParse(input.originalEffectSelection)
|
||||||
|
if (!effects.success || effects.data.status !== "prepared" || network.originalEffects?.status !== "observed-expected" ||
|
||||||
|
network.originalEffects.selectionIdentity !== fingerprint(effects.data) || network.originalEffects.switchEffectObserved !== true || !network.originalEffects.transition?.changedPrefixes.length) throw fail()
|
||||||
|
}
|
||||||
const packet = configuration.packet, result = row.result
|
const packet = configuration.packet, result = row.result
|
||||||
if (packet.operationId !== operationId || packet.stepIndex !== 0 || packet.nativeId !== original.nativeId ||
|
if (packet.operationId !== operationId || packet.stepIndex !== 0 || packet.nativeId !== original.nativeId ||
|
||||||
packet.originalCandidateId !== original.candidateId || packet.originalTargetProof !== original.targetProof ||
|
packet.originalCandidateId !== original.candidateId || packet.originalTargetProof !== original.targetProof ||
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { readFile } from "node:fs/promises"
|
|||||||
import { randomUUID } from "node:crypto"
|
import { randomUUID } from "node:crypto"
|
||||||
import ts from "typescript"
|
import ts from "typescript"
|
||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
import { routeAiProfileSchema } from "@mmapp/contracts/mcp-routing"
|
import { routeAiControllerBlockReason, routeAiProfileSchema, usesScopedRouteAiContext } from "@mmapp/contracts/mcp-routing"
|
||||||
import { fingerprint, McpError } from "./security.js"
|
import { fingerprint, McpError } from "./security.js"
|
||||||
|
|
||||||
type Handler = { prepare?: (input: Record<string, unknown>, context: unknown) => Promise<Record<string, unknown>>; execute?: (input: Record<string, unknown>) => Promise<unknown> }
|
type Handler = { prepare?: (input: Record<string, unknown>, context: unknown) => Promise<Record<string, unknown>>; execute?: (input: Record<string, unknown>) => Promise<unknown> }
|
||||||
@@ -32,10 +32,10 @@ async function fixture() {
|
|||||||
const profileModule = await load("./routing-profiles.ts", {
|
const profileModule = await load("./routing-profiles.ts", {
|
||||||
"./routing-controller-overlap.js": overlap, "./security.js": { fingerprint, McpError, accessGeneration: async () => "g" },
|
"./routing-controller-overlap.js": overlap, "./security.js": { fingerprint, McpError, accessGeneration: async () => "g" },
|
||||||
"./routing-controller-baseline.js": { prepareRoutingControllerBaseline: async () => { baselinePreparations++; return { packet: {}, proof: "inert-baseline" } }, assertRoutingControllerBaseline: async () => {}, routingControllerBaselinePrincipal: async () => principal, advanceRoutingControllerBaseline: async () => {} },
|
"./routing-controller-baseline.js": { prepareRoutingControllerBaseline: async () => { baselinePreparations++; return { packet: {}, proof: "inert-baseline" } }, assertRoutingControllerBaseline: async () => {}, routingControllerBaselinePrincipal: async () => principal, advanceRoutingControllerBaseline: async () => {} },
|
||||||
"./routing-community-public.js": { publicRoutingControllerState: () => ({}) }, "node:crypto": { randomUUID }, zod: { z }, "@mmapp/contracts/mcp-routing": { routeAiProfileSchema }, "../../db/index.js": { pool },
|
"./routing-community-public.js": { publicRoutingControllerState: () => ({}) }, "node:crypto": { randomUUID }, zod: { z }, "@mmapp/contracts/mcp-routing": { routeAiControllerBlockReason, routeAiProfileSchema, usesScopedRouteAiContext }, "../../db/index.js": { pool },
|
||||||
"./service-registry.js": { registerMcpService: (name: string, handler: Handler) => services.set(name, handler) },
|
"./service-registry.js": { registerMcpService: (name: string, handler: Handler) => services.set(name, handler) },
|
||||||
"./store.js": { audit: async () => {}, validatePrincipal: async () => connection }, "./routing-model.js": { decideRoutingSwitch: () => ({}) },
|
"./store.js": { audit: async () => {}, validatePrincipal: async () => connection }, "./routing-model.js": { decideRoutingSwitch: () => ({}) },
|
||||||
"./routing-community-context-model.js": { routeContextExecutionReason: () => null },
|
"./path-proof-purpose.js": { COMMUNITY_PATH_MAX_AGE_MS: 90000 },
|
||||||
"./routing-controller-fence.js": { saveControllerProfileCas: async () => { casWrites++; return { id: "saved", version: 1 } } },
|
"./routing-controller-fence.js": { saveControllerProfileCas: async () => { casWrites++; return { id: "saved", version: 1 } } },
|
||||||
"./routing-reservation-store.js": { createRoutingReservationStore: () => { throw new Error("Overlap-only fixture must not read or mutate routing charges") } },
|
"./routing-reservation-store.js": { createRoutingReservationStore: () => { throw new Error("Overlap-only fixture must not read or mutate routing charges") } },
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { readRoutingTopology } from "../../services/routing-topology-observation.js"
|
||||||
|
import { createRoutingSourceContext, readRoutingEndpoints } from "./routing-observation-source.js"
|
||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
import { dedupeVrfAliasSnapshots } from "../../services/vrf-production-action-binding.js"
|
import { dedupeVrfAliasSnapshots } from "../../services/vrf-production-action-binding.js"
|
||||||
import { pool } from "../../db/index.js"
|
import { pool } from "../../db/index.js"
|
||||||
@@ -177,14 +179,26 @@ export function registerRoutingControls(register: typeof add) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
register("routing.topology.read", "mm_routing_topology_get", "Наблюдаемая конфигурация маршрутизации", "network", "mm:network:read", "read", "servers", z.object({ serverId }).strict(), {
|
register("routing.topology.read", "mm_routing_topology_get", "Наблюдаемая конфигурация маршрутизации", "network", "mm:network:read", "read", "servers", z.object({ serverId }).strict(), {
|
||||||
read: async input => {
|
read: async (input, context) => {
|
||||||
const { client } = await routerClient(Number(input.serverId))
|
const sid = Number(input.serverId)
|
||||||
const paths = { interfaces: "/interface", ipv4: "/ip/route", ipv6: "/ipv6/route", tables: "/routing/table", rules: "/routing/rule", vrfs: "/ip/vrf", ospfNeighbors: "/routing/ospf/neighbor", bgpSessions: "/routing/bgp/session" }
|
if (!context.connection.policy.scope.serverIds.includes(sid)) throw new McpError("SERVER_DENIED", "Источник топологии вне разрешённой области", 403)
|
||||||
const entries = await Promise.all(Object.entries(paths).map(async ([key, path]) => {
|
const signal = AbortSignal.any([AbortSignal.timeout(30000), ...(context.signal ? [context.signal] : [])])
|
||||||
try { const values = await client.get<unknown>(path); const rows = Array.isArray(values) ? values : [values]; return [key, { items: rows.slice(0, 500), truncated: rows.length > 500, observed: true }] }
|
const authorize = async () => {
|
||||||
catch { return [key, { items: [], observed: false, reason: "Устройство не подтвердило возможность чтения" }] }
|
if (signal.aborted) throw new McpError("READ_CANCELLED", "Чтение топологии отменено или превысило срок", 409)
|
||||||
}))
|
const current = await effectiveMcpConnection(context.principal)
|
||||||
return { serverId: input.serverId, observedAt: new Date().toISOString(), resources: Object.fromEntries(entries), observedPath: false }
|
if (!hasPermission(current.owner_permissions, "mm:network:read") || !hasPermission(current.policy.permissions, "mm:network:read")) throw new McpError("PERMISSION_DENIED", "Право чтения сети отозвано", 403)
|
||||||
|
if (fingerprint(current.policy) !== fingerprint(context.connection.policy) || fingerprint(current.owner_permissions) !== fingerprint(context.connection.owner_permissions)) throw new McpError("RESET_REQUIRED", "Допуск изменился; повторите чтение топологии", 409)
|
||||||
|
}
|
||||||
|
await authorize()
|
||||||
|
const source = await createRoutingSourceContext(await readRoutingEndpoints([sid]))
|
||||||
|
if (source.expected.length !== 1) throw new McpError("DEVICE_UNAVAILABLE", "Источник топологии отсутствует или выключен", 409)
|
||||||
|
const beforeRead = async () => {
|
||||||
|
await authorize(); await source.assertCurrent()
|
||||||
|
if (signal.aborted) throw new McpError("READ_CANCELLED", "Чтение топологии отменено или превысило срок", 409)
|
||||||
|
}
|
||||||
|
await beforeRead()
|
||||||
|
const { client } = await routerClient(sid)
|
||||||
|
return { serverId: sid, ...await readRoutingTopology(client, beforeRead, signal) }
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
register("routing.ecmp.update", "mm_ecmp_plan", "Равная стоимость маршрутов ECMP и hash policy", "network", "mm:network:write", "write", "servers", z.object({ serverId, family, routeIds: z.array(objectId).min(2).max(8).refine(ids => new Set(ids).size === ids.length), distance: z.number().int().min(1).max(255).default(1), hashPolicy: z.enum(["l3", "l4", "l3-inner"]).optional() }).strict(), {
|
register("routing.ecmp.update", "mm_ecmp_plan", "Равная стоимость маршрутов ECMP и hash policy", "network", "mm:network:write", "write", "servers", z.object({ serverId, family, routeIds: z.array(objectId).min(2).max(8).refine(ids => new Set(ids).size === ids.length), distance: z.number().int().min(1).max(255).default(1), hashPolicy: z.enum(["l3", "l4", "l3-inner"]).optional() }).strict(), {
|
||||||
@@ -229,7 +243,7 @@ export function registerRoutingControls(register: typeof add) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
register("routing.vrf.update", "mm_vrf_plan", "Проверенный состав существующего VRF", "network", "mm:network:write", "write", "servers", z.object({ serverId, id: objectId, interfaces: z.array(text).min(1).max(100).refine(values => new Set(values).size === values.length) }).strict(), {
|
register("routing.vrf.update", "mm_vrf_plan", "Проверенный состав существующего VRF", "network", "mm:network:write", "write", "servers", z.object({ serverId, id: objectId, interfaces: z.array(text).min(1).max(100).refine(values => new Set(values).size === values.length) }).strict(), {
|
||||||
version: 3,
|
version: 4,
|
||||||
prepare: async (input, context) => {
|
prepare: async (input, context) => {
|
||||||
const sid = Number(input.serverId), { client } = await routerClient(sid), snapshot = await readRosSnapshot(sid, "vrf"), current = snapshot.rows.find(row => row[".id"] === input.id)
|
const sid = Number(input.serverId), { client } = await routerClient(sid), snapshot = await readRosSnapshot(sid, "vrf"), current = snapshot.rows.find(row => row[".id"] === input.id)
|
||||||
if (!current || current.name === "main" || !hasManagedCommentPrefix(String(current.comment ?? ""))) throw new McpError("PROTECTED_OBJECT", "Изменять состав можно только у существующего VRF приложения")
|
if (!current || current.name === "main" || !hasManagedCommentPrefix(String(current.comment ?? ""))) throw new McpError("PROTECTED_OBJECT", "Изменять состав можно только у существующего VRF приложения")
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ function fixture(service='routing.community') {
|
|||||||
const principal={connectionId:id(3), credentialId:id(4), credentialExpiresAt:'2026-11-02T13:00:00.000Z'};
|
const principal={connectionId:id(3), credentialId:id(4), credentialExpiresAt:'2026-11-02T13:00:00.000Z'};
|
||||||
const authority={packet:{profileId:id(5),profileVersion:7,connectionId:id(3),credentialId:id(4),ownerId:'owner'}, proof:'a'.repeat(64)};
|
const authority={packet:{profileId:id(5),profileVersion:7,connectionId:id(3),credentialId:id(4),ownerId:'owner'}, proof:'a'.repeat(64)};
|
||||||
const metadata={operationId:id(1),planId:id(2),operationName:'routing.community.switch',operationVersion:7,ownerId:'owner',phase:'forward',stepIndex:0,serviceName:service,principal,policyVersion:2,generation:'generation',originalExpiresAt:now.getTime()+60000,sealedEvidenceProof:sha('evidence'),sealedPayloadProof:sha('payload'),serviceInput:{profile:{autoRecovery:false},persistentProfileAuthority:authority}};
|
const metadata={operationId:id(1),planId:id(2),operationName:'routing.community.switch',operationVersion:7,ownerId:'owner',phase:'forward',stepIndex:0,serviceName:service,principal,policyVersion:2,generation:'generation',originalExpiresAt:now.getTime()+60000,sealedEvidenceProof:sha('evidence'),sealedPayloadProof:sha('payload'),serviceInput:{profile:{autoRecovery:false},persistentProfileAuthority:authority}};
|
||||||
const source={operation_state:'running',plan_state:'executing',policy_version:2,generation:'generation',approved_by:'human',observed_at:now,evidence_sealed:'evidence',payload_sealed:'payload',step_state:'intent',intended_state:redact({kind:'service',service,input:metadata.serviceInput})};
|
const source={operation_state:'running',plan_state:'executing',policy_version:2,generation:'generation',approved_by:service==='routing.community.scoped'?'owner':'human',observed_at:now,evidence_sealed:'evidence',payload_sealed:'payload',step_state:'intent',intended_state:redact({kind:'service',service,input:metadata.serviceInput})};
|
||||||
const binding={revision:1,operationId:id(1),planId:id(2),stepIndex:0,profileId:id(5),profileVersion:7,connectionId:id(3),ownerId:'owner',principal,service,operationVersion:7,authorityProof:authority.proof,evidenceProof:fingerprint('evidence'),payloadProof:fingerprint('payload'),scope:[1],createdAt:now.toISOString(),chargeUnits:1};
|
const binding={revision:1,operationId:id(1),planId:id(2),stepIndex:0,profileId:id(5),profileVersion:7,connectionId:id(3),ownerId:'owner',principal,service,operationVersion:7,authorityProof:authority.proof,evidenceProof:fingerprint('evidence'),payloadProof:fingerprint('payload'),scope:[1],createdAt:now.toISOString(),chargeUnits:1};
|
||||||
const row={source_operation_id:id(1),source_plan_id:id(2),source_step_index:0,profile_id:id(5),connection_id:id(3),charge_units:1,binding_sealed:null,binding_proof:null,created_at:now};
|
const row={source_operation_id:id(1),source_plan_id:id(2),source_step_index:0,profile_id:id(5),connection_id:id(3),charge_units:1,binding_sealed:null,binding_proof:null,created_at:now};
|
||||||
let chargeRows=[row], sourceRows=[source], reservedRows=[], decryptCalls=0, nativeWrites=0, keyUnavailable=false;
|
let chargeRows=[row], sourceRows=[source], reservedRows=[], decryptCalls=0, nativeWrites=0, keyUnavailable=false;
|
||||||
@@ -59,6 +59,7 @@ function fixture(service='routing.community') {
|
|||||||
run:async()=>{await verifier(db,metadata,'owner');nativeWrites++;},writes:()=>nativeWrites,decryptCalls:()=>decryptCalls};
|
run:async()=>{await verifier(db,metadata,'owner');nativeWrites++;},writes:()=>nativeWrites,decryptCalls:()=>decryptCalls};
|
||||||
}
|
}
|
||||||
for(const service of ['routing.community','routing.community.scoped'])test(`exact ${service} running charge passes without mutation`,async()=>{const f=fixture(service);await f.run();assert.equal(f.writes(),1);assert.equal(f.queries.length,3);assert.ok(f.queries.every(({sql})=>sql.startsWith('SELECT ')));assert.ok(f.queries[0].sql.includes('LIMIT 2'));});
|
for(const service of ['routing.community','routing.community.scoped'])test(`exact ${service} running charge passes without mutation`,async()=>{const f=fixture(service);await f.run();assert.equal(f.writes(),1);assert.equal(f.queries.length,3);assert.ok(f.queries.every(({sql})=>sql.startsWith('SELECT ')));assert.ok(f.queries[0].sql.includes('LIMIT 2'));});
|
||||||
|
test('scoped transport refuses delegated approval before reading the charge',async()=>{const f=fixture('routing.community.scoped');f.source.approved_by='delegated-admin';await assert.rejects(f.run);assert.equal(f.writes(),0);assert.equal(f.queries.length,1);});
|
||||||
const mutations=[
|
const mutations=[
|
||||||
['missing charge',f=>f.noCharge()],['duplicate charge',f=>f.duplicateCharge()],['reserved overlap',f=>f.reserved()],['ambiguous source',f=>f.ambiguousSource()],
|
['missing charge',f=>f.noCharge()],['duplicate charge',f=>f.duplicateCharge()],['reserved overlap',f=>f.reserved()],['ambiguous source',f=>f.ambiguousSource()],
|
||||||
['pending operation',f=>f.source.operation_state='pending'],['applied operation',f=>f.source.operation_state='applied'],['terminal step',f=>f.source.step_state='verified'],['draft plan',f=>f.source.plan_state='draft'],['missing approval',f=>f.source.approved_by=null],['policy drift',f=>f.source.policy_version++],['generation drift',f=>f.source.generation='other'],
|
['pending operation',f=>f.source.operation_state='pending'],['applied operation',f=>f.source.operation_state='applied'],['terminal step',f=>f.source.step_state='verified'],['draft plan',f=>f.source.plan_state='draft'],['missing approval',f=>f.source.approved_by=null],['policy drift',f=>f.source.policy_version++],['generation drift',f=>f.source.generation='other'],
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ function cursor(rows:unknown[],onFetch?:()=>void) {
|
|||||||
test("historical cursor authenticates more than two pages of genuine nonpersistent classifications",async()=>{
|
test("historical cursor authenticates more than two pages of genuine nonpersistent classifications",async()=>{
|
||||||
const dir=await mkdtemp(join(tmpdir(),"mm-manual-history-unit-")),previous=process.env.MCP_STATE_DIR;process.env.MCP_STATE_DIR=dir
|
const dir=await mkdtemp(join(tmpdir(),"mm-manual-history-unit-")),previous=process.env.MCP_STATE_DIR;process.env.MCP_STATE_DIR=dir
|
||||||
try {
|
try {
|
||||||
const rows=[];for(let n=0;n<25;n++)rows.push((await fixture(false,n%2?6:7)).row)
|
const rows=[];for(let n=0;n<25;n++)rows.push((await fixture(false,6+n%3)).row)
|
||||||
const f=cursor(rows)
|
const f=cursor(rows)
|
||||||
await assertLegacyManualHistoryWithinOwner(f.db,randomUUID(),at,()=>{})
|
await assertLegacyManualHistoryWithinOwner(f.db,randomUUID(),at,()=>{})
|
||||||
assert.equal(f.calls.filter(sql=>sql.startsWith("FETCH")).length,3)
|
assert.equal(f.calls.filter(sql=>sql.startsWith("FETCH")).length,3)
|
||||||
@@ -76,8 +76,10 @@ test("historical authentication, active persistent refusal, future rejection and
|
|||||||
await assertLegacyManualHistoryWithinOwner(cursor([f.row]).db,f.profileId,"2026-11-01T13:01:00.001Z",()=>{})
|
await assertLegacyManualHistoryWithinOwner(cursor([f.row]).db,f.profileId,"2026-11-01T13:01:00.001Z",()=>{})
|
||||||
const expiredTamper={...f.row,source_evidence:null}
|
const expiredTamper={...f.row,source_evidence:null}
|
||||||
await assert.rejects(assertLegacyManualHistoryWithinOwner(cursor([expiredTamper]).db,f.profileId,"2026-11-01T14:00:00.000Z",()=>{}))
|
await assert.rejects(assertLegacyManualHistoryWithinOwner(cursor([expiredTamper]).db,f.profileId,"2026-11-01T14:00:00.000Z",()=>{}))
|
||||||
const current={...f.row,recovery_version:7}
|
for (const version of [7,8]) {
|
||||||
|
const current={...f.row,recovery_version:version}
|
||||||
await assert.rejects(assertLegacyManualHistoryWithinOwner(cursor([current]).db,f.profileId,"2026-11-01T14:00:00.000Z",()=>{}),/lacks durable charge/)
|
await assert.rejects(assertLegacyManualHistoryWithinOwner(cursor([current]).db,f.profileId,"2026-11-01T14:00:00.000Z",()=>{}),/lacks durable charge/)
|
||||||
|
}
|
||||||
const stripped={...f.input};delete stripped.persistentProfileAuthority
|
const stripped={...f.input};delete stripped.persistentProfileAuthority
|
||||||
await assert.rejects(assertLegacyManualHistoryWithinOwner(cursor([{...f.row,source_evidence:await encryptSecret(JSON.stringify({steps:[{kind:"service",service:"routing.community.scoped",input:stripped}]}))}]).db,f.profileId,at,()=>{}))
|
await assert.rejects(assertLegacyManualHistoryWithinOwner(cursor([{...f.row,source_evidence:await encryptSecret(JSON.stringify({steps:[{kind:"service",service:"routing.community.scoped",input:stripped}]}))}]).db,f.profileId,at,()=>{}))
|
||||||
const future={...f.intent,createdAt:"2026-11-01T12:00:00.001Z"}
|
const future={...f.intent,createdAt:"2026-11-01T12:00:00.001Z"}
|
||||||
@@ -112,14 +114,14 @@ test("state transfer cap MODEL refuses oversized or NULL history as a whole, wit
|
|||||||
}finally{if(previous===undefined)delete process.env.MCP_STATE_DIR;else process.env.MCP_STATE_DIR=previous;await rm(dir,{recursive:true,force:true})}
|
}finally{if(previous===undefined)delete process.env.MCP_STATE_DIR;else process.env.MCP_STATE_DIR=previous;await rm(dir,{recursive:true,force:true})}
|
||||||
})
|
})
|
||||||
|
|
||||||
async function retentionFixture() {
|
async function retentionFixture(version = 7) {
|
||||||
const f=await fixture(true,7), authority=f.input.persistentProfileAuthority as {packet:{profileVersion:number;scope:number[]};proof:string}
|
const f=await fixture(true,version), authority=f.input.persistentProfileAuthority as {packet:{profileVersion:number;scope:number[]};proof:string}
|
||||||
const payload=await encryptSecret(JSON.stringify({MODEL:true}))
|
const payload=await encryptSecret(JSON.stringify({MODEL:true}))
|
||||||
const row={...f.row,recovery_payload:payload,authority_profile_version:1}
|
const row={...f.row,recovery_payload:payload,authority_profile_version:1}
|
||||||
const binding=manualInverseReceiptSchema.shape.binding.parse({revision:1,recoveryOperationId:f.intent.operationId,recoveryPlanId:row.recovery_plan_id,
|
const binding=manualInverseReceiptSchema.shape.binding.parse({revision:1,recoveryOperationId:f.intent.operationId,recoveryPlanId:row.recovery_plan_id,
|
||||||
sourceOperationId:f.intent.sourceOperationId,sourcePlanId:row.source_plan_id,sourceStepIndex:0,profileId:f.profileId,profileVersion:1,
|
sourceOperationId:f.intent.sourceOperationId,sourcePlanId:row.source_plan_id,sourceStepIndex:0,profileId:f.profileId,profileVersion:1,
|
||||||
connectionId:f.intent.connectionId,ownerId:f.intent.ownerId,originalPrincipal:f.input.principal,recoveryPrincipal:f.input.principal,
|
connectionId:f.intent.connectionId,ownerId:f.intent.ownerId,originalPrincipal:f.input.principal,recoveryPrincipal:f.input.principal,
|
||||||
operationVersion:7,sourceVersion:7,authorityProof:authority.proof,sourceProof:f.intent.sourceProof,sourceRequestProof:f.input.requestProof,
|
operationVersion:version,sourceVersion:7,authorityProof:authority.proof,sourceProof:f.intent.sourceProof,sourceRequestProof:f.input.requestProof,
|
||||||
recoveryRequestProof:f.intent.requestSeal,sourceEvidenceProof:fingerprint(row.source_evidence),recoveryEvidenceProof:fingerprint(row.recovery_evidence),
|
recoveryRequestProof:f.intent.requestSeal,sourceEvidenceProof:fingerprint(row.source_evidence),recoveryEvidenceProof:fingerprint(row.recovery_evidence),
|
||||||
recoveryPayloadProof:fingerprint(payload),scope:authority.packet.scope,createdAt:at,chargeUnits:1})
|
recoveryPayloadProof:fingerprint(payload),scope:authority.packet.scope,createdAt:at,chargeUnits:1})
|
||||||
const receipt={binding,proof:await sign(binding,"routing-manual-inverse-charge-v1")}
|
const receipt={binding,proof:await sign(binding,"routing-manual-inverse-charge-v1")}
|
||||||
@@ -153,7 +155,8 @@ function retentionOwner(f:Awaited<ReturnType<typeof retentionFixture>>,present=t
|
|||||||
test('retention MODEL intent2 authenticates SQL clock after parent GC; unknown noReplay anchor survives',async()=>{
|
test('retention MODEL intent2 authenticates SQL clock after parent GC; unknown noReplay anchor survives',async()=>{
|
||||||
const dir=await mkdtemp(join(tmpdir(),'mm-intent2-retention-')),previous=process.env.MCP_STATE_DIR;process.env.MCP_STATE_DIR=dir
|
const dir=await mkdtemp(join(tmpdir(),'mm-intent2-retention-')),previous=process.env.MCP_STATE_DIR;process.env.MCP_STATE_DIR=dir
|
||||||
try {
|
try {
|
||||||
const f=await retentionFixture(),owner=retentionOwner(f),before=fingerprint(f.row)
|
for (const version of [7,8]) {
|
||||||
|
const f=await retentionFixture(version),owner=retentionOwner(f),before=fingerprint(f.row)
|
||||||
// SQL aging and the transaction owner below are MODELS, never genuine SQL.
|
// SQL aging and the transaction owner below are MODELS, never genuine SQL.
|
||||||
await assertLegacyManualHistoryWithinOwner(owner.db,f.profileId,'2026-11-03T12:00:00.000Z',()=>{})
|
await assertLegacyManualHistoryWithinOwner(owner.db,f.profileId,'2026-11-03T12:00:00.000Z',()=>{})
|
||||||
assert.deepEqual(await retainManualInverseChargesWithinOwner(owner.db,1),{deleted:1})
|
assert.deepEqual(await retainManualInverseChargesWithinOwner(owner.db,1),{deleted:1})
|
||||||
@@ -164,6 +167,7 @@ test('retention MODEL intent2 authenticates SQL clock after parent GC; unknown n
|
|||||||
assert(candidate.indexOf("state IN('pending','running')")<candidate.indexOf('LIMIT 100'))
|
assert(candidate.indexOf("state IN('pending','running')")<candidate.indexOf('LIMIT 100'))
|
||||||
assert(candidate.includes('r.authority_profile_version IS NOT NULL'))
|
assert(candidate.includes('r.authority_profile_version IS NOT NULL'))
|
||||||
assert.equal(f.intent.revision,2);assert.equal(await decryptSecretExisting(f.row.intent_sealed),JSON.stringify(f.intent))
|
assert.equal(f.intent.revision,2);assert.equal(await decryptSecretExisting(f.row.intent_sealed),JSON.stringify(f.intent))
|
||||||
|
}
|
||||||
}finally{if(previous===undefined)delete process.env.MCP_STATE_DIR;else process.env.MCP_STATE_DIR=previous;await rm(dir,{recursive:true,force:true})}
|
}finally{if(previous===undefined)delete process.env.MCP_STATE_DIR;else process.env.MCP_STATE_DIR=previous;await rm(dir,{recursive:true,force:true})}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -182,6 +186,11 @@ test('retention MODEL refuses recent/future/malformed copy, present corrupt pare
|
|||||||
const t=retentionOwner(tamper);await assert.rejects(retainManualInverseChargesWithinOwner(t.db,1),/MAC/);assert.equal(t.deleted.length,0)
|
const t=retentionOwner(tamper);await assert.rejects(retainManualInverseChargesWithinOwner(t.db,1),/MAC/);assert.equal(t.deleted.length,0)
|
||||||
const legacy=await fixture(true,7)
|
const legacy=await fixture(true,7)
|
||||||
await assert.rejects(assertLegacyManualHistoryWithinOwner(cursor([legacy.row]).db,legacy.profileId,'2026-11-03T12:00:00.000Z',()=>{}),/lacks durable/)
|
await assert.rejects(assertLegacyManualHistoryWithinOwner(cursor([legacy.row]).db,legacy.profileId,'2026-11-03T12:00:00.000Z',()=>{}),/lacks durable/)
|
||||||
|
const wrongVersion=await retentionFixture(7);wrongVersion.row.recovery_version=8
|
||||||
|
const mismatched=retentionOwner(wrongVersion)
|
||||||
|
await assert.rejects(assertLegacyManualHistoryWithinOwner(mismatched.db,wrongVersion.profileId,'2026-11-03T12:00:00.000Z',()=>{}),/signed allocation/)
|
||||||
|
await assert.rejects(retainManualInverseChargesWithinOwner(mismatched.db,1),/hint/)
|
||||||
|
assert.equal(mismatched.deleted.length,0)
|
||||||
// Revision2 payload proof cannot be replaced by an authentic stamp from a different payload.
|
// Revision2 payload proof cannot be replaced by an authentic stamp from a different payload.
|
||||||
const payload=await retentionFixture();payload.row.recovery_payload=await encryptSecret('{}')
|
const payload=await retentionFixture();payload.row.recovery_payload=await encryptSecret('{}')
|
||||||
await assert.rejects(assertLegacyManualHistoryWithinOwner(retentionOwner(payload,false).db,payload.profileId,'2026-11-03T12:00:00.000Z',()=>{}),/payload/)
|
await assert.rejects(assertLegacyManualHistoryWithinOwner(retentionOwner(payload,false).db,payload.profileId,'2026-11-03T12:00:00.000Z',()=>{}),/payload/)
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export async function classifyManualInverseHistory(row:HistoricalRow,observedAt:
|
|||||||
intent.receiptId!==row.recovery_receipt_id || intent.validUntil!==iso(row.deadline) ||
|
intent.receiptId!==row.recovery_receipt_id || intent.validUntil!==iso(row.deadline) ||
|
||||||
row.source_connection_id!==intent.connectionId || row.recovery_connection_id!==intent.connectionId ||
|
row.source_connection_id!==intent.connectionId || row.recovery_connection_id!==intent.connectionId ||
|
||||||
row.source_action!=="routing.community.switch" || row.source_version!==7 ||
|
row.source_action!=="routing.community.switch" || row.source_version!==7 ||
|
||||||
row.recovery_action!=="operations.recover" || ![6,7].includes(row.recovery_version??0) ||
|
row.recovery_action!=="operations.recover" || ![6,7,8].includes(row.recovery_version??0) ||
|
||||||
!["partial","applied"].includes(row.source_state??"")) throw fail("Historical manual intent/source identity differs")
|
!["partial","applied"].includes(row.source_state??"")) throw fail("Historical manual intent/source identity differs")
|
||||||
const source=await sealedJson(row.source_evidence) as ChangeEvidence, recovery=await sealedJson(row.recovery_evidence) as ChangeEvidence
|
const source=await sealedJson(row.source_evidence) as ChangeEvidence, recovery=await sealedJson(row.recovery_evidence) as ChangeEvidence
|
||||||
const step=source.steps?.[0], inverse=recovery.steps?.[0]
|
const step=source.steps?.[0], inverse=recovery.steps?.[0]
|
||||||
@@ -141,7 +141,7 @@ export async function assertLegacyManualHistoryWithinOwner(db:PoolClient,profile
|
|||||||
// Authenticate any durable inverse row before treating this old manual
|
// Authenticate any durable inverse row before treating this old manual
|
||||||
// identity as represented by the normal profile ledger.
|
// identity as represented by the normal profile ledger.
|
||||||
const {authenticateManualInverseHistoryWithinOwner}=await import("./routing-manual-inverse-store.js")
|
const {authenticateManualInverseHistoryWithinOwner}=await import("./routing-manual-inverse-store.js")
|
||||||
if(await authenticateManualInverseHistoryWithinOwner(db,classified.intent,classified.authority,observedAt,{sourceEvidenceProof:fingerprint(row.source_evidence),recoveryEvidenceProof:fingerprint(row.recovery_evidence),recoveryPlanId:row.recovery_plan_id,sourcePlanId:row.source_plan_id,sourceRequestProof:classified.sourceRequestProof,sourcePrincipal:classified.sourcePrincipal,recoveryPrincipal:classified.recoveryPrincipal})) continue
|
if(await authenticateManualInverseHistoryWithinOwner(db,classified.intent,classified.authority,observedAt,{sourceEvidenceProof:fingerprint(row.source_evidence),recoveryEvidenceProof:fingerprint(row.recovery_evidence),recoveryPlanId:row.recovery_plan_id,recoveryVersion:row.recovery_version,sourcePlanId:row.source_plan_id,sourceRequestProof:classified.sourceRequestProof,sourcePrincipal:classified.sourcePrincipal,recoveryPrincipal:classified.recoveryPrincipal})) continue
|
||||||
if(row.recovery_version!==6) throw fail("Current persistent manual inverse lacks durable charge")
|
if(row.recovery_version!==6) throw fail("Current persistent manual inverse lacks durable charge")
|
||||||
if(classified.authority.packet.profileId!==profileId) continue
|
if(classified.authority.packet.profileId!==profileId) continue
|
||||||
// Historical v6 sealed evidence.validUntil bounded admission; source and
|
// Historical v6 sealed evidence.validUntil bounded admission; source and
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { effectiveMcpConnection } from "./store.js"
|
|||||||
import { decryptSecretExisting, encryptSecret, fingerprint, McpError, privateFingerprinterExisting, privateSecretFingerprint } from "./security.js"
|
import { decryptSecretExisting, encryptSecret, fingerprint, McpError, privateFingerprinterExisting, privateSecretFingerprint } from "./security.js"
|
||||||
import { routingProfileAuthorityBindingSchema } from "./routing-profile-authority.js"
|
import { routingProfileAuthorityBindingSchema } from "./routing-profile-authority.js"
|
||||||
import type { RoutingSwitchLedgerEntry } from "./routing-switch-ledger-contract.js"
|
import type { RoutingSwitchLedgerEntry } from "./routing-switch-ledger-contract.js"
|
||||||
|
import { findMcpOperation } from "./registry.js"
|
||||||
|
|
||||||
const fail = (message: string) => new McpError("STATE_CONFLICT", message, 409)
|
const fail = (message: string) => new McpError("STATE_CONFLICT", message, 409)
|
||||||
const uuid = z.string().uuid(), proof = z.string().regex(/^[a-f0-9]{64}$/), stamp = z.string().datetime()
|
const uuid = z.string().uuid(), proof = z.string().regex(/^[a-f0-9]{64}$/), stamp = z.string().datetime()
|
||||||
@@ -14,7 +15,7 @@ const principal = z.object({ connectionId: uuid, credentialId: uuid, credentialE
|
|||||||
export const manualInverseBindingSchema = z.object({ revision: z.literal(1), recoveryOperationId: uuid, recoveryPlanId: uuid,
|
export const manualInverseBindingSchema = z.object({ revision: z.literal(1), recoveryOperationId: uuid, recoveryPlanId: uuid,
|
||||||
sourceOperationId: uuid, sourcePlanId: uuid, sourceStepIndex: z.literal(0), profileId: uuid, profileVersion: z.number().int().positive(),
|
sourceOperationId: uuid, sourcePlanId: uuid, sourceStepIndex: z.literal(0), profileId: uuid, profileVersion: z.number().int().positive(),
|
||||||
connectionId: uuid, ownerId: z.string().min(1).max(1000), originalPrincipal: principal, recoveryPrincipal: principal,
|
connectionId: uuid, ownerId: z.string().min(1).max(1000), originalPrincipal: principal, recoveryPrincipal: principal,
|
||||||
operationVersion: z.literal(7), sourceVersion: z.literal(7), authorityProof: proof, sourceProof: proof,
|
operationVersion: z.union([z.literal(7), z.literal(8)]), sourceVersion: z.literal(7), authorityProof: proof, sourceProof: proof,
|
||||||
sourceRequestProof: proof, recoveryRequestProof: proof, sourceEvidenceProof: proof,
|
sourceRequestProof: proof, recoveryRequestProof: proof, sourceEvidenceProof: proof,
|
||||||
recoveryEvidenceProof: proof, recoveryPayloadProof: proof,
|
recoveryEvidenceProof: proof, recoveryPayloadProof: proof,
|
||||||
scope: z.array(z.number().int().positive().safe()).min(1).max(1000), createdAt: stamp, chargeUnits: z.literal(1) }).strict()
|
scope: z.array(z.number().int().positive().safe()).min(1).max(1000), createdAt: stamp, chargeUnits: z.literal(1) }).strict()
|
||||||
@@ -74,7 +75,8 @@ export async function selectManualInverseAdmissionWithinOwner(db: PoolClient, pl
|
|||||||
if (plan.operation_id!=="operations.recover") return undefined
|
if (plan.operation_id!=="operations.recover") return undefined
|
||||||
const step=evidence.steps[0]
|
const step=evidence.steps[0]
|
||||||
if (evidence.steps.length!==1 || step.kind!=="service" || step.service!=="routing.community.scoped.recover") return undefined
|
if (evidence.steps.length!==1 || step.kind!=="service" || step.service!=="routing.community.scoped.recover") return undefined
|
||||||
if (plan.operation_version!==7 || step.input.mode!=="manual") throw fail("Manual recovery action version/mode differs")
|
if (plan.operation_version!==findMcpOperation(plan.operation_id)?.version) throw new McpError("PLAN_EXPIRED", "Версия операции изменилась; подготовьте новый план", 409)
|
||||||
|
if (step.input.mode!=="manual") throw fail("Manual recovery mode differs")
|
||||||
const { loadScopedManualRecoveryAdmissionWithinOwner } = await import("./routing-community-scoped-recovery.js")
|
const { loadScopedManualRecoveryAdmissionWithinOwner } = await import("./routing-community-scoped-recovery.js")
|
||||||
const loaded = await loadScopedManualRecoveryAdmissionWithinOwner(step.input, actor, db)
|
const loaded = await loadScopedManualRecoveryAdmissionWithinOwner(step.input, actor, db)
|
||||||
const { source } = loaded
|
const { source } = loaded
|
||||||
@@ -92,7 +94,7 @@ function expectedBinding(selected: NonNullable<Awaited<ReturnType<typeof selectM
|
|||||||
return manualInverseBindingSchema.parse({revision:1,recoveryOperationId:operationId,recoveryPlanId:plan.id,
|
return manualInverseBindingSchema.parse({revision:1,recoveryOperationId:operationId,recoveryPlanId:plan.id,
|
||||||
sourceOperationId:selected.source.row.id,sourcePlanId:selected.source.row.source_plan_id,sourceStepIndex:0,profileId:authority.profileId,profileVersion:authority.profileVersion,
|
sourceOperationId:selected.source.row.id,sourcePlanId:selected.source.row.source_plan_id,sourceStepIndex:0,profileId:authority.profileId,profileVersion:authority.profileVersion,
|
||||||
connectionId:actor.connectionId,ownerId:authority.ownerId,originalPrincipal:principal.parse(selected.input.principal),recoveryPrincipal:actor,
|
connectionId:actor.connectionId,ownerId:authority.ownerId,originalPrincipal:principal.parse(selected.input.principal),recoveryPrincipal:actor,
|
||||||
operationVersion:7,sourceVersion:7,authorityProof:selected.authority.proof,sourceProof:selected.source.sourceProof,
|
operationVersion:plan.operation_version,sourceVersion:selected.source.row.source_version,authorityProof:selected.authority.proof,sourceProof:selected.source.sourceProof,
|
||||||
sourceRequestProof:selected.input.requestProof,recoveryRequestProof:selected.recoveryInput.requestProof,
|
sourceRequestProof:selected.input.requestProof,recoveryRequestProof:selected.recoveryInput.requestProof,
|
||||||
sourceEvidenceProof:selected.sourceEvidenceProof,recoveryEvidenceProof:fingerprint(plan.evidence.sealed),
|
sourceEvidenceProof:selected.sourceEvidenceProof,recoveryEvidenceProof:fingerprint(plan.evidence.sealed),
|
||||||
recoveryPayloadProof:fingerprint(plan.payload.sealed),scope:authority.scope,createdAt:observedAt,chargeUnits:1})
|
recoveryPayloadProof:fingerprint(plan.payload.sealed),scope:authority.scope,createdAt:observedAt,chargeUnits:1})
|
||||||
@@ -134,7 +136,7 @@ export async function admitManualInverseWithinOwner(db: PoolClient, operationId:
|
|||||||
/** A committed charge is required before the existing manual native intent.
|
/** A committed charge is required before the existing manual native intent.
|
||||||
* This authenticates identity only; existing live/native authority still runs. */
|
* This authenticates identity only; existing live/native authority still runs. */
|
||||||
export async function assertManualInverseChargeWithinOwner(db:PoolClient,operationId:string,input:Record<string,unknown>,actor:McpPrincipal) {
|
export async function assertManualInverseChargeWithinOwner(db:PoolClient,operationId:string,input:Record<string,unknown>,actor:McpPrincipal) {
|
||||||
const rows=(await db.query<StoredPlan>("SELECT p.* FROM mcp_operations o JOIN mcp_plans p ON p.id=o.plan_id WHERE o.id=$1 AND o.connection_id=$2 AND o.state='running' AND p.state='executing' AND p.operation_id='operations.recover' AND p.operation_version=7",[operationId,actor.connectionId])).rows
|
const rows=(await db.query<StoredPlan>("SELECT p.* FROM mcp_operations o JOIN mcp_plans p ON p.id=o.plan_id WHERE o.id=$1 AND o.connection_id=$2 AND o.state='running' AND p.state='executing' AND p.operation_id='operations.recover' AND p.operation_version=$3",[operationId,actor.connectionId,findMcpOperation("operations.recover")?.version])).rows
|
||||||
if(rows.length!==1) throw fail("Manual native claim requires current running action version")
|
if(rows.length!==1) throw fail("Manual native claim requires current running action version")
|
||||||
const plan=rows[0], evidence=JSON.parse(await decryptSecretExisting(plan.evidence.sealed)) as ChangeEvidence
|
const plan=rows[0], evidence=JSON.parse(await decryptSecretExisting(plan.evidence.sealed)) as ChangeEvidence
|
||||||
const step=evidence.steps[0]
|
const step=evidence.steps[0]
|
||||||
@@ -154,7 +156,7 @@ export async function assertManualInverseChargeWithinOwner(db:PoolClient,operati
|
|||||||
export async function authenticateManualInverseHistoryWithinOwner(db:PoolClient,
|
export async function authenticateManualInverseHistoryWithinOwner(db:PoolClient,
|
||||||
intent:{revision?:number;manualInverseCharge?:ManualInverseReceipt;operationId:string;sourceOperationId:string;sourceReceiptId:string;sourceProof:string;requestSeal:string;connectionId:string;ownerId:string},
|
intent:{revision?:number;manualInverseCharge?:ManualInverseReceipt;operationId:string;sourceOperationId:string;sourceReceiptId:string;sourceProof:string;requestSeal:string;connectionId:string;ownerId:string},
|
||||||
authority:z.infer<typeof routingProfileAuthorityBindingSchema>,observedAt:string,
|
authority:z.infer<typeof routingProfileAuthorityBindingSchema>,observedAt:string,
|
||||||
identity:{sourceEvidenceProof:string;recoveryEvidenceProof:string;recoveryPlanId:string|null;sourcePlanId:string|null;sourceRequestProof:unknown;sourcePrincipal:unknown;recoveryPrincipal:unknown}) {
|
identity:{sourceEvidenceProof:string;recoveryEvidenceProof:string;recoveryPlanId:string|null;recoveryVersion:number|null;sourcePlanId:string|null;sourceRequestProof:unknown;sourcePrincipal:unknown;recoveryPrincipal:unknown}) {
|
||||||
const rows=(await db.query<ChargeRow>(`SELECT ${columns} FROM mcp_routing_manual_inverse_charges WHERE recovery_operation_id=$1 OR source_operation_id=$2 LIMIT 3`,[intent.operationId,intent.sourceOperationId])).rows
|
const rows=(await db.query<ChargeRow>(`SELECT ${columns} FROM mcp_routing_manual_inverse_charges WHERE recovery_operation_id=$1 OR source_operation_id=$2 LIMIT 3`,[intent.operationId,intent.sourceOperationId])).rows
|
||||||
const copy = intent.revision === 2 ? await authenticateCopy(intent.manualInverseCharge, observedAt) : undefined
|
const copy = intent.revision === 2 ? await authenticateCopy(intent.manualInverseCharge, observedAt) : undefined
|
||||||
if (!rows.length && !copy) return false
|
if (!rows.length && !copy) return false
|
||||||
@@ -166,7 +168,8 @@ export async function authenticateManualInverseHistoryWithinOwner(db:PoolClient,
|
|||||||
value.profileId!==authority.packet.profileId || value.profileVersion!==authority.packet.profileVersion || value.authorityProof!==authority.proof ||
|
value.profileId!==authority.packet.profileId || value.profileVersion!==authority.packet.profileVersion || value.authorityProof!==authority.proof ||
|
||||||
value.connectionId!==intent.connectionId || value.ownerId!==intent.ownerId || value.sourceProof!==intent.sourceProof ||
|
value.connectionId!==intent.connectionId || value.ownerId!==intent.ownerId || value.sourceProof!==intent.sourceProof ||
|
||||||
value.recoveryRequestProof!==intent.requestSeal || value.sourceEvidenceProof!==identity.sourceEvidenceProof ||
|
value.recoveryRequestProof!==intent.requestSeal || value.sourceEvidenceProof!==identity.sourceEvidenceProof ||
|
||||||
value.recoveryEvidenceProof!==identity.recoveryEvidenceProof || value.recoveryPlanId!==identity.recoveryPlanId || value.sourcePlanId!==identity.sourcePlanId)throw fail("Historical inverse is not represented by its signed allocation")
|
value.recoveryEvidenceProof!==identity.recoveryEvidenceProof || value.recoveryPlanId!==identity.recoveryPlanId ||
|
||||||
|
value.operationVersion!==identity.recoveryVersion || value.sourcePlanId!==identity.sourcePlanId)throw fail("Historical inverse is not represented by its signed allocation")
|
||||||
// Only revision2 needs this extra one-row bounded projection; preserve the
|
// Only revision2 needs this extra one-row bounded projection; preserve the
|
||||||
// existing cursor page payload ceiling and legacy protocol untouched.
|
// existing cursor page payload ceiling and legacy protocol untouched.
|
||||||
const payload = copy ? (await db.query<{sealed:string|null}>("SELECT CASE WHEN octet_length(payload::text)<=1048576 THEN payload->>'sealed' END AS sealed FROM mcp_plans WHERE id=$1 LIMIT 2",[identity.recoveryPlanId])).rows : []
|
const payload = copy ? (await db.query<{sealed:string|null}>("SELECT CASE WHEN octet_length(payload::text)<=1048576 THEN payload->>'sealed' END AS sealed FROM mcp_plans WHERE id=$1 LIMIT 2",[identity.recoveryPlanId])).rows : []
|
||||||
@@ -201,11 +204,11 @@ export async function retainManualInverseChargesWithinOwner(db: PoolClient, rete
|
|||||||
" WHERE r.mode='manual' AND r.source_operation_id=$1 AND r.source_step_index=0 AND r.recovery_operation_id=$2",[candidate.source_operation_id,candidate.recovery_operation_id])).rows
|
" WHERE r.mode='manual' AND r.source_operation_id=$1 AND r.source_step_index=0 AND r.recovery_operation_id=$2",[candidate.source_operation_id,candidate.recovery_operation_id])).rows
|
||||||
if(rows.length!==1)throw fail("Retention intent/source pair disappeared")
|
if(rows.length!==1)throw fail("Retention intent/source pair disappeared")
|
||||||
const row=rows[0],classified=await classifyManualInverseHistory(row,observedAt)
|
const row=rows[0],classified=await classifyManualInverseHistory(row,observedAt)
|
||||||
if(classified.kind!=="persistent" || classified.intent.revision!==2 || row.recovery_version!==7 ||
|
if(classified.kind!=="persistent" || classified.intent.revision!==2 || row.recovery_version!==classified.intent.manualInverseCharge.binding.operationVersion ||
|
||||||
row.authority_profile_version!==candidate.profile_version || row.authority_profile_version!==classified.intent.manualInverseCharge.binding.profileVersion)
|
row.authority_profile_version!==candidate.profile_version || row.authority_profile_version!==classified.intent.manualInverseCharge.binding.profileVersion)
|
||||||
throw fail("Retention hint is not an authenticated persistent intent2")
|
throw fail("Retention hint is not an authenticated persistent intent2")
|
||||||
if(!await authenticateManualInverseHistoryWithinOwner(db,classified.intent,classified.authority,observedAt,{
|
if(!await authenticateManualInverseHistoryWithinOwner(db,classified.intent,classified.authority,observedAt,{
|
||||||
sourceEvidenceProof:fingerprint(row.source_evidence),recoveryEvidenceProof:fingerprint(row.recovery_evidence),recoveryPlanId:row.recovery_plan_id,sourcePlanId:row.source_plan_id,sourceRequestProof:classified.sourceRequestProof,sourcePrincipal:classified.sourcePrincipal,recoveryPrincipal:classified.recoveryPrincipal}))
|
sourceEvidenceProof:fingerprint(row.source_evidence),recoveryEvidenceProof:fingerprint(row.recovery_evidence),recoveryPlanId:row.recovery_plan_id,recoveryVersion:row.recovery_version,sourcePlanId:row.source_plan_id,sourceRequestProof:classified.sourceRequestProof,sourcePrincipal:classified.sourcePrincipal,recoveryPrincipal:classified.recoveryPrincipal}))
|
||||||
throw fail("Retention parent is not authenticated")
|
throw fail("Retention parent is not authenticated")
|
||||||
const result=await db.query("DELETE FROM mcp_routing_manual_inverse_charges WHERE recovery_operation_id=$1 AND source_operation_id=$2",[candidate.recovery_operation_id,candidate.source_operation_id])
|
const result=await db.query("DELETE FROM mcp_routing_manual_inverse_charges WHERE recovery_operation_id=$1 AND source_operation_id=$2",[candidate.recovery_operation_id,candidate.source_operation_id])
|
||||||
if(result.rowCount!==1)throw fail("Retention parent delete was not confirmed")
|
if(result.rowCount!==1)throw fail("Retention parent delete was not confirmed")
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import assert from "node:assert/strict"
|
import assert from "node:assert/strict"
|
||||||
import { test } from "node:test"
|
import { test } from "node:test"
|
||||||
import { routeAiProfileSchema, type RouteObservation } from "@mmapp/contracts/mcp-routing"
|
import { routeAiProfileSchema, type RouteObservation } from "@mmapp/contracts/mcp-routing"
|
||||||
import { rankRoutingCandidates, decideRoutingSwitch, type ControllerState, type PathHealth } from "./routing-model.js"
|
import { rankRoutingCandidates, decideRoutingSwitch, selectFailoverReserveProbes, type ControllerState, type PathHealth } from "./routing-model.js"
|
||||||
|
|
||||||
const now = Date.parse("2026-10-17T10:00:00Z"), at = new Date(now).toISOString()
|
const now = Date.parse("2026-10-17T10:00:00Z"), at = new Date(now).toISOString()
|
||||||
const profile = routeAiProfileSchema.parse({ name: "Test", serverId: 1, community: "65000:10", family: "ipv4", candidates: [
|
const profile = routeAiProfileSchema.parse({ name: "Test", serverId: 1, community: "65000:10", family: "ipv4", candidates: [
|
||||||
@@ -43,14 +43,63 @@ test("new unknown attempts block older successful data independently for each me
|
|||||||
test("failover requires three distinct failures and a freshly verified eligible standby", () => {
|
test("failover requires three distinct failures and a freshly verified eligible standby", () => {
|
||||||
const ranking = rankRoutingCandidates(profile, [...a, sample("b1", 1, 4, "wan-b"), sample("b2", 4, 3, "exit-b")], now)
|
const ranking = rankRoutingCandidates(profile, [...a, sample("b1", 1, 4, "wan-b"), sample("b2", 4, 3, "exit-b")], now)
|
||||||
let state: ControllerState = {}
|
let state: ControllerState = {}
|
||||||
const health = (id: string, status: PathHealth["status"] = "failed"): PathHealth[] => [{ id, candidateId: "a", status, observedAt: at }, { id: "reserve", candidateId: "b", status: "passed", observedAt: at }]
|
const health = (id: string, time = now, status: PathHealth["status"] = "failed"): PathHealth[] => {
|
||||||
|
const observedAt = new Date(time).toISOString()
|
||||||
|
return [{ id, candidateId: "a", status, observedAt }, { id: "reserve", candidateId: "b", status: "passed", observedAt }]
|
||||||
|
}
|
||||||
for (let i = 0; i < 5; i++) { const next = decideRoutingSwitch(profile, state, ranking, "a", now, health("one")); state = next.state; assert.equal(next.switchTo, null) }
|
for (let i = 0; i < 5; i++) { const next = decideRoutingSwitch(profile, state, ranking, "a", now, health("one")); state = next.state; assert.equal(next.switchTo, null) }
|
||||||
assert.equal(state.failureCount, 1)
|
assert.equal(state.failureCount, 1)
|
||||||
const second = decideRoutingSwitch(profile, state, ranking, "a", now, health("two")); assert.equal(second.switchTo, null)
|
const secondAt = now + 60000, thirdAt = now + 120000
|
||||||
assert.equal(decideRoutingSwitch(profile, second.state, ranking, "a", now, health("three")).switchTo, "b")
|
const second = decideRoutingSwitch(profile, state, ranking, "a", secondAt, health("two", secondAt)); assert.equal(second.switchTo, null)
|
||||||
assert.equal(decideRoutingSwitch(profile, second.state, ranking, "a", now, health("three", "unknown")).switchTo, null)
|
assert.equal(decideRoutingSwitch(profile, second.state, ranking, "a", thirdAt, health("three", thirdAt)).switchTo, "b")
|
||||||
assert.equal(decideRoutingSwitch(profile, { ...second.state, switches: [at, at, at] }, ranking, "a", now, health("three")).switchTo, null)
|
const unknown = decideRoutingSwitch(profile, second.state, ranking, "a", thirdAt, health("three", thirdAt, "unknown"))
|
||||||
assert.equal(decideRoutingSwitch(profile, second.state, ranking, "a", now + 100000, health("three")).switchTo, null)
|
assert.equal(unknown.switchTo, null)
|
||||||
|
assert.equal(unknown.state.failureObservedAt, undefined)
|
||||||
|
assert.equal(decideRoutingSwitch(profile, { ...second.state, switches: [at, at, at] }, ranking, "a", thirdAt, health("three", thirdAt)).switchTo, null)
|
||||||
|
assert.equal(decideRoutingSwitch(profile, second.state, ranking, "a", thirdAt + 100000, health("three", thirdAt)).switchTo, null)
|
||||||
|
})
|
||||||
|
test("failover starts a fresh series after downtime or legacy state and ignores reordered failures", () => {
|
||||||
|
const ranking = rankRoutingCandidates(profile, [...a, sample("b1", 1, 4, "wan-b"), sample("b2", 4, 3, "exit-b")], now)
|
||||||
|
const last = now + 60000
|
||||||
|
const persisted: ControllerState = JSON.parse(JSON.stringify({ failureCount: 2, failureKey: "two",
|
||||||
|
failureObservedAt: new Date(last).toISOString(), failingCandidateId: "a" }))
|
||||||
|
const check = (state: ControllerState, id: string, time: number, clock = time) => {
|
||||||
|
const observedAt = new Date(time).toISOString()
|
||||||
|
return decideRoutingSwitch(profile, state, ranking, "a", clock,
|
||||||
|
[{ id, candidateId: "a", status: "failed", observedAt }, { id: "reserve", candidateId: "b", status: "passed", observedAt }])
|
||||||
|
}
|
||||||
|
for (const result of [check(persisted, "two", last + 60000), check(persisted, "older", last - 1000, last),
|
||||||
|
check(persisted, "clock-rollback", last - 1000), check(persisted, "equal-time", last)]) {
|
||||||
|
assert.equal(result.switchTo, null)
|
||||||
|
assert.equal(result.state.failureCount, 2)
|
||||||
|
assert.equal(result.state.failureObservedAt, persisted.failureObservedAt)
|
||||||
|
}
|
||||||
|
for (const [state, time] of [[persisted, now + 4 * 3600000], [{ ...persisted, failureObservedAt: undefined }, last + 60000]] as const) {
|
||||||
|
const result = check(state, "next", time)
|
||||||
|
assert.equal(result.switchTo, null)
|
||||||
|
assert.equal(result.state.failureCount, 1)
|
||||||
|
assert.equal(result.state.failureObservedAt, new Date(time).toISOString())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
test("bounded failover probing keeps the highest eligible reserve and eventually visits every other eligible probe", () => {
|
||||||
|
const controlProbe = { targetInterface: "exit-a", expectedHops: [{ serverId: 3, interface: "exit-a" }] }
|
||||||
|
const configured = routeAiProfileSchema.parse({ ...profile, excludedCandidateIds: ["05"],
|
||||||
|
candidates: Array.from({ length: 100 }, (_, index) => ({ ...profile.candidates[0], id: String(index).padStart(2, "0"),
|
||||||
|
controlProbe: index === 4 ? undefined : controlProbe })) })
|
||||||
|
const ranking = rankRoutingCandidates(configured, a, now)
|
||||||
|
const expected = ranking.filter(row => row.eligible && row.candidate.controlProbe && !["00", "01"].includes(row.candidate.id)).map(row => row.candidate.id)
|
||||||
|
let cursor: string | undefined
|
||||||
|
for (const alternate of expected) {
|
||||||
|
const probes = selectFailoverReserveProbes(ranking, "00", cursor)
|
||||||
|
assert.deepEqual(probes.candidateIds, ["01", alternate])
|
||||||
|
assert.equal(probes.cursor, alternate)
|
||||||
|
cursor = probes.cursor
|
||||||
|
}
|
||||||
|
assert.deepEqual(selectFailoverReserveProbes(ranking, "00", cursor).candidateIds, ["01", "02"])
|
||||||
|
assert.deepEqual(selectFailoverReserveProbes(ranking, "00", "05").candidateIds, ["01", "02"])
|
||||||
|
const pinned = rankRoutingCandidates({ ...configured, pinnedCandidateId: "09" }, a, now)
|
||||||
|
assert.deepEqual(selectFailoverReserveProbes(pinned, "00"), { candidateIds: ["09"], cursor: undefined })
|
||||||
|
assert.deepEqual(selectFailoverReserveProbes(pinned, "09"), { candidateIds: [], cursor: undefined })
|
||||||
})
|
})
|
||||||
test("routing combines RTT, independent losses and bottleneck speed, never claims an observed path", () => {
|
test("routing combines RTT, independent losses and bottleneck speed, never claims an observed path", () => {
|
||||||
const ranked = rankRoutingCandidates(profile, a, now).find(row => row.candidate.id === "a")!
|
const ranked = rankRoutingCandidates(profile, a, now).find(row => row.candidate.id === "a")!
|
||||||
@@ -92,3 +141,63 @@ test("controller does not count polling the same sample as consecutive confirmat
|
|||||||
assert.equal(decideRoutingSwitch(profile, { ...state, switches: [at, at, at] }, ranks, "a", now).switchTo, null)
|
assert.equal(decideRoutingSwitch(profile, { ...state, switches: [at, at, at] }, ranks, "a", now).switchTo, null)
|
||||||
assert.equal(decideRoutingSwitch(profile, state, ranks, null, now).switchTo, null)
|
assert.equal(decideRoutingSwitch(profile, state, ranks, null, now).switchTo, null)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("hysteresis retains pending confirmation without counting sub-threshold observations or releasing cooldown", () => {
|
||||||
|
const ranked = rankRoutingCandidates(profile, [...a, sample("b1", 1, 4, "wan-b"), sample("b2", 4, 3, "exit-b")], now)
|
||||||
|
const compare = (state: ControllerState, gain: number, observation: string, time = now) => {
|
||||||
|
const ranks = ["b", "a"].map(id => ({ ...ranked.find(row => row.candidate.id === id)!,
|
||||||
|
score: id === "a" ? 50 : 50 + gain, observationIds: [id + observation] }))
|
||||||
|
return decideRoutingSwitch(profile, state, ranks, "a", time)
|
||||||
|
}
|
||||||
|
|
||||||
|
const initial = compare({}, 15, "1").state
|
||||||
|
assert.equal(initial.consecutive, 1)
|
||||||
|
for (const [gain, observation] of [[6, "2"], [5, "3"]] as const) {
|
||||||
|
const result = compare(initial, gain, observation)
|
||||||
|
assert.equal(result.switchTo, null)
|
||||||
|
assert.deepEqual(result.state, initial)
|
||||||
|
}
|
||||||
|
const reset = compare(initial, 4, "4")
|
||||||
|
assert.equal(reset.state.pendingCandidateId, undefined)
|
||||||
|
assert.equal(reset.state.consecutive, 0)
|
||||||
|
|
||||||
|
const second = compare(initial, 15, "5")
|
||||||
|
assert.equal(second.switchTo, null)
|
||||||
|
const third = compare({ ...second.state, lastSwitchAt: at }, 15, "6")
|
||||||
|
assert.equal(third.state.consecutive, 3)
|
||||||
|
assert.equal(third.switchTo, null)
|
||||||
|
|
||||||
|
const afterCooldown = now + profile.cooldownSec * 1000
|
||||||
|
assert.equal(compare(third.state, 6, "7", afterCooldown).switchTo, null)
|
||||||
|
assert.equal(compare(third.state, 15, "8", afterCooldown).switchTo, "b")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("a different pending candidate cannot borrow confirmations from a shared observation key", () => {
|
||||||
|
const ranks = rankRoutingCandidates(profile, [...a, sample("b1", 1, 4, "wan-b"), sample("b2", 4, 3, "exit-b")], now)
|
||||||
|
.map(row => ({ ...row, score: row.candidate.id === "a" ? 50 : 75 }))
|
||||||
|
.sort((left, right) => right.score - left.score)
|
||||||
|
const observationKey = ranks.flatMap(row => row.observationIds).sort().join(":")
|
||||||
|
const persisted: ControllerState = { pendingCandidateId: "previous-candidate", consecutive: 3, observationKey }
|
||||||
|
const selected = decideRoutingSwitch(profile, persisted, ranks, "a", now)
|
||||||
|
assert.equal(selected.switchTo, null)
|
||||||
|
assert.equal(selected.state.pendingCandidateId, "b")
|
||||||
|
assert.equal(selected.state.consecutive, 0)
|
||||||
|
assert.equal(selected.state.observationKey, observationKey)
|
||||||
|
|
||||||
|
const replay = decideRoutingSwitch(profile, selected.state, ranks, "a", now)
|
||||||
|
assert.equal(replay.switchTo, null)
|
||||||
|
assert.equal(replay.state.consecutive, 0)
|
||||||
|
|
||||||
|
const freshRanks = [{ ...ranks[0], observationIds: ["new-b-1", "new-b-2"] }, ranks[1]]
|
||||||
|
const fresh = decideRoutingSwitch(profile, replay.state, freshRanks, "a", now)
|
||||||
|
assert.equal(fresh.state.consecutive, 1)
|
||||||
|
assert.equal(fresh.switchTo, null)
|
||||||
|
|
||||||
|
const belowFloor = [{ ...freshRanks[0], score: 54 }, freshRanks[1]]
|
||||||
|
const reset = decideRoutingSwitch(profile, fresh.state, belowFloor, "a", now)
|
||||||
|
assert.equal(reset.state.pendingCandidateId, undefined)
|
||||||
|
assert.equal(reset.state.consecutive, 0)
|
||||||
|
const replayAfterReset = decideRoutingSwitch(profile, reset.state, freshRanks, "a", now)
|
||||||
|
assert.equal(replayAfterReset.switchTo, null)
|
||||||
|
assert.equal(replayAfterReset.state.consecutive, 0)
|
||||||
|
})
|
||||||
@@ -51,36 +51,58 @@ export function rankRoutingCandidates(profile: RouteAiProfile, observations: Rou
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface PathHealth { id: string; candidateId: string; status: "passed" | "failed" | "unknown"; observedAt: string }
|
export interface PathHealth { id: string; candidateId: string; status: "passed" | "failed" | "unknown"; observedAt: string }
|
||||||
export interface ControllerState { pendingCandidateId?: string; consecutive?: number; observationKey?: string; lastSwitchAt?: string; switches?: string[]; operationId?: string; pausedReason?: string; failureCount?: number; failureKey?: string; failingCandidateId?: string }
|
export interface ControllerState { pendingCandidateId?: string; consecutive?: number; observationKey?: string; lastSwitchAt?: string; switches?: string[]; operationId?: string; pausedReason?: string; failureCount?: number; failureKey?: string; failureObservedAt?: string; failingCandidateId?: string }
|
||||||
|
|
||||||
|
export function selectFailoverReserveProbes(ranking: ReturnType<typeof rankRoutingCandidates>, currentCandidateId: string, previous?: string) {
|
||||||
|
const standbys = ranking.filter(({ candidate, eligible }) => eligible && candidate.id !== currentCandidateId && candidate.controlProbe)
|
||||||
|
.map(({ candidate }) => candidate.id)
|
||||||
|
const [preferred, ...remaining] = standbys
|
||||||
|
if (remaining.length === 0) return { candidateIds: standbys, cursor: undefined }
|
||||||
|
const index = remaining.indexOf(previous ?? "")
|
||||||
|
const cursor = remaining[(index + 1) % remaining.length]
|
||||||
|
return { candidateIds: [preferred, cursor], cursor }
|
||||||
|
}
|
||||||
|
|
||||||
|
const pathHealthFreshnessSec = 90
|
||||||
export function decideRoutingSwitch(profile: RouteAiProfile, state: ControllerState, ranking: ReturnType<typeof rankRoutingCandidates>, currentCandidateId: string | null, now = Date.now(), health: PathHealth[] = [], chargedSwitchesPerHour?: number) {
|
export function decideRoutingSwitch(profile: RouteAiProfile, state: ControllerState, ranking: ReturnType<typeof rankRoutingCandidates>, currentCandidateId: string | null, now = Date.now(), health: PathHealth[] = [], chargedSwitchesPerHour?: number) {
|
||||||
const next: ControllerState = { ...state, switches: (state.switches ?? []).filter(at => now - Date.parse(at) < 3600000) }
|
const next: ControllerState = { ...state, switches: (state.switches ?? []).filter(at => now - Date.parse(at) < 3600000) }
|
||||||
if (chargedSwitchesPerHour !== undefined && (!Number.isSafeInteger(chargedSwitchesPerHour) || chargedSwitchesPerHour < next.switches!.length || chargedSwitchesPerHour > 4096)) throw Error("Invalid common routing charge count")
|
if (chargedSwitchesPerHour !== undefined && (!Number.isSafeInteger(chargedSwitchesPerHour) || chargedSwitchesPerHour < next.switches!.length || chargedSwitchesPerHour > 4096)) throw Error("Invalid common routing charge count")
|
||||||
const switchCount = chargedSwitchesPerHour ?? next.switches!.length
|
const switchCount = chargedSwitchesPerHour ?? next.switches!.length
|
||||||
const best = ranking.find(candidate => candidate.eligible), current = ranking.find(candidate => candidate.candidate.id === currentCandidateId)
|
const best = ranking.find(candidate => candidate.eligible), current = ranking.find(candidate => candidate.candidate.id === currentCandidateId)
|
||||||
const currentHealth = health.find(value => value.candidateId === currentCandidateId && fresh(value.observedAt, 90, now))
|
const currentHealth = health.find(value => value.candidateId === currentCandidateId && fresh(value.observedAt, pathHealthFreshnessSec, now))
|
||||||
if (currentHealth?.status === "failed" && currentCandidateId && !state.pausedReason) {
|
if (currentHealth?.status === "failed" && currentCandidateId && !state.pausedReason) {
|
||||||
if (state.failureKey !== currentHealth.id) {
|
const observed = Date.parse(currentHealth.observedAt), previous = Date.parse(state.failureObservedAt ?? "")
|
||||||
next.failureCount = state.failingCandidateId === currentCandidateId ? (state.failureCount ?? 0) + 1 : 1
|
const sameCandidate = state.failingCandidateId === currentCandidateId
|
||||||
next.failureKey = currentHealth.id; next.failingCandidateId = currentCandidateId
|
if (state.failureKey === currentHealth.id) {
|
||||||
|
if (!sameCandidate || observed !== previous) return { state: next, switchTo: null, reason: "Ожидание новой проверки отказа" }
|
||||||
|
} else {
|
||||||
|
if (observed <= previous) return { state: next, switchTo: null, reason: "Ожидание новой проверки отказа" }
|
||||||
|
const continues = sameCandidate && fresh(state.failureObservedAt ?? null, pathHealthFreshnessSec, observed)
|
||||||
|
next.failureCount = continues ? (state.failureCount ?? 0) + 1 : 1
|
||||||
|
next.failureKey = currentHealth.id; next.failureObservedAt = currentHealth.observedAt; next.failingCandidateId = currentCandidateId
|
||||||
}
|
}
|
||||||
const reserve = ranking.find(value => value.eligible && value.candidate.id !== currentCandidateId && health.some(check => check.candidateId === value.candidate.id && check.status === "passed" && fresh(check.observedAt, 90, now)))
|
const reserve = ranking.find(value => value.eligible && value.candidate.id !== currentCandidateId && health.some(check => check.candidateId === value.candidate.id && check.status === "passed" && fresh(check.observedAt, pathHealthFreshnessSec, now)))
|
||||||
if ((next.failureCount ?? 0) < 3 || !reserve) return { state: next, switchTo: null, reason: "Failover ожидает три отдельных отказа и подтверждённый резерв" }
|
if ((next.failureCount ?? 0) < 3 || !reserve) return { state: next, switchTo: null, reason: "Failover ожидает три отдельных отказа и подтверждённый резерв" }
|
||||||
if (switchCount >= profile.maxSwitchesPerHour) return { state: { ...next, pausedReason: "Лимит переключений" }, switchTo: null, reason: "Исчерпан часовой бюджет, включая failover" }
|
if (switchCount >= profile.maxSwitchesPerHour) return { state: { ...next, pausedReason: "Лимит переключений" }, switchTo: null, reason: "Исчерпан часовой бюджет, включая failover" }
|
||||||
if (next.lastSwitchAt && now - Date.parse(next.lastSwitchAt) < profile.cooldownSec * 1000) return { state: next, switchTo: null, reason: "Failover ожидает окончания cooldown" }
|
if (next.lastSwitchAt && now - Date.parse(next.lastSwitchAt) < profile.cooldownSec * 1000) return { state: next, switchTo: null, reason: "Failover ожидает окончания cooldown" }
|
||||||
return { state: { ...next, failureCount: 0 }, switchTo: reserve.candidate.id, reason: "Failover: три подтверждённых отказа и проверенный резерв" }
|
return { state: { ...next, failureCount: 0 }, switchTo: reserve.candidate.id, reason: "Failover: три подтверждённых отказа и проверенный резерв" }
|
||||||
}
|
}
|
||||||
next.failureCount = 0; next.failureKey = undefined; next.failingCandidateId = undefined
|
next.failureCount = 0; next.failureKey = undefined; next.failureObservedAt = undefined; next.failingCandidateId = undefined
|
||||||
if (state.pausedReason || !currentCandidateId || !best || best.candidate.id === currentCandidateId) return { state: { ...next, pendingCandidateId: undefined, consecutive: 0 }, switchTo: null, reason: "Нет подтверждённого улучшения или текущего пути" }
|
if (state.pausedReason || !currentCandidateId || !best || best.candidate.id === currentCandidateId) return { state: { ...next, pendingCandidateId: undefined, consecutive: 0 }, switchTo: null, reason: "Нет подтверждённого улучшения или текущего пути" }
|
||||||
if (!current?.eligible || current.score === null) return { state: next, switchTo: null, reason: "Состояние текущего пути неизвестно; failover требует отдельных подтверждённых проверок отказа" }
|
if (!current?.eligible || current.score === null) return { state: next, switchTo: null, reason: "Состояние текущего пути неизвестно; failover требует отдельных подтверждённых проверок отказа" }
|
||||||
const gain = best.score! - current.score
|
const gain = best.score! - current.score
|
||||||
if (gain < Math.max(0, profile.switchThreshold - profile.hysteresis)) return { state: { ...next, consecutive: 0, pendingCandidateId: undefined }, switchTo: null, reason: "Сработал гистерезис" }
|
if (gain < Math.max(0, profile.switchThreshold - profile.hysteresis)) return { state: { ...next, consecutive: 0, pendingCandidateId: undefined }, switchTo: null, reason: "Сработал гистерезис" }
|
||||||
if (gain < profile.switchThreshold && next.pendingCandidateId !== best.candidate.id) return { state: next, switchTo: null, reason: "Порог улучшения не достигнут" }
|
if (gain < profile.switchThreshold) return { state: next, switchTo: null, reason: "Порог улучшения не достигнут" }
|
||||||
const observationKey = [...best.observationIds, ...current.observationIds].sort().join(":")
|
const observationKey = [...best.observationIds, ...current.observationIds].sort().join(":")
|
||||||
|
if (next.pendingCandidateId !== undefined && next.pendingCandidateId !== best.candidate.id) {
|
||||||
|
next.pendingCandidateId = best.candidate.id
|
||||||
|
next.consecutive = 0
|
||||||
|
}
|
||||||
if (observationKey !== next.observationKey) {
|
if (observationKey !== next.observationKey) {
|
||||||
next.consecutive = next.pendingCandidateId === best.candidate.id ? (next.consecutive ?? 0) + 1 : 1
|
next.consecutive = next.pendingCandidateId === best.candidate.id ? (next.consecutive ?? 0) + 1 : 1
|
||||||
next.pendingCandidateId = best.candidate.id; next.observationKey = observationKey
|
next.pendingCandidateId = best.candidate.id; next.observationKey = observationKey
|
||||||
}
|
}
|
||||||
if ((next.consecutive ?? 0) < profile.confirmationSamples) return { state: next, switchTo: null, reason: "Ожидание новых измерений" }
|
if (next.pendingCandidateId !== best.candidate.id || (next.consecutive ?? 0) < profile.confirmationSamples) return { state: next, switchTo: null, reason: "Ожидание новых измерений" }
|
||||||
if (next.lastSwitchAt && now - Date.parse(next.lastSwitchAt) < profile.cooldownSec * 1000) return { state: next, switchTo: null, reason: "Cooldown" }
|
if (next.lastSwitchAt && now - Date.parse(next.lastSwitchAt) < profile.cooldownSec * 1000) return { state: next, switchTo: null, reason: "Cooldown" }
|
||||||
if (switchCount >= profile.maxSwitchesPerHour) return { state: { ...next, pausedReason: "Лимит переключений" }, switchTo: null, reason: "Исчерпан часовой бюджет переключений" }
|
if (switchCount >= profile.maxSwitchesPerHour) return { state: { ...next, pausedReason: "Лимит переключений" }, switchTo: null, reason: "Исчерпан часовой бюджет переключений" }
|
||||||
return { state: next, switchTo: best.candidate.id, reason: "Улучшение подтверждено свежими наблюдениями" }
|
return { state: next, switchTo: best.candidate.id, reason: "Улучшение подтверждено свежими наблюдениями" }
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { isIP } from "node:net"
|
||||||
|
|
||||||
|
export function parseRoutingEndpoint(value: unknown) {
|
||||||
|
if (typeof value !== "string" || !value.length || value.length > 256) throw Error("missing-or-unsupported-field")
|
||||||
|
const match = /^([^%@/,\s]+)(?:\/(\d+))?(?:%([^%@/,\s]+))?(?:@([^%@/,\s]+))?$/.exec(value)
|
||||||
|
if (!match) throw Error("unsupported-endpoint")
|
||||||
|
const [, address, prefix, interfaceName, vrf] = match
|
||||||
|
const family = isIP(address)
|
||||||
|
if (!family) throw Error("unsupported-address")
|
||||||
|
if (prefix !== undefined && prefix !== String(family === 4 ? 32 : 128)) throw Error("unsupported-peer-range")
|
||||||
|
return { address, interface: interfaceName, vrf }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function matchesBgpSessionContext(
|
||||||
|
row: Record<string, unknown>,
|
||||||
|
expected: { table: string; vrf: string },
|
||||||
|
local: ReturnType<typeof parseRoutingEndpoint>,
|
||||||
|
remote: ReturnType<typeof parseRoutingEndpoint>,
|
||||||
|
) {
|
||||||
|
if (!Object.hasOwn(row, "routing-table") || row["routing-table"] !== expected.table) return false
|
||||||
|
if (Object.hasOwn(row, "vrf")) {
|
||||||
|
return row.vrf === expected.vrf && (local.vrf === undefined || local.vrf === expected.vrf) &&
|
||||||
|
(remote.vrf === undefined || remote.vrf === expected.vrf)
|
||||||
|
}
|
||||||
|
// RouterOS may report the transport VRF only in the two runtime addresses.
|
||||||
|
// The installation table alone cannot identify it.
|
||||||
|
return local.vrf === expected.vrf && remote.vrf === expected.vrf
|
||||||
|
}
|
||||||
@@ -14,7 +14,7 @@ async function fixture() {
|
|||||||
let nativeReads = 0, profileChecks = 0, sourceReads = 0, configChecks = 0
|
let nativeReads = 0, profileChecks = 0, sourceReads = 0, configChecks = 0
|
||||||
let nativeBody = "desired", nativeDependency = "dependency", nativeTarget = "desired-target", nativeCandidate = "b", nativeId = "*1", generation = "generation"
|
let nativeBody = "desired", nativeDependency = "dependency", nativeTarget = "desired-target", nativeCandidate = "b", nativeId = "*1", generation = "generation"
|
||||||
let afterNative: (() => void) | undefined, afterCheckpoint: (() => void) | undefined, afterProfile: (() => void) | undefined
|
let afterNative: (() => void) | undefined, afterCheckpoint: (() => void) | undefined, afterProfile: (() => void) | undefined
|
||||||
let sharedReads = 0, sharedClosed = false
|
let sharedReads = 0, sharedClosed = false, manualOwner = false
|
||||||
class ClockDate extends Date { static now() { return now } }
|
class ClockDate extends Date { static now() { return now } }
|
||||||
class BridgeError extends Error { constructor(readonly code: string, message: string) { super(message) } }
|
class BridgeError extends Error { constructor(readonly code: string, message: string) { super(message) } }
|
||||||
const fingerprint = (value: unknown) => JSON.stringify(value)
|
const fingerprint = (value: unknown) => JSON.stringify(value)
|
||||||
@@ -37,6 +37,7 @@ async function fixture() {
|
|||||||
const sharedToken = Object.freeze({}), acquisition = Object.freeze({}), sharedBinding = { sourceOperationId: row.id, profile: structuredClone(profile), principal: structuredClone(principal),
|
const sharedToken = Object.freeze({}), acquisition = Object.freeze({}), sharedBinding = { sourceOperationId: row.id, profile: structuredClone(profile), principal: structuredClone(principal),
|
||||||
deadline: now + 10000, contextSources: structuredClone(input.contextSources), candidateId: "b" }
|
deadline: now + 10000, contextSources: structuredClone(input.contextSources), candidateId: "b" }
|
||||||
const providers: Record<string, unknown> = {
|
const providers: Record<string, unknown> = {
|
||||||
|
"./routing-network-execution-session.js": { isVerifiedManualEffectOwner: (owner: unknown) => owner === sharedToken && manualOwner },
|
||||||
zod: { z }, "@mmapp/contracts/mcp-routing": { routeAiProfileSchema: { parse: (value: unknown) => value } },
|
zod: { z }, "@mmapp/contracts/mcp-routing": { routeAiProfileSchema: { parse: (value: unknown) => value } },
|
||||||
"../../db/index.js": { pool: { query: async () => { sourceReads++; return { rows: [{ ...row, plan_id: sourceChanged ? "changed" : row.plan_id }] } } } },
|
"../../db/index.js": { pool: { query: async () => { sourceReads++; return { rows: [{ ...row, plan_id: sourceChanged ? "changed" : row.plan_id }] } } } },
|
||||||
"../../lib/permissions.js": { hasPermission: (values: string[], permission: string) => values.includes(permission) },
|
"../../lib/permissions.js": { hasPermission: (values: string[], permission: string) => values.includes(permission) },
|
||||||
@@ -93,7 +94,7 @@ async function fixture() {
|
|||||||
const reseal = () => { row.evidence.sealed = JSON.stringify(evidence) }
|
const reseal = () => { row.evidence.sealed = JSON.stringify(evidence) }
|
||||||
return { resolve: () => resolve(row.id, action), resolveShared: (token = sharedToken) => resolveShared(token, action), sharedToken, sharedBinding,
|
return { resolve: () => resolve(row.id, action), resolveShared: (token = sharedToken) => resolveShared(token, action), sharedToken, sharedBinding,
|
||||||
consume, close, resolveRound: () => resolveRound(sharedToken, action), readRound: (token: object, read = acquisition) => readRound(token, read), closeRound, acquisition,
|
consume, close, resolveRound: () => resolveRound(sharedToken, action), readRound: (token: object, read = acquisition) => readRound(token, read), closeRound, acquisition,
|
||||||
row, input, profile, checkpoint, packet, action, authority, reseal,
|
row, input, profile, checkpoint, packet, action, authority, reseal, brandManual: () => { manualOwner = true },
|
||||||
stats: () => ({ nativeReads, profileChecks, sourceReads, configChecks, sharedReads, sharedClosed }), advance: (ms: number) => { now += ms },
|
stats: () => ({ nativeReads, profileChecks, sourceReads, configChecks, sharedReads, sharedClosed }), advance: (ms: number) => { now += ms },
|
||||||
revoke: () => { revoked = true }, sourceChanged: () => { sourceChanged = true }, checkpointMissing: () => { checkpointMissing = true },
|
revoke: () => { revoked = true }, sourceChanged: () => { sourceChanged = true }, checkpointMissing: () => { checkpointMissing = true },
|
||||||
checkpointChanged: () => { checkpointChanged = true }, guard: () => { guard = true },
|
checkpointChanged: () => { checkpointChanged = true }, guard: () => { guard = true },
|
||||||
@@ -110,6 +111,25 @@ test("fixed resolver captures opaque context and consume rechecks configuration
|
|||||||
assert.equal(f.stats().nativeReads, 2); assert(f.stats().profileChecks > 5)
|
assert.equal(f.stats().nativeReads, 2); assert(f.stats().profileChecks > 5)
|
||||||
await assert.rejects(f.consume(token), /Unknown or consumed/); await assert.rejects(f.resolve(), /already issued/)
|
await assert.rejects(f.consume(token), /Unknown or consumed/); await assert.rejects(f.resolve(), /already issued/)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
for (const mode of ["positive", "unbranded", "wrong-checkpoint", "terminal-source", "standalone"] as const) {
|
||||||
|
test(`manual revision-2 round ${mode}`, async () => {
|
||||||
|
const f = await fixture()
|
||||||
|
f.input.proofRevision = 2; f.input.manualEffectPolicy = "observational-v1"; delete f.input.persistentProfileAuthority
|
||||||
|
f.row.state = mode === "terminal-source" ? "applied" : "running"; f.row.step_state = "intent"
|
||||||
|
f.packet.revision = mode === "wrong-checkpoint" ? 2 : 1
|
||||||
|
if (mode !== "unbranded") f.brandManual()
|
||||||
|
f.reseal()
|
||||||
|
if (mode !== "positive") {
|
||||||
|
await assert.rejects(mode === "standalone" ? f.resolve() : f.resolveRound())
|
||||||
|
assert.equal(f.stats().nativeReads, 0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const round = await f.resolveRound(), observation = await f.readRound(round)
|
||||||
|
assert.equal(observation.sharedNetworkBudgetVerified, true)
|
||||||
|
assert.equal(observation.forwardingVerified, false)
|
||||||
|
})
|
||||||
|
}
|
||||||
test("caller objects, JSON, another resolver and closed contexts cannot be consumed", async () => {
|
test("caller objects, JSON, another resolver and closed contexts cannot be consumed", async () => {
|
||||||
const f = await fixture(), other = await fixture(), token = await f.resolve()
|
const f = await fixture(), other = await fixture(), token = await f.resolve()
|
||||||
await assert.rejects(f.consume({}), /Unknown or consumed/); await assert.rejects(f.consume(JSON.parse(JSON.stringify(token))), /Unknown or consumed/)
|
await assert.rejects(f.consume({}), /Unknown or consumed/); await assert.rejects(f.consume(JSON.parse(JSON.stringify(token))), /Unknown or consumed/)
|
||||||
@@ -201,12 +221,13 @@ test("missing genuine checkpoint closes shared owner without accepting equality"
|
|||||||
|
|
||||||
test("forward round context reuses one admitted source only through its exact ephemeral acquisition", async () => {
|
test("forward round context reuses one admitted source only through its exact ephemeral acquisition", async () => {
|
||||||
const f = await fixture(), token = await f.resolveRound()
|
const f = await fixture(), token = await f.resolveRound()
|
||||||
|
assert.equal(f.stats().sharedReads, 0, "Round admission grants no native-success observation")
|
||||||
for (let round = 0; round < 2; round++) {
|
for (let round = 0; round < 2; round++) {
|
||||||
const before = await f.readRound(token), after = await f.readRound(token)
|
const before = await f.readRound(token), after = await f.readRound(token)
|
||||||
assert.equal(before.operationId, f.row.id); assert.equal(after.securityProof, false)
|
assert.equal(before.operationId, f.row.id); assert.equal(after.securityProof, false)
|
||||||
assert.equal(after.forwardingVerified, false); assert.equal(after.sharedNetworkBudgetVerified, true)
|
assert.equal(after.forwardingVerified, false); assert.equal(after.sharedNetworkBudgetVerified, true)
|
||||||
}
|
}
|
||||||
assert.equal(f.stats().sharedReads, 5)
|
assert.equal(f.stats().sharedReads, 4)
|
||||||
await assert.rejects(f.resolveShared(), /already issued/)
|
await assert.rejects(f.resolveShared(), /already issued/)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -221,3 +242,16 @@ for (const fault of ["clone", "owner", "body", "dependency", "checkpoint", "revo
|
|||||||
await assert.rejects(f.readRound(fault === "clone" ? JSON.parse(JSON.stringify(token)) : token, fault === "owner" ? {} : f.acquisition))
|
await assert.rejects(f.readRound(fault === "clone" ? JSON.parse(JSON.stringify(token)) : token, fault === "owner" ? {} : f.acquisition))
|
||||||
assert.equal(f.stats().sharedClosed, !["clone", "close"].includes(fault))
|
assert.equal(f.stats().sharedClosed, !["clone", "close"].includes(fault))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
for (const drift of ["body", "dependency", "checkpoint", "revoke"] as const)
|
||||||
|
test("first forward-round read detects " + drift + " after source-only admission", async () => {
|
||||||
|
const f = await fixture(), token = await f.resolveRound()
|
||||||
|
assert.equal(f.stats().nativeReads, 0)
|
||||||
|
if (drift === "body" || drift === "dependency") f.drift(drift)
|
||||||
|
if (drift === "checkpoint") f.checkpointChanged()
|
||||||
|
if (drift === "revoke") f.revoke()
|
||||||
|
await assert.rejects(f.readRound(token))
|
||||||
|
assert.equal(f.stats().sharedClosed, true)
|
||||||
|
await assert.rejects(f.readRound(token))
|
||||||
|
})
|
||||||
@@ -8,6 +8,7 @@ import { findMcpOperation } from "./registry.js"
|
|||||||
import { accessGeneration, decryptSecret, fingerprint, McpError, redact } from "./security.js"
|
import { accessGeneration, decryptSecret, fingerprint, McpError, redact } from "./security.js"
|
||||||
import { assertScopedCommunityRequest } from "./routing-community-scoped-request.js"
|
import { assertScopedCommunityRequest } from "./routing-community-scoped-request.js"
|
||||||
import { loadScopedConfigurationPacket } from "./routing-community-scoped-checkpoint.js"
|
import { loadScopedConfigurationPacket } from "./routing-community-scoped-checkpoint.js"
|
||||||
|
import { isVerifiedManualEffectOwner } from "./routing-network-execution-session.js"
|
||||||
import { assertPersistentScopedProfileAuthority, routingProfileAuthorityBindingSchema } from "./routing-profile-authority.js"
|
import { assertPersistentScopedProfileAuthority, routingProfileAuthorityBindingSchema } from "./routing-profile-authority.js"
|
||||||
import { readRouteCommunityContext, readRouteCommunityContextWithSharedNativeSession } from "./routing-community-context.js"
|
import { readRouteCommunityContext, readRouteCommunityContextWithSharedNativeSession } from "./routing-community-context.js"
|
||||||
import { readRoutingNetworkScopedCommunityContext } from "./routing-network-scoped-context.js"
|
import { readRoutingNetworkScopedCommunityContext } from "./routing-network-scoped-context.js"
|
||||||
@@ -68,7 +69,9 @@ export async function resolveRoutingNetworkConfigurationContextFromSharedContext
|
|||||||
* as the existing bridge. Only its owning session can use it inside a queued
|
* as the existing bridge. Only its owning session can use it inside a queued
|
||||||
* round acquisition; JSON copies and caller-supplied observations cannot. */
|
* round acquisition; JSON copies and caller-supplied observations cannot. */
|
||||||
export async function resolveRoutingNetworkForwardRoundConfigurationContext(sharedContext: RoutingNetworkSharedContext, action: ActionContext): Promise<RoutingNetworkForwardRoundConfigurationContext> {
|
export async function resolveRoutingNetworkForwardRoundConfigurationContext(sharedContext: RoutingNetworkSharedContext, action: ActionContext): Promise<RoutingNetworkForwardRoundConfigurationContext> {
|
||||||
const initial = await resolveRoutingNetworkConfigurationContextFromSharedContext(sharedContext, action)
|
const binding = inspectRoutingNetworkSharedContext(sharedContext)
|
||||||
|
if (fingerprint(binding.principal) !== fingerprint(action.principal)) throw fail("Shared configuration principal changed")
|
||||||
|
const initial = await resolveConfigurationContext(binding.sourceOperationId, action, sharedContext, true)
|
||||||
const entry = contexts.get(initial)
|
const entry = contexts.get(initial)
|
||||||
if (!entry) throw fail("Configuration bridge admission disappeared")
|
if (!entry) throw fail("Configuration bridge admission disappeared")
|
||||||
contexts.delete(initial)
|
contexts.delete(initial)
|
||||||
@@ -96,7 +99,8 @@ export function closeRoutingNetworkForwardRoundConfigurationContext(context: Rou
|
|||||||
roundContexts.delete(context); entry?.close()
|
roundContexts.delete(context); entry?.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resolveConfigurationContext(sourceOperationId: string, action: ActionContext, sharedContext?: RoutingNetworkSharedContext): Promise<RoutingNetworkConfigurationContext> {
|
async function resolveConfigurationContext(sourceOperationId: string, action: ActionContext, sharedContext?: RoutingNetworkSharedContext, deferredRoundReadback = false): Promise<RoutingNetworkConfigurationContext> {
|
||||||
|
if (deferredRoundReadback && !sharedContext) throw fail("Forward round requires its original shared owner")
|
||||||
z.string().uuid().parse(sourceOperationId)
|
z.string().uuid().parse(sourceOperationId)
|
||||||
const principal = freeze(structuredClone(action.principal)), connection = freeze(structuredClone(action.connection))
|
const principal = freeze(structuredClone(action.principal)), connection = freeze(structuredClone(action.connection))
|
||||||
const abort = new AbortController()
|
const abort = new AbortController()
|
||||||
@@ -134,9 +138,16 @@ async function resolveConfigurationContext(sourceOperationId: string, action: Ac
|
|||||||
if (binding.sourceOperationId !== sourceOperationId || fingerprint(binding.profile) !== fingerprint(profile) ||
|
if (binding.sourceOperationId !== sourceOperationId || fingerprint(binding.profile) !== fingerprint(profile) ||
|
||||||
fingerprint(binding.contextSources) !== fingerprint(input.contextSources) || binding.candidateId !== input.candidateId) throw fail("Shared configuration source binding changed")
|
fingerprint(binding.contextSources) !== fingerprint(input.contextSources) || binding.candidateId !== input.candidateId) throw fail("Shared configuration source binding changed")
|
||||||
}
|
}
|
||||||
if (input.proofRevision !== 3 || input.persistentProfileAuthority === undefined || profile.autoRecovery) throw fail("Persistent manual revision-3 source required; recovery guard remains closed")
|
const manual = !!sharedContext && deferredRoundReadback && isVerifiedManualEffectOwner(sharedContext)
|
||||||
|
if (profile.autoRecovery) throw fail("Recovery guard remains closed")
|
||||||
|
if (manual) {
|
||||||
|
if (row.state !== "running" || row.step_state !== "intent" || input.proofRevision !== 2 ||
|
||||||
|
input.persistentProfileAuthority !== undefined || input.manualEffectPolicy !== "observational-v1") throw fail("Exact running manual effect source required")
|
||||||
|
} else {
|
||||||
|
if (input.proofRevision !== 3 || input.persistentProfileAuthority === undefined) throw fail("Persistent manual revision-3 source required")
|
||||||
const authority = routingProfileAuthorityBindingSchema.parse(input.persistentProfileAuthority)
|
const authority = routingProfileAuthorityBindingSchema.parse(input.persistentProfileAuthority)
|
||||||
deadline = Math.min(deadline, Date.parse(authority.packet.validUntil))
|
deadline = Math.min(deadline, Date.parse(authority.packet.validUntil))
|
||||||
|
}
|
||||||
signal = AbortSignal.any([signal, AbortSignal.timeout(Math.max(1, deadline - Date.now()))])
|
signal = AbortSignal.any([signal, AbortSignal.timeout(Math.max(1, deadline - Date.now()))])
|
||||||
active()
|
active()
|
||||||
const ids = [...new Set([profile.serverId, ...profile.candidates.flatMap(candidate => [candidate.jumpHostId, candidate.exitNodeId])])].sort((a,b) => a-b)
|
const ids = [...new Set([profile.serverId, ...profile.candidates.flatMap(candidate => [candidate.jumpHostId, candidate.exitNodeId])])].sort((a,b) => a-b)
|
||||||
@@ -157,7 +168,7 @@ async function resolveConfigurationContext(sourceOperationId: string, action: Ac
|
|||||||
const checkpoint = async () => {
|
const checkpoint = async () => {
|
||||||
await authorize()
|
await authorize()
|
||||||
const result = await guarded(() => loadScopedConfigurationPacket(input, requestSeal, { operationId: sourceOperationId, stepIndex: 0 }))
|
const result = await guarded(() => loadScopedConfigurationPacket(input, requestSeal, { operationId: sourceOperationId, stepIndex: 0 }))
|
||||||
if (!result || result.packet.revision !== 2 || result.packet.operationId !== sourceOperationId || result.packet.stepIndex !== 0 ||
|
if (!result || result.packet.revision !== (manual ? 1 : 2) || result.packet.operationId !== sourceOperationId || result.packet.stepIndex !== 0 ||
|
||||||
result.packet.phase !== "CONFIG_APPLIED" || Date.parse(result.observedAt) < row.created_at.getTime() || Date.parse(result.observedAt) > Date.now() ||
|
result.packet.phase !== "CONFIG_APPLIED" || Date.parse(result.observedAt) < row.created_at.getTime() || Date.parse(result.observedAt) > Date.now() ||
|
||||||
Date.parse(result.observedAt) >= deadline) throw fail("Genuine persistent CONFIG_APPLIED unavailable")
|
Date.parse(result.observedAt) >= deadline) throw fail("Genuine persistent CONFIG_APPLIED unavailable")
|
||||||
await authorize()
|
await authorize()
|
||||||
@@ -190,7 +201,11 @@ async function resolveConfigurationContext(sourceOperationId: string, action: Ac
|
|||||||
bodyIdentity: fingerprint(original.packet.desiredRule), dependencyIdentity: original.packet.dependencyProof },
|
bodyIdentity: fingerprint(original.packet.desiredRule), dependencyIdentity: original.packet.dependencyProof },
|
||||||
securityProof: false as const, forwardingVerified: false as const, sharedNetworkBudgetVerified: !!sharedContext })
|
securityProof: false as const, forwardingVerified: false as const, sharedNetworkBudgetVerified: !!sharedContext })
|
||||||
}
|
}
|
||||||
await readback()
|
// A forward-round token carries no native success observation. Its fixed
|
||||||
|
// owner must perform a fresh read immediately BEFORE every snapshot and
|
||||||
|
// another immediately AFTER. Avoid an unused eager native read here;
|
||||||
|
// independent/shared one-shot bridge semantics remain unchanged.
|
||||||
|
if (!deferredRoundReadback) await readback()
|
||||||
const token = Object.freeze({}) as RoutingNetworkConfigurationContext
|
const token = Object.freeze({}) as RoutingNetworkConfigurationContext
|
||||||
contexts.set(token, { consumed: false, close, read: readback, consume: async () => {
|
contexts.set(token, { consumed: false, close, read: readback, consume: async () => {
|
||||||
try { return await readback() }
|
try { return await readback() }
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
import { createRequire } from 'node:module';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
const require = createRequire(new URL('../../../package.json', import.meta.url));
|
||||||
|
const ts = require('typescript');
|
||||||
|
const code = ts.transpileModule(await readFile(new URL('./routing-network-execution-session.ts', import.meta.url), 'utf8'), {
|
||||||
|
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }
|
||||||
|
}).outputText;
|
||||||
|
function fixture() {
|
||||||
|
const now = Date.now();
|
||||||
|
const metadata = { approved: true, phase: 'forward', stepIndex: 0, operationName: 'routing.community.switch', operationVersion: 7,
|
||||||
|
serviceName: 'routing.community.scoped', operationId: 'real-operation', planId: 'real-plan', principal: { connectionId: 'real-connection', credentialExpiresAt: new Date(now + 90000).toISOString() },
|
||||||
|
serviceInput: { persistentProfileAuthority: { packet: { validUntil: new Date(now + 70000).toISOString() } } } };
|
||||||
|
metadata.sealedEvidenceProof = createHash('sha256').update('evidence-ciphertext').digest('hex');
|
||||||
|
metadata.sealedPayloadProof = createHash('sha256').update('payload-ciphertext').digest('hex');
|
||||||
|
const original = structuredClone(metadata);
|
||||||
|
const row = { created_at: new Date(now - 40000), expires_at: new Date(now + 100000), evidence: { sealed: 'evidence-ciphertext' }, payload: { sealed: 'payload-ciphertext' },
|
||||||
|
intended_state: { kind: 'service', service: metadata.serviceName, input: structuredClone(metadata.serviceInput) } };
|
||||||
|
const lifetime = { expiresAt: now + 60000, monotonicDeadline: performance.now() + 60000, signal: new AbortController().signal };
|
||||||
|
let calls = 0, inspections = 0, closed = 0, options, query, originAvailable = true, afterCreate = () => {};
|
||||||
|
const connection = { owner_valid_until: new Date(now + 110000), policy: { expiresAt: new Date(now + 120000).toISOString() } };
|
||||||
|
const token = Object.freeze({}), sharedContext = Object.freeze({});
|
||||||
|
const providers = {
|
||||||
|
'node:crypto': { createHash },
|
||||||
|
'../../db/index.js': { pool: { query: async value => { query = value; return { rows: [row] }; } } },
|
||||||
|
'../../services/config-mutation-policy.js': {
|
||||||
|
captureVerifiedMcpServiceExecution: async () => { if (!originAvailable) throw Error('no-origin'); return token; },
|
||||||
|
inspectVerifiedMcpServiceExecution: async value => { assert.equal(value, token); inspections++; return structuredClone(metadata); },
|
||||||
|
inspectVerifiedMcpServiceExecutionLifetime: async value => { assert.equal(value, token); return lifetime; }
|
||||||
|
},
|
||||||
|
'./store.js': { effectiveMcpConnection: async principal => { assert.deepEqual(principal, original.principal); return connection; } },
|
||||||
|
'./registry.js': { findMcpOperation: () => ({ version: 7 }) },
|
||||||
|
'./security.js': { fingerprint: JSON.stringify, redact: value => value, McpError: Error },
|
||||||
|
'./routing-profile-authority.js': { routingProfileAuthorityBindingSchema: { parse: value => value } },
|
||||||
|
'./routing-network-native-session.js': { createRoutingNetworkNativeSession: async value => { calls++; options = value; afterCreate(); return { sharedContext, close() { closed++; } }; } }
|
||||||
|
};
|
||||||
|
const exports = {};
|
||||||
|
new Function('exports', 'require', code)(exports, name => { assert.ok(providers[name], name); return providers[name]; });
|
||||||
|
const manifest = { servers: [1, 2], probes: [] };
|
||||||
|
return { metadata, row, lifetime, connection, manifest, now, sharedContext, isManual: exports.isVerifiedManualEffectOwner, run: () => exports.createRoutingNetworkSessionForCurrentExecution(manifest),
|
||||||
|
revokeOrigin: () => { originAvailable = false; }, afterCreate: fn => { afterCreate = fn; },
|
||||||
|
stats: () => ({ calls, closed, inspections, options, query }) };
|
||||||
|
}
|
||||||
|
for (const mode of ['positive', 'missing-policy', 'missing-selection', 'recovery', 'late-revocation']) {
|
||||||
|
test(`manual effect owner origin ${mode}`, async () => {
|
||||||
|
const f = fixture();
|
||||||
|
f.metadata.serviceInput = { proofRevision: 2, manualEffectPolicy: 'observational-v1', profile: { autoRecovery: mode === 'recovery' }, originalEffectSelection: { status: 'prepared' } };
|
||||||
|
if (mode === 'missing-policy') delete f.metadata.serviceInput.manualEffectPolicy;
|
||||||
|
if (mode === 'missing-selection') delete f.metadata.serviceInput.originalEffectSelection;
|
||||||
|
if (mode === 'late-revocation') f.afterCreate(() => { f.metadata.approved = false; });
|
||||||
|
f.row.intended_state.input = structuredClone(f.metadata.serviceInput);
|
||||||
|
if (mode === 'positive') {
|
||||||
|
await f.run();
|
||||||
|
assert.equal(f.isManual(f.sharedContext), true);
|
||||||
|
assert.equal(f.isManual(JSON.parse(JSON.stringify(f.sharedContext))), false);
|
||||||
|
} else {
|
||||||
|
await assert.rejects(f.run());
|
||||||
|
assert.equal(f.isManual(f.sharedContext), false);
|
||||||
|
assert.equal(f.stats().calls, mode === 'late-revocation' ? 1 : 0);
|
||||||
|
assert.equal(f.stats().closed, mode === 'late-revocation' ? 1 : 0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
test('real origin supplies operation, principal, signal and minimum immutable horizon', async () => {
|
||||||
|
const f = fixture(); await f.run(); const { options, query } = f.stats();
|
||||||
|
assert.equal(options.sourceOperationId, f.metadata.operationId); assert.equal(options.context.signal, f.lifetime.signal);
|
||||||
|
assert.equal(options.deadline, f.lifetime.expiresAt - 15000); assert.equal(options.manifest, f.manifest);
|
||||||
|
assert.deepEqual(query.values, [f.metadata.operationId, f.metadata.principal.connectionId, f.metadata.planId]);
|
||||||
|
assert.match(query.text, /o.state='running'/); assert.match(query.text, /s.state='intent'/);
|
||||||
|
});
|
||||||
|
test('elapsed original 120 seconds cannot be renewed from current time', async () => {
|
||||||
|
const f = fixture(); f.row.created_at = new Date(f.now - 120001); await assert.rejects(f.run); assert.equal(f.stats().calls, 0);
|
||||||
|
});
|
||||||
|
for (const [name, change] of [
|
||||||
|
['plan', f => { f.row.expires_at = new Date(f.now + 30000); }],
|
||||||
|
['owner', f => { f.connection.owner_valid_until = new Date(f.now + 30000); }],
|
||||||
|
['policy', f => { f.connection.policy.expiresAt = new Date(f.now + 30000).toISOString(); }],
|
||||||
|
['profile', f => { f.metadata.serviceInput.persistentProfileAuthority.packet.validUntil = new Date(f.now + 30000).toISOString(); f.row.intended_state.input = structuredClone(f.metadata.serviceInput); }],
|
||||||
|
['operation', f => { f.row.created_at = new Date(f.now - 90000); }]
|
||||||
|
]) test(`minimum ${name} expiry is conserved with publication reserve`, async () => { const f = fixture(); change(f); await f.run(); assert.equal(f.stats().options.deadline, f.now + 15000); });
|
||||||
|
for (const [key, value] of [['approved', false], ['phase', 'recovery'], ['stepIndex', 1], ['operationName', 'operations.recover'], ['operationVersion', 6], ['serviceName', 'routing.community']]) {
|
||||||
|
test(`wrong live origin ${key} is denied before native factory`, async () => { const f = fixture(); f.metadata[key] = value; await assert.rejects(f.run); assert.equal(f.stats().calls, 0); });
|
||||||
|
}
|
||||||
|
test('outside verified origin cannot supply authority', async () => { const f = fixture(); f.revokeOrigin(); await assert.rejects(f.run); assert.equal(f.stats().calls, 0); });
|
||||||
|
test('persisted intended state must match the live service input', async () => { const f = fixture(); f.row.intended_state.input = {}; await assert.rejects(f.run); assert.equal(f.stats().calls, 0); });
|
||||||
|
for (const field of ['evidence', 'payload']) test(`changed randomized sealed ${field} is denied before native`, async () => { const f = fixture(); f.row[field].sealed = 'changed'; await assert.rejects(f.run); assert.equal(f.stats().calls, 0); });
|
||||||
|
test('monotonic expiry denies open even with unexpired wall horizon', async () => { const f = fixture(); f.lifetime.monotonicDeadline = performance.now() - 1; await assert.rejects(f.run); assert.equal(f.stats().calls, 0); });
|
||||||
|
test('late origin change closes the newly opened native owner', async () => { const f = fixture(); f.afterCreate(() => { f.metadata.planId = 'changed'; }); await assert.rejects(f.run); assert.equal(f.stats().closed, 1); });
|
||||||
|
test('returned native authorize callback remains tied to original metadata', async () => { const f = fixture(); await f.run(); f.metadata.operationId = 'changed'; await assert.rejects(f.stats().options.authorize); });
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { createHash } from "node:crypto"
|
||||||
|
import type { QueryConfig } from "pg"
|
||||||
|
import { pool } from "../../db/index.js"
|
||||||
|
import { captureVerifiedMcpServiceExecution, inspectVerifiedMcpServiceExecution, inspectVerifiedMcpServiceExecutionLifetime } from "../../services/config-mutation-policy.js"
|
||||||
|
import { effectiveMcpConnection } from "./store.js"
|
||||||
|
import { findMcpOperation } from "./registry.js"
|
||||||
|
import { fingerprint, McpError, redact } from "./security.js"
|
||||||
|
import { routingProfileAuthorityBindingSchema } from "./routing-profile-authority.js"
|
||||||
|
import { createRoutingNetworkNativeSession, type RoutingNetworkNativeManifest, type RoutingNetworkSharedContext } from "./routing-network-native-session.js"
|
||||||
|
|
||||||
|
const manualEffectOwners = new WeakSet<RoutingNetworkSharedContext>()
|
||||||
|
|
||||||
|
/** Only the verified execution factory can mark a genuine shared owner. */
|
||||||
|
export function isVerifiedManualEffectOwner(owner: RoutingNetworkSharedContext): boolean {
|
||||||
|
return manualEffectOwners.has(owner)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Private producer for the existing native/configuration-round reader. The
|
||||||
|
* manifest selects reads, never identity or authority. Its exact scope, probe
|
||||||
|
* ownership and whole-session budget are still checked by the native factory.
|
||||||
|
* Only the original live verified service step can open or use this session.
|
||||||
|
* No operation/principal/deadline/authorization callback is caller supplied.
|
||||||
|
* This grants no write, recovery or authenticated log-interval coverage. */
|
||||||
|
export async function createRoutingNetworkSessionForCurrentExecution(manifest: RoutingNetworkNativeManifest): Promise<Awaited<ReturnType<typeof createRoutingNetworkNativeSession>>> {
|
||||||
|
const fail = () => new McpError("STATE_CONFLICT", "Нет исходного running scoped execution для native acquisition", 409)
|
||||||
|
const origin = await captureVerifiedMcpServiceExecution()
|
||||||
|
const initial = await inspectVerifiedMcpServiceExecution(origin)
|
||||||
|
if (!initial.approved || initial.phase !== "forward" || initial.stepIndex !== 0 ||
|
||||||
|
initial.operationName !== "routing.community.switch" || initial.operationVersion !== findMcpOperation(initial.operationName)?.version ||
|
||||||
|
initial.serviceName !== "routing.community.scoped") throw fail()
|
||||||
|
const input = initial.serviceInput, manual = input.proofRevision === 2
|
||||||
|
if (manual && (input.manualEffectPolicy !== "observational-v1" || input.persistentProfileAuthority !== undefined ||
|
||||||
|
(input.profile as { autoRecovery?: unknown } | undefined)?.autoRecovery !== false ||
|
||||||
|
(input.originalEffectSelection as { status?: unknown } | undefined)?.status !== "prepared")) throw fail()
|
||||||
|
const lifetime = await inspectVerifiedMcpServiceExecutionLifetime(origin)
|
||||||
|
const authorize = async () => {
|
||||||
|
const current = await inspectVerifiedMcpServiceExecution(origin)
|
||||||
|
if (fingerprint(current) !== fingerprint(initial)) throw fail()
|
||||||
|
}
|
||||||
|
await authorize()
|
||||||
|
const connection = await effectiveMcpConnection(initial.principal)
|
||||||
|
await authorize()
|
||||||
|
// The service lifetime may be narrower than plan expiry, but it does not
|
||||||
|
// replace the operation-created-at +120s native horizon. Never renew at now.
|
||||||
|
const query: QueryConfig & { query_timeout: number } = { text: `SELECT o.created_at,p.expires_at,
|
||||||
|
CASE WHEN octet_length(p.evidence::text)<=1048576 THEN p.evidence END AS evidence,
|
||||||
|
CASE WHEN octet_length(p.payload::text)<=1048576 THEN p.payload END AS payload,
|
||||||
|
CASE WHEN octet_length(s.intended_state::text)<=1048576 THEN s.intended_state END AS intended_state FROM mcp_operations o
|
||||||
|
JOIN mcp_plans p ON p.id=o.plan_id JOIN mcp_operation_steps s ON s.operation_id=o.id
|
||||||
|
WHERE o.id=$1 AND o.connection_id=$2 AND o.plan_id=$3 AND o.state='running'
|
||||||
|
AND s.step_index=0 AND s.state='intent' LIMIT 2`,
|
||||||
|
values: [initial.operationId, initial.principal.connectionId, initial.planId],
|
||||||
|
query_timeout: Math.max(1, Math.min(10000, lifetime.expiresAt - Date.now())) }
|
||||||
|
const source = await pool.query(query)
|
||||||
|
await authorize()
|
||||||
|
if (source.rows.length !== 1 || !(source.rows[0].created_at instanceof Date) || !(source.rows[0].expires_at instanceof Date)) throw fail()
|
||||||
|
const sha = (value: string) => createHash("sha256").update(value).digest("hex")
|
||||||
|
if (typeof source.rows[0].evidence?.sealed !== "string" || typeof source.rows[0].payload?.sealed !== "string" ||
|
||||||
|
sha(source.rows[0].evidence.sealed) !== initial.sealedEvidenceProof || sha(source.rows[0].payload.sealed) !== initial.sealedPayloadProof) throw fail()
|
||||||
|
if (fingerprint(source.rows[0].intended_state) !== fingerprint(redact({ kind: "service", service: initial.serviceName, input: initial.serviceInput }))) throw fail()
|
||||||
|
const authorityExpiry = initial.serviceInput.persistentProfileAuthority === undefined ? Infinity :
|
||||||
|
Date.parse(routingProfileAuthorityBindingSchema.parse(initial.serviceInput.persistentProfileAuthority).packet.validUntil)
|
||||||
|
const deadline = Math.min(source.rows[0].created_at.getTime() + 120000, source.rows[0].expires_at.getTime(), lifetime.expiresAt,
|
||||||
|
Date.parse(initial.principal.credentialExpiresAt), connection.owner_valid_until.getTime(), Date.parse(connection.policy.expiresAt), authorityExpiry)
|
||||||
|
// Reserve publication/current-authority time for the existing manual result.
|
||||||
|
// This only narrows the original horizon; it never creates a renewed grant.
|
||||||
|
const readDeadline = deadline - 15000
|
||||||
|
if (!Number.isFinite(readDeadline) || Date.now() >= readDeadline || lifetime.signal.aborted || performance.now() >= lifetime.monotonicDeadline) throw fail()
|
||||||
|
const session = await createRoutingNetworkNativeSession({ sourceOperationId: initial.operationId,
|
||||||
|
context: { principal: initial.principal, connection, signal: lifetime.signal }, authorize, deadline: readDeadline, manifest })
|
||||||
|
try {
|
||||||
|
await authorize()
|
||||||
|
if (manual) manualEffectOwners.add(session.sharedContext)
|
||||||
|
return session
|
||||||
|
}
|
||||||
|
catch (error) { session.close(); throw error }
|
||||||
|
}
|
||||||
Loaded 100 of 313 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user