Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 backend/package.json backend/tsconfig.json backend/mcp-coverage.json "$STAGING/backend/"
|
||||
cp -R backend/src "$STAGING/backend/"
|
||||
mkdir -p "$STAGING/backend/scripts/writer-profile"
|
||||
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/"
|
||||
node backend/scripts/stage-database-operator.mjs "$STAGING/backend/scripts"
|
||||
mkdir -p "$STAGING/deploy/netflow-agent"
|
||||
cp deploy/netflow-agent/install.sh "$STAGING/deploy/netflow-agent/"
|
||||
if [ -d backend/drizzle ]; then
|
||||
|
||||
@@ -16,7 +16,7 @@ permissions:
|
||||
packages: write
|
||||
jobs:
|
||||
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
|
||||
timeout-minutes: 40
|
||||
env:
|
||||
@@ -24,6 +24,7 @@ jobs:
|
||||
EXPECTED_SHA: ${{ inputs.expected_sha }}
|
||||
DISPATCH_SHA: ${{ gitea.sha }}
|
||||
DISPATCH_EVENT: ${{ gitea.event_name }}
|
||||
DISPATCH_REF: ${{ gitea.ref }}
|
||||
REPOSITORY: ${{ gitea.repository }}
|
||||
RUN_ID: ${{ gitea.run_id }}
|
||||
RUN_ATTEMPT: ${{ gitea.run_attempt }}
|
||||
@@ -42,6 +43,7 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test "$DISPATCH_EVENT" = workflow_dispatch
|
||||
test "$DISPATCH_REF" = refs/heads/codex/mcp-acceptance-current-20261103
|
||||
[[ "$EXPECTED_SHA" =~ ^[0-9a-f]{40}$ ]]
|
||||
test "$EXPECTED_SHA" = "$DISPATCH_SHA"
|
||||
test "$(git rev-parse HEAD)" = "$EXPECTED_SHA"
|
||||
@@ -88,9 +90,7 @@ jobs:
|
||||
if [ -d backend/drizzle ]; then
|
||||
cp -R backend/drizzle "$STAGING/backend/"
|
||||
fi
|
||||
mkdir -p "$STAGING/backend/scripts/writer-profile"
|
||||
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/"
|
||||
node backend/scripts/stage-database-operator.mjs "$STAGING/backend/scripts"
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Log in to existing Gitea registry
|
||||
@@ -139,7 +139,7 @@ jobs:
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
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
|
||||
timeout-minutes: 40
|
||||
env:
|
||||
@@ -147,6 +147,7 @@ jobs:
|
||||
EXPECTED_SHA: ${{ inputs.expected_sha }}
|
||||
DISPATCH_SHA: ${{ gitea.sha }}
|
||||
DISPATCH_EVENT: ${{ gitea.event_name }}
|
||||
DISPATCH_REF: ${{ gitea.ref }}
|
||||
REPOSITORY: ${{ gitea.repository }}
|
||||
RUN_ID: ${{ gitea.run_id }}
|
||||
RUN_ATTEMPT: ${{ gitea.run_attempt }}
|
||||
@@ -178,6 +179,7 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test "$DISPATCH_EVENT" = workflow_dispatch
|
||||
test "$DISPATCH_REF" = refs/heads/codex/mcp-acceptance-current-20261103
|
||||
[[ "$EXPECTED_SHA" =~ ^[0-9a-f]{40}$ ]]
|
||||
test "$EXPECTED_SHA" = "$DISPATCH_SHA"
|
||||
test "$(git rev-parse HEAD)" = "$EXPECTED_SHA"
|
||||
|
||||
@@ -924,18 +924,21 @@ export default function TrafficPage() {
|
||||
})
|
||||
return () => { cancelled = true }
|
||||
}
|
||||
void Promise.all([
|
||||
getTrafficFlows(backendUrl, range),
|
||||
getFlowExporters(backendUrl, range),
|
||||
getFlowClients(backendUrl, range),
|
||||
]).then(([stats, exporters, clients]) => {
|
||||
const loadFlowCards = async () => {
|
||||
// These reads share the collector's constrained admission budget.
|
||||
const stats = await getTrafficFlows(backendUrl, range)
|
||||
if (cancelled) return
|
||||
const exporters = await getFlowExporters(backendUrl, range)
|
||||
if (cancelled) return
|
||||
const clients = await getFlowClients(backendUrl, range)
|
||||
if (cancelled) return
|
||||
setFlowResult({ key: flowKey, receivedAt: Date.now(), stats, exporters: exporters.exporters, clients: clients.clients })
|
||||
setSelectedId(prev => {
|
||||
const list = flowScope === "users" ? clients.clients : exporters.exporters
|
||||
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 : "Не удалось загрузить потоки" })
|
||||
})
|
||||
return () => { cancelled = true }
|
||||
|
||||
@@ -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;
|
||||
@@ -576,6 +576,13 @@
|
||||
"operations": [],
|
||||
"reason": "Управление самим MCP, транспорт или машинная авторизация: отдельного AI-инструмента нет."
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/mcp/database/receipts/:id/audit-status",
|
||||
"status": "excluded",
|
||||
"operations": [],
|
||||
"reason": "Человеческая административная сверка аудита внешней квитанции восстановления: управление самим MCP, без отдельного AI-инструмента."
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/mcp/devices/:serverId/:resource",
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"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: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",
|
||||
"mcp:stdio": "node dist/scripts/mcp-stdio.js",
|
||||
"mcp:stdio:dev": "tsx src/scripts/mcp-stdio.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: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: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: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",
|
||||
@@ -65,7 +66,7 @@
|
||||
"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: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-approval:pg": "node scripts/test-server-poll-approval-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: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-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-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",
|
||||
@@ -140,7 +142,7 @@
|
||||
"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: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: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",
|
||||
@@ -153,8 +155,9 @@
|
||||
"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-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-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: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",
|
||||
|
||||
@@ -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 ts from 'typescript'
|
||||
|
||||
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/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',
|
||||
])
|
||||
import {OPERATOR_SOURCES} from './database-operator-sources.mjs'
|
||||
export {OPERATOR_SOURCES}
|
||||
|
||||
const emitted=source=>source.replace(/\.mts$/,'.mjs').replace(/\.ts$/,'.js')
|
||||
const digest=value=>createHash('sha256').update(value).digest('hex')
|
||||
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 {fileURLToPath,pathToFileURL} from 'node:url'
|
||||
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)),'..')
|
||||
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'))
|
||||
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})
|
||||
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()
|
||||
async function copyDist(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)
|
||||
assert.deepEqual([...copied].sort(),first.backendDistModules.map(item=>item.file).sort())
|
||||
// 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 buildScript=path.join(projectBackend,'scripts','build-database-operator.mjs');await copyFile(fileURLToPath(new URL('./build-database-operator.mjs',import.meta.url)),buildScript)
|
||||
const scripts=path.join(projectBackend,'scripts')
|
||||
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})
|
||||
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)
|
||||
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';"]){
|
||||
@@ -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 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})
|
||||
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=[]
|
||||
for(const [entry,event] of [['database-writer-bootstrap-operator.mjs','application-writer-bootstrap-refused'],['database-writer-profile-operator.mjs','application-writer-profile-publication-refused']]){
|
||||
const child=spawnSync(process.execPath,['--import',pathToFileURL(guard).href,path.join(output,entry),'invalid'],{cwd:temporary,env,encoding:'utf8',timeout:30000})
|
||||
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),...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)
|
||||
}
|
||||
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 parse=(b:Buffer)=>JSON.parse(new TextDecoder('utf-8',{fatal:true}).decode(b))
|
||||
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']
|
||||
assert(['publish','reconcile','preflight'].includes(args[0])&&args.length===1+2*flags.length)
|
||||
assertJsDriverEnvironment(process.env)
|
||||
@@ -38,7 +39,12 @@ export async function main(args:string[]){
|
||||
assertJsDriverEnvironment(process.env)
|
||||
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 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 pool=new pg.Pool(options)
|
||||
// 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})}
|
||||
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,
|
||||
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}
|
||||
@@ -55,5 +61,5 @@ export async function main(args:string[]){
|
||||
}
|
||||
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}
|
||||
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'
|
||||
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')
|
||||
interface PreflightDiagnostics {state:string;headSha256:string;sourceMatched:boolean;rolesMatched:boolean;catalogueMatched: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 OperatorModule {main(args:string[]):Promise<{event:string;outcome:string;writerAuthorized: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<{platformProtectionVerified:boolean}>},approval:unknown):{preflightCurrentProfile(pool:unknown,options:{deadlineAt:number}):Promise<PreflightDiagnostics>}}
|
||||
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{
|
||||
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'})
|
||||
@@ -56,17 +56,23 @@ function fixture(fault=''){
|
||||
'../../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'},
|
||||
}
|
||||
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}
|
||||
}
|
||||
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)
|
||||
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(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.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()=>{
|
||||
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 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'}
|
||||
let failEnd=false,asyncError=false
|
||||
let failEnd=false,asyncError=false,protectedRefused=false
|
||||
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')}}
|
||||
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={
|
||||
'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-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:()=>({})},
|
||||
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')}})},
|
||||
'./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')}},
|
||||
'./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)},
|
||||
}
|
||||
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)
|
||||
assert.equal(output.event,'application-writer-profile-preflight');assert.equal(output.outcome,'matched');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.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','protected-source','preflight','pool-end'])
|
||||
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/)
|
||||
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,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')
|
||||
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')
|
||||
const routingForwardRounds = process.argv.includes('--routing-network-forward-rounds')
|
||||
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 routingOriginalEffects = process.argv.includes('--routing-original-effects')
|
||||
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')
|
||||
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')
|
||||
@@ -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')
|
||||
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')
|
||||
const mcpOperationOrigin = process.argv.includes('--mcp-operation-origin')
|
||||
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 pollOperationRetirement = process.argv.includes('--poll-operation-retirement')
|
||||
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')
|
||||
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')
|
||||
@@ -242,12 +246,13 @@ try {
|
||||
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')
|
||||
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),
|
||||
...(routingOriginalEffects ? { MMAPP_OWN_ROUTING_ORIGINAL_EFFECTS: 'true' } : {}),
|
||||
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',
|
||||
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))
|
||||
})
|
||||
@@ -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 && 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 && 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 && 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')
|
||||
@@ -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 && 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 && 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 && 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')
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import EmbeddedPostgres from 'embedded-postgres'
|
||||
import { createRequire } from 'node:module'
|
||||
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 { createServer } from 'node:net'
|
||||
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'))
|
||||
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 })
|
||||
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')
|
||||
async function compile(sourceDirectory) {
|
||||
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
|
||||
// owned child has already exited. This never targets another database.
|
||||
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 })
|
||||
console.log('Owned routing PostgreSQL stopped and its data removed; snapshot/log evidence retained')
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
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 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 current=await readCurrent(s,installation)
|
||||
if(!current)throw Error('Current signed policy required for preflight')
|
||||
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(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'))
|
||||
// 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
|
||||
@@ -157,7 +157,7 @@ async function preflightCurrentProfile(pool,{deadlineAt=Date.now()+120000}={}){
|
||||
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)
|
||||
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){
|
||||
|
||||
@@ -3,10 +3,14 @@ import { applySqlMigrations } from "./migrate.js"
|
||||
import { dropExpiredPartitions, ensurePartitionsAround } from "./partitions.js"
|
||||
import { importSqliteToPostgres, shouldImportSqlite, sqliteFileLooksPresent } from "./sqlite-import.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
|
||||
|
||||
export async function initDatabase(): Promise<void> {
|
||||
await assertDatabaseMaintenanceStartup(mcpStateDirectory(), env.DATABASE_URL)
|
||||
await pool.query("SELECT 1")
|
||||
await applySqlMigrations(pool)
|
||||
await ensurePartitionsAround(pool)
|
||||
@@ -39,4 +43,5 @@ export async function initDatabase(): Promise<void> {
|
||||
}
|
||||
await dropExpiredPartitions(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"
|
||||
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", () => {
|
||||
assert.equal(inventory.length, 44); assert.equal(MIGRATION_MANIFEST.length, 44)
|
||||
await test("all 55 exact file pins and every top-level schema operation are inventoried", () => {
|
||||
assert.equal(inventory.length, 55); assert.equal(MIGRATION_MANIFEST.length, 55)
|
||||
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, 38)
|
||||
assert(inventory.every(file => file.operations.length === file.statementCount && file.effects.length > 0))
|
||||
let tables = new Set<string>()
|
||||
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.deepEqual(endpoint.opaqueBodies.map(body => body.kind), ["stored-function-body", "executed-do-body"])
|
||||
})
|
||||
await test("0035 through0044 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"])
|
||||
await test("0035 through 0055 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", "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"])
|
||||
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)
|
||||
})
|
||||
@@ -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_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))
|
||||
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.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)
|
||||
|
||||
@@ -702,6 +702,158 @@ 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.",
|
||||
"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."
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -724,7 +876,7 @@ export const migrationManifestDigest = () => hash(JSON.stringify(MIGRATION_MANIF
|
||||
// resetting the accumulated public table set at the reviewed destructive0002.
|
||||
// This is a necessary presence check, not a substitute for exact live column,
|
||||
// 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 {
|
||||
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 { env } from "./config.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 authPlugin, { requireApiAuth } from "./plugins/auth.js"
|
||||
import { installDatabaseRequestGate } from "./plugins/database-request-gate.js"
|
||||
@@ -186,6 +188,8 @@ const isMain =
|
||||
|
||||
if (isMain) {
|
||||
try {
|
||||
// Resolve external maintenance before init/migrations or consumer startup.
|
||||
await assertDatabaseMaintenanceStartup(mcpStateDirectory(), env.DATABASE_URL)
|
||||
await initDatabase()
|
||||
const app = await buildApp()
|
||||
let shuttingDown = false
|
||||
|
||||
@@ -14,6 +14,7 @@ import { logFiltersSchema, logSettingsPatchSchema } from "@mmapp/contracts"
|
||||
import { appUserCreateSchema, appUserUpdateSchema, appUserRoleSchema, sectionPermSchema, serverPermSchema, userBindingCreateSchema } from "@mmapp/contracts/users"
|
||||
import type { McpOperationDescriptor } from "@mmapp/contracts/mcp"
|
||||
import { hasPermission } from "../../lib/permissions.js"
|
||||
import { assertOperationReplayScope, findOperationReplayTombstone, operationHistoryExpired } from "./operation-replay-tombstones.js"
|
||||
import { pool } from "../../db/index.js"
|
||||
import { getServerReadById } from "../servers/service/servers-service.js"
|
||||
import { listUsers, getUserById, listInterfaceCatalog, getInterfaceCatalogState } from "../users/service/users-service.js"
|
||||
@@ -473,7 +474,12 @@ add("changes.cancel", "mm_change_cancel", "Отменить свой незап
|
||||
add("operations.read", "mm_operation_get", "Результат своей операции", "mcp", null, "read", "connection", z.object({ operationId: z.string().uuid() }).strict(), {
|
||||
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])
|
||||
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)
|
||||
const { rows: steps } = await pool.query("SELECT * FROM mcp_operation_steps WHERE operation_id=$1 ORDER BY step_index", [input.operationId])
|
||||
return { ...operation, steps }
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { isConfirmedRoutingFibTable } from "../../services/routing-table-fib.js"
|
||||
import type { RouteAiProfile } from "@mmapp/contracts/mcp-routing"
|
||||
import { nativeBgpRows, type NativeBgpRow } from "../../services/bgp-export-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]
|
||||
}
|
||||
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[] = []
|
||||
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))
|
||||
|
||||
@@ -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
|
||||
const originalConnect = pool.connect
|
||||
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 {
|
||||
// Every owned restore profile archives inert domain collectors, not only the
|
||||
// data-equivalence profile. Otherwise restart can launch unrelated downloads
|
||||
@@ -138,6 +147,10 @@ try {
|
||||
await assert.rejects(assertPortalClaimsCurrent(revokedOwner))
|
||||
await assert.rejects(authenticateMcpToken(issued.token))
|
||||
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.
|
||||
await pool.query("UPDATE mcp_restore_acceptance_marker SET value='after-receipt'")
|
||||
await executeMcpDatabaseRestore(operation.id, principal)
|
||||
@@ -165,6 +178,7 @@ try {
|
||||
await delay(100)
|
||||
}
|
||||
assert.equal(receipt.state, "blocked", JSON.stringify(receipt))
|
||||
await waitForDatabaseRetirement()
|
||||
assert.equal(await accessGeneration(), generationBeforeCorruption)
|
||||
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")
|
||||
|
||||
@@ -5,7 +5,7 @@ import { z } from "zod"
|
||||
import { pool } from "../../db/index.js"
|
||||
import { hasPermission, type AuthUser } from "../../lib/permissions.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 { accessGeneration, decryptBinary, encryptBinary, decryptSecret, McpError, mcpStateDirectory } from "./security.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 { withApplicationMutationLock } from "../../services/config-mutation-lock.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
|
||||
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> }
|
||||
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) {
|
||||
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) => {
|
||||
@@ -91,15 +96,17 @@ export function registerDatabaseRestore(register: typeof add) {
|
||||
}
|
||||
/** 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) {
|
||||
const lease = await pool.connect()
|
||||
let locked = false, unsafe = false
|
||||
try {
|
||||
locked = (await lease.query<{ locked: boolean }>("SELECT pg_try_advisory_lock(hashtextextended($1,0)) AS locked", ["mcp-operation:" + operationId])).rows[0].locked
|
||||
if (locked) await executeRestore(operationId, principal,originalGrant)
|
||||
} finally {
|
||||
if (locked) try { await lease.query("SELECT pg_advisory_unlock(hashtextextended($1,0))", ["mcp-operation:" + operationId]) } catch { unsafe = true }
|
||||
lease.release(unsafe)
|
||||
}
|
||||
return withSystemDatabaseRestoreAdmission(async () => {
|
||||
const lease = await pool.connect()
|
||||
let locked = false, unsafe = false
|
||||
try {
|
||||
locked = (await lease.query<{ locked: boolean }>("SELECT pg_try_advisory_lock(hashtextextended($1,0)) AS locked", ["mcp-operation:" + operationId])).rows[0].locked
|
||||
if (locked) await executeRestore(operationId, principal,originalGrant)
|
||||
} finally {
|
||||
if (locked) try { await lease.query("SELECT pg_advisory_unlock(hashtextextended($1,0))", ["mcp-operation:" + operationId]) } catch { unsafe = true }
|
||||
lease.release(unsafe)
|
||||
}
|
||||
})
|
||||
}
|
||||
async function executeRestore(operationId: string, principal: McpPrincipal,originalGrant?:OriginalServiceGrant) {
|
||||
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 }
|
||||
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 }
|
||||
let attempted = false, stage = "authority"
|
||||
let attempted = false, stage = "authority", restoreActorId:string|undefined
|
||||
const assertAuthority = async () => {
|
||||
await assertOriginalMcpTemporaryGrant(pool,principal.connectionId,principal.credentialId,originalGrant)
|
||||
const connection = await validatePrincipal(principal)
|
||||
@@ -124,6 +131,7 @@ async function executeRestore(operationId: string, principal: McpPrincipal,origi
|
||||
}
|
||||
try {
|
||||
const { connection, ids } = await assertAuthority(), source = await archive(payload.archiveRef, connection.owner_id)
|
||||
restoreActorId=connection.owner_id
|
||||
stage = "archive-read"
|
||||
if (source.sha256 !== payload.expectedSha256) throw new McpError("STATE_CONFLICT", "Архив подменён", 409)
|
||||
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]) }
|
||||
stage = "receipt-publish"
|
||||
await durableWrite(filename(operationId, ".receipt.json"), JSON.stringify(receipt))
|
||||
await audit(pool, connection.owner_id, "database.restore.receipt", null, receipt).catch(() => {})
|
||||
} catch (error) {
|
||||
// The isolated destructive acceptance runner can print bounded diagnostics
|
||||
// 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))
|
||||
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 { 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"
|
||||
|
||||
const proof = async (value: unknown) => createHash("sha256").update(fingerprint(value)).digest("hex")
|
||||
@@ -25,6 +28,7 @@ function fixture() {
|
||||
return { rows }
|
||||
} }
|
||||
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 }
|
||||
}
|
||||
{
|
||||
@@ -100,4 +104,22 @@ for (const mutate of mutations) {
|
||||
assert.equal(source.originalContext.tool, "bandwidth"); assert.equal(source.stageBudget, 1)
|
||||
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 { SERVER_PROBE_OPERATION } from "../../services/server-probe-authority.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 uuid = z.string().uuid()
|
||||
@@ -113,5 +113,14 @@ export const loadDiagnosticCancellationSource = sourceLoader(productionCrypto)
|
||||
/** Test-only crypto seam. Never accept these dependencies from tool JSON. */
|
||||
export const createDiagnosticCancellationSourceForTests = sourceLoader
|
||||
/** 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
|
||||
/** 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 { pool } from "../../db/index.js"
|
||||
import { readRetiredServerPollHistory } from "../../services/server-poll-history-reader.js"
|
||||
import { hasPermission } from "../../lib/permissions.js"
|
||||
import { effectiveMcpConnection } from "./store.js"
|
||||
import { decryptSecret, fingerprint, McpError } from "./security.js"
|
||||
@@ -7,40 +8,84 @@ import { findMcpOperation } from "./registry.js"
|
||||
import { findMcpService } from "./service-registry.js"
|
||||
import type { add } from "./actions.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()
|
||||
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)
|
||||
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.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]
|
||||
if (!row) throw new McpError("NOT_FOUND", "Операция не найдена", 404)
|
||||
const original = findMcpOperation(row.operation_id)
|
||||
return current
|
||||
}
|
||||
|
||||
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)
|
||||
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)
|
||||
}
|
||||
|
||||
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)
|
||||
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(), {
|
||||
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) => {
|
||||
const { row, evidence } = await authorizedSource(String(input.operationId), context)
|
||||
const authorize = async () => { await authorizedSource(String(input.operationId), context) }
|
||||
const operationId = String(input.operationId)
|
||||
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 = []
|
||||
for (const [index, step] of evidence.steps.entries()) {
|
||||
if (step.kind !== "service") throw new McpError("UNSUPPORTED_CAPABILITY", "Нативная глобальная сверка недоступна", 409)
|
||||
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()
|
||||
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
|
||||
@@ -5,6 +5,8 @@ await prepareOwnedBandwidthState()
|
||||
const { pool, closePool } = await import("../../db/index.js")
|
||||
try {
|
||||
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")
|
||||
console.log("Owned actual MCP operation root:", JSON.stringify(await acceptOwnedMcpOperationRoot()))
|
||||
} finally { await closePool() }
|
||||
@@ -9,6 +9,7 @@ import { performance } from "node:perf_hooks"
|
||||
import type { QueryConfig, PoolClient } from "pg"
|
||||
import { mcpTemporaryGrantSchema, type McpObjectRef } from "@mmapp/contracts/mcp"
|
||||
import { pool } from "../../db/index.js"
|
||||
import { sharedDatabaseGate } from "../../services/shared-database-gate.js"
|
||||
import { assertNoConfigurationLockContext } from "../../services/config-mutation-lock.js"
|
||||
import { assertNoOperationConfigurationLockContext } from "../../services/operation-config-lock-lease.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 work = async () => {
|
||||
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")
|
||||
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
|
||||
@@ -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 }) }
|
||||
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. */
|
||||
export async function retireMcpOperationAdmission(admission: unknown): Promise<void> {
|
||||
const entry = admission && typeof admission === "object" ? admissions.get(admission) : undefined
|
||||
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.
|
||||
* 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)
|
||||
} finally {
|
||||
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> {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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}
|
||||
})
|
||||
}
|
||||
@@ -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 { pool } from "../../db/index.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 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))
|
||||
// Keep ordered VRF membership (first matching row wins), never equate an
|
||||
// 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 }))
|
||||
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"])
|
||||
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))
|
||||
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>
|
||||
try { gateway = routeGatewayContext(candidate.gateway) } catch { throw new McpError("STATE_UNAVAILABLE", "Gateway кандидата не подтверждён", 409) }
|
||||
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)
|
||||
} 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,
|
||||
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,
|
||||
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 })),
|
||||
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 { readFile } from "node:fs/promises"
|
||||
import { test } from "node:test"
|
||||
import { runInNewContext } from "node:vm"
|
||||
import ts from "typescript"
|
||||
import type { ActionContext } from "./action-types.js"
|
||||
import { loadReservedScopedRecoverySource } from "./routing-community-scoped-recovery-source.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/)
|
||||
})
|
||||
|
||||
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 () => {
|
||||
const expected = {} as Parameters<typeof validateReservedInverseVerified>[1]
|
||||
for (const row of [{}, { state: "verified", phase: "RECOVERY_VERIFIED", configurationVerified: true }, { inverse_intent_sealed: "x".repeat(524289) }]) {
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { createRoutingReservationStore } from "./routing-reservation-store.js"
|
||||
import { observeCommittedScopedCheckpoint } from "../../test/committed-scoped-checkpoint-observer.js"
|
||||
import { prepareRoutingControllerBaseline } from "./routing-controller-baseline.js"
|
||||
import { prepareRoutingControllerBaseline, advanceRoutingControllerBaseline } from "./routing-controller-baseline.js"
|
||||
import { loadScopedConfigurationPacket, loadScopedNetworkCheckpoint } from "./routing-community-scoped-checkpoint.js"
|
||||
import { assertScopedCommunityRequest } from "./routing-community-scoped-request.js"
|
||||
import { originalEffectReportSchema, originalEffectSelectionSchema } from "./routing-original-effects.js"
|
||||
import { tickRoutingProfiles } from "./routing-profiles.js"
|
||||
import { saveControllerProfileCas } from "./routing-controller-fence.js"
|
||||
import { withApplicationMutationLock, withConfigMutationLock } from "../../services/config-mutation-lock.js"
|
||||
import { performance } from "node:perf_hooks"
|
||||
@@ -31,8 +35,9 @@ import { registerManagementChannel } from "./management.js"
|
||||
import { isSchedulerJobsPaused, pauseSchedulerJobsAndWait, resumeSchedulerJobs } from "../../services/scheduler-running.js"
|
||||
|
||||
/** Disposable SQL + loopback HTTP only. Does not access or configure CHR. */
|
||||
export async function acceptScopedRouteCommunitySwitch(actor: AuthUser, options: { originalCredentialRevocationOnly?: boolean; forwardRoundsOnly?: boolean; manualInverseChargeOnly?: boolean; manualInverseChargeCase?: "positive" | "quota" | "concurrent" | "unknown" | "revoked" | "nonpersistent" } = {}) {
|
||||
const sid = 131, writer: AuthUser = { ...actor, permissions: [...new Set([...actor.permissions, "mm:filters:write"])] }
|
||||
export async function acceptScopedRouteCommunitySwitch(actor: AuthUser, options: { originalEffectsOnly?: boolean; originalCredentialRevocationOnly?: boolean; forwardRoundsOnly?: boolean; manualInverseChargeOnly?: boolean; manualInverseChargeCase?: "positive" | "quota" | "concurrent" | "unknown" | "revoked" | "nonpersistent" | "mixed-forward"; lateInverseAuthorityChange?: "edit" | "disable" | "grant" } = {}) {
|
||||
const sid = options.lateInverseAuthorityChange ? { edit: 131, disable: 132, grant: 133 }[options.lateInverseAuthorityChange] : 131
|
||||
const writer: AuthUser = { ...actor, permissions: [...new Set([...actor.permissions, "mm:filters:write"])] }
|
||||
const initial = compileManagedFilterRules([{ community: "65000:10", gateway: "192.0.2.1", outIface: "", action: "route", description: "" }, { community: "65000:20", gateway: "192.0.2.5", outIface: "", action: "route", description: "" }])
|
||||
const filters = ["a", "b"].map(key => ({ ".id": "*f" + key, chain: "custom-" + key, rule: initial, disabled: "false", comment: managedComment("NEVER_SCOPED_COMMENT_" + key), opaque: "NEVER_SCOPED_OPAQUE" }))
|
||||
let mode: "normal" | "ignored" | "lost" | "collateral" | "post-failed" | "post-unknown" = "normal", writes = 0, requests = 0
|
||||
@@ -42,6 +47,22 @@ export async function acceptScopedRouteCommunitySwitch(actor: AuthUser, options:
|
||||
let forwardRoundBulk = 0, afterRoundLogRead: (() => Promise<void>) | undefined
|
||||
let primaryDown = false, primaryControlRequests = 0, pendingInversePost = false, inversePostChecks = 0
|
||||
const observedVrfs = new Set<string>()
|
||||
// Only this separately selected SQL/localhost phase changes its wire data.
|
||||
// These are declared synthetic RouterOS responses, never actual CHR facts.
|
||||
let originalEffectCase: "transition" | "no-op" | "ambiguous" = "transition"
|
||||
const originalEffectWire: Array<{ method: string; path: string; afterPatch: boolean }> = []
|
||||
const originalEffectChanged = () => originalEffectCase === "no-op" || filters[0].rule !== initial
|
||||
const effectRoutes = () => {
|
||||
const changed = originalEffectChanged()
|
||||
const row = { ".id": "*effect1", afi: "ip4", "routing-table": "tenant-a", "dst-address": "203.0.113.0/24",
|
||||
bgp: "true", connect: "false", static: "false", active: "true", contribution: "active", ecmp: "false",
|
||||
disabled: "false", filtered: "false", unreachable: "false", blackhole: "false", "bgp.communities": "65000:10",
|
||||
gateway: changed ? "192.0.2.9" : "192.0.2.1", "immediate-gw": (changed ? "203.0.113.9" : "203.0.113.1") + "%wan-a" }
|
||||
return originalEffectCase === "ambiguous" ? [row, { ...row, ".id": "*effect2" }] : [row]
|
||||
}
|
||||
const effectConnection = { ".id": "*ca", name: "peer-a", instance: "default", vrf: "main", "routing-table": "tenant-a", afi: "ip",
|
||||
"input.filter": "custom-a", "output.filter-chain": "", templates: "", disabled: "false", "use-bfd": "false",
|
||||
"local.address": "198.18.0.1", "remote.address": "198.18.0.2", "remote.as": "65001" }
|
||||
const collections = (path: string): Record<string, unknown>[] => ({
|
||||
"/system/resource": [{ version: "7.20.1" }],
|
||||
...Object.fromEntries(["/routing/route", "/routing/bgp/session", "/routing/bfd/session", "/log"].map(resource => [resource,
|
||||
@@ -54,12 +75,25 @@ export async function acceptScopedRouteCommunitySwitch(actor: AuthUser, options:
|
||||
"/ip/route": [1, 9].map(value => ({ ".id": "*r" + value, "dst-address": "192.0.2." + value + "/32", "routing-table": "main", disabled: "false", comment: managedComment("recursive"), opaque: "NEVER_SCOPED_ROUTE" })),
|
||||
"/interface": ["wan-a", "wan-b", "hop", "target"].map((name, index) => ({ ".id": "*if" + index, name, disabled: "false", running: "true" })),
|
||||
"/ip/address": ["wan-a", "wan-b", "hop", "target"].map((name, index) => ({ ".id": "*ad" + index, interface: name, address: "198.51.100." + (index + 1) + "/32", disabled: "false", invalid: "false" })),
|
||||
...(options.originalEffectsOnly ? {
|
||||
"/routing/table": ["main", "tenant-a", "tenant-b", "probe-a", "probe-b"].map((name, index) => ({ ".id": "*t" + index, name, fib: "", disabled: "false", invalid: "false" })),
|
||||
"/routing/route": effectRoutes(), "/routing/bgp/connection": [effectConnection],
|
||||
"/routing/bgp/session": [{ ...effectConnection, ".id": "*sa", "local.afi": "ip", "remote.afi": "ip", established: "true" }],
|
||||
"/routing/bfd/session": [], "/log": [{ ".id": "*l1", topics: "bgp,route,info", message: "Declared synthetic localhost effect observation; interval coverage unknown" }],
|
||||
"/ip/route": [1, 9].map(value => ({ ".id": "*r" + value, "dst-address": "192.0.2." + value + "/32", "routing-table": "main", disabled: "false",
|
||||
"immediate-gw": "203.0.113." + value + "%wan-a", comment: managedComment("recursive"), opaque: "NEVER_SCOPED_ROUTE" })),
|
||||
} : {}),
|
||||
} as Record<string, Record<string, unknown>[]>)[path] ?? []
|
||||
let requestFailure: { error: unknown } | undefined
|
||||
const handler: RequestListener = async (req, res) => {
|
||||
requests++; res.setHeader("content-type", "application/json")
|
||||
if (options.originalEffectsOnly) {
|
||||
assert.equal(req.socket.localAddress, "127.0.0.1")
|
||||
assert.equal(req.headers.authorization, "Basic " + Buffer.from("admin:NEVER_SCOPED_PASSWORD").toString("base64"))
|
||||
}
|
||||
let raw = ""; for await (const chunk of req) raw += chunk
|
||||
const args = raw ? JSON.parse(raw) : {}, path = decodeURIComponent((req.url ?? "").replace(/^\/rest/, ""))
|
||||
if (options.originalEffectsOnly) originalEffectWire.push({ method: req.method ?? "", path, afterPatch: filters[0].rule !== initial })
|
||||
if (req.socket.localAddress === "127.0.0.1") {
|
||||
if (/^\/(routing|tool)\//.test(path) || path === "/system/license") primaryControlRequests++
|
||||
if (primaryDown) { req.socket.destroy(); return }
|
||||
@@ -155,7 +189,7 @@ export async function acceptScopedRouteCommunitySwitch(actor: AuthUser, options:
|
||||
}
|
||||
await pool.query("INSERT INTO filter_rules(server_id,sort_order,community,community_name,action,gateway,gateway_tunnel_id,description) VALUES($1,77,'65000:10','NEVER_SCOPED_SQL_NAME','route','203.0.113.99','NEVER_SCOPED_SQL_TUNNEL','NEVER_SCOPED_SQL_DESCRIPTION')", [sid])
|
||||
const dbBefore = (await pool.query("SELECT * FROM filter_rules WHERE server_id=$1 ORDER BY id", [sid])).rows
|
||||
const prepare = async (key = "a", persistent?: { id: string; version: number }, exactProfile?: ReturnType<typeof profile>) => {
|
||||
const prepare = async (key = "a", persistent?: { id: string; version: number }, exactProfile?: ReturnType<typeof profile>, assertApprovalRequired = false) => {
|
||||
const current = exactProfile === undefined ? profile(key) : routeAiProfileSchema.parse(exactProfile)
|
||||
assert.equal((await invokeMcpTool(principal, "mm_route_path_verify", { profile: current, candidateId: "b" }) as { status: string }).status, "passed")
|
||||
const plan = await invokeMcpTool(principal, "mm_route_optimizer_plan", { profile: current, candidateId: "b" }, undefined,
|
||||
@@ -171,6 +205,11 @@ export async function acceptScopedRouteCommunitySwitch(actor: AuthUser, options:
|
||||
assert(!JSON.stringify(projection).includes("persistentProfileAuthority"), "Persistent authority remains private")
|
||||
}
|
||||
assert.equal(evidence.requiresHuman, true); assert.equal(evidence.mutationCost, 2); assert.ok(!JSON.stringify(input).includes("NEVER_")); assert.equal(input.compiledBody, undefined)
|
||||
if (assertApprovalRequired) {
|
||||
const beforeApproval = { writes, requests }
|
||||
await assert.rejects(applyPlan(principal, plan.planId, randomUUID()), (error: unknown) => error instanceof McpError && error.code === "APPROVAL_REQUIRED")
|
||||
assert.deepEqual({ writes, requests }, beforeApproval)
|
||||
}
|
||||
await approvePlan(plan.planId, writer, true); return { plan, input }
|
||||
}
|
||||
const execute = async (planId: string, executingPrincipal: McpPrincipal = principal) => {
|
||||
@@ -203,6 +242,139 @@ export async function acceptScopedRouteCommunitySwitch(actor: AuthUser, options:
|
||||
return { id, version: 1 }
|
||||
}
|
||||
const disablePersistent = (id: string) => pool.query("UPDATE mcp_routing_profiles SET enabled=false WHERE id=$1", [id])
|
||||
if (options.originalEffectsOnly) {
|
||||
const cases: Array<Record<string, unknown>> = []
|
||||
let publicManualRevision2: Record<string, unknown> | undefined
|
||||
for (const scenario of ["transition", "no-op", "ambiguous", "public-manual-rev2"] as const) {
|
||||
const manual = scenario === "public-manual-rev2"
|
||||
const effectCase = manual ? "transition" : scenario
|
||||
reset(); await refresh(); originalEffectCase = effectCase
|
||||
const config = profile("a")
|
||||
for (const candidate of config.candidates) candidate.controlProbe!.sourceAddress = "198.51.100.1"
|
||||
assert.equal(config.autoRecovery, false)
|
||||
// The baseline and persistent authority are issued by the SAME actual
|
||||
// fixed readers/CAS used by existing tests, not hand-written proofs.
|
||||
const persistent = manual ? undefined : await persistentFixture(config)
|
||||
try {
|
||||
const { plan, input } = await prepare("a", persistent, config, manual)
|
||||
assert.equal(input.manualEffectPolicy, manual ? "observational-v1" : undefined)
|
||||
if (manual) assert.equal(input.persistentProfileAuthority, undefined)
|
||||
const sealedSelection = originalEffectSelectionSchema.parse(input.originalEffectSelection)
|
||||
assert.equal(sealedSelection.status, effectCase === "ambiguous" ? "unknown" : "prepared")
|
||||
const storedPlan = await loadPlan(plan.planId)
|
||||
assert.equal(storedPlan.approved_by, writer.id)
|
||||
const publicProjection = JSON.stringify(await publicPlan(storedPlan))
|
||||
assert(!publicProjection.includes("originalEffectSelection"))
|
||||
assert(!publicProjection.includes("manualEffectPolicy"))
|
||||
const beforeWrites = writes, wireStart = originalEffectWire.length
|
||||
const operation = await execute(plan.planId)
|
||||
assert.equal(operation.state, "applied", "Optional effect unknown must preserve genuine manual configuration/probe acceptance")
|
||||
assert.equal(writes - beforeWrites, 1)
|
||||
const rows: Array<{ created_at: Date; state: string; operation_id: string; operation_version: number; step_state: string;
|
||||
result: { configurationVerified: boolean; controlProbeVerified: boolean; originalEffects: unknown } }> = (await pool.query(`SELECT o.created_at,o.state,p.operation_id,p.operation_version,s.state AS step_state,s.result
|
||||
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 s.step_index=0`, [operation.id, connectionId, plan.planId])).rows
|
||||
assert.equal(rows.length, 1); assert.equal(rows[0].operation_id, "routing.community.switch")
|
||||
assert.equal(rows[0].state, "applied"); assert.equal(rows[0].step_state, "verified")
|
||||
assert.equal(rows[0].result.configurationVerified, true); assert.equal(rows[0].result.controlProbeVerified, true)
|
||||
const report = originalEffectReportSchema.parse(rows[0].result.originalEffects)
|
||||
const requestSeal = await assertScopedCommunityRequest(input)
|
||||
if (manual) {
|
||||
const changedInput = { ...input, manualEffectPolicy: "unapproved-policy" }
|
||||
await assert.rejects(assertScopedCommunityRequest(changedInput), (error: unknown) => error instanceof McpError && error.code === "STATE_CONFLICT")
|
||||
}
|
||||
const configuration = await loadScopedConfigurationPacket(input, requestSeal, { operationId: operation.id, stepIndex: 0 })
|
||||
assert(configuration); assert.equal(configuration.packet.operationId, operation.id)
|
||||
assert.equal(configuration.packet.revision, manual ? 1 : 2); assert.equal(configuration.packet.phase, "CONFIG_APPLIED")
|
||||
const network = await loadScopedNetworkCheckpoint(input, requestSeal)
|
||||
assert(network); assert.equal(network.status, "passed"); assert.deepEqual(network.originalEffects, report)
|
||||
const runningWire = originalEffectWire.slice(wireStart)
|
||||
const ribBefore = runningWire.filter(row => row.method === "GET" && row.path === "/routing/route" && !row.afterPatch).length
|
||||
const ribAfter = runningWire.filter(row => row.method === "GET" && row.path === "/routing/route" && row.afterPatch).length
|
||||
console.log("Owned original effect diagnostic:", JSON.stringify({ case: scenario, state: operation.state,
|
||||
status: report.status, reason: report.reason, rounds: report.rounds.length, budget: report.budget ?? null,
|
||||
ribBefore, ribAfter, runtimeNativeRequests: runningWire.length, lastNativePaths: runningWire.slice(-8).map(row => row.path) }))
|
||||
assert.equal(ribBefore, effectCase === "ambiguous" ? 0 : 1)
|
||||
assert.equal(ribAfter, effectCase === "ambiguous" ? 0 : 2)
|
||||
assert.equal(report.status, effectCase === "transition" ? "observed-expected" : "unknown")
|
||||
assert.equal(report.switchEffectObserved, effectCase === "transition")
|
||||
assert.equal(report.logCoverage, "unknown"); assert.equal(report.routeOriginVerified, false)
|
||||
assert.equal(report.forwardingVerified, false); assert.equal(report.semanticProof, false); assert.equal(report.autoRecoveryAllowed, false)
|
||||
if (sealedSelection.status === "prepared") {
|
||||
assert.equal(report.selectionIdentity, fingerprint(sealedSelection)); assert.equal(report.rounds.length, 2)
|
||||
assert(report.budget && report.budget.calls > 18 && report.budget.calls <= 256)
|
||||
assert(report.budget.bytes > 0 && report.budget.bytes <= 8 * 1048576)
|
||||
assert(report.budget.rows > 0 && report.budget.rows <= 10000)
|
||||
assert(report.budget.deadline <= rows[0].created_at.getTime() + 105000, "Original120s horizon is narrowed by15s publication reserve")
|
||||
assert(report.rounds.every(round => Date.parse(round.observedAt) < report.budget!.deadline && round.controlProbe === "passed" && round.bgp === "observed-expected" && round.bfd === "not-configured"))
|
||||
assert.deepEqual(report.transition?.changedPrefixes, effectCase === "transition" ? ["203.0.113.0/24"] : [])
|
||||
assert.equal(report.transitionAttribution, "unproven")
|
||||
}
|
||||
if (manual) {
|
||||
const repeatReads = requests
|
||||
const repeated = await applyPlan(principal, plan.planId, randomUUID())
|
||||
assert.equal(repeated.id, operation.id)
|
||||
assert.equal(repeated.state, "applied")
|
||||
assert.equal(requests, repeatReads)
|
||||
assert.equal(writes - beforeWrites, 1)
|
||||
assert.equal(Number((await pool.query("SELECT count(*) AS n FROM mcp_routing_profiles WHERE enabled")).rows[0].n), 0)
|
||||
assert.equal(Number((await pool.query("SELECT count(*) AS n FROM mcp_scoped_recovery_intents WHERE source_operation_id=$1", [operation.id])).rows[0].n), 0)
|
||||
publicManualRevision2 = { accepted: true, humanApprovalRequired: true, proofRevision: input.proofRevision,
|
||||
configurationCheckpointRevision: configuration.packet.revision, effectStatus: report.status,
|
||||
rounds: report.rounds.length, nativeRibBefore: ribBefore, nativeRibAfter: ribAfter, sharedBudget: report.budget,
|
||||
autoRecoveryAllowed: report.autoRecoveryAllowed, scopedControllerEnabled: false, nativeWrites: 1 }
|
||||
continue
|
||||
}
|
||||
assert(persistent)
|
||||
const row = (await pool.query("SELECT state,grant_expires_at FROM mcp_routing_profiles WHERE id=$1 AND version=1", [persistent.id])).rows[0]
|
||||
assert(row?.state.baseline)
|
||||
const advance = () => advanceRoutingControllerBaseline(row.state.baseline, persistent.id, 1, config, row.grant_expires_at.toISOString(),
|
||||
{ principal, connection: principalConnection }, plan.planId, operation.id)
|
||||
const principalConnection = await effectiveMcpConnection(principal), beforeAdvanceReads = requests
|
||||
if (effectCase === "transition") {
|
||||
const advanced = await advance(); assert.equal(advanced.packet.candidateId, "b")
|
||||
assert.equal(advanced.packet.transition?.operationId, operation.id); assert.equal(advanced.packet.transition?.checkpointProof, configuration.checkpointProof)
|
||||
} else {
|
||||
await assert.rejects(advance, (error: unknown) => error instanceof McpError && error.code === "STATE_CONFLICT")
|
||||
assert.equal(requests, beforeAdvanceReads, "Unknown/no-transition effect gate refuses before another native context read")
|
||||
}
|
||||
// Check actual HMAC loading, not only a caller-visible result object.
|
||||
if (effectCase === "transition") {
|
||||
const audit: Array<{ id: string; payload: Record<string, unknown> }> = (await pool.query("SELECT id,payload FROM mcp_audit WHERE connection_id=$1 AND action='routing.scoped.network-check' AND payload->>'receiptId'=$2", [connectionId, input.receiptId])).rows
|
||||
assert.equal(audit.length, 1)
|
||||
try {
|
||||
await pool.query("UPDATE mcp_audit SET payload=jsonb_set(payload,'{originalEffects,switchEffectObserved}','false'::jsonb) WHERE id=$1", [audit[0].id])
|
||||
assert.equal(await loadScopedNetworkCheckpoint(input, requestSeal), null, "Modified effect vector cannot reuse its original network HMAC")
|
||||
} finally { await pool.query("UPDATE mcp_audit SET payload=$2::jsonb WHERE id=$1", [audit[0].id, JSON.stringify(audit[0].payload)]) }
|
||||
assert.deepEqual((await loadScopedNetworkCheckpoint(input, requestSeal))?.originalEffects, report)
|
||||
}
|
||||
const repeatReads = requests
|
||||
const repeated = await applyPlan(principal, plan.planId, randomUUID())
|
||||
assert.equal(repeated.id, operation.id, "Completed plan returns its original operation without replay")
|
||||
assert.equal(repeated.state, "applied")
|
||||
assert.equal(Number((await pool.query("SELECT count(*) AS n FROM mcp_operations WHERE plan_id=$1", [plan.planId])).rows[0].n), 1)
|
||||
assert.equal(writes - beforeWrites, 1); assert.equal(requests, repeatReads, "Completed approved plan cannot repeat native work")
|
||||
// Preserve the existing scoped-controller activation guard. A real
|
||||
// tick pauses HERE before effect dispatch; never call this an
|
||||
// effect-triggered scheduler pause or enable that unsupported lane.
|
||||
await tickRoutingProfiles()
|
||||
const paused = (await pool.query("SELECT enabled,state FROM mcp_routing_profiles WHERE id=$1", [persistent.id])).rows[0]
|
||||
assert.equal(paused.enabled, false); assert.equal(typeof paused.state.pausedReason, "string")
|
||||
assert.deepEqual(paused.state.baseline, row.state.baseline)
|
||||
cases.push({ case: effectCase, manualState: operation.state, configurationCheckpoint: true, actualPrivateExecutionOrigin: true,
|
||||
effectStatus: report.status, observedFibTransition: report.switchEffectObserved, nativeRibBefore: ribBefore, nativeRibAfter: ribAfter,
|
||||
sharedBudget: report.budget ?? null, effectBaselineAdvanceAccepted: effectCase === "transition", effectGateNativeReads: effectCase === "transition" ? "fresh-native-checked" : 0,
|
||||
actualTickPausedByExistingScopedGuard: true, tickEffectPauseAccepted: false, publicManualRevision2EffectOwnerAccepted: false })
|
||||
} finally { if (persistent) await disablePersistent(persistent.id) }
|
||||
}
|
||||
assert(publicManualRevision2)
|
||||
assert.deepEqual((await pool.query("SELECT * FROM filter_rules WHERE server_id=$1 ORDER BY id", [sid])).rows, dbBefore)
|
||||
const result = { accepted: true, cases, publicManualRevision2, persistentAuthorityCases: 0, realMcpDispatcher: true, realSql: true, routerOsTransport: "declared-synthetic-localhost-http",
|
||||
genuineApprovedRunningOrigin: true, actualConfigurationCheckpoint: true, signedEffectTamperRejected: true,
|
||||
originalAutoRecoveryEnabled: false, fullRoutingAccepted: false, scopedControllerEnabled: false, nativeChrAccepted: false, forwardingVerified: false }
|
||||
console.log("Original effect SQL/runtime component:", JSON.stringify(result))
|
||||
return result
|
||||
}
|
||||
const assertOriginalCredentialRevocation = async () => {
|
||||
console.log(JSON.stringify({stage:'original-credential-revocation:init'}))
|
||||
reset(); await refresh()
|
||||
@@ -252,14 +424,16 @@ export async function acceptScopedRouteCommunitySwitch(actor: AuthUser, options:
|
||||
await assertOriginalCredentialRevocation()
|
||||
return {accepted:true,originalCredentialRevocation:true,unauthorized401:true,fullAdmissionRollback:true,nativeReads:0,nativeWrites:0,realSql:true,allWriterLedgerComplete:false}
|
||||
}
|
||||
if (options.manualInverseChargeOnly) {
|
||||
if (options.manualInverseChargeOnly || options.lateInverseAuthorityChange) {
|
||||
const chargeCase = options.manualInverseChargeCase ?? "positive"
|
||||
if (options.lateInverseAuthorityChange) assert.equal(chargeCase, "positive")
|
||||
const persistent = chargeCase !== "nonpersistent"
|
||||
// Same genuine fixed-reader baseline/CAS used by the existing persistent
|
||||
// acceptance below. No hand-written baseline, sealed plan or authority.
|
||||
const id = randomUUID(), current = await effectiveMcpConnection(principal), config = profile("a")
|
||||
assert.equal(config.autoRecovery, false)
|
||||
if (chargeCase === "quota") config.maxSwitchesPerHour = 1
|
||||
if (chargeCase === "mixed-forward") config.maxSwitchesPerHour = 2
|
||||
const expiry = new Date(Math.min(Date.now() + 600000, current.owner_valid_until.getTime(), Date.parse(principal.credentialExpiresAt), Date.parse(current.policy.expiresAt)))
|
||||
await withApplicationMutationLock(() => withConfigMutationLock([sid], async () => {
|
||||
const baseline = await prepareRoutingControllerBaseline(id, 1, config, expiry.toISOString(), { principal, connection: current })
|
||||
@@ -292,6 +466,10 @@ export async function acceptScopedRouteCommunitySwitch(actor: AuthUser, options:
|
||||
}
|
||||
console.log(JSON.stringify({stage:"manual-inverse:forward-prepare",chargeCase}))
|
||||
const { plan, input } = await prepare("a", persistent ? { id, version: 1 } : undefined, config)
|
||||
// Prepare the competing forward while the ORIGINAL native baseline is
|
||||
// current. Admission must charge it even if execution later refuses the
|
||||
// changed network state. No invented request/authority or reserved lane.
|
||||
const competingForward = chargeCase === "mixed-forward" ? await prepare("a", { id, version: 1 }, config) : undefined
|
||||
// Existing known partial configuration path: genuine committed CONFIG_APPLIED,
|
||||
// then failed control probe, then genuine manual inverse positive to A.
|
||||
mode = "post-failed"
|
||||
@@ -348,7 +526,97 @@ export async function acceptScopedRouteCommunitySwitch(actor: AuthUser, options:
|
||||
await assert.rejects(applyPlan(principal, recovery.planId, randomUUID()))
|
||||
assert.deepEqual(await snapshot(), afterForward, "No approval means no debit/charge/native write")
|
||||
await approvePlan(recovery.planId, writer, true)
|
||||
if (chargeCase === "mixed-forward") {
|
||||
assert(competingForward)
|
||||
const attempts = [
|
||||
{ lane: "forward", planId: competingForward.plan.planId, key: randomUUID(), cost: 2 },
|
||||
{ lane: "manual-inverse", planId: recovery.planId, key: randomUUID(), cost: 1 },
|
||||
] as const
|
||||
const initialPlans = await Promise.all(attempts.map(attempt => loadPlan(attempt.planId)))
|
||||
assert(initialPlans.every(value => value.state === "approved"))
|
||||
const admitted = await Promise.allSettled(attempts.map(attempt => applyPlan(principal, attempt.planId, attempt.key)))
|
||||
const winners = admitted.flatMap((value, index) => value.status === "fulfilled" ? [index] : [])
|
||||
assert.equal(winners.length, 1, "Two writer lanes must compete for the same last profile slot")
|
||||
const winnerIndex = winners[0], loserIndex = 1 - winnerIndex
|
||||
const winner = admitted[winnerIndex], loser = admitted[loserIndex]
|
||||
assert(winner.status === "fulfilled" && loser.status === "rejected")
|
||||
assert(loser.reason instanceof McpError && loser.reason.code === "STATE_CONFLICT", "The losing lane must fail profile admission")
|
||||
assert.equal(fingerprint(await loadPlan(attempts[loserIndex].planId)), fingerprint(initialPlans[loserIndex]), "Losing plan is completely unchanged")
|
||||
assert.equal(Number((await pool.query("SELECT count(*)::int AS n FROM mcp_operations WHERE plan_id=$1", [attempts[loserIndex].planId])).rows[0].n), 0)
|
||||
assert.equal(Number((await pool.query("SELECT count(*)::int AS n FROM mcp_routing_forward_charges WHERE source_plan_id=$1", [attempts[loserIndex].planId])).rows[0].n), 0)
|
||||
assert.equal(Number((await pool.query("SELECT count(*)::int AS n FROM mcp_routing_manual_inverse_charges WHERE recovery_plan_id=$1", [attempts[loserIndex].planId])).rows[0].n), 0)
|
||||
const deadline = Date.now() + 140000
|
||||
let terminalState = "pending"
|
||||
while (["pending", "running"].includes(terminalState) && Date.now() < deadline) {
|
||||
terminalState = (await pool.query("SELECT state FROM mcp_operations WHERE id=$1", [winner.value.id])).rows[0].state
|
||||
if (["pending", "running"].includes(terminalState)) await delay(20)
|
||||
}
|
||||
assert(!["pending", "running"].includes(terminalState), "Winner must retire before fixture cleanup")
|
||||
if (requestFailure) throw requestFailure.error
|
||||
snapshotStage = "mixed-writer-terminal"
|
||||
const afterMixed = await snapshot()
|
||||
assert.equal(afterMixed.total, 2)
|
||||
assert.equal(afterMixed.forward, winnerIndex === 0 ? 2 : 1)
|
||||
assert.equal(afterMixed.manualInverse, winnerIndex === 1 ? 1 : 0)
|
||||
assert.equal(afterMixed.reserved, 0); assert.equal(afterMixed.legacy, 0)
|
||||
assert.equal(afterMixed.generic, afterForward.generic + attempts[winnerIndex].cost, "Losing writer cannot debit generic quota")
|
||||
if (winnerIndex === 1) {
|
||||
const charges = (await pool.query("SELECT source_operation_id,source_step_index,recovery_operation_id FROM mcp_routing_manual_inverse_charges WHERE recovery_operation_id=$1", [winner.value.id])).rows
|
||||
assert.equal(charges.length, 1); assert.equal(charges[0].source_operation_id, source.id)
|
||||
assert.equal(charges[0].source_step_index, 0); assert.equal(charges[0].recovery_operation_id, winner.value.id)
|
||||
assert.equal(terminalState, "applied"); assert.equal(afterMixed.writes, 2)
|
||||
}
|
||||
const beforeReplay = fingerprint(await snapshot())
|
||||
assert.equal((await applyPlan(principal, attempts[winnerIndex].planId, attempts[winnerIndex].key)).id, winner.value.id)
|
||||
await delay(30)
|
||||
assert.equal(fingerprint(await snapshot()), beforeReplay, "Same-key observation cannot recharge or replay either lane")
|
||||
return { accepted: true, mixedWriterAdmission: true, winnerLane: attempts[winnerIndex].lane, before, afterForward, afterMixed,
|
||||
exactlyOneAdmission: true, losingPlanOperationChargeAndGenericRollback: true, sameKeyReplayUnchanged: true,
|
||||
realSql: true, allWriterLedgerComplete: false, reservedDispatchEnabled: false, autoRecoveryEnabled: false }
|
||||
}
|
||||
console.log(JSON.stringify({ stage: "manual-inverse:human-recovery-apply" }))
|
||||
const lateChange = options.lateInverseAuthorityChange
|
||||
if (lateChange) {
|
||||
const priorIntent = (await pool.query("SELECT coalesce(max(id),0)::text AS id FROM mcp_audit WHERE connection_id=$1 AND action='configuration.intent'", [connectionId])).rows[0].id
|
||||
let writeId: string | undefined
|
||||
afterIntent = async () => {
|
||||
const intents = (await pool.query(`SELECT payload FROM mcp_audit WHERE connection_id=$1 AND id>$2::bigint
|
||||
AND action='configuration.intent' AND payload->>'path'=$3`,
|
||||
[connectionId, priorIntent, "/routing/filter/rule/" + loaded.configuration.packet.nativeId])).rows
|
||||
assert.equal(intents.length, 1)
|
||||
assert.equal(intents[0].payload.method, "PATCH")
|
||||
assert.equal(intents[0].payload.serverId, sid)
|
||||
writeId = intents[0].payload.writeId
|
||||
assert.match(writeId ?? "", /^[0-9a-f-]{36}$/)
|
||||
const sql = {
|
||||
edit: "UPDATE mcp_routing_profiles SET version=version+1,config=jsonb_set(config,'{name}','\"late inverse edit\"') WHERE id=$1",
|
||||
disable: "UPDATE mcp_routing_profiles SET enabled=false WHERE id=$1",
|
||||
grant: "UPDATE mcp_routing_profiles SET grant_expires_at=clock_timestamp()-interval '1 second' WHERE id=$1",
|
||||
}[lateChange]
|
||||
assert.equal((await pool.query(sql, [id])).rowCount, 1)
|
||||
}
|
||||
const inverse = await execute(recovery.planId)
|
||||
assert.equal(afterIntent, undefined)
|
||||
assert.ok(writeId)
|
||||
assert.notEqual(inverse.state, "applied")
|
||||
assert.equal(writes, afterForward.writes)
|
||||
assert.equal(inversePostChecks, 0)
|
||||
const auditRows = (await pool.query(`SELECT action FROM mcp_audit WHERE connection_id=$1 AND payload->>'writeId'=$2
|
||||
ORDER BY id`, [connectionId, writeId])).rows
|
||||
assert.deepEqual(auditRows.map(row => row.action), ["configuration.intent", "configuration.unconfirmed"])
|
||||
const refusal = (await pool.query(`SELECT payload->>'phase' AS phase, payload->>'code' AS code
|
||||
FROM mcp_audit WHERE connection_id=$1 AND action='routing.scoped.recovery-unconfirmed'`, [connectionId])).rows
|
||||
assert.deepEqual(refusal, [{ phase: "before-dispatch-context", code: "STATE_CONFLICT" }])
|
||||
assert.deepEqual((await pool.query(`SELECT state,verified_sealed FROM mcp_scoped_recovery_intents
|
||||
WHERE source_operation_id=$1 AND recovery_operation_id=$2`, [source.id, inverse.id])).rows,
|
||||
[{ state: "unknown", verified_sealed: null }])
|
||||
assert.equal((await pool.query(`SELECT count(*)::int AS n FROM mcp_operation_checkpoints
|
||||
WHERE operation_id=$1 AND configuration_sealed IS NOT NULL`, [inverse.id])).rows[0].n, 0)
|
||||
assert.equal((await pool.query("SELECT error_code FROM mcp_operations WHERE id=$1", [inverse.id])).rows[0].error_code,
|
||||
"MUTATION_OUTCOME_UNKNOWN")
|
||||
return { accepted: true, lateInverseAuthorityChange: lateChange, durableIntents: 1, inverseNativeWrites: 0,
|
||||
realSql: true, realMcpDispatcher: true, fullInverseLifecycleRepeated: false }
|
||||
}
|
||||
if (chargeCase === "quota" || chargeCase === "revoked") {
|
||||
if (chargeCase === "revoked") await pool.query("UPDATE mcp_routing_profiles SET enabled=false WHERE id=$1", [id])
|
||||
const beforeOperations = (await pool.query("SELECT count(*)::int AS n FROM mcp_operations WHERE connection_id=$1", [connectionId])).rows[0].n
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { originalEffectReportSchema, type OriginalEffectReport } from "./routing-original-effects.js"
|
||||
import { z } from "zod"
|
||||
import type { PoolClient } from "pg"
|
||||
import { pool } from "../../db/index.js"
|
||||
@@ -19,7 +20,7 @@ const checkpointSchema = z.union([manualCheckpointSchema, manualCheckpointSchema
|
||||
export type ScopedConfigurationPacket = z.infer<typeof checkpointSchema>
|
||||
type Checkpoint = ScopedConfigurationPacket
|
||||
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 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}`)
|
||||
@@ -72,12 +73,13 @@ export async function loadScopedConfigurationCheckpoint(input: Record<string, un
|
||||
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)
|
||||
const configuration = await loadScopedConfigurationCheckpoint(input, requestSeal)
|
||||
if (!configuration) throw new McpError("STATE_CONFLICT", "Нет подтверждённого CONFIG_APPLIED", 409)
|
||||
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")
|
||||
await assertPersistentScopedProfileAuthority(input)
|
||||
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
|
||||
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
|
||||
return { status: packet.check.status, networkSeal }
|
||||
return { status: packet.check.status, networkSeal, ...(packet.originalEffects === undefined ? {} : { originalEffects: packet.originalEffects }) }
|
||||
}
|
||||
@@ -257,24 +257,30 @@ async function checkpoint(value: Session, intent: Intent, phase: Checkpoint["pha
|
||||
export async function executeReservedScopedRecovery(allocation: RoutingReservedInverseAllocation, expected: { sourceOperationId: string; principal: McpPrincipal }) {
|
||||
const store = createRoutingReservationStore(), spent = await store.beginInverseAttempt(allocation)
|
||||
const execution = spent.execution
|
||||
const binding = await store.inspectInverseExecution(execution)
|
||||
if (binding.sourceOperationId !== expected.sourceOperationId || fingerprint(binding.principal) !== fingerprint(expected.principal)) throw fail("Reserved runner identity differs")
|
||||
const current = await effectiveMcpConnection(binding.principal)
|
||||
const context: ActionContext = { principal: binding.principal, connection: current }
|
||||
const source = await loadReservedScopedRecoverySource(execution, context)
|
||||
const channel: ManagementTransportBinding | undefined = source.input.managementBindingCiphertext == null ? undefined
|
||||
: JSON.parse(await decryptSecret(String(source.input.managementBindingCiphertext)))
|
||||
const input: Record<string, unknown> = { mode: "reserved", serverId: source.profile.serverId, sourceOperationId: binding.sourceOperationId,
|
||||
sourceProof: source.sourceProof, configurationProof: binding.configurationProof, sourceReceiptId: binding.sourceReceiptId,
|
||||
profile: source.profile, candidateId: source.configuration.packet.originalCandidateId, rules: source.input.beforeRules, beforeRules: source.input.rules,
|
||||
principal: binding.principal, ownerId: current.owner_id, ownerProof: fingerprint(current.owner_permissions), policyProof: fingerprint(current.policy), connectionGeneration: current.generation,
|
||||
generation: await accessGeneration(), receiptId: binding.inverseReceiptId, proofRevision: 1,
|
||||
validUntil: new Date(Math.min(Date.parse(binding.deadline), deadline(source, context, channel?.expiresAt))).toISOString(), managementBindingCiphertext: source.input.managementBindingCiphertext ?? null }
|
||||
input.requestProof = await sealedProof(input, "request")
|
||||
reservedExecutions.set(input, execution)
|
||||
try { return await executeScopedRecovery(input) }
|
||||
catch (error) { await store.markUnknown(binding.reservationId, "inverse").catch(() => {}); throw error }
|
||||
finally { reservedExecutions.delete(input) }
|
||||
try {
|
||||
const binding = await store.inspectInverseExecution(execution)
|
||||
if (binding.sourceOperationId !== expected.sourceOperationId || fingerprint(binding.principal) !== fingerprint(expected.principal)) throw fail("Reserved runner identity differs")
|
||||
const current = await effectiveMcpConnection(binding.principal)
|
||||
const context: ActionContext = { principal: binding.principal, connection: current }
|
||||
const source = await loadReservedScopedRecoverySource(execution, context)
|
||||
const channel: ManagementTransportBinding | undefined = source.input.managementBindingCiphertext == null ? undefined
|
||||
: JSON.parse(await decryptSecret(String(source.input.managementBindingCiphertext)))
|
||||
const input: Record<string, unknown> = { mode: "reserved", serverId: source.profile.serverId, sourceOperationId: binding.sourceOperationId,
|
||||
sourceProof: source.sourceProof, configurationProof: binding.configurationProof, sourceReceiptId: binding.sourceReceiptId,
|
||||
profile: source.profile, candidateId: source.configuration.packet.originalCandidateId, rules: source.input.beforeRules, beforeRules: source.input.rules,
|
||||
principal: binding.principal, ownerId: current.owner_id, ownerProof: fingerprint(current.owner_permissions), policyProof: fingerprint(current.policy), connectionGeneration: current.generation,
|
||||
generation: await accessGeneration(), receiptId: binding.inverseReceiptId, proofRevision: 1,
|
||||
validUntil: new Date(Math.min(Date.parse(binding.deadline), deadline(source, context, channel?.expiresAt))).toISOString(), managementBindingCiphertext: source.input.managementBindingCiphertext ?? null }
|
||||
input.requestProof = await sealedProof(input, "request")
|
||||
reservedExecutions.set(input, execution)
|
||||
try { return await executeScopedRecovery(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
|
||||
|
||||
@@ -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 { originalEffectSelectionSchema, unknownOriginalEffects, originalEffectBaselineMatches, inspectOriginalEffectRounds, type OriginalEffectReport } from "./routing-original-effects.js"
|
||||
import { randomUUID } from "node:crypto"
|
||||
import { z } from "zod"
|
||||
import { routeAiProfileSchema } from "@mmapp/contracts/mcp-routing"
|
||||
@@ -25,6 +29,11 @@ import { createRoutingReservationStore } from "./routing-reservation-store.js"
|
||||
|
||||
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 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)
|
||||
const publicRule = (rule: { community: string; gateway: string; outIface: string }) => ({ community: rule.community, action: "route", gateway: rule.gateway, gatewayTunnelId: rule.outIface })
|
||||
type ObservationPhase = "admission" | "configuration"
|
||||
@@ -80,7 +89,7 @@ async function receipt(input: Record<string, unknown>, action: string) {
|
||||
await assertPersistentScopedProfileAuthority(input)
|
||||
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) {
|
||||
await sealedInput(input)
|
||||
const value = await observe(input, undefined, verified)
|
||||
@@ -128,7 +137,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)) }))) }
|
||||
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,
|
||||
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)] }
|
||||
@@ -156,7 +168,26 @@ export function registerScopedCommunityService() {
|
||||
if (await receiptExists(input, "routing.scoped.attempt")) throw fail("Повтор PATCH запрещён", "MUTATION_OUTCOME_UNKNOWN")
|
||||
await receipt(input, "routing.scoped.attempt")
|
||||
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 {
|
||||
// Optional original-effect vector is never an extra write/recovery grant.
|
||||
// Old manual configuration+probe acceptance remains authoritative.
|
||||
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 }
|
||||
}
|
||||
await executeGuardedConfigurationWrite(before.profile.serverId, "PATCH", "/routing/filter/rule/" + owned.nativeId, async () => {
|
||||
// This runs AFTER the durable configuration.intent, immediately before transport.
|
||||
const fresh = await original(input, true); await before.context.fence()
|
||||
@@ -170,13 +201,47 @@ export function registerScopedCommunityService() {
|
||||
await saveScopedConfigurationCheckpoint(input, await sealedInput(input))
|
||||
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")
|
||||
// A probe can be long: prove the whole configuration again before a durable VERIFIED receipt.
|
||||
const final = await observe(input, undefined, true, undefined, "configuration")
|
||||
if (!matches(input, final.context).configurationMatchesDesired) throw fail("Состояние изменилось после probe")
|
||||
await final.context.fence()
|
||||
await saveScopedNetworkCheckpoint(input, await sealedInput(input), { ...check, status: check.status })
|
||||
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)
|
||||
const observed = await inspectOriginalEffectRounds(effectSelection.data, [first, second], effectBaseline), budget = effectSession.readBudget()
|
||||
originalEffects = { ...observed, budget: { deadline: budget.deadline, calls: budget.calls, bytes: budget.bytes, rows: budget.rows } }
|
||||
} 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()
|
||||
const budget = effectSession.readBudget()
|
||||
originalEffects = { ...originalEffects!, budget: { deadline: budget.deadline, calls: budget.calls, bytes: budget.bytes, rows: budget.rows } }
|
||||
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")
|
||||
if (!matches(input, final.context).configurationMatchesDesired) throw fail("Состояние изменилось после probe")
|
||||
await final.context.fence()
|
||||
}
|
||||
if (effectSession && originalEffects) {
|
||||
const budget = effectSession.readBudget()
|
||||
originalEffects = { ...originalEffects, budget: { deadline: budget.deadline, calls: budget.calls, bytes: budget.bytes, rows: budget.rows } }
|
||||
}
|
||||
await saveScopedNetworkCheckpoint(input, await sealedInput(input), { ...check, status: check.status }, originalEffects)
|
||||
return verifiedResult(input)
|
||||
} catch { throw fail("Результат переключения не подтверждён; повтор запрещён, требуется сверка", "MUTATION_OUTCOME_UNKNOWN") }
|
||||
finally { effectSession?.close() }
|
||||
})),
|
||||
verify: async (input, result) => {
|
||||
const expected = await verifiedResult(input)
|
||||
@@ -200,6 +265,7 @@ async function verifiedResult(input: Record<string, unknown>) {
|
||||
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,
|
||||
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}`) }
|
||||
await assertPersistentScopedProfileAuthority(input)
|
||||
return result
|
||||
|
||||
@@ -4,6 +4,7 @@ import { findMcpOperation } from "./registry.js"
|
||||
import { assertScopedCommunityRequest } from "./routing-community-scoped-request.js"
|
||||
import { assertPersistentScopedProfileAuthority, routingProfileAuthorityBindingSchema } from "./routing-profile-authority.js"
|
||||
import { loadScopedConfigurationPacket, loadScopedNetworkCheckpoint } from "./routing-community-scoped-checkpoint.js"
|
||||
import { originalEffectSelectionSchema } from "./routing-original-effects.js"
|
||||
import { z } from "zod"
|
||||
import { routeAiProfileSchema, type RouteAiProfile } from "@mmapp/contracts/mcp-routing"
|
||||
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 network = await loadScopedNetworkCheckpoint(input, requestSeal)
|
||||
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
|
||||
if (packet.operationId !== operationId || packet.stepIndex !== 0 || packet.nativeId !== original.nativeId ||
|
||||
packet.originalCandidateId !== original.candidateId || packet.originalTargetProof !== original.targetProof ||
|
||||
|
||||
@@ -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 { dedupeVrfAliasSnapshots } from "../../services/vrf-production-action-binding.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(), {
|
||||
read: async input => {
|
||||
const { client } = await routerClient(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" }
|
||||
const entries = await Promise.all(Object.entries(paths).map(async ([key, path]) => {
|
||||
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 }] }
|
||||
catch { return [key, { items: [], observed: false, reason: "Устройство не подтвердило возможность чтения" }] }
|
||||
}))
|
||||
return { serverId: input.serverId, observedAt: new Date().toISOString(), resources: Object.fromEntries(entries), observedPath: false }
|
||||
read: async (input, context) => {
|
||||
const sid = Number(input.serverId)
|
||||
if (!context.connection.policy.scope.serverIds.includes(sid)) throw new McpError("SERVER_DENIED", "Источник топологии вне разрешённой области", 403)
|
||||
const signal = AbortSignal.any([AbortSignal.timeout(30000), ...(context.signal ? [context.signal] : [])])
|
||||
const authorize = async () => {
|
||||
if (signal.aborted) throw new McpError("READ_CANCELLED", "Чтение топологии отменено или превысило срок", 409)
|
||||
const current = await effectiveMcpConnection(context.principal)
|
||||
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(), {
|
||||
|
||||
@@ -43,14 +43,43 @@ test("new unknown attempts block older successful data independently for each me
|
||||
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)
|
||||
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) }
|
||||
assert.equal(state.failureCount, 1)
|
||||
const second = decideRoutingSwitch(profile, state, ranking, "a", now, health("two")); assert.equal(second.switchTo, null)
|
||||
assert.equal(decideRoutingSwitch(profile, second.state, ranking, "a", now, health("three")).switchTo, "b")
|
||||
assert.equal(decideRoutingSwitch(profile, second.state, ranking, "a", now, health("three", "unknown")).switchTo, null)
|
||||
assert.equal(decideRoutingSwitch(profile, { ...second.state, switches: [at, at, at] }, ranking, "a", now, health("three")).switchTo, null)
|
||||
assert.equal(decideRoutingSwitch(profile, second.state, ranking, "a", now + 100000, health("three")).switchTo, null)
|
||||
const secondAt = now + 60000, thirdAt = now + 120000
|
||||
const second = decideRoutingSwitch(profile, state, ranking, "a", secondAt, health("two", secondAt)); assert.equal(second.switchTo, null)
|
||||
assert.equal(decideRoutingSwitch(profile, second.state, ranking, "a", thirdAt, health("three", thirdAt)).switchTo, "b")
|
||||
const unknown = decideRoutingSwitch(profile, second.state, ranking, "a", thirdAt, health("three", thirdAt, "unknown"))
|
||||
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("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")!
|
||||
@@ -92,3 +121,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, 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,47 @@ export function rankRoutingCandidates(profile: RouteAiProfile, observations: Rou
|
||||
}
|
||||
|
||||
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 }
|
||||
const pathHealthFreshnessSec = 90
|
||||
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) }
|
||||
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 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 (state.failureKey !== currentHealth.id) {
|
||||
next.failureCount = state.failingCandidateId === currentCandidateId ? (state.failureCount ?? 0) + 1 : 1
|
||||
next.failureKey = currentHealth.id; next.failingCandidateId = currentCandidateId
|
||||
const observed = Date.parse(currentHealth.observedAt), previous = Date.parse(state.failureObservedAt ?? "")
|
||||
const sameCandidate = state.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 (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" }
|
||||
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 (!current?.eligible || current.score === null) return { state: next, switchTo: null, reason: "Состояние текущего пути неизвестно; failover требует отдельных подтверждённых проверок отказа" }
|
||||
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 < 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(":")
|
||||
if (next.pendingCandidateId !== undefined && next.pendingCandidateId !== best.candidate.id) {
|
||||
next.pendingCandidateId = best.candidate.id
|
||||
next.consecutive = 0
|
||||
}
|
||||
if (observationKey !== next.observationKey) {
|
||||
next.consecutive = next.pendingCandidateId === best.candidate.id ? (next.consecutive ?? 0) + 1 : 1
|
||||
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 (switchCount >= profile.maxSwitchesPerHour) return { state: { ...next, pausedReason: "Лимит переключений" }, switchTo: null, reason: "Исчерпан часовой бюджет переключений" }
|
||||
return { state: next, switchTo: best.candidate.id, reason: "Улучшение подтверждено свежими наблюдениями" }
|
||||
|
||||
@@ -14,7 +14,7 @@ async function fixture() {
|
||||
let nativeReads = 0, profileChecks = 0, sourceReads = 0, configChecks = 0
|
||||
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 sharedReads = 0, sharedClosed = false
|
||||
let sharedReads = 0, sharedClosed = false, manualOwner = false
|
||||
class ClockDate extends Date { static now() { return now } }
|
||||
class BridgeError extends Error { constructor(readonly code: string, message: string) { super(message) } }
|
||||
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),
|
||||
deadline: now + 10000, contextSources: structuredClone(input.contextSources), candidateId: "b" }
|
||||
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 } },
|
||||
"../../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) },
|
||||
@@ -93,7 +94,7 @@ async function fixture() {
|
||||
const reseal = () => { row.evidence.sealed = JSON.stringify(evidence) }
|
||||
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,
|
||||
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 },
|
||||
revoke: () => { revoked = true }, sourceChanged: () => { sourceChanged = true }, checkpointMissing: () => { checkpointMissing = 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)
|
||||
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 () => {
|
||||
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/)
|
||||
@@ -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 () => {
|
||||
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++) {
|
||||
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(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/)
|
||||
})
|
||||
|
||||
@@ -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))
|
||||
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 { assertScopedCommunityRequest } from "./routing-community-scoped-request.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 { readRouteCommunityContext, readRouteCommunityContextWithSharedNativeSession } from "./routing-community-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
|
||||
* round acquisition; JSON copies and caller-supplied observations cannot. */
|
||||
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)
|
||||
if (!entry) throw fail("Configuration bridge admission disappeared")
|
||||
contexts.delete(initial)
|
||||
@@ -96,7 +99,8 @@ export function closeRoutingNetworkForwardRoundConfigurationContext(context: Rou
|
||||
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)
|
||||
const principal = freeze(structuredClone(action.principal)), connection = freeze(structuredClone(action.connection))
|
||||
const abort = new AbortController()
|
||||
@@ -134,9 +138,16 @@ async function resolveConfigurationContext(sourceOperationId: string, action: Ac
|
||||
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")
|
||||
}
|
||||
if (input.proofRevision !== 3 || input.persistentProfileAuthority === undefined || profile.autoRecovery) throw fail("Persistent manual revision-3 source required; recovery guard remains closed")
|
||||
const authority = routingProfileAuthorityBindingSchema.parse(input.persistentProfileAuthority)
|
||||
deadline = Math.min(deadline, Date.parse(authority.packet.validUntil))
|
||||
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)
|
||||
deadline = Math.min(deadline, Date.parse(authority.packet.validUntil))
|
||||
}
|
||||
signal = AbortSignal.any([signal, AbortSignal.timeout(Math.max(1, deadline - Date.now()))])
|
||||
active()
|
||||
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 () => {
|
||||
await authorize()
|
||||
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() ||
|
||||
Date.parse(result.observedAt) >= deadline) throw fail("Genuine persistent CONFIG_APPLIED unavailable")
|
||||
await authorize()
|
||||
@@ -190,7 +201,11 @@ async function resolveConfigurationContext(sourceOperationId: string, action: Ac
|
||||
bodyIdentity: fingerprint(original.packet.desiredRule), dependencyIdentity: original.packet.dependencyProof },
|
||||
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
|
||||
contexts.set(token, { consumed: false, close, read: readback, consume: async () => {
|
||||
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 }
|
||||
}
|
||||
@@ -15,6 +15,8 @@ assert.equal(target.search, ""); assert.equal(target.hash, "")
|
||||
assert(process.env.MCP_STATE_DIR && isAbsolute(process.env.MCP_STATE_DIR))
|
||||
assert.equal(basename(resolve(process.env.MCP_STATE_DIR)), "state")
|
||||
assert(basename(dirname(resolve(process.env.MCP_STATE_DIR))).startsWith("mm-mcp-test-"))
|
||||
const originalEffects = process.env.MMAPP_OWN_ROUTING_ORIGINAL_EFFECTS === "true"
|
||||
assert(!(originalEffects && process.env.MMAPP_OWN_ROUTING_WORKER_REPRO === "true"), "Owned effect acceptance cannot mix with the worker repro profile")
|
||||
process.env.AUTH_REQUIRED = "true"
|
||||
process.env.AUTH_JWT_SECRET = "owned-routing-forward-rounds-acceptance-secret"
|
||||
process.env.AUTH_ISSUER = "https://owned-routing-forward-rounds.test"
|
||||
@@ -32,7 +34,8 @@ try {
|
||||
if(process.env.MMAPP_OWN_ROUTING_WORKER_REPRO==="true") {
|
||||
console.log("Worker fixture repro:",await (await import("./routing-worker-fixture-isolation.sql-helper.js")).acceptRoutingWorkerFixtureIsolation(actor))
|
||||
}
|
||||
const result = await (await import("./routing-community-scoped-acceptance.js")).acceptScopedRouteCommunitySwitch(actor, process.env.MMAPP_OWN_ROUTING_WORKER_REPRO==="true"?{}:{ forwardRoundsOnly: true })
|
||||
const result = await (await import("./routing-community-scoped-acceptance.js")).acceptScopedRouteCommunitySwitch(actor,
|
||||
originalEffects ? { originalEffectsOnly: true } : process.env.MMAPP_OWN_ROUTING_WORKER_REPRO==="true"?{}:{ forwardRoundsOnly: true })
|
||||
assert.equal(result.accepted, true)
|
||||
} finally {
|
||||
await (await import("../../services/scheduler.js")).stopSchedulerForDatabaseOperation()
|
||||
|
||||
@@ -353,3 +353,21 @@ test("DNS cancellation retains uncancellable lookup capacity and a late answer c
|
||||
authorize: async () => { if (++checks > 1) throw new SessionError("RESET_REQUIRED", "revoked during DNS") } }), (error: { code?: string }) => error.code === "RESET_REQUIRED")
|
||||
assert.equal(lookups, 1)
|
||||
})
|
||||
|
||||
|
||||
test("complete authority fences bound repeated runtime authorization while preserving every native boundary", async () => {
|
||||
const f = await fixture(), session = await f.create(f.options)
|
||||
const before = f.stats()
|
||||
await session.readSnapshot("before")
|
||||
const after = f.stats(), native = after.nativeCalls - before.nativeCalls, callbacks = after.callbacks - before.callbacks
|
||||
assert.equal(native, 6)
|
||||
// Two full fences and the direct dispatch guard stay fresh for every call.
|
||||
// SQL/file leaves inside those fences must not recursively multiply them.
|
||||
assert(callbacks >= native * 4)
|
||||
assert(callbacks <= 50, "Six native reads must fit a bounded number of complete runtime revalidations")
|
||||
f.afterNative(f.revoke)
|
||||
await assert.rejects(session.readSnapshot("after-forward"))
|
||||
assert.equal(f.stats().nativeCalls, after.nativeCalls + 1)
|
||||
await assert.rejects(session.readSnapshot("after-forward"))
|
||||
assert.equal(f.stats().nativeCalls, after.nativeCalls + 1)
|
||||
})
|
||||
@@ -119,8 +119,16 @@ export async function createRoutingNetworkNativeSession(options: {
|
||||
try { return await work() }
|
||||
finally { active(); await trustedAuthorize(); active() }
|
||||
}
|
||||
// Primitive source reads keep the immutable lifetime checks. The complete
|
||||
// authority fence surrounds their batch; native dispatch retains its own
|
||||
// fresh before/after authorization. Avoid recursively re-running the full
|
||||
// runtime authorization around every SQL/file leaf inside that same fence.
|
||||
const bounded = async <T>(work: () => Promise<T>) => {
|
||||
active()
|
||||
try { return await work() } finally { active() }
|
||||
}
|
||||
const currentAuthority = async () => {
|
||||
const current = await guarded(() => effectiveMcpConnection(principal))
|
||||
const current = await bounded(() => effectiveMcpConnection(principal))
|
||||
if (current.id !== connection.id || current.owner_id !== connection.owner_id || current.generation !== connection.generation ||
|
||||
fingerprint(current.policy) !== fingerprint(connection.policy) || fingerprint(current.owner_permissions) !== fingerprint(connection.owner_permissions) ||
|
||||
!hasPermission(current.owner_permissions, "mm:network:read") || !hasPermission(current.policy.permissions, "mm:network:read") ||
|
||||
@@ -129,13 +137,14 @@ export async function createRoutingNetworkNativeSession(options: {
|
||||
const sourceRow = async () => {
|
||||
const query: QueryConfig & { query_timeout: number } = { text: "SELECT o.id,o.state,o.connection_id,o.created_at,p.id AS plan_id,p.operation_id,p.operation_version,p.approved_by,p.expires_at,s.state AS step_state,CASE WHEN octet_length(s.intended_state::text)<=1048576 THEN s.intended_state END AS intended_state,CASE WHEN octet_length(p.evidence::text)<=1048576 THEN p.evidence END AS evidence 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 AND s.step_index=0 WHERE o.id=$1 AND o.connection_id=$2 LIMIT 2",
|
||||
values: [sourceOperationId, principal.connectionId], query_timeout: Math.max(1, Math.min(10000, deadline - Date.now())) }
|
||||
const result = await guarded(() => pool.query(query))
|
||||
const result = await bounded(() => pool.query(query))
|
||||
if (result.rows.length !== 1) throw fail("NOT_FOUND", "Exact source native session недоступен")
|
||||
const row = result.rows[0]
|
||||
if (!["running", "applied", "partial"].includes(row.state) || !["intent", "applied", "verified"].includes(row.step_state) || row.operation_id !== "routing.community.switch" || row.operation_version !== findMcpOperation(row.operation_id)?.version || !row.approved_by || !row.evidence?.sealed || !(row.created_at instanceof Date) || !(row.expires_at instanceof Date)) throw fail("STATE_CONFLICT", "Нет совместимого подтверждённого source intent")
|
||||
return row
|
||||
}
|
||||
try {
|
||||
await guarded(async () => {})
|
||||
await currentAuthority()
|
||||
const row = await sourceRow()
|
||||
if (row.state !== "running" || row.step_state !== "intent") throw fail("STATE_CONFLICT", "Native session требует исходный running intent")
|
||||
@@ -176,14 +185,14 @@ export async function createRoutingNetworkNativeSession(options: {
|
||||
if (endpoints.length !== fullScope.length || endpoints.some(endpoint => !endpoint.enabled) || fingerprint(endpoints.map(endpoint => endpoint.id).sort((a,b) => a-b)) !== fingerprint(fullScope)) throw fail("DEVICE_UNAVAILABLE", "Полный источник native manifest недоступен")
|
||||
const sources = await guarded(() => createRoutingSourceContext(endpoints))
|
||||
if (fingerprint(sources.expected) !== fingerprint(input.contextSources)) throw fail("STATE_CONFLICT", "Исходный endpoint manifest изменился")
|
||||
const fence = async () => {
|
||||
const fence = async () => guarded(async () => {
|
||||
await currentAuthority()
|
||||
if (await guarded(() => accessGeneration()) !== generation) throw fail("RESET_REQUIRED", "Access generation native session изменён")
|
||||
await guarded(() => assertPersistentScopedProfileAuthority(input, signal))
|
||||
if (await bounded(() => accessGeneration()) !== generation) throw fail("RESET_REQUIRED", "Access generation native session изменён")
|
||||
await bounded(() => assertPersistentScopedProfileAuthority(input, signal))
|
||||
const current = await sourceRow()
|
||||
if (fingerprint({ id: current.id, createdAt: current.created_at, planId: current.plan_id, evidence: current.evidence, expiresAt: current.expires_at, intendedState: current.intended_state }) !== sourceIdentity) throw fail("STATE_CONFLICT", "Исходный native intent изменён")
|
||||
await guarded(() => sources.assertCurrent())
|
||||
}
|
||||
await bounded(() => sources.assertCurrent())
|
||||
})
|
||||
const pins = await guarded(() => resolveRoutingNetworkDestinationPins(endpoints, { deadline, signal, authorize: fence }))
|
||||
await fence()
|
||||
const clients = routeContextClients(freeze(structuredClone(endpoints)), signal, { deadline, maxCalls: 256, maxBytes: 8 * 1048576, pins })
|
||||
|
||||
@@ -17,7 +17,8 @@ const { installLoopbackFixtureAgent } = await import("../../test/loopback-http-a
|
||||
const { mcpConnectionPolicySchema } = await import("@mmapp/contracts/mcp")
|
||||
const { routeAiProfileSchema } = await import("@mmapp/contracts/mcp-routing")
|
||||
const security = await import("./security.js")
|
||||
const { effectiveMcpConnection } = await import("./store.js")
|
||||
const { effectiveMcpConnection, authenticateMcpToken } = await import("./store.js")
|
||||
const { invokeMcpTool } = await import("./runtime.js")
|
||||
const { createRoutingSourceContext, readRoutingEndpoints } = await import("./routing-observation-source.js")
|
||||
const { resolveRoutingProfileAuthority } = await import("./routing-profile-authority.js")
|
||||
await import("./actions.js")
|
||||
@@ -28,7 +29,7 @@ const { captureRoutingNetworkObservationAssembly } = await import("./routing-net
|
||||
const { readRoutingNetworkScopedCommunityContext } = await import("./routing-network-scoped-context.js")
|
||||
type Options = Parameters<typeof createRoutingNetworkNativeSession>[0]
|
||||
|
||||
type Mode = "normal" | "zero-ping" | "redirect" | "auth" | "malformed" | "oversized" | "rows" | "total-bytes" | "total-rows"
|
||||
type Mode = "topology-unknown" | "normal" | "zero-ping" | "redirect" | "auth" | "malformed" | "oversized" | "rows" | "total-bytes" | "total-rows"
|
||||
let mode: Mode = "normal", onRequest: (() => Promise<void> | void) | undefined
|
||||
const requests: { method: string; path: string; body: Record<string, string> }[] = []
|
||||
let responseBytes = 0, writes = 0, sinks = 0
|
||||
@@ -82,6 +83,7 @@ const router = http.createServer(async (req, res) => {
|
||||
assert.equal(body.address, "203.0.113.9"); assert.equal(body.interface, "wan1"); assert.equal(body.vrf, "probe"); assert.equal(body["max-hops"], "16")
|
||||
payload = [{ ".section": "0", address: "203.0.113.9", sent: "3", loss: "0", last: "1ms" }]
|
||||
} else { writes++; throw Error("Unexpected native mutation") }
|
||||
if (mode === "topology-unknown" && path === "/routing/route") payload = [{ ...collections(path)[0], "immediate-gw": "17", "bgp.peer-cache-id": "*s1" }]
|
||||
if (mode === "rows") payload = Array.from({ length: 1001 }, () => ({ version: "7.26" }))
|
||||
if (mode === "total-rows" && path !== "/system/resource") payload = Array.from({ length: 1000 }, () => ({ value: "fixture" }))
|
||||
if (mode === "total-bytes") payload = [{ version: "7.26", padding: "x".repeat(600000) }]
|
||||
@@ -133,6 +135,12 @@ async function source(withProbes = true) {
|
||||
|
||||
try {
|
||||
await applySqlMigrations(pool)
|
||||
// Other genuine phases may have already created authenticated checkpoints in
|
||||
// this owned database. Read-only acceptance must preserve every existing row
|
||||
// and add none; raw SQL JSON also preserves timestamp microseconds that
|
||||
// conversion to a JavaScript Date would truncate.
|
||||
const checkpointBaselineRows = (await pool.query("SELECT to_jsonb(c)::text AS inventory_row FROM mcp_operation_checkpoints c ORDER BY operation_id,step_index")).rows
|
||||
const checkpointBaseline = security.fingerprint(checkpointBaselineRows)
|
||||
assert.equal((await pool.query("SELECT count(*)::int AS n FROM servers")).rows[0].n, 0)
|
||||
await pool.query("UPDATE mcp_settings SET enabled=true WHERE id=1")
|
||||
serverId = Number((await pool.query("INSERT INTO servers(name,host,port,enabled,username,password,use_ssl,verify_ssl) VALUES('owned-native-reader','127.0.0.1',$1,true,'fixture','inert-only',false,true) RETURNING id", [bound.port])).rows[0].id)
|
||||
@@ -140,9 +148,11 @@ try {
|
||||
const f = await source(), originalAuthorize = f.options.authorize, session = await createRoutingNetworkNativeSession(f.options), before = responseBytes
|
||||
const first = await session.readSnapshot("before")
|
||||
assert.equal(first.securityProof, false); assert.equal(first.logCoverage, "unknown"); assert.equal(first.probes.length, 1)
|
||||
const stamp = Date.now(), server = first.servers[0]
|
||||
const server = first.servers[0]
|
||||
const manifest = { revision: 1, phase: "forward", identity: { operationId: f.operationId, stepIndex: 0, profileId: f.profileId, profileVersion: 1, sourceEpoch: "owned-fixture-epoch", authorityIdentity: "owned-fixture-authority" },
|
||||
sourceCreatedAt: stamp - 1000, deadline: f.options.deadline, observationStartAt: stamp - 500, maximumSampleAgeMs: 30000, serverIds: [serverId],
|
||||
// Bind the diagnostic fixture to the actual acquisition interval. A guessed
|
||||
// lookback after SQL/HTTP completes can put its source after the first read.
|
||||
sourceCreatedAt: Date.parse(first.startedAt), deadline: f.options.deadline, observationStartAt: Date.parse(first.observedAt), maximumSampleAgeMs: 30000, serverIds: [serverId],
|
||||
configuration: { serverId, nativeId: "*r1", table: "tenant", bodyIdentity: "not-a-configuration-checkpoint", dependencyIdentity: "not-a-publication-proof" },
|
||||
routes: [{ key: "route1", serverId, family: "ipv4", prefix: "203.0.113.0/24", table: "tenant", sessionKey: "peer1", nextHop: "192.0.2.1", interface: "wan1", resolverTable: "main", resolutionChain: [{ address: "192.0.2.1", table: "main", interface: "wan1" }] }],
|
||||
bgp: [{ key: "peer1", serverId, family: "ipv4", connectionId: "*c1", instance: "default", localAddress: "192.0.2.2", remoteAddress: "192.0.2.1", remoteAsn: 65001 }],
|
||||
@@ -233,9 +243,59 @@ try {
|
||||
const f = await source(false), session = await createRoutingNetworkNativeSession(f.options); mode = "total-rows"
|
||||
await session.readSnapshot("before"); await assert.rejects(session.readSnapshot("after-forward")); assert(session.readBudget().rows > 10000)
|
||||
})
|
||||
const topologyObserver = async () => {
|
||||
const f = await source(false), { connectionId, credentialId } = f.options.context.principal
|
||||
const policy = mcpConnectionPolicySchema.parse({ ...f.options.context.connection.policy, mode: "observer", permissions: ["mm:network:read"],
|
||||
scope: { serverIds: [serverId!], operationIds: ["routing.topology.read"], allowGlobal: false } })
|
||||
await pool.query("UPDATE mcp_connections SET policy=$2::jsonb,owner_permissions=$3::jsonb WHERE id=$1", [connectionId, JSON.stringify(policy), JSON.stringify(["mm:network:read"])])
|
||||
const token = security.randomToken("bearer")
|
||||
await pool.query("UPDATE mcp_credentials SET digest=$2 WHERE id=$1", [credentialId, security.tokenDigest(token)])
|
||||
return { principal: await authenticateMcpToken(token), connectionId }
|
||||
}
|
||||
await run("actual public topology bearer reader observes final gateway without operation binding or namespace joins", async () => {
|
||||
const f = await topologyObserver(), count = requests.length, plans = Number((await pool.query("SELECT count(*) AS n FROM mcp_plans")).rows[0].n)
|
||||
const result = await invokeMcpTool(f.principal, "mm_routing_topology_get", { serverId }) as { routeResolution: { status: string; items: Array<{ status: string; immediateGateway: { address: string; interface: string }; semanticProof: boolean; routeOriginVerified: boolean; recursiveChainVerified: boolean }> } }
|
||||
assert.equal(requests.length - count, 11)
|
||||
assert.equal(result.routeResolution.status, "observed")
|
||||
const route = result.routeResolution.items[0]
|
||||
assert.equal(route.status, "observed"); assert.deepEqual(route.immediateGateway, { address: "192.0.2.1", interface: "wan1" })
|
||||
assert.equal(route.semanticProof, false); assert.equal(route.routeOriginVerified, false); assert.equal(route.recursiveChainVerified, false)
|
||||
assert.equal(Number((await pool.query("SELECT count(*) AS n FROM mcp_plans")).rows[0].n), plans)
|
||||
assert(!JSON.stringify(result).includes("peer-cache-id")); assert(!JSON.stringify(result).includes("bgp.session"))
|
||||
})
|
||||
await run("actual public topology preserves unknown numeric gateway despite matching BGP cache/session strings", async () => {
|
||||
const f = await topologyObserver(); mode = "topology-unknown"
|
||||
const result = await invokeMcpTool(f.principal, "mm_routing_topology_get", { serverId }) as { routeResolution: { items: Array<{ status: string; reason: string }> } }
|
||||
assert.equal(result.routeResolution.items[0].status, "unknown")
|
||||
assert.equal(result.routeResolution.items[0].reason, "unconfirmed-immediate-endpoint")
|
||||
})
|
||||
await run("actual public topology bearer and server scope/disabled checks refuse before native I/O", async () => {
|
||||
const f = await topologyObserver(), count = requests.length
|
||||
await assert.rejects(authenticateMcpToken("not-a-token"))
|
||||
await assert.rejects(invokeMcpTool(f.principal, "mm_routing_topology_get", { serverId: serverId! + 100000 }))
|
||||
await pool.query("UPDATE servers SET enabled=false WHERE id=$1", [serverId])
|
||||
await assert.rejects(invokeMcpTool(f.principal, "mm_routing_topology_get", { serverId }))
|
||||
await pool.query("UPDATE servers SET enabled=true WHERE id=$1", [serverId])
|
||||
assert.equal(requests.length, count)
|
||||
})
|
||||
await run("actual public topology pause endpoint ABA and cancellation retire at first response without publication", async () => {
|
||||
for (const fault of ["pause", "endpoint-aba", "cancel"] as const) {
|
||||
const f = await topologyObserver(), count = requests.length, cancel = new AbortController()
|
||||
onRequest = async () => {
|
||||
if (fault === "pause") await pool.query("UPDATE mcp_connections SET policy=jsonb_set(policy,'{status}','\"paused\"') WHERE id=$1", [f.connectionId])
|
||||
else if (fault === "endpoint-aba") {
|
||||
await pool.query("UPDATE servers SET verify_ssl=NOT verify_ssl WHERE id=$1", [serverId])
|
||||
await pool.query("UPDATE servers SET verify_ssl=NOT verify_ssl WHERE id=$1", [serverId])
|
||||
} else cancel.abort()
|
||||
}
|
||||
await assert.rejects(invokeMcpTool(f.principal, "mm_routing_topology_get", { serverId }, cancel.signal))
|
||||
assert.equal(requests.length, count + 1)
|
||||
}
|
||||
})
|
||||
assert.equal(writes, 0)
|
||||
assert.equal((await pool.query("SELECT count(*)::int AS n FROM mcp_operation_checkpoints")).rows[0].n, 0, "Read-only acceptance must not manufacture CONFIG_APPLIED or network checkpoints")
|
||||
console.log("Owned routing native reader SQL/HTTP:", JSON.stringify({ cases: cases.length, names: cases, requests: requests.length, nativeWrites: writes, actualSql: true, actualHttp: true, syntheticEncryptedSourcePlans: true, genuinePrepareAccepted: false, configurationCheckpointFabricated: false, signedNetworkOutcomePublished: false, logCoverage: "unknown", reservedDispatchEnabled: false, realChr: false }))
|
||||
assert.equal(security.fingerprint((await pool.query("SELECT to_jsonb(c)::text AS inventory_row FROM mcp_operation_checkpoints c ORDER BY operation_id,step_index")).rows), checkpointBaseline,
|
||||
"Read-only acceptance must preserve existing checkpoints exactly and create none")
|
||||
console.log("Owned routing native reader SQL/HTTP:", JSON.stringify({ cases: cases.length, names: cases, requests: requests.length, nativeWrites: writes, actualSql: true, actualHttp: true, syntheticEncryptedSourcePlans: true, genuinePrepareAccepted: false, configurationCheckpointFabricated: false, priorCheckpointsPreserved: checkpointBaselineRows.length, checkpointInventoryUnchanged: true, signedNetworkOutcomePublished: false, logCoverage: "unknown", reservedDispatchEnabled: false, realChr: false }))
|
||||
} finally {
|
||||
if (profiles.length) await pool.query("UPDATE mcp_routing_profiles SET enabled=false WHERE id=ANY($1::uuid[])", [profiles]).catch(() => {})
|
||||
if (connections.length) await pool.query("UPDATE mcp_connections SET policy=jsonb_set(policy,'{status}','\"paused\"') WHERE id=ANY($1::uuid[])", [connections]).catch(() => {})
|
||||
|
||||
@@ -65,6 +65,7 @@ for (const ipv6 of [false, true]) for (const inverse of [false, true]) test(`fix
|
||||
})
|
||||
|
||||
const invalid = {
|
||||
"source-after-first-read": (f: ReturnType<typeof captured>) => { f.manifest.sourceCreatedAt = Date.parse(f.snapshots[0].startedAt) + 1 },
|
||||
"foreign-operation": (f: ReturnType<typeof captured>) => { f.snapshots[1].sourceOperationId = "00000000-0000-4000-8000-000000000099" },
|
||||
"duplicate-server": (f: ReturnType<typeof captured>) => { f.snapshots[1].servers.push(f.snapshots[1].servers[0]) },
|
||||
"missing-probe": (f: ReturnType<typeof captured>) => { f.snapshots[1].probes = [] },
|
||||
|
||||
@@ -14,7 +14,9 @@ import { nativeBgpRows, bgpTemplateReferences } from "../../services/bgp-export-
|
||||
import { bgpFilterName, effectiveBgpFilter, stableBgpPolicyRow } from "../../services/bgp-filter-binding-model.js"
|
||||
import { canonicalVrfMembers } from "../../services/vrf-members.js"
|
||||
import { readOspfEffectiveDisabled } from "../../services/ospf-effective-disabled.js"
|
||||
import { isConfirmedRoutingFibTable } from "../../services/routing-table-fib.js"
|
||||
import * as contextModel from "./routing-community-context-model.js"
|
||||
import { prepareOriginalEffectSelection } from "./routing-original-effects.js"
|
||||
import { readMeasurementAddress } from "./routing-measurement-address.js"
|
||||
import { routeContextClients } from "./routing-community-context-native.js"
|
||||
import { pathProofTag } from "./path-proof-purpose.js"
|
||||
@@ -65,9 +67,8 @@ async function fixture(autoRecovery = false) {
|
||||
afterHttp?.(); afterHttp = undefined
|
||||
if (afterPath?.path === path) { const run = afterPath.run; afterPath = undefined; run() }
|
||||
})
|
||||
await new Promise<void>(resolve => router.listen(0, "127.0.0.1", resolve))
|
||||
const now = Date.now(), fingerprint = (value: unknown) => createHash("sha256").update(JSON.stringify(value)).digest("hex")
|
||||
const endpoint = { id: 1, host: "127.0.0.1", port: (router.address() as { port: number }).port, username: "fixture", password: "private", useSsl: false, verifySsl: true, enabled: true, routingEndpointEpoch: randomUUID() }
|
||||
const endpoint = { id: 1, host: "127.0.0.1", port: 0, username: "fixture", password: "private", useSsl: false, verifySsl: true, enabled: true, routingEndpointEpoch: randomUUID() }
|
||||
const principal = { connectionId: randomUUID(), credentialId: randomUUID(), credentialExpiresAt: new Date(now + 600000).toISOString() }
|
||||
const connection = { id: principal.connectionId, owner_id: "owner", generation: "generation", owner_permissions: ["mm:network:read"],
|
||||
policy: { permissions: ["mm:network:read"], scope: { serverIds: [1] }, expiresAt: new Date(now + 600000).toISOString() }, owner_valid_until: new Date(now + 600000) }
|
||||
@@ -92,6 +93,7 @@ async function fixture(autoRecovery = false) {
|
||||
"./routing-community-context-native.js": { routeContextClients }, "../../services/vrf-members.js": { canonicalVrfMembers }, "../../services/ospf-effective-disabled.js": { readOspfEffectiveDisabled },
|
||||
"./routing-measurement-address.js": { readMeasurementAddress } }
|
||||
await loadSource("./routing-community-context.ts", { ...common, "./routing-network-native-session.js": native,
|
||||
"../../services/routing-table-fib.js": { isConfirmedRoutingFibTable }, "./routing-original-effects.js": { prepareOriginalEffectSelection },
|
||||
"../../services/bgp-export-model.js": { nativeBgpRows, bgpTemplateReferences }, "../../services/bgp-filter-binding-model.js": { bgpFilterName, effectiveBgpFilter, stableBgpPolicyRow },
|
||||
"./routing-community-context-model.js": contextModel, "./path-proof-purpose.js": { pathProofTag },
|
||||
"./management-transport.js": { assertManagementTransport: async () => { throw Error("No alternate management fixture") } } }, context)
|
||||
@@ -103,14 +105,23 @@ async function fixture(autoRecovery = false) {
|
||||
await loadSource("./routing-network-scoped-context.ts", { "./routing-network-native-session.js": native }, privateApi)
|
||||
const create = native.createRoutingNetworkNativeSession as (options: unknown) => Promise<Session>
|
||||
const abort = new AbortController()
|
||||
const session = await create({ sourceOperationId: row.id, context: { principal, connection, signal: abort.signal }, authorize: authority, deadline: now + 119000, manifest: { servers: [1], probes: [] } })
|
||||
await new Promise<void>(resolve => router.listen(0, "127.0.0.1", resolve))
|
||||
endpoint.port = (router.address() as { port: number }).port
|
||||
const closeRouter = () => {
|
||||
router.closeAllConnections()
|
||||
return new Promise<void>(resolve => router.close(() => resolve()))
|
||||
}
|
||||
const session = await create({ sourceOperationId: row.id, context: { principal, connection, signal: abort.signal }, authorize: authority, deadline: now + 119000, manifest: { servers: [1], probes: [] } }).catch(async error => {
|
||||
await closeRouter()
|
||||
throw error
|
||||
})
|
||||
const readContext = privateApi.readRoutingNetworkScopedCommunityContext as (token: Token) => Promise<Context>
|
||||
return { session, readContext: () => readContext(session.sharedContext), forged: () => readContext(JSON.parse(JSON.stringify(session.sharedContext))),
|
||||
readResource: native.readRoutingNetworkSharedResource as (token: Token, server: number, path: string, body?: Record<string, string>) => Promise<unknown>,
|
||||
bypassTail: context.readRouteCommunityContextWithSharedNativeSession as (token: Token) => Promise<unknown>,
|
||||
counters: () => ({ requests, wireBytes, wireRows, rawGetObjects }), bulk: (value: number) => { bulk = value }, padding: (value: number) => { padding = value },
|
||||
revoke: () => { revoked = true }, abort: () => abort.abort(), afterHttp: (hook: () => void) => { afterHttp = hook }, afterPath: (path: string, hook: () => void) => { afterPath = { path, run: hook } },
|
||||
cleanup: async () => { session.close(); router.closeAllConnections(); await new Promise<void>(resolve => router.close(() => resolve())) } }
|
||||
cleanup: async () => { session.close(); await closeRouter() } }
|
||||
}
|
||||
|
||||
test("actual source context and snapshots share one pinned HTTP request/byte/row budget including object /get", async () => {
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createRequire } from 'node:module';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import test from 'node:test';
|
||||
const require=createRequire(new URL('../../../package.json',import.meta.url)),ts=require('typescript'),{z}=require('zod');
|
||||
const code=ts.transpileModule(await readFile(new URL('./routing-community-scoped-service.ts',import.meta.url),'utf8'),{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2022}}).outputText;
|
||||
async function fixture(mode='prepared',failure){
|
||||
const calls=[],principal={connectionId:randomUUID(),credentialId:randomUUID(),credentialExpiresAt:new Date(Date.now()+120000).toISOString()},connection={id:principal.connectionId,owner_id:'owner',generation:'generation',owner_permissions:['mm:filters:write','mm:network:read'],policy:{mode:'copilot',permissions:['mm:filters:write','mm:network:read'],scope:{serverIds:[1]},expiresAt:new Date(Date.now()+120000).toISOString()},owner_valid_until:new Date(Date.now()+120000)};
|
||||
const profile={serverId:1,family:'ipv4',table:'tenant',probeVrf:'probe',community:'65000:10',autoRecovery:false,candidates:[{id:'a',jumpHostId:1,exitNodeId:1,gateway:'192.0.2.8',gatewayRef:{resource:'ipv4_route',id:'*r0'},controlProbe:{}},{id:'b',jumpHostId:1,exitNodeId:1,gateway:'192.0.2.9',gatewayRef:{resource:'ipv4_route',id:'*r'},controlProbe:{}}],excludedCandidateIds:[],pinnedCandidateId:null};
|
||||
const input={serverId:1,profile,candidateId:'b',principal,ownerId:'owner',policyProof:JSON.stringify(connection.policy),connectionGeneration:'generation',generation:'access',proofRevision:3,originalCandidateId:'a',nativeId:'*1',outIface:'',originalRuleCiphertext:'original',desiredRuleCiphertext:'desired',desiredTargetProof:'desired-proof',beforeRules:[],receiptId:randomUUID()};
|
||||
if(mode!=='legacy')input.originalEffectSelection=mode==='prepared'?{status:'prepared',probe:{key:'b',serverId:1,destination:'198.51.100.20',source:'198.51.100.10',interface:'wan',probeVrf:'probe'}}:{status:'unknown',reason:'unsupported-source'};
|
||||
let wrote=false,handler,network,foreign=false,reads=0,rounds=0,closed=0,ownerClosed=false,nativeCalls=100;
|
||||
const unknown=reason=>({scope:'prepared-route-fib-adjacency-control-effects',status:'unknown',reason,rounds:[],logCoverage:'unknown',semanticProof:false,forwardingVerified:false,autoRecoveryAllowed:false});
|
||||
const report={...unknown(null),status:'observed-expected',rounds:[{controlProbe:'passed'},{controlProbe:'passed'}]};
|
||||
const session={sharedContext:{},readSnapshot:async stage=>{calls.push('baseline:'+stage);return {};},readConfigurationBoundForwardSnapshot:async()=>{calls.push('round:'+(++rounds));if(failure==='round'||failure==='round-closed'){ownerClosed=failure==='round-closed';throw Error('unknown');}return {};},readBudget:()=>({deadline:Date.now()+10000,calls:nativeCalls,bytes:1000,rows:50,closed:ownerClosed}),close:()=>{closed++;ownerClosed=true;calls.push('close-session');}};
|
||||
const providers={
|
||||
'node:crypto':{randomUUID},zod:{z},'@mmapp/contracts/mcp-routing':{routeAiProfileSchema:{parse:x=>x}},'../../db/index.js':{pool:{query:async sql=>({rows:String(sql).includes('FROM mcp_path_checks')?[{id:randomUUID(),connection_id:principal.connectionId,status:'passed',created_at:new Date(),evidence:{}}]:[]})}},
|
||||
'../../lib/permissions.js':{hasPermission:(list,value)=>list.includes(value)},
|
||||
'../../services/config-mutation-policy.js':{assertVerifiedConfigurationAccess:async()=>{},executeGuardedConfigurationWrite:async(_sid,_method,_path,work)=>{calls.push('guarded-intent');return work();}},
|
||||
'../../services/config-mutation-lock.js':{withApplicationMutationLock:work=>work(),withConfigMutationLock:(_ids,work)=>work()},
|
||||
'./store.js':{effectiveMcpConnection:async()=>connection,audit:async()=>{}},
|
||||
'./security.js':{accessGeneration:async()=>'access',decryptSecret:async x=>x,encryptSecret:async x=>x,fingerprint:JSON.stringify,McpError:Error,privateSecretFingerprint:async()=>'seal',redact:x=>x},
|
||||
'./routing-community-scoped-checkpoint.js':{saveScopedConfigurationCheckpoint:async()=>{calls.push('CONFIG_APPLIED');},loadScopedConfigurationCheckpoint:async()=>({checkpointProof:'configuration'}),saveScopedNetworkCheckpoint:async(_input,_seal,check,originalEffects)=>{calls.push('save-network');network={status:check.status,networkSeal:'network',...(originalEffects?{originalEffects}:{})};},loadScopedNetworkCheckpoint:async()=>network},
|
||||
'./routing-community-scoped-request.js':{assertScopedCommunityRequest:async()=>'request'},'./management-transport.js':{prepareManagementTransportBinding:async()=>({})},
|
||||
'./routing-community-context.js':{readRouteCommunityContext:async()=>{reads++;return {before:{scopedExecutionAvailable:true,currentCandidateId:wrote?'b':'a',dependencyProof:'dep',sourceProofs:[{serverId:1,proof:'source'}]},ownedTarget:{nativeId:'*1',rawRow:{rule:wrote?'desired':'original'},endpoint:{},desiredTargetProof:()=> 'desired-proof'},fence:async()=>{},clients:new Map([[1,{get:async()=>[{'.id':'*r'}]}]]),captureOriginalEffectSelection:async()=>{calls.push('capture-prepared-selection');return input.originalEffectSelection;}};}},
|
||||
'./routing-community-context-model.js':{candidateMatchesGateway:()=>true},'./routing-model.js':{rankRoutingCandidates:()=>profile.candidates.map(candidate=>({candidate,eligible:true}))},'./routing-observations.js':{readRoutingObservations:async()=>[]},
|
||||
'./path-verification.js':{requireRecentPath:async()=>'path-check',checkCandidatePath:async()=>{calls.push('legacy-probe');return {id:randomUUID(),candidateId:'b',status:'passed',observedAt:new Date().toISOString()};}},
|
||||
'./routing-community-scoped-model.js':{compileScopedCommunitySwitch:()=>({compiledBody:'desired',proposed:{community:'65000:10',gateway:'192.0.2.9',outIface:''},before:{community:'65000:10',gateway:'192.0.2.8',outIface:''}}),scopedConfigurationMatches:()=>({configurationMatchesOriginal:!wrote,configurationMatchesDesired:wrote&&!foreign})},
|
||||
'./routing-community-scoped-native.js':{patchScopedCommunity:async()=>{calls.push('PATCH');wrote=true;}},'./service-registry.js':{registerMcpService:(_name,value)=>{handler=value;}},
|
||||
'./routing-profile-authority.js':{assertPersistentScopedProfileAuthority:async()=>{},resolveRoutingProfileAuthority:async()=>{},routingProfileAuthorityBindingSchema:{parse:x=>x},scopedProfileAuthorityTransport:async()=>({})},
|
||||
'./routing-observation-source.js':{createRoutingSourceContext:async()=>({}),readRoutingEndpoints:async()=>[]},'./routing-reservation-store.js':{createRoutingReservationStore:()=>({})},
|
||||
'./routing-network-execution-session.js':{createRoutingNetworkSessionForCurrentExecution:async options=>{calls.push('open-session');assert.deepEqual(Object.keys(options).sort(),['probes','servers']);if(failure==='open')throw Error('unknown');return session;}},
|
||||
'./routing-network-configuration-bridge.js':{resolveRoutingNetworkForwardRoundConfigurationContext:async()=>{calls.push('configuration-round-owner');return {};},closeRoutingNetworkForwardRoundConfigurationContext:()=>{calls.push('close-round-owner');}},
|
||||
'./routing-network-scoped-context.js':{readRoutingNetworkScopedCommunityContext:async()=>{calls.push('shared-publication');if(failure==='publication')throw Error('DO_NOT_EXPOSE_TEST_DETAIL');nativeCalls+=10;return{before:{dependencyProof:'dep',targetRuleProof:'desired-proof',currentCandidateId:'b'},fence:async()=>{}};}},
|
||||
'./routing-original-effects.js':{originalEffectSelectionSchema:{safeParse:x=>x?{success:true,data:x}:{success:false}},unknownOriginalEffects:unknown,originalEffectBaselineMatches:()=>failure!=='baseline',inspectOriginalEffectRounds:async()=>{calls.push('inspect-effects');if(failure==='foreign')foreign=true;return report;}}
|
||||
};
|
||||
const exports={};new Function('exports','require',code)(exports,name=>{assert.ok(providers[name],name);return providers[name];});exports.registerScopedCommunityService();
|
||||
return {calls,input,run:()=>handler.execute(input,{operationId:randomUUID(),phase:'forward',stepIndex:0}),prepare:()=>exports.prepareScopedCommunitySwitch({profile,candidateId:'b'},{principal,connection}),stats:()=>({wrote,closed,reads}),network:()=>network};
|
||||
}
|
||||
test('actual service consumer baselines before the single PATCH and persists separate effects after genuine CONFIG_APPLIED and manual probe',async()=>{const f=await fixture(),result=await f.run();const c=f.calls;assert(c.indexOf('baseline:before')<c.indexOf('PATCH'));assert(c.indexOf('CONFIG_APPLIED')<c.indexOf('configuration-round-owner'));assert(c.indexOf('legacy-probe')<c.indexOf('round:1'));assert(c.indexOf('round:2')<c.indexOf('shared-publication'));assert(c.indexOf('shared-publication')<c.indexOf('save-network'));assert.equal(c.filter(x=>x==='PATCH').length,1);assert.equal(result.configurationVerified,true);assert.equal(result.controlProbeVerified,true);assert.equal(result.forwardingVerified,false);assert.equal(result.originalEffects.status,'observed-expected');assert.equal(result.originalEffects.budget.calls,110);assert.equal(f.stats().closed,1);assert.deepEqual(result.originalEffects,f.network().originalEffects);});
|
||||
for(const mode of ['legacy','unknown'])test(`existing ${mode} manual path does not open native effect session`,async()=>{const f=await fixture(mode),result=await f.run();assert(!f.calls.includes('open-session'));assert.equal(result.configurationVerified,true);assert.equal(result.controlProbeVerified,true);assert.equal(f.calls.filter(x=>x==='PATCH').length,1);});
|
||||
for(const failure of ['open','baseline','round','round-closed','publication'])test(`optional ${failure} uncertainty preserves actual manual config/control receipt with effects unknown`,async()=>{const f=await fixture('prepared',failure),result=await f.run();assert.equal(result.configurationVerified,true);assert.equal(result.controlProbeVerified,true);assert.equal(result.originalEffects.status,'unknown');assert.equal(f.calls.filter(x=>x==='PATCH').length,1);assert(f.calls.includes('save-network'));});
|
||||
test('foreign configuration drift after optional effect observations still refuses publication, never repeats PATCH',async()=>{const f=await fixture('prepared','foreign');await assert.rejects(f.run);assert.equal(f.calls.filter(x=>x==='PATCH').length,1);assert(!f.calls.includes('save-network'));assert.equal(f.stats().closed,1);});
|
||||
test('revision2 remains on existing manual path even with a prepared-like field',async()=>{const f=await fixture();f.input.proofRevision=2;await f.run();assert(!f.calls.includes('open-session'));});
|
||||
test('sealed manual policy runs read-only rounds through the real scoped consumer',async()=>{const f=await fixture();f.input.proofRevision=2;f.input.manualEffectPolicy='observational-v1';const result=await f.run();assert.equal(f.calls.filter(x=>x==='PATCH').length,1);assert(f.calls.includes('round:2'));assert.equal(result.originalEffects.status,'observed-expected');});
|
||||
test('actual preparation consumes the required real-context selection method and includes it in sealed service input',async()=>{const f=await fixture(),evidence=await f.prepare();assert(f.calls.includes('capture-prepared-selection'));assert.deepEqual(evidence.steps[0].input.originalEffectSelection,f.input.originalEffectSelection);assert.equal(evidence.steps[0].input.requestProof,'seal');assert(!f.calls.includes('PATCH'));});
|
||||
test('closed effect owner falls back to exactly the old final context read without bypassing publication guard',async()=>{const legacy=await fixture('legacy');await legacy.run();const f=await fixture('prepared','round-closed');await f.run();assert(!f.calls.includes('shared-publication'));assert.equal(f.stats().reads,legacy.stats().reads);});
|
||||
|
||||
test('optional publication error exposes only a bounded failure category', async()=>{
|
||||
const f=await fixture('prepared','publication'), result=await f.run();
|
||||
assert.equal(result.originalEffects.reason,'effect-publication-revalidation-unavailable:STATE_UNAVAILABLE');
|
||||
assert(!JSON.stringify(result).includes('DO_NOT_EXPOSE_TEST_DETAIL'));
|
||||
assert.equal(result.configurationVerified,true);assert.equal(result.controlProbeVerified,true);
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createRequire } from 'node:module';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { randomUUID, createHash } from 'node:crypto';
|
||||
import test from 'node:test';
|
||||
const require=createRequire(new URL('../../../package.json',import.meta.url)),ts=require('typescript'),{z}=require('zod');
|
||||
const hash=value=>createHash('sha256').update(JSON.stringify(value)).digest('hex'),sign=async(value,purpose)=>hash([purpose,value]);
|
||||
const code=ts.transpileModule(await readFile(new URL('./routing-controller-baseline.ts',import.meta.url),'utf8'),{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2022}}).outputText;
|
||||
const effectCode=ts.transpileModule(await readFile(new URL('./routing-original-effects.ts',import.meta.url),'utf8'),{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2022}}).outputText,effectExports={};
|
||||
new Function('exports','require',effectCode)(effectExports,name=>{if(name==='node:net')return require('node:net');if(name==='zod')return{z};if(name==='./security.js')return{fingerprint:hash};if(name==='./routing-community-context-model.js')return{};throw Error(name);});
|
||||
const preparedValue={status:'prepared',revision:1,serverId:1,routeSelectionScope:'table-afi-community-observation',candidateId:'b',family:'ipv4',table:'tenant',community:'65000:10',expectedGateway:'192.0.2.9',expectedImmediate:{address:'198.51.100.2',interface:'ether2'},binding:{connectionId:'*c',sessionId:'*s',instance:'default',table:'tenant',vrf:'main',localAddress:'192.0.2.1',remoteAddress:'192.0.2.2',remoteAsn:65001,afi:'ip'},bfd:{applicability:'not-configured',evidence:'explicit-use-bfd-false'},routes:[{nativeId:'*r',prefix:'203.0.113.0/24',originalGateway:'192.0.2.8'}],probe:{key:'b',serverId:1,destination:'198.51.100.20',source:'198.51.100.10',interface:'wan',probeVrf:'probe',expectedHops:['198.51.100.20']}};
|
||||
async function fixture(selection='absent',effectStatus='observed-expected',switched=true){
|
||||
const principal={connectionId:randomUUID(),credentialId:randomUUID(),credentialExpiresAt:new Date(Date.now()+120000).toISOString()},profileId=randomUUID(),planId=randomUUID(),operationId=randomUUID(),grant=new Date(Date.now()+60000).toISOString();
|
||||
const profile={serverId:1,table:'tenant',probeVrf:'probe',candidates:[{id:'a',jumpHostId:1,exitNodeId:1},{id:'b',jumpHostId:1,exitNodeId:1}]},perms=['mm:network:read','mm:network:write','mm:filters:write'];
|
||||
const connection={id:principal.connectionId,owner_id:'owner',owner_permissions:perms,owner_valid_until:new Date(Date.now()+120000),owner_claims_issued_at:new Date(Date.now()-1000),generation:'access',policy:{status:'active',mode:'copilot',permissions:perms,scope:{serverIds:[1],operationIds:['routing.analyze','routing.community.switch','routing.path.verify']},expiresAt:new Date(Date.now()+120000).toISOString()}};
|
||||
const context={principal,connection},sourceProofs=[{serverId:1,proof:'c'.repeat(64)}],dependency='d'.repeat(64),originalTarget='a'.repeat(64),desiredTarget='b'.repeat(64),checkpointProof='e'.repeat(64),networkSeal='f'.repeat(64),requestSeal='1'.repeat(64);
|
||||
let advancing=false,nativeReads=0,row;
|
||||
const input={proofRevision:3,profile,principal,ownerId:'owner',nativeId:'*1',originalCandidateId:'a',originalTargetProof:originalTarget,desiredTargetProof:desiredTarget,dependencyProof:dependency,contextSources:sourceProofs,candidateId:'b',receiptId:randomUUID(),persistentProfileAuthority:{packet:{profileId,profileVersion:1}}};
|
||||
if(selection!=='absent')input.originalEffectSelection=selection?.status==='prepared'&&Object.keys(selection).length===1?structuredClone(preparedValue):selection;
|
||||
const packet={revision:2,operationId,stepIndex:0,nativeId:'*1',originalCandidateId:'a',originalTargetProof:originalTarget,dependencyProof:dependency,contextSources:sourceProofs,desiredCandidateId:'b',desiredTargetProof:desiredTarget,desiredRule:'desired'};
|
||||
const network={status:'passed',networkSeal,...(effectStatus==='missing'?{}:{originalEffects:{status:effectStatus,switchEffectObserved:switched,selectionIdentity:hash(input.originalEffectSelection??null),transition:{changedPrefixes:switched?['203.0.113.0/24']:[]}}})};
|
||||
const providers={
|
||||
'./routing-main-transition.js':{},'../../db/index.js':{pool:{query:async()=>({rows:[row]})}},'./registry.js':{findMcpOperation:()=>({version:7})},
|
||||
'./routing-community-scoped-request.js':{assertScopedCommunityRequest:async()=>requestSeal},'./routing-profile-authority.js':{assertPersistentScopedProfileAuthority:async()=>{},routingProfileAuthorityBindingSchema:{parse:x=>x}},
|
||||
'./routing-community-scoped-checkpoint.js':{loadScopedConfigurationPacket:async()=>({packet,checkpointProof,observedAt:new Date(Date.now()-500).toISOString()}),loadScopedNetworkCheckpoint:async()=>network},
|
||||
zod:{z},'@mmapp/contracts/mcp-routing':{routeAiProfileSchema:{parse:x=>x}},'../../lib/permissions.js':{hasPermission:(list,value)=>list.includes(value)},'./store.js':{effectiveMcpConnection:async()=>connection},
|
||||
'./security.js':{accessGeneration:async()=>'access',constantEqual:(a,b)=>a===b,decryptSecret:async x=>x,fingerprint:hash,redact:x=>x,McpError:Error,privateSecretFingerprint:sign},
|
||||
'./routing-community-context.js':{readRouteCommunityContext:async()=>{nativeReads++;return {before:{scopedExecutionAvailable:true,currentCandidateId:advancing?'b':'a',targetRuleProof:advancing?desiredTarget:originalTarget,dependencyProof:dependency,sourceProofs,nativeRuleId:'*1'},ownedTarget:{nativeId:'*1',rawRow:{rule:advancing?'desired':'original'}},fence:async()=>{}};}},
|
||||
'./routing-original-effects.js':{originalEffectSelectionSchema:effectExports.originalEffectSelectionSchema}
|
||||
};
|
||||
const exports={};new Function('exports','require',code)(exports,name=>{assert.ok(providers[name],name);return providers[name];});
|
||||
const baseline=await exports.prepareRoutingControllerBaseline(profileId,1,profile,grant,context);advancing=true;
|
||||
const step={kind:'service',service:'routing.community.scoped',input};
|
||||
row={id:operationId,plan_id:planId,state:'applied',step_state:'verified',operation_id:'routing.community.switch',operation_version:7,approved_by:'owner',created_at:new Date(Date.now()-1000),evidence:{sealed:JSON.stringify({steps:[step],target:{scope:'servers',serverIds:[1]}})},intended_state:step};
|
||||
row.result={nativeVerificationSeal:await sign(hash({request:requestSeal,dependency,desired:desiredTarget,candidate:'b',configuration:checkpointProof,network:networkSeal}),'route-ai-scoped-verified-v3'),configurationCheckpointProof:checkpointProof,networkCheckpointProof:networkSeal,receiptId:input.receiptId,proofRevision:3,nativeWrites:1,configurationVerified:true,postProbeStatus:'passed',controlProbeVerified:true};
|
||||
return {advance:()=>exports.advanceRoutingControllerBaseline(baseline,profileId,1,profile,grant,context,planId,operationId),nativeReads:()=>nativeReads,row,network};
|
||||
}
|
||||
test('actual controller preserves old advancement only when new selection field is absent',async()=>{const f=await fixture();assert.equal((await f.advance()).packet.candidateId,'b');assert.equal(f.nativeReads(),2);});
|
||||
for(const [name,selection]of[['unknown',{status:'unknown',reason:'unsupported'}],['malformed',{}],['malformed prepared',{status:'prepared',revision:2}],['null',null],['array',[]]])test(`actual controller rejects present ${name} before desired native read`,async()=>{const f=await fixture(selection);await assert.rejects(f.advance);assert.equal(f.nativeReads(),1);assert.equal(f.row.result.configurationVerified,true);assert.equal(f.row.result.controlProbeVerified,true);});
|
||||
for(const status of ['unknown','observed-other','missing'])test(`actual controller rejects prepared selection with ${status} durable effect result`,async()=>{const f=await fixture({status:'prepared'},status);await assert.rejects(f.advance);assert.equal(f.nativeReads(),1);});
|
||||
test('actual controller rejects matched poststate without an observed selected FIB transition',async()=>{const f=await fixture({status:'prepared'},'observed-expected',false);await assert.rejects(f.advance);assert.equal(f.nativeReads(),1);});
|
||||
test('actual controller advances after durable expected selected FIB transition',async()=>{const f=await fixture({status:'prepared'});assert.equal((await f.advance()).packet.candidateId,'b');assert.equal(f.nativeReads(),2);});
|
||||
test('actual controller rejects effect vector belonging to a different prepared selection',async()=>{const f=await fixture({status:'prepared'});f.network.originalEffects.selectionIdentity='0'.repeat(64);await assert.rejects(f.advance);assert.equal(f.nativeReads(),1);});
|
||||
test('actual controller rejects transition flag without an actual changed selected prefix',async()=>{const f=await fixture({status:'prepared'});f.network.originalEffects.transition.changedPrefixes=[];await assert.rejects(f.advance);assert.equal(f.nativeReads(),1);});
|
||||
@@ -0,0 +1,51 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createRequire } from 'node:module';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import test from 'node:test';
|
||||
import { isIP } from 'node:net';
|
||||
import { randomUUID, createHash } from 'node:crypto';
|
||||
const require=createRequire(new URL('../../../package.json',import.meta.url)), ts=require('typescript'),{z}=require('zod');
|
||||
const source=await readFile(new URL('./routing-original-effects.ts',import.meta.url),'utf8');
|
||||
const exports={};
|
||||
const gateway=value=>{const match=/^([^@%\s,]+)(?:%([^@%\s,]+))?(?:@([^@%\s,]+))?$/.exec(value);if(!match||!isIP(match[1]))throw Error();return {address:match[1],family:isIP(match[1]),interface:match[2]??null,resolverTable:match[3]??'main'};};
|
||||
const providers={ 'node:net':{isIP},'node:crypto':{randomUUID},zod:{z},'./routing-community-context-model.js':{routeGatewayContext:gateway},'./security.js':{fingerprint:value=>createHash('sha256').update(JSON.stringify(value)).digest('hex')},
|
||||
'./path-verification.js':{inspectControlPing:rows=>{const row=rows.at(-1),sent=Number(row.sent),received=Number(row.received);return {confirmedFailure:sent===3&&received===0,ping:{measured:sent===3&&Number.isInteger(received)&&received>=0&&received<=3,loss:(sent-received)/sent*100}};},
|
||||
inspectTrace:(rows,destination,expected)=>({verified:Array.isArray(rows)&&rows.at(-1)?.address===destination&&expected.every(x=>rows.some(r=>r.address===x))})}};
|
||||
new Function('exports','require',ts.transpileModule(source,{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2022}}).outputText)(exports,name=>{assert.ok(providers[name],name);return providers[name];});
|
||||
function fixture(){
|
||||
const profile={serverId:1,family:'ipv4',table:'tenant',probeVrf:'probe',community:'65000:10',candidates:[{id:'b',wanInterface:'wan',gateway:'192.0.2.9',gatewayRef:{resource:'ipv4_route',id:'*9'},controlProbe:{targetInterface:'target'}}]};
|
||||
const binding={connectionId:'*c',instance:'default',table:'tenant',vrf:'main'};
|
||||
const connection={'.id':'*c',instance:'default','routing-table':'tenant',vrf:'main','local.address':'192.0.2.1','remote.address':'192.0.2.2','remote.as':'65001',afi:'ip',templates:'',disabled:'false','use-bfd':'false'};
|
||||
const session={...connection,'.id':'*s','local.afi':'ip','remote.afi':'ip',established:'true'};
|
||||
const route={'.id':'*r',afi:'ip4','routing-table':'tenant','dst-address':'203.0.113.0/24',bgp:'true','bgp.communities':'65000:10',gateway:'192.0.2.8',active:'true',contribution:'active',ecmp:'false','immediate-gw':'198.51.100.1%ether1'};
|
||||
const raw={connections:[connection],sessions:[session],routes:[route],candidate:{'.id':'*9','immediate-gw':'198.51.100.2%ether2'},probe:{source:'198.51.100.10',destination:'198.51.100.20',expected:['198.51.100.20']}};
|
||||
const selection=exports.prepareOriginalEffectSelection(profile,'b',binding,raw);
|
||||
const sample=()=>({observedAt:new Date().toISOString(),servers:[{serverId:1,routes:{payload:[{...route,gateway:'192.0.2.9','immediate-gw':'198.51.100.2%ether2'}]},bgp:{payload:[{...session}],connectionsPayload:[{...connection}]},logs:{payload:[{topics:'route,info',message:'bounded diagnostic'}]}}],probes:[{key:'b',pingPayload:[{sent:'3',received:'3','packet-loss':'0'}],tracePayload:[{address:'198.51.100.20'}]}]});
|
||||
const baseline=sample();baseline.servers[0].routes.payload=structuredClone(raw.routes);
|
||||
return {profile,binding,raw,selection,sample,baseline,prepare:()=>exports.prepareOriginalEffectSelection(profile,'b',binding,raw),inspect:samples=>exports.inspectOriginalEffectRounds(selection,samples,baseline)};
|
||||
}
|
||||
test('actual captured predicate seals BGP destinations separately from static resolver ID',()=>{const f=fixture();assert.equal(f.selection.status,'prepared');assert.equal(f.selection.routes[0].nativeId,'*r');assert.notEqual(f.selection.routes[0].nativeId,f.raw.candidate['.id']);assert.equal(f.selection.binding.sessionId,'*s');assert.equal('sessionKey'in f.selection.routes[0],false);});
|
||||
test('two real effect captures can observe expected FIB/adjacency/probe while logcoverage and stronger claims stay false',async()=>{const f=fixture(),result=await f.inspect([f.sample(),f.sample()]);assert.equal(result.status,'observed-expected');assert.equal(result.logCoverage,'unknown');assert.equal(result.routeOriginVerified,false);assert.equal(result.recursiveChainVerified,false);assert.equal(result.forwardingVerified,false);assert.equal(result.autoRecoveryAllowed,false);assert.equal(result.rounds[0].relatedTopicRows,1);});
|
||||
for(const [name,change]of[
|
||||
['missing affected BGP route',f=>{f.raw.routes=[];}],['ambiguous prefix',f=>{f.raw.routes.push({...f.raw.routes[0],'.id':'*r2'});}],
|
||||
['connected resolver is not affected BGP route',f=>{f.raw.routes[0].bgp='false';f.raw.routes[0].connect='true';}],
|
||||
['unresolved inheritance',f=>{f.raw.connections[0].templates='template';}],['BFD not explicitly known',f=>{delete f.raw.connections[0]['use-bfd'];}],
|
||||
['router-selected source retained as unsupported effect subset',f=>{delete f.raw.probe.source;}],['mixed session identity',f=>{f.raw.sessions.push({...f.raw.sessions[0],'.id':'*s2'});}],
|
||||
['noncanonical AFI prefix',f=>{f.raw.routes[0]['dst-address']='2001:db8::/64';}]
|
||||
])test(`prepared ambiguity/unsupported remains unknown: ${name}`,()=>{const f=fixture();change(f);assert.equal(f.prepare().status,'unknown');});
|
||||
test('baseline binds saved route IDs/prefix/gateway and does not infer operation UUID',()=>{const f=fixture(),base=f.sample();base.servers[0].routes.payload=structuredClone(f.raw.routes);assert.equal(exports.originalEffectBaselineMatches(f.selection,base),true);base.servers[0].routes.payload[0]['.id']='*different';assert.equal(exports.originalEffectBaselineMatches(f.selection,base),false);});
|
||||
for(const [name,change,expected]of[
|
||||
['unexpected immediate next-hop',s=>{s.servers[0].routes.payload[0]['immediate-gw']='198.51.100.3%ether3';},'observed-other'],
|
||||
['unchanged connected route',s=>{s.servers[0].routes.payload[0].bgp='false';s.servers[0].routes.payload[0].connect='true';},'unknown'],
|
||||
['same prefix competition',s=>{s.servers[0].routes.payload.push({...s.servers[0].routes.payload[0],'.id':'*other'});},'unknown'],
|
||||
['new community target scope',s=>{s.servers[0].routes.payload.push({...s.servers[0].routes.payload[0],'.id':'*other','dst-address':'203.0.114.0/24'});},'unknown'],
|
||||
['reconnected session ID',s=>{s.servers[0].bgp.payload[0]['.id']='*new';},'unknown'],
|
||||
['observed adjacency down',s=>{s.servers[0].bgp.payload[0].established='false';},'observed-other'],
|
||||
['BFD applicability changed',s=>{s.servers[0].bgp.connectionsPayload[0]['use-bfd']='true';},'unknown'],
|
||||
['unreachable route',s=>{s.servers[0].routes.payload[0].unreachable='true';},'unknown'],
|
||||
['terminal control loss',s=>{s.probes[0].pingPayload=[{sent:'3',received:'0','packet-loss':'100'}];},'observed-other']
|
||||
])test(`effect observation: ${name}`,async()=>{const f=fixture(),s=f.sample();change(s);const result=await f.inspect([s,structuredClone(s)]);assert.equal(result.status,expected);assert.equal(result.forwardingVerified,false);});
|
||||
test('one round is not upgraded to two',async()=>{const f=fixture();assert.equal((await f.inspect([f.sample()])).status,'unknown');});
|
||||
test('unchanged expected poststate is not labelled a switch effect',async()=>{const f=fixture();f.baseline.servers[0].routes.payload[0].gateway=f.selection.expectedGateway;f.baseline.servers[0].routes.payload[0]['immediate-gw']='198.51.100.2%ether2';f.selection.routes[0].originalGateway=f.selection.expectedGateway;const result=await f.inspect([f.sample(),f.sample()]);assert.equal(result.status,'unknown');assert.equal(result.switchEffectObserved,false);assert.deepEqual(result.transition.changedPrefixes,[]);assert.equal(result.reason,'poststate-matched-without-selected-fib-transition');});
|
||||
test('actual selected destination gateway/final endpoint transition is recorded separately from origin',async()=>{const f=fixture(),result=await f.inspect([f.sample(),f.sample()]);assert.equal(result.status,'observed-expected');assert.equal(result.switchEffectObserved,true);assert.deepEqual(result.transition.changedPrefixes,['203.0.113.0/24']);assert.equal(result.transition.baselineObservedAt,f.baseline.observedAt);assert.equal(result.routeOriginVerified,false);});
|
||||
test('logical gateway change with unchanged final forwarding endpoint is not a FIB switch',async()=>{const f=fixture();f.baseline.servers[0].routes.payload[0]['immediate-gw']='198.51.100.2%ether2';const result=await f.inspect([f.sample(),f.sample()]);assert.equal(result.status,'unknown');assert.equal(result.switchEffectObserved,false);assert.equal(result.routeSelectionScope,'table-afi-community-observation');assert.equal(result.transitionAttribution,'unproven');assert.deepEqual(result.transition.changedPrefixes,[]);});
|
||||
@@ -0,0 +1,174 @@
|
||||
import { isIP } from "node:net"
|
||||
import { z } from "zod"
|
||||
import type { RouteAiProfile } from "@mmapp/contracts/mcp-routing"
|
||||
import { routeGatewayContext } from "./routing-community-context-model.js"
|
||||
import { fingerprint } from "./security.js"
|
||||
|
||||
type Row = Record<string, unknown>
|
||||
const id = z.string().regex(/^\*[a-z0-9]+$/i).max(100)
|
||||
const text = z.string().min(1).max(200)
|
||||
const ip = text.refine(value => isIP(value) !== 0)
|
||||
const flag = (value: unknown): boolean | undefined => [true, "true", "yes"].includes(value as string | boolean) ? true : [false, "false", "no"].includes(value as string | boolean) ? false : undefined
|
||||
const communities = (row: Row) => Array.isArray(row["bgp.communities"]) ? row["bgp.communities"] as unknown[] : typeof row["bgp.communities"] === "string" ? row["bgp.communities"].split(",") : []
|
||||
const family = (row: Row) => ["ip", "ip4"].includes(String(row.afi)) ? "ipv4" : ["ipv6", "ip6"].includes(String(row.afi)) ? "ipv6" : null
|
||||
const unique = (rows: Row[], nativeId: string) => rows.filter(row => row[".id"] === nativeId)
|
||||
const tupleSchema = z.object({ connectionId: id, sessionId: id, instance: text, table: text, vrf: text,
|
||||
localAddress: ip, remoteAddress: ip, remoteAsn: z.number().int().positive().max(4294967295), afi: z.enum(["ip", "ipv6"]) }).strict()
|
||||
const prepared = z.object({ status: z.literal("prepared"), revision: z.literal(1), serverId: z.number().int().positive(),
|
||||
routeSelectionScope: z.literal("table-afi-community-observation"),
|
||||
candidateId: text, family: z.enum(["ipv4", "ipv6"]), table: text, community: text, expectedGateway: text,
|
||||
expectedImmediate: z.object({ address: ip, interface: text }).strict(), binding: tupleSchema,
|
||||
bfd: z.object({ applicability: z.literal("not-configured"), evidence: z.literal("explicit-use-bfd-false") }).strict(),
|
||||
routes: z.array(z.object({ nativeId: id, prefix: text, originalGateway: text }).strict()).min(1).max(32),
|
||||
probe: z.object({ key: text, serverId: z.number().int().positive(), destination: ip, source: ip, interface: text, probeVrf: text,
|
||||
expectedHops: z.array(ip).min(1).max(8) }).strict(),
|
||||
}).strict()
|
||||
export const originalEffectSelectionSchema = z.union([prepared, z.object({ status: z.literal("unknown"), reason: text }).strict()])
|
||||
export type OriginalEffectSelection = z.infer<typeof originalEffectSelectionSchema>
|
||||
type Prepared = z.infer<typeof prepared>
|
||||
const state = z.enum(["observed-expected", "observed-other", "unknown"])
|
||||
const roundSchema = z.object({ observedAt: z.string().datetime(), routes: z.array(z.object({ prefix: text, status: state }).strict()).max(32),
|
||||
bgp: state, bfd: z.enum(["not-configured", "unknown"]), controlProbe: z.enum(["passed", "failed", "unknown"]),
|
||||
relatedTopicRows: z.number().int().nonnegative().max(1000) }).strict()
|
||||
export const originalEffectReportSchema = z.object({ scope: z.literal("prepared-route-fib-adjacency-control-effects"), status: state,
|
||||
reason: text.nullable(), rounds: z.array(roundSchema).max(3), logSource: z.literal("routeros-rest-ring"), logCoverage: z.literal("unknown"),
|
||||
selectionIdentity: z.string().regex(/^[a-f0-9]{64}$/).nullable(),
|
||||
routeSelectionScope: z.literal("table-afi-community-observation"), transitionAttribution: z.literal("unproven"),
|
||||
switchEffectObserved: z.boolean(),
|
||||
transition: z.object({ baselineObservedAt: z.string().datetime(), changedPrefixes: z.array(text).max(32) }).strict().optional(),
|
||||
budget: z.object({ deadline: z.number().int().positive(), calls: z.number().int().nonnegative().max(256), bytes: z.number().int().nonnegative().max(8 * 1048576),
|
||||
rows: z.number().int().nonnegative().max(10000) }).strict().optional(),
|
||||
semanticProof: z.literal(false), routeOriginVerified: z.literal(false), recursiveChainVerified: z.literal(false), forwardingVerified: z.literal(false), autoRecoveryAllowed: z.literal(false) }).strict()
|
||||
export type OriginalEffectReport = z.infer<typeof originalEffectReportSchema>
|
||||
export const unknownOriginalEffects = (reason: string): OriginalEffectReport => ({ scope: "prepared-route-fib-adjacency-control-effects", status: "unknown", reason,
|
||||
rounds: [], logSource: "routeros-rest-ring", logCoverage: "unknown", selectionIdentity: null, routeSelectionScope: "table-afi-community-observation", transitionAttribution: "unproven", switchEffectObserved: false, semanticProof: false, routeOriginVerified: false, recursiveChainVerified: false, forwardingVerified: false, autoRecoveryAllowed: false })
|
||||
|
||||
function tuple(row: Row, expected: Prepared["binding"], session: boolean) {
|
||||
return row.instance === expected.instance && row["routing-table"] === expected.table && row.vrf === expected.vrf &&
|
||||
row["local.address"] === expected.localAddress && row["remote.address"] === expected.remoteAddress && Number(row["remote.as"]) === expected.remoteAsn &&
|
||||
(session ? row["local.afi"] === expected.afi && row["remote.afi"] === expected.afi : row.afi === expected.afi)
|
||||
}
|
||||
function selectedRows(rows: Row[], selection: Pick<Prepared, "table" | "family" | "community">) {
|
||||
return rows.filter(row => row["routing-table"] === selection.table && family(row) === selection.family && flag(row.bgp) === true && communities(row).includes(selection.community))
|
||||
}
|
||||
|
||||
/** Called only by the real private context reader. This selects observed route
|
||||
* effects by table/AFI/Community, never a route→session provenance relationship.
|
||||
* The initial bounded subset requires explicit flat peer identity, explicit
|
||||
* BFD-off and an already configured explicit probe source. Other manual paths
|
||||
* keep their existing configuration/probe behavior, with effects unknown. */
|
||||
export function prepareOriginalEffectSelection(profile: RouteAiProfile, candidateId: string, binding: { connectionId: string; instance: string | null; vrf: string; table: string } | null,
|
||||
raw: { routes: Row[]; connections: Row[]; sessions: Row[]; candidate: Row | undefined; probe: { destination: string; expected: string[]; source?: string } | undefined }): OriginalEffectSelection {
|
||||
try {
|
||||
if (!binding || !binding.instance) throw Error()
|
||||
const candidate = profile.candidates.find(value => value.id === candidateId), probe = raw.probe
|
||||
if (!candidate?.controlProbe || !probe?.source || !raw.candidate) throw Error()
|
||||
const connection = unique(raw.connections, binding.connectionId)
|
||||
if (connection.length !== 1 || !(connection[0].templates === "" || Array.isArray(connection[0].templates) && connection[0].templates.length === 0) ||
|
||||
flag(connection[0].disabled) !== false || flag(connection[0]["use-bfd"]) !== false) throw Error()
|
||||
const peer = connection[0], afi = profile.family === "ipv4" ? "ip" : "ipv6"
|
||||
const wanted = tupleSchema.parse({ connectionId: peer[".id"], sessionId: "*0", instance: binding.instance, table: binding.table, vrf: binding.vrf,
|
||||
localAddress: peer["local.address"], remoteAddress: peer["remote.address"], remoteAsn: Number(peer["remote.as"]), afi })
|
||||
if (!tuple(peer, wanted, false)) throw Error()
|
||||
const sessions = raw.sessions.filter(row => tuple(row, wanted, true))
|
||||
if (sessions.length !== 1 || flag(sessions[0].established) === undefined) throw Error()
|
||||
wanted.sessionId = id.parse(sessions[0][".id"])
|
||||
const affected = selectedRows(raw.routes, profile)
|
||||
if (!affected.length || affected.length > 32 || new Set(affected.map(row => row["dst-address"])).size !== affected.length || new Set(affected.map(row => row[".id"])).size !== affected.length) throw Error()
|
||||
for (const row of affected) {
|
||||
const [address, mask, extra] = String(row["dst-address"]).split("/")
|
||||
if (extra !== undefined || isIP(address) !== (profile.family === "ipv4" ? 4 : 6) || !/^\d+$/.test(mask ?? "") || Number(mask) > (profile.family === "ipv4" ? 32 : 128)) throw Error()
|
||||
routeGatewayContext(String(row.gateway))
|
||||
}
|
||||
// A selected destination cannot silently choose among competing origins.
|
||||
if (affected.some(row => raw.routes.filter(other => other["routing-table"] === profile.table && other["dst-address"] === row["dst-address"]).length !== 1)) throw Error()
|
||||
const endpoint = candidate.gatewayRef.resource === "gre" ? { address: routeGatewayContext(candidate.gateway).address, interface: String(raw.candidate.name) } : routeGatewayContext(String(raw.candidate["immediate-gw"]))
|
||||
if (!endpoint.interface) throw Error()
|
||||
return prepared.parse({ status: "prepared", revision: 1, serverId: profile.serverId, candidateId, family: profile.family, table: profile.table,
|
||||
routeSelectionScope: "table-afi-community-observation",
|
||||
community: profile.community, expectedGateway: candidate.gateway, expectedImmediate: { address: endpoint.address, interface: endpoint.interface }, binding: wanted,
|
||||
bfd: { applicability: "not-configured", evidence: "explicit-use-bfd-false" },
|
||||
routes: affected.map(row => ({ nativeId: row[".id"], prefix: row["dst-address"], originalGateway: row.gateway })).sort((a, b) => String(a.prefix).localeCompare(String(b.prefix))),
|
||||
probe: { key: candidateId, serverId: profile.serverId, destination: probe.destination, source: probe.source, interface: candidate.wanInterface,
|
||||
probeVrf: profile.probeVrf, expectedHops: probe.expected } })
|
||||
} catch { return { status: "unknown", reason: "prepared-effect-selection-unsupported-or-ambiguous" } }
|
||||
}
|
||||
|
||||
type Snapshot = { observedAt: string; servers: Array<{ serverId: number; routes: { payload: Row[] }; bgp: { payload: Row[]; connectionsPayload: Row[] }; logs: { payload: Row[] } }>;
|
||||
probes: Array<{ key: string; pingPayload: Row[]; tracePayload?: Row[] }> }
|
||||
export function originalEffectBaselineMatches(selection: Prepared, sample: Snapshot) {
|
||||
const source = sample.servers.find(row => row.serverId === selection.serverId)
|
||||
if (!source) return false
|
||||
const actual = selectedRows(source.routes.payload, selection)
|
||||
return actual.length === selection.routes.length && selection.routes.every(wanted => actual.filter(row => row[".id"] === wanted.nativeId && row["dst-address"] === wanted.prefix && row.gateway === wanted.originalGateway).length === 1)
|
||||
}
|
||||
|
||||
/** Actual bounded native captures only; all stronger causal/whole-path claims
|
||||
* remain false. Related log inspection is reported without completeness. */
|
||||
export async function inspectOriginalEffectRounds(rawSelection: unknown, samples: Snapshot[], baseline: Snapshot) {
|
||||
const selection = prepared.parse(rawSelection), rounds: z.infer<typeof roundSchema>[] = []
|
||||
if (!originalEffectBaselineMatches(selection, baseline)) return unknownOriginalEffects("prepared-effect-baseline-drift")
|
||||
const { inspectControlPing, inspectTrace } = await import("./path-verification.js")
|
||||
for (const sample of samples) {
|
||||
const source = sample.servers.find(row => row.serverId === selection.serverId)
|
||||
if (!source) return unknownOriginalEffects("effect-source-missing")
|
||||
const scope = selectedRows(source.routes.payload, selection)
|
||||
const scopeChanged = fingerprint(scope.map(row => row["dst-address"]).sort()) !== fingerprint(selection.routes.map(row => row.prefix).sort())
|
||||
const routes = selection.routes.map(wanted => {
|
||||
const matching = source.routes.payload.filter(row => row["routing-table"] === selection.table && row["dst-address"] === wanted.prefix && family(row) === selection.family)
|
||||
let status: z.infer<typeof state> = "unknown"
|
||||
if (!scopeChanged && matching.length === 1) {
|
||||
const row = matching[0]
|
||||
if (flag(row.bgp) === true && flag(row.connect) !== true && flag(row.static) !== true && flag(row.active) !== undefined && row.ecmp !== undefined && flag(row.ecmp) === false &&
|
||||
["disabled", "filtered", "unreachable", "blackhole", "invalid"].every(key => row[key] === undefined || flag(row[key]) === false)) {
|
||||
try {
|
||||
const final = routeGatewayContext(String(row["immediate-gw"]))
|
||||
status = flag(row.active) === true && row.contribution === "active" && row.gateway === selection.expectedGateway && final.address === selection.expectedImmediate.address && final.interface === selection.expectedImmediate.interface ? "observed-expected" : "observed-other"
|
||||
} catch { /* no final resolution evidence */ }
|
||||
}
|
||||
}
|
||||
return { prefix: wanted.prefix, status }
|
||||
})
|
||||
const connections = unique(source.bgp.connectionsPayload, selection.binding.connectionId)
|
||||
const sessions = source.bgp.payload.filter(row => tuple(row, selection.binding, true))
|
||||
let bgp: z.infer<typeof state> = "unknown"
|
||||
if (connections.length === 1 && tuple(connections[0], selection.binding, false) && flag(connections[0].disabled) === false && flag(connections[0]["use-bfd"]) === false &&
|
||||
sessions.length === 1 && sessions[0][".id"] === selection.binding.sessionId && flag(sessions[0].established) !== undefined) bgp = flag(sessions[0].established) ? "observed-expected" : "observed-other"
|
||||
const probe = sample.probes.find(row => row.key === selection.probe.key)
|
||||
let controlProbe: "passed" | "failed" | "unknown" = "unknown"
|
||||
if (probe) {
|
||||
const ping = inspectControlPing(z.array(z.record(z.string(), z.string())).max(256).parse(probe.pingPayload))
|
||||
if (ping.confirmedFailure) controlProbe = "failed"
|
||||
else if (ping.ping.measured && ping.ping.loss === 0 && inspectTrace(probe.tracePayload, selection.probe.destination, selection.probe.expectedHops).verified) controlProbe = "passed"
|
||||
}
|
||||
rounds.push(roundSchema.parse({ observedAt: sample.observedAt, routes, bgp,
|
||||
bfd: connections.length === 1 && flag(connections[0]["use-bfd"]) === false ? "not-configured" : "unknown", controlProbe,
|
||||
relatedTopicRows: source.logs.payload.filter(row => typeof row.topics === "string" && row.topics.split(",").some(topic => ["bgp", "bfd", "route", "interface"].includes(topic))).length }))
|
||||
}
|
||||
if (rounds.length !== 2) return unknownOriginalEffects("two-effect-rounds-required")
|
||||
const all = rounds.flatMap(round => [...round.routes.map(route => route.status), round.bgp, round.bfd === "unknown" ? "unknown" : "observed-expected", round.controlProbe === "passed" ? "observed-expected" : round.controlProbe === "failed" ? "observed-other" : "unknown"])
|
||||
const before = baseline.servers.find(row => row.serverId === selection.serverId)!
|
||||
// Native IDs alone may churn; they never count as a forwarding transition.
|
||||
// Require an actual selected active destination's final forwarding endpoint
|
||||
// to differ from the expected post-state seen consistently in both rounds.
|
||||
// A changed logical gateway resolving to the SAME endpoint is not a FIB switch.
|
||||
const changedPrefixes = selection.routes.filter(wanted => {
|
||||
if (!rounds.every(round => round.routes.find(row => row.prefix === wanted.prefix)?.status === "observed-expected")) return false
|
||||
const row = before.routes.payload.find(row => row[".id"] === wanted.nativeId && row["dst-address"] === wanted.prefix)!
|
||||
if (flag(row.bgp) !== true || flag(row.active) !== true || row.contribution !== "active" || flag(row.connect) === true || flag(row.static) === true || flag(row.ecmp) !== false ||
|
||||
["disabled", "filtered", "unreachable", "blackhole", "invalid"].some(key => row[key] !== undefined && flag(row[key]) !== false)) return false
|
||||
try {
|
||||
const endpoint = routeGatewayContext(String(row["immediate-gw"]))
|
||||
routeGatewayContext(String(row.gateway))
|
||||
if (!endpoint.interface) return false
|
||||
return endpoint.address !== selection.expectedImmediate.address || endpoint.interface !== selection.expectedImmediate.interface
|
||||
} catch { return false }
|
||||
}).map(row => row.prefix)
|
||||
const postState = all.includes("unknown") ? "unknown" : all.includes("observed-other") ? "observed-other" : "observed-expected"
|
||||
return originalEffectReportSchema.parse({ ...unknownOriginalEffects(""), rounds, reason: null,
|
||||
selectionIdentity: fingerprint(selection),
|
||||
transition: { baselineObservedAt: baseline.observedAt, changedPrefixes }, switchEffectObserved: postState === "observed-expected" && changedPrefixes.length > 0,
|
||||
status: postState === "observed-expected" && !changedPrefixes.length ? "unknown" : postState,
|
||||
...(postState === "observed-expected" && !changedPrefixes.length ? { reason: "poststate-matched-without-selected-fib-transition" } : {}) })
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ export async function acceptRoutingRecoveryAuthoritySql() {
|
||||
const { pool } = await import("../../db/index.js")
|
||||
const { mcpConnectionPolicySchema } = await import("@mmapp/contracts/mcp")
|
||||
const { routeAiProfileSchema } = await import("@mmapp/contracts/mcp-routing")
|
||||
const { accessGeneration } = await import("./security.js")
|
||||
const { accessGeneration, McpError } = await import("./security.js")
|
||||
const { readRoutingEndpoints, createRoutingSourceContext } = await import("./routing-observation-source.js")
|
||||
const { resolveRoutingProfileAuthority } = await import("./routing-profile-authority.js")
|
||||
const { captureScopedRecoveryAuthority, scopedRecoveryDeadlineCeiling } = await import("./routing-community-scoped-recovery.js")
|
||||
@@ -27,7 +27,7 @@ export async function acceptRoutingRecoveryAuthoritySql() {
|
||||
assert.equal((await pool.query("SELECT current_database() AS name")).rows[0].name, "postgres")
|
||||
const previousEnabled = (await pool.query("SELECT enabled FROM mcp_settings WHERE id=1")).rows[0].enabled as boolean
|
||||
const profileIds: string[] = [], ids: number[] = []
|
||||
let nativeHttpRequests = 0, callbackDispatches = 0, durableIntents = 0
|
||||
let nativeHttpRequests = 0, callbackDispatches = 0, dispatchFences = 0
|
||||
const listener = createServer((req, res) => { nativeHttpRequests++; req.resume(); res.setHeader("content-type", "application/json"); res.end("{}") })
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => { listener.once("error", reject); listener.listen(0, "127.0.0.1", () => { listener.off("error", reject); resolve() }) })
|
||||
@@ -65,12 +65,7 @@ export async function acceptRoutingRecoveryAuthoritySql() {
|
||||
const path = "/routing/filter/rule/*1"
|
||||
await assert.rejects(withVerifiedConfigurationChange({ actorId: ownerId, connectionId, approved: true, serverIds: ids, serverWide: true, objects: [],
|
||||
authorize: async () => { await captured.assert() }, beforeDispatch: async () => {
|
||||
assert.equal((await pool.query("SELECT count(*)::int AS n FROM mcp_audit WHERE connection_id=$1 AND action='configuration.intent' AND payload->>'path'=$2", [connectionId, path])).rows[0].n, 1)
|
||||
durableIntents++
|
||||
if (change === "edit") await pool.query("UPDATE mcp_routing_profiles SET version=version+1,config=jsonb_set(config,'{name}','\"late edit\"') WHERE id=$1", [profileId])
|
||||
if (change === "disable") await pool.query("UPDATE mcp_routing_profiles SET enabled=false WHERE id=$1", [profileId])
|
||||
if (change === "grant") await pool.query("UPDATE mcp_routing_profiles SET grant_expires_at=clock_timestamp()-interval '1 second' WHERE id=$1", [profileId])
|
||||
await captured.assert()
|
||||
dispatchFences++
|
||||
},
|
||||
}, () => executeGuardedConfigurationWrite(ids[0], "PATCH", path, async () => {
|
||||
callbackDispatches++
|
||||
@@ -78,12 +73,17 @@ export async function acceptRoutingRecoveryAuthoritySql() {
|
||||
const req = request({ hostname: "127.0.0.1", port, path: "/rest" + path, method: "PATCH", timeout: 1000 }, res => { res.resume(); res.once("end", resolve) })
|
||||
req.once("error", reject); req.once("timeout", () => req.destroy(new Error("Synthetic loopback timeout"))); req.end("{}")
|
||||
})
|
||||
})), /Постоянный допуск/)
|
||||
assert.equal(callbackDispatches, 0); assert.equal(nativeHttpRequests, 0)
|
||||
assert.equal((await pool.query("SELECT count(*)::int AS n FROM mcp_audit WHERE connection_id=$1 AND action='configuration.unconfirmed'", [connectionId])).rows[0].n, 1)
|
||||
})), (error: unknown) => error instanceof McpError && error.code === "STATE_CONFLICT" && /Активный routing profile/.test(error.message))
|
||||
assert.equal(callbackDispatches, 0); assert.equal(nativeHttpRequests, 0); assert.equal(dispatchFences, 0)
|
||||
assert.equal((await pool.query("SELECT count(*)::int AS n FROM mcp_audit WHERE connection_id=$1 AND action IN ('configuration.intent','configuration.unconfirmed','configuration.acknowledged')", [connectionId])).rows[0].n, 0)
|
||||
|
||||
if (change === "edit") await pool.query("UPDATE mcp_routing_profiles SET version=version+1,config=jsonb_set(config,'{name}','\"late edit\"') WHERE id=$1", [profileId])
|
||||
if (change === "disable") await pool.query("UPDATE mcp_routing_profiles SET enabled=false WHERE id=$1", [profileId])
|
||||
if (change === "grant") await pool.query("UPDATE mcp_routing_profiles SET grant_expires_at=clock_timestamp()-interval '1 second' WHERE id=$1", [profileId])
|
||||
await assert.rejects(captured.assert(), /Постоянный допуск/)
|
||||
}
|
||||
return { cases: 3, lateMutations: ["edit", "disable", "grant"], deepCaptureCases: 3, durableIntents, callbackDispatches, nativeHttpRequests,
|
||||
productionSessionAuthorityFence: true, realSql: true, productionHmac: true, fullInverseLifecycleRepeated: false, controllerEnabled: false, externalNetwork: false }
|
||||
return { cases: 3, deepCaptureCases: 3, directGuardBeforeIntent: true, dispatchFences, callbackDispatches, nativeHttpRequests,
|
||||
capturedAuthorityChanges: ["edit", "disable", "grant"], realSql: true, productionHmac: true, controllerEnabled: false, externalNetwork: false }
|
||||
} finally {
|
||||
if (profileIds.length) await pool.query("UPDATE mcp_routing_profiles SET enabled=false WHERE id=ANY($1::uuid[])", [profileIds])
|
||||
if (ids.length) await pool.query("UPDATE servers SET enabled=false WHERE id=ANY($1::int[])", [ids])
|
||||
|
||||
@@ -2,9 +2,26 @@ import { assertOwnedRoutingRecoveryAuthoritySql, acceptRoutingRecoveryAuthorityS
|
||||
|
||||
// Guard precedes imports that configure a pool or load private MCP state.
|
||||
assertOwnedRoutingRecoveryAuthoritySql()
|
||||
process.env.AUTH_REQUIRED = "true"
|
||||
process.env.AUTH_JWT_SECRET = "owned-routing-recovery-authority-acceptance-secret"
|
||||
process.env.AUTH_ISSUER = "https://owned-routing-recovery-authority.test"
|
||||
const { pool, closePool } = await import("../../db/index.js")
|
||||
try {
|
||||
const { applySqlMigrations } = await import("../../db/migrate.js")
|
||||
await applySqlMigrations(pool)
|
||||
console.log("captured recovery authority SQL acceptance:", JSON.stringify(await acceptRoutingRecoveryAuthoritySql()))
|
||||
} finally { await closePool() }
|
||||
const { updateMcpSettings } = await import("./store.js")
|
||||
await import("./actions.js")
|
||||
const { acceptScopedRouteCommunitySwitch } = await import("./routing-community-scoped-acceptance.js")
|
||||
const actor = { id: "owned-routing-recovery-authority", email: "routing-recovery@example.invalid", name: "Owned recovery authority",
|
||||
apps: ["mm"], permissions: ["mm:settings:admin", "mm:mcp:admin", "mm:mcp:approve", "mm:network:read", "mm:network:write", "mm:filters:write", "mm:servers:admin"],
|
||||
issuedAt: Date.now() - 1000, expiresAt: Date.now() + 900000 }
|
||||
await updateMcpSettings({ enabled: true }, actor)
|
||||
for (const lateInverseAuthorityChange of ["edit", "disable", "grant"] as const) {
|
||||
const evidence = await acceptScopedRouteCommunitySwitch(actor, { lateInverseAuthorityChange })
|
||||
console.log("genuine late inverse authority SQL acceptance:", JSON.stringify(evidence))
|
||||
}
|
||||
} finally {
|
||||
await (await import("../../services/scheduler.js")).stopSchedulerForDatabaseOperation()
|
||||
await closePool()
|
||||
}
|
||||
@@ -1,9 +1,23 @@
|
||||
import { assertOwnedRoutingReservationSql, acceptRoutingReservationSql } from "./routing-reservation.sql-helper.js"
|
||||
|
||||
assertOwnedRoutingReservationSql()
|
||||
process.env.AUTH_REQUIRED = "true"
|
||||
process.env.AUTH_JWT_SECRET = "owned-routing-mixed-writer-acceptance-secret"
|
||||
process.env.AUTH_ISSUER = "https://owned-routing-mixed-writer.test"
|
||||
const { pool, closePool } = await import("../../db/index.js")
|
||||
try {
|
||||
const { applySqlMigrations } = await import("../../db/migrate.js")
|
||||
await applySqlMigrations(pool)
|
||||
console.log("private routing reservation runtime admission SQL acceptance:", JSON.stringify(await acceptRoutingReservationSql({ runtimeAdmission: true })))
|
||||
} finally { await closePool() }
|
||||
const actor = { id: "owned-routing-mixed-writer", email: "routing-mixed@example.invalid", name: "Owned mixed writer",
|
||||
apps: ["mm"], permissions: ["mm:settings:admin", "mm:mcp:admin", "mm:mcp:approve", "mm:network:read", "mm:network:write", "mm:filters:write", "mm:servers:admin"],
|
||||
issuedAt: Date.now() - 1000, expiresAt: Date.now() + 900000 }
|
||||
await (await import("./store.js")).updateMcpSettings({ enabled: true }, actor)
|
||||
await import("./actions.js")
|
||||
const mixed = await (await import("./routing-community-scoped-acceptance.js")).acceptScopedRouteCommunitySwitch(actor,
|
||||
{ manualInverseChargeOnly: true, manualInverseChargeCase: "mixed-forward" })
|
||||
console.log("private routing mixed forward/manual inverse admission SQL acceptance:", JSON.stringify(mixed))
|
||||
} finally {
|
||||
await (await import("../../services/scheduler.js")).stopSchedulerForDatabaseOperation()
|
||||
await closePool()
|
||||
}
|
||||
@@ -352,6 +352,47 @@ export async function acceptRoutingReservationSql(options: { runtimeAdmission?:
|
||||
await assert.rejects(store.beginForwardAttempt(reservation.forwardAllocation), /capability/)
|
||||
names.push("spent forward/no genuine checkpoint and preterminal unused-proof refuse; unknown never replays or refunds")
|
||||
}
|
||||
{
|
||||
const f = await fixture(), source = await f.makeSource(), reservation = await store.reserve(source.operationId)
|
||||
const { saveScopedConfigurationCheckpoint, saveScopedNetworkCheckpoint } = await import("./routing-community-scoped-checkpoint.js")
|
||||
const { executeReservedScopedRecovery } = await import("./routing-community-scoped-recovery.js")
|
||||
await pool.query("UPDATE mcp_operations SET state='running' WHERE id=$1", [source.operationId])
|
||||
const step = { kind: "service", service: "routing.community.scoped", input: source.input }
|
||||
await pool.query("INSERT INTO mcp_operation_steps(operation_id,step_index,state,intended_state) VALUES($1,0,'intent',$2::jsonb)",
|
||||
[source.operationId, JSON.stringify(security.redact(step))])
|
||||
await pool.query("INSERT INTO mcp_operation_checkpoints(operation_id,step_index,before_sealed) VALUES($1,0,$2)",
|
||||
[source.operationId, await security.encryptSecret(JSON.stringify(source.input.beforeRules))])
|
||||
assert(reservation.forwardAllocation)
|
||||
await store.beginForwardAttempt(reservation.forwardAllocation)
|
||||
// Use the actual checkpoint writers for production crypto and SQL, but
|
||||
// deliberately seed a synthetic failed-forward observation. No PATCH
|
||||
// or genuine network observation is part of this owned component case.
|
||||
const requestSeal = String(source.input.requestProof)
|
||||
const configuration = await saveScopedConfigurationCheckpoint(source.input, requestSeal)
|
||||
await saveScopedNetworkCheckpoint(source.input, requestSeal, { id: randomUUID(), candidateId: "b",
|
||||
status: "failed", observedAt: configuration.observedAt })
|
||||
const published = await store.recordForwardOutcome(reservation.reservationId)
|
||||
assert(published.allocation)
|
||||
assert.equal(published.receipt.state.inverse, "held")
|
||||
const refused = { sourceOperationId: randomUUID(), principal: f.principal }
|
||||
await assert.rejects(executeReservedScopedRecovery(published.allocation, refused), /Reserved runner identity differs/)
|
||||
const unknown = await store.read(reservation.reservationId, f.principal)
|
||||
assert.deepEqual(unknown.state, { ...published.receipt.state, revision: published.receipt.state.revision + 2, inverse: "unknown" })
|
||||
assert.equal(unknown.chargedMutationUnits, 2)
|
||||
assert.equal(unknown.chargedProfileSlots, 2)
|
||||
assert.equal(unknown.refundsAvailable, false)
|
||||
const shared = (await pool.query(`SELECT state,slot_phase,inverse_intent_sealed,inverse_configuration_sealed,verified_sealed
|
||||
FROM mcp_scoped_recovery_intents WHERE reservation_id=$1`, [reservation.reservationId])).rows
|
||||
assert.deepEqual(shared, [{ state: "unknown", slot_phase: "reserved-attempt", inverse_intent_sealed: null,
|
||||
inverse_configuration_sealed: null, verified_sealed: null }])
|
||||
await assert.rejects(executeReservedScopedRecovery(published.allocation, refused), /capability/)
|
||||
await assert.rejects(store.recordForwardOutcome(reservation.reservationId), /republished/)
|
||||
await assert.rejects(store.proveUnusedInverse(reservation.reservationId), /terminal applied/)
|
||||
assert.equal(await used(f.connectionId), 2)
|
||||
assert.equal((await store.readHourlyCharges(f.profileId, f.principal)).reservedCharges, 2)
|
||||
assert.deepEqual((await store.read(reservation.reservationId, f.principal)).state, unknown.state)
|
||||
names.push("genuine inverse capability on synthetic failed-forward checkpoints: acknowledged spend, late identity refusal, signed same-slot unknown and both charges conserved without replay or refund")
|
||||
}
|
||||
{
|
||||
const f = await fixture(), source = await f.makeSource(), reservation = await store.reserve(source.operationId)
|
||||
const saved = (await pool.query("SELECT state_proof FROM mcp_routing_reservations WHERE id=$1", [reservation.reservationId])).rows[0].state_proof
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { pollErrorLog } from "../../services/server-poll-error-provenance.js"
|
||||
import { capturePublishedServerProbeTerminalProof } from "../../services/server-probe-operation-retirement.js"
|
||||
import { SERVER_PROBE_OPERATION } from "../../services/server-probe-authority.js"
|
||||
import { preparedPlanExpiry } from "./plan-expiry.js"
|
||||
import { acknowledgedCreateId, assertNoCallerCreateId } from "./native-create-identity.js"
|
||||
import { randomUUID, createHash } from "node:crypto"
|
||||
@@ -23,6 +25,7 @@ import { beginMcpOperationAdmission, insertMcpOperationAdmission, retireMcpOpera
|
||||
import { createOspfOwnerSourceGuard, verifyOspfOwnerGraph, verifyOspfOwnerGraphAtPhase } from "./ospf-owner-graph.js"
|
||||
import { evaluateMcpPolicy } from "./policy.js"
|
||||
import { findAction, findActionByOperation } from "./actions.js"
|
||||
import { refuseExpiredOperationReplay } from "./operation-replay-tombstones.js"
|
||||
import { accessGeneration, decryptSecret, encryptSecret, fingerprint, privateSecretFingerprint, McpError, redact } from "./security.js"
|
||||
import { audit, getConnection, transaction, validatePrincipal, effectiveMcpConnection, assertConnectionVisible, type McpPrincipal, type ConnectionRow, type StoredPlan, type StoredOperation } from "./store.js"
|
||||
import { executeRosStep, readRosSnapshot, resourceById, routerClient, type RosSnapshot } from "./resources.js"
|
||||
@@ -291,6 +294,7 @@ export async function applyPlan(principal: McpPrincipal, planId: string, idempot
|
||||
reservedRouting = !!await routingReservationEvidence(original, originalEvidence)
|
||||
return existing
|
||||
}
|
||||
await refuseExpiredOperationReplay(client, principal, connection.id, planId, idempotencyKey)
|
||||
await client.query("SELECT pg_advisory_xact_lock(hashtextextended('mcp-runner-admission',0))")
|
||||
const pending = await client.query<{ count: number }>("SELECT count(*)::int AS count FROM mcp_operations WHERE state IN ('pending','running')")
|
||||
if (pending.rows[0].count >= 100) throw new McpError("BUDGET_EXCEEDED", "Очередь исполнителя заполнена; повторите подготовленное применение позже", 429)
|
||||
@@ -359,6 +363,7 @@ export async function applyPlan(principal: McpPrincipal, planId: string, idempot
|
||||
async function executeOperation(operationId: string, principal: McpPrincipal, routingReservationForward?: RoutingReservedForwardAllocation, routingReservationId?: string,originalServiceGrant?:OriginalServiceGrant, operationAdmission?: McpOperationAdmission): Promise<void> {
|
||||
return withMcpOperationExecutionOrigin(operationAdmission, async () => {
|
||||
const lease = await pool.connect()
|
||||
let terminalProbe: Awaited<ReturnType<typeof executeOperationWithLease>>
|
||||
let locked = false, released = false
|
||||
const ownerLifetime = new AbortController()
|
||||
// This closure is the SINGLE owner of physical destruction/release.
|
||||
@@ -380,7 +385,7 @@ async function executeOperation(operationId: string, principal: McpPrincipal, ro
|
||||
throw error
|
||||
}
|
||||
if (released || !locked) return
|
||||
await withOperationConfigurationLockClient(lease, retire, ownerLifetime.signal,
|
||||
terminalProbe = await withOperationConfigurationLockClient(lease, retire, ownerLifetime.signal,
|
||||
() => executeOperationWithLease(operationId, principal, routingReservationForward, routingReservationId, originalServiceGrant))
|
||||
} finally {
|
||||
if (!released && locked) {
|
||||
@@ -395,9 +400,14 @@ async function executeOperation(operationId: string, principal: McpPrincipal, ro
|
||||
lease.release()
|
||||
}
|
||||
}
|
||||
// Capture has its own bounded checkout and may fail without rewriting the
|
||||
// acknowledged terminal outcome or redispatching the diagnostic.
|
||||
if (terminalProbe) {
|
||||
try { await capturePublishedServerProbeTerminalProof(terminalProbe) } catch { /* Existing retention remains authoritative. */ }
|
||||
}
|
||||
})
|
||||
}
|
||||
async function executeOperationWithLease(operationId: string, principal: McpPrincipal, routingReservationForward?: RoutingReservedForwardAllocation, routingReservationId?: string,originalServiceGrant?:OriginalServiceGrant): Promise<void> {
|
||||
async function executeOperationWithLease(operationId: string, principal: McpPrincipal, routingReservationForward?: RoutingReservedForwardAllocation, routingReservationId?: string,originalServiceGrant?:OriginalServiceGrant) {
|
||||
const { rows: [operation] } = await pool.query<StoredOperation>("SELECT * FROM mcp_operations WHERE id=$1 AND state='running'", [operationId])
|
||||
if (!operation) return
|
||||
const plan = await loadPlan(operation.plan_id), evidence = JSON.parse(await decryptSecret(plan.evidence.sealed)) as ChangeEvidence
|
||||
@@ -576,14 +586,18 @@ async function executeOperationWithLease(operationId: string, principal: McpPrin
|
||||
})
|
||||
await (evidence.steps.some(step => step.kind === "service") ? withApplicationMutationLock(applyWithLocks) : applyWithLocks())
|
||||
assertOperationConfigurationLockCheckpoint()
|
||||
await transaction(async client => {
|
||||
const receiptId = plan.operation_id === SERVER_PROBE_OPERATION ? diagnosticReceipt?.receiptId : undefined
|
||||
const finishedAt = await transaction(async client => {
|
||||
assertOperationConfigurationLockCheckpoint()
|
||||
await client.query("UPDATE mcp_operations SET state='applied',verification=$3,result=$2::jsonb,finished_at=now() WHERE id=$1", [operationId, JSON.stringify({ confirmedWrites: diagnosticOnly ? 0 : confirmed, verificationScope: evidence.verificationScope ?? "configuration", ...(diagnosticOnly ? { completedServiceSteps: confirmed, diagnosticReceipt } : {}) }), evidence.verificationScope === "acknowledgement" ? "inconclusive" : "passed"])
|
||||
const terminal = await client.query<{ finished_at: string }>("UPDATE mcp_operations SET state='applied',verification=$3,result=$2::jsonb,finished_at=now() WHERE id=$1"
|
||||
+ (receiptId ? ` RETURNING to_char(finished_at AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS.US"Z"') AS finished_at` : ""),
|
||||
[operationId, JSON.stringify({ confirmedWrites: diagnosticOnly ? 0 : confirmed, verificationScope: evidence.verificationScope ?? "configuration", ...(diagnosticOnly ? { completedServiceSteps: confirmed, diagnosticReceipt } : {}) }), evidence.verificationScope === "acknowledgement" ? "inconclusive" : "passed"])
|
||||
assertOperationConfigurationLockCheckpoint()
|
||||
await client.query("UPDATE mcp_plans SET state='completed' WHERE id=$1", [plan.id])
|
||||
assertOperationConfigurationLockCheckpoint()
|
||||
await audit(client, "mcp-runner", "operation.complete", operation.connection_id, { operationId, confirmedWrites: diagnosticOnly ? 0 : confirmed, ...(diagnosticOnly ? { completedServiceSteps: confirmed, diagnosticReceipt } : {}) })
|
||||
assertOperationConfigurationLockCheckpoint()
|
||||
return receiptId ? terminal.rows[0]?.finished_at : undefined
|
||||
})
|
||||
assertOperationConfigurationLockCheckpoint()
|
||||
if (routingReservationId && reservedForwardPassed) {
|
||||
@@ -592,6 +606,7 @@ async function executeOperationWithLease(operationId: string, principal: McpPrin
|
||||
await createRoutingReservationStore().proveUnusedInverse(routingReservationId).catch(() => {})
|
||||
}
|
||||
assertOperationConfigurationLockCheckpoint()
|
||||
if (receiptId && finishedAt) return { receiptId, operationId, connectionId:operation.connection_id, finishedAt }
|
||||
} catch (error) {
|
||||
if (routingReservationId && hasConfirmedReservedRoutingForward(error)) confirmed = Math.max(confirmed, 1)
|
||||
if (error instanceof VrfProductionAcknowledgedFailure) confirmed = Math.max(confirmed, error.acknowledgedWrites)
|
||||
|
||||
@@ -12,9 +12,9 @@ const principal = { connectionId, credentialId, credentialExpiresAt: new Date(no
|
||||
const proof = async (value: unknown) => createHash("sha256").update(JSON.stringify(value)).digest("hex")
|
||||
const context = { principal, connection: { id: connectionId, owner_id: "owner", owner_claims_issued_at: new Date(now - 1000) } } as ActionContext
|
||||
let assertions = 0
|
||||
function fixture() {
|
||||
function fixture(retiredSource?: ServerProbeMcpDependencies["retiredSource"]) {
|
||||
const events: string[] = [], receipts = new Map<string, ServerProbeReceipt>(), contexts: ServerProbeJournalContext[] = [], sealed = new Map<string, string>()
|
||||
let evidence: ChangeEvidence, result: unknown = null, stepState = "intent", operationState = "running", scope = [2, 9], revoked = false, sqlTamper: string | undefined, dispatchFailure: unknown, preparedTool: PreparedServerProbe["tool"] = "ping", mismatchDestination = false, nextCipher = 0, revokeOnQuery = false, expiredPrepared = false, clock = now
|
||||
let evidence: ChangeEvidence, result: unknown = null, stepState = "intent", operationState = "running", scope = [2, 9], revoked = false, sqlTamper: string | undefined, dispatchFailure: unknown, preparedTool: PreparedServerProbe["tool"] = "ping", mismatchDestination = false, nextCipher = 0, revokeOnQuery = false, expiredPrepared = false, clock = now, liveHistory = true
|
||||
const access: ServerProbeAuthority = { transport: "mcp", ownerId: "owner", connectionId, credentialId, expiresAt: now + 600000, generation: "generation", policyProof: "1".repeat(64), ownerProof: "2".repeat(64), active: true, operationAllowed: true, scopeServerIds: scope, ownerPermissions: ["mm:servers:write"], policyPermissions: ["mm:servers:write"] }
|
||||
const readAuthority = async () => { events.push("authority"); if (revoked) throw new ServerProbeExecutionError("PERMISSION_DENIED", "PRIVATE_PROVIDER_SECRET", 403); return { ...access, expiresAt: clock + 600000, scopeServerIds: [...scope] } }
|
||||
const assertProtections = async (ids: number[]) => { events.push("protections"); if (ids.some(id => !scope.includes(id))) throw new Error("out of scope") }
|
||||
@@ -28,6 +28,7 @@ function fixture() {
|
||||
read: async id => { events.push("journal.read"); return receipts.get(id) },
|
||||
}
|
||||
const deps: ServerProbeMcpDependencies = {
|
||||
retiredSource,
|
||||
authority: (() => ({ readAuthority, assertProtections })) as ServerProbeMcpDependencies["authority"],
|
||||
historicalAuthority: ((currentPrincipal: typeof principal) => ({ readAuthority: async () => { events.push("read-authority"); return { ...await readAuthority(), connectionId: currentPrincipal.connectionId, credentialId: currentPrincipal.credentialId } }, assertProtections })) as ServerProbeMcpDependencies["historicalAuthority"],
|
||||
history: dependencies => createServerProbeJournalHistoryReader({ ...dependencies, loadArchive: async binding => {
|
||||
@@ -51,7 +52,7 @@ function fixture() {
|
||||
journal: (ctx, dependencies, execution) => { assert.equal(execution?.deadlineAt, Date.parse(prepared().expiresAt)); contexts.push(ctx); return { ...journal, read: async (...args) => { await dependencies.assertAuthority(ctx, args[1]); return journal.read(...args) }, claim: async binding => { await dependencies.assertAuthority(ctx, binding.bindingProof); return journal.claim(binding) } } },
|
||||
query: async (sql, values) => {
|
||||
events.push("sql"); if (revokeOnQuery) revoked = true
|
||||
if (sql.includes("probe-history:scope")) { assert.equal(values[2], connectionId); assert.equal(values[3], "owner"); return [{ source_server_id: 9, destination_server_id: preparedTool === "bandwidth" ? 2 : null }] }
|
||||
if (sql.includes("probe-history:scope")) { assert.equal(values[2], connectionId); assert.equal(values[3], "owner"); return liveHistory ? [{ source_server_id: 9, destination_server_id: preparedTool === "bandwidth" ? 2 : null }] : [] }
|
||||
if (sql.includes("FROM servers")) { assert.ok(sql.includes("ANY($1::int[])")); assert.deepEqual(values[0], scope); return sql.includes("AND id=$2") ? [] : [{ id: 2, host: "peer", enabled: true }] }
|
||||
if (!sql.includes("mcp_operation_steps")) return [{ evidence: { sealed: await seal(JSON.stringify(evidence)) } }]
|
||||
assert.equal(values[0], connectionId); assert.equal(values[1], "owner"); assert.equal(values[2], "servers.probes.run")
|
||||
@@ -68,8 +69,9 @@ function fixture() {
|
||||
}, seal, open, proof, now: () => clock,
|
||||
}
|
||||
const adapter = createServerProbeMcpAdapter(deps)
|
||||
const retireHistory = () => { liveHistory = false }
|
||||
const prepare = async (tool: PreparedServerProbe["tool"] = "ping") => { evidence = await adapter.prepare({ serverId: 9, request: tool === "bandwidth" ? { tool, bwRemoteAddress: "peer", dstServerId: 8 } : { tool, target: "192.0.2.1" } }, context); const step = evidence.steps[0]; assert.equal(step.kind, "service"); return step.kind === "service" ? step.input : assert.fail() }
|
||||
return { adapter, events, receipts, contexts, prepare, setResult: (value: unknown) => { result = value; stepState = "applied" }, finishOperation: () => { operationState = "applied"; stepState = "acknowledged" }, setTamper: (value?: string) => { sqlTamper = value }, revoke: () => { revoked = true }, setScope: (value: number[]) => { scope = value }, fail: (value: unknown) => { dispatchFailure = value }, revokeOnQuery: () => { revokeOnQuery = true }, evidence: () => evidence, mismatchDestination: () => { mismatchDestination = true }, expirePrepared: () => { expiredPrepared = true; clock = now + 3600000 } }
|
||||
return { adapter, events, receipts, contexts, prepare, retireHistory, setResult: (value: unknown) => { result = value; stepState = "applied" }, finishOperation: () => { operationState = "applied"; stepState = "acknowledged" }, setTamper: (value?: string) => { sqlTamper = value }, revoke: () => { revoked = true }, setScope: (value: number[]) => { scope = value }, fail: (value: unknown) => { dispatchFailure = value }, revokeOnQuery: () => { revokeOnQuery = true }, evidence: () => evidence, mismatchDestination: () => { mismatchDestination = true }, expirePrepared: () => { expiredPrepared = true; clock = now + 3600000 } }
|
||||
}
|
||||
|
||||
for (const tool of ["ping", "traceroute", "bandwidth", "dns", "route", "mtu"] as const) {
|
||||
@@ -128,6 +130,38 @@ for (const input of [{ serverId: 9, request: { tool: "ping", target: "x", creden
|
||||
await assert.rejects(f.adapter.service.execute(input, { operationId, phase: "forward", stepIndex: 0, signal: controller.signal })); assert.equal(f.events.filter(event => event === "execute").length, attempted); assertions += 3
|
||||
}
|
||||
assert.ok(!("diagnosticProgress" in serverProbeSafeMcpError(new ServerProbeExecutionError("X", "secret", 500, "unknown", 99), { receiptId, operationId, stageBudget: 1 }))); assertions++
|
||||
{
|
||||
const hash = "3".repeat(64), date = (value: number) => new Date(value).toISOString().replace("Z", "000Z")
|
||||
const retired = { source: { version: 1 as const, operation: "servers.probes.run" as const, operationVersion: 1 as const, tool: "ping" as const,
|
||||
receiptId, executionId: operationId, connectionId, ownerId: "owner", planId: randomUUID(), originalCredentialId: credentialId,
|
||||
sourceServerId: 9, serverIds: [9], issuedAt: now - 1000, authorityExpiresAt: date(now + 600000), terminalAt: date(now + 600000),
|
||||
activityAt: date(now + 600000), gcNotBefore: date(now + 87060000), retentionDays: 1, safetyMs: 60000,
|
||||
bindingProof: hash, sourceDigest: hash, terminalDigest: hash, stageSnapshotDigest: hash, receiptProof: hash, ciphertextSha256: hash,
|
||||
}, payloadPurged: false }
|
||||
let lookups = 0, changeOnRecheck = false
|
||||
const f = fixture(async lookup => {
|
||||
lookups++
|
||||
assert.deepEqual(lookup, { receiptId, operationId, connectionId, ownerId: "owner", scopeServerIds: [2, 9] })
|
||||
return changeOnRecheck && lookups % 2 === 0 ? undefined : retired
|
||||
})
|
||||
const input = await f.prepare(), output = await f.adapter.service.execute(input, { operationId, phase: "forward", stepIndex: 0 })
|
||||
f.setResult(output); f.finishOperation()
|
||||
const request = { operationId, receiptId, page: 0 }
|
||||
const live = await f.adapter.readPage(request, context)
|
||||
assert.equal(lookups, 0)
|
||||
f.retireHistory()
|
||||
const rotated = { ...context, principal: { ...principal, credentialId: randomUUID() } }
|
||||
assert.deepEqual(await f.adapter.readPage(request, rotated), live)
|
||||
assert.deepEqual(await f.adapter.receiptTarget(request, rotated), { scope: "servers", serverIds: [9] })
|
||||
const beforeForeign = lookups
|
||||
await assert.rejects(f.adapter.readPage(request, { ...rotated, connection: { ...context.connection, owner_id: "foreign" } }))
|
||||
assert.equal(lookups, beforeForeign)
|
||||
lookups = 0; changeOnRecheck = true
|
||||
const privateReads = f.events.filter(event => event === "history.read").length
|
||||
await assert.rejects(f.adapter.readPage(request, rotated))
|
||||
assert.equal(f.events.filter(event => event === "history.read").length, privateReads)
|
||||
assertions += 7
|
||||
}
|
||||
{
|
||||
const f = fixture(), input = await f.prepare("bandwidth"), output = await f.adapter.service.execute(input, { operationId, phase: "forward", stepIndex: 0 }); f.setResult(output); f.finishOperation(); f.expirePrepared()
|
||||
await assert.rejects(f.adapter.service.current(input))
|
||||
|
||||
@@ -10,6 +10,7 @@ import { serverProbeReceiptPage } from "../../services/server-probe-public-resul
|
||||
import { decryptSecret, encryptSecret, fingerprint, privateSecretFingerprint, McpError, redact } from "./security.js"
|
||||
import type { ActionContext, ChangeEvidence } from "./action-types.js"
|
||||
import type { McpServiceHandler } from "./service-registry.js"
|
||||
import type { findRetiredServerProbeHistory, ServerProbeRetiredLookup } from "../../services/server-probe-retired-history.js"
|
||||
|
||||
/** Foundations only: importing this module registers no tools or services. */
|
||||
export const serverProbeActionSchema = z.object({ serverId: z.number().int().positive().safe(), request: serverProbeExecutionRequest }).strict()
|
||||
@@ -32,6 +33,7 @@ export interface ServerProbeMcpDependencies {
|
||||
executor: typeof createProductionServerProbeExecutor
|
||||
journal: (context: ServerProbeJournalContext, dependencies: ServerProbeJournalDependencies, execution?: { signal?: AbortSignal; deadlineAt?: number }) => ServerProbeJournal
|
||||
query: Query
|
||||
retiredSource?: (lookup: ServerProbeRetiredLookup) => ReturnType<typeof findRetiredServerProbeHistory>
|
||||
seal: (value: string) => Promise<string>
|
||||
open: (value: string) => Promise<string>
|
||||
proof: (value: unknown) => Promise<string>
|
||||
@@ -52,7 +54,7 @@ export async function openServerProbeMcpBinding(raw: Record<string, unknown>, cr
|
||||
return { input, binding }
|
||||
}
|
||||
const ids = (prepared: PreparedServerProbe) => [...new Set([prepared.serverId, ...(prepared.destinationServerId ? [prepared.destinationServerId] : [])])].sort((a, b) => a - b)
|
||||
function metadata(receipt: ServerProbeReceipt, operationId: string, stageBudget: number) {
|
||||
export function serverProbeReceiptMetadata(receipt: ServerProbeReceipt, operationId: string, stageBudget: number) {
|
||||
const page = serverProbeReceiptPage(receipt, 0)
|
||||
if (!Number.isSafeInteger(receipt.attemptedStages) || !Number.isSafeInteger(receipt.completedStages) || receipt.completedStages < 0 || receipt.completedStages > receipt.attemptedStages || receipt.attemptedStages > stageBudget) return denied()
|
||||
return { operationId, receiptId: receipt.receiptId, tool: receipt.tool, status: receipt.state, httpStatus: receipt.httpStatus, stageBudget, attemptedStages: receipt.attemptedStages, completedStages: receipt.completedStages, sha256: page.sha256, totalPages: page.totalPages, bytes: page.bytes, automaticReplay: false, forwardingVerified: false }
|
||||
@@ -119,7 +121,7 @@ export function createServerProbeMcpAdapter(overrides: Partial<ServerProbeMcpDep
|
||||
await owned(input, binding, row.operation_id)
|
||||
await preflight(binding, signal)
|
||||
if (!receipt || receipt.receiptId !== input.receiptId || receipt.serverId !== binding.prepared.serverId || receipt.destinationServerId !== binding.prepared.destinationServerId || receipt.tool !== binding.prepared.tool) return denied()
|
||||
return { row, receipt, result: metadata(receipt, row.operation_id, binding.stageBudget) }
|
||||
return { row, receipt, result: serverProbeReceiptMetadata(receipt, row.operation_id, binding.stageBudget) }
|
||||
}
|
||||
const target = async (raw: Record<string, unknown>, context: ActionContext): Promise<ChangeEvidence["target"]> => {
|
||||
const input = serverProbeActionSchema.parse(raw), access = deps.authority(context.principal, { signal: context.signal })
|
||||
@@ -177,7 +179,7 @@ export function createServerProbeMcpAdapter(overrides: Partial<ServerProbeMcpDep
|
||||
const { executor } = execution(input, binding, row, true, context.signal)
|
||||
const receipt = await executor.execute(binding.prepared, context.signal)
|
||||
await preflight(binding, context.signal); await owned(input, binding, context.operationId, true); await preflight(binding, context.signal)
|
||||
return metadata(receipt, context.operationId, binding.stageBudget)
|
||||
return serverProbeReceiptMetadata(receipt, context.operationId, binding.stageBudget)
|
||||
} catch (error) { throw serverProbeSafeMcpError(error, progress) }
|
||||
},
|
||||
verify: async (raw, result) => {
|
||||
@@ -200,19 +202,47 @@ export function createServerProbeMcpAdapter(overrides: Partial<ServerProbeMcpDep
|
||||
AND j.source_server_id=ANY($6::int[]) AND (j.destination_server_id IS NULL OR j.destination_server_id=ANY($6::int[])) LIMIT 2`,
|
||||
[request.receiptId, request.operationId, context.principal.connectionId, initial.ownerId, SERVER_PROBE_OPERATION, initial.scopeServerIds])
|
||||
await fresh()
|
||||
if (rows.length !== 1) return denied()
|
||||
const row = rows[0], serverIds = [...new Set([z.number().int().positive().safe().parse(row.source_server_id), ...(row.destination_server_id === null ? [] : [z.number().int().positive().safe().parse(row.destination_server_id)])])].sort((a, b) => a - b)
|
||||
const loadRetired = deps.retiredSource
|
||||
const readRetired = rows.length === 0 && loadRetired ? () => loadRetired({
|
||||
receiptId: request.receiptId, operationId: request.operationId, connectionId: context.principal.connectionId,
|
||||
ownerId: initial.ownerId, scopeServerIds: initial.scopeServerIds,
|
||||
}) : undefined
|
||||
const retired = readRetired ? await readRetired() : undefined
|
||||
if (readRetired) await fresh()
|
||||
if (rows.length !== 1 && !retired) return denied()
|
||||
const row = rows[0]
|
||||
const serverIds = retired ? retired.source.serverIds : [...new Set([z.number().int().positive().safe().parse(row.source_server_id), ...(row.destination_server_id === null ? [] : [z.number().int().positive().safe().parse(row.destination_server_id)])])].sort((a, b) => a - b)
|
||||
const fence = async () => { const current = await fresh(); if (serverIds.some(id => !current.scopeServerIds.includes(id))) return denied(); await access.assertProtections(serverIds, current); await fresh() }
|
||||
await fence()
|
||||
return { initial, serverIds, fence }
|
||||
const retiredFence = retired && readRetired ? async () => {
|
||||
await fence()
|
||||
const current = await readRetired()
|
||||
await fence()
|
||||
if (!current || fingerprint(current) !== fingerprint(retired)) return denied()
|
||||
} : undefined
|
||||
return { initial, serverIds, fence, retired, retiredFence }
|
||||
}
|
||||
const receiptTarget = async (raw: Record<string, unknown>, context: ActionContext): Promise<ChangeEvidence["target"]> => {
|
||||
const request = serverProbeReceiptReadSchema.parse(raw), { serverIds, fence } = await historicalAccess(request, context)
|
||||
await fence(); return { scope: "servers", serverIds }
|
||||
const request = serverProbeReceiptReadSchema.parse(raw), { serverIds, fence, retiredFence } = await historicalAccess(request, context)
|
||||
await (retiredFence ?? fence)(); return { scope: "servers", serverIds }
|
||||
}
|
||||
const readPage = async (raw: Record<string, unknown>, context: ActionContext) => {
|
||||
try {
|
||||
const request = serverProbeReceiptReadSchema.parse(raw), { initial, serverIds, fence } = await historicalAccess(request, context)
|
||||
const request = serverProbeReceiptReadSchema.parse(raw), { initial, serverIds, fence, retired, retiredFence } = await historicalAccess(request, context)
|
||||
if (retired && retiredFence) {
|
||||
const source = retired.source
|
||||
const originalContext: ServerProbeJournalContext = {
|
||||
identity: { kind: "mcp", ownerId: source.ownerId, executionId: source.executionId, connectionId: source.connectionId, credentialId: source.originalCredentialId,
|
||||
issuedAt: source.issuedAt, expiresAt: Date.parse(source.authorityExpiresAt), operationName: SERVER_PROBE_OPERATION },
|
||||
sourceServerId: source.sourceServerId, ...(source.destinationServerId !== undefined ? { destinationServerId: source.destinationServerId } : {}), tool: source.tool,
|
||||
}
|
||||
await retiredFence()
|
||||
const reader = deps.history({ assertReadAuthority: retiredFence }, { signal: context.signal })
|
||||
const receipt = await reader.read({ context: originalContext, receiptId: source.receiptId, bindingProof: source.bindingProof })
|
||||
const page = serverProbeReceiptPage(receipt, request.page)
|
||||
await retiredFence()
|
||||
return page
|
||||
}
|
||||
await fence()
|
||||
const rows = await deps.query(`SELECT p.evidence FROM mcp_operations o JOIN mcp_plans p ON p.id=o.plan_id AND p.connection_id=o.connection_id JOIN mcp_connections c ON c.id=o.connection_id WHERE o.id=$1 AND o.connection_id=$2 AND c.owner_id=$3 AND p.operation_id=$4 AND p.operation_version=1 AND octet_length(p.evidence::text)<=2097152 LIMIT 2`, [request.operationId, context.principal.connectionId, initial.ownerId, SERVER_PROBE_OPERATION])
|
||||
await fence(); if (rows.length !== 1) return denied()
|
||||
|
||||
@@ -171,6 +171,16 @@ export async function acceptServerProbeAllToolsSql() {
|
||||
assert.equal(done.result.confirmedWrites, 0); assert.equal(done.result.completedServiceSteps, 1); assert.equal(done.result.verificationScope, "acknowledgement")
|
||||
const receipt = done.result.diagnosticReceipt as { receiptId: string; stageBudget: number; attemptedStages: number; completedStages: number; totalPages: number; sha256: string }
|
||||
assert(receipt); assert.equal(receipt.attemptedStages, receipt.completedStages); assert(receipt.attemptedStages > 0 && receipt.attemptedStages <= receipt.stageBudget)
|
||||
let captured = false
|
||||
for (const until = Date.now()+2000; Date.now()<until;) {
|
||||
const proof = (await pool.query(`SELECT f.source->>'terminalAt'=to_char(GREATEST(o.finished_at,p.expires_at) AT TIME ZONE 'UTC',
|
||||
'YYYY-MM-DD"T"HH24:MI:SS.US"Z"') AS terminal_matches FROM server_probe_terminal_proofs f
|
||||
JOIN mcp_operations o ON o.id=f.operation_id JOIN mcp_plans p ON p.id=o.plan_id
|
||||
WHERE f.operation_id=$1 AND f.receipt_id=$2`, [done.id,receipt.receiptId])).rows[0]
|
||||
if (proof) { assert.equal(proof.terminal_matches,true); captured = true; break }
|
||||
await delay(20)
|
||||
}
|
||||
assert.equal(captured,true,"Owned successful diagnostic did not publish its terminal proof")
|
||||
const step = (await pool.query("SELECT state,result FROM mcp_operation_steps WHERE operation_id=$1", [done.id])).rows
|
||||
assert.equal(step.length, 1); assert.equal(step[0].state, "acknowledged")
|
||||
for (const field of ["output", "data", "contentB64", "bindingCiphertext"]) assert(!Object.hasOwn(step[0].result, field))
|
||||
@@ -201,6 +211,49 @@ export async function acceptServerProbeAllToolsSql() {
|
||||
cases.push(tool + ": actual public plan/approval/apply, receipt metadata, full stage ledger and checksum pages without redispatch")
|
||||
}
|
||||
|
||||
// Force a genuine capture SQL failure after the real diagnostic has
|
||||
// committed. A sequence records the rejected attempt despite rollback.
|
||||
const captureFault = await fixture()
|
||||
const faultPlan = await prepare(captureFault, { tool: "route", target: "127.0.0.1" })
|
||||
await pool.query("CREATE SEQUENCE public.owned_probe_capture_failure")
|
||||
try {
|
||||
await pool.query(`CREATE FUNCTION public.owned_probe_capture_failure() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
IF NEW.source->>'connectionId'=TG_ARGV[0] THEN
|
||||
PERFORM nextval('public.owned_probe_capture_failure');
|
||||
RAISE EXCEPTION 'owned terminal proof insertion refused';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END $$`)
|
||||
try {
|
||||
assert.match(captureFault.principal.connectionId,/^[a-f0-9-]{36}$/)
|
||||
await pool.query(`CREATE TRIGGER owned_probe_capture_failure BEFORE INSERT ON public.server_probe_terminal_proofs
|
||||
FOR EACH ROW EXECUTE FUNCTION public.owned_probe_capture_failure('${captureFault.principal.connectionId}')`)
|
||||
try {
|
||||
const beforeFault = requests+dnsUdp+dnsTcp, faultKey=randomUUID()
|
||||
const applied = await applyPlan(captureFault.principal,faultPlan,faultKey), done=await terminal(applied.id)
|
||||
let attempted = false
|
||||
for (const until=Date.now()+2500;Date.now()<until;) {
|
||||
attempted = (await pool.query("SELECT is_called FROM public.owned_probe_capture_failure")).rows[0].is_called
|
||||
if (attempted) break
|
||||
await delay(20)
|
||||
}
|
||||
assert.equal(attempted,true,"Owned post-COMMIT capture insert must have reached the injected SQL failure")
|
||||
assert.equal(done.state,"applied"); assert.equal(done.error_code,null)
|
||||
assert.equal((await pool.query("SELECT 1 FROM server_probe_terminal_proofs WHERE operation_id=$1",[applied.id])).rows.length,0)
|
||||
const originalReceipt = done.result.diagnosticReceipt as { receiptId:string; sha256:string }
|
||||
const page = await invokeMcpTool(captureFault.principal,"mm_server_probe_receipt_get",
|
||||
{ operationId:applied.id,receiptId:originalReceipt.receiptId,page:0 }) as {sha256:string}
|
||||
assert.equal(page.sha256,originalReceipt.sha256)
|
||||
assert.equal((await applyPlan(captureFault.principal,faultPlan,faultKey)).id,applied.id)
|
||||
assert.equal((await terminal(applied.id)).state,"applied")
|
||||
assert.equal(requests+dnsUdp+dnsTcp,beforeFault+1)
|
||||
assert.equal((await pool.query("SELECT last_value::int AS attempts FROM public.owned_probe_capture_failure")).rows[0].attempts,1)
|
||||
} finally { await pool.query("DROP TRIGGER owned_probe_capture_failure ON public.server_probe_terminal_proofs") }
|
||||
} finally { await pool.query("DROP FUNCTION public.owned_probe_capture_failure()") }
|
||||
} finally { await pool.query("DROP SEQUENCE public.owned_probe_capture_failure") }
|
||||
cases.push("terminal proof SQL refusal: acknowledged result remains applied and readable, exactly one loopback measurement and no retry")
|
||||
|
||||
// Pause only the one runner's pending->running SQL BEFORE sending it. This
|
||||
// leaves a real pending approved source, allowing genuine durable cancel.
|
||||
// A server-table lock after 'running' would have no intent step yet and the
|
||||
@@ -209,8 +262,9 @@ export async function acceptServerProbeAllToolsSql() {
|
||||
const entered = gate(), resume = gate(); releasePending = resume.resolve
|
||||
let heldOperation = "", held = false
|
||||
pool.query = (async function (...args: unknown[]) {
|
||||
if (!held && typeof args[0] === "string" && args[0].startsWith("UPDATE mcp_operations SET state='running'")) {
|
||||
held = true; heldOperation = String((args[1] as unknown[])[0]); entered.resolve(); await resume.promise
|
||||
const query = args[0] as { text?: string; values?: unknown[] } | undefined
|
||||
if (!held && query?.text?.startsWith("/* mcp-operation-root:claim */") && typeof query.values?.[0] === "string") {
|
||||
held = true; heldOperation = query.values[0]; entered.resolve(); await resume.promise
|
||||
}
|
||||
return Reflect.apply(originalQuery, pool, args)
|
||||
}) as typeof pool.query
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { createMcpServerProbeAuthority, createMcpServerProbeReadAuthority } from "../../services/server-probe-authority.js"
|
||||
import { createProductionServerProbeExecutor } from "../../services/server-probe-execution.js"
|
||||
import { createServerProbeStorageHistoryReader } from "../../services/server-probe-storage-history.js"
|
||||
import { findRetiredServerProbeHistory } from "../../services/server-probe-retired-history.js"
|
||||
import { createSqlServerProbeSourceReaders } from "../../services/server-probe-storage-source.js"
|
||||
import { createProbeStorageHostIo } from "../../services/server-probe-storage-host-io.js"
|
||||
import { assertVerifiedConfigurationAccess } from "../../services/config-mutation-policy.js"
|
||||
@@ -28,6 +29,7 @@ export function createStorageServerProbeMcpAdapter(limits: ProbeStorageLimits =
|
||||
},
|
||||
}
|
||||
const adapter = createServerProbeMcpAdapter({ query: io.rows, journal: io.journal,
|
||||
retiredSource: lookup => findRetiredServerProbeHistory(io.rows, lookup),
|
||||
authority: (principal, options) => createMcpServerProbeAuthority(principal, options, authorityDependencies),
|
||||
historicalAuthority: (principal, options) => createMcpServerProbeReadAuthority(principal, options, authorityDependencies),
|
||||
history: (dependencies, execution) => createServerProbeStorageHistoryReader({ assertReadAuthority: dependencies.assertReadAuthority,
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { randomUUID } from "node:crypto"
|
||||
import { retainServerProbeStorage } from "../../services/server-probe-retention.js"
|
||||
import { maintainServerProbeOperationRetirement } from "../../services/server-probe-operation-retirement.js"
|
||||
import { collectExpiredServerPollStorage } from "../../services/server-poll-storage-retention.js"
|
||||
import { compactCompletedServerPollStorage } from "../../services/server-poll-terminal-compaction.js"
|
||||
import { retireCompletedServerPollOperations } from "../../services/server-poll-operation-retirement.js"
|
||||
import { pool } from "../../db/index.js"
|
||||
import { sharedDatabaseGate } from "../../services/shared-database-gate.js"
|
||||
import { recoverMcpOperations, verifyStep } from "./runtime.js"
|
||||
import { retireOrdinaryMcpOperations } from "./ordinary-operation-retirement.js"
|
||||
import { audit, getMcpSettings } from "./store.js"
|
||||
import { accessGeneration, decryptSecret, redact } from "./security.js"
|
||||
import { tickRoutingProfiles } from "./routing-profiles.js"
|
||||
@@ -48,11 +51,22 @@ export async function detectMcpDrift() {
|
||||
}
|
||||
}
|
||||
|
||||
const mcpRetentionBatchSize = 1000
|
||||
|
||||
async function retainMcpData() {
|
||||
const settings = await getMcpSettings()
|
||||
await pool.query("DELETE FROM mcp_requests WHERE created_at<now()-make_interval(days=>$1) AND status NOT IN ('running')", [settings.requestsRetentionDays])
|
||||
await pool.query("DELETE FROM mcp_audit WHERE created_at<now()-make_interval(days=>$1)", [settings.auditRetentionDays])
|
||||
await pool.query("DELETE FROM mcp_artifacts WHERE expires_at<now()")
|
||||
await pool.query(`WITH expired AS (
|
||||
SELECT id FROM mcp_requests WHERE created_at<now()-make_interval(days=>$1) AND status NOT IN ('running')
|
||||
ORDER BY created_at,id LIMIT $2 FOR UPDATE
|
||||
) DELETE FROM mcp_requests USING expired WHERE mcp_requests.id=expired.id`, [settings.requestsRetentionDays, mcpRetentionBatchSize])
|
||||
await pool.query(`WITH expired AS (
|
||||
SELECT id FROM mcp_audit WHERE created_at<now()-make_interval(days=>$1)
|
||||
ORDER BY created_at,id LIMIT $2 FOR UPDATE
|
||||
) DELETE FROM mcp_audit USING expired WHERE mcp_audit.id=expired.id`, [settings.auditRetentionDays, mcpRetentionBatchSize])
|
||||
await pool.query(`WITH expired AS (
|
||||
SELECT id FROM mcp_artifacts WHERE expires_at<now()
|
||||
ORDER BY expires_at,id LIMIT $1 FOR UPDATE
|
||||
) DELETE FROM mcp_artifacts USING expired WHERE mcp_artifacts.id=expired.id`, [mcpRetentionBatchSize])
|
||||
await pool.query("DELETE FROM mcp_oauth_requests WHERE expires_at<now()-interval '1 day'")
|
||||
await pool.query("DELETE FROM mcp_budget_buckets WHERE bucket<now()-interval '2 days'")
|
||||
await routingReservationTransaction(db => retainManualInverseChargesWithinOwner(db, settings.requestsRetentionDays))
|
||||
@@ -79,8 +93,11 @@ export async function tickMcpWorkers() {
|
||||
await retainMcpData()
|
||||
await retainServerProbeStorage()
|
||||
await compactCompletedServerPollStorage()
|
||||
await retireCompletedServerPollOperations()
|
||||
await collectExpiredServerPollStorage()
|
||||
await retireOrdinaryMcpOperations()
|
||||
if ((await getMcpSettings()).enabled) { await detectMcpDrift(); await tickRoutingProfiles() }
|
||||
await maintainServerProbeOperationRetirement()
|
||||
} finally {
|
||||
if (locked) try { await lease.query("SELECT pg_advisory_unlock(hashtextextended('mcp-workers',0))") } catch { unsafe = true }
|
||||
lease.release(unsafe)
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { test } from "node:test"
|
||||
import Fastify, { type FastifyInstance, type FastifyPluginAsync } from "fastify"
|
||||
import { readFile } from "node:fs/promises"
|
||||
import { runInNewContext } from "node:vm"
|
||||
import * as crypto from "node:crypto"
|
||||
import { z } from "zod"
|
||||
import ts from "typescript"
|
||||
import { DatabaseRequestGate } from "../services/database-request-gate.js"
|
||||
import * as operation from "../services/system-database-operation.js"
|
||||
|
||||
// Real Fastify + plugin and shared gate/readiness wiring; SQL is inert and
|
||||
// counted. No configured pool, credentials, native process or network is opened.
|
||||
async function fixture() {
|
||||
async function load<T extends object>(file: URL, modules: Record<string, unknown>): Promise<T> {
|
||||
const exports: Record<string, unknown> = {}
|
||||
const compiled = ts.transpileModule(await readFile(file, "utf8"), { 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] }, URL, URLSearchParams, Buffer, Date, Promise, Error, process: { env: {} } })
|
||||
return exports as T
|
||||
}
|
||||
const shared = await load<{ sharedDatabaseGate: DatabaseRequestGate; databaseSchemaReadiness: ReturnType<typeof operation.createDatabaseSchemaReadiness> }>(new URL("../services/shared-database-gate.ts", import.meta.url), {
|
||||
"./database-request-gate.js": { DatabaseRequestGate }, "./system-database-operation.js": operation,
|
||||
})
|
||||
const gate = shared.sharedDatabaseGate as DatabaseRequestGate
|
||||
const readiness = shared.databaseSchemaReadiness as ReturnType<typeof operation.createDatabaseSchemaReadiness>
|
||||
let admissions = 0, sqlCalls = 0, transactions = 0, audits = 0
|
||||
let holdClientRead: (() => Promise<void>) | undefined
|
||||
const events: string[] = [], resource = "http://localhost:8000/mcp", generation = "current-owned-generation"
|
||||
const query = async (sql: string) => {
|
||||
sqlCalls++; events.push(sql.startsWith("INSERT INTO mcp_credentials") ? "credential-write" : "sql")
|
||||
if (sql.startsWith("SELECT id FROM mcp_oauth_clients")) {
|
||||
await holdClientRead?.(); return { rows: [{ id: "owned-client" }] }
|
||||
}
|
||||
if (sql.startsWith("SELECT * FROM mcp_oauth_clients")) return { rows: [{ id: "owned-client", redirect_uris: ["http://localhost/callback"] }] }
|
||||
if (sql.startsWith("SELECT * FROM mcp_credentials")) return { rows: [{ id: "refresh-id", client_id: "owned-client", audience: resource, expires_at: new Date(Date.now() + 600000), revoked_at: null, connection_id: "connection-id", family_id: "family-id" }] }
|
||||
if (sql.startsWith("SELECT * FROM mcp_oauth_clients ORDER")) return { rows: [] }
|
||||
return { rows: [], rowCount: 1 }
|
||||
}
|
||||
const request = gate.request.bind(gate)
|
||||
Object.defineProperty(gate, "request", { value: function<T>(run: () => Promise<T>, cancel?: () => void) { admissions++; return request(run, cancel) } })
|
||||
class McpError extends Error { constructor(readonly code: string, message: string, readonly statusCode = 403) { super(message) } }
|
||||
const oauth = await load<{ mcpOauth: FastifyPluginAsync }>(new URL("./mcp-oauth.ts", import.meta.url), {
|
||||
"node:crypto": crypto, zod: { z }, "../db/index.js": { pool: { query } },
|
||||
"../services/shared-database-gate.js": shared,
|
||||
"../config.js": { env: { authRequired: true, CORS_ORIGIN: "http://localhost:3000" } },
|
||||
"../plugins/auth.js": { requireAuth: async (req: { authUser?: { id: string } }) => { req.authUser = { id: "owned-admin" } } },
|
||||
"../modules/mcp/store.js": {
|
||||
audit: async () => { audits++ }, getConnection: async () => ({ generation, policy: { status: "active", expiresAt: new Date(Date.now() + 600000).toISOString() }, owner_valid_until: new Date(Date.now() + 600000) }),
|
||||
transaction: async (work: (db: { query: typeof query }) => Promise<unknown>) => { transactions++; events.push("transaction-start"); const result = await work({ query }); events.push("transaction-settled"); return result },
|
||||
},
|
||||
"../modules/mcp/security.js": { McpError, accessGeneration: async () => generation, constantEqual: (a: string, b: string) => a === b, randomToken: (kind: string) => kind + "-owned-inert", tokenDigest: (token: string) => crypto.createHash("sha256").update(token).digest("hex") },
|
||||
"./mcp-transport.js": { mcpPublicUrl: () => new URL(resource) },
|
||||
"../modules/mcp/portal-revocation.js": { assertPortalClaimsCurrent: async () => { await query("SELECT invalid_before FROM mcp_owner_revocations") } },
|
||||
})
|
||||
const api = await load<{ installDatabaseRequestGate(app: FastifyInstance): void }>(new URL("../plugins/database-request-gate.ts", import.meta.url), {
|
||||
"../services/shared-database-gate.js": shared,
|
||||
"../services/config-mutation-policy.js": { withConfigurationActor: (_actor: string, work: () => unknown) => work() },
|
||||
})
|
||||
const app = Fastify()
|
||||
api.installDatabaseRequestGate(app)
|
||||
await app.register(oauth.mcpOauth)
|
||||
const publicRequests = () => [
|
||||
{ method: "GET" as const, url: "/oauth/authorize?" + new URLSearchParams({ client_id: "owned-client", redirect_uri: "http://localhost/callback", response_type: "code", code_challenge: "a".repeat(43), code_challenge_method: "S256", resource }).toString() },
|
||||
{ method: "POST" as const, url: "/oauth/token", payload: { grant_type: "refresh_token", client_id: "owned-client", resource, refresh_token: "owned-refresh" } },
|
||||
{ method: "POST" as const, url: "/oauth/revoke", payload: { token: "owned-refresh", client_id: "owned-client" } },
|
||||
]
|
||||
return { app, gate, readiness, events, publicRequests, counts: () => ({ admissions, sqlCalls, transactions, audits }), hold: (work?: () => Promise<void>) => { holdClientRead = work } }
|
||||
}
|
||||
function deferred() { let resolve!: () => void; const promise = new Promise<void>(done => { resolve = done }); return { promise, resolve } }
|
||||
async function unavailable(f: Awaited<ReturnType<typeof fixture>>) {
|
||||
const before = f.counts()
|
||||
for (const options of f.publicRequests()) {
|
||||
const response = await f.app.inject(options)
|
||||
assert.equal(response.statusCode, 503); assert.deepEqual(response.json(), { error: "temporarily_unavailable" }); assert.equal(response.headers["cache-control"], "no-store")
|
||||
}
|
||||
assert.equal(f.counts().sqlCalls, before.sqlCalls); assert.equal(f.counts().transactions, before.transactions); assert.equal(f.counts().audits, before.audits)
|
||||
}
|
||||
|
||||
test("public OAuth authorize/token/revoke refuse before SQL while exclusive maintenance runs", async () => {
|
||||
const f = await fixture(), entered = deferred(), release = deferred()
|
||||
const maintenance = f.gate.exclusive(async () => { entered.resolve(); await release.promise })
|
||||
try { await entered.promise; await unavailable(f) }
|
||||
finally { release.resolve(); await maintenance; await f.app.close() }
|
||||
})
|
||||
test("in-flight actual token transaction settles before exclusive replacement begins", async () => {
|
||||
const f = await fixture(), entered = deferred(), release = deferred()
|
||||
f.hold(async () => { entered.resolve(); await release.promise })
|
||||
const token = f.app.inject(f.publicRequests()[1]).then(response => response)
|
||||
await entered.promise
|
||||
let replacements = 0
|
||||
const maintenance = f.gate.exclusive(async () => { replacements++; f.events.push("replacement"); f.readiness.beginReplacement() })
|
||||
try {
|
||||
await unavailable(f); assert.equal(replacements, 0)
|
||||
release.resolve(); const response = await token; assert.equal(response.statusCode, 200); assert.equal(response.json().access_token, "access-owned-inert")
|
||||
await maintenance; assert.equal(replacements, 1)
|
||||
assert(f.events.indexOf("transaction-settled") < f.events.indexOf("replacement")); assert.equal(f.events.filter(event => event === "credential-write").length, 2)
|
||||
await unavailable(f)
|
||||
} finally { release.resolve(); await token; await maintenance; await f.app.close() }
|
||||
})
|
||||
test("sticky replacement refusal survives exclusive finally and only explicit repair reopens OAuth SQL", async () => {
|
||||
const f = await fixture()
|
||||
try {
|
||||
await f.gate.exclusive(async () => { f.readiness.beginReplacement() })
|
||||
await unavailable(f)
|
||||
await f.gate.exclusive(async () => {})
|
||||
await unavailable(f)
|
||||
await f.gate.exclusive(async () => { f.readiness.confirmSchema() })
|
||||
const responses = []
|
||||
for (const options of f.publicRequests()) responses.push(await f.app.inject(options))
|
||||
assert.deepEqual(responses.map(response => response.statusCode), [302, 200, 200]); assert(f.counts().sqlCalls > 0)
|
||||
} finally { await f.app.close() }
|
||||
})
|
||||
test("both pure discovery endpoints remain200 with no SQL or admission while maintenance is sticky-closed", async () => {
|
||||
const f = await fixture()
|
||||
try {
|
||||
f.readiness.beginReplacement(); const before = f.counts()
|
||||
for (const url of ["/.well-known/oauth-protected-resource", "/.well-known/oauth-authorization-server"]) assert.equal((await f.app.inject(url)).statusCode, 200)
|
||||
assert.deepEqual(f.counts(), before)
|
||||
} finally { await f.app.close() }
|
||||
})
|
||||
test("OAuth admits public SQL once and protects both API admin SQL stages", async () => {
|
||||
const f = await fixture()
|
||||
try {
|
||||
const before = f.counts().admissions
|
||||
assert.equal((await f.app.inject(f.publicRequests()[0])).statusCode, 302)
|
||||
assert.equal(f.counts().admissions - before, 1)
|
||||
const middle = f.counts().admissions
|
||||
assert.equal((await f.app.inject("/api/mcp/oauth/clients")).statusCode, 200)
|
||||
assert.equal(f.counts().admissions - middle, 2)
|
||||
await f.gate.exclusive(async () => {
|
||||
const sqlBefore = f.counts().sqlCalls
|
||||
const response = await f.app.inject("/api/mcp/oauth/clients")
|
||||
assert.equal(response.statusCode, 503)
|
||||
assert.equal(f.counts().sqlCalls, sqlBefore)
|
||||
})
|
||||
} finally { await f.app.close() }
|
||||
})
|
||||
test("ordinary invalid OAuth input preserves its existing protocol error and no-store behavior", async () => {
|
||||
const f = await fixture()
|
||||
try {
|
||||
const response = await f.app.inject({ method: "POST", url: "/oauth/token", payload: {} })
|
||||
assert.equal(response.statusCode, 400); assert.deepEqual(response.json(), { error: "invalid_request" }); assert.equal(response.headers["cache-control"], "no-store"); assert.equal(f.counts().sqlCalls, 0)
|
||||
} finally { await f.app.close() }
|
||||
})
|
||||
@@ -2,6 +2,7 @@ import { createHash, randomUUID } from "node:crypto"
|
||||
import type { FastifyPluginAsync } from "fastify"
|
||||
import { z } from "zod"
|
||||
import { pool } from "../db/index.js"
|
||||
import { sharedDatabaseGate } from "../services/shared-database-gate.js"
|
||||
import { env } from "../config.js"
|
||||
import { requireAuth } from "../plugins/auth.js"
|
||||
import { audit, getConnection, transaction } from "../modules/mcp/store.js"
|
||||
@@ -18,6 +19,15 @@ export function verifyPkce(verifier: string, challenge: string): boolean {
|
||||
return /^[A-Za-z0-9._~-]{43,128}$/.test(verifier) && constantEqual(createHash("sha256").update(verifier).digest("base64url"), challenge)
|
||||
}
|
||||
export const mcpOauth: FastifyPluginAsync = async app => {
|
||||
// Public OAuth SQL is outside /api: own its full handler admission here.
|
||||
// Pure discovery stays available; /api consent/admin use the API gate.
|
||||
app.addHook("onRoute", options => {
|
||||
if (!options.url.startsWith("/oauth/")) return
|
||||
const original = options.handler
|
||||
options.handler = async function (request, reply) {
|
||||
return sharedDatabaseGate.request(async () => await original.call(this, request, reply))
|
||||
}
|
||||
})
|
||||
app.setErrorHandler((error, _request, reply) => {
|
||||
reply.header("Cache-Control", "no-store")
|
||||
if (error instanceof z.ZodError) return reply.code(400).send({ error: "invalid_request" })
|
||||
@@ -99,7 +109,8 @@ export const mcpOauth: FastifyPluginAsync = async app => {
|
||||
if (!env.authRequired) return reply.code(503).send({ error: "Требуется авторизация портала" })
|
||||
await requireAuth(request, reply)
|
||||
if (reply.sent) return
|
||||
await assertPortalClaimsCurrent(request.authUser!)
|
||||
// The generic API gate admits the handler, not this SQL-bearing hook.
|
||||
await sharedDatabaseGate.request(() => assertPortalClaimsCurrent(request.authUser!))
|
||||
if (request.headers.origin && request.headers.origin !== env.CORS_ORIGIN) return reply.code(403).send({ error: "Недопустимый Origin" })
|
||||
})
|
||||
admin.get("/api/mcp/oauth/clients", async () => ({ clients: (await pool.query("SELECT * FROM mcp_oauth_clients ORDER BY created_at DESC LIMIT 100")).rows }))
|
||||
|
||||
@@ -10,6 +10,7 @@ const { default: authPlugin } = await import("../plugins/auth.js")
|
||||
const { createMcpRoutes, mcpDevicePlanBodySchema, mcpDevicePlanArguments } = await import("./mcp.js")
|
||||
const { findAction } = await import("../modules/mcp/actions.js")
|
||||
const { pool } = await import("../db/index.js")
|
||||
const { sharedDatabaseGate } = await import("../services/shared-database-gate.js")
|
||||
const { env } = await import("../config.js")
|
||||
const { mcpPolicyPreviewRequestSchema, mcpCatalogResponseSchema, mcpPolicyPreviewResponseSchema } = await import("@mmapp/contracts/mcp")
|
||||
const { listMcpOperations } = await import("../modules/mcp/registry.js")
|
||||
@@ -37,6 +38,12 @@ const payload = mcpPolicyPreviewRequestSchema.parse({
|
||||
target: { scope: "servers", serverIds: [1] },
|
||||
})
|
||||
try {
|
||||
await sharedDatabaseGate.exclusive(async () => {
|
||||
const before = claimChecks
|
||||
const response = await app.inject({ url: "/api/mcp/catalog", headers: admin })
|
||||
assert.equal(response.statusCode, 503)
|
||||
assert.equal(claimChecks, before, "Maintenance stops the claims hook before SQL")
|
||||
})
|
||||
for (const request of [{ method: "GET" as const, url: "/api/mcp/catalog" }, { method: "POST" as const, url: "/api/mcp/policy/preview", payload }]) {
|
||||
assert.equal((await app.inject(request)).statusCode, 401)
|
||||
assert.equal((await app.inject({ ...request, headers: { authorization: "Bearer arbitrary-agent-token" } })).statusCode, 401)
|
||||
|
||||
@@ -18,12 +18,14 @@ import { accessGeneration, decryptSecret, encryptSecret, McpError, randomToken,
|
||||
import { createMcpConnection, listMcpConnections, getMcpSettings, updateMcpSettings, getConnection, assertConnectionVisible, rotateConnectionToken, setConnectionStatus, updateMcpConnection, transaction, audit } from "../modules/mcp/store.js"
|
||||
import { approvePlan, loadPlan, publicPlan } from "../modules/mcp/runtime.js"
|
||||
import { applyPlan, invokeMcpTool } from "../modules/mcp/runtime.js"
|
||||
import { findOwnerPlanReplayTombstone } from "../modules/mcp/operation-replay-tombstones.js"
|
||||
import { routeAiProfileSchema } from "@mmapp/contracts/mcp-routing"
|
||||
import { revalidateMcpOwner } from "../modules/mcp/store.js"
|
||||
import { resourceById } from "../modules/mcp/resources.js"
|
||||
import { registerManagementChannel } from "../modules/mcp/management.js"
|
||||
import { MAX_RESTORE_ARCHIVE_BYTES, uploadRestoreArchive, readRestoreReceipt } from "../modules/mcp/database-restore.js"
|
||||
import { MAX_RESTORE_ARCHIVE_BYTES, uploadRestoreArchive, readRestoreReceipt, readRestoreReceiptAuditStatus } from "../modules/mcp/database-restore.js"
|
||||
import { assertPortalClaimsCurrent } from "../modules/mcp/portal-revocation.js"
|
||||
import { sharedDatabaseGate } from "../services/shared-database-gate.js"
|
||||
import { readFile } from "node:fs/promises"
|
||||
import { checkMcpProtocol, type ProtocolProbe } from "../modules/mcp/readiness.js"
|
||||
import { humanBackupDownloadMetadata, readHumanBackupDownloadArtifact } from "../modules/mcp/backup-download-artifact.js"
|
||||
@@ -96,12 +98,14 @@ export function createMcpRoutes(checkClaims = assertPortalClaimsCurrent, options
|
||||
// Explicit guard: these routes must stay protected if the global API hook changes.
|
||||
await requireAuth(request, reply)
|
||||
if (reply.sent) return
|
||||
await checkClaims(actorOf(request.authUser))
|
||||
// This hook runs before the API handler's database admission.
|
||||
await sharedDatabaseGate.request(() => checkClaims(actorOf(request.authUser)))
|
||||
if (request.headers.origin && request.headers.origin !== env.CORS_ORIGIN) return reply.code(403).send({ error: "Недопустимый Origin" })
|
||||
})
|
||||
|
||||
app.post("/mcp/database/archives", { bodyLimit: MAX_RESTORE_ARCHIVE_BYTES }, async request => uploadRestoreArchive(request.body as Buffer, actorOf(request.authUser)))
|
||||
app.get("/mcp/database/receipts/:id", async request => readRestoreReceipt(idOf(request.params), actorOf(request.authUser)))
|
||||
app.get("/mcp/database/receipts/:id/audit-status", async request => readRestoreReceiptAuditStatus(idOf(request.params), actorOf(request.authUser)))
|
||||
app.post("/mcp/database/plan", async request => {
|
||||
const { connectionId, archiveRef, expectedSha256 } = z.object({ connectionId: uuid, archiveRef: uuid, expectedSha256: z.string().regex(/^[a-f0-9]{64}$/) }).strict().parse(request.body)
|
||||
const actor = actorOf(request.authUser)
|
||||
@@ -240,7 +244,16 @@ export function createMcpRoutes(checkClaims = assertPortalClaimsCurrent, options
|
||||
return ownerDelegate(async () => invokeMcpTool(principal, action === "recover" ? "mm_operation_recovery_plan" : globalReconcile ? "mm_global_operation_reconcile" : "mm_operation_reconcile", { operationId: id }))
|
||||
})
|
||||
app.post("/mcp/plans/:id/apply", async request => {
|
||||
const plan = await loadPlan(idOf(request.params)), actor = actorOf(request.authUser)
|
||||
const id = idOf(request.params), actor = actorOf(request.authUser)
|
||||
let plan: { id: string; connection_id: string; operation_id: string }
|
||||
try { plan = await loadPlan(id) }
|
||||
catch (error) {
|
||||
if (!(error instanceof McpError) || error.code !== "NOT_FOUND") throw error
|
||||
const tombstone = await findOwnerPlanReplayTombstone(pool, actor.id, id)
|
||||
if (!tombstone) throw error
|
||||
const principal = await ownerPrincipal(tombstone.connection_id, actor, tombstone.action_id)
|
||||
return ownerDelegate(async () => applyPlan(principal, tombstone.plan_id, `web:${tombstone.plan_id}`))
|
||||
}
|
||||
const principal = await ownerPrincipal(plan.connection_id, actor, plan.operation_id)
|
||||
await audit(pool, actor.id, "plan.web.apply", plan.connection_id, { planId: plan.id })
|
||||
return ownerDelegate(async () => applyPlan(principal, plan.id, `web:${plan.id}`))
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import path from 'node:path'
|
||||
import {mkdtemp,mkdir,writeFile,readFile,appendFile,rm,lstat,chmod,symlink} from 'node:fs/promises'
|
||||
import * as filesystem from 'node:fs/promises'
|
||||
import {fileURLToPath} from 'node:url'
|
||||
import {createRequire} from 'node:module'
|
||||
import {randomUUID,createHash} from 'node:crypto'
|
||||
import {runInNewContext} from 'node:vm'
|
||||
import {parseMaintenanceJournal,evaluateMaintenanceAcceptances,runtimeDatabaseTargetSha256,MAINTENANCE_ACCEPTANCE_FORMAT,type MaintenanceAcceptance} from './database-maintenance-resolution.js'
|
||||
import {inspectDatabaseMaintenanceStartup,readMaintenanceRecoveryFacts} from './database-maintenance-startup.js'
|
||||
const here=path.dirname(fileURLToPath(import.meta.url)),backend=path.resolve(here,'../..'),temp=path.resolve(backend,'../tmp'),require=createRequire(import.meta.url),ts=require('typescript'),digest=(v:Buffer|string)=>createHash('sha256').update(v).digest('hex')
|
||||
const target='postgresql://service:old-password@db.example.invalid:5432/managed?sslmode=require'
|
||||
const record=(operationId:string,state:string)=>JSON.stringify({operationId,state,at:'2026-11-01T00:00:00.000Z'})+'\n'
|
||||
const source={systemIdentifier:'1234567890',databaseOid:'12345',databaseName:'managed',serverVersionNum:180004,inRecovery:false as const}
|
||||
const knownCurrent={transport:{mode:'tls' as const,endpointSha256:'f'.repeat(64)},source,schemaSha256:'a'.repeat(64),rawSchemaSha256:'b'.repeat(64),physicalSchemaSha256:'c'.repeat(64),manifestSha256:'d'.repeat(64),markersSha256:'e'.repeat(64)}
|
||||
async function own(work:(root:string,uncertain:string,applied:string)=>Promise<void>){await mkdir(temp,{recursive:true});const root=await mkdtemp(path.join(temp,'owned-maintenance-resolution-'));try{
|
||||
await mkdir(path.join(root,'restore-vault'));await writeFile(path.join(root,'access-generation'),Buffer.alloc(32,7));const uncertain=randomUUID(),applied=randomUUID()
|
||||
await writeFile(path.join(root,'database-maintenance.jsonl'),record(uncertain,'intent')+record(uncertain,'unconfirmed')+record(applied,'intent')+record(applied,'applied'))
|
||||
for(const [id,state]of [[uncertain,'unknown'],[applied,'applied']]){const archiveRef=randomUUID(),at='2026-11-01T00:00:00.000Z';await writeFile(path.join(root,'restore-vault',archiveRef+'.used'),JSON.stringify({operationId:id,at}));await writeFile(path.join(root,'restore-vault',id+'.receipt.json'),JSON.stringify({operationId:id,archiveRef,state,startedAt:at,automaticRetry:false,...(state==='applied'?{finishedAt:at,accessRevoked:true,warnings:[]}:{errorCode:'RESTORE_UNCONFIRMED',finishedAt:at})}))}
|
||||
await work(root,uncertain,applied)
|
||||
}finally{assert(path.dirname(root)===temp&&path.basename(root).startsWith('owned-maintenance-resolution-'));await rm(root,{recursive:true,force:true})}}
|
||||
export async function modeledStartup(){
|
||||
// This is deliberately a MODELED POSIX ownership adapter over actual owned
|
||||
// files on Windows, not evidence that Windows/Linux protection was accepted.
|
||||
const sourceText=await readFile(path.join(backend,'src/services/database-maintenance-startup.ts'),'utf8'),exports:Record<string,unknown>={}
|
||||
const modeledStat=(stat:Awaited<ReturnType<typeof lstat>>)=>new Proxy(stat,{get(object,key){if(key==='uid')return typeof object.uid==='bigint'?0n:0;if(key==='mode')return typeof object.mode==='bigint'?(object.mode&~0o777n)|(object.isDirectory()?0o700n:0o600n):(object.mode&~0o777)|(object.isDirectory()?0o700:0o600);const value=Reflect.get(object,key);return typeof value==='function'?value.bind(object):value}})
|
||||
const fs={...filesystem,lstat:async(...args:Parameters<typeof filesystem.lstat>)=>modeledStat(await Reflect.apply(filesystem.lstat,filesystem,args)),open:async(...args:Parameters<typeof filesystem.open>)=>{const handle=await Reflect.apply(filesystem.open,filesystem,args);return new Proxy(handle,{get(object,key){if(key==='stat')return async(...args:Parameters<typeof handle.stat>)=>modeledStat(await Reflect.apply(handle.stat,handle,args));const value=Reflect.get(object,key);return typeof value==='function'?value.bind(object):value}})}}
|
||||
const code=ts.transpileModule(sourceText,{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2022,esModuleInterop:true}}).outputText
|
||||
runInNewContext(code,{exports,require:(name:string)=>name==='node:fs/promises'?fs:name==='node:process'?{platform:'linux',getuid:()=>0,env:{DATABASE_URL:target}}:require(name),Buffer,TextDecoder,Date,Map,Set,Object,Error})
|
||||
return exports as unknown as {inspectDatabaseMaintenanceStartup:typeof inspectDatabaseMaintenanceStartup;readAppliedRestoreAuditSources:(root:string)=>Promise<readonly {operationId:string}[]>}
|
||||
}
|
||||
async function acceptance(root:string,previousResolutionSha256:string|null=null):Promise<MaintenanceAcceptance>{const facts=await readMaintenanceRecoveryFacts(root);assert(facts.generation);return {format:MAINTENANCE_ACCEPTANCE_FORMAT,resolutionId:randomUUID(),decision:'accept-observed-current-state',historicOutcomeChanged:false,automaticRestore:false,actor:{kind:'host-operator',id:'explicit-test-operator'},approvalSha256:'1'.repeat(64),inspectionSha256:'2'.repeat(64),approvedAt:'2026-11-02T00:00:00.000Z',approvalExpiresAt:'2026-11-02T00:15:00.000Z',acceptedAt:'2026-11-02T00:01:00.000Z',runtimeTargetSha256:runtimeDatabaseTargetSha256(target),previousResolutionSha256,journalCoverage:{prefixBytes:facts.journal.length,sha256:digest(facts.journal),unresolvedOperations:facts.operations.filter(op=>op.state!=='applied')},vaultCoverage:facts.vault.evidence.filter(marker=>facts.vault.unresolvedFiles.includes(marker.file)),generationObservedSha256:digest(facts.generation),knownCurrent}}
|
||||
async function publish(root:string,value:MaintenanceAcceptance){await mkdir(path.join(root,'maintenance-acceptances'),{recursive:true});const raw=Buffer.from(JSON.stringify(value));await writeFile(path.join(root,'maintenance-acceptances',value.resolutionId+'.json'),raw);return raw}
|
||||
|
||||
test('explicit historical acceptance allows restart after approval expiry, preserves unknown and imports only actual applied',()=>own(async(root,uncertain,applied)=>{
|
||||
const modeled=await modeledStartup(),before=await readFile(path.join(root,'restore-vault',uncertain+'.receipt.json'))
|
||||
assert.equal((await modeled.inspectDatabaseMaintenanceStartup(root)).classification,'inspection-required')
|
||||
await publish(root,await acceptance(root));const result=await modeled.inspectDatabaseMaintenanceStartup(root)
|
||||
assert.equal(result.classification,'accepted-current-state');assert.equal((await modeled.inspectDatabaseMaintenanceStartup(root,target.replace('old-password','rotated-password'))).classification,'accepted-current-state');assert.equal(result.acceptedHistoricResolutions,1);assert.equal(result.historicOutcomesChanged,false)
|
||||
assert.equal(JSON.stringify((await modeled.readAppliedRestoreAuditSources(root)).map(value=>value.operationId)),JSON.stringify([applied]));assert.deepEqual(await readFile(path.join(root,'restore-vault',uncertain+'.receipt.json')),before)
|
||||
}))
|
||||
test('completed suffix and legitimate later schema/partition changes do not require a perpetual SQL fingerprint gate',()=>own(async root=>{
|
||||
const modeled=await modeledStartup();await publish(root,await acceptance(root));const later=randomUUID();await appendFile(path.join(root,'database-maintenance.jsonl'),record(later,'intent')+record(later,'applied'))
|
||||
// Reader has no SQL pool/schema query at all. External generation can rotate
|
||||
// without reviving old credentials or invalidating historical acceptance.
|
||||
await writeFile(path.join(root,'access-generation'),Buffer.alloc(32,9));assert.equal((await modeled.inspectDatabaseMaintenanceStartup(root)).classification,'accepted-current-state')
|
||||
await appendFile(path.join(root,'database-maintenance.jsonl'),record(randomUUID(),'intent'));assert.equal((await modeled.inspectDatabaseMaintenanceStartup(root)).classification,'inspection-required')
|
||||
}))
|
||||
test('new explicit acceptance can cover a later uncertainty without altering old records',()=>own(async root=>{
|
||||
const modeled=await modeledStartup(),first=await publish(root,await acceptance(root));await appendFile(path.join(root,'database-maintenance.jsonl'),record(randomUUID(),'intent'))
|
||||
assert.equal((await modeled.inspectDatabaseMaintenanceStartup(root)).classification,'inspection-required');const second=await acceptance(root,digest(first));second.acceptedAt='2026-11-02T00:02:00.000Z';await publish(root,second)
|
||||
assert.equal((await modeled.inspectDatabaseMaintenanceStartup(root)).classification,'accepted-current-state');assert.deepEqual(await readFile(path.join(root,'maintenance-acceptances',JSON.parse(first.toString()).resolutionId+'.json')),first)
|
||||
}))
|
||||
test('covered bytes drift, missing proof, forks and malformed tail never unlock startup',()=>own(async(root,uncertain)=>{
|
||||
const modeled=await modeledStartup(),value=await acceptance(root),raw=await publish(root,value),filename=path.join(root,'maintenance-acceptances',value.resolutionId+'.json'),journal=path.join(root,'database-maintenance.jsonl'),original=await readFile(journal)
|
||||
for(const mutation of ['receipt','tail','record','oversized-record','fork','target','generation']){
|
||||
const receipt=path.join(root,'restore-vault',uncertain+'.receipt.json'),receiptBefore=await readFile(receipt)
|
||||
if(mutation==='receipt')await writeFile(receipt,receiptBefore.toString().replace('RESTORE_UNCONFIRMED','OTHER_UNCONFIRMED'))
|
||||
if(mutation==='tail')await appendFile(journal,'{"operationId":')
|
||||
if(mutation==='record')await writeFile(filename,raw.subarray(0,raw.length-1));if(mutation==='oversized-record')await writeFile(filename,Buffer.alloc(1048577))
|
||||
let forkFile='';if(mutation==='fork'){const fork={...value,resolutionId:randomUUID()};forkFile=path.join(root,'maintenance-acceptances',fork.resolutionId+'.json');await writeFile(forkFile,JSON.stringify(fork))}
|
||||
if(mutation==='generation')await writeFile(path.join(root,'access-generation'),Buffer.alloc(31))
|
||||
const result=await modeled.inspectDatabaseMaintenanceStartup(root,mutation==='target'?target.replace('/managed','/other'):target);assert.equal(result.classification,'inspection-required',mutation)
|
||||
await writeFile(journal,original);await writeFile(filename,raw);await writeFile(receipt,receiptBefore);await writeFile(path.join(root,'access-generation'),Buffer.alloc(32,7));if(forkFile)await rm(forkFile)
|
||||
}
|
||||
}))
|
||||
test('credential rotation is excluded; host/database/TLS identity changes are bound',()=>{
|
||||
const expected=runtimeDatabaseTargetSha256(target);assert.equal(runtimeDatabaseTargetSha256(target.replace('old-password','new-password')),expected)
|
||||
for(const changed of [target.replace('db.example','other.example'),target.replace('/managed','/different'),target.replace('sslmode=require','sslmode=disable')])assert.notEqual(runtimeDatabaseTargetSha256(changed),expected)
|
||||
assert.throws(()=>runtimeDatabaseTargetSha256(target+'&unreviewed_target_option=true'))
|
||||
})
|
||||
test('native Windows never treats modeled POSIX acceptance as real protected authorization',()=>own(async root=>{
|
||||
if(process.platform!=='win32')return
|
||||
await publish(root,await acceptance(root));assert.equal((await inspectDatabaseMaintenanceStartup(root,target)).classification,'inspection-required')
|
||||
}))
|
||||
test('acceptance never accepts torn/duplicate transition grammar or fabricated current proof',()=>own(async root=>{
|
||||
const facts=await readMaintenanceRecoveryFacts(root),recordValue=await acceptance(root),raw=Buffer.from(JSON.stringify(recordValue))
|
||||
assert.throws(()=>parseMaintenanceJournal(Buffer.from(record(randomUUID(),'applied'))));assert.throws(()=>parseMaintenanceJournal(Buffer.from('{bad}')))
|
||||
const wrong={...recordValue,knownCurrent:{...knownCurrent,source:{...source,inRecovery:true}}};assert.throws(()=>evaluateMaintenanceAcceptances([Buffer.from(JSON.stringify(wrong))],facts.journal,facts.vault.evidence,recordValue.runtimeTargetSha256))
|
||||
const empty={...recordValue,journalCoverage:{...recordValue.journalCoverage,unresolvedOperations:[]},vaultCoverage:[]};assert.throws(()=>evaluateMaintenanceAcceptances([Buffer.from(JSON.stringify(empty))],facts.journal,facts.vault.evidence,recordValue.runtimeTargetSha256));assert(evaluateMaintenanceAcceptances([raw],facts.journal,facts.vault.evidence,recordValue.runtimeTargetSha256).acceptedCoreIds.size)
|
||||
}))
|
||||
|
||||
test('without an acceptance file existing completed-history startup gains no new target-parser prerequisite',()=>own(async(root,uncertain,applied)=>{
|
||||
await writeFile(path.join(root,'database-maintenance.jsonl'),record(applied,'intent')+record(applied,'applied'))
|
||||
const receipt=JSON.parse((await readFile(path.join(root,'restore-vault',uncertain+'.receipt.json'))).toString());await rm(path.join(root,'restore-vault',uncertain+'.receipt.json'));await rm(path.join(root,'restore-vault',receipt.archiveRef+'.used'))
|
||||
assert.equal((await inspectDatabaseMaintenanceStartup(root,'postgres:///managed?host=%2Ftmp&options=existing')).classification,'completed-core-maintenance')
|
||||
}))
|
||||
|
||||
test('new unresolved vault marker blocks even without a new core journal record',()=>own(async root=>{
|
||||
const modeled=await modeledStartup();await publish(root,await acceptance(root));const operationId=randomUUID(),archiveRef=randomUUID(),startedAt='2026-11-03T00:00:00.000Z',file=path.join(root,'restore-vault',operationId+'.receipt.json')
|
||||
await writeFile(file,JSON.stringify({operationId,archiveRef,state:'running',startedAt,automaticRetry:false}));assert.equal((await modeled.inspectDatabaseMaintenanceStartup(root)).classification,'inspection-required')
|
||||
await writeFile(file,JSON.stringify({operationId,archiveRef,state:'blocked',startedAt,automaticRetry:false,errorCode:'PERMISSION_DENIED',finishedAt:startedAt}));assert.equal((await modeled.inspectDatabaseMaintenanceStartup(root)).classification,'accepted-current-state')
|
||||
const dangling=randomUUID();await writeFile(path.join(root,'restore-vault',dangling+'.used'),JSON.stringify({operationId:randomUUID(),at:startedAt}));assert.equal((await modeled.inspectDatabaseMaintenanceStartup(root)).classification,'inspection-required')
|
||||
}))
|
||||
|
||||
test('native POSIX immutable fsynced acceptance is consumed by actual startup; mode and symlink drift refuse',async context=>{
|
||||
if(process.platform==='win32'){context.skip('Native POSIX filesystem acceptance must run directly on Linux; Windows model is not proof');return}
|
||||
await own(async root=>{
|
||||
const {writeFile:writeProtected}=await import('../../scripts/writer-profile/operator-files.mjs'),value=await acceptance(root),directory=path.join(root,'maintenance-acceptances'),filename=path.join(directory,value.resolutionId+'.json')
|
||||
await mkdir(directory,{mode:0o700});await writeProtected(filename,Buffer.from(JSON.stringify(value)),{flag:'wx',mode:0o600})
|
||||
assert.equal((await inspectDatabaseMaintenanceStartup(root,target)).classification,'accepted-current-state')
|
||||
await chmod(filename,0o644);assert.equal((await inspectDatabaseMaintenanceStartup(root,target)).classification,'inspection-required');await chmod(filename,0o600)
|
||||
const alias=path.join(directory,randomUUID()+'.json');await symlink(filename,alias);assert.equal((await inspectDatabaseMaintenanceStartup(root,target)).classification,'inspection-required');await rm(alias)
|
||||
const later=randomUUID();await appendFile(path.join(root,'database-maintenance.jsonl'),record(later,'intent')+record(later,'applied'));assert.equal((await inspectDatabaseMaintenanceStartup(root,target)).classification,'accepted-current-state')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,68 @@
|
||||
/** Explicit historical acceptance, not restore success or permanent schema/data admission. */
|
||||
import {createHash} from 'node:crypto'
|
||||
export const MAINTENANCE_ACCEPTANCE_FORMAT='database-maintenance-current-state-acceptance-v1'
|
||||
const sha=(value:Buffer|string)=>createHash('sha256').update(value).digest('hex')
|
||||
const hex=(v:unknown):v is string=>typeof v==='string'&&/^[a-f0-9]{64}$/.test(v)
|
||||
const uuid=(v:unknown):v is string=>typeof v==='string'&&/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/.test(v)
|
||||
function fail():never{throw Error('Explicit maintenance acceptance unavailable or conflicting')}
|
||||
function exact(value:unknown,keys:string[]):asserts value is Record<string,unknown>{if(!value||typeof value!=='object'||Array.isArray(value)||Object.keys(value).sort().join('\0')!==keys.sort().join('\0'))fail()}
|
||||
const instant=(v:unknown):v is string=>typeof v==='string'&&Number.isFinite(Date.parse(v))&&new Date(v).toISOString()===v
|
||||
export function parseMaintenanceJSON(raw:Buffer):unknown{const text=new TextDecoder('utf8',{fatal:true}).decode(raw),value:unknown=JSON.parse(text);if(JSON.stringify(value)!==text.trim())fail();return value}
|
||||
export interface MaintenanceOperation {operationId:string;state:'intent'|'applied'|'unconfirmed'}
|
||||
export function parseMaintenanceJournal(raw:Buffer):MaintenanceOperation[]{
|
||||
if(!raw.length||raw.length>8388608)fail();const text=new TextDecoder('utf8',{fatal:true}).decode(raw);if(!text.endsWith('\n'))fail()
|
||||
const lines=text.slice(0,-1).split('\n');if(lines.length>65536)fail();const operations=new Map<string,MaintenanceOperation['state']>()
|
||||
for(const line of lines){if(!line||Buffer.byteLength(line)>256)fail();const value=parseMaintenanceJSON(Buffer.from(line));exact(value,['operationId','state','at']);if(!uuid(value.operationId)||!instant(value.at))fail()
|
||||
if(value.state==='intent'){if(operations.has(value.operationId))fail();operations.set(value.operationId,'intent')}
|
||||
else if(value.state==='applied'||value.state==='unconfirmed'){if(operations.get(value.operationId)!=='intent')fail();operations.set(value.operationId,value.state)}else fail()
|
||||
}
|
||||
return [...operations].map(([operationId,state])=>({operationId,state}))
|
||||
}
|
||||
export function runtimeDatabaseTargetSha256(raw:string):string{
|
||||
const url=new URL(raw);if(!['postgres:','postgresql:'].includes(url.protocol)||!url.hostname||url.hash)fail()
|
||||
const port=Number(url.port||5432);if(!Number.isInteger(port)||port<1||port>65535)fail();const database=decodeURIComponent(url.pathname.slice(1));if(!database||/[\x00-\x1f\x7f/]/.test(database))fail()
|
||||
const relevant=new Set(['sslmode','ssl','sslrootcert','sslcert','sslkey','channel_binding','gssencmode','target_session_attrs']),ignored=new Set(['user','password','application_name','connect_timeout','statement_timeout','lock_timeout'])
|
||||
const tls:Record<string,string>={};for(const [key,value]of url.searchParams){if(relevant.has(key)){if(Object.hasOwn(tls,key))fail();tls[key]=value}else if(!ignored.has(key))fail()}
|
||||
return sha(JSON.stringify({format:'database-target-selector-v1',host:url.hostname.toLowerCase(),port,database,tls:Object.fromEntries(Object.entries(tls).sort())}))
|
||||
}
|
||||
export interface MaintenanceMarker {file:string;bytes:number;sha256:string}
|
||||
export interface MaintenanceKnownCurrent {transport:{mode:'tls'|'local-loopback';endpointSha256:string};source:{systemIdentifier:string;databaseOid:string;databaseName:string;serverVersionNum:number;inRecovery:false};schemaSha256:string;rawSchemaSha256:string;physicalSchemaSha256:string;manifestSha256:string;markersSha256:string}
|
||||
export interface MaintenanceAcceptance {
|
||||
format:typeof MAINTENANCE_ACCEPTANCE_FORMAT;resolutionId:string;decision:'accept-observed-current-state';historicOutcomeChanged:false;automaticRestore:false;
|
||||
actor:{kind:'host-operator';id:string};approvalSha256:string;inspectionSha256:string;approvedAt:string;approvalExpiresAt:string;acceptedAt:string;
|
||||
runtimeTargetSha256:string;previousResolutionSha256:string|null;journalCoverage:{prefixBytes:number;sha256:string;unresolvedOperations:MaintenanceOperation[]};vaultCoverage:MaintenanceMarker[];generationObservedSha256:string;knownCurrent:MaintenanceKnownCurrent
|
||||
}
|
||||
export function validateMaintenanceKnownCurrent(value:unknown):asserts value is MaintenanceKnownCurrent{
|
||||
exact(value,['transport','source','schemaSha256','rawSchemaSha256','physicalSchemaSha256','manifestSha256','markersSha256']);if(!['schemaSha256','rawSchemaSha256','physicalSchemaSha256','manifestSha256','markersSha256'].every(key=>hex(value[key])))fail()
|
||||
exact(value.transport,['mode','endpointSha256']);if(!['tls','local-loopback'].includes(String(value.transport.mode))||!hex(value.transport.endpointSha256))fail()
|
||||
exact(value.source,['systemIdentifier','databaseOid','databaseName','serverVersionNum','inRecovery']);const s=value.source
|
||||
if(typeof s.systemIdentifier!=='string'||!/^[1-9][0-9]{0,19}$/.test(s.systemIdentifier)||BigInt(s.systemIdentifier)>18446744073709551615n||typeof s.databaseOid!=='string'||!/^[1-9][0-9]{0,9}$/.test(s.databaseOid)||BigInt(s.databaseOid)>4294967295n||typeof s.databaseName!=='string'||!/^[^\x00-\x1f\x7f]{1,63}$/.test(s.databaseName)||!Number.isInteger(s.serverVersionNum)||Number(s.serverVersionNum)<180000||Number(s.serverVersionNum)>=190000||s.inRecovery!==false)fail()
|
||||
}
|
||||
export function validateMaintenanceAcceptance(value:unknown):asserts value is MaintenanceAcceptance{
|
||||
exact(value,['format','resolutionId','decision','historicOutcomeChanged','automaticRestore','actor','approvalSha256','inspectionSha256','approvedAt','approvalExpiresAt','acceptedAt','runtimeTargetSha256','previousResolutionSha256','journalCoverage','vaultCoverage','generationObservedSha256','knownCurrent'])
|
||||
if(value.format!==MAINTENANCE_ACCEPTANCE_FORMAT||!uuid(value.resolutionId)||value.decision!=='accept-observed-current-state'||value.historicOutcomeChanged!==false||value.automaticRestore!==false)fail()
|
||||
exact(value.actor,['kind','id']);if(value.actor.kind!=='host-operator'||typeof value.actor.id!=='string'||!/^[^\x00-\x1f\x7f]{1,160}$/.test(value.actor.id))fail()
|
||||
if(!['approvalSha256','inspectionSha256','runtimeTargetSha256','generationObservedSha256'].every(key=>hex(value[key]))||(value.previousResolutionSha256!==null&&!hex(value.previousResolutionSha256)))fail()
|
||||
if(!instant(value.approvedAt)||!instant(value.approvalExpiresAt)||!instant(value.acceptedAt)||Date.parse(value.approvedAt)>Date.parse(value.acceptedAt)||Date.parse(value.acceptedAt)>=Date.parse(value.approvalExpiresAt))fail()
|
||||
exact(value.journalCoverage,['prefixBytes','sha256','unresolvedOperations']);const coverage=value.journalCoverage
|
||||
if(!Number.isSafeInteger(coverage.prefixBytes)||Number(coverage.prefixBytes)<1||Number(coverage.prefixBytes)>8388608||!hex(coverage.sha256)||!Array.isArray(coverage.unresolvedOperations)||coverage.unresolvedOperations.length>65536)fail()
|
||||
const ids=new Set<string>();for(const operation of coverage.unresolvedOperations){exact(operation,['operationId','state']);if(!uuid(operation.operationId)||!['intent','unconfirmed'].includes(String(operation.state))||ids.has(operation.operationId))fail();ids.add(operation.operationId)}
|
||||
if(!Array.isArray(value.vaultCoverage)||value.vaultCoverage.length>4096)fail();const files=new Set<string>();for(const marker of value.vaultCoverage){exact(marker,['file','bytes','sha256']);if(typeof marker.file!=='string'||!/^([a-f0-9-]{36})\.(?:used|receipt\.json)$/.test(marker.file)||!uuid(marker.file.slice(0,36))||!Number.isSafeInteger(marker.bytes)||Number(marker.bytes)<1||Number(marker.bytes)>65536||!hex(marker.sha256)||files.has(marker.file))fail();files.add(marker.file)}
|
||||
if(!ids.size&&!files.size)fail();validateMaintenanceKnownCurrent(value.knownCurrent)
|
||||
}
|
||||
export function evaluateMaintenanceAcceptances(rawRecords:readonly Buffer[],journal:Buffer,markers:readonly MaintenanceMarker[],targetSha256:string){
|
||||
if(rawRecords.length>64)fail();const records=new Map<string,MaintenanceAcceptance>(),ids=new Set<string>()
|
||||
for(const raw of rawRecords){if(!raw.length||raw.length>1048576)fail();const value=parseMaintenanceJSON(raw);validateMaintenanceAcceptance(value);const digest=sha(raw);if(records.has(digest)||ids.has(value.resolutionId)||value.runtimeTargetSha256!==targetSha256)fail();records.set(digest,value);ids.add(value.resolutionId)
|
||||
const prefix=journal.subarray(0,value.journalCoverage.prefixBytes);if(prefix.length!==value.journalCoverage.prefixBytes||sha(prefix)!==value.journalCoverage.sha256)fail()
|
||||
const unresolved=parseMaintenanceJournal(prefix).filter(op=>op.state!=='applied');if(JSON.stringify(unresolved)!==JSON.stringify(value.journalCoverage.unresolvedOperations))fail()
|
||||
for(const marker of value.vaultCoverage){if(!markers.some(actual=>JSON.stringify(actual)===JSON.stringify(marker)))fail()}
|
||||
}
|
||||
if(!records.size)return {headSha256:null as string|null,acceptedCoreIds:new Set<string>(),acceptedVaultFiles:new Set<string>(),count:0}
|
||||
const parents=new Set([...records.values()].map(value=>value.previousResolutionSha256).filter((v):v is string=>v!==null)),heads=[...records.keys()].filter(key=>!parents.has(key));if(heads.length!==1)fail()
|
||||
let current:string|null=heads[0],child:MaintenanceAcceptance|undefined;const visited=new Set<string>(),acceptedCoreIds=new Set<string>(),acceptedVaultFiles=new Set<string>()
|
||||
while(current!==null){if(visited.has(current))fail();const value=records.get(current);if(!value)fail();visited.add(current)
|
||||
if(child){if(child.journalCoverage.prefixBytes<value.journalCoverage.prefixBytes||Date.parse(child.acceptedAt)<Date.parse(value.acceptedAt)||value.vaultCoverage.some(marker=>!child!.vaultCoverage.some(later=>JSON.stringify(later)===JSON.stringify(marker))))fail()}
|
||||
value.journalCoverage.unresolvedOperations.forEach(op=>acceptedCoreIds.add(op.operationId));value.vaultCoverage.forEach(marker=>acceptedVaultFiles.add(marker.file));child=value;current=value.previousResolutionSha256
|
||||
}
|
||||
if(visited.size!==records.size)fail();return {headSha256:heads[0],acceptedCoreIds,acceptedVaultFiles,count:records.size}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import {mkdtemp,readFile,writeFile,appendFile,mkdir,rm,readdir,link} from 'node:fs/promises'
|
||||
import * as filesystem from 'node:fs/promises'
|
||||
import {randomUUID} from 'node:crypto'
|
||||
import path from 'node:path'
|
||||
import {fileURLToPath,pathToFileURL} from 'node:url'
|
||||
import {spawnSync} from 'node:child_process'
|
||||
import {createRequire} from 'node:module'
|
||||
import {runInNewContext} from 'node:vm'
|
||||
import {inspectDatabaseMaintenanceStartup,assertDatabaseMaintenanceStartup,DatabaseMaintenanceStartupError} from './database-maintenance-startup.js'
|
||||
const testFile=fileURLToPath(import.meta.url),here=path.dirname(testFile),backend=path.resolve(here,'../..'),area=path.resolve(backend,'../tmp'),require=createRequire(import.meta.url),ts=require('typescript')
|
||||
const stamp='2026-11-03T00:00:00.000Z'
|
||||
// Same installed loader as the backend's owned test conventions, resolved to
|
||||
// an absolute file URL rather than assuming a child's package resolution.
|
||||
if(process.argv[2]==='--inspect-owned-maintenance'){
|
||||
const value=await inspectDatabaseMaintenanceStartup(process.argv[3])
|
||||
process.stdout.write(JSON.stringify(value)+'\n');process.exit(value.classification==='inspection-required'?1:0)
|
||||
}
|
||||
const record=(operationId:string,state:string)=>JSON.stringify({operationId,state,at:stamp})+'\n'
|
||||
async function own(work:(root:string)=>Promise<void>){
|
||||
await mkdir(area,{recursive:true});const root=await mkdtemp(path.join(area,'owned-maintenance-startup-'))
|
||||
try{await work(root)}finally{assert(path.dirname(root)===area&&path.basename(root).startsWith('owned-maintenance-startup-'));await rm(root,{recursive:true,force:true})}
|
||||
}
|
||||
async function generation(root:string){await writeFile(path.join(root,'access-generation'),Buffer.alloc(32,7))}
|
||||
async function completed(root:string){const id=randomUUID();await generation(root);await writeFile(path.join(root,'database-maintenance.jsonl'),record(id,'intent')+record(id,'applied'));return id}
|
||||
async function outer(root:string,state:string,used=true,wrongClaim=false){
|
||||
const vault=path.join(root,'restore-vault');await mkdir(vault,{recursive:true})
|
||||
const operationId=randomUUID(),archiveRef=randomUUID()
|
||||
const value:Record<string,unknown>={operationId,archiveRef,state,startedAt:stamp,automaticRetry:false}
|
||||
if(state==='applied')Object.assign(value,{finishedAt:stamp,accessRevoked:true,warnings:['migrationError']})
|
||||
else if(state!=='running')Object.assign(value,{errorCode:'RESTORE_UNCONFIRMED',finishedAt:stamp})
|
||||
await writeFile(path.join(vault,operationId+'.receipt.json'),JSON.stringify(value))
|
||||
if(used)await writeFile(path.join(vault,archiveRef+'.used'),JSON.stringify({operationId:wrongClaim?randomUUID():operationId,at:stamp}))
|
||||
return {vault,operationId,archiveRef}
|
||||
}
|
||||
function child(root:string){
|
||||
const value=spawnSync(process.execPath,['--import',pathToFileURL(require.resolve('tsx')).href,testFile,'--inspect-owned-maintenance',root],{cwd:backend,encoding:'utf8',timeout:10000})
|
||||
assert.equal(value.error,undefined);assert.equal(value.signal,null)
|
||||
return {code:value.status,inspection:JSON.parse(value.stdout)}
|
||||
}
|
||||
test('missing external state is read-only fresh legacy; empty existing state does not create authority',()=>own(async root=>{
|
||||
const absent=path.join(root,'not-created'),before=await readdir(root)
|
||||
assert.equal((await inspectDatabaseMaintenanceStartup(absent)).classification,'fresh-legacy')
|
||||
assert.deepEqual(await readdir(root),before)
|
||||
assert.equal((await inspectDatabaseMaintenanceStartup(root)).classification,'fresh-legacy')
|
||||
assert.deepEqual(await readdir(root),before)
|
||||
}))
|
||||
test('empty journal is only before-intent when an existing external access generation is readable',()=>own(async root=>{
|
||||
await writeFile(path.join(root,'database-maintenance.jsonl'),'')
|
||||
assert.equal((await inspectDatabaseMaintenanceStartup(root)).classification,'inspection-required')
|
||||
await generation(root)
|
||||
const result=await inspectDatabaseMaintenanceStartup(root);assert.equal(result.classification,'empty-before-intent');assert.equal(result.accessGenerationObserved,true)
|
||||
}))
|
||||
test('actual fresh process refuses dangling intent and unconfirmed without replay or rewriting',()=>own(async root=>{
|
||||
await generation(root);const file=path.join(root,'database-maintenance.jsonl'),id=randomUUID()
|
||||
await writeFile(file,record(id,'intent'))
|
||||
const before=await readFile(file)
|
||||
const first=child(root);assert.equal(first.code,1);assert.equal(first.inspection.reason,'UNFINISHED_MAINTENANCE')
|
||||
assert.deepEqual(await readFile(file),before)
|
||||
await appendFile(file,record(id,'unconfirmed'))
|
||||
const unknown=child(root);assert.equal(unknown.code,1);assert.equal(unknown.inspection.reason,'UNCONFIRMED_MAINTENANCE')
|
||||
assert.equal(unknown.inspection.automaticRestore,false)
|
||||
}))
|
||||
test('core applied allows ordinary bootstrap but never infers outer success, audit import or repaired schema',()=>own(async root=>{
|
||||
await completed(root)
|
||||
const result=child(root);assert.equal(result.code,0);assert.equal(result.inspection.classification,'completed-core-maintenance')
|
||||
assert.equal(result.inspection.outerOperationInferred,false);assert.equal(result.inspection.auditImportStatus,'not-verified');assert.equal(result.inspection.postRepairSchemaStatus,'not-inspected')
|
||||
}))
|
||||
for(const state of ['running','unknown'])test('core applied cannot promote outer '+state+' receipt to completed',()=>own(async root=>{
|
||||
await completed(root);await outer(root,state)
|
||||
const result=await inspectDatabaseMaintenanceStartup(root);assert.equal(result.classification,'inspection-required');assert.equal(result.reason,'EXTERNAL_OPERATION_UNRESOLVED')
|
||||
}))
|
||||
test('outer applied receipt requires exact matching persisted used claim',()=>own(async root=>{
|
||||
await completed(root);const f=await outer(root,'applied',true)
|
||||
assert.equal((await inspectDatabaseMaintenanceStartup(root)).outerAppliedReceipts,1)
|
||||
await writeFile(path.join(f.vault,f.archiveRef+'.used'),JSON.stringify({operationId:randomUUID(),at:stamp}))
|
||||
assert.equal((await inspectDatabaseMaintenanceStartup(root)).classification,'inspection-required')
|
||||
}))
|
||||
test('blocked predispatch operation may share an archive later successfully consumed by a different operation',()=>own(async root=>{
|
||||
await completed(root);const a=await outer(root,'blocked',false),operationB=randomUUID()
|
||||
await writeFile(path.join(a.vault,operationB+'.receipt.json'),JSON.stringify({operationId:operationB,archiveRef:a.archiveRef,state:'applied',startedAt:stamp,automaticRetry:false,finishedAt:stamp,accessRevoked:true,warnings:[]}))
|
||||
await writeFile(path.join(a.vault,a.archiveRef+'.used'),JSON.stringify({operationId:operationB,at:stamp}))
|
||||
const result=await inspectDatabaseMaintenanceStartup(root)
|
||||
assert.equal(result.classification,'completed-core-maintenance');assert.equal(result.blockedReceipts,1);assert.equal(result.outerAppliedReceipts,1)
|
||||
await writeFile(path.join(a.vault,a.archiveRef+'.used'),JSON.stringify({operationId:a.operationId,at:stamp}))
|
||||
assert.equal((await inspectDatabaseMaintenanceStartup(root)).classification,'inspection-required')
|
||||
}))
|
||||
test('missing journal with attempt markers is not a new clean install',()=>own(async root=>{
|
||||
await outer(root,'applied',true)
|
||||
const value=await inspectDatabaseMaintenanceStartup(root);assert.equal(value.classification,'inspection-required');assert.equal(value.reason,'JOURNAL_ABSENT_WITH_ATTEMPT_MARKERS')
|
||||
}))
|
||||
test('orphan used claim, unknown publication temporary file and blocked-with-used refuse',()=>own(async root=>{
|
||||
await completed(root);const f=await outer(root,'blocked',true)
|
||||
assert.equal((await inspectDatabaseMaintenanceStartup(root)).classification,'inspection-required')
|
||||
await rm(path.join(f.vault,f.operationId+'.receipt.json'))
|
||||
assert.equal((await inspectDatabaseMaintenanceStartup(root)).reason,'EXTERNAL_OPERATION_UNRESOLVED')
|
||||
await rm(path.join(f.vault,f.archiveRef+'.used'));await writeFile(path.join(f.vault,'receipt-publication.tmp'),'partial')
|
||||
assert.equal((await inspectDatabaseMaintenanceStartup(root)).classification,'inspection-required')
|
||||
}))
|
||||
test('malformed tails, duplicates, unknown fields/states and impossible transitions never admit startup',()=>own(async root=>{
|
||||
await generation(root);const id=randomUUID(),good=record(id,'intent')
|
||||
const bad=[good.slice(0,-1),good+'{',good+record(id,'intent'),good+record(id,'applied')+record(id,'applied'),record(id,'applied'),good+record(id,'future'),JSON.stringify({operationId:id,state:'intent',at:stamp,ready:true})+'\n',`{"operationId":"${id}","state":"intent","state":"applied","at":"${stamp}"}\n`]
|
||||
for(const content of bad){await writeFile(path.join(root,'database-maintenance.jsonl'),content);assert.equal((await inspectDatabaseMaintenanceStartup(root)).classification,'inspection-required')}
|
||||
}))
|
||||
test('journal byte bound refuses without accepting a truncated history',()=>own(async root=>{
|
||||
await generation(root);const file=path.join(root,'database-maintenance.jsonl')
|
||||
await writeFile(file,Buffer.alloc(8*1024*1024+1,32));assert.equal((await inspectDatabaseMaintenanceStartup(root)).classification,'inspection-required')
|
||||
}))
|
||||
test('hardlinked external files are not accepted as complete history',async t=>own(async root=>{
|
||||
await generation(root);const file=path.join(root,'database-maintenance.jsonl')
|
||||
await writeFile(file,record(randomUUID(),'intent'))
|
||||
try{await link(file,path.join(root,'other-link'))}catch(error){if((error as NodeJS.ErrnoException).code==='EPERM'){t.skip('Host filesystem policy refuses creating an owned hardlink');return}throw error}
|
||||
assert.equal((await inspectDatabaseMaintenanceStartup(root)).classification,'inspection-required')
|
||||
}))
|
||||
test('actual patched bootstrap issues no SQL, migration or consumer work before recovery guard',()=>own(async root=>{
|
||||
await generation(root);await writeFile(path.join(root,'database-maintenance.jsonl'),record(randomUUID(),'intent'))
|
||||
const source=await readFile(path.join(backend,'src/db/bootstrap.ts'),'utf8'),events:string[]=[],exports:Record<string,unknown>={}
|
||||
const query=async()=>{events.push('sql');return {rows:[{sqlite_imported_at:'done'}]}},client={query,release:()=>{}}
|
||||
const modules:Record<string,unknown>={
|
||||
'./index.js':{pool:{query,connect:async()=>{events.push('connect');return client}}},
|
||||
'./migrate.js':{applySqlMigrations:async()=>{events.push('migrations')}},
|
||||
'./partitions.js':{dropExpiredPartitions:async()=>{events.push('drop')},ensurePartitionsAround:async()=>{events.push('partitions')}},
|
||||
'./sqlite-import.js':{shouldImportSqlite:async()=>false,sqliteFileLooksPresent:()=>false,importSqliteToPostgres:async()=>{events.push('import')}},
|
||||
'../config.js':{env:{DATABASE_PATH:'synthetic-only'}},'../modules/mcp/security.js':{mcpStateDirectory:()=>root},
|
||||
'../services/database-maintenance-startup.js':{assertDatabaseMaintenanceStartup:async(directory:string)=>{events.push('guard');return assertDatabaseMaintenanceStartup(directory)}},
|
||||
'../services/database-restore-audit.js':{reconcileDatabaseRestoreAudits:async()=>{events.push('audit-reconcile')}},
|
||||
}
|
||||
const code=ts.transpileModule(source,{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2022}}).outputText
|
||||
runInNewContext(code,{exports,require:(name:string)=>{assert(name in modules);return modules[name]},console})
|
||||
const initialize=exports.initDatabase as ()=>Promise<void>
|
||||
await assert.rejects(initialize(),error=>error instanceof DatabaseMaintenanceStartupError)
|
||||
assert.deepEqual(events,['guard'])
|
||||
await writeFile(path.join(root,'database-maintenance.jsonl'),'');events.length=0
|
||||
await initialize();assert.equal(events[0],'guard');assert(events.includes('sql')&&events.includes('migrations'))
|
||||
const main=await readFile(path.join(backend,'src/index.ts'),'utf8')
|
||||
const start=main.indexOf('if (isMain)');assert(start>=0)
|
||||
const body=main.slice(start);assert(body.indexOf('await assertDatabaseMaintenanceStartup')<body.indexOf('await initDatabase()'));assert(body.indexOf('await initDatabase()')<body.indexOf('await app.listen'))
|
||||
}))
|
||||
test('actual journal append between metadata capture and open refuses an unstable snapshot',()=>own(async root=>{
|
||||
await generation(root);const operation=randomUUID(),file=path.join(root,'database-maintenance.jsonl')
|
||||
await writeFile(file,record(operation,'intent'))
|
||||
const source=await readFile(path.join(backend,'src/services/database-maintenance-startup.ts'),'utf8'),exports:Record<string,unknown>={};let changed=false
|
||||
const model={...filesystem,lstat:async(...args:Parameters<typeof filesystem.lstat>)=>{
|
||||
const value=await Reflect.apply(filesystem.lstat,filesystem,args)
|
||||
if(!changed&&args[0]===file){changed=true;await appendFile(file,record(operation,'applied'))}
|
||||
return value
|
||||
}}
|
||||
const code=ts.transpileModule(source,{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2022,esModuleInterop:true}}).outputText
|
||||
runInNewContext(code,{exports,require:(name:string)=>name==='node:fs/promises'?model:require(name),Buffer,TextDecoder,Date,Map,Set,Object,Error})
|
||||
const inspect=exports.inspectDatabaseMaintenanceStartup as typeof inspectDatabaseMaintenanceStartup
|
||||
const value=await inspect(root);assert.equal(changed,true);assert.equal(value.classification,'inspection-required')
|
||||
assert.equal(value.reason,'EXTERNAL_STATE_UNAVAILABLE_OR_MALFORMED')
|
||||
assert.equal((await inspectDatabaseMaintenanceStartup(root)).classification,'completed-core-maintenance','a later distinct stable inspection may observe actual terminal bytes without rewriting them')
|
||||
}))
|
||||
test('existing non-directory or unverifiable ancestor never becomes a missing clean state',()=>own(async root=>{
|
||||
await writeFile(path.join(root,'ordinary-file'),'not-directory')
|
||||
assert.equal((await inspectDatabaseMaintenanceStartup(path.join(root,'ordinary-file','state'))).classification,'inspection-required')
|
||||
const source=await readFile(path.join(backend,'src/services/database-maintenance-startup.ts'),'utf8'),exports:Record<string,unknown>={}
|
||||
const model={...filesystem,realpath:async(file:Parameters<typeof filesystem.realpath>[0])=>{
|
||||
if(file===root)throw Object.assign(Error('Synthetic ancestor became unverifiable'),{code:'ENOENT'})
|
||||
return filesystem.realpath(file)
|
||||
}}
|
||||
const code=ts.transpileModule(source,{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2022,esModuleInterop:true}}).outputText
|
||||
runInNewContext(code,{exports,require:(name:string)=>name==='node:fs/promises'?model:require(name),Buffer,TextDecoder,Date,Map,Set,Object,Error})
|
||||
const inspect=exports.inspectDatabaseMaintenanceStartup as typeof inspectDatabaseMaintenanceStartup
|
||||
assert.equal((await inspect(path.join(root,'not-created-state'))).classification,'inspection-required')
|
||||
}))
|
||||
@@ -0,0 +1,181 @@
|
||||
/** Read-only classification of the EXISTING external maintenance grammar.
|
||||
* No restore, journal rewrite, generation rotation, SQL, role issuer or READY. */
|
||||
import process from 'node:process'
|
||||
import {constants,type BigIntStats} from 'node:fs'
|
||||
import {lstat,open,readdir,realpath} from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import {createHash} from 'node:crypto'
|
||||
import {parseMaintenanceJournal,evaluateMaintenanceAcceptances,runtimeDatabaseTargetSha256,type MaintenanceMarker} from './database-maintenance-resolution.js'
|
||||
|
||||
const MAX_JOURNAL_BYTES=8*1024*1024,MAX_VAULT_ENTRIES=4096,MAX_RECEIPT_BYTES=65536
|
||||
const uuid=/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/
|
||||
type Reason='NONE'|'UNFINISHED_MAINTENANCE'|'UNCONFIRMED_MAINTENANCE'|'EXTERNAL_OPERATION_UNRESOLVED'|'JOURNAL_ABSENT_WITH_ATTEMPT_MARKERS'|'EXTERNAL_STATE_UNAVAILABLE_OR_MALFORMED'
|
||||
export interface MaintenanceStartupInspection {
|
||||
readonly classification:'fresh-legacy'|'empty-before-intent'|'completed-core-maintenance'|'accepted-current-state'|'inspection-required'
|
||||
readonly reason:Reason
|
||||
readonly journalOperations:number
|
||||
readonly coreApplied:number
|
||||
readonly outerAppliedReceipts:number
|
||||
readonly blockedReceipts:number
|
||||
readonly accessGenerationObserved:boolean
|
||||
readonly outerOperationInferred:false
|
||||
readonly auditImportStatus:'not-verified'
|
||||
readonly postRepairSchemaStatus:'not-inspected'
|
||||
readonly acceptedHistoricResolutions:number
|
||||
readonly historicOutcomesChanged:false
|
||||
readonly automaticRestore:false
|
||||
}
|
||||
class Refusal extends Error {constructor(readonly reason:Reason='EXTERNAL_STATE_UNAVAILABLE_OR_MALFORMED'){super('External maintenance state requires inspection')}}
|
||||
function refuse(reason?:Reason):never{throw new Refusal(reason)}
|
||||
function plain(value:unknown):value is Record<string,unknown>{return value!==null&&typeof value==='object'&&!Array.isArray(value)}
|
||||
function exact(value:unknown,keys:readonly string[]):asserts value is Record<string,unknown>{if(!plain(value)||Object.keys(value).sort().join('\0')!==[...keys].sort().join('\0'))refuse()}
|
||||
function id(value:unknown):asserts value is string{if(typeof value!=='string'||!uuid.test(value))refuse()}
|
||||
function instant(value:unknown):asserts value is string{if(typeof value!=='string'||!Number.isFinite(Date.parse(value))||new Date(value).toISOString()!==value)refuse()}
|
||||
function json(raw:Buffer):unknown{
|
||||
const text=new TextDecoder('utf8',{fatal:true}).decode(raw)
|
||||
// All existing writers produce JSON.stringify output. Exact roundtrip also
|
||||
// refuses duplicate keys and ambiguous/trailing non-JSON representations.
|
||||
const value:unknown=JSON.parse(text)
|
||||
if(JSON.stringify(value)!==text.trim())refuse()
|
||||
return value
|
||||
}
|
||||
function identity(s:BigIntStats){return [s.dev,s.ino,s.size,s.mode,s.nlink,s.mtimeNs,s.ctimeNs].map(String).join(':')}
|
||||
async function directory(file:string,optional=false){
|
||||
let s
|
||||
try{s=await lstat(file,{bigint:true})}
|
||||
catch(error){if(optional&&(error as NodeJS.ErrnoException).code==='ENOENT')return null;throw error}
|
||||
if(!s.isDirectory()||s.isSymbolicLink())refuse()
|
||||
// Only initial lstat absence is a new/legacy absence. An existing ancestor
|
||||
// whose realpath disappears or cannot be verified is ambiguous, not fresh.
|
||||
try{if(await realpath(file)!==file)refuse()}catch{refuse()}
|
||||
return identity(s)
|
||||
}
|
||||
async function existingAncestors(file:string){
|
||||
for(let current=path.dirname(file);;current=path.dirname(current)){
|
||||
await directory(current,true)
|
||||
if(current===path.dirname(current))break
|
||||
}
|
||||
}
|
||||
async function bounded(file:string,maximum:number,optional=false){
|
||||
let before
|
||||
try{before=await lstat(file,{bigint:true})}catch(error){if(optional&&(error as NodeJS.ErrnoException).code==='ENOENT')return null;throw error}
|
||||
if(!before.isFile()||before.isSymbolicLink()||before.nlink!==1n||before.size>BigInt(maximum)||await realpath(file)!==file)refuse()
|
||||
const signature=(s:typeof before)=>[s.dev,s.ino,s.size,s.mode,s.nlink,s.mtimeNs,s.ctimeNs].map(String).join(':')
|
||||
const handle=await open(file,constants.O_RDONLY|(constants.O_NOFOLLOW??0))
|
||||
try{
|
||||
const current=await handle.stat({bigint:true});if(signature(current)!==signature(before))refuse()
|
||||
const data=Buffer.alloc(Number(current.size)+1);let size=0
|
||||
while(size<data.length){const got=await handle.read(data,size,data.length-size,size);if(!got.bytesRead)break;size+=got.bytesRead}
|
||||
if(BigInt(size)!==current.size||signature(await handle.stat({bigint:true}))!==signature(before)||signature(await lstat(file,{bigint:true}))!==signature(before))refuse()
|
||||
return data.subarray(0,size)
|
||||
}finally{await handle.close()}
|
||||
}
|
||||
export interface AppliedRestoreAuditSource {readonly operationId:string;readonly archiveRef:string;readonly finishedAt:string;readonly receiptSha256:string;readonly receipt:Readonly<Record<string,unknown>>}
|
||||
interface VaultFacts {markers:number;applied:number;blocked:number;unresolved:boolean;auditSources:readonly AppliedRestoreAuditSource[];evidence:readonly MaintenanceMarker[];unresolvedFiles:readonly string[]}
|
||||
async function vaultFacts(stateDirectory:string):Promise<VaultFacts>{
|
||||
const root=path.join(stateDirectory,'restore-vault'),before=await directory(root,true)
|
||||
if(before===null)return {markers:0,applied:0,blocked:0,unresolved:false,auditSources:[],evidence:[],unresolvedFiles:[]}
|
||||
const auditSources:AppliedRestoreAuditSource[]=[],evidence:MaintenanceMarker[]=[],unresolvedFiles=new Set<string>()
|
||||
const names=(await readdir(root)).sort();if(names.length>MAX_VAULT_ENTRIES)refuse()
|
||||
const signatures=new Map<string,string>(),used=new Map<string,{operationId:string}>(),receipts=new Map<string,{state:string;archiveRef:string}>()
|
||||
for(const name of names){
|
||||
const match=/^([a-f0-9-]{36})\.(used|receipt\.json|archive\.json|archive\.bin)$/.exec(name)
|
||||
if(!match)refuse();id(match[1])
|
||||
const file=path.join(root,name),stat=await lstat(file,{bigint:true});if(!stat.isFile()||stat.isSymbolicLink()||stat.nlink!==1n||await realpath(file)!==file)refuse()
|
||||
signatures.set(name,identity(stat))
|
||||
if(match[2]==='used'){
|
||||
const raw=(await bounded(file,4096))!;evidence.push({file:name,bytes:raw.length,sha256:createHash('sha256').update(raw).digest('hex')});const value=json(raw);exact(value,['operationId','at']);id(value.operationId);instant(value.at)
|
||||
used.set(match[1],{operationId:value.operationId})
|
||||
}else if(match[2]==='receipt.json'){
|
||||
const raw=(await bounded(file,MAX_RECEIPT_BYTES))!,value=json(raw);evidence.push({file:name,bytes:raw.length,sha256:createHash('sha256').update(raw).digest('hex')})
|
||||
if(!plain(value)||!['running','applied','unknown','blocked'].includes(String(value.state)))refuse()
|
||||
const fields=['operationId','archiveRef','state','startedAt','automaticRetry']
|
||||
if(value.state==='applied')fields.push('finishedAt','accessRevoked','warnings')
|
||||
else if(value.state!=='running')fields.push('errorCode','finishedAt')
|
||||
exact(value,fields);id(value.operationId);id(value.archiveRef);instant(value.startedAt)
|
||||
if(value.operationId!==match[1]||value.automaticRetry!==false)refuse()
|
||||
if(value.state!=='running')instant(value.finishedAt)
|
||||
if(value.state==='applied'){
|
||||
const allowed=new Set(['listenerError','schedulerError','cleanupError','migrationError','maintenanceError'])
|
||||
if(value.accessRevoked!==true||!Array.isArray(value.warnings)||value.warnings.length>5||value.warnings.some(w=>typeof w!=='string'||!allowed.has(w))||new Set(value.warnings).size!==value.warnings.length)refuse()
|
||||
auditSources.push(Object.freeze({operationId:value.operationId,archiveRef:value.archiveRef,finishedAt:value.finishedAt as string,receiptSha256:createHash('sha256').update(raw).digest('hex'),receipt:Object.freeze({...value})}))
|
||||
}else if(value.state!=='running'&&(typeof value.errorCode!=='string'||!/^[A-Z][A-Z0-9_]{0,79}$/.test(value.errorCode)))refuse()
|
||||
receipts.set(match[1],{state:String(value.state),archiveRef:value.archiveRef})
|
||||
}
|
||||
}
|
||||
let applied=0,blocked=0,unresolved=false
|
||||
for(const [operationId,receipt]of receipts){
|
||||
const claim=used.get(receipt.archiveRef)
|
||||
if(receipt.state==='applied'){if(!claim||claim.operationId!==operationId)refuse();applied++}
|
||||
else if(receipt.state==='blocked'){if(claim?.operationId===operationId)refuse();blocked++}
|
||||
else{unresolved=true;unresolvedFiles.add(operationId+'.receipt.json')}
|
||||
}
|
||||
for(const [archiveRef,claim]of used){const receipt=receipts.get(claim.operationId);if(!receipt||receipt.archiveRef!==archiveRef||receipt.state!=='applied'){unresolved=true;unresolvedFiles.add(archiveRef+'.used')}}
|
||||
if(await directory(root)!==before||(await readdir(root)).sort().join('\0')!==[...names].sort().join('\0'))refuse()
|
||||
for(const [name,signature]of signatures)if(identity(await lstat(path.join(root,name),{bigint:true}))!==signature)refuse()
|
||||
return {markers:used.size+receipts.size,applied,blocked,unresolved,auditSources:Object.freeze(auditSources),evidence:Object.freeze(evidence),unresolvedFiles:Object.freeze([...unresolvedFiles].sort())}
|
||||
}
|
||||
/** Strict inspection facts; no acceptance decision and no SQL. Operator commands
|
||||
* use the same grammar, not a looser recovery parser. Existing single managed
|
||||
* host/operator assumption applies; bounded scans are not a concurrent fence. */
|
||||
export async function readMaintenanceRecoveryFacts(rawDirectory:string){
|
||||
if(typeof rawDirectory!=='string'||!path.isAbsolute(rawDirectory)||path.resolve(rawDirectory)!==rawDirectory)refuse()
|
||||
await existingAncestors(rawDirectory);const stateBefore=await directory(rawDirectory,true)
|
||||
if(stateBefore===null)return {kind:'fresh-legacy' as const,journal:Buffer.alloc(0),operations:[],vault:{markers:0,applied:0,blocked:0,unresolved:false,auditSources:[],evidence:[],unresolvedFiles:[]} as VaultFacts,generation:null}
|
||||
const journalFile=path.join(rawDirectory,'database-maintenance.jsonl'),raw=await bounded(journalFile,MAX_JOURNAL_BYTES,true),vault=await vaultFacts(rawDirectory)
|
||||
if(await directory(rawDirectory)!==stateBefore)refuse()
|
||||
if(raw===null||raw.length===0){if(vault.markers)refuse('JOURNAL_ABSENT_WITH_ATTEMPT_MARKERS');if(raw===null)return {kind:'fresh-legacy' as const,journal:Buffer.alloc(0),operations:[],vault,generation:null}
|
||||
const generation=await bounded(path.join(rawDirectory,'access-generation'),32);if(generation?.length!==32)refuse();return {kind:'empty-before-intent' as const,journal:raw,operations:[],vault,generation}
|
||||
}
|
||||
const generation=await bounded(path.join(rawDirectory,'access-generation'),32);if(generation?.length!==32)refuse();const operations=parseMaintenanceJournal(raw)
|
||||
if(!(await bounded(journalFile,MAX_JOURNAL_BYTES))!.equals(raw)||await directory(rawDirectory)!==stateBefore)refuse()
|
||||
return {kind:'journal' as const,journal:raw,operations,vault,generation}
|
||||
}
|
||||
/** Acceptance grants startup admission, so POSIX ownership/modes are verified.
|
||||
* Native Windows protection has no implementation and refuses, never modeled. */
|
||||
export async function readMaintenanceAcceptanceRecords(stateDirectory:string):Promise<readonly Buffer[]>{
|
||||
const root=path.join(stateDirectory,'maintenance-acceptances'),before=await directory(root,true);if(before===null)return []
|
||||
if(process.platform==='win32'||typeof process.getuid!=='function')refuse()
|
||||
const uid=BigInt(process.getuid()),protectedStat=async(file:string,isDirectory:boolean)=>{const s=await lstat(file,{bigint:true});if(s.uid!==uid||(s.mode&0o077n)!==0n||(isDirectory?!s.isDirectory():!s.isFile()))refuse()}
|
||||
await protectedStat(root,true)
|
||||
for(let current=path.dirname(root);;current=path.dirname(current)){const stat=await lstat(current,{bigint:true});if(stat.isSymbolicLink()||!stat.isDirectory()||(stat.uid!==uid&&stat.uid!==0n)||((stat.mode&0o022n)!==0n&&!(stat.uid===0n&&(stat.mode&0o1000n)!==0n)))refuse();if(current===path.dirname(current))break}
|
||||
const names=(await readdir(root)).sort();if(names.length>64)refuse();const records:Buffer[]=[]
|
||||
for(const name of names){if(!/^[a-f0-9-]{36}\.json$/.test(name))refuse();id(name.slice(0,36));const file=path.join(root,name);await protectedStat(file,false);const raw=(await bounded(file,1048576))!,value=json(raw);if(!plain(value)||value.resolutionId!==name.slice(0,36))refuse();records.push(raw)}
|
||||
if(await directory(root)!==before||(await readdir(root)).sort().join('\0')!==names.join('\0'))refuse();return records
|
||||
}
|
||||
const configuredTarget=()=>process.env.DATABASE_URL??'postgres://mmapp:mmapp@127.0.0.1:5432/mmapp'
|
||||
export async function inspectDatabaseMaintenanceStartup(rawDirectory:string,runtimeTarget=configuredTarget()):Promise<MaintenanceStartupInspection>{
|
||||
const facts={journalOperations:0,coreApplied:0,outerAppliedReceipts:0,blockedReceipts:0,accessGenerationObserved:false,outerOperationInferred:false as const,auditImportStatus:'not-verified' as const,postRepairSchemaStatus:'not-inspected' as const,acceptedHistoricResolutions:0,historicOutcomesChanged:false as const,automaticRestore:false as const}
|
||||
const result=(classification:MaintenanceStartupInspection['classification'],reason:Reason):MaintenanceStartupInspection=>Object.freeze({classification,reason,...facts})
|
||||
try{
|
||||
const state=await readMaintenanceRecoveryFacts(rawDirectory),records=await readMaintenanceAcceptanceRecords(rawDirectory)
|
||||
facts.outerAppliedReceipts=state.vault.applied;facts.blockedReceipts=state.vault.blocked;facts.accessGenerationObserved=state.generation!==null
|
||||
if(state.kind!=='journal'){if(records.length)refuse();return result(state.kind,'NONE')}
|
||||
facts.journalOperations=state.operations.length;facts.coreApplied=state.operations.filter(op=>op.state==='applied').length
|
||||
const accepted=evaluateMaintenanceAcceptances(records,state.journal,state.vault.evidence,records.length?runtimeDatabaseTargetSha256(runtimeTarget):'0'.repeat(64));facts.acceptedHistoricResolutions=accepted.count
|
||||
const unresolved=state.operations.filter(op=>op.state!=='applied'&&!accepted.acceptedCoreIds.has(op.operationId))
|
||||
if(unresolved.some(op=>op.state==='intent'))return result('inspection-required','UNFINISHED_MAINTENANCE')
|
||||
if(unresolved.some(op=>op.state==='unconfirmed'))return result('inspection-required','UNCONFIRMED_MAINTENANCE')
|
||||
if(state.vault.unresolvedFiles.some(file=>!accepted.acceptedVaultFiles.has(file)))return result('inspection-required','EXTERNAL_OPERATION_UNRESOLVED')
|
||||
return result(accepted.count?'accepted-current-state':'completed-core-maintenance','NONE')
|
||||
}catch(error){return result('inspection-required',error instanceof Refusal?error.reason:'EXTERNAL_STATE_UNAVAILABLE_OR_MALFORMED')}
|
||||
}
|
||||
export class DatabaseMaintenanceStartupError extends Error {
|
||||
readonly code='DATABASE_MAINTENANCE_INSPECTION_REQUIRED'
|
||||
constructor(readonly inspection:MaintenanceStartupInspection){super('External database maintenance requires read-only inspection before managed startup')}
|
||||
}
|
||||
export async function assertDatabaseMaintenanceStartup(directory:string,runtimeTarget=configuredTarget()):Promise<MaintenanceStartupInspection>{
|
||||
const result=await inspectDatabaseMaintenanceStartup(directory,runtimeTarget)
|
||||
if(result.classification==='inspection-required')throw new DatabaseMaintenanceStartupError(result)
|
||||
return result
|
||||
}
|
||||
/** Audit recovery consumes the same strict grammar/claims as startup. No core
|
||||
* applied record can manufacture an applied outer receipt or original actor. */
|
||||
export async function readAppliedRestoreAuditSources(directory:string,runtimeTarget=configuredTarget()):Promise<readonly AppliedRestoreAuditSource[]>{
|
||||
await assertDatabaseMaintenanceStartup(directory,runtimeTarget)
|
||||
const before=await vaultFacts(directory)
|
||||
await assertDatabaseMaintenanceStartup(directory,runtimeTarget)
|
||||
const after=await vaultFacts(directory)
|
||||
if(JSON.stringify(before)!==JSON.stringify(after))throw new DatabaseMaintenanceStartupError(await inspectDatabaseMaintenanceStartup(directory))
|
||||
return after.auditSources
|
||||
}
|
||||
@@ -3,14 +3,47 @@ function conflict(message: string): Error & { statusCode: number } {
|
||||
return Object.assign(new Error(message), { statusCode: 409 })
|
||||
}
|
||||
|
||||
type DatabaseAdmission = { active: boolean; kind: "request" | "exclusive"; requestAncestry: boolean; children: Set<Promise<void>> }
|
||||
|
||||
/** Handler lifetime, not socket lifetime: disconnect does not finish an active write. */
|
||||
export class DatabaseRequestGate {
|
||||
private readonly requestContext = new AsyncLocalStorage<boolean>()
|
||||
assertNoRequestContext(): void { if (this.requestContext.getStore()) throw conflict("Scheduler bootstrap inside request context") }
|
||||
private readonly requestContext = new AsyncLocalStorage<DatabaseAdmission>()
|
||||
assertNoRequestContext(): void { if (this.requestContext.getStore()?.requestAncestry) throw conflict("Scheduler bootstrap inside request context") }
|
||||
/** Live private scope only; detached children cannot retain a settled admission. */
|
||||
hasActiveAdmission(): boolean { return this.requestContext.getStore()?.active === true }
|
||||
/** Registration is synchronous; the parent drains this continuation and its
|
||||
* descendants even if the caller deliberately does not await its result. */
|
||||
async withinAdmission<T>(run: () => Promise<T>): Promise<T> {
|
||||
const parent = this.requestContext.getStore()
|
||||
if (!parent?.active) throw conflict("Database admission is no longer active")
|
||||
const admission: DatabaseAdmission = { active: true, kind: parent.kind, requestAncestry: parent.requestAncestry, children: new Set() }
|
||||
let release!: () => void
|
||||
const settled = new Promise<void>(resolve => { release = resolve })
|
||||
parent.children.add(settled)
|
||||
try { return await this.requestContext.run(admission, run) }
|
||||
finally {
|
||||
await this.drainAdmission(admission)
|
||||
parent.children.delete(settled)
|
||||
release()
|
||||
}
|
||||
}
|
||||
private async drainAdmission(admission: DatabaseAdmission): Promise<void> {
|
||||
admission.active = false
|
||||
await Promise.all(admission.children)
|
||||
}
|
||||
private openWaiters = new Set<() => void>()
|
||||
isPaused(): boolean { return this.paused }
|
||||
// Replacement uncertainty survives the temporary exclusive operation.
|
||||
private admissionClosed = false
|
||||
closeAdmission(): void { this.admissionClosed = true }
|
||||
openAdmission(): void { this.admissionClosed = false; this.notifyOpen() }
|
||||
private notifyOpen(): void {
|
||||
if (this.isPaused()) return
|
||||
const waiting = [...this.openWaiters]; this.openWaiters.clear()
|
||||
for (const resolve of waiting) resolve()
|
||||
}
|
||||
isPaused(): boolean { return this.paused || this.admissionClosed }
|
||||
whenOpen(): Promise<void> {
|
||||
if (!this.paused) return Promise.resolve()
|
||||
if (!this.isPaused()) return Promise.resolve()
|
||||
return new Promise(resolve => { this.openWaiters.add(resolve) })
|
||||
}
|
||||
private paused = false
|
||||
@@ -19,10 +52,13 @@ export class DatabaseRequestGate {
|
||||
|
||||
async request<T>(run: () => Promise<T>, cancelStream?: () => void): Promise<T> {
|
||||
if (this.paused) throw conflict("Выполняется операция с базой приложения; повторите запрос позже")
|
||||
if (this.admissionClosed) throw Object.assign(new Error("Схема базы после восстановления не подтверждена; требуется проверка или явное восстановление"), { statusCode: 503 })
|
||||
const lease = Symbol()
|
||||
this.active.set(lease, cancelStream)
|
||||
try { return await this.requestContext.run(true, run) }
|
||||
const admission: DatabaseAdmission = { active: true, kind: "request", requestAncestry: true, children: new Set() }
|
||||
try { return await this.requestContext.run(admission, run) }
|
||||
finally {
|
||||
await this.drainAdmission(admission)
|
||||
this.active.delete(lease)
|
||||
if (!this.active.size) this.idle?.()
|
||||
}
|
||||
@@ -38,13 +74,13 @@ export class DatabaseRequestGate {
|
||||
this.idle = resolve
|
||||
timer = setTimeout(() => reject(conflict("Не удалось дождаться завершения API-запросов; операция с базой отменена")), timeoutMs)
|
||||
})
|
||||
return await run()
|
||||
const admission: DatabaseAdmission = { active: true, kind: "exclusive", requestAncestry: this.requestContext.getStore()?.requestAncestry === true, children: new Set() }
|
||||
try { return await this.requestContext.run(admission, run) } finally { await this.drainAdmission(admission) }
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer)
|
||||
this.idle = undefined
|
||||
this.paused = false
|
||||
const waiting = [...this.openWaiters]; this.openWaiters.clear()
|
||||
for (const resolve of waiting) resolve()
|
||||
this.notifyOpen()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import {mkdtemp,mkdir,writeFile,readFile,rm} from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import {fileURLToPath} from 'node:url'
|
||||
import {randomUUID} from 'node:crypto'
|
||||
import {EventEmitter} from 'node:events'
|
||||
import type {Pool} from 'pg'
|
||||
import {createRequire} from 'node:module'
|
||||
import {runInNewContext} from 'node:vm'
|
||||
import {reconcileRestoreReceiptAudit,inspectRestoreReceiptAudit,reconcileDatabaseRestoreAudits} from './database-restore-audit.js'
|
||||
const here=path.dirname(fileURLToPath(import.meta.url)),backend=path.resolve(here,'../..'),temp=path.resolve(backend,'../tmp'),require=createRequire(import.meta.url),ts=require('typescript')
|
||||
async function own(work:(root:string,operation:string,receipt:Record<string,unknown>)=>Promise<void>,old=false){
|
||||
await mkdir(temp,{recursive:true});const root=await mkdtemp(path.join(temp,'owned-restore-audit-'))
|
||||
try{
|
||||
const operation=randomUUID(),archive=randomUUID(),core=randomUUID(),stamp=new Date(Date.now()-(old?181*86400000:1000)).toISOString()
|
||||
const receipt={operationId:operation,archiveRef:archive,state:'applied',startedAt:stamp,automaticRetry:false,finishedAt:stamp,accessRevoked:true,warnings:[]}
|
||||
await writeFile(path.join(root,'access-generation'),Buffer.alloc(32,11));await mkdir(path.join(root,'restore-vault'))
|
||||
await writeFile(path.join(root,'database-maintenance.jsonl'),['intent','applied'].map(state=>JSON.stringify({operationId:core,state,at:stamp})).join('\n')+'\n')
|
||||
await writeFile(path.join(root,'restore-vault',archive+'.used'),JSON.stringify({operationId:operation,at:stamp}))
|
||||
await writeFile(path.join(root,'restore-vault',operation+'.receipt.json'),JSON.stringify(receipt))
|
||||
await work(root,operation,receipt)
|
||||
}finally{assert(path.dirname(root)===temp&&path.basename(root).startsWith('owned-restore-audit-'));await rm(root,{recursive:true,force:true})}
|
||||
}
|
||||
type Row={actor_id:string;connection_id:null;action:string;payload:unknown;created_at?:string}
|
||||
function model(){
|
||||
const rows:Row[]=[],queries:string[]=[],releases:boolean[]=[];let lostCommit=false,insertFailure=false,releaseFailure=false,offFailure=false,inserts=0
|
||||
const pool={connect:async()=>{
|
||||
let pending:Row[]=[]
|
||||
const client=new EventEmitter(),originalOff=client.off.bind(client)
|
||||
return Object.assign(client,{off:(name:string,listener:()=>void)=>{if(offFailure)throw Error('Synthetic listener cleanup failure');return originalOff(name,listener)},release:(bad:boolean)=>{releases.push(bad);if(releaseFailure)throw Error('Synthetic release failure')},query:async(config:{text:string;values?:unknown[]})=>{
|
||||
const text=config.text,values=config.values??[];queries.push(text)
|
||||
if(text.startsWith('SELECT audit_retention'))return {rows:[{days:180,now:String(Date.now())}],command:'SELECT'}
|
||||
if(text.startsWith('SELECT actor_id'))return {rows:rows.filter(row=>(row.payload as {operationId:string}).operationId===values[1]).slice(0,2),command:'SELECT'}
|
||||
if(text.startsWith('INSERT')){if(insertFailure)throw Error('Synthetic audit unavailable');inserts++;pending.push({actor_id:String(values[0]),action:String(values[1]),connection_id:null,payload:JSON.parse(String(values[2])),created_at:String(values[3])});return {rows:[],command:'INSERT'}}
|
||||
if(text==='COMMIT'){rows.push(...pending);pending=[];if(lostCommit){lostCommit=false;throw Error('Synthetic COMMIT delivery loss')}return {rows:[],command:'COMMIT'}}
|
||||
if(text==='ROLLBACK')pending=[]
|
||||
return {rows:[],command:text.split(' ')[0]}
|
||||
}})
|
||||
}} as unknown as Pick<Pool,'connect'>
|
||||
return {pool,rows,queries,releases,inserts:()=>inserts,loseCommit:()=>{lostCommit=true},failInsert:()=>{insertFailure=true},failRelease:()=>{releaseFailure=true},failOff:()=>{offFailure=true}}
|
||||
}
|
||||
test('live and startup audit writers converge through read-first without changing external receipt',()=>own(async(root,id,receipt)=>{
|
||||
const m=model(),file=path.join(root,'restore-vault',id+'.receipt.json'),before=await readFile(file)
|
||||
const live=await reconcileRestoreReceiptAudit(m.pool,root,id,'actual-owner');assert.equal(live.status,'imported');assert.equal(live.originalActorKnown,true)
|
||||
assert.equal(m.rows[0].action,'database.restore.receipt');assert.deepEqual(m.rows[0].payload,receipt);assert.equal(m.rows[0].actor_id,'actual-owner')
|
||||
await reconcileDatabaseRestoreAudits(m.pool,root);assert.equal(m.inserts(),1)
|
||||
const status=await inspectRestoreReceiptAudit(m.pool,root,id);assert.equal(status.status,'already-recorded');assert.equal(m.inserts(),1)
|
||||
assert(m.queries.some(q=>q==='BEGIN ISOLATION LEVEL READ COMMITTED READ ONLY'));assert(m.queries.every(q=>!/^CREATE|^ALTER|^DROP|pg_restore|psql/.test(q)))
|
||||
assert.deepEqual(await readFile(file),before)
|
||||
}))
|
||||
test('legacy missing audit imports exact receipt evidence under system importer without inventing human',()=>own(async(root,id)=>{
|
||||
const m=model();await reconcileDatabaseRestoreAudits(m.pool,root)
|
||||
assert.equal(m.rows.length,1);assert.equal(m.rows[0].actor_id,'database-maintenance-reconciler');assert.equal(m.rows[0].action,'database.restore.receipt.recovered')
|
||||
assert.equal((m.rows[0].payload as {originalActorKnown:boolean}).originalActorKnown,false)
|
||||
assert.equal((await reconcileRestoreReceiptAudit(m.pool,root,id,'actual-owner')).status,'already-recorded');assert.equal(m.inserts(),1)
|
||||
}))
|
||||
test('lost audit COMMIT destroys owner and reconciles observed existing row without another insert',()=>own(async(root,id)=>{
|
||||
const m=model();m.loseCommit()
|
||||
assert.equal((await reconcileRestoreReceiptAudit(m.pool,root,id)).status,'unconfirmed');assert.equal(m.releases[0],true)
|
||||
assert.equal(m.queries.filter(value=>value==='ROLLBACK').length,0,'Uncertain COMMIT owner must be destroyed, not rolled back or reused')
|
||||
assert.equal((await reconcileRestoreReceiptAudit(m.pool,root,id)).status,'already-recorded');assert.equal(m.inserts(),1)
|
||||
}))
|
||||
test('conflicting or duplicate audit evidence is never treated as imported',()=>own(async(root,id)=>{
|
||||
const m=model();m.rows.push({actor_id:'foreign',connection_id:null,action:'database.restore.receipt',payload:{operationId:id,foreign:true}})
|
||||
assert.equal((await reconcileRestoreReceiptAudit(m.pool,root,id)).status,'conflict');assert.equal(m.inserts(),0)
|
||||
await assert.rejects(reconcileDatabaseRestoreAudits(m.pool,root),error=>error instanceof Error&&'code'in error&&error.code==='DATABASE_RESTORE_AUDIT_PENDING')
|
||||
}))
|
||||
test('audit retention is not reset by receipt reimport',()=>own(async(root,id)=>{
|
||||
const m=model();assert.equal((await reconcileRestoreReceiptAudit(m.pool,root,id)).status,'outside-retention');assert.equal(m.inserts(),0)
|
||||
},true))
|
||||
test('audit outage remains pending and never overwrites fsynced applied receipt',()=>own(async(root,id)=>{
|
||||
const m=model();m.failInsert();const file=path.join(root,'restore-vault',id+'.receipt.json'),before=await readFile(file)
|
||||
assert.equal((await reconcileRestoreReceiptAudit(m.pool,root,id)).status,'pending');assert.deepEqual(await readFile(file),before)
|
||||
}))
|
||||
for(const fault of ['release','listener'])test('confirmed audit COMMIT survives '+fault+' cleanup failure without relabeling restore',()=>own(async(root,id)=>{
|
||||
const m=model();if(fault==='release')m.failRelease();else m.failOff()
|
||||
const file=path.join(root,'restore-vault',id+'.receipt.json'),before=await readFile(file)
|
||||
const result=await reconcileRestoreReceiptAudit(m.pool,root,id,'actual-owner')
|
||||
assert.equal(result.status,'imported');assert.equal(result.cleanupUnconfirmed,true);assert.equal(m.rows.length,1)
|
||||
assert.equal(m.releases.length,1,'Listener cleanup failure must not bypass actual owner release')
|
||||
assert.deepEqual(await readFile(file),before)
|
||||
}))
|
||||
for(const legacyImport of [false,true])test('actual bootstrap audit follows migrations and legacy import='+legacyImport+' before business startup',()=>own(async(root)=>{
|
||||
const source=await readFile(path.join(backend,'src/db/bootstrap.ts'),'utf8'),events:string[]=[],exports:Record<string,unknown>={}
|
||||
const query=async()=>{events.push('sql');return {rows:[{sqlite_imported_at:'done'}]}},client={query,release:()=>{}}
|
||||
const modules:Record<string,unknown>={
|
||||
'./index.js':{pool:{query,connect:async()=>client}},'./migrate.js':{applySqlMigrations:async()=>events.push('migrations')},
|
||||
'./partitions.js':{dropExpiredPartitions:async()=>events.push('drop'),ensurePartitionsAround:async()=>events.push('partitions')},
|
||||
'./sqlite-import.js':{shouldImportSqlite:async()=>legacyImport,sqliteFileLooksPresent:()=>false,importSqliteToPostgres:async()=>{events.push('import');return {durationMs:0,tables:{}}}},
|
||||
'../config.js':{env:{DATABASE_PATH:'synthetic'}},'../modules/mcp/security.js':{mcpStateDirectory:()=>root},
|
||||
'../services/database-maintenance-startup.js':{assertDatabaseMaintenanceStartup:async()=>events.push('guard')},
|
||||
'../services/database-restore-audit.js':{reconcileDatabaseRestoreAudits:async()=>{events.push('audit');throw Error('Pending audit import')}},
|
||||
}
|
||||
runInNewContext(ts.transpileModule(source,{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2022}}).outputText,{exports,require:(name:string)=>{assert(name in modules);return modules[name]},console})
|
||||
await assert.rejects((exports.initDatabase as ()=>Promise<void>)(),/Pending audit import/)
|
||||
assert.equal(events[0],'guard');assert.equal(events.at(-1),'audit');assert(events.indexOf('migrations')<events.indexOf('audit'));if(legacyImport)assert(events.indexOf('import')<events.indexOf('audit'))
|
||||
}))
|
||||
test('live audit recovery call is outside the catch that can relabel restore',async()=>{
|
||||
const source=await readFile(path.join(backend,'src/modules/mcp/database-restore.ts'),'utf8'),tree=ts.createSourceFile('restore.ts',source,ts.ScriptTarget.ES2022,true)
|
||||
let callAncestors:unknown[]=[]
|
||||
function visit(node:{kind:number;expression?:{getText:()=>string};getChildren:()=>unknown[]},ancestors:unknown[]=[]){
|
||||
if(node.kind===ts.SyntaxKind.CallExpression&&node.expression?.getText()==='reconcileRestoreReceiptAudit')callAncestors=ancestors
|
||||
for(const child of node.getChildren())visit(child as typeof node,[...ancestors,node])
|
||||
}
|
||||
visit(tree)
|
||||
const tries=callAncestors.filter((node:unknown)=>(node as {kind:number}).kind===ts.SyntaxKind.TryStatement) as {getText:()=>string}[]
|
||||
assert.equal(tries.length,1);assert(!tries[0].getText().includes('state: attempted ? "unknown"'))
|
||||
assert(source.includes('restoreActorId=connection.owner_id'))
|
||||
})
|
||||
@@ -0,0 +1,80 @@
|
||||
/** Specific external receipt -> audit import. Never dispatches restore or
|
||||
* changes journal/generation/receipt. Both live and startup writers use it. */
|
||||
import type {Pool,PoolClient,QueryConfig,QueryResultRow} from 'pg'
|
||||
import {isDeepStrictEqual} from 'node:util'
|
||||
import {env} from '../config.js'
|
||||
import {readAppliedRestoreAuditSources,type AppliedRestoreAuditSource} from './database-maintenance-startup.js'
|
||||
|
||||
const LIVE='database.restore.receipt',RECOVERED='database.restore.receipt.recovered',IMPORTER='database-maintenance-reconciler'
|
||||
export type RestoreAuditStatus='imported'|'already-recorded'|'missing'|'outside-retention'|'pending'|'unconfirmed'|'conflict'
|
||||
export interface RestoreAuditInspection {readonly operationId:string;readonly receiptSha256:string;readonly status:RestoreAuditStatus;
|
||||
/** Recorded audit actor or live validated owner; not cryptographic provenance. */
|
||||
readonly originalActorKnown:boolean;readonly cleanupUnconfirmed:boolean;readonly automaticRestore:false}
|
||||
const boundedQuery=(text:string,values:unknown[]=[]):QueryConfig&{query_timeout:number}=>({text,values,query_timeout:10000})
|
||||
|
||||
type AuditRow=QueryResultRow&{actor_id:string;connection_id:string|null;action:string;payload:unknown}
|
||||
function recoveryPayload(source:AppliedRestoreAuditSource){return {operationId:source.operationId,externalReceiptSha256:source.receiptSha256,originalActorKnown:false,receipt:source.receipt}}
|
||||
function status(source:AppliedRestoreAuditSource,value:RestoreAuditStatus,known=false):RestoreAuditInspection{return Object.freeze({operationId:source.operationId,receiptSha256:source.receiptSha256,status:value,originalActorKnown:known,cleanupUnconfirmed:false,automaticRestore:false})}
|
||||
|
||||
async function one(pool:Pick<Pool,'connect'>,source:AppliedRestoreAuditSource,write:boolean,actorId?:string):Promise<RestoreAuditInspection>{
|
||||
let client:PoolClient|undefined,begun=false,commitSent=false,committed=false,bad=false,faulted=false,cleanupUnconfirmed=false,result:RestoreAuditInspection
|
||||
const fault=()=>{faulted=true}
|
||||
const check=()=>{if(faulted)throw Error('Audit owner unavailable')}
|
||||
try{
|
||||
result=await(async()=>{
|
||||
client=await pool.connect();client.on('error',fault);client.on('end',fault)
|
||||
const query=async<R extends QueryResultRow=QueryResultRow>(text:string,values:unknown[]=[])=>{check();const result=await client!.query<R>(boundedQuery(text,values));check();return result}
|
||||
await query(write?'BEGIN ISOLATION LEVEL READ COMMITTED READ WRITE':'BEGIN ISOLATION LEVEL READ COMMITTED READ ONLY');begun=true
|
||||
await query("SET LOCAL statement_timeout='10s'");await query("SET LOCAL lock_timeout='5s'")
|
||||
await query('SELECT pg_catalog.pg_advisory_xact_lock(pg_catalog.hashtextextended($1,0))',['mcp-restore-audit:'+source.operationId])
|
||||
const settings=await query<{days:number;now:string}>('SELECT audit_retention_days AS days,(EXTRACT(EPOCH FROM pg_catalog.clock_timestamp())*1000)::bigint::text AS now FROM public.mcp_settings WHERE id=1 LIMIT 2')
|
||||
const config=settings.rows[0],finished=Date.parse(source.finishedAt)
|
||||
if(settings.rows.length!==1||!config||!Number.isInteger(config.days)||config.days<30||config.days>3650||!/^\d{10,16}$/.test(config.now)||!Number.isSafeInteger(Number(config.now))||!Number.isFinite(finished)||finished>Number(config.now)+5000)throw Error('Audit retention/clock unavailable')
|
||||
if(finished<Number(config.now)-config.days*86400000){const end=await query('ROLLBACK');if(end.command!=='ROLLBACK')throw Error('Audit rollback unconfirmed');begun=false;return status(source,'outside-retention')}
|
||||
const existing=await query<AuditRow>("SELECT actor_id,connection_id,action,payload FROM public.mcp_audit WHERE action=ANY($1::text[]) AND payload->>'operationId'=$2 ORDER BY id LIMIT 2",[[LIVE,RECOVERED],source.operationId])
|
||||
if(existing.rows.length>1)throw Object.assign(Error('Audit receipt conflict'),{auditConflict:true})
|
||||
if(existing.rows.length){
|
||||
const row=existing.rows[0]
|
||||
const live=row.action===LIVE&&typeof row.actor_id==='string'&&row.actor_id.length>0&&row.connection_id===null&&isDeepStrictEqual(row.payload,source.receipt)&&(!actorId||row.actor_id===actorId)
|
||||
const recovered=row.action===RECOVERED&&row.actor_id===IMPORTER&&row.connection_id===null&&isDeepStrictEqual(row.payload,recoveryPayload(source))
|
||||
if(!live&&!recovered)throw Object.assign(Error('Audit receipt conflict'),{auditConflict:true})
|
||||
const end=await query('ROLLBACK');if(end.command!=='ROLLBACK')throw Error('Audit rollback unconfirmed');begun=false
|
||||
return status(source,'already-recorded',live)
|
||||
}
|
||||
if(!write){const end=await query('ROLLBACK');if(end.command!=='ROLLBACK')throw Error('Audit rollback unconfirmed');begun=false;return status(source,'missing')}
|
||||
if(actorId!==undefined&&(typeof actorId!=='string'||!actorId))throw Error('Audit actor unavailable')
|
||||
const actor=actorId??IMPORTER,action=actorId?LIVE:RECOVERED,payload=actorId?source.receipt:recoveryPayload(source)
|
||||
await query('INSERT INTO public.mcp_audit(actor_id,action,connection_id,payload,created_at) VALUES($1,$2,NULL,$3::jsonb,$4::timestamptz)',[actor,action,JSON.stringify(payload),source.finishedAt])
|
||||
commitSent=true;const end=await client!.query(boundedQuery('COMMIT'));if(end.command!=='COMMIT')throw Error('Audit COMMIT acknowledgement unavailable');committed=true;begun=false
|
||||
if(faulted){bad=true;cleanupUnconfirmed=true}
|
||||
return status(source,'imported',!!actorId)
|
||||
})()
|
||||
}catch(error){
|
||||
bad=true
|
||||
result=status(source,commitSent&&!committed?'unconfirmed':error&&typeof error==='object'&&'auditConflict'in error?'conflict':'pending')
|
||||
}finally{
|
||||
if(client){
|
||||
if(begun&&!commitSent&&!faulted)try{const end=await client.query(boundedQuery('ROLLBACK'));if(end.command!=='ROLLBACK'){bad=true;cleanupUnconfirmed=true}}catch{bad=true;cleanupUnconfirmed=true}
|
||||
try{client.off('error',fault)}catch{bad=true;cleanupUnconfirmed=true}
|
||||
try{client.off('end',fault)}catch{bad=true;cleanupUnconfirmed=true}
|
||||
try{client.release(bad||faulted||commitSent&&!committed)}catch{cleanupUnconfirmed=true}
|
||||
}
|
||||
}
|
||||
return cleanupUnconfirmed?Object.freeze({...result,cleanupUnconfirmed:true}):result
|
||||
}
|
||||
export async function reconcileRestoreReceiptAudit(pool:Pick<Pool,'connect'>,directory:string,operationId:string,actorId?:string):Promise<RestoreAuditInspection>{
|
||||
const sources=await readAppliedRestoreAuditSources(directory,env.DATABASE_URL),source=sources.find(value=>value.operationId===operationId)
|
||||
if(!source)throw Error('Confirmed external receipt unavailable')
|
||||
return one(pool,source,true,actorId)
|
||||
}
|
||||
export async function inspectRestoreReceiptAudit(pool:Pick<Pool,'connect'>,directory:string,operationId:string):Promise<RestoreAuditInspection>{
|
||||
const sources=await readAppliedRestoreAuditSources(directory,env.DATABASE_URL),source=sources.find(value=>value.operationId===operationId)
|
||||
if(!source)throw Error('Confirmed external receipt unavailable')
|
||||
return one(pool,source,false)
|
||||
}
|
||||
export async function reconcileDatabaseRestoreAudits(pool:Pick<Pool,'connect'>,directory:string):Promise<void>{
|
||||
for(const source of await readAppliedRestoreAuditSources(directory,env.DATABASE_URL)){
|
||||
const result=await one(pool,source,true)
|
||||
if(result.cleanupUnconfirmed||!['imported','already-recorded','outside-retention'].includes(result.status))throw Object.assign(Error('External restore audit requires reconciliation before managed startup'),{code:'DATABASE_RESTORE_AUDIT_PENDING',auditStatus:result.cleanupUnconfirmed?'cleanup-unconfirmed':result.status})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { test } from "node:test"
|
||||
import { readFile } from "node:fs/promises"
|
||||
import { runInNewContext } from "node:vm"
|
||||
import { AsyncLocalStorage } from "node:async_hooks"
|
||||
import { EventEmitter } from "node:events"
|
||||
import * as crypto from "node:crypto"
|
||||
import { performance } from "node:perf_hooks"
|
||||
import path from "node:path"
|
||||
import os from "node:os"
|
||||
import { z } from "zod"
|
||||
import ts from "typescript"
|
||||
import type { PoolClient } from "pg"
|
||||
import type { McpPrincipal } from "../modules/mcp/store.js"
|
||||
import { DatabaseRequestGate } from "./database-request-gate.js"
|
||||
import * as operation from "./system-database-operation.js"
|
||||
import * as maintenance from "../modules/mcp/database-maintenance.js"
|
||||
|
||||
async function load<T extends object>(file: URL, modules: Record<string, unknown>): Promise<T> {
|
||||
const exports = {}
|
||||
const source = ts.transpileModule(await readFile(file, "utf8"), { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, esModuleInterop: true } }).outputText
|
||||
runInNewContext(source, { exports, require: (name: string) => { assert(name in modules, `Unapproved import ${name}`); return modules[name] }, Buffer, Date, Error, Promise, structuredClone, AbortController, setTimeout, clearTimeout, queueMicrotask, console, process: { env: {} } })
|
||||
return exports as T
|
||||
}
|
||||
function deferred() { let resolve!: () => void; const promise = new Promise<void>(done => { resolve = done }); return { promise, resolve } }
|
||||
async function bounded<T>(work: Promise<T>): Promise<T> {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
try { return await Promise.race([work, new Promise<never>((_, reject) => { timer = setTimeout(() => reject(Error("Owned pure check deadline")), 1500) })]) }
|
||||
finally { clearTimeout(timer) }
|
||||
}
|
||||
const principal: McpPrincipal = { connectionId: "00000000-0000-4000-8000-000000000001", credentialId: "00000000-0000-4000-8000-000000000002", credentialExpiresAt: new Date(Date.now() + 600000).toISOString() }
|
||||
class McpError extends Error { constructor(readonly code: string, message: string, readonly statusCode = 403) { super(message) } }
|
||||
|
||||
async function originFixture(gate: DatabaseRequestGate) {
|
||||
let checkouts = 0, sqlCalls = 0, commits = 0
|
||||
let hold: (() => Promise<void>) | undefined
|
||||
const committed = deferred(), events: string[] = [], generation = Buffer.alloc(32, 71)
|
||||
const cleanupQuery = async (raw: string | { text: string }) => {
|
||||
assert(gate.hasActiveAdmission(), "Cleanup SQL must own live admission")
|
||||
const text = typeof raw === "string" ? raw : raw.text; sqlCalls++; events.push(text)
|
||||
if (text === "COMMIT") { commits++; if (commits === 2) committed.resolve() }
|
||||
return { command: text === "BEGIN" || text === "COMMIT" ? text : "UPDATE", rows: text.startsWith("UPDATE public.mcp_operations") ? [{ plan_id: "owned-plan" }] : [] }
|
||||
}
|
||||
const pool = { connect: async () => { assert(gate.hasActiveAdmission()); checkouts++; await hold?.(); return { query: cleanupQuery, release() {} } } }
|
||||
const connection = { generation: generation.toString("hex"), policy: { version: 1, expiresAt: new Date(Date.now() + 600000).toISOString() }, owner_valid_until: new Date(Date.now() + 600000) }
|
||||
const origin = await load<typeof import("../modules/mcp/operation-execution-origin.js")>(new URL("../modules/mcp/operation-execution-origin.ts", import.meta.url), {
|
||||
"node:async_hooks": { AsyncLocalStorage }, "node:crypto": crypto, "node:path": path, "node:perf_hooks": { performance },
|
||||
"node:fs": { readFileSync: (file: string) => Buffer.alloc(32, file.endsWith("access-generation") ? 71 : 72) },
|
||||
"node:fs/promises": { readFile: async () => Buffer.from(generation) },
|
||||
"@mmapp/contracts/mcp": { mcpTemporaryGrantSchema: { parse: (value: unknown) => value } },
|
||||
"../../db/index.js": { pool }, "../../services/shared-database-gate.js": { sharedDatabaseGate: gate },
|
||||
"../../services/config-mutation-lock.js": { assertNoConfigurationLockContext() {} }, "../../services/operation-config-lock-lease.js": { assertNoOperationConfigurationLockContext() {} },
|
||||
"./store.js": { validatePrincipal: async () => connection, audit: async (db: { query: typeof cleanupQuery }) => db.query("INSERT INTO inert_audit") },
|
||||
"./security.js": { McpError, mcpStateDirectory: () => "/inert-owned-state", fingerprint: () => "inert", decryptSecret: async (value: string) => value, privateFingerprinterExisting: async () => () => "inert-key" },
|
||||
"./actions.js": { findActionByOperation: () => ({ operation: { version: 1 } }) }, "./policy.js": { evaluateMcpPolicy: () => ({ decision: "allowed" }) }, "./change-cost.js": { changeMutationCost: () => 1 },
|
||||
"./temporary-grant-origin.js": { assertOriginalMcpTemporaryGrant: async () => {} },
|
||||
})
|
||||
const mint = async () => {
|
||||
let id = "", planId = ""
|
||||
const result = (command: string, rows: unknown[]) => ({ command, rows, rowCount: rows.length })
|
||||
const client = { query: async (sql: string, args: string[] = []) => {
|
||||
if (sql.startsWith("SELECT pg_current_xact_id")) return result("SELECT", [{ creating_xid: "123" }])
|
||||
if (sql.startsWith("INSERT INTO mcp_operations")) { [id, planId] = args; return result("INSERT", [{ id, plan_id: planId, connection_id: principal.connectionId, state: "pending" }]) }
|
||||
assert(sql.includes("AS creating_transaction")); return result("SELECT", [{ id, plan_id: planId, connection_id: principal.connectionId, creating_transaction: true, operation_id: "users.create", operation_version: 1, policy_version: 1, generation: connection.generation, expires_at: new Date(Date.now() + 600000), evidence: { sealed: "owned-evidence" }, payload: { sealed: "owned-payload" } }])
|
||||
} }
|
||||
const start = origin.beginMcpOperationAdmission(principal)
|
||||
return (await origin.insertMcpOperationAdmission(client as unknown as PoolClient, start, crypto.randomUUID(), crypto.randomUUID(), { proof: "inert", expiresAt: null, monotonicEnd: Infinity, admissionExpiresAt: Date.now() + 600000, admissionMonotonicEnd: performance.now() + 600000 })).admission
|
||||
}
|
||||
return { origin, mint, events, committed, counts: () => ({ checkouts, sqlCalls, commits }), hold: (work?: () => Promise<void>) => { hold = work } }
|
||||
}
|
||||
async function actualBoundedRunner() {
|
||||
const runtime = await readFile(new URL("../modules/mcp/runtime.ts", import.meta.url), "utf8")
|
||||
const begin = runtime.indexOf("let activeRunners = 0"), end = runtime.indexOf("async function policyInput", begin)
|
||||
assert(begin > 0 && end > begin)
|
||||
// Execute the existing private slot owner directly, not a mirrored semaphore.
|
||||
const code = ts.transpileModule(runtime.slice(begin, end) + "\nexports.runBounded = runBounded", { compilerOptions: { target: ts.ScriptTarget.ES2022 } }).outputText
|
||||
const exports = {} as { runBounded(work: () => Promise<void>): Promise<void> }
|
||||
runInNewContext(code, { exports, Promise }); return exports.runBounded
|
||||
}
|
||||
|
||||
test("deferred one-shot cleanup owns no bounded runner slot and emits no SQL until repair admission", async () => {
|
||||
const gate = new DatabaseRequestGate(), f = await originFixture(gate), runBounded = await actualBoundedRunner(), first = await f.mint(), second = await f.mint()
|
||||
gate.closeAdmission()
|
||||
await bounded(Promise.all([runBounded(() => f.origin.retireMcpOperationAdmission(first)), runBounded(() => f.origin.retireMcpOperationAdmission(second))]))
|
||||
assert.deepEqual(f.counts(), { checkouts: 0, sqlCalls: 0, commits: 0 })
|
||||
await bounded(runBounded(() => gate.exclusive(async () => { assert.equal(f.counts().sqlCalls, 0); gate.openAdmission() })))
|
||||
await bounded(f.committed.promise); assert.equal(f.counts().commits, 2)
|
||||
const before = f.counts(); await f.origin.retireMcpOperationAdmission(first); await f.origin.retireMcpOperationAdmission(second); assert.deepEqual(f.counts(), before)
|
||||
})
|
||||
test("cleanup already inside admitted work completes while exclusive drain waits, without inversion", async () => {
|
||||
const gate = new DatabaseRequestGate(), f = await originFixture(gate), cap = await f.mint(), entered = deferred(), release = deferred()
|
||||
f.hold(async () => { entered.resolve(); await release.promise })
|
||||
const request = gate.request(() => f.origin.retireMcpOperationAdmission(cap)); await entered.promise
|
||||
let replaced = false; const exclusive = gate.exclusive(async () => { assert.equal(f.counts().commits, 1); replaced = true })
|
||||
assert.equal(replaced, false); release.resolve(); await bounded(Promise.all([request, exclusive])); assert.equal(replaced, true)
|
||||
})
|
||||
test("temporary exclusive also defers outside cleanup until its callback has completed", async () => {
|
||||
const gate = new DatabaseRequestGate(), f = await originFixture(gate), cap = await f.mint(), entered = deferred(), release = deferred()
|
||||
const exclusive = gate.exclusive(async () => { entered.resolve(); await release.promise }); await entered.promise
|
||||
await bounded(f.origin.retireMcpOperationAdmission(cap)); assert.equal(f.counts().checkouts, 0)
|
||||
release.resolve(); await exclusive
|
||||
await bounded(gate.request(async () => { while (f.counts().commits !== 1) await new Promise<void>(resolve => setTimeout(resolve, 0)) }))
|
||||
})
|
||||
test("settled request ancestry cannot grant cleanup admission or be laundered into scheduler bootstrap", async () => {
|
||||
const gate = new DatabaseRequestGate(), entered = deferred(), release = deferred()
|
||||
let stale: Promise<void> | undefined
|
||||
await gate.request(async () => { stale = (async () => { entered.resolve(); await release.promise; assert.equal(gate.hasActiveAdmission(), false); assert.throws(() => gate.assertNoRequestContext(), /request context/); await gate.exclusive(async () => { assert(gate.hasActiveAdmission()); assert.throws(() => gate.assertNoRequestContext(), /request context/) }) })() })
|
||||
await entered.promise; release.resolve(); await stale
|
||||
await gate.exclusive(async () => { assert(gate.hasActiveAdmission()); assert.doesNotThrow(() => gate.assertNoRequestContext()) })
|
||||
})
|
||||
|
||||
async function serviceFixture(gate: DatabaseRequestGate) {
|
||||
const calls: string[] = [], readiness = operation.createDatabaseSchemaReadiness()
|
||||
const spawn = (command: string) => {
|
||||
calls.push(command); const child = new EventEmitter() as EventEmitter & { stdout: EventEmitter; stderr: EventEmitter; stdin: { end(): void } }
|
||||
child.stdout = new EventEmitter(); child.stderr = new EventEmitter(); child.stdin = { end: () => queueMicrotask(() => child.emit("close", 0)) }; return child
|
||||
}
|
||||
const service = await load<typeof import("./system-database-backup.js")>(new URL("./system-database-backup.ts", import.meta.url), {
|
||||
"node:crypto": crypto, "node:async_hooks": { AsyncLocalStorage }, "node:fs/promises": { open: async () => ({ writeFile: async () => {}, sync: async () => {}, close: async () => {} }), chmod: async () => {}, mkdir: async () => {}, rm: async () => {}, writeFile: async () => {} }, "node:os": os, "node:path": path, "node:child_process": { spawn },
|
||||
"../modules/events/service/events-service.js": { appendEvent: async () => {} }, "./database-dump-connection.js": { runPrivateDatabaseDump: async () => Buffer.alloc(0) }, "./database-control-archive-boundary.js": { DATABASE_CONTROL_DUMP_EXCLUDE_SCHEMA_ARG: "--exclude-schema=mmapp_control" },
|
||||
"./shared-database-gate.js": { sharedDatabaseGate: gate, databaseSchemaReadiness: readiness }, "./config-mutation-policy.js": { assertDatabaseRestoreAllowed: async () => {} }, "../modules/mcp/security.js": { accessGeneration: async () => "inert", invalidateMcpAccess: async () => {}, mcpStateDirectory: () => "/inert-owned-state" }, "../modules/mcp/database-maintenance.js": maintenance,
|
||||
"../db/migrate.js": { applySqlMigrations: async () => { assert(gate.hasActiveAdmission()); calls.push("migrations") } }, "./system-database-restore-execution.js": { validateSystemDatabaseRestoreArchive() {}, executeSystemDatabaseRestore: async (_buffer: Buffer, _url: string, deps: { run(args: string[]): Promise<void> }) => { await deps.run(["owned.dump"]); return {} } },
|
||||
"../config.js": { env: { DATABASE_URL: "inert-owned-database" } }, "../db/index.js": { pool: { connect: () => assert.fail("Native SQL forbidden") } }, "../db/operation-lock.js": { withOperationLock: async (_connect: unknown, _key: number, work: () => Promise<unknown>) => { assert(gate.hasActiveAdmission()); calls.push("operation-lock"); return work() } }, "./system-database-operation.js": operation,
|
||||
"./scheduler.js": { stopSchedulerForDatabaseOperation: async () => {}, restartSchedulerAfterDatabaseOperation: async () => { calls.push("scheduler-restart") } }, "./traffic-flow-ripe.js": { pauseRipeBackgroundWork: async () => {}, resumeRipeBackgroundWork() {} }, "./system-database-cache.js": { invalidateSystemDatabaseCaches() {} },
|
||||
"./system-database-partition-restore.js": { restorePartitionedDatabase: async (_file: string, _url: string, deps: { beforeTargetReplacement(): void }) => { assert(gate.hasActiveAdmission()); assert(gate.isPaused()); deps.beforeTargetReplacement(); calls.push("replacement") } }, "./traffic-flow-ingest.js": { stopTrafficFlowListenerForDatabaseOperation: async () => {}, restartTrafficFlowListenerAfterDatabaseOperation: async () => {} },
|
||||
})
|
||||
return { service, calls }
|
||||
}
|
||||
test("terminal helper grants matching inner restore exactly one exclusive admission and revokes stale handoff", async () => {
|
||||
const gate = new DatabaseRequestGate(), f = await serviceFixture(gate), exclusive = gate.exclusive.bind(gate); let entries = 0
|
||||
Object.defineProperty(gate, "exclusive", { value: <T>(work: () => Promise<T>) => { entries++; return exclusive(work) } })
|
||||
let late: Promise<unknown> | undefined; const release = deferred()
|
||||
await f.service.withSystemDatabaseRestoreAdmission(async () => { await f.service.restoreSystemDatabaseBackup(Buffer.from("PGDMP")); late = release.promise.then(() => f.service.restoreSystemDatabaseBackup(Buffer.from("PGDMP"))) })
|
||||
assert.equal(entries, 1); assert(f.calls.includes("replacement")); release.resolve(); await late; assert.equal(entries, 2)
|
||||
await f.service.withSystemDatabaseBackupAdmission(async () => { await assert.rejects(f.service.restoreSystemDatabaseBackup(Buffer.from("PGDMP")), /уже выполняется/) })
|
||||
})
|
||||
test("actual terminal first pool checkout and claim are inside exclusive, never during another restore", async () => {
|
||||
const gate = new DatabaseRequestGate(), f = await serviceFixture(gate); let checkouts = 0, claims = 0
|
||||
const terminal = await load<typeof import("../modules/mcp/database-restore.js")>(new URL("../modules/mcp/database-restore.ts", import.meta.url), {
|
||||
"node:crypto": crypto, "node:fs/promises": {}, "node:path": path, zod: { z }, "../../db/index.js": { pool: { connect: async () => { assert(gate.hasActiveAdmission()); assert(gate.isPaused()); checkouts++; return { query: async () => { assert(gate.hasActiveAdmission()); claims++; return { rows: [{ locked: false }] } }, release: () => assert(gate.hasActiveAdmission()) } } } },
|
||||
"../../lib/permissions.js": {}, "../../services/system-database-restore-execution.js": {}, "../../services/system-database-backup.js": f.service, "../../services/config-mutation-policy.js": {}, "./security.js": {}, "./store.js": {}, "./state-file-publish.js": {}, "../../services/config-mutation-lock.js": {}, "./temporary-grant-origin.js": {}, "../../services/database-restore-audit.js": {},
|
||||
})
|
||||
const entered = deferred(), release = deferred(), other = gate.exclusive(async () => { entered.resolve(); await release.promise }); await entered.promise
|
||||
await assert.rejects(terminal.executeMcpDatabaseRestore(crypto.randomUUID(), principal), /уже выполняется/); assert.equal(checkouts, 0)
|
||||
release.resolve(); await other
|
||||
await terminal.executeMcpDatabaseRestore(crypto.randomUUID(), principal); assert.equal(checkouts, 1); assert.equal(claims, 1)
|
||||
})
|
||||
|
||||
|
||||
test("detached cleanup begun in a live parent pins admission after that parent callback returns", async () => {
|
||||
const gate = new DatabaseRequestGate(), f = await originFixture(gate), cap = await f.mint()
|
||||
const checkout = deferred(), releaseCheckout = deferred(), parentReturned = deferred()
|
||||
f.hold(async () => { checkout.resolve(); await releaseCheckout.promise })
|
||||
let cleanup!: Promise<void>
|
||||
const request = gate.request(async () => {
|
||||
cleanup = f.origin.retireMcpOperationAdmission(cap)
|
||||
await checkout.promise
|
||||
parentReturned.resolve()
|
||||
// Deliberately do not await the detached cleanup.
|
||||
})
|
||||
await parentReturned.promise
|
||||
let replaced = false
|
||||
const exclusive = gate.exclusive(async () => { assert.equal(f.counts().commits, 1); replaced = true })
|
||||
await new Promise<void>(resolve => setTimeout(resolve, 10))
|
||||
assert.equal(replaced, false); assert.equal(f.counts().sqlCalls, 0)
|
||||
releaseCheckout.resolve()
|
||||
await bounded(Promise.all([request, cleanup, exclusive]))
|
||||
assert.equal(replaced, true)
|
||||
})
|
||||
|
||||
test("detached matching restore handoff pins exclusive through an already-started inner restore", async () => {
|
||||
const gate = new DatabaseRequestGate(), f = await serviceFixture(gate), entered = deferred(), release = deferred()
|
||||
const scoped = gate.withinAdmission.bind(gate)
|
||||
Object.defineProperty(gate, "withinAdmission", { value: <T>(work: () => Promise<T>) => scoped(async () => {
|
||||
entered.resolve(); await release.promise; return work()
|
||||
}) })
|
||||
let inner!: Promise<unknown>
|
||||
const outer = f.service.withSystemDatabaseRestoreAdmission(async () => {
|
||||
inner = f.service.restoreSystemDatabaseBackup(Buffer.from("PGDMP"))
|
||||
await entered.promise
|
||||
})
|
||||
await entered.promise
|
||||
await new Promise<void>(resolve => setTimeout(resolve, 10))
|
||||
assert(gate.isPaused())
|
||||
await assert.rejects(gate.exclusive(async () => {}), /уже выполняется/)
|
||||
assert(!f.calls.includes("operation-lock")); assert(!f.calls.includes("replacement"))
|
||||
release.resolve(); await bounded(Promise.all([outer, inner]))
|
||||
assert(f.calls.includes("replacement")); assert.equal(gate.isPaused(), false)
|
||||
})
|
||||
|
||||
|
||||
test("retained child rejection preserves drain and never grants a stale child admission", async () => {
|
||||
const gate = new DatabaseRequestGate(), started = deferred(), release = deferred()
|
||||
let child!: Promise<void>, late!: () => Promise<unknown>
|
||||
const request = gate.request(async () => {
|
||||
child = gate.withinAdmission(async () => {
|
||||
const inherited = AsyncLocalStorage.snapshot()
|
||||
late = () => inherited(() => gate.withinAdmission(async () => assert.fail("Stale work cannot run")))
|
||||
started.resolve(); await release.promise; throw Error("Expected child rejection")
|
||||
})
|
||||
void child.catch(() => {})
|
||||
await started.promise
|
||||
})
|
||||
await started.promise
|
||||
let replaced = false; const exclusive = gate.exclusive(async () => { replaced = true })
|
||||
assert.equal(replaced, false); release.resolve()
|
||||
await assert.rejects(child, /Expected child rejection/)
|
||||
await bounded(Promise.all([request, exclusive]))
|
||||
assert.equal(replaced, true)
|
||||
// Invoke in the inherited child context, not this outside test context.
|
||||
await assert.rejects(late(), /no longer active/)
|
||||
})
|
||||
@@ -6,10 +6,11 @@ import { observedBoolean, observedDurationMs, observedInteger } from "./ospf-obs
|
||||
import { readGreNativeRows } from "./gre-observed-state.js"
|
||||
import { greAddressKey } from "./entity-snapshots.js"
|
||||
import type { GreMutation } from "./gre-mutation-input.js"
|
||||
import { greDependencyPaths } from "./routeros-native-paths.js"
|
||||
export { greDependencyPaths } from "./routeros-native-paths.js"
|
||||
|
||||
export const greMutationProblem = (message: string) => Object.assign(new Error(message), { statusCode: 409 })
|
||||
export type GreNativeRow = Record<string, string>
|
||||
export const greDependencyPaths = ["/interface", "/interface/bridge/port", "/interface/vlan", "/interface/list/member", "/ip/vrf", "/ip/route", "/ipv6/route", "/routing/filter/rule", "/routing/ospf/interface-template", "/routing/bfd/configuration", "/routing/bgp/connection", "/routing/rule", ...["ip","ipv6"].flatMap(family=>["filter","nat","mangle","raw"].map(table=>`/${family}/firewall/${table}`))] as const
|
||||
export interface GreMutationState {
|
||||
serverConfiguration: string; gre: GreNativeRow[]; ipv4: GreNativeRow[]; ipv6: GreNativeRow[];
|
||||
restoreProtectionProof?:string;
|
||||
|
||||
@@ -617,10 +617,7 @@ export function firewallRestPath(
|
||||
return `${root}/${table}`
|
||||
}
|
||||
|
||||
export function encodeRosId(rosId: string): string {
|
||||
const id = rosId.startsWith("*") ? rosId : `*${rosId.replace(/^\*/, "")}`
|
||||
return encodeURIComponent(id)
|
||||
}
|
||||
export { encodeRosId } from "./routeros-native-paths.js"
|
||||
|
||||
// ── MikrotikClient ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
export function encodeRosId(rosId: string): string {
|
||||
const id = rosId.startsWith("*") ? rosId : `*${rosId.replace(/^\*/, "")}`
|
||||
return encodeURIComponent(id)
|
||||
}
|
||||
|
||||
export const greDependencyPaths = ["/interface", "/interface/bridge/port", "/interface/vlan",
|
||||
"/interface/list/member", "/ip/vrf", "/ip/route", "/ipv6/route", "/routing/filter/rule",
|
||||
"/routing/ospf/interface-template", "/routing/bfd/configuration", "/routing/bgp/connection", "/routing/rule",
|
||||
...["ip", "ipv6"].flatMap(family => ["filter", "nat", "mangle", "raw"].map(table => `/${family}/firewall/${table}`))] as const
|
||||
|
||||
export const wireguardInterfaceRuntimeDependencyPaths = ["/routing/bgp/session", "/routing/ospf/neighbor",
|
||||
"/routing/ospf/interface", "/routing/bfd/session"] as const
|
||||
|
||||
export const wireguardInterfaceDependencyPaths = [...new Set<string>([...greDependencyPaths, "/interface/vxlan",
|
||||
"/interface/vrrp", "/interface/bonding", "/interface/eoip", "/interface/gre", "/interface/ipip", "/ip/dhcp-server",
|
||||
"/ip/dhcp-client", "/ipv6/dhcp-server", "/ipv6/dhcp-client", "/ip/ipsec/peer", "/ip/ipsec/policy",
|
||||
...wireguardInterfaceRuntimeDependencyPaths])]
|
||||
@@ -0,0 +1,13 @@
|
||||
import assert from "node:assert/strict"
|
||||
import test from "node:test"
|
||||
import { isConfirmedRoutingFibTable } from "./routing-table-fib.js"
|
||||
|
||||
test("native FIB switch and explicit enabled representations preserve the table", () => {
|
||||
for (const fib of ["", true, "true", "yes"]) assert.equal(isConfirmedRoutingFibTable({ name: "main", fib, invalid: "false" }), true)
|
||||
})
|
||||
test("absent or false FIB never confirms a forwarding table", () => {
|
||||
for (const fib of [undefined, null, false, "false", "no", "unknown", "0", 1]) assert.equal(isConfirmedRoutingFibTable({ fib }), false)
|
||||
})
|
||||
test("disabled, invalid and ambiguous status cannot be promoted by the FIB switch", () => {
|
||||
for (const key of ["disabled", "invalid"]) for (const value of [true, "true", "yes", "", null, "unknown"]) assert.equal(isConfirmedRoutingFibTable({ fib: "", [key]: value }), false)
|
||||
})
|
||||
@@ -0,0 +1,8 @@
|
||||
/** RouterOS fib is a CLI switch: its enabled REST representation may be "".
|
||||
* Do not interpret empty strings as true for general boolean properties.
|
||||
* https://manual.mikrotik.com/docs/cli-reference/routing/table/ */
|
||||
export function isConfirmedRoutingFibTable(row: Readonly<Record<string, unknown>>): boolean {
|
||||
const fib = row.fib
|
||||
if (![true, "true", "yes", ""].includes(fib as string | boolean)) return false
|
||||
return ["disabled", "invalid"].every(key => row[key] === undefined || [false, "false", "no"].includes(row[key] as string | boolean))
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { test } from "node:test"
|
||||
import { projectRoutingImmediateGateways, readRoutingTopology } from "./routing-topology-observation.js"
|
||||
const route = { ".id": "*r", afi: "ip", "dst-address": "192.0.2.0/24", "routing-table": "main", active: "true", contribution: "active", gateway: "198.51.100.1", "immediate-gw": "198.51.100.1%wan", comment: "NEVER_PRIVATE", "bgp.peer-cache-id": "*opaque", "bgp.session": "*opaque" }
|
||||
const ports = [{ name: "wan", running: "true" }], tables = [{ name: "main", fib: "true" }]
|
||||
const project = (row = route, version = "7.24.5 (stable)", interfaces: unknown = ports, catalogue: unknown = tables) => projectRoutingImmediateGateways(version, [row], interfaces, catalogue)
|
||||
test("documented IPv4 final gateway is reachable without operation/BGP namespace assertions", () => {
|
||||
const result = project(); assert.equal(result.status, "observed"); assert.equal(result.items[0].status, "observed")
|
||||
const item = result.items[0]; if (item.status !== "observed") throw Error("Missing observation")
|
||||
assert.deepEqual(item.immediateGateway, { address: "198.51.100.1", interface: "wan" })
|
||||
assert.equal(item.routeOriginVerified, false); assert.equal(item.recursiveChainVerified, false); assert.equal(item.semanticProof, false)
|
||||
assert(!JSON.stringify(result).includes("NEVER_PRIVATE")); assert(!JSON.stringify(result).includes("opaque"))
|
||||
})
|
||||
test("IPv6 endpoint and directly connected interface are observed separately from full recursion", () => {
|
||||
const ipv6 = project({ ...route, afi: "ipv6", "dst-address": "2001:db8::/64", "immediate-gw": "2001:db8::1%wan" })
|
||||
assert.equal(ipv6.items[0].status, "observed")
|
||||
const direct = project({ ...route, connect: "true", "immediate-gw": "wan" } as typeof route)
|
||||
assert.equal(direct.items[0].status, "observed")
|
||||
})
|
||||
for (const [name, changed] of Object.entries({ multipath: { ecmp: "true" }, absentActive: { active: undefined }, disabled: { disabled: "true" }, unavailable: { "immediate-gw": "" }, numericOnly: { "immediate-gw": "17" }, wrongFamily: { "immediate-gw": "2001:db8::1%wan" }, wrongVrf: { "immediate-gw": "198.51.100.1%wan@other" } })) test(name + " stays unknown", () => {
|
||||
assert.equal(project({ ...route, ...changed } as typeof route).items[0].status, "unknown")
|
||||
})
|
||||
test("unsupported version, missing/duplicate/down interface and missing/nonfib table stay unknown", () => {
|
||||
assert.equal(project(route, "6.49").status, "unknown")
|
||||
for (const ports of [[], [{ name: "wan", running: "false" }], [{ name: "wan", running: "true" }, { name: "wan", running: "true" }]]) assert.equal(project(route, "7.24.5", ports).items[0].status, "unknown")
|
||||
for (const tables of [[], [{ name: "main", fib: "false" }]]) assert.equal(project(route, "7.24.5", ports, tables).items[0].status, "unknown")
|
||||
assert.equal(projectRoutingImmediateGateways("7.24.5", [route, route], ports, tables).status, "unknown")
|
||||
})
|
||||
test("actual topology reader uses fixed GETs/fences/signal/wire bound and publishes no raw RIB", async () => {
|
||||
const calls: string[] = []; let fences = 0
|
||||
const client = { async get<T>(path: string, timeout?: number, signal?: AbortSignal, bytes?: number, beforeDispatch?: () => Promise<unknown>) {
|
||||
assert(timeout! <= 10000); assert.equal(bytes, 1048576); assert(signal); await beforeDispatch?.(); calls.push(path)
|
||||
return structuredClone(path === "/system/resource" ? [{ version: "7.24.5" }] : path === "/interface" ? ports : path === "/routing/table" ? tables : path === "/routing/route" ? [route] : []) as T
|
||||
} }
|
||||
const result = await readRoutingTopology(client, async () => { fences++ }, new AbortController().signal)
|
||||
assert.equal(calls.length, 11); assert.equal(result.routeResolution.items[0].status, "observed"); assert(fences >= calls.length * 3)
|
||||
assert(!JSON.stringify(result).includes("NEVER_PRIVATE")); assert.equal(result.observedPath, false); assert.equal(result.routeResolution.captureAtomic, false)
|
||||
})
|
||||
test("post-read access failure cannot be swallowed as provider unknown", async () => {
|
||||
let denied = false
|
||||
const client = { async get<T>() { denied = true; return [] as T } }
|
||||
await assert.rejects(readRoutingTopology(client, async () => { if (denied) throw Error("revoked") }), /revoked/)
|
||||
})
|
||||
|
||||
test("cancellation during the last asynchronous publication fence refuses the result", async () => {
|
||||
const cancel = new AbortController(); let fences = 0
|
||||
const client = { async get<T>(path: string) { return (path === "/system/resource" ? [{ version: "7.24.5" }] : path === "/interface" ? ports : path === "/routing/table" ? tables : path === "/routing/route" ? [route] : []) as T } }
|
||||
await assert.rejects(readRoutingTopology(client, async () => { if (++fences === 23) { await Promise.resolve(); cancel.abort() } }, cancel.signal), /Topology read cancelled/)
|
||||
assert.equal(fences, 23)
|
||||
})
|
||||
|
||||
test("actual RouterOS FIB switch encoding is distinct from an absent or invalid table", () => {
|
||||
const native = [{ ".id": "*0", name: "main", dynamic: "true", fib: "", invalid: "false" }]
|
||||
assert.equal(project(route, "7.24.5 (stable)", ports, native).items[0].status, "observed")
|
||||
for (const table of [{ name: "main" }, { name: "main", fib: "false" }, { name: "main", fib: "", invalid: "true" }, { name: "main", fib: "", disabled: "true" }]) assert.equal(project(route, "7.24.5", ports, [table]).items[0].status, "unknown")
|
||||
})
|
||||
Loaded 100 of 212 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user