feat(mcp): подготовить сервер и доступ к приёмке
This commit is contained in:
commit
bf3d50e1ff
3835 files changed
+537122
No files matched your search
@@ -0,0 +1,69 @@
|
||||
import { config } from "dotenv"
|
||||
import { z } from "zod"
|
||||
|
||||
config()
|
||||
|
||||
function boolEnv(v: string | undefined, fallback: boolean): boolean {
|
||||
if (v === undefined || v === "") return fallback
|
||||
return v === "1" || v.toLowerCase() === "true"
|
||||
}
|
||||
|
||||
const isProd = process.env.NODE_ENV === "production"
|
||||
|
||||
const envSchema = z.object({
|
||||
DATABASE_URL: z.string().default("postgres://mmapp:[email protected]:5432/mmapp"),
|
||||
DATABASE_PATH: z.string().default("./mikrotik.db"),
|
||||
SQLITE_IMPORT_FULL_HISTORY: z.boolean().default(false),
|
||||
PORT: z.coerce.number().int().positive().default(8000),
|
||||
CORS_ORIGIN: z.string().default("http://localhost:3000"),
|
||||
AUTH_REQUIRED: z.boolean().default(false),
|
||||
AUTH_JWT_SECRET: z.string().default(""),
|
||||
AUTH_ISSUER: z.string().default("https://auth.shnt.top"),
|
||||
AUTH_PORTAL_URL: z.string().default("http://localhost:5175"),
|
||||
})
|
||||
|
||||
const raw = {
|
||||
DATABASE_URL: process.env.DATABASE_URL,
|
||||
DATABASE_PATH: process.env.DATABASE_PATH,
|
||||
SQLITE_IMPORT_FULL_HISTORY: boolEnv(process.env.SQLITE_IMPORT_FULL_HISTORY, false),
|
||||
PORT: process.env.PORT,
|
||||
CORS_ORIGIN: process.env.CORS_ORIGIN,
|
||||
AUTH_REQUIRED: boolEnv(process.env.AUTH_REQUIRED, false),
|
||||
AUTH_JWT_SECRET:
|
||||
process.env.AUTH_JWT_SECRET?.trim() ||
|
||||
process.env.JWT_SECRET?.trim() ||
|
||||
(isProd ? "" : "dev-secret-change-me"),
|
||||
AUTH_ISSUER:
|
||||
process.env.AUTH_ISSUER?.trim() ||
|
||||
process.env.ISSUER?.trim() ||
|
||||
"https://auth.shnt.top",
|
||||
AUTH_PORTAL_URL: (
|
||||
process.env.AUTH_PORTAL_URL ??
|
||||
process.env.NEXT_PUBLIC_AUTH_PORTAL_URL ??
|
||||
"http://localhost:5175"
|
||||
).replace(/\/$/, ""),
|
||||
}
|
||||
|
||||
const parsed = envSchema.safeParse(raw)
|
||||
|
||||
if (!parsed.success) {
|
||||
console.error("❌ Invalid environment variables:", parsed.error.flatten().fieldErrors)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
if (parsed.data.AUTH_REQUIRED && parsed.data.AUTH_JWT_SECRET.length < 8) {
|
||||
console.error("❌ AUTH_JWT_SECRET / JWT_SECRET required when AUTH_REQUIRED=true")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
export const env = {
|
||||
DATABASE_URL: parsed.data.DATABASE_URL,
|
||||
DATABASE_PATH: parsed.data.DATABASE_PATH,
|
||||
sqliteImportFullHistory: parsed.data.SQLITE_IMPORT_FULL_HISTORY,
|
||||
PORT: parsed.data.PORT,
|
||||
CORS_ORIGIN: parsed.data.CORS_ORIGIN,
|
||||
authRequired: parsed.data.AUTH_REQUIRED,
|
||||
jwtSecret: parsed.data.AUTH_JWT_SECRET || "dev-secret-change-me",
|
||||
authIssuer: parsed.data.AUTH_ISSUER,
|
||||
authPortalUrl: parsed.data.AUTH_PORTAL_URL,
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Порог среднего RTT (мс) для ICMP-проб: выше — статус `warn` при потерях ≤1%.
|
||||
* 60 мс было слишком жёстко для WAN/CDN (например ~65–80 мс до 1.1.1.1 из РФ — нормальная география).
|
||||
* Фронт: держите то же значение в `lib/ping-probe.ts`.
|
||||
*/
|
||||
export const PING_PROBE_WARN_RTT_MS = 120
|
||||
@@ -0,0 +1,194 @@
|
||||
import {randomBytes} from 'node:crypto'
|
||||
import pg,{type PoolClient,type QueryResult,type QueryResultRow} from 'pg'
|
||||
|
||||
export interface ApplicationOperatorSql {
|
||||
query<R extends QueryResultRow=QueryResultRow>(text:string,values?:unknown[]):Promise<QueryResult<R>>
|
||||
}
|
||||
export type PairDecision<T>={disposition:'commit'|'rollback';value:T}
|
||||
export type PairResult<T>={disposition:'committed'|'rolled-back';value:T}
|
||||
type Outcome='not-started'|'rolled-back'|'committed'|'unknown'
|
||||
export class ApplicationOperatorTransactionError<T=unknown> extends Error {
|
||||
readonly automaticReplay=false
|
||||
constructor(readonly outcome:Outcome,readonly phase:string,readonly committedValue:T|undefined,readonly hasCommittedValue:boolean,readonly cleanupUnconfirmed:boolean,options?:ErrorOptions){
|
||||
super('Application/operator transaction pairing refused; do not replay automatically',options)
|
||||
}
|
||||
}
|
||||
const CONTEXT=`SELECT CURRENT_USER::text AS actor,SESSION_USER::text AS login,
|
||||
d.oid::text AS database_oid,pg_catalog.pg_backend_pid() AS pid,
|
||||
pg_catalog.current_setting('server_version_num')::int AS version,
|
||||
pg_catalog.current_setting('transaction_read_only') AS read_only,
|
||||
pg_catalog.pg_current_xact_id_if_assigned()::text AS xid,
|
||||
(r.rolsuper OR r.rolcreatedb OR r.rolcreaterole OR r.rolreplication OR r.rolbypassrls) AS unsafe,
|
||||
EXISTS(SELECT 1 FROM pg_catalog.pg_namespace n WHERE n.nspname='public' AND n.nspowner=r.oid) AS owns_public,
|
||||
EXISTS(SELECT 1 FROM pg_catalog.pg_class c JOIN pg_catalog.pg_namespace n ON n.oid=c.relnamespace WHERE n.nspname='public' AND c.relowner=r.oid) AS owns_application_object,
|
||||
pg_catalog.pg_has_role(r.oid,($1::pg_catalog.regrole)::pg_catalog.oid,'MEMBER') AS member_operator
|
||||
FROM pg_catalog.pg_roles r CROSS JOIN pg_catalog.pg_database d WHERE r.rolname=CURRENT_USER AND d.datname=pg_catalog.current_database()`
|
||||
const WITNESS=`SELECT pid,database::text AS database_oid,mode,granted FROM pg_catalog.pg_locks
|
||||
WHERE locktype='advisory' AND database=(SELECT oid FROM pg_catalog.pg_database WHERE datname=pg_catalog.current_database())
|
||||
AND classid=$1::oid AND objid=$2::oid AND objsubid=2 ORDER BY pid,mode LIMIT 3`
|
||||
type Context={actor:string;login:string;database_oid:string;pid:number;version:number;read_only:'on'|'off';xid:string|null;unsafe:boolean;owns_public:boolean;owns_application_object:boolean;member_operator:boolean}
|
||||
function exactContext(rows:QueryResultRow[],actor:string,readOnly:boolean):Context {
|
||||
const row=rows[0] as Context|undefined
|
||||
if(rows.length!==1||!row||row.actor!==actor||row.login!==actor||!Number.isSafeInteger(row.pid)||row.pid<=0||!/^\d+$/.test(row.database_oid)||row.version<180000||row.version>=190000||row.read_only!==(readOnly?'on':'off')||row.unsafe!==false||typeof row.member_operator!=='boolean'||typeof row.owns_public!=='boolean'||typeof row.owns_application_object!=='boolean'||row.xid!==null&&!/^[1-9][0-9]*$/.test(row.xid))throw Error('Actual PG18 expected role/transaction context unavailable')
|
||||
if(readOnly&&(row.owns_public||row.owns_application_object||row.member_operator))throw Error('Runtime role is an application owner/operator member')
|
||||
if(!readOnly&&!row.owns_public)throw Error('Operator is not the explicitly configured application schema owner')
|
||||
return row
|
||||
}
|
||||
// Defensive direct-boundary refusal, NOT an untrusted SQL parser/sandbox.
|
||||
// Only fixed trusted application statements may reach this internal facade.
|
||||
function fixedApplicationStatement(text:string){
|
||||
if(typeof text!=='string'||!text.trim()||Buffer.byteLength(text)>1048576)throw Error('Bounded trusted application SQL required')
|
||||
const head=text.replace(/^(?:\s|--[^\r\n]*(?:\r?\n|$)|\/\*[^]*?\*\/)+/,'')
|
||||
if(/^(?:BEGIN|START\s+TRANSACTION|COMMIT|END|ROLLBACK|SAVEPOINT|RELEASE|PREPARE\s+TRANSACTION|SET\s+(?:(?:LOCAL|SESSION)\s+)?ROLE|RESET\s+ROLE)\b/i.test(head))throw Error('Transaction and role ownership belongs to pairing component')
|
||||
}
|
||||
/** Private trusted host assembly only. No source/profile issuer, SQL sandbox,
|
||||
* configured URL, Pool creation, role provisioning, fallback or READY grant. */
|
||||
export function createApplicationOperatorTransactions(config:{runtimePool:pg.Pool;operatorPool:pg.Pool;runtimeRole:string;operatorRole:string;maxConcurrentPairs:1|2}){
|
||||
const {runtimePool,operatorPool,runtimeRole,operatorRole,maxConcurrentPairs}=config
|
||||
if(!(runtimePool instanceof pg.Pool)||!(operatorPool instanceof pg.Pool)||runtimePool===operatorPool||runtimeRole===operatorRole||![runtimeRole,operatorRole].every(role=>/^[a-z_][a-z0-9_]{0,62}$/.test(role))||![1,2].includes(maxConcurrentPairs))throw Error('Fixed actual pools, separated roles and bounded capacity required')
|
||||
const runtimeConnect=runtimePool.connect.bind(runtimePool),operatorConnect=operatorPool.connect.bind(operatorPool)
|
||||
let activePairs=0,poisoned=false
|
||||
return Object.freeze({
|
||||
async run<T>(work:(tx:ApplicationOperatorSql)=>Promise<PairDecision<T>>,input:{deadlineAt:number;signal?:AbortSignal;authorize?:()=>Promise<unknown>}):Promise<PairResult<T>>{
|
||||
const options=Object.freeze({deadlineAt:input.deadlineAt,signal:input.signal,authorize:input.authorize})
|
||||
if(poisoned||activePairs>=maxConcurrentPairs||typeof work!=='function'||!Number.isSafeInteger(options.deadlineAt)||options.deadlineAt<=Date.now()||options.deadlineAt-Date.now()>120000||options.signal?.aborted)throw new ApplicationOperatorTransactionError('not-started','admission',undefined,false,poisoned)
|
||||
activePairs++
|
||||
let runtime:PoolClient|undefined,operator:PoolClient|undefined,runtimeReleased=false,operatorReleased=false
|
||||
let runtimeBegun=false,operatorBegun=false,commitSent=false,commitConfirmed=false,operatorTerminal=false,witnessTerminal=false
|
||||
let phase='checkout-runtime',accepting=false,retired=false,primary:unknown,hasPrimary=false,workFailed=false,queuedFailure:unknown,queuedFailed=false,tail:Promise<void>=Promise.resolve()
|
||||
const retain=(cause:unknown)=>{if(!hasPrimary){primary=cause;hasPrimary=true}}
|
||||
let committedValue:T|undefined,hasCommittedValue=false,decision:PairDecision<T>|undefined
|
||||
const random=randomBytes(8),first=random.readInt32BE(0),second=random.readInt32BE(4),key=[first,second],unsigned=[first>>>0,second>>>0]
|
||||
const errors=new Map<PoolClient,()=>void>()
|
||||
let stopReject!:(error:Error)=>void
|
||||
const stopped=new Promise<never>((_,reject)=>{stopReject=reject});void stopped.catch(()=>{})
|
||||
const dispose=(client:PoolClient|undefined,unsafe:boolean)=>{
|
||||
if(!client)return
|
||||
if(client===runtime){if(runtimeReleased)return;runtimeReleased=true}
|
||||
else if(client===operator){if(operatorReleased)return;operatorReleased=true}
|
||||
const listener=errors.get(client)
|
||||
if(listener){client.off('error',listener);client.off('end',listener)}
|
||||
try{client.release(unsafe)}catch(error){poisoned=true;retain(error)}
|
||||
}
|
||||
const stop=(cause:unknown)=>{
|
||||
if(retired)return
|
||||
retired=true;accepting=false;poisoned=true;retain(cause)
|
||||
dispose(operator,true);dispose(runtime,true)
|
||||
stopReject(new ApplicationOperatorTransactionError(commitConfirmed?'committed':operatorBegun?'unknown':'not-started',phase,committedValue,hasCommittedValue,true,{cause}))
|
||||
}
|
||||
const fence=()=>{if(retired||options.signal?.aborted||Date.now()>=options.deadlineAt)throw hasPrimary?primary:Error('Pairing lifetime expired')}
|
||||
const bounded=<R>(operation:Promise<R>)=>Promise.race([operation,stopped])
|
||||
const observe=(client:PoolClient)=>{const listener=()=>stop(Error('Owned physical connection ended unexpectedly'));errors.set(client,listener);client.on('error',listener);client.on('end',listener)}
|
||||
const borrow=async(connect:()=>Promise<PoolClient>,kind:'runtime'|'operator')=>{
|
||||
const pending=connect().then(client=>{
|
||||
if(kind==='runtime')runtime=client;else operator=client
|
||||
if(retired){dispose(client,true);return client}
|
||||
observe(client);return client
|
||||
})
|
||||
// Handler remains attached if cancellation wins; a late checkout is retired.
|
||||
void pending.catch(()=>{})
|
||||
const client=await bounded(pending);fence();return client
|
||||
}
|
||||
const query=async(client:PoolClient,text:string,values?:unknown[])=>{fence();const result=await bounded(client.query(text,values));fence();return result}
|
||||
const authorize=async()=>{fence();await bounded(Promise.resolve().then(()=>options.authorize?.()));fence()}
|
||||
const onAbort=()=>stop(Error('Pairing cancelled'))
|
||||
const timer=setTimeout(()=>stop(Error('Pairing deadline exceeded')),Math.max(1,options.deadlineAt-Date.now()))
|
||||
options.signal?.addEventListener('abort',onAbort,{once:true})
|
||||
let operatorContext:Context|undefined,runtimeContext:Context|undefined
|
||||
const assertPair=async()=>{
|
||||
if(!operator||!operatorContext||!runtimeContext)throw Error('Live pair is not established')
|
||||
try{
|
||||
const current=exactContext((await query(operator,CONTEXT,[operatorRole])).rows,operatorRole,false)
|
||||
if(current.pid!==operatorContext.pid||current.xid!==operatorContext.xid||current.database_oid!==operatorContext.database_oid||current.xid===null)throw Error('Operator backend/owned writable transaction changed')
|
||||
const locks=(await query(operator,WITNESS,unsigned)).rows
|
||||
if(locks.length!==1||locks[0].pid!==runtimeContext.pid||locks[0].database_oid!==runtimeContext.database_oid||locks[0].mode!=='ShareLock'||locks[0].granted!==true)throw Error('Original runtime witness is not live on this application database')
|
||||
}catch(error){stop(error);throw error}
|
||||
}
|
||||
try{
|
||||
await authorize();runtime=await borrow(runtimeConnect,'runtime')
|
||||
await query(runtime,'BEGIN ISOLATION LEVEL READ COMMITTED READ ONLY');runtimeBegun=true
|
||||
await query(runtime,"SET LOCAL statement_timeout='10s'");await query(runtime,"SET LOCAL lock_timeout='5s'")
|
||||
runtimeContext=exactContext((await query(runtime,CONTEXT,[operatorRole])).rows,runtimeRole,true)
|
||||
await query(runtime,'SELECT pg_catalog.pg_advisory_xact_lock_shared($1::int,$2::int)',key)
|
||||
phase='checkout-operator';operator=await borrow(operatorConnect,'operator')
|
||||
await query(operator,'BEGIN ISOLATION LEVEL READ COMMITTED READ WRITE');operatorBegun=true
|
||||
await query(operator,"SET LOCAL statement_timeout='120s'");await query(operator,"SET LOCAL lock_timeout='10s'");await query(operator,'SET LOCAL synchronous_commit=on')
|
||||
await query(operator,'SELECT pg_catalog.pg_current_xact_id()')
|
||||
operatorContext=exactContext((await query(operator,CONTEXT,[operatorRole])).rows,operatorRole,false)
|
||||
if(operatorContext.xid===null)throw Error('Owned writable transaction identity unavailable')
|
||||
phase='live-source-pair'
|
||||
const attempt=(await query(operator,'SELECT pg_catalog.pg_try_advisory_xact_lock($1::int,$2::int) AS acquired',key)).rows
|
||||
if(attempt.length!==1||attempt[0].acquired!==false)throw Error('Operator is not paired to original live application database')
|
||||
await assertPair();await authorize();phase='work';accepting=true
|
||||
const facade:ApplicationOperatorSql=Object.freeze({query:<R extends QueryResultRow=QueryResultRow>(text:string,values?:unknown[]):Promise<QueryResult<R>>=>{
|
||||
const rejected=(error:unknown):Promise<QueryResult<R>>=>{const failure=Promise.reject(error);void failure.catch(()=>{});return failure}
|
||||
if(!accepting||retired)return rejected(new ApplicationOperatorTransactionError(commitConfirmed?'committed':'unknown','closed-facade',committedValue,hasCommittedValue,false))
|
||||
try{fixedApplicationStatement(text)}catch(error){if(!queuedFailed){queuedFailure=error;queuedFailed=true}return rejected(error)}
|
||||
const parameters=values?.slice()
|
||||
const pending=tail.then(async()=>{
|
||||
if(queuedFailed)throw queuedFailure
|
||||
await authorize();await assertPair()
|
||||
const result=await query(operator!,text,parameters)
|
||||
await assertPair();await authorize();return result as QueryResult<R>
|
||||
})
|
||||
// Keep EVERY accepted request in a serial drain, including detached work.
|
||||
tail=pending.then(()=>{},error=>{if(!queuedFailed){queuedFailure=error;queuedFailed=true}})
|
||||
void pending.catch(()=>{})
|
||||
return pending
|
||||
}})
|
||||
try{decision=await bounded(Promise.resolve().then(()=>work(facade)))}catch(error){workFailed=true;retain(error)}
|
||||
finally{accepting=false}
|
||||
phase='drain-work';await bounded(tail)
|
||||
if(workFailed||queuedFailed)throw workFailed?primary:queuedFailure
|
||||
if(!decision||!['commit','rollback'].includes(decision.disposition)||!Object.hasOwn(decision,'value'))throw Error('Trusted work must return explicit terminal disposition/value')
|
||||
decision=Object.freeze({disposition:decision.disposition,value:decision.value})
|
||||
await authorize();await assertPair()
|
||||
phase='operator-terminal'
|
||||
if(decision.disposition==='commit'){
|
||||
commitSent=true
|
||||
const terminal=await bounded(operator.query('COMMIT').then(result=>{
|
||||
// Preserve ACK/value BEFORE a cancellation/deadline fence can throw.
|
||||
if(result.command==='COMMIT'){commitConfirmed=true;operatorTerminal=true;operatorBegun=false;committedValue=decision!.value;hasCommittedValue=true}
|
||||
return result
|
||||
}))
|
||||
if(terminal.command!=='COMMIT')throw Error('Operator COMMIT acknowledgement unavailable')
|
||||
fence()
|
||||
}else{
|
||||
const terminal=await query(operator,'ROLLBACK')
|
||||
if(terminal.command!=='ROLLBACK')throw Error('Operator ROLLBACK acknowledgement unavailable')
|
||||
operatorTerminal=true;operatorBegun=false
|
||||
}
|
||||
phase='witness-terminal'
|
||||
const terminal=await query(runtime,'ROLLBACK')
|
||||
if(terminal.command!=='ROLLBACK')throw Error('Runtime witness cleanup acknowledgement unavailable')
|
||||
witnessTerminal=true;runtimeBegun=false
|
||||
dispose(operator,false);dispose(runtime,false)
|
||||
if(hasPrimary)throw primary
|
||||
return {disposition:decision.disposition==='commit'?'committed':'rolled-back',value:decision.value}
|
||||
}catch(error){
|
||||
accepting=false;retain(error)
|
||||
if(!retired){
|
||||
// Drain accepted SQL BEFORE cleanup; deadline/physical faults destroy both
|
||||
// instead of sending ROLLBACK concurrently over an in-flight query.
|
||||
try{await bounded(tail)}catch(cause){stop(cause)}
|
||||
if(!retired){
|
||||
try{if(operator&&operatorBegun&&!commitSent){const terminal=await query(operator,'ROLLBACK');if(terminal.command!=='ROLLBACK')throw Error('Operator rollback unconfirmed');operatorBegun=false;operatorTerminal=true}}
|
||||
catch(cause){stop(cause)}
|
||||
try{if(!retired&&runtime&&runtimeBegun){const terminal=await query(runtime,'ROLLBACK');if(terminal.command!=='ROLLBACK')throw Error('Witness rollback unconfirmed');runtimeBegun=false;witnessTerminal=true}}
|
||||
catch(cause){stop(cause)}
|
||||
}
|
||||
}
|
||||
const cleanupUnconfirmed=retired||runtimeBegun||operatorBegun||(commitSent&&!commitConfirmed)
|
||||
if(cleanupUnconfirmed)poisoned=true
|
||||
dispose(operator,cleanupUnconfirmed);dispose(runtime,cleanupUnconfirmed)
|
||||
const outcome:Outcome=commitConfirmed?'committed':commitSent?'unknown':operatorTerminal?'rolled-back':runtimeBegun||operatorBegun?'unknown':'not-started'
|
||||
throw new ApplicationOperatorTransactionError(outcome,phase,committedValue,hasCommittedValue,cleanupUnconfirmed,{cause:primary})
|
||||
}finally{
|
||||
accepting=false;clearTimeout(timer);options.signal?.removeEventListener('abort',onAbort)
|
||||
if(!runtimeReleased&&runtime)dispose(runtime,!witnessTerminal)
|
||||
if(!operatorReleased&&operator)dispose(operator,!operatorTerminal)
|
||||
activePairs--
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import {createHash} from 'node:crypto'
|
||||
|
||||
/** Exact writer-profile v1 canonical bytes. Pure value codec; no trust/authority. */
|
||||
export function canonical(value:unknown):string {
|
||||
return JSON.stringify(value,(_key,item:unknown)=>item&&typeof item==='object'&&!Array.isArray(item)
|
||||
?Object.fromEntries(Object.entries(item).sort(([a],[b])=>a<b?-1:a>b?1:0)):item)
|
||||
}
|
||||
export const sha=(value:Uint8Array|string)=>createHash('sha256').update(value).digest('hex')
|
||||
export const bytes=(value:unknown)=>Buffer.from(canonical(value)+'\n','utf8')
|
||||
@@ -0,0 +1,16 @@
|
||||
// Self-contained production-target unit: no historical tmp oracle/key files/DB.
|
||||
import assert from 'node:assert/strict'
|
||||
import {generateKeyPairSync,sign} from 'node:crypto'
|
||||
import {bytes,canonical,sha,epoch,nextEpoch,POLICY_DOMAIN,HEAD_DOMAIN,signedEnvelope,liveValidity,type ApplicationWriterPolicy} from './application-writer-profile-format.js'
|
||||
const key=generateKeyPairSync('ed25519'),now=Date.now(),installationId='11111111-1111-4111-8111-111111111111'
|
||||
const subject={installationId,publicKeyPem:key.publicKey.export({format:'pem',type:'spki'}).toString(),publicKeySpkiSha256:sha(key.publicKey.export({format:'der',type:'spki'}))}
|
||||
const policy:ApplicationWriterPolicy={format:'application-writer-policy-v1',installationId,epoch:'9007199254740993',issuedAt:now-1000,notBefore:now-1000,notAfter:now+60000,source:{systemIdentifier:'18446744073709551615',databaseOid:'4294967295',databaseName:'synthetic',serverVersionNum:180004,inRecovery:false},sourceObservationSha256:'a'.repeat(64),roles:{runtime:'11',operator:'12',capture:'13',publisher:'14',control:'15'},manifestSha256:'b'.repeat(64),definitionSha256:'c'.repeat(64),catalogSha256:'d'.repeat(64),futureLeaves:'fixed12-native-policy-v1'}
|
||||
function signed(body:unknown,domain=POLICY_DOMAIN){return bytes({body,signature:sign(null,Buffer.from(domain+canonical(body)),key.privateKey).toString('base64url')})}
|
||||
assert.equal(canonical({z:[2,1],a:{я:'ё',b:1}}),'{"a":{"b":1,"я":"ё"},"z":[2,1]}')
|
||||
assert.equal(epoch('9007199254740993'),'9007199254740993');assert.equal(nextEpoch('9007199254740993'),'9007199254740994');assert.throws(()=>nextEpoch('9223372036854775807'));assert.throws(()=>epoch(9007199254740993));assert.throws(()=>epoch('01'))
|
||||
const raw=signed(policy),parsed=signedEnvelope(raw,'policy',subject);assert.deepEqual(parsed.body,policy);assert.equal(parsed.sha256,sha(raw));assert.deepEqual(Object.keys(parsed).sort(),['body','sha256']);liveValidity(parsed.body,now)
|
||||
assert.throws(()=>signedEnvelope(signed({...policy,unknown:true}),'policy',subject));assert.throws(()=>signedEnvelope(signed({...policy,roles:{...policy.roles,unknown:'16'}}),'policy',subject));assert.throws(()=>signedEnvelope(signed({...policy,source:{...policy.source,inRecovery:true}}),'policy',subject))
|
||||
const tampered=JSON.parse(raw.toString());tampered.body.catalogSha256='0'.repeat(64);assert.throws(()=>signedEnvelope(bytes(tampered),'policy',subject));assert.throws(()=>signedEnvelope(raw,'policy',{...subject,publicKeySpkiSha256:'0'.repeat(64)}));assert.throws(()=>signedEnvelope(Buffer.concat([raw,Buffer.from(' ')]),'policy',subject));assert.throws(()=>signedEnvelope(Buffer.from([255]),'policy',subject))
|
||||
const head={format:'application-writer-head-v1',installationId,epoch:'1',policySha256:sha(raw),previousHeadSha256:null};assert.deepEqual(signedEnvelope(signed(head,HEAD_DOMAIN),'head',subject).body,head);assert.throws(()=>signedEnvelope(signed(head,POLICY_DOMAIN),'head',subject));assert.throws(()=>signedEnvelope(signed({...head,epoch:'2'},HEAD_DOMAIN),'head',subject))
|
||||
const expired={...policy,issuedAt:now-20000,notBefore:now-20000,notAfter:now-10000};assert.deepEqual(signedEnvelope(signed(expired),'policy',subject).body,expired);assert.throws(()=>liveValidity(expired,now));assert.throws(()=>liveValidity(policy,now+6000))
|
||||
console.log(JSON.stringify({syntheticOnly:true,unitGroups:7,privateKeysWritten:false,postgres:false,writerAuthorityMinted:false,readyAuthorized:false}))
|
||||
@@ -0,0 +1,56 @@
|
||||
import {createPublicKey,verify,type KeyObject} from 'node:crypto'
|
||||
import {z} from 'zod'
|
||||
import {bytes,canonical,sha} from './application-writer-profile-canonical.js'
|
||||
export {bytes,canonical,sha}
|
||||
|
||||
// Internal schema/crypto only: no key loader, DB/CLI/runtime, installation issuer,
|
||||
// caller-JSON pin parser, provider, source/current-head proof or writer capability.
|
||||
// A verified signature under a supplied public key is DATA, never authorization.
|
||||
export const KEY=8723110,PUBLISHER='barrier_profile_publisher',OPERATOR='barrier_operator'
|
||||
export const POLICY_DOMAIN='mmapp:application-writer-policy:ed25519:v1\n',HEAD_DOMAIN='mmapp:application-writer-head:ed25519:v1\n'
|
||||
export const MAX_EPOCH=9223372036854775807n
|
||||
export function epoch(value:unknown):string {
|
||||
if(typeof value!=='string'||!(/^[1-9][0-9]{0,18}$/).test(value)||BigInt(value)>MAX_EPOCH)throw Error('Canonical PG bigint epoch required')
|
||||
return value
|
||||
}
|
||||
export function nextEpoch(value:unknown):string {
|
||||
const next=BigInt(epoch(value))+1n
|
||||
if(next>MAX_EPOCH)throw Error('PG bigint epoch exhausted')
|
||||
return next.toString()
|
||||
}
|
||||
const hex=z.string().regex(/^[a-f0-9]{64}$/),uuid=z.string().uuid(),decimal=z.string().refine(x=>{try{return epoch(x)===x}catch{return false}})
|
||||
const oid=z.string().regex(/^[1-9][0-9]{0,9}$/).refine(value=>BigInt(value)<=4294967295n)
|
||||
const roles=z.object({runtime:oid,operator:oid,capture:oid,publisher:oid,control:oid}).strict()
|
||||
const source=z.object({systemIdentifier:z.string().regex(/^[1-9][0-9]{0,19}$/).refine(value=>BigInt(value)<=18446744073709551615n),databaseOid:oid,databaseName:z.string().min(1).max(63),serverVersionNum:z.number().int().min(180000).max(189999),inRecovery:z.literal(false)}).strict()
|
||||
export const policySchema=z.object({format:z.literal('application-writer-policy-v1'),installationId:uuid,epoch:decimal,issuedAt:z.number().int().nonnegative(),notBefore:z.number().int().nonnegative(),notAfter:z.number().int().nonnegative(),source,sourceObservationSha256:hex,roles,manifestSha256:hex,definitionSha256:hex,catalogSha256:hex,futureLeaves:z.literal('fixed12-native-policy-v1')}).strict().refine(p=>p.notBefore===p.issuedAt&&p.notAfter>p.notBefore&&p.notAfter-p.notBefore<=86400000)
|
||||
export const headSchema=z.object({format:z.literal('application-writer-head-v1'),installationId:uuid,epoch:decimal,policySha256:hex,previousHeadSha256:hex.nullable()}).strict().refine(p=>(p.epoch==='1')===(p.previousHeadSha256===null))
|
||||
const envelope=z.object({body:z.unknown(),signature:z.string().regex(/^[A-Za-z0-9_-]{86}$/)}).strict()
|
||||
export type ApplicationWriterPolicy=z.infer<typeof policySchema>
|
||||
export type ApplicationWriterHead=z.infer<typeof headSchema>
|
||||
type ArtifactKind='policy'|'head'
|
||||
// Not exported as a provisioning/config API. Future private host must obtain
|
||||
// this subject from protected installation state, never a request or provider.
|
||||
interface PublicVerificationSubject {readonly installationId:string;readonly publicKeyPem:string;readonly publicKeySpkiSha256:string}
|
||||
type ParsedData<T>={body:T;sha256:string}
|
||||
export function publicKey(installation:PublicVerificationSubject):KeyObject {
|
||||
const key=createPublicKey(installation.publicKeyPem)
|
||||
if(key.asymmetricKeyType!=='ed25519'||sha(key.export({format:'der',type:'spki'}))!==installation.publicKeySpkiSha256)throw Error('Fixed installation SPKI required')
|
||||
return key
|
||||
}
|
||||
export function signedEnvelope(raw:Uint8Array|string,kind:'policy',installation:PublicVerificationSubject):ParsedData<ApplicationWriterPolicy>
|
||||
export function signedEnvelope(raw:Uint8Array|string,kind:'head',installation:PublicVerificationSubject):ParsedData<ApplicationWriterHead>
|
||||
export function signedEnvelope(raw:Uint8Array|string,kind:ArtifactKind,installation:PublicVerificationSubject):ParsedData<ApplicationWriterPolicy|ApplicationWriterHead>
|
||||
export function signedEnvelope(raw:Uint8Array|string,kind:ArtifactKind,installation:PublicVerificationSubject):ParsedData<ApplicationWriterPolicy|ApplicationWriterHead> {
|
||||
const data=Buffer.from(raw)
|
||||
if(data.length<1||data.length>(kind==='policy'?1048576:8192))throw Error('Signed artifact bound')
|
||||
const value=envelope.parse(JSON.parse(new TextDecoder('utf8',{fatal:true}).decode(data)))
|
||||
if(!bytes(value).equals(data))throw Error('Canonical signed artifact required')
|
||||
if(Buffer.from(value.signature,'base64url').toString('base64url')!==value.signature)throw Error('Canonical signature encoding required')
|
||||
const body=(kind==='policy'?policySchema:headSchema).parse(value.body)
|
||||
if(body.installationId!==installation.installationId||!verify(null,Buffer.from((kind==='policy'?POLICY_DOMAIN:HEAD_DOMAIN)+canonical(body)),publicKey(installation),Buffer.from(value.signature,'base64url')))throw Error('Installation signature refused')
|
||||
return {body,sha256:sha(data)}
|
||||
}
|
||||
export function liveValidity(policy:ApplicationWriterPolicy,serverNow:number):void {
|
||||
const now=Date.now()
|
||||
if(!Number.isSafeInteger(serverNow)||Math.abs(serverNow-now)>5000||now<policy.notBefore||now>=policy.notAfter||serverNow<policy.notBefore||serverNow>=policy.notAfter)throw Error('Current signed policy interval/clock refused')
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
// Frozen five-query regression oracle; TEST ONLY, excluded from backend build.
|
||||
import {epoch,signedEnvelope,liveValidity,type ApplicationWriterPolicy} from './application-writer-profile-format.js'
|
||||
|
||||
// Internal read-side value component ONLY. No configured pool, provider/pin JSON
|
||||
// parser, publish/DDL/CAS write, key loader, role provisioning or authority mint.
|
||||
// Caller retains authenticated backend/source/lock/transaction/lifetime ownership.
|
||||
// Structure/index/ACL checks and protected subject provenance are separate gates.
|
||||
export const HISTORY='application_writer_profile_history',CURRENT='application_writer_profile_current'
|
||||
export interface ApplicationWriterRegistryReadSql {
|
||||
query<R extends Record<string,unknown>=Record<string,unknown>>(text:string,values?:unknown[]):Promise<{rows:R[]}>
|
||||
}
|
||||
type Subject=Parameters<typeof signedEnvelope>[2]
|
||||
interface CurrentRow extends Record<string,unknown> {installation_id:string;epoch:string;head_sha256:string}
|
||||
interface HeadRow extends CurrentRow {policy_sha256:string;previous_installation_id:string|null;previous_epoch:string|null;previous_head_sha256:string|null;head_bytes:Uint8Array|string|null}
|
||||
interface PolicyRow extends Record<string,unknown> {policy_sha256:string;policy_bytes:Uint8Array|string|null}
|
||||
export interface ApplicationWriterRegistryReadOptions {allowEmpty?:boolean;validNow?:boolean}
|
||||
/** Ordinary observed data, NOT a CAS reservation/current-state capability.
|
||||
* Subject must be fixed by a future protected private host, never a request.
|
||||
* validNow=false is reserved for trusted expired-policy inspection/renewal;
|
||||
* it is not a runtime authorization option. No trusted state is created here. */
|
||||
export interface ApplicationWriterRegistryCurrentData {epoch:string;headSha256:string;policy:ApplicationWriterPolicy;policySha256:string;serverNow:number}
|
||||
function payload(value:Uint8Array|string|null):Uint8Array|string {
|
||||
// SQL CASE turns oversize/empty bytes into NULL; reject before parsing. Same
|
||||
// refusal as MJS Buffer.from(null), with an explicit internal diagnostic.
|
||||
if(value===null)throw Error('Bounded signed registry payload absent')
|
||||
return value
|
||||
}
|
||||
export async function readCurrentApplicationWriterProfile(client:ApplicationWriterRegistryReadSql,installation:Subject,{allowEmpty=false,validNow=true}:ApplicationWriterRegistryReadOptions={}):Promise<ApplicationWriterRegistryCurrentData|null>{
|
||||
const foreign=(await client.query(`(SELECT installation_id FROM mmapp_control.${HISTORY} WHERE installation_id<$1::uuid ORDER BY installation_id DESC LIMIT 1) UNION ALL (SELECT installation_id FROM mmapp_control.${HISTORY} WHERE installation_id>$1::uuid ORDER BY installation_id LIMIT 1)`,[installation.installationId])).rows
|
||||
if(foreign.length)throw Error('Foreign installation history refused')
|
||||
const current=(await client.query<CurrentRow>(`SELECT installation_id::text,epoch::text,head_sha256 FROM mmapp_control.${CURRENT} WHERE id=1`)).rows
|
||||
const latest=(await client.query<HeadRow>(`SELECT installation_id::text,epoch::text,head_sha256,policy_sha256,previous_installation_id::text,previous_epoch::text,previous_head_sha256,CASE WHEN pg_catalog.octet_length(head_bytes) BETWEEN 1 AND 8192 THEN head_bytes ELSE NULL END AS head_bytes FROM mmapp_control.${HISTORY} WHERE installation_id=$1::uuid ORDER BY epoch DESC LIMIT 2`,[installation.installationId])).rows
|
||||
if(current.length===0&&latest.length===0&&allowEmpty)return null
|
||||
const c=current[0],top=latest[0]
|
||||
if(current.length!==1||!top||c.installation_id!==installation.installationId||c.epoch!==epoch(top.epoch)||c.head_sha256!==top.head_sha256)throw Error('Current does not match indexed append-only latest')
|
||||
const row=(await client.query<PolicyRow>(`SELECT policy_sha256,CASE WHEN pg_catalog.octet_length(policy_bytes) BETWEEN 1 AND 1048576 THEN policy_bytes ELSE NULL END AS policy_bytes FROM mmapp_control.${HISTORY} WHERE installation_id=$1::uuid AND epoch=$2::bigint AND head_sha256=$3`,[c.installation_id,c.epoch,c.head_sha256])).rows
|
||||
if(row.length!==1)throw Error('Exact current policy row absent')
|
||||
const policy=signedEnvelope(payload(row[0].policy_bytes),'policy',installation),head=signedEnvelope(payload(top.head_bytes),'head',installation)
|
||||
if(head.sha256!==c.head_sha256||head.body.epoch!==c.epoch||policy.body.epoch!==c.epoch||head.body.policySha256!==policy.sha256||row[0].policy_sha256!==policy.sha256||top.policy_sha256!==policy.sha256||head.body.previousHeadSha256!==top.previous_head_sha256)throw Error('Exact current signature/hash/link refused')
|
||||
if(c.epoch==='1'){
|
||||
if(latest.length!==1||top.previous_epoch!==null||top.previous_installation_id!==null||top.previous_head_sha256!==null)throw Error('Unique genesis required')
|
||||
}else{
|
||||
const previous=latest[1]
|
||||
if(!previous||BigInt(epoch(previous.epoch))+1n!==BigInt(c.epoch)||top.previous_epoch!==previous.epoch||top.previous_installation_id!==installation.installationId||top.previous_head_sha256!==previous.head_sha256)throw Error('Exact consecutive predecessor required')
|
||||
const before=signedEnvelope(payload(previous.head_bytes),'head',installation)
|
||||
if(before.sha256!==previous.head_sha256||before.body.epoch!==previous.epoch||before.body.policySha256!==previous.policy_sha256||before.body.previousHeadSha256!==previous.previous_head_sha256)throw Error('Historical signed predecessor refused')
|
||||
}
|
||||
const now=(await client.query<{now:string}>("SELECT (EXTRACT(EPOCH FROM pg_catalog.clock_timestamp())*1000)::bigint::text AS now")).rows
|
||||
if(now.length!==1)throw Error('Actual server clock required')
|
||||
if(validNow)liveValidity(policy.body,Number(now[0].now))
|
||||
return {epoch:c.epoch,headSha256:c.head_sha256,policy:policy.body,policySha256:policy.sha256,serverNow:Number(now[0].now)}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
// Self-contained MODEL unit. No genuine SQL/ownership/authority claim.
|
||||
import {generateKeyPairSync,sign} from 'node:crypto'
|
||||
import {bytes,canonical,sha,POLICY_DOMAIN,HEAD_DOMAIN,type ApplicationWriterPolicy} from './application-writer-profile-format.js'
|
||||
import type {ApplicationWriterRegistryReadSql} from './application-writer-profile-registry-read.js'
|
||||
export function syntheticFixture(value='2'){
|
||||
const key=generateKeyPairSync('ed25519'),installationId='11111111-1111-4111-8111-111111111111',now=Date.now()
|
||||
const subject={installationId,publicKeyPem:key.publicKey.export({format:'pem',type:'spki'}).toString(),publicKeySpkiSha256:sha(key.publicKey.export({format:'der',type:'spki'}))}
|
||||
const policy:ApplicationWriterPolicy={format:'application-writer-policy-v1',installationId,epoch:value,issuedAt:now-1000,notBefore:now-1000,notAfter:now+60000,source:{systemIdentifier:'123',databaseOid:'456',databaseName:'synthetic',serverVersionNum:180004,inRecovery:false},sourceObservationSha256:'a'.repeat(64),roles:{runtime:'11',operator:'12',capture:'13',publisher:'14',control:'15'},manifestSha256:'b'.repeat(64),definitionSha256:'c'.repeat(64),catalogSha256:'d'.repeat(64),futureLeaves:'fixed12-native-policy-v1'}
|
||||
const signBody=(body:unknown,domain:string)=>bytes({body,signature:sign(null,Buffer.from(domain+canonical(body)),key.privateKey).toString('base64url')})
|
||||
function head(epoch:string,policyHash:string,previous:string|null){const head_bytes=signBody({format:'application-writer-head-v1',installationId,epoch,policySha256:policyHash,previousHeadSha256:previous},HEAD_DOMAIN);return {installation_id:installationId,epoch,head_sha256:sha(head_bytes),policy_sha256:policyHash,previous_installation_id:epoch==='1'?null:installationId,previous_epoch:epoch==='1'?null:(BigInt(epoch)-1n).toString(),previous_head_sha256:previous,head_bytes}}
|
||||
const policy_bytes=signBody(policy,POLICY_DOMAIN),policyHash=sha(policy_bytes),previous=value==='1'?undefined:head((BigInt(value)-1n).toString(),'e'.repeat(64),value==='2'?null:'f'.repeat(64)),top=head(value,policyHash,previous?.head_sha256??null)
|
||||
return {subject,policy,signBody,state:{foreign:[] as Record<string,unknown>[],current:[{installation_id:installationId,epoch:value,head_sha256:top.head_sha256}],latest:previous?[top,previous]:[top],policy:[{policy_sha256:policyHash,policy_bytes:policy_bytes as Uint8Array|null}],clock:[{now:String(now)}]}}
|
||||
}
|
||||
export type Fixture=ReturnType<typeof syntheticFixture>
|
||||
export function modelClient(state:Fixture['state']){
|
||||
const trace:{text:string;values?:unknown[]}[]=[]
|
||||
const client:ApplicationWriterRegistryReadSql={async query<R extends Record<string,unknown>=Record<string,unknown>>(text:string,values?:unknown[]){trace.push({text,values:structuredClone(values)});let rows:unknown[]
|
||||
if(text.startsWith('(SELECT installation_id'))rows=state.foreign
|
||||
else if(text.includes('WHERE id=1'))rows=state.current
|
||||
else if(text.includes('ORDER BY epoch DESC LIMIT 2'))rows=state.latest
|
||||
else if(text.startsWith('SELECT policy_sha256,CASE'))rows=state.policy
|
||||
else if(text.includes('clock_timestamp()'))rows=state.clock
|
||||
else throw Error('MODEL unknown SQL; no fake success')
|
||||
return {rows:structuredClone(rows) as R[]}
|
||||
}}
|
||||
return {client,trace}
|
||||
}
|
||||
|
||||
type Row=Record<string,unknown>
|
||||
type State={foreign:Row[];current:Row[];latest:Row[];policy:Row[];clock:Row[]}
|
||||
const keys=['installation_id','epoch','head_sha256','policy_sha256','previous_installation_id','previous_epoch','previous_head_sha256','head_bytes','policy_bytes']
|
||||
function frames(state:State,id:string):Row[]{
|
||||
const output:Row[]=[]
|
||||
const add=(tag:number,rows:Row[])=>rows.forEach((row,index)=>output.push({tag,ordinal:index+1,...Object.fromEntries(keys.map(key=>[key,row[key]??null]))}))
|
||||
add(0,state.foreign.slice(0,2));if(state.foreign.length)return output
|
||||
add(1,state.current.slice(0,2))
|
||||
const c=state.current[0],top=state.latest[0]
|
||||
const valid=state.current.length===1&&c.installation_id===id&&top&&c.epoch===top.epoch&&c.head_sha256===top.head_sha256&&c.installation_id===top.installation_id&&typeof top.epoch==='string'&&/^[1-9][0-9]*$/.test(top.epoch)
|
||||
add(2,state.latest.slice(0,2).map(row=>({...row,head_bytes:valid?row.head_bytes:null})))
|
||||
if(valid)add(3,state.policy.slice(0,2))
|
||||
return output
|
||||
}
|
||||
export function stagedModelClient(state:Fixture['state']){
|
||||
const trace:{text:string;values?:unknown[]}[]=[]
|
||||
const client:ApplicationWriterRegistryReadSql={async query<R extends Record<string,unknown>=Record<string,unknown>>(text:string,values?:unknown[]){
|
||||
trace.push({text,values:structuredClone(values)});let rows:unknown[]
|
||||
if(text.startsWith('WITH')){
|
||||
if(typeof values?.[0]!=='string')throw Error('MODEL installation parameter required')
|
||||
rows=frames(state,values[0])
|
||||
}else if(text.includes('clock_timestamp()'))rows=state.clock
|
||||
else throw Error('MODEL unknown grouped SQL; no fake success')
|
||||
return {rows:structuredClone(rows) as R[]}
|
||||
}}
|
||||
return {client,trace}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
// MODEL ONLY. Models deliberately cannot establish PostgreSQL execution/order.
|
||||
import assert from 'node:assert/strict'
|
||||
import {readFileSync} from 'node:fs'
|
||||
import {createHash} from 'node:crypto'
|
||||
import {syntheticFixture,modelClient,type Fixture} from './application-writer-profile-registry-read-model.test.js'
|
||||
import {readCurrentApplicationWriterProfile as original} from './application-writer-profile-registry-five-query-oracle.test.js'
|
||||
import {readCurrentApplicationWriterProfile as staged,type ApplicationWriterRegistryReadSql} from './application-writer-profile-registry-read.js'
|
||||
import {POLICY_DOMAIN} from './application-writer-profile-format.js'
|
||||
type Row=Record<string,unknown>
|
||||
type State={foreign:Row[];current:Row[];latest:Row[];policy:Row[];clock:Row[]}
|
||||
const keys=['installation_id','epoch','head_sha256','policy_sha256','previous_installation_id','previous_epoch','previous_head_sha256','head_bytes','policy_bytes']
|
||||
function frames(state:State,id:string):Row[]{
|
||||
const output:Row[]=[]
|
||||
const add=(tag:number,rows:Row[])=>rows.forEach((row,index)=>output.push({tag,ordinal:index+1,...Object.fromEntries(keys.map(key=>[key,row[key]??null]))}))
|
||||
add(0,state.foreign.slice(0,2));if(state.foreign.length)return output
|
||||
add(1,state.current.slice(0,2))
|
||||
const c=state.current[0],top=state.latest[0]
|
||||
const valid=state.current.length===1&&c.installation_id===id&&top&&c.epoch===top.epoch&&c.head_sha256===top.head_sha256&&c.installation_id===top.installation_id&&typeof top.epoch==='string'&&/^[1-9][0-9]*$/.test(top.epoch)
|
||||
add(2,state.latest.slice(0,2).map(row=>({...row,head_bytes:valid?row.head_bytes:null})))
|
||||
if(valid)add(3,state.policy.slice(0,2))
|
||||
return output
|
||||
}
|
||||
function groupedModel(state:State,id:string,mutate?:(rows:Row[])=>Row[],clockError?:string){
|
||||
const trace:string[]=[],returned:Row[][]=[]
|
||||
const client:ApplicationWriterRegistryReadSql={async query<R extends Row=Row>(text:string,values?:unknown[]){
|
||||
trace.push(text)
|
||||
if(text.startsWith('WITH')){
|
||||
assert.deepEqual(values,[id]);assert.equal(trace.length,1)
|
||||
const rows=mutate?mutate(frames(state,id)):frames(state,id);returned.push(rows)
|
||||
return {rows:rows as R[]}
|
||||
}
|
||||
assert(text.includes('clock_timestamp()'));assert.equal(trace.length,2)
|
||||
if(clockError)throw Error(clockError)
|
||||
return {rows:structuredClone(state.clock) as R[]}
|
||||
}}
|
||||
return {client,trace,returned}
|
||||
}
|
||||
async function outcome(run:()=>Promise<unknown>){try{return {value:await run()}}catch(error){assert(error instanceof Error);return {error:error.message}}}
|
||||
let cases=0
|
||||
async function parity(name:string,modify:(state:State,f:Fixture)=>void=()=>{},options={}){
|
||||
const f=syntheticFixture(),state=structuredClone(f.state) as State;modify(state,f)
|
||||
const oracle=modelClient(state as Fixture['state']),candidate=groupedModel(state,f.subject.installationId)
|
||||
const a=await outcome(()=>original(oracle.client,f.subject,options)),b=await outcome(()=>staged(candidate.client,f.subject,options))
|
||||
assert.deepEqual(b,a,name);assert.equal(candidate.trace.length,'value' in a&&a.value!==null?2:1,name)
|
||||
cases++;return {state,f,a,candidate}
|
||||
}
|
||||
for(const epoch of ['1','2','9007199254740993','9223372036854775807']){
|
||||
const f=syntheticFixture(epoch),a=modelClient(f.state),b=groupedModel(f.state,f.subject.installationId)
|
||||
assert.deepEqual(await staged(b.client,f.subject),await original(a.client,f.subject));assert.equal(a.trace.length,5);assert.equal(b.trace.length,2);cases++
|
||||
}
|
||||
await parity('empty allowed',s=>{s.current=[];s.latest=[]},{allowEmpty:true})
|
||||
await parity('empty refused',s=>{s.current=[];s.latest=[]})
|
||||
await parity('orphan latest',s=>{s.current=[]})
|
||||
await parity('orphan current',s=>{s.latest=[]})
|
||||
await parity('foreign sentinel',s=>{s.foreign=[{installation_id:'22222222-2222-4222-8222-222222222222'}]})
|
||||
await parity('two foreign sentinels',s=>{s.foreign=[{installation_id:'0'},{installation_id:'2'}]})
|
||||
await parity('duplicate current',s=>{s.current.push({...s.current[0]})})
|
||||
await parity('current epoch mismatch',s=>{s.current[0].epoch='1'})
|
||||
await parity('current hash mismatch',s=>{s.current[0].head_sha256='f'.repeat(64)})
|
||||
await parity('current installation mismatch',s=>{s.current[0].installation_id='22222222-2222-4222-8222-222222222222'})
|
||||
await parity('current epoch NULL',s=>{s.current[0].epoch=null})
|
||||
await parity('latest epoch NULL',s=>{s.latest[0].epoch=null})
|
||||
await parity('missing exact policy',s=>{s.policy=[]})
|
||||
await parity('duplicate exact policy',s=>{s.policy.push({...s.policy[0]})})
|
||||
await parity('oversize SQL-masked policy',s=>{s.policy[0].policy_bytes=null})
|
||||
await parity('oversize SQL-masked current head',s=>{s.latest[0].head_bytes=null})
|
||||
await parity('oversize SQL-masked previous head',s=>{s.latest[1].head_bytes=null})
|
||||
await parity('forged policy',s=>{s.policy[0].policy_bytes=Buffer.from('tampered')})
|
||||
await parity('forged top head',s=>{s.latest[0].head_bytes=Buffer.from('tampered')})
|
||||
await parity('forged previous head',s=>{s.latest[1].head_bytes=Buffer.from('tampered')})
|
||||
await parity('missing predecessor',s=>{s.latest.pop()})
|
||||
await parity('predecessor gap',s=>{s.latest[1].epoch='3'})
|
||||
await parity('predecessor installation mismatch',s=>{s.latest[0].previous_installation_id='22222222-2222-4222-8222-222222222222'})
|
||||
await parity('predecessor link mismatch',s=>{s.latest[0].previous_head_sha256='f'.repeat(64)})
|
||||
await parity('policy hash mismatch',s=>{s.policy[0].policy_sha256='f'.repeat(64)})
|
||||
await parity('raw UTF8 string payloads',s=>{for(const row of s.latest)row.head_bytes=Buffer.from(row.head_bytes as Uint8Array).toString('utf8');s.policy[0].policy_bytes=Buffer.from(s.policy[0].policy_bytes as Uint8Array).toString('utf8')})
|
||||
for(const clock of [[],[{now:'1'},{now:'2'}],[{now:'invalid'}]]){
|
||||
const f=syntheticFixture(),s=structuredClone(f.state) as State;s.clock=clock
|
||||
const a=modelClient(s as Fixture['state']),b=groupedModel(s,f.subject.installationId)
|
||||
assert.deepEqual(await outcome(()=>staged(b.client,f.subject)),await outcome(()=>original(a.client,f.subject)));assert.equal(b.trace.length,2);cases++
|
||||
}
|
||||
{const f=syntheticFixture('1');f.state.latest.push({...f.state.latest[0]});const a=modelClient(f.state),b=groupedModel(f.state,f.subject.installationId);assert.deepEqual(await outcome(()=>staged(b.client,f.subject)),await outcome(()=>original(a.client,f.subject)));assert.equal(b.trace.length,1);cases++}
|
||||
// The clock is later than cryptographic validation, including an expiry that
|
||||
// happens after a snapshot/model response; an expired inspection still returns.
|
||||
for(const validNow of [true,false]){
|
||||
const f=syntheticFixture(),s=structuredClone(f.state) as State;s.clock=[{now:String(f.policy.notAfter+1)}]
|
||||
const a=modelClient(s as Fixture['state']),b=groupedModel(s,f.subject.installationId)
|
||||
assert.deepEqual(await outcome(()=>staged(b.client,f.subject,{validNow})),await outcome(()=>original(a.client,f.subject,{validNow})));assert.equal(b.trace.length,2);cases++
|
||||
}
|
||||
// A supplied subject does not authenticate installation ownership. Reject a
|
||||
// genuine signature for another supplied public key just as the frozen reader.
|
||||
{const f=syntheticFixture(),other=syntheticFixture(),b=groupedModel(f.state,f.subject.installationId),a=modelClient(f.state);assert.deepEqual(await outcome(()=>staged(b.client,{...f.subject,publicKeyPem:other.subject.publicKeyPem,publicKeySpkiSha256:other.subject.publicKeySpkiSha256})),await outcome(()=>original(a.client,{...f.subject,publicKeyPem:other.subject.publicKeyPem,publicKeySpkiSha256:other.subject.publicKeySpkiSha256})));assert.equal(b.trace.length,1);cases++}
|
||||
// Re-signing the policy alone cannot bypass the exact signed head hash binding.
|
||||
await parity('new signed policy without linked head',(s,f)=>{s.policy[0].policy_bytes=f.signBody({...f.policy,catalogSha256:'f'.repeat(64)},POLICY_DOMAIN)})
|
||||
// Direct frame attacks test defensive transport shape validation, not SQL.
|
||||
const attacks:((rows:Row[])=>Row[])[]=[r=>r.reverse(),r=>[...r,r[0]],r=>r.map((v,i)=>i? v:{...v,extra:true}),r=>r.map((v,i)=>i?v:{...v,ordinal:2}),r=>r.map((v,i)=>i?v:{...v,tag:4}),r=>r.map((v,i)=>i?v:{...v,epoch:2}),r=>r.map(v=>v.tag===3?{...v,policy_bytes:{data:[]}}:v),r=>r.map((v,i)=>i?v:{...v,policy_bytes:Buffer.from('leak')})]
|
||||
for(const attack of attacks){const f=syntheticFixture(),b=groupedModel(f.state,f.subject.installationId,attack);await assert.rejects(()=>staged(b.client,f.subject));assert.equal(b.trace.length,1);cases++}
|
||||
for(const section of [1,2,3]){const f=syntheticFixture(),b=groupedModel(f.state,f.subject.installationId,r=>{const rows=r.filter(v=>v.tag===section);return r.filter(v=>Number(v.tag)<section).concat(rows,rows.map((v,i)=>({...v,ordinal:rows.length+i+1})),r.filter(v=>Number(v.tag)>section))});await assert.rejects(()=>staged(b.client,f.subject));assert.equal(b.trace.length,1);cases++}
|
||||
{let calls=0;const f=syntheticFixture(),client:ApplicationWriterRegistryReadSql={async query(){calls++;throw Error('snapshot-query-failed')}};await assert.rejects(()=>staged(client,f.subject),/snapshot-query-failed/);assert.equal(calls,1);cases++}
|
||||
{const f=syntheticFixture(),b=groupedModel(f.state,f.subject.installationId,undefined,'clock-query-failed');await assert.rejects(()=>staged(b.client,f.subject),/clock-query-failed/);assert.equal(b.trace.length,2);cases++}
|
||||
for(const foreign of [false,true]){
|
||||
const f=syntheticFixture(),s=structuredClone(f.state) as State;if(foreign)s.foreign=[{installation_id:'other'}];else s.current[0].epoch='1'
|
||||
const b=groupedModel(s,f.subject.installationId);await assert.rejects(()=>staged(b.client,f.subject));assert.equal(b.trace.length,1)
|
||||
assert(b.returned[0].every(r=>r.head_bytes===null&&r.policy_bytes===null));if(foreign)assert(b.returned[0].every(r=>r.tag===0));cases++
|
||||
}
|
||||
// Pin the frozen TEST oracle independently of the changed production reader.
|
||||
// Normalize CRLF so a checkout's line-ending policy does not change semantics.
|
||||
const oracle=readFileSync(new URL('./application-writer-profile-registry-five-query-oracle.test.ts',import.meta.url),'utf8').replace(/\r\n/g,'\n')
|
||||
assert.equal(createHash('sha256').update(oracle).digest('hex'),'b820596939ce295188564307c41126277752ed65303208f17f9b35eb5bf2322f')
|
||||
// Structural source checks are not a SQL parser/execution proof. The next
|
||||
// owned PostgreSQL acceptance must prove actual suppression and same snapshot.
|
||||
const fixture=syntheticFixture(),sql=groupedModel(fixture.state,fixture.subject.installationId);await staged(sql.client,fixture.subject)
|
||||
assert(sql.trace[0].includes('NOT EXISTS(SELECT 1 FROM foreign_installations)'))
|
||||
assert(sql.trace[0].includes('JOIN current_gate c'))
|
||||
assert(sql.trace[0].includes('CASE WHEN EXISTS(SELECT 1 FROM current_gate)'))
|
||||
assert(sql.trace[0].endsWith('FROM frames ORDER BY tag,ordinal'))
|
||||
console.log(JSON.stringify({modelOnly:true,cases,postgres:false,runtimeAuthority:false,readFacadeCalls:{before:5,after:2},staticBeforeAfterSqlReduction:30,performanceProved:false,writerAuthority:false,ready:false}))
|
||||
@@ -0,0 +1,14 @@
|
||||
// Self-contained MODEL unit. No genuine SQL/ownership/authority claim.
|
||||
import assert from 'node:assert/strict'
|
||||
import {readCurrentApplicationWriterProfile} from './application-writer-profile-registry-read.js'
|
||||
import {syntheticFixture,stagedModelClient as modelClient} from './application-writer-profile-registry-read-model.test.js'
|
||||
export {syntheticFixture,stagedModelClient as modelClient,type Fixture} from './application-writer-profile-registry-read-model.test.js'
|
||||
// Tests execute on direct invocation AND normal test-file import. Exported fixture
|
||||
// helpers are test-only, used by staged parity; never production authority.
|
||||
for(const epoch of ['1','2','9007199254740993','9223372036854775807']){const f=syntheticFixture(epoch),m=modelClient(f.state),value=await readCurrentApplicationWriterProfile(m.client,f.subject);assert(value);assert.equal(value.epoch,epoch);assert.equal(value.headSha256,f.state.current[0].head_sha256);assert.equal(m.trace.length,2);assert.ok(m.trace.every(q=>/^(?:WITH|\(?SELECT)\b/.test(q.text)));assert.deepEqual(Object.keys(value).sort(),['epoch','headSha256','policy','policySha256','serverNow'])}
|
||||
{const f=syntheticFixture('1');f.state.current=[];f.state.latest=[];const m=modelClient(f.state);assert.equal(await readCurrentApplicationWriterProfile(m.client,f.subject,{allowEmpty:true}),null);assert.equal(m.trace.length,1);await assert.rejects(()=>readCurrentApplicationWriterProfile(modelClient(f.state).client,f.subject))}
|
||||
{const f=syntheticFixture();f.state.current[0].epoch='1';await assert.rejects(()=>readCurrentApplicationWriterProfile(modelClient(f.state).client,f.subject),/Current does not match/)}
|
||||
{const f=syntheticFixture();f.state.latest[1].head_bytes=Buffer.from('tampered');await assert.rejects(()=>readCurrentApplicationWriterProfile(modelClient(f.state).client,f.subject))}
|
||||
{const f=syntheticFixture();f.state.policy[0].policy_bytes=null;await assert.rejects(()=>readCurrentApplicationWriterProfile(modelClient(f.state).client,f.subject),/Bounded signed registry payload absent/)}
|
||||
{const f=syntheticFixture();f.state.foreign=[{installation_id:'22222222-2222-4222-8222-222222222222'}];const m=modelClient(f.state);await assert.rejects(()=>readCurrentApplicationWriterProfile(m.client,f.subject),/Foreign installation/);assert.equal(m.trace.length,1)}
|
||||
console.log(JSON.stringify({modelUnit:true,unitGroups:6,postgres:false,currentCasWrite:false,writerAuthorityMinted:false,readyAuthorized:false}))
|
||||
@@ -0,0 +1,128 @@
|
||||
import {epoch,signedEnvelope,liveValidity,type ApplicationWriterPolicy} from './application-writer-profile-format.js'
|
||||
|
||||
// Internal read-side value component ONLY. No configured pool, provider/pin JSON
|
||||
// parser, publish/DDL/CAS write, key loader, role provisioning or authority mint.
|
||||
// Caller retains authenticated backend/source/lock/transaction/lifetime ownership.
|
||||
// Structure/index/ACL checks and protected subject provenance are separate gates.
|
||||
export const HISTORY='application_writer_profile_history',CURRENT='application_writer_profile_current'
|
||||
export interface ApplicationWriterRegistryReadSql {
|
||||
query<R extends Record<string,unknown>=Record<string,unknown>>(text:string,values?:unknown[]):Promise<{rows:R[]}>
|
||||
}
|
||||
type Subject=Parameters<typeof signedEnvelope>[2]
|
||||
interface CurrentRow extends Record<string,unknown> {installation_id:string;epoch:string;head_sha256:string}
|
||||
interface HeadRow extends CurrentRow {policy_sha256:string;previous_installation_id:string|null;previous_epoch:string|null;previous_head_sha256:string|null;head_bytes:Uint8Array|string|null}
|
||||
interface PolicyRow extends Record<string,unknown> {policy_sha256:string;policy_bytes:Uint8Array|string|null}
|
||||
export interface ApplicationWriterRegistryReadOptions {allowEmpty?:boolean;validNow?:boolean}
|
||||
/** Ordinary observed data, NOT a CAS reservation/current-state capability.
|
||||
* Subject must be fixed by a future protected private host, never a request.
|
||||
* validNow=false is reserved for trusted expired-policy inspection/renewal;
|
||||
* it is not a runtime authorization option. No trusted state is created here. */
|
||||
export interface ApplicationWriterRegistryCurrentData {epoch:string;headSha256:string;policy:ApplicationWriterPolicy;policySha256:string;serverNow:number}
|
||||
function payload(value:Uint8Array|string|null):Uint8Array|string {
|
||||
// SQL CASE turns oversize/empty bytes into NULL; reject before parsing. Same
|
||||
// refusal as MJS Buffer.from(null), with an explicit internal diagnostic.
|
||||
if(value===null)throw Error('Bounded signed registry payload absent')
|
||||
return value
|
||||
}
|
||||
// Staged fixed DATA query. Every relation-dependent read shares this statement
|
||||
// snapshot. Foreign/current-invalid metadata never releases policy/head bytes.
|
||||
const FIXED_REGISTRY_SNAPSHOT_SQL=`WITH
|
||||
foreign_installations AS MATERIALIZED (
|
||||
(SELECT installation_id FROM mmapp_control.application_writer_profile_history WHERE installation_id<$1::uuid ORDER BY installation_id DESC LIMIT 1)
|
||||
UNION ALL
|
||||
(SELECT installation_id FROM mmapp_control.application_writer_profile_history WHERE installation_id>$1::uuid ORDER BY installation_id LIMIT 1)),
|
||||
current_rows AS MATERIALIZED (
|
||||
SELECT installation_id,epoch,head_sha256 FROM mmapp_control.application_writer_profile_current
|
||||
WHERE id=1 AND NOT EXISTS(SELECT 1 FROM foreign_installations)
|
||||
ORDER BY installation_id,epoch,head_sha256 LIMIT 2),
|
||||
latest_metadata AS MATERIALIZED (
|
||||
SELECT installation_id,epoch,head_sha256,policy_sha256,previous_installation_id,previous_epoch,previous_head_sha256
|
||||
FROM mmapp_control.application_writer_profile_history
|
||||
WHERE installation_id=$1::uuid AND NOT EXISTS(SELECT 1 FROM foreign_installations)
|
||||
ORDER BY epoch DESC LIMIT 2),
|
||||
current_gate AS MATERIALIZED (
|
||||
SELECT c.* FROM current_rows c JOIN (SELECT * FROM latest_metadata ORDER BY epoch DESC LIMIT 1)t
|
||||
ON c.installation_id=t.installation_id AND c.epoch=t.epoch AND c.head_sha256=t.head_sha256
|
||||
WHERE (SELECT count(*) FROM current_rows)=1 AND c.installation_id=$1::uuid AND t.epoch>0),
|
||||
policy_rows AS MATERIALIZED (
|
||||
SELECT h.policy_sha256,CASE WHEN pg_catalog.octet_length(h.policy_bytes) BETWEEN 1 AND 1048576 THEN h.policy_bytes ELSE NULL END AS policy_bytes
|
||||
FROM mmapp_control.application_writer_profile_history h JOIN current_gate c
|
||||
ON h.installation_id=c.installation_id AND h.epoch=c.epoch AND h.head_sha256=c.head_sha256
|
||||
ORDER BY h.policy_sha256 LIMIT 2),
|
||||
frames AS (
|
||||
SELECT 0::int AS tag,(row_number() OVER(ORDER BY installation_id))::int AS ordinal,installation_id::text,
|
||||
NULL::text AS epoch,NULL::text AS head_sha256,NULL::text AS policy_sha256,NULL::text AS previous_installation_id,
|
||||
NULL::text AS previous_epoch,NULL::text AS previous_head_sha256,NULL::bytea AS head_bytes,NULL::bytea AS policy_bytes
|
||||
FROM foreign_installations
|
||||
UNION ALL
|
||||
SELECT 1,(row_number() OVER(ORDER BY installation_id,epoch,head_sha256))::int,installation_id::text,epoch::text,head_sha256,
|
||||
NULL,NULL,NULL,NULL,NULL::bytea,NULL::bytea FROM current_rows
|
||||
UNION ALL
|
||||
SELECT 2,(row_number() OVER(ORDER BY m.epoch DESC))::int,m.installation_id::text,m.epoch::text,m.head_sha256,m.policy_sha256,
|
||||
m.previous_installation_id::text,m.previous_epoch::text,m.previous_head_sha256,
|
||||
CASE WHEN EXISTS(SELECT 1 FROM current_gate) THEN
|
||||
(SELECT CASE WHEN pg_catalog.octet_length(h.head_bytes) BETWEEN 1 AND 8192 THEN h.head_bytes ELSE NULL END
|
||||
FROM mmapp_control.application_writer_profile_history h
|
||||
WHERE h.installation_id=m.installation_id AND h.epoch=m.epoch AND h.head_sha256=m.head_sha256 LIMIT 1)
|
||||
ELSE NULL::bytea END,NULL::bytea FROM latest_metadata m
|
||||
UNION ALL
|
||||
SELECT 3,(row_number() OVER(ORDER BY policy_sha256))::int,NULL,NULL,NULL,policy_sha256,NULL,NULL,NULL,NULL::bytea,policy_bytes FROM policy_rows)
|
||||
SELECT tag,ordinal,installation_id,epoch,head_sha256,policy_sha256,previous_installation_id,previous_epoch,previous_head_sha256,head_bytes,policy_bytes
|
||||
FROM frames ORDER BY tag,ordinal`
|
||||
|
||||
function decodeSnapshot(rows:Record<string,unknown>[],installationId:string) {
|
||||
const result={foreign:[] as Record<string,unknown>[],current:[] as CurrentRow[],latest:[] as HeadRow[],policy:[] as PolicyRow[]}
|
||||
const fields=['tag','ordinal','installation_id','epoch','head_sha256','policy_sha256','previous_installation_id','previous_epoch','previous_head_sha256','head_bytes','policy_bytes']
|
||||
const used=[['installation_id'],['installation_id','epoch','head_sha256'],['installation_id','epoch','head_sha256','policy_sha256','previous_installation_id','previous_epoch','previous_head_sha256','head_bytes'],['policy_sha256','policy_bytes']]
|
||||
let previousTag=-1,ordinal=0
|
||||
if(!Array.isArray(rows)||rows.length>8)throw Error('Bounded registry snapshot required')
|
||||
for(const row of rows){
|
||||
if(!row||typeof row!=='object'||Reflect.ownKeys(row).length!==fields.length||fields.some(k=>!Object.hasOwn(row,k)))throw Error('Exact registry snapshot frame required')
|
||||
const tag=row.tag
|
||||
if(typeof tag!=='number'||!Number.isInteger(tag)||tag<0||tag>3||tag<previousTag)throw Error('Ordered registry snapshot tags required')
|
||||
if(tag!==previousTag){ordinal=0;previousTag=tag}
|
||||
if(row.ordinal!==++ordinal||ordinal>2)throw Error('Bounded ordered registry snapshot section required')
|
||||
for(const name of fields.slice(2)){
|
||||
const value=row[name]
|
||||
if(!used[tag].includes(name)){if(value!==null)throw Error('Unused registry snapshot field refused');continue}
|
||||
if(name.endsWith('_bytes')){if(value!==null&&typeof value!=='string'&&!(value instanceof Uint8Array))throw Error('Raw registry snapshot bytes required')}
|
||||
else if(value!==null&&typeof value!=='string')throw Error('Text registry snapshot field required')
|
||||
}
|
||||
if(tag===0)result.foreign.push({installation_id:row.installation_id})
|
||||
if(tag===1)result.current.push({installation_id:row.installation_id,epoch:row.epoch,head_sha256:row.head_sha256} as CurrentRow)
|
||||
if(tag===2)result.latest.push(Object.fromEntries(used[2].map(k=>[k,row[k]])) as HeadRow)
|
||||
if(tag===3)result.policy.push({policy_sha256:row.policy_sha256,policy_bytes:row.policy_bytes} as PolicyRow)
|
||||
}
|
||||
// SQL must suppress all dependent sections behind a foreign sentinel, and
|
||||
// payload-bearing sections behind invalid current/top metadata. Fail closed
|
||||
// even if an unexpected transport/model supplies contradictory frames.
|
||||
if(result.foreign.length&&(result.current.length||result.latest.length||result.policy.length))throw Error('Foreign registry snapshot released dependent rows')
|
||||
const c=result.current[0],t=result.latest[0]
|
||||
const matches=result.current.length===1&&t&&c.installation_id===installationId&&c.installation_id===t.installation_id&&c.epoch===t.epoch&&c.head_sha256===t.head_sha256&&typeof t.epoch==='string'&&/^[1-9][0-9]*$/.test(t.epoch)
|
||||
if(!matches&&(result.policy.length||result.latest.some(r=>r.head_bytes!==null)))throw Error('Invalid current registry snapshot released payload')
|
||||
return result
|
||||
}
|
||||
export async function readCurrentApplicationWriterProfile(client:ApplicationWriterRegistryReadSql,installation:Subject,{allowEmpty=false,validNow=true}:ApplicationWriterRegistryReadOptions={}):Promise<ApplicationWriterRegistryCurrentData|null>{
|
||||
const sections=decodeSnapshot((await client.query(FIXED_REGISTRY_SNAPSHOT_SQL,[installation.installationId])).rows,installation.installationId)
|
||||
const {foreign,current,latest}=sections
|
||||
if(foreign.length)throw Error('Foreign installation history refused')
|
||||
if(current.length===0&&latest.length===0&&allowEmpty)return null
|
||||
const c=current[0],top=latest[0]
|
||||
if(current.length!==1||!top||c.installation_id!==installation.installationId||c.epoch!==epoch(top.epoch)||c.head_sha256!==top.head_sha256)throw Error('Current does not match indexed append-only latest')
|
||||
const row=sections.policy
|
||||
if(row.length!==1)throw Error('Exact current policy row absent')
|
||||
const policy=signedEnvelope(payload(row[0].policy_bytes),'policy',installation),head=signedEnvelope(payload(top.head_bytes),'head',installation)
|
||||
if(head.sha256!==c.head_sha256||head.body.epoch!==c.epoch||policy.body.epoch!==c.epoch||head.body.policySha256!==policy.sha256||row[0].policy_sha256!==policy.sha256||top.policy_sha256!==policy.sha256||head.body.previousHeadSha256!==top.previous_head_sha256)throw Error('Exact current signature/hash/link refused')
|
||||
if(c.epoch==='1'){
|
||||
if(latest.length!==1||top.previous_epoch!==null||top.previous_installation_id!==null||top.previous_head_sha256!==null)throw Error('Unique genesis required')
|
||||
}else{
|
||||
const previous=latest[1]
|
||||
if(!previous||BigInt(epoch(previous.epoch))+1n!==BigInt(c.epoch)||top.previous_epoch!==previous.epoch||top.previous_installation_id!==installation.installationId||top.previous_head_sha256!==previous.head_sha256)throw Error('Exact consecutive predecessor required')
|
||||
const before=signedEnvelope(payload(previous.head_bytes),'head',installation)
|
||||
if(before.sha256!==previous.head_sha256||before.body.epoch!==previous.epoch||before.body.policySha256!==previous.policy_sha256||before.body.previousHeadSha256!==previous.previous_head_sha256)throw Error('Historical signed predecessor refused')
|
||||
}
|
||||
const now=(await client.query<{now:string}>("SELECT (EXTRACT(EPOCH FROM pg_catalog.clock_timestamp())*1000)::bigint::text AS now")).rows
|
||||
if(now.length!==1)throw Error('Actual server clock required')
|
||||
if(validNow)liveValidity(policy.body,Number(now[0].now))
|
||||
return {epoch:c.epoch,headSha256:c.head_sha256,policy:policy.body,policySha256:policy.sha256,serverNow:Number(now[0].now)}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
// MODEL metadata only. Never a PG/role/ownership/authority attestor.
|
||||
import assert from 'node:assert/strict'
|
||||
import {readProtectedRegistryStructureData,type RegistryLogicalRole,type RegistryStructureReadSql} from './application-writer-profile-registry-structure.js'
|
||||
type ModelRow=Record<string,unknown>
|
||||
const current='application_writer_profile_current',history='application_writer_profile_history'
|
||||
const logical=['runtime','operator','capture','publisher','control'] as const
|
||||
export function structureFixture(custom=false){
|
||||
const names=custom?['run \"data','operator ; --','capture unicode-ё','pub []','control \"owner']:['barrier_runtime','barrier_operator','barrier_capture','barrier_profile_publisher','barrier_admin']
|
||||
const bindings=Object.fromEntries(logical.map((scope,i)=>[scope,{name:names[i],signedOid:String(1001+i)}])) as Record<RegistryLogicalRole,{name:string;signedOid:string}>
|
||||
const rows:Record<string,ModelRow[]>={}
|
||||
rows.roles=logical.filter(scope=>scope!=='control').map(scope=>({rolname:bindings[scope].name,oid:bindings[scope].signedOid,rolinherit:true,rolcanlogin:true,unsafe:false,db_owner:false,membership:false,predefined_member:false,db_create:false,db_temp:false,set_replication:false,set_event:false,source_read:scope==='operator'||scope==='publisher'})).sort((a,b)=>a.rolname.localeCompare(b.rolname))
|
||||
const fileFunctions=["pg_catalog.lo_import(text)", "pg_catalog.lo_import(text,oid)", "pg_catalog.lo_export(oid,text)", "pg_catalog.pg_read_file(text)", "pg_catalog.pg_read_file(text,boolean)", "pg_catalog.pg_read_file(text,bigint,bigint)", "pg_catalog.pg_read_file(text,bigint,bigint,boolean)", "pg_catalog.pg_read_binary_file(text)", "pg_catalog.pg_read_binary_file(text,boolean)", "pg_catalog.pg_read_binary_file(text,bigint,bigint)", "pg_catalog.pg_read_binary_file(text,bigint,bigint,boolean)", "pg_catalog.pg_ls_dir(text)", "pg_catalog.pg_ls_dir(text,boolean,boolean)", "pg_catalog.pg_stat_file(text)", "pg_catalog.pg_stat_file(text,boolean)"]
|
||||
rows.fileAcl=rows.roles.flatMap(role=>fileFunctions.map((signature,i)=>({rolname:role.rolname,signature,function_oid:String(2001+i),execute:false}))).sort((a,b)=>(String(a.rolname)+a.signature).localeCompare(String(b.rolname)+b.signature))
|
||||
rows.privileged=[{rolname:bindings.operator.name,nspname:'mmapp_control',proname:'candidate_truncate_preadmitted',oid:'3001',arguments:0}]
|
||||
rows.tables=[current,history].map((relname,i)=>({oid:String(5001+i),relname,relkind:'r',relpersistence:'p',relrowsecurity:false,relforcerowsecurity:false,relhasrules:false,owner:bindings.control.name,inherited:false,user_trigger:false}))
|
||||
rows.owner=[{oid:bindings.control.signedOid,name:bindings.control.name}]
|
||||
const fields:Record<string,[string,string,boolean][]>={"application_writer_profile_history": [["installation_id", "uuid", true], ["epoch", "int8", true], ["head_sha256", "text", true], ["previous_installation_id", "uuid", false], ["previous_epoch", "int8", false], ["previous_head_sha256", "text", false], ["policy_sha256", "text", true], ["policy_bytes", "bytea", true], ["head_bytes", "bytea", true]], "application_writer_profile_current": [["id", "int2", true], ["installation_id", "uuid", true], ["epoch", "int8", true], ["head_sha256", "text", true]]}
|
||||
rows.columns=[current,history].flatMap(relname=>fields[relname].map(([attname,typname,attnotnull])=>({relname,attname,typname,type_namespace:'pg_catalog',attnotnull,attidentity:'',attgenerated:'',column_acl:false,has_default:false})))
|
||||
rows.acl=[]
|
||||
for(const relname of [current,history])for(const [grantee,permissions]of [[bindings.control.name,['DELETE','INSERT','MAINTAIN','REFERENCES','SELECT','TRIGGER','TRUNCATE','UPDATE']],[bindings.operator.name,['SELECT']],[bindings.publisher.name,relname===current?['INSERT','SELECT','UPDATE']:['INSERT','SELECT']]] as [string,string[]][])for(const privilege_type of permissions)rows.acl.push({relname,grantee,grantor:bindings.control.name,privilege_type,is_grantable:false})
|
||||
rows.constraints=[]
|
||||
function constraint(relname:string,contype:string,definition:string,keys:number[],referenced:string|null=null,referenced_keys:number[]|null=null,confmatchtype=' '){return {relname,contype,convalidated:true,condeferrable:false,condeferred:false,confmatchtype,keys,referenced,referenced_keys,definition}}
|
||||
rows.constraints.push(constraint(current,'c','CHECK ((id = 1))',[1]),constraint(current,'f','FOREIGN KEY (installation_id, epoch, head_sha256) REFERENCES mmapp_control.application_writer_profile_history(installation_id, epoch, head_sha256)',[2,3,4],history,[1,2,3],'s'),constraint(current,'p','PRIMARY KEY (id)',[1]))
|
||||
for(const definition of ["CHECK ((epoch > 0))", "CHECK ((head_sha256 ~ '^[a-f0-9]{64}$'::text))", "CHECK ((policy_sha256 ~ '^[a-f0-9]{64}$'::text))", "CHECK (((octet_length(policy_bytes) >= 1) AND (octet_length(policy_bytes) <= 1048576)))", "CHECK (((octet_length(head_bytes) >= 1) AND (octet_length(head_bytes) <= 8192)))", "CHECK ((((epoch = 1) AND (previous_installation_id IS NULL) AND (previous_epoch IS NULL) AND (previous_head_sha256 IS NULL)) OR ((epoch > 1) AND (previous_installation_id IS NOT NULL) AND (previous_installation_id = installation_id) AND (previous_epoch IS NOT NULL) AND (previous_epoch = (epoch - 1)) AND (previous_head_sha256 IS NOT NULL))))"])rows.constraints.push(constraint(history,'c',definition,[1]))
|
||||
rows.constraints.push(constraint(history,'f','FOREIGN KEY (previous_installation_id, previous_epoch, previous_head_sha256) REFERENCES mmapp_control.application_writer_profile_history(installation_id, epoch, head_sha256) MATCH FULL',[4,5,6],history,[1,2,3],'f'),constraint(history,'p','PRIMARY KEY (installation_id, epoch)',[1,2]),constraint(history,'u','UNIQUE (installation_id, epoch, head_sha256)',[1,2,3]))
|
||||
rows.triggers=[...['RI_FKey_check_ins','RI_FKey_check_upd'].map(proname=>({relname:current,tgisinternal:true,tgenabled:'O',nspname:'pg_catalog',proname})),...['RI_FKey_check_ins','RI_FKey_check_upd','RI_FKey_noaction_del','RI_FKey_noaction_upd','RI_FKey_noaction_del','RI_FKey_noaction_upd'].map(proname=>({relname:history,tgisinternal:true,tgenabled:'O',nspname:'pg_catalog',proname}))]
|
||||
rows.indexes=[{relname:current,indisprimary:true,keys:[1]},{relname:history,indisprimary:false,keys:[1,2,3]},{relname:history,indisprimary:true,keys:[1,2]}].map(row=>({...row,indisunique:true,indisvalid:true,indisready:true,indislive:true,unconditional:true,simple:true,amname:'btree'}))
|
||||
return {bindings,rows}
|
||||
}
|
||||
export function structureModel(fixture:ReturnType<typeof structureFixture>,options:{firstQuery?:()=>void;constraintError?:Error;restoreError?:Error}={}){
|
||||
const trace:{sql:string;values?:unknown[]}[]=[]
|
||||
const client:RegistryStructureReadSql={async query<R extends Record<string,unknown>=Record<string,unknown>>(sql:string,values?:unknown[]){trace.push({sql,values:structuredClone(values)});if(trace.length===1)options.firstQuery?.();let key:string
|
||||
if(sql.startsWith('SELECT r.rolname,r.oid::text AS oid,r.rolinherit'))key='roles'
|
||||
else if(sql.startsWith('SELECT r.rolname,f.signature'))key='fileAcl'
|
||||
else if(sql.startsWith('SELECT r.rolname,n.nspname,p.proname'))key='privileged'
|
||||
else if(sql.startsWith('SELECT c.oid::text AS oid,c.relname'))key='tables'
|
||||
else if(sql.startsWith('SELECT n.nspowner::text AS oid')){const owner=fixture.rows.owner;return {rows:structuredClone(sql.includes(' AS name')?owner:owner.map(row=>({oid:row.oid}))) as R[]}}
|
||||
else if(sql.startsWith('SELECT c.relname,a.attname'))key='columns'
|
||||
else if(sql.startsWith('SELECT c.relname,grantee.rolname'))key='acl'
|
||||
else if(sql.startsWith("SELECT pg_catalog.current_setting('search_path')"))return {rows:[{path:'model_outer_local'}] as unknown as R[]}
|
||||
else if(sql.startsWith('SELECT c.relname,k.contype')){if(options.constraintError)throw options.constraintError;key='constraints'}
|
||||
else if(sql.startsWith("SELECT pg_catalog.set_config('search_path',$1,true)")){if(options.restoreError)throw options.restoreError;return {rows:[]}}
|
||||
else if(sql.startsWith('SELECT c.relname,t.tgisinternal'))key='triggers'
|
||||
else if(sql.startsWith('SELECT c.relname,i.indisunique'))key='indexes'
|
||||
else throw Error('MODEL unrecognized SQL; no fake success')
|
||||
return {rows:structuredClone(fixture.rows[key]) as R[]}
|
||||
}}
|
||||
return {client,trace}
|
||||
}
|
||||
const fixture=structureFixture(),model=structureModel(fixture),result=await readProtectedRegistryStructureData(model.client,fixture.bindings)
|
||||
assert.match(result.sha256,/^[a-f0-9]{64}$/);assert.equal(model.trace.length,12);assert.deepEqual(result.roles,Object.fromEntries(logical.map(scope=>[scope,fixture.bindings[scope].signedOid])));assert.deepEqual(Object.keys(result).sort(),['roles','sha256'])
|
||||
{const f=structureFixture(true),m=structureModel(f);await readProtectedRegistryStructureData(m.client,f.bindings);for(const row of m.trace)for(const pair of Object.values(f.bindings))assert(!row.sql.includes(pair.name),'configured names must be bound DATA only')}
|
||||
{const f=structureFixture(),m=structureModel(f,{firstQuery(){f.bindings.operator.name='mutated_after_await';f.bindings.control.signedOid='9999'}});await readProtectedRegistryStructureData(m.client,f.bindings)}
|
||||
for(const mutation of ['owner-name','grant-option','fk','index'] as const){const f=structureFixture();if(mutation==='owner-name')f.rows.owner[0].name='renamed_control';if(mutation==='grant-option')f.rows.acl[0].is_grantable=true;if(mutation==='fk')f.rows.constraints.find(r=>r.contype==='f'&&r.relname===history)!.confmatchtype='s';if(mutation==='index')f.rows.indexes[0].indisvalid=false;await assert.rejects(()=>readProtectedRegistryStructureData(structureModel(f).client,f.bindings))}
|
||||
console.log(JSON.stringify({modelUnit:true,groups:4,alternateNamesAreDataOnly:true,immutableBeforeAwait:true,postgres:false,authority:false}))
|
||||
@@ -0,0 +1,136 @@
|
||||
import {canonical,sha} from './application-writer-profile-canonical.js'
|
||||
|
||||
// Private read-side DATA only. No role provisioning, pool/provider, public JSON
|
||||
// pin parser, keyloader/publish/CAS/DDL, context ownership or writer authority.
|
||||
export type RegistryLogicalRole='runtime'|'operator'|'capture'|'publisher'|'control'
|
||||
export interface RegistryRoleBindingData {readonly name:string;readonly signedOid:string}
|
||||
export type RegistryRoleBindingsData=Readonly<Record<RegistryLogicalRole,Readonly<RegistryRoleBindingData>>>
|
||||
export interface RegistryStructureReadSql {query<R extends Record<string,unknown>=Record<string,unknown>>(text:string,values?:unknown[]):Promise<{rows:R[]}>}
|
||||
export interface RegistryStructureReadData {readonly sha256:string;readonly roles:Readonly<Record<RegistryLogicalRole,string>>}
|
||||
const logicalRoles=['runtime','operator','capture','publisher','control'] as const
|
||||
const actorRoles=['runtime','operator','capture','publisher'] as const
|
||||
export const HISTORY='application_writer_profile_history',CURRENT='application_writer_profile_current'
|
||||
type RegistryTable=typeof HISTORY|typeof CURRENT
|
||||
type Row=Record<string,unknown>
|
||||
interface RoleRow extends Row {rolname:string;oid:string;rolinherit:boolean;rolcanlogin:boolean;unsafe:boolean;db_owner:boolean;membership:boolean;predefined_member:boolean;db_create:boolean;db_temp:boolean;set_replication:boolean;set_event:boolean;source_read:boolean}
|
||||
interface FileAclRow extends Row {rolname:string;signature:string;function_oid:string|null;execute:boolean}
|
||||
interface PrivilegedFunctionRow extends Row {rolname:string;nspname:string;proname:string;oid:string;arguments:number}
|
||||
interface TableRow extends Row {oid:string;relname:RegistryTable;relkind:string;relpersistence:string;relrowsecurity:boolean;relforcerowsecurity:boolean;relhasrules:boolean;owner:string;inherited:boolean;user_trigger:boolean}
|
||||
interface OwnerRow extends Row {oid:string;name:string}
|
||||
interface ColumnRow extends Row {relname:RegistryTable;attname:string;typname:string;type_namespace:string;attnotnull:boolean;attidentity:string;attgenerated:string;column_acl:boolean;has_default:boolean}
|
||||
interface AclRow extends Row {relname:RegistryTable;grantee:string|null;grantor:string;privilege_type:string;is_grantable:boolean}
|
||||
interface ConstraintRow extends Row {relname:RegistryTable;contype:string;convalidated:boolean;condeferrable:boolean;condeferred:boolean;confmatchtype:string;keys:number[];referenced:string|null;referenced_keys:number[]|null;definition:string}
|
||||
interface TriggerRow extends Row {relname:RegistryTable;tgisinternal:boolean;tgenabled:string;nspname:string;proname:string}
|
||||
interface IndexRow extends Row {relname:RegistryTable;indisunique:boolean;indisprimary:boolean;indisvalid:boolean;indisready:boolean;indislive:boolean;keys:number[];unconditional:boolean;simple:boolean;amname:string}
|
||||
const same=(a:unknown,b:unknown,phase:string)=>{if(canonical(a)!==canonical(b))throw Error('Protected registry refused: '+phase)}
|
||||
function object(value:unknown):value is Record<string,unknown>{return value!==null&&typeof value==='object'&&!Array.isArray(value)&&Object.getPrototypeOf(value)===Object.prototype}
|
||||
function exactKeys(value:Record<string,unknown>,keys:readonly string[]){const actual=Reflect.ownKeys(value);return actual.every(k=>typeof k==='string')&&actual.length===keys.length&&actual.slice().sort().join('\0')===keys.slice().sort().join('\0')}
|
||||
function captureBindings(input:unknown):RegistryRoleBindingsData {
|
||||
if(!object(input)||!exactKeys(input,logicalRoles))throw Error('Exact five private role bindings required')
|
||||
const names=new Set<string>(),oids=new Set<string>(),captured={} as Record<RegistryLogicalRole,Readonly<RegistryRoleBindingData>>
|
||||
for(const scope of logicalRoles){
|
||||
const pair=input[scope]
|
||||
if(!object(pair)||!exactKeys(pair,['name','signedOid']))throw Error('Exact private role name/OID pair required')
|
||||
// Read each property ONCE, validate primitives, and never touch caller data
|
||||
// after the first await. Freeze our own copies, not the caller's objects.
|
||||
const name=pair.name,signedOid=pair.signedOid
|
||||
if(typeof name!=='string'||!name||name.includes('\0')||Buffer.byteLength(name,'utf8')>63||Buffer.from(name,'utf8').toString('utf8')!==name||typeof signedOid!=='string'||!(/^[1-9][0-9]{0,9}$/).test(signedOid)||BigInt(signedOid)>4294967295n||names.has(name)||oids.has(signedOid))throw Error('Distinct canonical private role identities required')
|
||||
names.add(name);oids.add(signedOid);captured[scope]=Object.freeze({name,signedOid})
|
||||
}
|
||||
return Object.freeze(captured)
|
||||
}
|
||||
function constraintSql(request:string):string {
|
||||
// Exactly ONE fixed query-owned deparser call, not a caller SQL assembly API.
|
||||
const expression='pg_catalog.pg_get_constraintdef(k.oid)'
|
||||
if(request.split(expression).length!==2)throw Error('Fixed registry deparser template changed')
|
||||
return request.replace(expression,"pg_catalog.pg_get_constraintdef(CASE WHEN pg_catalog.set_config('search_path','pg_catalog, public',true) IS NOT NULL THEN k.oid ELSE NULL END)")
|
||||
}
|
||||
const fields:Record<RegistryTable,readonly (readonly [string,string,boolean])[]>={
|
||||
[HISTORY]:[['installation_id','uuid',true],['epoch','int8',true],['head_sha256','text',true],['previous_installation_id','uuid',false],['previous_epoch','int8',false],['previous_head_sha256','text',false],['policy_sha256','text',true],['policy_bytes','bytea',true],['head_bytes','bytea',true]],
|
||||
[CURRENT]:[['id','int2',true],['installation_id','uuid',true],['epoch','int8',true],['head_sha256','text',true]],
|
||||
}
|
||||
export async function readProtectedRegistryStructureData(client:RegistryStructureReadSql,input:RegistryRoleBindingsData):Promise<RegistryStructureReadData>{
|
||||
const captured=captureBindings(input),scopes=Object.fromEntries(logicalRoles.map(scope=>[scope,captured[scope].name])) as Record<RegistryLogicalRole,string>
|
||||
const signedOids=Object.fromEntries(logicalRoles.map(scope=>[scope,captured[scope].signedOid])) as Record<RegistryLogicalRole,string>
|
||||
const actors=actorRoles.map(scope=>scopes[scope]),OPERATOR=scopes.operator,PUBLISHER=scopes.publisher
|
||||
const roleRows=(await client.query<RoleRow>(`SELECT r.rolname,r.oid::text AS oid,r.rolinherit,r.rolcanlogin,(r.rolsuper OR r.rolcreatedb OR r.rolcreaterole OR r.rolreplication OR r.rolbypassrls) AS unsafe,
|
||||
EXISTS(SELECT 1 FROM pg_catalog.pg_database d WHERE d.datdba=r.oid) AS db_owner,
|
||||
EXISTS(SELECT 1 FROM pg_catalog.pg_auth_members m WHERE m.member=r.oid OR m.roleid=r.oid) AS membership,
|
||||
EXISTS(SELECT 1 FROM pg_catalog.pg_roles p WHERE p.rolname~'^pg_' AND pg_catalog.pg_has_role(r.oid,p.oid,'MEMBER')) AS predefined_member,
|
||||
pg_catalog.has_database_privilege(r.oid,(SELECT oid FROM pg_catalog.pg_database WHERE datname=pg_catalog.current_database()),'CREATE') AS db_create,
|
||||
pg_catalog.has_database_privilege(r.oid,(SELECT oid FROM pg_catalog.pg_database WHERE datname=pg_catalog.current_database()),'TEMP') AS db_temp,
|
||||
pg_catalog.has_parameter_privilege(r.oid,'session_replication_role','SET') AS set_replication,
|
||||
pg_catalog.has_parameter_privilege(r.oid,'event_triggers','SET') AS set_event,
|
||||
pg_catalog.has_function_privilege(r.oid,'pg_catalog.pg_control_system()','EXECUTE') AS source_read
|
||||
FROM pg_catalog.pg_roles r WHERE r.rolname=ANY($1::text[]) ORDER BY r.rolname`,[actors])).rows
|
||||
if(roleRows.length!==4)throw Error('Exact subject/publisher roles required')
|
||||
for(const row of roleRows){const scope=Object.entries(scopes).find(([,name])=>name===row.rolname)?.[0] as RegistryLogicalRole|undefined;if(!scope||row.oid!==signedOids[scope]||row.rolinherit!==true||row.rolcanlogin!==true||['unsafe','db_owner','membership','predefined_member','db_create','db_temp','set_replication','set_event'].some(k=>row[k]!==false)||[PUBLISHER,OPERATOR].includes(row.rolname)&&row.source_read!==true)throw Error('Role ownership/membership/file-program/parameter escape refused')}
|
||||
const fileFunctions=['pg_catalog.lo_import(text)','pg_catalog.lo_import(text,oid)','pg_catalog.lo_export(oid,text)','pg_catalog.pg_read_file(text)','pg_catalog.pg_read_file(text,boolean)','pg_catalog.pg_read_file(text,bigint,bigint)','pg_catalog.pg_read_file(text,bigint,bigint,boolean)','pg_catalog.pg_read_binary_file(text)','pg_catalog.pg_read_binary_file(text,boolean)','pg_catalog.pg_read_binary_file(text,bigint,bigint)','pg_catalog.pg_read_binary_file(text,bigint,bigint,boolean)','pg_catalog.pg_ls_dir(text)','pg_catalog.pg_ls_dir(text,boolean,boolean)','pg_catalog.pg_stat_file(text)','pg_catalog.pg_stat_file(text,boolean)']
|
||||
const fileAcl=(await client.query<FileAclRow>(`SELECT r.rolname,f.signature,pg_catalog.to_regprocedure(f.signature)::oid::text AS function_oid,
|
||||
pg_catalog.has_function_privilege(r.oid,pg_catalog.to_regprocedure(f.signature)::oid,'EXECUTE') AS execute
|
||||
FROM pg_catalog.pg_roles r CROSS JOIN pg_catalog.unnest($2::text[])f(signature) WHERE r.rolname=ANY($1::text[]) ORDER BY r.rolname,f.signature`,[actors,fileFunctions])).rows
|
||||
if(fileAcl.length!==actors.length*fileFunctions.length||fileAcl.some(row=>row.function_oid===null||row.execute!==false))throw Error('Direct large-object/server-file function grant refused')
|
||||
const privilegedFunctions=(await client.query<PrivilegedFunctionRow>(`SELECT r.rolname,n.nspname,p.proname,p.oid::text AS oid,p.pronargs::int AS arguments
|
||||
FROM pg_catalog.pg_roles r CROSS JOIN pg_catalog.pg_proc p JOIN pg_catalog.pg_namespace n ON n.oid=p.pronamespace
|
||||
WHERE r.rolname=ANY($1::text[]) AND p.prosecdef AND pg_catalog.has_schema_privilege(r.oid,n.oid,'USAGE')
|
||||
AND pg_catalog.has_function_privilege(r.oid,p.oid,'EXECUTE') ORDER BY r.rolname,n.nspname,p.proname,p.oid LIMIT 9`,[actors])).rows
|
||||
if(privilegedFunctions.length!==1||privilegedFunctions.some(row=>row.rolname!==OPERATOR||row.nspname!=='mmapp_control'||row.proname!=='candidate_truncate_preadmitted'||row.arguments!==0))throw Error('Unknown reachable SECURITY DEFINER escape refused')
|
||||
const tables=(await client.query<TableRow>(`SELECT c.oid::text AS oid,c.relname,c.relkind,c.relpersistence,c.relrowsecurity,c.relforcerowsecurity,c.relhasrules,r.rolname AS owner,
|
||||
EXISTS(SELECT 1 FROM pg_catalog.pg_inherits i WHERE i.inhrelid=c.oid OR i.inhparent=c.oid) AS inherited,
|
||||
EXISTS(SELECT 1 FROM pg_catalog.pg_trigger t WHERE t.tgrelid=c.oid AND NOT t.tgisinternal) AS user_trigger
|
||||
FROM pg_catalog.pg_class c JOIN pg_catalog.pg_namespace n ON n.oid=c.relnamespace JOIN pg_catalog.pg_roles r ON r.oid=c.relowner
|
||||
WHERE n.nspname='mmapp_control' AND c.relname=ANY($1::text[]) ORDER BY c.relname`,[[CURRENT,HISTORY]])).rows
|
||||
if(tables.length!==2||tables.some(t=>t.relkind!=='r'||t.relpersistence!=='p'||t.relrowsecurity||t.relforcerowsecurity||t.user_trigger||t.relhasrules||t.inherited||t.owner!==scopes.control))throw Error('Protected fixed relation ownership/structure refused')
|
||||
const owner=(await client.query<OwnerRow>("SELECT n.nspowner::text AS oid,pg_catalog.pg_get_userbyid(n.nspowner) AS name FROM pg_catalog.pg_namespace n WHERE n.nspname='mmapp_control'")).rows
|
||||
if(owner.length!==1||owner[0].oid!==signedOids.control||owner[0].name!==scopes.control)throw Error('Protected control namespace owner refused')
|
||||
const columns=(await client.query<ColumnRow>(`SELECT c.relname,a.attname,t.typname,tn.nspname AS type_namespace,a.attnotnull,a.attidentity,a.attgenerated,a.attacl IS NOT NULL AS column_acl,d.oid IS NOT NULL AS has_default
|
||||
FROM pg_catalog.pg_attribute a JOIN pg_catalog.pg_class c ON c.oid=a.attrelid JOIN pg_catalog.pg_namespace n ON n.oid=c.relnamespace
|
||||
JOIN pg_catalog.pg_type t ON t.oid=a.atttypid JOIN pg_catalog.pg_namespace tn ON tn.oid=t.typnamespace LEFT JOIN pg_catalog.pg_attrdef d ON d.adrelid=c.oid AND d.adnum=a.attnum
|
||||
WHERE n.nspname='mmapp_control' AND c.relname=ANY($1::text[]) AND a.attnum>0 AND NOT a.attisdropped ORDER BY c.relname,a.attnum`,[[CURRENT,HISTORY]])).rows
|
||||
for(const table of tables){const c=columns.filter(c=>c.relname===table.relname);same(c.map(r=>[r.attname,r.typname,r.attnotnull]),fields[table.relname],'fixed columns');if(c.some(c=>c.has_default||c.attidentity||c.attgenerated||c.column_acl||c.type_namespace!=='pg_catalog'))throw Error('Registry expression/default/column ACL/type refused')}
|
||||
const acl=(await client.query<AclRow>(`SELECT c.relname,grantee.rolname AS grantee,grantor.rolname AS grantor,a.privilege_type,a.is_grantable
|
||||
FROM pg_catalog.pg_class c JOIN pg_catalog.pg_namespace n ON n.oid=c.relnamespace
|
||||
CROSS JOIN LATERAL pg_catalog.aclexplode(c.relacl) a LEFT JOIN pg_catalog.pg_roles grantee ON grantee.oid=a.grantee
|
||||
JOIN pg_catalog.pg_roles grantor ON grantor.oid=a.grantor WHERE n.nspname='mmapp_control' AND c.relname=ANY($1::text[])
|
||||
ORDER BY c.relname,grantee.rolname,a.privilege_type`,[[CURRENT,HISTORY]])).rows
|
||||
const expected:AclRow[]=[]
|
||||
for(const table of [CURRENT,HISTORY] as const)for(const [grantee,permissions]of [[scopes.control,['DELETE','INSERT','MAINTAIN','REFERENCES','SELECT','TRIGGER','TRUNCATE','UPDATE']],[OPERATOR,['SELECT']],[PUBLISHER,table===CURRENT?['INSERT','SELECT','UPDATE']:['INSERT','SELECT']]] as [string,string[]][])for(const privilege of permissions)expected.push({relname:table,grantee,grantor:scopes.control,privilege_type:privilege,is_grantable:false})
|
||||
const order=<T>(x:readonly T[])=>x.slice().sort((a,b)=>canonical(a).localeCompare(canonical(b)));same(order(acl),order(expected),'append-only effective grant boundary')
|
||||
const request=`SELECT c.relname,k.contype,k.convalidated,k.condeferrable,k.condeferred,k.confmatchtype,
|
||||
k.conkey::int[] AS keys,ref.relname AS referenced,k.confkey::int[] AS referenced_keys,pg_catalog.pg_get_constraintdef(k.oid) AS definition
|
||||
FROM pg_catalog.pg_constraint k JOIN pg_catalog.pg_class c ON c.oid=k.conrelid JOIN pg_catalog.pg_namespace n ON n.oid=c.relnamespace
|
||||
LEFT JOIN pg_catalog.pg_class ref ON ref.oid=k.confrelid WHERE n.nspname='mmapp_control' AND c.relname=ANY($1::text[]) AND k.contype<>'n'
|
||||
ORDER BY c.relname,k.contype,k.conname`
|
||||
const prior=(await client.query<{path:string}>("SELECT pg_catalog.current_setting('search_path') AS path")).rows[0].path
|
||||
let constraints:ConstraintRow[],primary:unknown,failed=false
|
||||
try{constraints=(await client.query<ConstraintRow>(constraintSql(request),[[CURRENT,HISTORY]])).rows}catch(error){primary=error;failed=true;throw error}finally{try{await client.query("SELECT pg_catalog.set_config('search_path',$1,true)",[prior])}catch(error){if(failed)throw new AggregateError([primary,error],'Registry query and LOCAL context restoration failed');throw error}}
|
||||
if(constraints.some(c=>!c.convalidated||c.condeferrable||c.condeferred))throw Error('Unvalidated/deferred registry constraint refused')
|
||||
for(const table of [CURRENT,HISTORY] as const){
|
||||
const c=constraints.filter(c=>c.relname===table),kinds=c.map(c=>c.contype).sort().join('')
|
||||
if(kinds!==(table===CURRENT?'cfp':'ccccccfpu'))throw Error('Exact registry constraint set required')
|
||||
const fk=c.find(c=>c.contype==='f')!;same(fk.keys,table===CURRENT?[2,3,4]:[4,5,6],'predecessor FK columns');same(fk.referenced_keys,[1,2,3],'predecessor FK target columns');if(fk.referenced!==HISTORY||fk.confmatchtype!==(table===CURRENT?'s':'f'))throw Error('Exact predecessor/current FK required')
|
||||
same(fk.definition,table===CURRENT?'FOREIGN KEY (installation_id, epoch, head_sha256) REFERENCES mmapp_control.application_writer_profile_history(installation_id, epoch, head_sha256)':'FOREIGN KEY (previous_installation_id, previous_epoch, previous_head_sha256) REFERENCES mmapp_control.application_writer_profile_history(installation_id, epoch, head_sha256) MATCH FULL','fixed FK actions/definition')
|
||||
const pk=c.find(c=>c.contype==='p')!;same(pk.keys,table===CURRENT?[1]:[1,2],'registry primary key')
|
||||
same(pk.definition,table===CURRENT?'PRIMARY KEY (id)':'PRIMARY KEY (installation_id, epoch)','fixed PK definition')
|
||||
if(table===HISTORY){same(c.find(c=>c.contype==='u')!.keys,[1,2,3],'registry head unique key');same(c.find(c=>c.contype==='u')!.definition,'UNIQUE (installation_id, epoch, head_sha256)','fixed unique definition')}
|
||||
}
|
||||
const checks:Record<RegistryTable,string[]>={
|
||||
[CURRENT]:['CHECK ((id = 1))'],
|
||||
[HISTORY]:[
|
||||
'CHECK ((epoch > 0))',
|
||||
"CHECK ((head_sha256 ~ '^[a-f0-9]{64}$'::text))",
|
||||
"CHECK ((policy_sha256 ~ '^[a-f0-9]{64}$'::text))",
|
||||
'CHECK (((octet_length(policy_bytes) >= 1) AND (octet_length(policy_bytes) <= 1048576)))',
|
||||
'CHECK (((octet_length(head_bytes) >= 1) AND (octet_length(head_bytes) <= 8192)))',
|
||||
'CHECK ((((epoch = 1) AND (previous_installation_id IS NULL) AND (previous_epoch IS NULL) AND (previous_head_sha256 IS NULL)) OR ((epoch > 1) AND (previous_installation_id IS NOT NULL) AND (previous_installation_id = installation_id) AND (previous_epoch IS NOT NULL) AND (previous_epoch = (epoch - 1)) AND (previous_head_sha256 IS NOT NULL))))',
|
||||
],
|
||||
}
|
||||
for(const table of [CURRENT,HISTORY] as const)same(constraints.filter(c=>c.relname===table&&c.contype==='c').map(c=>c.definition).sort(),checks[table].sort(),'fixed PG18 CHECK definitions')
|
||||
const triggers=(await client.query<TriggerRow>(`SELECT c.relname,t.tgisinternal,t.tgenabled,nf.nspname,p.proname FROM pg_catalog.pg_trigger t JOIN pg_catalog.pg_class c ON c.oid=t.tgrelid JOIN pg_catalog.pg_namespace n ON n.oid=c.relnamespace JOIN pg_catalog.pg_proc p ON p.oid=t.tgfoid JOIN pg_catalog.pg_namespace nf ON nf.oid=p.pronamespace WHERE n.nspname='mmapp_control' AND c.relname=ANY($1::text[]) ORDER BY c.relname,p.proname`,[[CURRENT,HISTORY]])).rows
|
||||
if(triggers.filter(t=>t.relname===CURRENT).length!==2||triggers.filter(t=>t.relname===HISTORY).length!==6||triggers.some(t=>!t.tgisinternal||t.tgenabled!=='O'||t.nspname!=='pg_catalog'||!['RI_FKey_check_ins','RI_FKey_check_upd','RI_FKey_noaction_del','RI_FKey_noaction_upd'].includes(t.proname)))throw Error('Protected registry FK triggers refused')
|
||||
const indexes=(await client.query<IndexRow>(`SELECT c.relname,i.indisunique,i.indisprimary,i.indisvalid,i.indisready,i.indislive,i.indkey::int[] AS keys,i.indpred IS NULL AS unconditional,i.indexprs IS NULL AS simple,a.amname FROM pg_catalog.pg_index i JOIN pg_catalog.pg_class c ON c.oid=i.indrelid JOIN pg_catalog.pg_namespace n ON n.oid=c.relnamespace JOIN pg_catalog.pg_class x ON x.oid=i.indexrelid JOIN pg_catalog.pg_am a ON a.oid=x.relam WHERE n.nspname='mmapp_control' AND c.relname=ANY($1::text[]) ORDER BY c.relname,i.indisprimary`,[[CURRENT,HISTORY]])).rows
|
||||
if(indexes.length!==3||indexes.some(i=>!i.indisunique||!i.indisvalid||!i.indisready||!i.indislive||!i.unconditional||!i.simple||i.amname!=='btree'))throw Error('Protected registry indexed access refused')
|
||||
same(order(indexes.map(i=>({table:i.relname,primary:i.indisprimary,keys:i.keys}))),order([{table:CURRENT,primary:true,keys:[1]},{table:HISTORY,primary:true,keys:[1,2]},{table:HISTORY,primary:false,keys:[1,2,3]}]),'exact registry indexes')
|
||||
const structural={roles:roleRows,fileAcl,privilegedFunctions,tables:tables.map(({oid:_oid,...row})=>row),columns,acl:order(acl),constraints:order(constraints),triggers,indexes}
|
||||
return {sha256:sha(canonical(structural)),roles:Object.freeze({...signedOids})}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { pool } from "./index.js"
|
||||
import { applySqlMigrations } from "./migrate.js"
|
||||
import { dropExpiredPartitions, ensurePartitionsAround } from "./partitions.js"
|
||||
import { importSqliteToPostgres, shouldImportSqlite, sqliteFileLooksPresent } from "./sqlite-import.js"
|
||||
import { env } from "../config.js"
|
||||
|
||||
const ETL_LOCK = 8723101
|
||||
|
||||
export async function initDatabase(): Promise<void> {
|
||||
await pool.query("SELECT 1")
|
||||
await applySqlMigrations(pool)
|
||||
await ensurePartitionsAround(pool)
|
||||
const client = await pool.connect()
|
||||
try {
|
||||
await client.query("SELECT pg_advisory_lock($1)", [ETL_LOCK])
|
||||
if (await shouldImportSqlite(pool, env.DATABASE_PATH)) {
|
||||
console.log(`SQLite → PostgreSQL: импорт ${env.DATABASE_PATH}`)
|
||||
const report = await importSqliteToPostgres(pool, env.DATABASE_PATH, { strict: true })
|
||||
console.log(
|
||||
`SQLite → PostgreSQL: готово за ${report.durationMs}ms, таблиц ${Object.keys(report.tables).length}`,
|
||||
)
|
||||
} else {
|
||||
const marker = await pool.query<{ sqlite_imported_at: string | null }>(
|
||||
`SELECT sqlite_imported_at FROM data_migration WHERE id = 1`,
|
||||
)
|
||||
if (!marker.rows[0]?.sqlite_imported_at && !sqliteFileLooksPresent(env.DATABASE_PATH)) {
|
||||
console.warn(
|
||||
`SQLite → PostgreSQL: файл ${env.DATABASE_PATH} не найден, база после wipe остаётся пустой (defaults settings)`,
|
||||
)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
await client.query("SELECT pg_advisory_unlock($1)", [ETL_LOCK])
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
client.release()
|
||||
}
|
||||
await dropExpiredPartitions(pool)
|
||||
await ensurePartitionsAround(pool)
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { createHash, randomUUID } from "node:crypto"
|
||||
import { basename, dirname, resolve } from "node:path"
|
||||
assert.equal(process.env.MMAPP_OWN_CONTROLLED_MIGRATIONS, "true")
|
||||
const url = new URL(process.env.DATABASE_URL ?? "")
|
||||
assert.equal(url.hostname, "127.0.0.1"); assert.equal(url.pathname, "/postgres")
|
||||
assert(process.env.MCP_STATE_DIR && basename(dirname(resolve(process.env.MCP_STATE_DIR))).startsWith("mm-mcp-test-"))
|
||||
const { Pool } = await import("pg")
|
||||
const { pool, closePool } = await import("./index.js")
|
||||
const { applySqlMigrations } = await import("./migrate.js")
|
||||
const { executeControlledSqlMigrations } = await import("./controlled-migrations.js")
|
||||
const { initialDatabaseSchemaControlState } = await import("./database-schema-control-model.js")
|
||||
const { MIGRATION_MANIFEST } = await import("./migration-manifest.js")
|
||||
const originalConnect = pool.connect.bind(pool), databases: InstanceType<typeof Pool>[] = [], names: string[] = [], cases: string[] = []
|
||||
const hashes = new Set(MIGRATION_MANIFEST.map(pin => pin.sha256))
|
||||
async function database(seed = true) {
|
||||
const name = "mm_dispatch_" + randomUUID().replaceAll("-", "")
|
||||
assert(/^mm_dispatch_[a-f0-9]{32}$/.test(name))
|
||||
await pool.query('CREATE DATABASE "' + name + '" TEMPLATE template0')
|
||||
const target = new URL(url); target.pathname = "/" + name
|
||||
const connection = new Pool({ connectionString: target.toString(), max: 6 }); databases.push(connection); names.push(name)
|
||||
if (seed) {
|
||||
await connection.query("CREATE SCHEMA mmapp_control; CREATE TABLE mmapp_control.database_schema_state(id smallint PRIMARY KEY CHECK(id=1),revision bigint NOT NULL CHECK(revision>=0),state jsonb NOT NULL); CREATE TABLE mmapp_control.database_schema_intents(id uuid PRIMARY KEY,intent jsonb NOT NULL,terminal jsonb)")
|
||||
await connection.query("INSERT INTO mmapp_control.database_schema_state VALUES(1,0,$1::jsonb)", [JSON.stringify(initialDatabaseSchemaControlState())])
|
||||
}
|
||||
return connection
|
||||
}
|
||||
type Fault = "none" | "application-commit" | "terminal-commit"
|
||||
async function run(connection: InstanceType<typeof Pool>, fault: Fault = "none") {
|
||||
const files: string[] = []; let ordinal = 0, controlCommits = 0
|
||||
// Owned fixture instrumentation at the fixed module's actual Pool boundary.
|
||||
// Every SQL statement still executes on a real checked-out private backend.
|
||||
pool.connect = (async () => {
|
||||
const client = await connection.connect(), index = ++ordinal, originalQuery = client.query, release = client.release.bind(client), query = originalQuery.bind(client)
|
||||
client.release = (destroy?: boolean) => { client.query = originalQuery; release(destroy) }
|
||||
client.query = (async (text: string, values?: unknown[]) => {
|
||||
const digest = createHash("sha256").update(text).digest("hex")
|
||||
if (hashes.has(digest)) files.push(digest)
|
||||
const result = await query(text, values)
|
||||
if (index === 2 && text === "COMMIT" && fault === "application-commit") throw Error("Owned application COMMIT ACK lost")
|
||||
if (index === 1 && text === "COMMIT" && ++controlCommits === 4 && fault === "terminal-commit") throw Error("Owned terminal publication COMMIT ACK lost")
|
||||
return result
|
||||
}) as typeof client.query
|
||||
return client
|
||||
}) as typeof pool.connect
|
||||
try { return { result: await executeControlledSqlMigrations(), files, failed: false } }
|
||||
catch { return { files, failed: true } }
|
||||
finally { pool.connect = originalConnect as typeof pool.connect }
|
||||
}
|
||||
try {
|
||||
await applySqlMigrations(pool)
|
||||
const missing = await database(false), noControl = await run(missing)
|
||||
assert(noControl.failed); assert.equal(noControl.files.length, 0)
|
||||
cases.push("missing external control refuses before application migration SQL")
|
||||
const clean = await database(), initial = await run(clean)
|
||||
assert(!initial.failed); assert.equal(initial.result?.completedFiles, MIGRATION_MANIFEST.length); assert.equal(initial.result?.ready, false)
|
||||
assert.deepEqual(initial.files, MIGRATION_MANIFEST.map(pin => pin.sha256))
|
||||
assert.equal((await clean.query("SELECT count(*)::int AS n FROM mmapp_control.database_schema_intents WHERE terminal->>'outcome'='observed-success'")).rows[0].n, MIGRATION_MANIFEST.length)
|
||||
cases.push("all current exact pinned files execute once with genuine keyed ACK receipts; READY remains closed")
|
||||
const repeated = await run(clean); assert(!repeated.failed); assert.equal(repeated.files.length, 0)
|
||||
cases.push("fresh owner verifies complete known prefix and executes no file twice")
|
||||
const legacy = await database(); await legacy.query("CREATE TABLE public.unmarked(id integer)")
|
||||
const refused = await run(legacy); assert(refused.failed); assert.equal(refused.files.length, 0)
|
||||
cases.push("unmarked legacy application objects are not treated as a fresh database")
|
||||
const unknown = await database(), lostFile = await run(unknown, "application-commit")
|
||||
assert(lostFile.failed); assert.equal(lostFile.files.length, 1)
|
||||
assert.equal((await unknown.query("SELECT terminal->>'outcome' AS outcome FROM mmapp_control.database_schema_intents")).rows[0].outcome, "interrupted-unknown")
|
||||
const unknownRepeat = await run(unknown); assert(unknownRepeat.failed); assert.equal(unknownRepeat.files.length, 0)
|
||||
cases.push("actual application COMMIT with lost ACK becomes unknown and is never replayed by fresh owner")
|
||||
const prefix = await database(), lostTerminal = await run(prefix, "terminal-commit")
|
||||
assert(lostTerminal.failed); assert.equal(lostTerminal.files.length, 1)
|
||||
assert.equal((await prefix.query("SELECT terminal->>'outcome' AS outcome FROM mmapp_control.database_schema_intents")).rows[0].outcome, "observed-success")
|
||||
const resumed = await run(prefix); assert(!resumed.failed); assert.equal(resumed.files.length, MIGRATION_MANIFEST.length - 1)
|
||||
assert.deepEqual(resumed.files, MIGRATION_MANIFEST.slice(1).map(pin => pin.sha256))
|
||||
cases.push("lost terminal publication ACK preserves genuine known file ACK; fresh invocation resumes only remaining manifest files without replay")
|
||||
const badMarker = await database(), markerPrefix = await run(badMarker, "terminal-commit"); assert(markerPrefix.failed)
|
||||
await badMarker.query("DELETE FROM public.schema_migrations")
|
||||
const markerDrift = await run(badMarker); assert(markerDrift.failed); assert.equal(markerDrift.files.length, 0)
|
||||
cases.push("marker and protected history mismatch refuses before any next file")
|
||||
const tampered = await database(), tamperPrefix = await run(tampered, "terminal-commit"); assert(tamperPrefix.failed)
|
||||
await tampered.query("UPDATE mmapp_control.database_schema_intents SET terminal=jsonb_set(terminal,'{observationSha256}',to_jsonb($1::text))", ["f".repeat(64)])
|
||||
await tampered.query("UPDATE mmapp_control.database_schema_state SET state=jsonb_set(state,'{terminal,observationSha256}',to_jsonb($1::text))", ["f".repeat(64)])
|
||||
const forged = await run(tampered); assert(forged.failed); assert.equal(forged.files.length, 0)
|
||||
cases.push("matching singleton/history plain-hash forgery cannot replace fixed server-keyed file receipt")
|
||||
console.log("Owned controlled migration SQL:", JSON.stringify({ cases: cases.length, names: cases, databases: names.length, actualSql: true, fixedSourceFiles: MIGRATION_MANIFEST.length, fullRuns: 2, actualApplicationAckLosses: 1, actualTerminalPublicationAckLosses: 3, readyAuthorized: false, startupWired: false, productionDatabaseTouched: false }))
|
||||
} finally {
|
||||
pool.connect = originalConnect as typeof pool.connect
|
||||
for (const connection of databases) await connection.end()
|
||||
await closePool()
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { createHash, createHmac, randomUUID } from "node:crypto"
|
||||
import * as fs from "node:fs/promises"
|
||||
import { test } from "node:test"
|
||||
import { runInNewContext } from "node:vm"
|
||||
import ts from "typescript"
|
||||
import * as manifest from "./migration-manifest.js"
|
||||
import * as model from "./database-schema-control-model.js"
|
||||
import { PARTITION_WRITER_ADVISORY_KEY } from "../services/partition-write-transaction.js"
|
||||
|
||||
const pins = manifest.MIGRATION_MANIFEST
|
||||
const sources = await Promise.all(pins.map(async pin => ({ pin, source: await fs.readFile(new URL("../../drizzle/" + pin.file, import.meta.url), "utf8") })))
|
||||
const originalSource = await fs.readFile(new URL("./controlled-migrations.ts", import.meta.url), "utf8")
|
||||
const compiled = ts.transpileModule(originalSource.replaceAll("import.meta.url", JSON.stringify(new URL("./controlled-migrations.ts", import.meta.url).href)), { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, esModuleInterop: true } }).outputText
|
||||
const canonical = (value: unknown): string => JSON.stringify(value, (_key, item: unknown) => item && typeof item === "object" && !Array.isArray(item) ? Object.fromEntries(Object.entries(item).sort(([a], [b]) => a.localeCompare(b))) : item)
|
||||
|
||||
/** Execute actual production dispatcher source with inert module I/O. These
|
||||
* protocol tests do not claim PostgreSQL DDL execution or cryptographic key
|
||||
* provenance; root's private PG suite independently covers actual SQL. */
|
||||
function fixture() {
|
||||
let state = model.initialDatabaseSchemaControlState(), revision = 0, publicPresent = false, markerIds = new Set<string>()
|
||||
const history = new Map<string, { id: string; intent: model.DatabaseSchemaIntent; terminal: model.DatabaseSchemaTerminal | null }>()
|
||||
const events: string[] = [], appReleases: boolean[] = [], reads = new Map<string, number>()
|
||||
const faults = { beginFile: "", file: "", marker: "", terminal: false, lostCommit: false, rollbackTag: false, driftFile: "", extraFile: false, forgeMarker: false }
|
||||
let activeLease = false
|
||||
const control = { acquireDatabaseSchemaControl: async () => {
|
||||
if (activeLease) throw Error("live control lease")
|
||||
activeLease = true; state = model.claimDatabaseSchemaOwnership(state, randomUUID()); revision++
|
||||
let closed = false
|
||||
return {
|
||||
read: async () => { if (closed) throw Error("closed"); return { revision: String(revision), state: structuredClone(state) } },
|
||||
transition: async (command: model.DatabaseSchemaControlCommand) => {
|
||||
if (closed) throw Error("closed")
|
||||
if (command.type === "begin") {
|
||||
const pin = pins.find(pin => pin.sha256 === command.artifactSha256)!
|
||||
if (faults.beginFile === pin.file) { closed = true; activeLease = false; throw Error("intent unavailable BEFORE publication") }
|
||||
events.push("intent:" + pin.file)
|
||||
state = model.transitionDatabaseSchemaControl(state, state.owner!, structuredClone(command))
|
||||
history.set(command.id, { id: command.id, intent: structuredClone(state.intent!), terminal: null })
|
||||
} else {
|
||||
const row = history.get(command.id)!
|
||||
events.push("terminal:" + row.intent.artifactSha256 + ":" + command.outcome)
|
||||
state = model.transitionDatabaseSchemaControl(state, state.owner!, structuredClone(command))
|
||||
row.terminal = structuredClone(state.terminal)
|
||||
if (faults.terminal) { closed = true; activeLease = false; throw Error("terminal ACK lost") }
|
||||
}
|
||||
revision++; return { revision: String(revision), state: structuredClone(state) }
|
||||
},
|
||||
close: async () => { closed = true; activeLease = false },
|
||||
}
|
||||
} }
|
||||
// These exact strings are read from the actual module exports after load.
|
||||
const pool = { connect: async () => {
|
||||
let outerMarkers: Set<string> | undefined, outerPublic: boolean | undefined
|
||||
return {
|
||||
query: async (query: string, values: unknown[] = []) => {
|
||||
if (query === sql.history) return { rows: [...history.values()].map(row => structuredClone(row)) }
|
||||
if (query === sql.namespaces) return { rows: [{ name: "mmapp_control" }, { name: "public" }] }
|
||||
if (query === sql.publicObjects) return { rows: [{ present: publicPresent }] }
|
||||
if (query === sql.markerTable) return { rows: publicPresent ? [{ kind: "r", persistence: "p", rls: false, force_rls: false, user_triggers: false }] : [] }
|
||||
if (query === sql.markers) return { rows: [...markerIds].sort().map(id => ({ id })) }
|
||||
if (query === sql.marker) {
|
||||
const id = String(values[0]); events.push("marker:" + id)
|
||||
if (faults.marker === id) throw Error("marker ACK unavailable")
|
||||
if (markerIds.has(id)) throw Error("duplicate marker")
|
||||
markerIds.add(id); return { rows: [] }
|
||||
}
|
||||
if (query === "BEGIN") { assert(!outerMarkers); outerMarkers = new Set(markerIds); outerPublic = publicPresent; events.push("BEGIN"); return { rows: [] } }
|
||||
if (query === "COMMIT") { assert(outerMarkers); outerMarkers = undefined; outerPublic = undefined; events.push("COMMIT"); if (faults.lostCommit) throw Error("application COMMIT ACK lost"); return { rows: [], command: faults.rollbackTag ? "ROLLBACK" : "COMMIT" } }
|
||||
if (query.startsWith("SET ") || query.startsWith("RESET ")) { events.push(query); return { rows: [] } }
|
||||
if (query.includes("pg_catalog.pg_advisory_lock(")) { assert.deepEqual(Array.from(values), [PARTITION_WRITER_ADVISORY_KEY]); events.push("exclusive lease"); return { rows: [] } }
|
||||
if (query.includes("pg_catalog.pg_advisory_unlock(")) { events.push("unlock"); return { rows: [{ unlocked: true }] } }
|
||||
const exact = sources.find(entry => entry.source === query)
|
||||
assert(exact, "Only original pinned file bytes and fixed internal SQL may execute")
|
||||
events.push("file:" + exact.pin.file)
|
||||
if (faults.file === exact.pin.file) throw Error("file ACK unknown")
|
||||
assert.equal(Boolean(outerMarkers), exact.pin.transaction === "owner-transaction")
|
||||
publicPresent = true
|
||||
if (exact.pin.selfMarker) markerIds.add(exact.pin.file.slice(0, -4))
|
||||
if (faults.forgeMarker) markerIds.add("9999_unknown")
|
||||
return exact.pin.transaction === "self-managed-single-transaction" ? [{ command: "BEGIN", rows: [] }, { command: "COMMIT", rows: [] }] : { rows: [] }
|
||||
},
|
||||
release: (destroy: boolean) => { appReleases.push(destroy); if (destroy && outerMarkers) { markerIds = outerMarkers; publicPresent = outerPublic! } },
|
||||
}
|
||||
} }
|
||||
const modules: Record<string, unknown> = {
|
||||
"node:crypto": { createHash, randomUUID },
|
||||
"node:fs/promises": {
|
||||
readdir: async (...args: Parameters<typeof fs.readdir>) => {
|
||||
const rows = await Reflect.apply(fs.readdir, fs, args) as unknown as { name: string; isFile(): boolean }[]
|
||||
return faults.extraFile ? [...rows, { name: "9999_unreviewed.sql", isFile: () => true }] : rows
|
||||
},
|
||||
readFile: async (file: URL) => { const name = file.pathname.split("/").at(-1)!; reads.set(name, (reads.get(name) ?? 0) + 1); const bytes = await fs.readFile(file); return faults.driftFile === name ? Buffer.concat([bytes, Buffer.from("\n")]) : bytes },
|
||||
},
|
||||
"./database-schema-control.js": control,
|
||||
"./database-schema-control-model.js": { ...model, checkDatabaseSchemaControlState: (value: unknown) => model.checkDatabaseSchemaControlState(structuredClone(value)) },
|
||||
"./migration-manifest.js": manifest,
|
||||
"../services/partition-write-transaction.js": { PARTITION_WRITER_ADVISORY_KEY },
|
||||
"../modules/mcp/security.js": {
|
||||
fingerprint: (value: unknown) => createHash("sha256").update(canonical(value)).digest("hex"),
|
||||
privateSecretFingerprint: async (value: string, domain: string) => createHmac("sha256", "inert-only-issuer:" + domain).update(value).digest("hex"),
|
||||
},
|
||||
"./index.js": { pool },
|
||||
}
|
||||
const exports = {} as { executeControlledSqlMigrations(): Promise<{ completedFiles: number; ready: false }>; CONTROLLED_MIGRATIONS_SQL: Record<string, string> }
|
||||
runInNewContext(compiled, { exports, require: (name: string) => { assert(name in modules, "Unapproved import: " + name); return modules[name] }, Buffer, URL, TextDecoder, Promise, Error, Map, Set, Object, structuredClone }, { filename: "controlled-migrations.ts" })
|
||||
const sql = exports.CONTROLLED_MIGRATIONS_SQL
|
||||
return { events, faults, appReleases, reads, run: exports.executeControlledSqlMigrations, history, state: () => structuredClone(state), markerIds: () => new Set(markerIds), legacy: () => { publicPresent = true; markerIds.add("0000_postgresql") }, corruptMarker: () => markerIds.add("9999_unknown") }
|
||||
}
|
||||
test("actual dispatcher executes exact current files with durable intents and correct transaction contracts", async () => {
|
||||
const f = fixture(), result = await f.run()
|
||||
assert.equal(result.completedFiles, pins.length); assert.equal(result.ready, false)
|
||||
assert.equal(f.events.filter(event => event.startsWith("file:")).length, pins.length)
|
||||
assert.equal(f.events.filter(event => event === "BEGIN").length, 17)
|
||||
assert.equal(f.events.filter(event => event === "COMMIT").length, 17)
|
||||
assert.equal(f.events.filter(event => event.startsWith("marker:")).length, 34)
|
||||
assert.equal(f.events.filter(event => event === "exclusive lease").length, 1)
|
||||
for (const pin of pins) { assert(f.events.indexOf("intent:" + pin.file) < f.events.indexOf("file:" + pin.file)); assert.equal(f.reads.get(pin.file), 1) }
|
||||
assert.deepEqual(f.appReleases, [false])
|
||||
assert.equal(f.history.size, pins.length)
|
||||
})
|
||||
|
||||
for (const previousLength of [39, 40, 41, 42, 43]) test("old controlled" + previousLength + " manifest history is not automatically reattested under44", async () => {
|
||||
const f = fixture(); f.faults.beginFile = pins[previousLength].file
|
||||
await assert.rejects(f.run()); assert.equal(f.history.size, previousLength)
|
||||
const oldDigest = createHash("sha256").update(JSON.stringify(pins.slice(0, previousLength))).digest("hex")
|
||||
for (const row of f.history.values()) {
|
||||
row.intent.manifestSha256 = oldDigest
|
||||
row.terminal!.intent.manifestSha256 = oldDigest
|
||||
}
|
||||
f.faults.beginFile = ""; f.events.length = 0
|
||||
await assert.rejects(f.run())
|
||||
assert.equal(f.events.some(event => event.startsWith("file:")), false)
|
||||
assert.equal(f.history.size, previousLength)
|
||||
})
|
||||
test("known completed prefix resumes after fresh ownership without replaying earlier bytes", async () => {
|
||||
const f = fixture(); f.faults.beginFile = pins[7].file
|
||||
await assert.rejects(f.run()); assert.equal(f.history.size, 7)
|
||||
f.faults.beginFile = ""; f.events.length = 0
|
||||
const result = await f.run()
|
||||
assert.equal(result.completedFiles, pins.length)
|
||||
assert.deepEqual(f.events.filter(event => event.startsWith("file:")), pins.slice(7).map(pin => "file:" + pin.file))
|
||||
})
|
||||
test("fully completed restart validates ledger/markers and executes zero application files", async () => {
|
||||
const f = fixture(); await f.run(); f.events.length = 0
|
||||
assert.equal((await f.run()).completedFiles, pins.length)
|
||||
assert.equal(f.events.filter(event => event.startsWith("file:")).length, 0)
|
||||
})
|
||||
test("lost control terminal ACK reconciles signed durable success and skips that file", async () => {
|
||||
const f = fixture(); f.faults.terminal = true
|
||||
await assert.rejects(f.run())
|
||||
assert.equal(f.state().terminal?.outcome, "observed-success")
|
||||
assert.equal(f.history.size, 1)
|
||||
f.faults.terminal = false; f.events.length = 0
|
||||
assert.equal((await f.run()).completedFiles, pins.length)
|
||||
assert.deepEqual(f.events.filter(event => event.startsWith("file:")), pins.slice(1).map(pin => "file:" + pin.file))
|
||||
})
|
||||
test("legacy markers or source drift/new files never dispatch application SQL", async () => {
|
||||
for (const kind of ["legacy", "drift", "new"] as const) {
|
||||
const f = fixture()
|
||||
if (kind === "legacy") f.legacy()
|
||||
if (kind === "drift") f.faults.driftFile = pins[0].file
|
||||
if (kind === "new") f.faults.extraFile = true
|
||||
await assert.rejects(f.run())
|
||||
assert.equal(f.events.filter(event => event.startsWith("file:")).length, 0)
|
||||
assert.equal(f.history.size, 0)
|
||||
}
|
||||
})
|
||||
test("application failure or lost COMMIT ACK records unknown and forbids restart replay", async () => {
|
||||
for (const kind of ["file", "commit"] as const) {
|
||||
const f = fixture()
|
||||
if (kind === "file") f.faults.file = pins[0].file; else f.faults.lostCommit = true
|
||||
await assert.rejects(f.run())
|
||||
assert.equal(f.state().terminal?.outcome, "interrupted-unknown")
|
||||
assert.equal(f.appReleases.at(-1), true)
|
||||
f.faults.file = ""; f.faults.lostCommit = false; f.events.length = 0
|
||||
await assert.rejects(f.run())
|
||||
assert.equal(f.events.filter(event => event.startsWith("file:")).length, 0)
|
||||
}
|
||||
})
|
||||
test("self-managed external-marker gap remains unknown and never retries file", async () => {
|
||||
const f = fixture(); f.faults.marker = pins[7].file.slice(0, -4)
|
||||
await assert.rejects(f.run())
|
||||
assert.equal(f.state().terminal?.outcome, "interrupted-unknown")
|
||||
assert.equal(f.events.filter(event => event === "file:" + pins[7].file).length, 1)
|
||||
f.faults.marker = ""; f.events.length = 0
|
||||
await assert.rejects(f.run()); assert(!f.events.some(event => event.startsWith("file:")))
|
||||
})
|
||||
test("resolved ROLLBACK command tag never becomes a successful COMMIT receipt", async () => {
|
||||
const f = fixture(); f.faults.rollbackTag = true
|
||||
await assert.rejects(f.run())
|
||||
assert.equal(f.state().terminal?.outcome, "interrupted-unknown")
|
||||
assert.equal(f.history.size, 1)
|
||||
})
|
||||
test("marker inconsistency rejects before issuing a successful file receipt", async () => {
|
||||
const f = fixture(); f.faults.forgeMarker = true
|
||||
await assert.rejects(f.run())
|
||||
assert.equal(f.state().terminal?.outcome, "interrupted-unknown")
|
||||
})
|
||||
test("caller-forged receipt hash or marker mismatch cannot authorize prefix skipping", async () => {
|
||||
for (const kind of ["hash", "marker"] as const) {
|
||||
const f = fixture(); f.faults.beginFile = pins[1].file
|
||||
await assert.rejects(f.run()); f.faults.beginFile = ""
|
||||
if (kind === "hash") f.history.values().next().value!.terminal!.observationSha256 = "a".repeat(64)
|
||||
else f.corruptMarker()
|
||||
f.events.length = 0
|
||||
await assert.rejects(f.run()); assert(!f.events.some(event => event.startsWith("file:")))
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,145 @@
|
||||
import { createHash, randomUUID } from "node:crypto"
|
||||
import { readdir, readFile } from "node:fs/promises"
|
||||
import type { PoolClient } from "pg"
|
||||
import { acquireDatabaseSchemaControl } from "./database-schema-control.js"
|
||||
import { checkDatabaseSchemaControlState, type DatabaseSchemaIntent, type DatabaseSchemaTerminal } from "./database-schema-control-model.js"
|
||||
import { analyzeMigration, MIGRATION_MANIFEST, migrationManifestDigest, validateMigrationFileSet, type MigrationReview } from "./migration-manifest.js"
|
||||
import { PARTITION_WRITER_ADVISORY_KEY } from "../services/partition-write-transaction.js"
|
||||
import { fingerprint, privateSecretFingerprint } from "../modules/mcp/security.js"
|
||||
|
||||
/** Opt-in internal dispatcher only. Default startup remains on the old path.
|
||||
* Control DDL/enrollment, imports, data verification and READY are absent. */
|
||||
export const CONTROLLED_MIGRATIONS_SQL = Object.freeze({
|
||||
history: `SELECT id::text AS id, intent, terminal FROM mmapp_control.database_schema_intents ORDER BY id LIMIT ${MIGRATION_MANIFEST.length + 1}`,
|
||||
namespaces: "SELECT nspname AS name FROM pg_catalog.pg_namespace WHERE nspname !~ '^pg_' AND nspname <> 'information_schema' ORDER BY nspname",
|
||||
publicObjects: "SELECT EXISTS (SELECT 1 FROM pg_catalog.pg_depend WHERE refclassid = 'pg_catalog.pg_namespace'::pg_catalog.regclass AND refobjid = 'public'::pg_catalog.regnamespace) OR EXISTS (SELECT 1 FROM pg_catalog.pg_extension WHERE extnamespace = 'public'::pg_catalog.regnamespace) AS present",
|
||||
markerTable: "SELECT c.relkind::text AS kind, c.relpersistence::text AS persistence, c.relrowsecurity AS rls, c.relforcerowsecurity AS force_rls, EXISTS (SELECT 1 FROM pg_catalog.pg_trigger t WHERE t.tgrelid = c.oid AND NOT t.tgisinternal) AS user_triggers FROM pg_catalog.pg_class c JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace WHERE n.nspname = 'public' AND c.relname = 'schema_migrations'",
|
||||
markers: `SELECT id FROM public.schema_migrations ORDER BY id LIMIT ${MIGRATION_MANIFEST.length + 1}`,
|
||||
marker: "INSERT INTO public.schema_migrations (id) VALUES ($1)",
|
||||
})
|
||||
export class ControlledMigrationsError extends Error {
|
||||
readonly code = "CONTROLLED_MIGRATIONS_REFUSED"
|
||||
constructor(message: string, readonly outcomeUnknown = false, options?: ErrorOptions) { super(message, options) }
|
||||
}
|
||||
const refuse = (message: string): never => { throw new ControlledMigrationsError(message) }
|
||||
const digest = (value: string) => createHash("sha256").update(value).digest("hex")
|
||||
const canonical = (value: unknown): string => JSON.stringify(value, (_key, item: unknown) => item && typeof item === "object" && !Array.isArray(item) ? Object.fromEntries(Object.entries(item).sort(([a], [b]) => a.localeCompare(b))) : item)
|
||||
function requireCommitAck(result: unknown): void {
|
||||
const terminal = Array.isArray(result) ? result.at(-1) : result
|
||||
if (!terminal || typeof terminal !== "object" || Reflect.get(terminal, "command") !== "COMMIT") refuse("Application COMMIT command acknowledgement unavailable")
|
||||
}
|
||||
async function observation(intent: DatabaseSchemaIntent, pin: MigrationReview) {
|
||||
// Fixed, purpose-separated server-keyed ACK issuer. Caller-supplied hashes
|
||||
// cannot mint a prefix receipt. This still does not attest that current
|
||||
// schema/data is unchanged, and never issues a READY capability.
|
||||
return privateSecretFingerprint(fingerprint({ format: "controlled-migration-file-ack-v1", intent, file: pin.file, transaction: pin.transaction, selfMarker: pin.selfMarker, fileCommitAck: true, markerAck: true }), "database-controlled-migration-file-ack-v1")
|
||||
}
|
||||
interface ExactFile { pin: MigrationReview; sql: string }
|
||||
async function exactFiles(): Promise<ExactFile[]> {
|
||||
const directory = new URL("../../drizzle/", import.meta.url)
|
||||
const entries = (await readdir(directory, { withFileTypes: true })).filter(entry => entry.name.toLowerCase().endsWith(".sql"))
|
||||
if (entries.some(entry => !entry.isFile())) refuse("Migration SQL must be ordinary pinned files")
|
||||
validateMigrationFileSet(entries.map(entry => entry.name))
|
||||
// Read ONCE and validate those SAME bytes. Never execute a second unchecked
|
||||
// read after inventory validation, and never expose the source to a caller.
|
||||
return Promise.all(MIGRATION_MANIFEST.map(async pin => {
|
||||
const bytes = await readFile(new URL(pin.file, directory))
|
||||
analyzeMigration(pin.file, bytes, pin)
|
||||
return { pin, sql: new TextDecoder("utf-8", { fatal: true }).decode(bytes) }
|
||||
}))
|
||||
}
|
||||
async function exactHistory(rows: Record<string, unknown>[], current: ReturnType<typeof checkDatabaseSchemaControlState>, manifest: string) {
|
||||
if (rows.length > MIGRATION_MANIFEST.length || current.intent !== null) refuse("Unresolved or unexpected migration history")
|
||||
const completed = new Map<string, DatabaseSchemaTerminal>(), ids = new Set<string>()
|
||||
for (const row of rows) {
|
||||
if (Object.keys(row).sort().join(" ") !== "id intent terminal" || typeof row.id !== "string" || !/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(row.id) || ids.has(row.id)) refuse("Invalid migration history envelope")
|
||||
ids.add(row.id as string)
|
||||
// Reuse the strict persisted terminal parser; no boolean/hash alone is a
|
||||
// readiness grant. A fabricated newer epoch is rejected here as well.
|
||||
const parsed = checkDatabaseSchemaControlState({ format: current.format, epoch: current.epoch, owner: current.owner, phase: "TERMINAL", intent: null, terminal: row.terminal })
|
||||
const terminal = parsed.terminal!
|
||||
if (canonical(row.intent) !== canonical(terminal.intent) || terminal.intent.id !== row.id || terminal.intent.kind !== "migration" || terminal.intent.manifestSha256 !== manifest || terminal.outcome !== "observed-success") refuse("Unknown, failed, foreign or mismatched migration receipt")
|
||||
const pin = MIGRATION_MANIFEST.find(entry => entry.sha256 === terminal.intent.artifactSha256)
|
||||
if (!pin || completed.has(pin.file) || terminal.observationSha256 !== await observation(terminal.intent, pin)) return refuse("Migration receipt is not the exact internally recorded file ACK")
|
||||
completed.set(pin.file, terminal)
|
||||
}
|
||||
const prefix = MIGRATION_MANIFEST.slice(0, completed.size)
|
||||
if (prefix.some(pin => !completed.has(pin.file))) refuse("Migration history is not an exact completed prefix")
|
||||
const last = prefix.length ? completed.get(prefix[prefix.length - 1].file) : null
|
||||
if (canonical(last) !== canonical(current.terminal)) refuse("Current control terminal disagrees with completed prefix")
|
||||
return prefix.length
|
||||
}
|
||||
async function baseline(client: PoolClient, prefix: number) {
|
||||
const namespaces = (await client.query(CONTROLLED_MIGRATIONS_SQL.namespaces)).rows
|
||||
if (canonical(namespaces) !== canonical([{ name: "mmapp_control" }, { name: "public" }])) refuse("Unexpected database namespace scope")
|
||||
if (prefix === 0) {
|
||||
const rows = (await client.query(CONTROLLED_MIGRATIONS_SQL.publicObjects)).rows
|
||||
if (rows.length !== 1 || rows[0].present !== false) refuse("Legacy or unmarked application state cannot be enrolled")
|
||||
return
|
||||
}
|
||||
const table = (await client.query(CONTROLLED_MIGRATIONS_SQL.markerTable)).rows
|
||||
if (canonical(table) !== canonical([{ kind: "r", persistence: "p", rls: false, force_rls: false, user_triggers: false }])) refuse("Ordinary unguarded migration marker table required")
|
||||
const markers = (await client.query(CONTROLLED_MIGRATIONS_SQL.markers)).rows
|
||||
const expected = MIGRATION_MANIFEST.slice(0, prefix).map(pin => ({ id: pin.file.slice(0, -4) }))
|
||||
if (canonical(markers) !== canonical(expected)) refuse("Migration markers disagree with exact controlled prefix")
|
||||
}
|
||||
|
||||
/** Fixed production pool + fixed pinned source path; no caller-supplied SQL,
|
||||
* pool, execution callback, receipt, or verifier. Tests may replace module I/O
|
||||
* in their isolated loader; that is not a production admission interface. */
|
||||
export async function executeControlledSqlMigrations() {
|
||||
const files = await exactFiles(), manifest = migrationManifestDigest()
|
||||
const { pool } = await import("./index.js")
|
||||
const control = await acquireDatabaseSchemaControl(pool)
|
||||
let app: PoolClient | undefined, released = false, activeIntent: DatabaseSchemaIntent | undefined
|
||||
let applicationSqlSent = false, completedFiles = 0
|
||||
try {
|
||||
app = await pool.connect()
|
||||
await app.query("SET lock_timeout = '10s'")
|
||||
await app.query("SET statement_timeout = '120s'")
|
||||
await app.query("SET synchronous_commit = on")
|
||||
// Exclusive SESSION lock fences every adopted partition writer for the
|
||||
// whole DDL sequence, including self-managed files and marker gaps.
|
||||
await app.query("SELECT pg_catalog.pg_advisory_lock($1::bigint)", [PARTITION_WRITER_ADVISORY_KEY])
|
||||
await app.query("SET search_path = public, pg_catalog")
|
||||
const current = (await control.read()).state
|
||||
completedFiles = await exactHistory((await app.query(CONTROLLED_MIGRATIONS_SQL.history)).rows, current, manifest)
|
||||
await baseline(app, completedFiles)
|
||||
for (const { pin, sql } of files.slice(completedFiles)) {
|
||||
const id = randomUUID()
|
||||
const started = await control.transition({ type: "begin", id, kind: "migration", manifestSha256: manifest, artifactSha256: pin.sha256 })
|
||||
activeIntent = started.state.intent!
|
||||
// These writes are reached only after the durable intent COMMIT ACK.
|
||||
applicationSqlSent = true
|
||||
if (pin.transaction === "owner-transaction") {
|
||||
await app.query("BEGIN")
|
||||
await app.query(sql)
|
||||
await app.query(CONTROLLED_MIGRATIONS_SQL.marker, [pin.file.slice(0, -4)])
|
||||
requireCommitAck(await app.query("COMMIT"))
|
||||
} else {
|
||||
// Do not wrap, split, strip or rewrite the audited BEGIN...COMMIT.
|
||||
requireCommitAck(await app.query(sql))
|
||||
if (!pin.selfMarker) await app.query(CONTROLLED_MIGRATIONS_SQL.marker, [pin.file.slice(0, -4)])
|
||||
}
|
||||
await baseline(app, completedFiles + 1)
|
||||
await control.transition({ type: "terminal", id, outcome: "observed-success", observationSha256: await observation(activeIntent, pin) })
|
||||
activeIntent = undefined; completedFiles++
|
||||
}
|
||||
await baseline(app, completedFiles)
|
||||
const unlocked = await app.query("SELECT pg_catalog.pg_advisory_unlock($1::bigint) AS unlocked", [PARTITION_WRITER_ADVISORY_KEY])
|
||||
if (unlocked.rows.length !== 1 || unlocked.rows[0].unlocked !== true) refuse("Application schema lease release unconfirmed")
|
||||
for (const setting of ["search_path", "synchronous_commit", "lock_timeout", "statement_timeout"]) await app.query("RESET " + setting)
|
||||
app.release(false); released = true
|
||||
await control.close()
|
||||
return Object.freeze({ completedFiles, manifestSha256: manifest, ready: false as const, schemaAndDataVerified: false as const })
|
||||
} catch (cause) {
|
||||
if (activeIntent) {
|
||||
// If intent/terminal COMMIT itself lost ACK the store is poisoned; this
|
||||
// best-effort record fails closed and the unresolved intent survives.
|
||||
try { await control.transition({ type: "terminal", id: activeIntent.id, outcome: "interrupted-unknown", observationSha256: digest(JSON.stringify({ format: "controlled-migration-unknown-v1", intent: activeIntent })) }) } catch { /* never retry application SQL */ }
|
||||
}
|
||||
if (app && !released) { released = true; app.release(true) }
|
||||
try { await control.close() } catch { /* preserve unknown outcome */ }
|
||||
throw new ControlledMigrationsError("Controlled migration stopped; automatic replay forbidden", applicationSqlSent, { cause })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { randomUUID } from "node:crypto"
|
||||
import { fingerprint, privateSecretFingerprint } from "../modules/mcp/security.js"
|
||||
import { basename, dirname, resolve } from "node:path"
|
||||
import { readFile, writeFile } from "node:fs/promises"
|
||||
assert.equal(process.env.MMAPP_OWN_CONTROLLED_SCHEMA_REFERENCE, "true")
|
||||
const databaseUrl = new URL(process.env.DATABASE_URL ?? "")
|
||||
assert.equal(databaseUrl.hostname, "127.0.0.1"); assert.equal(databaseUrl.pathname, "/postgres")
|
||||
assert(process.env.MCP_STATE_DIR && basename(dirname(resolve(process.env.MCP_STATE_DIR))).startsWith("mm-mcp-test-"))
|
||||
const { Pool } = await import("pg")
|
||||
const { pool, closePool } = await import("./index.js")
|
||||
const { applySqlMigrations } = await import("./migrate.js")
|
||||
const { MIGRATION_MANIFEST } = await import("./migration-manifest.js")
|
||||
const { initialDatabaseSchemaControlState } = await import("./database-schema-control-model.js")
|
||||
const { executeControlledSqlMigrations } = await import("./controlled-migrations.js")
|
||||
const { provisionControlledSchemaReference, readControlledSchemaReference, ControlledSchemaReferenceProvisioningError } = await import("./controlled-schema-reference.js")
|
||||
const { verifyControlledSchemaCurrentState, CONTROLLED_SCHEMA_VERIFICATION_SQL } = await import("./controlled-schema-verification.js")
|
||||
const originalConnect = pool.connect.bind(pool), cases: string[] = []
|
||||
let application: InstanceType<typeof Pool> | undefined
|
||||
async function owned<T>(work: () => Promise<T>) {
|
||||
assert(application)
|
||||
pool.connect = application.connect.bind(application) as typeof pool.connect
|
||||
try { return await work() } finally { pool.connect = originalConnect as typeof pool.connect }
|
||||
}
|
||||
try {
|
||||
await applySqlMigrations(pool)
|
||||
const name = "mm_reference_app_" + randomUUID().replaceAll("-", "")
|
||||
assert(/^mm_reference_app_[a-f0-9]{32}$/.test(name))
|
||||
await pool.query('CREATE DATABASE "' + name + '" TEMPLATE template0')
|
||||
const url = new URL(databaseUrl); url.pathname = "/" + name
|
||||
application = new Pool({ connectionString: url.toString(), max: 6 })
|
||||
await application.query("CREATE SCHEMA mmapp_control; CREATE TABLE mmapp_control.database_schema_state(id smallint PRIMARY KEY CHECK(id=1),revision bigint NOT NULL CHECK(revision>=0),state jsonb NOT NULL); CREATE TABLE mmapp_control.database_schema_intents(id uuid PRIMARY KEY,intent jsonb NOT NULL,terminal jsonb)")
|
||||
await application.query("INSERT INTO mmapp_control.database_schema_state VALUES(1,0,$1::jsonb)", [JSON.stringify(initialDatabaseSchemaControlState())])
|
||||
assert.equal((await owned(executeControlledSqlMigrations)).completedFiles, MIGRATION_MANIFEST.length)
|
||||
assert.equal(await readControlledSchemaReference(), null)
|
||||
const missing = await owned(verifyControlledSchemaCurrentState)
|
||||
assert.equal(missing.referenceStatus, "missing"); assert.equal(missing.ready, false); assert.equal(missing.dataFacts.length, 0)
|
||||
cases.push("missing reference never promotes a controlled40 ledger or probes data as trusted schema")
|
||||
const before = (await application.query("SELECT count(*)::int AS n FROM pg_database WHERE datname LIKE 'mm_schema_reference_%'")).rows[0].n
|
||||
const generated = await owned(provisionControlledSchemaReference)
|
||||
assert.equal(generated.referenceDatabaseDropped, true); assert.equal(generated.readyAuthorized, false)
|
||||
assert.equal((await application.query("SELECT count(*)::int AS n FROM pg_database WHERE datname LIKE 'mm_schema_reference_%'")).rows[0].n, before)
|
||||
const reference = await readControlledSchemaReference(); assert(reference)
|
||||
assert.equal(reference.sha256, generated.logicalSchemaSha256)
|
||||
assert.equal(reference.format, "pg18-application-schema-v3")
|
||||
// Reconstruct the retired v2 algorithm from genuine current raw catalogs.
|
||||
// This is test-only: numeric indkey/tombstones and old fast-default-only
|
||||
// projection are intentional. Production never upgrades or accepts v2.
|
||||
const canonical = (value: unknown): string => Array.isArray(value) ? "[" + value.map(canonical).join(",") + "]"
|
||||
: value && typeof value === "object" ? "{" + Object.entries(value).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([key, item]) => JSON.stringify(key) + ":" + canonical(item)).join(",") + "}"
|
||||
: JSON.stringify(value) ?? "null"
|
||||
const sortRows = <T>(rows: T[]): T[] => rows.map(row => canonical(row)).sort().map(row => JSON.parse(row) as T)
|
||||
const legacySections = structuredClone(reference.sections)
|
||||
legacySections.constraints = sortRows(legacySections.constraints.map(row => Object.fromEntries(Object.entries(row).filter(([key]) => key !== "restore_definition"))))
|
||||
const legacyLogical = { ...legacySections, columns: sortRows(legacySections.columns.map(row => Object.fromEntries(Object.entries(row).filter(([key]) => key !== "has_missing" && key !== "missing_value")))) }
|
||||
const legacyBody = { format: "pg18-application-schema-v2", scope: reference.scope, serverVersion: reference.serverVersion, sections: legacyLogical }
|
||||
const legacySchema = { ...legacyBody, sections: legacySections, observedPhysical: reference.observedPhysical, canonical: canonical(legacyBody), sha256: fingerprint(legacyBody) }
|
||||
const compatibilityFile = resolve(process.env.MCP_STATE_DIR!, "database-controlled-schema-reference-v1.json"), v3Bytes = await readFile(compatibilityFile)
|
||||
const { signature: _v3Signature, canonicalSha256: _v3Canonical, ...v3Payload } = JSON.parse(v3Bytes.toString("utf8"))
|
||||
const v2Payload = { ...v3Payload, schema: legacySchema }, v2Canonical = fingerprint(v2Payload)
|
||||
const v2Bytes = Buffer.from(JSON.stringify({ ...v2Payload, canonicalSha256: v2Canonical, signature: await privateSecretFingerprint(v2Canonical, "database-controlled-schema-reference-v1") }) + "\n")
|
||||
try {
|
||||
await writeFile(compatibilityFile, v2Bytes)
|
||||
await assert.rejects(readControlledSchemaReference(), /format\/version/)
|
||||
await assert.rejects(owned(provisionControlledSchemaReference))
|
||||
assert.deepEqual(await readFile(compatibilityFile), v2Bytes)
|
||||
} finally { await writeFile(compatibilityFile, v3Bytes) }
|
||||
assert.equal((await readControlledSchemaReference())?.sha256, reference.sha256)
|
||||
cases.push("genuine raw-catalog legacy-v2 algorithm and fixed-issuer HMAC cannot bypass v3 refusal or overwrite existing reference; fresh v3 producer remains valid")
|
||||
|
||||
cases.push("fixed producer executes current pins in independently created database and drops only its verified owned UUID before publishing HMAC artifact")
|
||||
await assert.rejects(owned(provisionControlledSchemaReference))
|
||||
assert.equal((await readControlledSchemaReference())?.sha256, reference.sha256)
|
||||
cases.push("existing trusted reference is not overwritten or regenerated by a repeat invocation")
|
||||
const matching = await owned(verifyControlledSchemaCurrentState)
|
||||
assert.equal(matching.referenceStatus, "matched")
|
||||
assert.equal(matching.currentPristineDataInvariants, true, JSON.stringify({ data: matching.dataFacts.filter(f => !f.valid), sequences: matching.sequenceFacts.filter(f => !f.valid) }))
|
||||
assert.equal(matching.ready, false); assert.equal(matching.historicalDataEquivalent, false)
|
||||
cases.push("actual PG18 current schema matches independent issuer reference; exact pristine data and sequence invariants are checked without READY")
|
||||
// Run the genuine fixed predicate on typed, temporary copies of already
|
||||
// verified pristine rows. No production trigger is bypassed or altered.
|
||||
const epochProbe = await application.connect()
|
||||
try {
|
||||
for (const table of ["traffic_settings", "uptime_settings", "internet_path_settings"]) {
|
||||
await epochProbe.query('CREATE TEMP TABLE mm_owned_epoch_seed AS SELECT * FROM public."' + table + '"')
|
||||
const timeFields = ["created_at", "updated_at"]
|
||||
const seed = (await epochProbe.query("SELECT pg_catalog.to_jsonb(t)-ARRAY['created_at','updated_at','poll_configuration_epoch'] AS seed FROM pg_temp.mm_owned_epoch_seed t")).rows[0].seed
|
||||
const original = CONTROLLED_SCHEMA_VERIFICATION_SQL.seed(table)
|
||||
const relation = 'FROM public."' + table + '"'
|
||||
assert.equal(original.split(relation).length, 2)
|
||||
const probe = original.replace(relation, 'FROM pg_temp.mm_owned_epoch_seed')
|
||||
const check = async () => (await epochProbe.query(probe, [JSON.stringify(seed), timeFields])).rows[0].valid
|
||||
assert.equal(await check(), true)
|
||||
for (const epoch of [null, "00000000-0000-0000-0000-000000000000", "12345678-1234-1234-9234-123456789abc", "12345678-1234-4234-7234-123456789abc"]) {
|
||||
await epochProbe.query("UPDATE pg_temp.mm_owned_epoch_seed SET poll_configuration_epoch=$1::uuid", [epoch])
|
||||
assert.equal(await check(), false)
|
||||
}
|
||||
await epochProbe.query("UPDATE pg_temp.mm_owned_epoch_seed SET poll_configuration_epoch=pg_catalog.gen_random_uuid()")
|
||||
assert.equal(await check(), true)
|
||||
await epochProbe.query("UPDATE pg_temp.mm_owned_epoch_seed SET enabled=false")
|
||||
assert.equal(await check(), false)
|
||||
await epochProbe.query("UPDATE pg_temp.mm_owned_epoch_seed SET enabled=true")
|
||||
await epochProbe.query("ALTER TABLE pg_temp.mm_owned_epoch_seed ADD COLUMN unrelated_field integer DEFAULT 1")
|
||||
assert.equal(await check(), false)
|
||||
await epochProbe.query("DROP TABLE pg_temp.mm_owned_epoch_seed")
|
||||
}
|
||||
} finally { epochProbe.release(true) }
|
||||
assert(matching.gaps.includes("NONDETERMINISTIC_POLL_EPOCH_ORIGIN_UNATTESTED"))
|
||||
cases.push("actual typed SQL epoch predicates accept UUIDv4 only for three0042 seeds; null/nil/version/variant, changed settings and unknown columns refuse without historical-origin authority")
|
||||
await application.query("INSERT INTO public.servers(host) VALUES('192.0.2.1')")
|
||||
const changedData = await owned(verifyControlledSchemaCurrentState)
|
||||
assert.equal(changedData.referenceStatus, "matched"); assert.equal(changedData.currentPristineDataInvariants, false)
|
||||
assert(changedData.dataFacts.some(f => f.table === "servers" && !f.valid)); assert(changedData.sequenceFacts.some(f => !f.valid))
|
||||
cases.push("actual added application row and advanced identity sequence fail pristine data evidence despite equal DDL")
|
||||
await application.query("ALTER TABLE public.servers ADD COLUMN mm_owned_schema_drift integer")
|
||||
const drift = await owned(verifyControlledSchemaCurrentState)
|
||||
assert.equal(drift.referenceStatus, "different"); assert.equal(drift.dataFacts.length, 0)
|
||||
cases.push("actual schema drift refuses trusted match before any data-probe classification")
|
||||
const file = resolve(process.env.MCP_STATE_DIR!, "database-controlled-schema-reference-v1.json"), original = await readFile(file)
|
||||
const oldEnvelope = JSON.parse(original.toString("utf8")); oldEnvelope.prefixLength = 39
|
||||
await writeFile(file, JSON.stringify(oldEnvelope))
|
||||
await assert.rejects(readControlledSchemaReference(), /artifact envelope/)
|
||||
await writeFile(file, original)
|
||||
cases.push("historical39 reference envelope cannot be promoted to current40 by startup or restore")
|
||||
const fake = JSON.parse(original.toString("utf8")); fake.signature = "f".repeat(64)
|
||||
await writeFile(file, JSON.stringify(fake))
|
||||
await assert.rejects(readControlledSchemaReference())
|
||||
const untrusted = await owned(verifyControlledSchemaCurrentState)
|
||||
assert.equal(untrusted.referenceStatus, "invalid"); assert.equal(untrusted.dataFacts.length, 0); assert.equal(untrusted.ready, false)
|
||||
await writeFile(file, original)
|
||||
cases.push("forged artifact signature is refused by fixed reader and never authorizes data verification")
|
||||
const initialStateDirectory = process.env.MCP_STATE_DIR!, faultDirectory = resolve(initialStateDirectory, "owned-reference-create-ack-loss")
|
||||
let uncertainName = "", creates = 0, drops = 0
|
||||
process.env.MCP_STATE_DIR = faultDirectory
|
||||
pool.connect = (async () => {
|
||||
const client = await application!.connect()
|
||||
return { release(destroy?: boolean) { client.release(destroy) }, async query(text: string, values?: unknown[]) {
|
||||
if (text.startsWith("DROP DATABASE ")) drops++
|
||||
const result = await client.query(text, values)
|
||||
if (text.startsWith('CREATE DATABASE "mm_schema_reference_')) {
|
||||
const match = /^CREATE DATABASE "(mm_schema_reference_[a-f0-9]{32})"/.exec(text); assert(match)
|
||||
uncertainName = match[1]; creates++
|
||||
throw Error("Owned fixture lost actual reference CREATE acknowledgement")
|
||||
}
|
||||
return result
|
||||
} }
|
||||
}) as typeof pool.connect
|
||||
try {
|
||||
await assert.rejects(provisionControlledSchemaReference(), (error: unknown) => error instanceof ControlledSchemaReferenceProvisioningError && error.referenceDatabase === uncertainName && error.phase === "create-unknown" && !error.publicationAttempted && !error.automaticRetry)
|
||||
assert.equal(creates, 1); assert.equal(drops, 0); assert.equal(await readControlledSchemaReference(), null)
|
||||
} finally { pool.connect = originalConnect as typeof pool.connect; process.env.MCP_STATE_DIR = initialStateDirectory }
|
||||
assert(/^mm_schema_reference_[a-f0-9]{32}$/.test(uncertainName))
|
||||
const orphan = (await application.query("SELECT datname,pg_get_userbyid(datdba)=current_user AS owned FROM pg_database WHERE datname=$1", [uncertainName])).rows
|
||||
assert.deepEqual(orphan, [{ datname: uncertainName, owned: true }])
|
||||
// Explicit test-owner cleanup after independently proving the exact UUID.
|
||||
// The production producer itself never retries or force-cleans unknown CREATE.
|
||||
await application.query('DROP DATABASE "' + uncertainName + '"')
|
||||
cases.push("actual CREATE with lost ACK publishes no reference and never retries or drops uncertain database; test owner independently cleans verified UUID")
|
||||
console.log("Owned controlled schema reference SQL:", JSON.stringify({ cases: cases.length, names: cases, actualSql: true, referenceGeneratedByRealProducer: true, independentPinnedFiles: MIGRATION_MANIFEST.length, applicationPinnedFiles: MIGRATION_MANIFEST.length, readyAuthorized: false, startupWired: false, productionDatabaseTouched: false }))
|
||||
} finally {
|
||||
pool.connect = originalConnect as typeof pool.connect
|
||||
await application?.end()
|
||||
await closePool()
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { randomUUID } from "node:crypto"
|
||||
import { mkdir, open, readFile, readdir, lstat } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { Pool, type PoolClient } from "pg"
|
||||
import { env } from "../config.js"
|
||||
import { fingerprint, mcpStateDirectory, privateSecretFingerprint } from "../modules/mcp/security.js"
|
||||
import { analyzeMigration, MIGRATION_MANIFEST, migrationManifestDigest, validateMigrationFileSet } from "./migration-manifest.js"
|
||||
import { captureMigrationSchema, compareMigrationSchemas, type CapturedMigrationSchema } from "./migration-schema-inventory.js"
|
||||
|
||||
const maximum = 32 * 1048576, domain = "database-controlled-schema-reference-v1"
|
||||
const filename = () => path.join(mcpStateDirectory(), "database-controlled-schema-reference-v1.json")
|
||||
export class ControlledSchemaReferenceProvisioningError extends Error {
|
||||
readonly automaticRetry = false
|
||||
constructor(readonly referenceDatabase: string, readonly phase: string, readonly publicationAttempted: boolean, options: ErrorOptions) {
|
||||
super("Controlled reference provisioning unconfirmed; inspect the exact owned database before manual cleanup", options)
|
||||
}
|
||||
}
|
||||
const scope = { applicationNamespaces: ["public"], excludedControlNamespaces: ["mmapp_control"] }
|
||||
const refuse = (reason: string): never => { throw new Error("Controlled schema reference refused: " + reason) }
|
||||
const sha = (value: unknown) => typeof value === "string" && /^[a-f0-9]{64}$/.test(value)
|
||||
const identifier = (value: string) => '"' + value.replaceAll('"', '""') + '"'
|
||||
const literal = (value: string) => "'" + value.replaceAll("'", "''") + "'"
|
||||
interface Envelope {
|
||||
format: "controlled-schema-reference-v1"; manifestSha256: string; prefixLength: number; profileSha256: string;
|
||||
schema: CapturedMigrationSchema; canonicalSha256: string; signature: string;
|
||||
}
|
||||
const profile = (schema: CapturedMigrationSchema) => fingerprint({ serverVersion: schema.serverVersion, scope: schema.scope, database: schema.sections.database, namespaces: schema.sections.namespaces })
|
||||
|
||||
/** Fixed path and fixed issuer only. Supplied JSON or a digest never becomes a
|
||||
* reference. This proves provenance of a generated DDL reference, not data. */
|
||||
export async function readControlledSchemaReference(): Promise<CapturedMigrationSchema | null> {
|
||||
let handle
|
||||
try { handle = await open(filename(), "r") }
|
||||
catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; throw error }
|
||||
let text: string
|
||||
try {
|
||||
const stat = await handle.stat()
|
||||
if (!stat.isFile() || stat.size < 1 || stat.size > maximum) return refuse("artifact bound")
|
||||
const bytes = Buffer.alloc(stat.size + 1); let length = 0
|
||||
while (length < bytes.length) { const read = await handle.read(bytes, length, bytes.length - length, length); if (!read.bytesRead) break; length += read.bytesRead }
|
||||
if (length !== stat.size) return refuse("artifact changed while reading")
|
||||
text = new TextDecoder("utf-8", { fatal: true }).decode(bytes.subarray(0, length))
|
||||
} finally { await handle.close() }
|
||||
const value = JSON.parse(text) as Envelope
|
||||
if (!value || Object.getPrototypeOf(value) !== Object.prototype || Object.keys(value).sort().join(" ") !== "canonicalSha256 format manifestSha256 prefixLength profileSha256 schema signature" ||
|
||||
value.format !== "controlled-schema-reference-v1" || value.manifestSha256 !== migrationManifestDigest() || value.prefixLength !== MIGRATION_MANIFEST.length || !sha(value.profileSha256) || !sha(value.canonicalSha256) || !sha(value.signature)) return refuse("artifact envelope")
|
||||
compareMigrationSchemas(value.schema, value.schema)
|
||||
if (JSON.stringify(value.schema.scope) !== JSON.stringify(scope) || value.profileSha256 !== profile(value.schema)) return refuse("artifact profile")
|
||||
const { signature, canonicalSha256, ...payload } = value
|
||||
if (fingerprint(payload) !== canonicalSha256 || signature !== await privateSecretFingerprint(canonicalSha256, domain)) return refuse("artifact issuer proof")
|
||||
return structuredClone(value.schema)
|
||||
}
|
||||
|
||||
async function snapshot(client: PoolClient): Promise<CapturedMigrationSchema> {
|
||||
await client.query("BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY")
|
||||
try {
|
||||
await client.query("SET LOCAL search_path=pg_catalog")
|
||||
await client.query("SET LOCAL TimeZone='UTC'")
|
||||
await client.query("SET LOCAL DateStyle='ISO, YMD'")
|
||||
await client.query("SET LOCAL statement_timeout='5s'")
|
||||
const result = await captureMigrationSchema(client, { scope })
|
||||
await client.query("ROLLBACK"); return result
|
||||
} catch (error) { await client.query("ROLLBACK").catch(() => {}); throw error }
|
||||
}
|
||||
const databaseProfileSql = `SELECT current_user AS role,pg_catalog.current_setting('server_version_num') AS version,
|
||||
pg_catalog.pg_encoding_to_char(d.encoding) AS encoding,d.datcollate AS collate,d.datctype AS ctype,d.datlocprovider::text AS provider,
|
||||
d.datlocale AS locale,d.daticurules AS rules,d.datcollversion AS collation_version,ts.spcname AS tablespace,
|
||||
d.datacl IS NULL AS default_acl,pg_catalog.shobj_description(d.oid,'pg_database') IS NULL AS no_comment,
|
||||
pg_catalog.pg_get_userbyid(d.datdba)=current_user AS owned_by_current
|
||||
FROM pg_catalog.pg_database d JOIN pg_catalog.pg_tablespace ts ON ts.oid=d.dattablespace WHERE d.datname=current_database()`
|
||||
|
||||
/** Separate explicit provisioning workflow. Creates ONLY its own fresh UUID
|
||||
* database from template0, executes fixed pinned source there, closes it and
|
||||
* drops exactly that verified database BEFORE publishing an exclusive artifact.
|
||||
* No application data, application schema or control rows are changed.
|
||||
* This is never called by startup, restore, imports or a public MCP tool. */
|
||||
export async function provisionControlledSchemaReference() {
|
||||
try { await lstat(filename()); return refuse("artifact already exists") }
|
||||
catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error }
|
||||
const directory = new URL("../../drizzle/", import.meta.url)
|
||||
const entries = (await readdir(directory, { withFileTypes: true })).filter(entry => entry.name.toLowerCase().endsWith(".sql"))
|
||||
if (entries.some(entry => !entry.isFile())) return refuse("non-file migration source")
|
||||
validateMigrationFileSet(entries.map(entry => entry.name))
|
||||
const files = await Promise.all(MIGRATION_MANIFEST.map(async pin => { const bytes = await readFile(new URL(pin.file, directory)); analyzeMigration(pin.file, bytes, pin); return { pin, sql: new TextDecoder("utf-8", { fatal: true }).decode(bytes) } }))
|
||||
const { pool } = await import("./index.js"), control = await pool.connect()
|
||||
const name = "mm_schema_reference_" + randomUUID().replaceAll("-", "")
|
||||
if (!/^mm_schema_reference_[a-f0-9]{32}$/.test(name)) return refuse("owned reference name")
|
||||
let reference: Pool | undefined, captured: CapturedMigrationSchema | undefined
|
||||
let phase = "profile", publicationAttempted = false
|
||||
try {
|
||||
await control.query("SET standard_conforming_strings=on")
|
||||
await control.query("SET statement_timeout='120s'")
|
||||
const rows = (await control.query(databaseProfileSql)).rows
|
||||
if (rows.length !== 1) return refuse("database profile missing")
|
||||
const meta = rows[0]
|
||||
if (!/^18\d{4}$/.test(meta.version) || !["b", "c", "i"].includes(meta.provider) || !meta.default_acl || !meta.no_comment || !meta.owned_by_current ||
|
||||
[meta.role, meta.encoding, meta.collate, meta.ctype, meta.tablespace].some(value => typeof value !== "string" || !value || value.length > 256 || /[\x00-\x1f\x7f]/.test(value)) ||
|
||||
[meta.locale, meta.rules, meta.collation_version].some(value => value !== null && (typeof value !== "string" || value.length > 4096 || /[\x00-\x1f\x7f]/.test(value)))) return refuse("unsupported database profile")
|
||||
const provider = { b: "builtin", c: "libc", i: "icu" }[meta.provider as "b" | "c" | "i"]
|
||||
let sql = `CREATE DATABASE ${identifier(name)} TEMPLATE template0 OWNER ${identifier(meta.role)} ENCODING ${literal(meta.encoding)} LOCALE_PROVIDER ${provider} LC_COLLATE ${literal(meta.collate)} LC_CTYPE ${literal(meta.ctype)} TABLESPACE ${identifier(meta.tablespace)}`
|
||||
if (meta.provider === "b" || meta.provider === "i") { if (!meta.locale) return refuse("provider locale missing"); sql += ` ${meta.provider === "b" ? "BUILTIN_LOCALE" : "ICU_LOCALE"} ${literal(meta.locale)}` }
|
||||
if (meta.provider === "i" && meta.rules !== null) sql += " ICU_RULES " + literal(meta.rules)
|
||||
phase = "create-unknown"
|
||||
await control.query(sql); phase = "created"
|
||||
const url = new URL(env.DATABASE_URL); url.pathname = "/" + name
|
||||
reference = new Pool({ connectionString: url.toString(), max: 1, connectionTimeoutMillis: 8000, statement_timeout: 120000 })
|
||||
const client = await reference.connect()
|
||||
let clean = false
|
||||
try {
|
||||
if (fingerprint((await client.query(databaseProfileSql)).rows[0]) !== fingerprint(meta)) return refuse("generated database profile mismatch")
|
||||
await client.query("SET search_path=public,pg_catalog")
|
||||
await client.query("CREATE SCHEMA mmapp_control")
|
||||
for (const { pin, sql } of files) {
|
||||
if (pin.transaction === "owner-transaction") await client.query("BEGIN")
|
||||
const executed = await client.query(sql)
|
||||
if (!pin.selfMarker) await client.query("INSERT INTO public.schema_migrations(id) VALUES($1)", [pin.file.slice(0, -4)])
|
||||
const committed = pin.transaction === "owner-transaction" ? await client.query("COMMIT") : Array.isArray(executed) ? executed.at(-1) : executed
|
||||
if (committed?.command !== "COMMIT") return refuse("reference file COMMIT not acknowledged")
|
||||
}
|
||||
const markers = (await client.query(`SELECT id FROM public.schema_migrations ORDER BY id LIMIT ${MIGRATION_MANIFEST.length + 1}`)).rows
|
||||
if (fingerprint(markers) !== fingerprint(MIGRATION_MANIFEST.map(pin => ({ id: pin.file.slice(0, -4) })))) return refuse("reference markers mismatch")
|
||||
captured = await snapshot(client); clean = true
|
||||
} finally { client.release(!clean) }
|
||||
await reference.end(); reference = undefined
|
||||
const owned = (await control.query("SELECT datname,pg_catalog.pg_get_userbyid(datdba)=current_user AS owned FROM pg_catalog.pg_database WHERE datname=$1", [name])).rows
|
||||
if (owned.length !== 1 || owned[0].datname !== name || owned[0].owned !== true) return refuse("reference cleanup ownership changed")
|
||||
phase = "drop-unknown"
|
||||
await control.query("DROP DATABASE " + identifier(name)); phase = "dropped"
|
||||
const payload = { format: "controlled-schema-reference-v1" as const, manifestSha256: migrationManifestDigest(), prefixLength: MIGRATION_MANIFEST.length, profileSha256: profile(captured), schema: captured }
|
||||
const canonicalSha256 = fingerprint(payload), signature = await privateSecretFingerprint(canonicalSha256, domain)
|
||||
const bytes = Buffer.from(JSON.stringify({ ...payload, canonicalSha256, signature }) + "\n")
|
||||
if (bytes.length > maximum) return refuse("generated artifact too large")
|
||||
await mkdir(mcpStateDirectory(), { recursive: true, mode: 0o700 })
|
||||
publicationAttempted = true
|
||||
const output = await open(filename(), "wx", 0o600)
|
||||
try { await output.writeFile(bytes); await output.sync() } finally { await output.close() }
|
||||
return Object.freeze({ manifestSha256: payload.manifestSha256, logicalSchemaSha256: captured.sha256, referenceDatabaseDropped: true, readyAuthorized: false })
|
||||
} catch (cause) {
|
||||
throw new ControlledSchemaReferenceProvisioningError(name, phase, publicationAttempted, { cause })
|
||||
} finally {
|
||||
if (reference) await reference.end().catch(() => {})
|
||||
// A failed or uncertain CREATE/DROP is never retried or force-dropped. The
|
||||
// exact owned UUID may remain for an operator; no artifact is published.
|
||||
control.release(true)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { createHash, createHmac, randomUUID } from "node:crypto"
|
||||
import { readFile } from "node:fs/promises"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { test } from "node:test"
|
||||
import { runInNewContext } from "node:vm"
|
||||
import ts from "typescript"
|
||||
import * as model from "./database-schema-control-model.js"
|
||||
import * as manifest from "./migration-manifest.js"
|
||||
import { CONTROLLED_MIGRATIONS_SQL } from "./controlled-migrations.js"
|
||||
import { PARTITION_WRITER_ADVISORY_KEY } from "../services/partition-write-transaction.js"
|
||||
|
||||
const canonical = (value: unknown): string => JSON.stringify(value, (_key, item: unknown) => item && typeof item === "object" && !Array.isArray(item) ? Object.fromEntries(Object.entries(item).sort(([a], [b]) => a.localeCompare(b))) : item)
|
||||
const fingerprint = (value: unknown) => createHash("sha256").update(canonical(value)).digest("hex")
|
||||
const privateSecretFingerprint = async (value: string, domain: string) => createHmac("sha256", "inert-only-key:" + domain).update(value).digest("hex")
|
||||
const source = await readFile(new URL("./controlled-schema-verification.ts", import.meta.url), "utf8")
|
||||
const compiled = ts.transpileModule(source.replaceAll("import.meta.url", JSON.stringify(new URL("./controlled-schema-verification.ts", import.meta.url).href)), { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, esModuleInterop: true } }).outputText
|
||||
|
||||
/** Actual verifier source, inert module I/O. Synthetic capture/reference here
|
||||
* checks control flow only; it is not a production reference or SQL acceptance.
|
||||
* Root's producer→verifier private PG tests exercise genuine schema/data. */
|
||||
async function fixture() {
|
||||
const owner = randomUUID(), epoch = 1, digest = manifest.migrationManifestDigest()
|
||||
const history = await Promise.all(manifest.MIGRATION_MANIFEST.map(async pin => {
|
||||
const intent = { id: randomUUID(), epoch, kind: "migration" as const, manifestSha256: digest, artifactSha256: pin.sha256 }
|
||||
const observationSha256 = await privateSecretFingerprint(fingerprint({ format: "controlled-migration-file-ack-v1", intent, file: pin.file, transaction: pin.transaction, selfMarker: pin.selfMarker, fileCommitAck: true, markerAck: true }), "database-controlled-migration-file-ack-v1")
|
||||
return { id: intent.id, intent, terminal: { intent, outcome: "observed-success" as const, observationSha256 } }
|
||||
}))
|
||||
let state: model.DatabaseSchemaControlState = { format: model.DATABASE_SCHEMA_CONTROL_FORMAT, owner, epoch, phase: "TERMINAL", intent: null, terminal: history.at(-1)!.terminal }
|
||||
const facts = { reference: "valid" as "valid" | "missing" | "invalid" | "different", badTable: "", badSequence: false, badContext: false, badMarkers: false, forgedLedger: false, markerView: false, loseCommit: false }
|
||||
const events: string[] = [], releases: boolean[] = []
|
||||
const context = { backend_pid: "42", transaction_started: "2026-10-25 10:00:00+00" }
|
||||
const schema = { sha256: "a".repeat(64), sections: {
|
||||
relations: [{ namespace: "public", name: "schema_migrations", kind: "r", persistence: "p", row_security: false, force_row_security: false }],
|
||||
columns: [{ namespace: "public", relation: "schema_migrations", name: "id", dropped: false, type: "text" }],
|
||||
sequences: [{ namespace: "public", name: "servers_id_seq", start: "1" }],
|
||||
} }
|
||||
const app = {
|
||||
query: async (query: string, values: unknown[] = []) => {
|
||||
events.push(query)
|
||||
if (query === CONTROLLED_MIGRATIONS_SQL.history) {
|
||||
const rows = structuredClone(history)
|
||||
if (facts.forgedLedger) rows[0].terminal.observationSha256 = "a".repeat(64)
|
||||
return { rows }
|
||||
}
|
||||
if (query === CONTROLLED_MIGRATIONS_SQL.markers) return { rows: manifest.MIGRATION_MANIFEST.slice(0, facts.badMarkers ? manifest.MIGRATION_MANIFEST.length - 1 : manifest.MIGRATION_MANIFEST.length).map(pin => ({ id: pin.file.slice(0, -4) })) }
|
||||
if (query === sql.context) return { rows: [{ ...context }] }
|
||||
if (query.startsWith("SET ") || query.startsWith("RESET ") || query.startsWith("BEGIN ")) return { rows: [] }
|
||||
if (query.includes("pg_catalog.pg_advisory_lock(")) { assert.deepEqual(Array.from(values), [PARTITION_WRITER_ADVISORY_KEY]); return { rows: [] } }
|
||||
if (query.includes("pg_catalog.pg_advisory_unlock(")) return { rows: [{ unlocked: true }] }
|
||||
if (query === "COMMIT") { if (facts.loseCommit) throw Error("lost readonly COMMIT ACK"); return { rows: [], command: "COMMIT" } }
|
||||
const data = /\/\* controlled-schema-data:(seed|empty|sequence):([a-z_0-9]+) \*\//.exec(query)
|
||||
assert(data, "Only fixed production data SQL may execute")
|
||||
if (data[1] === "seed") {
|
||||
assert(query.includes("LIMIT 2")); assert(query.includes("- $2::text[]"))
|
||||
const expected = JSON.parse(String(values[0])); if (data[2] === "server_probe_storage_usage") assert.deepEqual(expected, { scope: "global", admissions: 0, reserved_files: 0, reserved_bytes: 0 }); else if (data[2] === "server_poll_storage_usage") assert.deepEqual(expected, { scope: "global", admissions: 0, reserved_rows: 0, reserved_bytes: 0, compacted_admissions: 0, archive_bytes: 0 }); else assert.equal(expected.id, 1)
|
||||
}
|
||||
return { rows: [{ ...context, ...(facts.badContext ? { backend_pid: "43" } : {}), valid: data[1] === "sequence" ? !facts.badSequence : data[2] !== facts.badTable }] }
|
||||
},
|
||||
release: (destroy: boolean) => { releases.push(destroy) },
|
||||
}
|
||||
const modules: Record<string, unknown> = {
|
||||
"node:url": { fileURLToPath },
|
||||
"./database-schema-control.js": { acquireDatabaseSchemaControl: async () => {
|
||||
events.push("acquire control"); state = model.claimDatabaseSchemaOwnership(state, randomUUID())
|
||||
return { read: async () => ({ state: structuredClone(state) }), close: async () => { events.push("close control") } }
|
||||
} },
|
||||
"./database-schema-control-model.js": { ...model, checkDatabaseSchemaControlState: (value: unknown) => model.checkDatabaseSchemaControlState(structuredClone(value)) },
|
||||
"./controlled-migrations.js": { CONTROLLED_MIGRATIONS_SQL },
|
||||
"./migration-manifest.js": manifest,
|
||||
"./controlled-schema-reference.js": { readControlledSchemaReference: async () => { if (facts.reference === "invalid") throw Error("bad artifact signature"); return facts.reference === "missing" ? null : { ...schema, sha256: facts.reference === "different" ? "b".repeat(64) : schema.sha256 } } },
|
||||
"./migration-schema-inventory.js": {
|
||||
captureMigrationSchema: async (_client: unknown, options: unknown) => { events.push("capture schema"); assert.deepEqual(JSON.parse(JSON.stringify(options)), { scope: { applicationNamespaces: ["public"], excludedControlNamespaces: ["mmapp_control"] } }); return { ...schema, sections: { ...schema.sections, relations: [{ ...schema.sections.relations[0], kind: facts.markerView ? "v" : "r" }] } } },
|
||||
compareMigrationSchemas: (expected: typeof schema, observed: typeof schema) => { events.push("compare schema"); if (expected.sha256 !== observed.sha256) throw Error("schema differs") },
|
||||
},
|
||||
"../modules/mcp/security.js": { fingerprint, privateSecretFingerprint },
|
||||
"../services/partition-write-transaction.js": { PARTITION_WRITER_ADVISORY_KEY },
|
||||
"./index.js": { pool: { connect: async () => app } },
|
||||
}
|
||||
const exports = {} as { verifyControlledSchemaCurrentState(): Promise<{ ready: false; schemaAndDataVerified: false; historicalDataEquivalent: false; currentPristineDataInvariants: boolean; referenceStatus: string; gaps: string[]; dataFacts: unknown[]; sequenceFacts: unknown[] }>; CONTROLLED_SCHEMA_VERIFICATION_SQL: typeof import("./controlled-schema-verification.js").CONTROLLED_SCHEMA_VERIFICATION_SQL }
|
||||
runInNewContext(compiled, { exports, require: (name: string) => { assert(name in modules, "Unapproved import: " + name); return modules[name] }, URL, Promise, Error, Object, Map, Set, Date, structuredClone }, { filename: "controlled-schema-verification.ts" })
|
||||
const sql = exports.CONTROLLED_SCHEMA_VERIFICATION_SQL
|
||||
return { exports, facts, events, releases, run: exports.verifyControlledSchemaCurrentState }
|
||||
}
|
||||
test("fixed current snapshot authenticates full ledger and checks bounded data after reference equality", async () => {
|
||||
const f = await fixture(), result = await f.run()
|
||||
assert.equal(result.referenceStatus, "matched"); assert.equal(result.currentPristineDataInvariants, true)
|
||||
assert.equal(result.ready, false); assert.equal(result.schemaAndDataVerified, false); assert.equal(result.historicalDataEquivalent, false)
|
||||
assert.equal(result.dataFacts.length, manifest.CURRENT_REQUIRED_TABLES.length)
|
||||
assert.equal(f.events.filter(query => query.startsWith("/* controlled-schema-data:seed:")).length, 18)
|
||||
assert.equal(result.sequenceFacts.length, 1)
|
||||
assert(f.events.indexOf("compare schema") < f.events.findIndex(query => query.startsWith("/* controlled-schema-data:")))
|
||||
assert(f.events.indexOf("BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY") < f.events.indexOf("capture schema"))
|
||||
assert.deepEqual(f.releases, [false])
|
||||
})
|
||||
test("missing, invalid or different reference never reads application data or grants READY", async () => {
|
||||
for (const status of ["missing", "invalid", "different"] as const) {
|
||||
const f = await fixture(); f.facts.reference = status
|
||||
const result = await f.run()
|
||||
assert.equal(result.referenceStatus, status); assert.equal(result.ready, false); assert.equal(result.currentPristineDataInvariants, false)
|
||||
assert(!f.events.some(query => query.startsWith("/* controlled-schema-data:")))
|
||||
assert(result.gaps.includes("TRUSTED_SCHEMA_REFERENCE_" + status.toUpperCase()))
|
||||
}
|
||||
})
|
||||
test("real data predicate failures remain observed failures, never hidden by matching schema", async () => {
|
||||
for (const table of ["data_migration", "traffic_flow_settings", "servers"]) {
|
||||
const f = await fixture(); f.facts.badTable = table
|
||||
const result = await f.run()
|
||||
assert.equal(result.referenceStatus, "matched"); assert.equal(result.currentPristineDataInvariants, false); assert.equal(result.ready, false)
|
||||
}
|
||||
const f = await fixture(); f.facts.badSequence = true
|
||||
assert.equal((await f.run()).currentPristineDataInvariants, false)
|
||||
})
|
||||
test("forged ACK ledger or marker mismatch refuses rather than returning verification", async () => {
|
||||
for (const field of ["forgedLedger", "badMarkers"] as const) {
|
||||
const f = await fixture(); f.facts[field] = true
|
||||
await assert.rejects(f.run()); assert.deepEqual(f.releases, [true])
|
||||
assert(!f.events.some(query => query.startsWith("/* controlled-schema-data:")))
|
||||
}
|
||||
})
|
||||
test("marker schema drift refuses before reading a forged marker view", async () => {
|
||||
const f = await fixture(); f.facts.markerView = true
|
||||
await assert.rejects(f.run()); assert(!f.events.includes(CONTROLLED_MIGRATIONS_SQL.markers))
|
||||
})
|
||||
test("snapshot backend changes or lost read-only COMMIT cannot publish accepted evidence", async () => {
|
||||
for (const field of ["badContext", "loseCommit"] as const) {
|
||||
const f = await fixture(); f.facts[field] = true
|
||||
await assert.rejects(f.run()); assert.deepEqual(f.releases, [true])
|
||||
}
|
||||
})
|
||||
test("data SQL permits only known seed tables and bounded lowercase identifiers", async () => {
|
||||
const f = await fixture()
|
||||
assert.throws(() => f.exports.CONTROLLED_SCHEMA_VERIFICATION_SQL.seed("servers"))
|
||||
assert.throws(() => f.exports.CONTROLLED_SCHEMA_VERIFICATION_SQL.seed('mcp_settings"; SELECT 1'))
|
||||
})
|
||||
|
||||
|
||||
test("only the three0042 seeds validate current UUIDv4 epochs separately without attesting origin", async () => {
|
||||
const f = await fixture()
|
||||
for (const table of ["traffic_settings", "uptime_settings", "internet_path_settings"]) {
|
||||
const query = f.exports.CONTROLLED_SCHEMA_VERIFICATION_SQL.seed(table)
|
||||
assert(query.includes("t.poll_configuration_epoch IS NOT NULL"))
|
||||
assert(query.includes("4[0-9a-f]{3}-[89ab]"))
|
||||
assert(query.includes("to_jsonb(t) - 'poll_configuration_epoch'"))
|
||||
f.facts.badTable = table; assert.equal((await f.run()).currentPristineDataInvariants, false)
|
||||
}
|
||||
assert(!f.exports.CONTROLLED_SCHEMA_VERIFICATION_SQL.seed("servers_api_ping_settings").includes("poll_configuration_epoch"))
|
||||
f.facts.badTable = ""
|
||||
const evidence = await f.run()
|
||||
assert(evidence.gaps.includes("NONDETERMINISTIC_POLL_EPOCH_ORIGIN_UNATTESTED")); assert.equal(evidence.ready, false)
|
||||
})
|
||||
@@ -0,0 +1,149 @@
|
||||
import { fileURLToPath } from "node:url"
|
||||
import type { PoolClient } from "pg"
|
||||
import { acquireDatabaseSchemaControl } from "./database-schema-control.js"
|
||||
import { checkDatabaseSchemaControlState, type DatabaseSchemaControlState } from "./database-schema-control-model.js"
|
||||
import { CONTROLLED_MIGRATIONS_SQL } from "./controlled-migrations.js"
|
||||
import { CURRENT_REQUIRED_TABLES, inventoryMigrationDirectory, MIGRATION_MANIFEST, migrationManifestDigest } from "./migration-manifest.js"
|
||||
import { captureMigrationSchema, compareMigrationSchemas, type CapturedMigrationSchema } from "./migration-schema-inventory.js"
|
||||
import { readControlledSchemaReference } from "./controlled-schema-reference.js"
|
||||
import { fingerprint, privateSecretFingerprint } from "../modules/mcp/security.js"
|
||||
import { PARTITION_WRITER_ADVISORY_KEY } from "../services/partition-write-transaction.js"
|
||||
|
||||
/** Current pristine-bootstrap invariants derived from the exact pinned INSERTs.
|
||||
* Timestamps are checked separately as CURRENT finite/equal values, never as
|
||||
* proof of the nondeterministic time originally assigned by a migration. */
|
||||
const seeds: Readonly<Record<string, Readonly<Record<string, unknown>>>> = Object.freeze({
|
||||
server_probe_storage_usage: { scope: "global", admissions: 0, reserved_files: 0, reserved_bytes: 0 },
|
||||
traffic_settings: { id: 1, enabled: true, interval_sec: 30, retention_days: 14, last_collected_at: null, last_duration_ms: null, last_error: null },
|
||||
servers_api_ping_settings: { id: 1, enabled: false, interval_sec: 120, last_collected_at: null, last_duration_ms: null, last_error: null },
|
||||
traffic_flow_settings: { id: 1, enabled: false, collector_ip: "10.255.254.1", flow_listen_port: 4739, wg_listen_port: 51821, prefix: "10.255.254.0/24", public_endpoint: "", host_public_key: "", host_private_key: "", hub_server_id: null, retention_hours: 24, top_n: 200, map_service_min_share_pct: 5, last_datagram_at: null, last_exporter_ip: null, last_error: null, packets_received: 0, peers_json: [] },
|
||||
uptime_settings: { id: 1, enabled: true, resources_enabled: true, ping_enabled: true, speed_enabled: true, interval_sec: 15, probe_interval_sec: 15, speed_interval_sec: 60, retention_days: 14, last_collected_at: null, last_duration_ms: null, last_error: null },
|
||||
evobgp_settings: { id: 1, base_url: "", api_key: "", enabled: false },
|
||||
alert_telegram_settings: { id: 1, bot_token: "", chat_id: "", message_thread_id: null },
|
||||
acme_settings: { id: 1, directory_url: "https://acme-v02.api.letsencrypt.org/directory", cloudflare_api_token: "", default_zone_id: "", account_private_key: "" },
|
||||
certificate_renew_settings: { id: 1, enabled: true, interval_sec: 21600, renew_before_days: 30, last_collected_at: null, last_duration_ms: null, last_error: null },
|
||||
backup_schedule_settings: { id: 1, enabled: true, frequency: "daily", hour: 3, minute: 0, week_day: 0, month_day: 1, keep_count: 7, format: "rsc", server_ids_json: [], last_run_at: null, last_duration_ms: null, last_error: null },
|
||||
internet_path_settings: { id: 1, enabled: true, interval_sec: 300, retention_days: 14, last_collected_at: null, last_duration_ms: null, last_error: null },
|
||||
alert_engine_cursor: { id: 1, last_source_finished_at: null },
|
||||
data_migration: { id: 1, sqlite_imported_at: null, sqlite_path: null, sqlite_sha256: null, report_json: null },
|
||||
backup_storage_settings: { id: 1, provider: "local", s3_endpoint: "", s3_region: "us-east-1", s3_bucket: "", s3_prefix: "mikrotik", s3_access_key_id: "", s3_secret_access_key: "", s3_force_path_style: true, keep_local_copy: true, last_test_at: null, last_test_error: null },
|
||||
geoip_settings: { id: 1, enabled: true, update_interval_sec: 604800, last_check_at: null, last_success_at: null, last_error: null, country_build_at: null, asn_build_at: null, etags_json: {} },
|
||||
router_log_settings: { id: 1, enabled: false, interval_sec: 10, retention_days: 14 },
|
||||
server_poll_storage_usage: { scope: "global", admissions: 0, reserved_rows: 0, reserved_bytes: 0, compacted_admissions: 0, archive_bytes: 0 },
|
||||
mcp_settings: { id: 1, enabled: false, requests_retention_days: 30, audit_retention_days: 180 },
|
||||
})
|
||||
// Migration0042 assigns fresh UUIDv4 epochs independently in each database.
|
||||
// Check their current shape separately; this never attests their origin/history.
|
||||
const epochSeeds = new Set(["traffic_settings", "uptime_settings", "internet_path_settings"])
|
||||
const bothTimes = new Set(["traffic_settings", "servers_api_ping_settings", "traffic_flow_settings", "uptime_settings", "internet_path_settings", "geoip_settings"])
|
||||
const noTimes = new Set(["data_migration", "router_log_settings", "server_probe_storage_usage", "server_poll_storage_usage"])
|
||||
const metadata = "pg_catalog.pg_backend_pid()::text AS backend_pid, pg_catalog.transaction_timestamp()::text AS transaction_started"
|
||||
const identifier = (value: string) => { if (!/^[a-z_][a-z_0-9]{0,62}$/.test(value)) throw new ControlledSchemaVerificationError("Unsupported pinned data identifier"); return '"' + value + '"' }
|
||||
export const CONTROLLED_SCHEMA_VERIFICATION_SQL = Object.freeze({
|
||||
context: `SELECT ${metadata}`,
|
||||
empty: (table: string) => `/* controlled-schema-data:empty:${table} */ SELECT ${metadata}, NOT EXISTS (SELECT 1 FROM public.${identifier(table)} LIMIT 1) AS valid`,
|
||||
seed: (table: string) => {
|
||||
if (!Object.hasOwn(seeds, table)) throw new ControlledSchemaVerificationError("Unknown pinned seed table")
|
||||
const times = noTimes.has(table) ? [] : bothTimes.has(table) ? ["created_at", "updated_at"] : ["updated_at"]
|
||||
const timeCheck = times.length ? " AND " + times.map(name => `pg_catalog.isfinite(t.${identifier(name)})`).join(" AND ") + (times.length === 2 ? " AND t.created_at = t.updated_at" : "") : ""
|
||||
const epoch = epochSeeds.has(table)
|
||||
const projection = epoch ? "(pg_catalog.to_jsonb(t) - 'poll_configuration_epoch')" : "pg_catalog.to_jsonb(t)"
|
||||
const epochCheck = epoch ? " AND t.poll_configuration_epoch IS NOT NULL AND t.poll_configuration_epoch::text ~ '^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$'" : ""
|
||||
return `/* controlled-schema-data:seed:${table} */ SELECT ${metadata}, (SELECT count(*) = 1 AND COALESCE(pg_catalog.bool_and((${projection} - $2::text[]) = $1::jsonb${timeCheck}${epochCheck}), false) FROM (SELECT * FROM public.${identifier(table)} LIMIT 2) t) AS valid`
|
||||
},
|
||||
sequence: (name: string) => `/* controlled-schema-data:sequence:${name} */ SELECT ${metadata}, last_value::text = $1::text AND is_called IS FALSE AS valid FROM public.${identifier(name)}`,
|
||||
})
|
||||
export class ControlledSchemaVerificationError extends Error { readonly code = "CONTROLLED_SCHEMA_VERIFICATION_REFUSED" }
|
||||
const refuse = (message: string): never => { throw new ControlledSchemaVerificationError(message) }
|
||||
const canonical = (value: unknown): string => JSON.stringify(value, (_key, item: unknown) => item && typeof item === "object" && !Array.isArray(item) ? Object.fromEntries(Object.entries(item).sort(([a], [b]) => a.localeCompare(b))) : item)
|
||||
async function authenticateCompletedLedger(rows: Record<string, unknown>[], state: DatabaseSchemaControlState, manifest: string) {
|
||||
if (rows.length !== MIGRATION_MANIFEST.length || state.intent !== null) refuse("Complete controlled file ledger required")
|
||||
const byFile = new Map<string, unknown>(), ids = new Set<string>()
|
||||
for (const row of rows) {
|
||||
if (Object.keys(row).sort().join(" ") !== "id intent terminal" || typeof row.id !== "string" || ids.has(row.id)) refuse("Invalid controlled ledger row")
|
||||
const terminal = checkDatabaseSchemaControlState({ format: state.format, epoch: state.epoch, owner: state.owner, phase: "TERMINAL", intent: null, terminal: row.terminal }).terminal!
|
||||
const intent = terminal.intent
|
||||
if (canonical(intent) !== canonical(row.intent) || intent.id !== row.id || intent.kind !== "migration" || intent.manifestSha256 !== manifest || terminal.outcome !== "observed-success") refuse("Unresolved, foreign or mismatched file history")
|
||||
const pin = MIGRATION_MANIFEST.find(pin => pin.sha256 === intent.artifactSha256)
|
||||
if (!pin || byFile.has(pin.file)) return refuse("Unknown or duplicate controlled file")
|
||||
const expected = await privateSecretFingerprint(fingerprint({ format: "controlled-migration-file-ack-v1", intent, file: pin.file, transaction: pin.transaction, selfMarker: pin.selfMarker, fileCommitAck: true, markerAck: true }), "database-controlled-migration-file-ack-v1")
|
||||
if (terminal.observationSha256 !== expected) refuse("Controlled file ACK issuer mismatch")
|
||||
ids.add(row.id as string); byFile.set(pin.file, terminal)
|
||||
}
|
||||
if (MIGRATION_MANIFEST.some(pin => !byFile.has(pin.file)) || canonical(state.terminal) !== canonical(byFile.get(MIGRATION_MANIFEST.at(-1)!.file))) refuse("Current terminal disagrees with full file history")
|
||||
}
|
||||
|
||||
/** Fixed production I/O, evidence only. No caller reference/provider/SQL,
|
||||
* provisioning, application writes, READY publication, or data issuer exists.
|
||||
* The exclusive lease fences adopted writers; all-replica quiescence remains
|
||||
* an explicit runtime gap before any future durable READY grant. */
|
||||
export async function verifyControlledSchemaCurrentState() {
|
||||
await inventoryMigrationDirectory(fileURLToPath(new URL("../../drizzle/", import.meta.url)))
|
||||
const manifest = migrationManifestDigest()
|
||||
let reference: CapturedMigrationSchema | null = null, referenceStatus: "missing" | "invalid" | "matched" | "different" = "missing"
|
||||
try { reference = await readControlledSchemaReference() } catch { referenceStatus = "invalid" }
|
||||
const { pool } = await import("./index.js")
|
||||
const control = await acquireDatabaseSchemaControl(pool)
|
||||
let app: PoolClient | undefined, released = false
|
||||
try {
|
||||
app = await pool.connect()
|
||||
await app.query("SET lock_timeout = '10s'")
|
||||
await app.query("SELECT pg_catalog.pg_advisory_lock($1::bigint)", [PARTITION_WRITER_ADVISORY_KEY])
|
||||
const state = (await control.read()).state
|
||||
await app.query("BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY")
|
||||
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 = '5s'"]) await app.query(sql)
|
||||
const contextRows = (await app.query(CONTROLLED_SCHEMA_VERIFICATION_SQL.context)).rows
|
||||
if (contextRows.length !== 1 || Object.keys(contextRows[0]).sort().join(" ") !== "backend_pid transaction_started" || typeof contextRows[0].backend_pid !== "string" || !/^\d+$/.test(contextRows[0].backend_pid) || typeof contextRows[0].transaction_started !== "string" || !Number.isFinite(Date.parse(contextRows[0].transaction_started))) refuse("Read-only evidence transaction context unavailable")
|
||||
const context = contextRows[0]
|
||||
await authenticateCompletedLedger((await app.query(CONTROLLED_MIGRATIONS_SQL.history)).rows, state, manifest)
|
||||
const schema = await captureMigrationSchema(app, { scope: { applicationNamespaces: ["public"], excludedControlNamespaces: ["mmapp_control"] } })
|
||||
const markerTable = schema.sections.relations.find(row => row.namespace === "public" && row.name === "schema_migrations")
|
||||
const markerId = schema.sections.columns.find(row => row.namespace === "public" && row.relation === "schema_migrations" && row.name === "id" && row.dropped === false)
|
||||
if (!markerTable || markerTable.kind !== "r" || markerTable.persistence !== "p" || markerTable.row_security !== false || markerTable.force_row_security !== false || !markerId || markerId.type !== "text") refuse("Ordinary text migration marker relation required")
|
||||
const markers = (await app.query(CONTROLLED_MIGRATIONS_SQL.markers)).rows
|
||||
if (canonical(markers) !== canonical(MIGRATION_MANIFEST.map(pin => ({ id: pin.file.slice(0, -4) })))) refuse("Markers disagree with complete controlled ledger")
|
||||
if (reference) {
|
||||
try { compareMigrationSchemas(reference, schema); referenceStatus = "matched" } catch { referenceStatus = "different" }
|
||||
}
|
||||
const dataFacts: { table: string; valid: boolean; profile: "seed" | "empty" | "marker" }[] = []
|
||||
const sequenceFacts: { sequence: string; valid: boolean }[] = []
|
||||
const checked = async (query: string, values: unknown[] = []) => {
|
||||
const rows = (await app!.query(query, values)).rows
|
||||
if (rows.length !== 1 || Object.keys(rows[0]).sort().join(" ") !== "backend_pid transaction_started valid" || rows[0].backend_pid !== context.backend_pid || rows[0].transaction_started !== context.transaction_started || typeof rows[0].valid !== "boolean") refuse("Data observation escaped the owned evidence snapshot")
|
||||
return rows[0].valid as boolean
|
||||
}
|
||||
// An untrusted schema could turn a name into a view/function or custom
|
||||
// output type. Read application data only after trusted reference equality.
|
||||
if (referenceStatus === "matched") {
|
||||
for (const table of CURRENT_REQUIRED_TABLES) {
|
||||
if (table === "schema_migrations") { dataFacts.push({ table, valid: true, profile: "marker" }); continue }
|
||||
const seed = seeds[table]
|
||||
const times = noTimes.has(table) ? [] : bothTimes.has(table) ? ["created_at", "updated_at"] : ["updated_at"]
|
||||
const valid = seed ? await checked(CONTROLLED_SCHEMA_VERIFICATION_SQL.seed(table), [JSON.stringify(seed), times]) : await checked(CONTROLLED_SCHEMA_VERIFICATION_SQL.empty(table))
|
||||
dataFacts.push({ table, valid, profile: seed ? "seed" : "empty" })
|
||||
}
|
||||
for (const sequence of schema.sections.sequences) {
|
||||
if (sequence.namespace !== "public" || typeof sequence.name !== "string" || typeof sequence.start !== "string") return refuse("Unsupported pinned sequence state")
|
||||
sequenceFacts.push({ sequence: sequence.name, valid: await checked(CONTROLLED_SCHEMA_VERIFICATION_SQL.sequence(sequence.name), [sequence.start]) })
|
||||
}
|
||||
}
|
||||
const committed = await app.query("COMMIT")
|
||||
if (committed.command !== "COMMIT") refuse("Read-only evidence COMMIT unconfirmed")
|
||||
const unlock = await app.query("SELECT pg_catalog.pg_advisory_unlock($1::bigint) AS unlocked", [PARTITION_WRITER_ADVISORY_KEY])
|
||||
if (unlock.rows.length !== 1 || unlock.rows[0].unlocked !== true) refuse("Evidence lease release unconfirmed")
|
||||
await app.query("RESET lock_timeout")
|
||||
app.release(false); released = true
|
||||
await control.close()
|
||||
return Object.freeze({ format: "controlled-schema-current-evidence-v1" as const, manifestSha256: manifest, authenticatedFiles: MIGRATION_MANIFEST.length,
|
||||
referenceStatus, schemaSha256: schema.sha256, dataFacts, sequenceFacts,
|
||||
currentPristineDataInvariants: referenceStatus === "matched" && dataFacts.every(fact => fact.valid) && sequenceFacts.every(fact => fact.valid),
|
||||
ready: false as const, schemaAndDataVerified: false as const, historicalDataEquivalent: false as const,
|
||||
gaps: [...(referenceStatus !== "matched" ? ["TRUSTED_SCHEMA_REFERENCE_" + referenceStatus.toUpperCase()] : []),
|
||||
"HISTORICAL_DATA_AND_NONDETERMINISTIC_TIMESTAMP_RECEIPT_MISSING", "NONDETERMINISTIC_POLL_EPOCH_ORIGIN_UNATTESTED", "IMPORT_RESTORE_DATA_EQUIVALENCE_UNPROVEN", "ALL_REPLICA_QUIESCENCE_UNATTESTED", "DURABLE_READY_PROTOCOL_UNIMPLEMENTED"],
|
||||
})
|
||||
} catch (cause) {
|
||||
if (app && !released) { released = true; app.release(true) }
|
||||
try { await control.close() } catch { /* preserve primary evidence failure */ }
|
||||
throw new ControlledSchemaVerificationError("Controlled current-state verification refused", { cause })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { test } from "node:test"
|
||||
import { checkDatabaseSchemaControlState, claimDatabaseSchemaOwnership, databaseSchemaAdmission, initialDatabaseSchemaControlState, transitionDatabaseSchemaControl, type DatabaseSchemaControlState } from "./database-schema-control-model.js"
|
||||
|
||||
const owner = "00000000-0000-4000-8000-000000000001", nextOwner = "00000000-0000-4000-8000-000000000002"
|
||||
const id = "00000000-0000-4000-8000-000000000003", hash = "a".repeat(64)
|
||||
const claimed = () => claimDatabaseSchemaOwnership(initialDatabaseSchemaControlState(), owner)
|
||||
const begin = (state = claimed()) => transitionDatabaseSchemaControl(state, owner, { type: "begin", id, kind: "restore", manifestSha256: hash, artifactSha256: hash })
|
||||
test("initial and freshly claimed state remain quarantined", () => {
|
||||
assert.equal(databaseSchemaAdmission(initialDatabaseSchemaControlState()).ready, false)
|
||||
assert.equal(claimed().epoch, 1)
|
||||
assert.equal(databaseSchemaAdmission(claimed()).ready, false)
|
||||
})
|
||||
test("success observation never opens readiness or proves data migration", () => {
|
||||
const state = transitionDatabaseSchemaControl(begin(), owner, { type: "terminal", id, outcome: "observed-success", observationSha256: hash })
|
||||
assert.equal(state.phase, "TERMINAL")
|
||||
assert.equal(databaseSchemaAdmission(state).reason, "SCHEMA_AND_DATA_VERIFIER_REQUIRED")
|
||||
})
|
||||
test("restart preserves pending intent and cannot infer outcome from matching schema", () => {
|
||||
const state = claimDatabaseSchemaOwnership(begin(), nextOwner)
|
||||
assert.equal(state.intent?.epoch, 1)
|
||||
assert.equal(state.epoch, 2)
|
||||
assert.equal(databaseSchemaAdmission(state).reason, "UNRESOLVED_INTENT")
|
||||
for (const outcome of ["observed-success", "observed-failure"] as const) assert.throws(() => transitionDatabaseSchemaControl(state, nextOwner, { type: "terminal", id, outcome, observationSha256: hash }))
|
||||
const terminal = transitionDatabaseSchemaControl(state, nextOwner, { type: "terminal", id, outcome: "interrupted-unknown", observationSha256: hash })
|
||||
assert.equal(databaseSchemaAdmission(terminal).ready, false)
|
||||
})
|
||||
test("fresh owner retains terminal for future independent verification", () => {
|
||||
const terminal = transitionDatabaseSchemaControl(begin(), owner, { type: "terminal", id, outcome: "observed-success", observationSha256: hash })
|
||||
const restarted = claimDatabaseSchemaOwnership(terminal, nextOwner)
|
||||
assert.deepEqual(restarted.terminal, terminal.terminal)
|
||||
assert.equal(restarted.phase, "QUARANTINED")
|
||||
})
|
||||
test("owner mismatch, unresolved intent, duplicate terminal and malformed commands refuse", () => {
|
||||
assert.throws(() => transitionDatabaseSchemaControl(begin(), nextOwner, { type: "terminal", id, outcome: "observed-success", observationSha256: hash }))
|
||||
assert.throws(() => begin(begin()))
|
||||
const terminal = transitionDatabaseSchemaControl(begin(), owner, { type: "terminal", id, outcome: "observed-failure", observationSha256: hash })
|
||||
assert.throws(() => begin(terminal))
|
||||
assert.throws(() => transitionDatabaseSchemaControl(terminal, owner, { type: "terminal", id, outcome: "observed-failure", observationSha256: hash }))
|
||||
assert.throws(() => transitionDatabaseSchemaControl(claimed(), owner, { type: "begin", id, kind: "migration", manifestSha256: "true", artifactSha256: hash }))
|
||||
})
|
||||
test("unknown durable fields, forged READY, unsafe epochs and inconsistent phases refuse", () => {
|
||||
for (const changes of [{ ready: true }, { phase: "READY" }, { epoch: Number.MAX_SAFE_INTEGER + 1 }, { phase: "INTENT" }, { phase: "TERMINAL" }, { owner: "a" }]) assert.throws(() => checkDatabaseSchemaControlState({ ...claimed(), ...changes }))
|
||||
assert.throws(() => claimDatabaseSchemaOwnership({ ...claimed(), epoch: Number.MAX_SAFE_INTEGER }, nextOwner))
|
||||
assert.throws(() => claimDatabaseSchemaOwnership(claimed(), owner))
|
||||
})
|
||||
test("returned states are detached from untrusted caller mutation", () => {
|
||||
const original = begin(), checked = checkDatabaseSchemaControlState(original)
|
||||
checked.intent!.artifactSha256 = "b".repeat(64)
|
||||
assert.equal(original.intent!.artifactSha256, hash)
|
||||
const frozen = databaseSchemaAdmission(original)
|
||||
assert(Object.isFrozen(frozen))
|
||||
assert.equal((original as DatabaseSchemaControlState).phase, "INTENT")
|
||||
})
|
||||
@@ -0,0 +1,107 @@
|
||||
/** Durable coordination only. Neither caller receipts nor schema equality are
|
||||
* data-migration proof. There is deliberately no READY transition here until a
|
||||
* fixed, independently reviewed schema AND data verifier is implemented. */
|
||||
export const DATABASE_SCHEMA_CONTROL_FORMAT = "database-schema-control-v1" as const
|
||||
export type DatabaseSchemaOperation = "bootstrap" | "migration" | "import" | "restore"
|
||||
export interface DatabaseSchemaIntent {
|
||||
id: string
|
||||
epoch: number
|
||||
kind: DatabaseSchemaOperation
|
||||
manifestSha256: string
|
||||
artifactSha256: string
|
||||
}
|
||||
export interface DatabaseSchemaTerminal {
|
||||
intent: DatabaseSchemaIntent
|
||||
outcome: "observed-success" | "observed-failure" | "interrupted-unknown"
|
||||
/** Observation content address or fixed issuer's SHA256-based keyed digest.
|
||||
* The generic store never treats this field as an authorization capability;
|
||||
* a specific dispatcher must independently verify its fixed issuer. */
|
||||
observationSha256: string
|
||||
}
|
||||
export interface DatabaseSchemaControlState {
|
||||
format: typeof DATABASE_SCHEMA_CONTROL_FORMAT
|
||||
epoch: number
|
||||
owner: string | null
|
||||
phase: "QUARANTINED" | "INTENT" | "TERMINAL"
|
||||
intent: DatabaseSchemaIntent | null
|
||||
terminal: DatabaseSchemaTerminal | null
|
||||
}
|
||||
export class DatabaseSchemaControlError extends Error {
|
||||
readonly code = "DATABASE_SCHEMA_CONTROL_REFUSED"
|
||||
constructor(message: string, readonly outcomeUnknown = false, options?: ErrorOptions) { super(message, options) }
|
||||
}
|
||||
const fail = (message: string): never => { throw new DatabaseSchemaControlError(message) }
|
||||
const uuid = (value: unknown): value is string => typeof value === "string" && /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(value)
|
||||
const sha = (value: unknown): value is string => typeof value === "string" && /^[0-9a-f]{64}$/.test(value)
|
||||
function shape(value: unknown, fields: string): asserts value is Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value) || Object.getPrototypeOf(value) !== Object.prototype || Object.keys(value).sort().join(" ") !== fields.split(" ").sort().join(" ")) fail("Invalid control record shape")
|
||||
}
|
||||
function checkIntent(value: unknown): asserts value is DatabaseSchemaIntent {
|
||||
shape(value, "id epoch kind manifestSha256 artifactSha256")
|
||||
if (!uuid(value.id) || !Number.isSafeInteger(value.epoch) || Number(value.epoch) < 1 || !["bootstrap", "migration", "import", "restore"].includes(String(value.kind)) || !sha(value.manifestSha256) || !sha(value.artifactSha256)) fail("Invalid exact operation intent")
|
||||
}
|
||||
export function checkDatabaseSchemaControlState(value: unknown): DatabaseSchemaControlState {
|
||||
shape(value, "format epoch owner phase intent terminal")
|
||||
if (value.format !== DATABASE_SCHEMA_CONTROL_FORMAT || !Number.isSafeInteger(value.epoch) || Number(value.epoch) < 0 || !(value.owner === null && value.epoch === 0 || uuid(value.owner) && Number(value.epoch) > 0) || !["QUARANTINED", "INTENT", "TERMINAL"].includes(String(value.phase))) fail("Invalid control owner/epoch/phase")
|
||||
if (value.intent !== null) { checkIntent(value.intent); if (value.intent.epoch > Number(value.epoch)) fail("Intent epoch exceeds owner epoch") }
|
||||
if (value.terminal !== null) {
|
||||
shape(value.terminal, "intent outcome observationSha256"); checkIntent(value.terminal.intent)
|
||||
if (value.terminal.intent.epoch > Number(value.epoch) || !["observed-success", "observed-failure", "interrupted-unknown"].includes(String(value.terminal.outcome)) || !sha(value.terminal.observationSha256)) fail("Invalid terminal observation")
|
||||
}
|
||||
if (value.phase === "INTENT" && value.intent === null || value.phase === "TERMINAL" && (value.intent !== null || value.terminal === null)) fail("Inconsistent control phase")
|
||||
if (value.owner === null && (value.intent !== null || value.terminal !== null || value.phase !== "QUARANTINED")) fail("Invalid initial state")
|
||||
return structuredClone(value) as unknown as DatabaseSchemaControlState
|
||||
}
|
||||
export function initialDatabaseSchemaControlState(): DatabaseSchemaControlState {
|
||||
return { format: DATABASE_SCHEMA_CONTROL_FORMAT, epoch: 0, owner: null, phase: "QUARANTINED", intent: null, terminal: null }
|
||||
}
|
||||
export function claimDatabaseSchemaOwnership(raw: unknown, owner: string): DatabaseSchemaControlState {
|
||||
const state = checkDatabaseSchemaControlState(raw)
|
||||
if (!uuid(owner) || state.owner === owner || state.epoch === Number.MAX_SAFE_INTEGER) fail("Fresh owner and bounded monotonic epoch required")
|
||||
// Preserve the interrupted intent and last terminal observation. A restart
|
||||
// must not erase uncertainty, including a server-side COMMIT with lost ACK.
|
||||
return { ...state, owner, epoch: state.epoch + 1, phase: "QUARANTINED" }
|
||||
}
|
||||
export type DatabaseSchemaControlCommand =
|
||||
| { type: "begin"; id: string; kind: DatabaseSchemaOperation; manifestSha256: string; artifactSha256: string }
|
||||
| { type: "terminal"; id: string; outcome: DatabaseSchemaTerminal["outcome"]; observationSha256: string }
|
||||
/** Exact flat primitive contract: reject accessors/proxies through clone at
|
||||
* the IO boundary; no nested policy bytes or arbitrary caller JSON accepted. */
|
||||
export function checkDatabaseSchemaControlCommand(raw: unknown): DatabaseSchemaControlCommand {
|
||||
if (!raw || typeof raw !== "object") return fail("Missing control command")
|
||||
// Inspect a bounded flat shape before copying. Do not walk arbitrary policy
|
||||
// JSON or invoke accessor properties while awaiting a transaction.
|
||||
const descriptors = Object.getOwnPropertyDescriptors(raw)
|
||||
const names = Object.keys(descriptors).sort().join(" ")
|
||||
if (Object.getPrototypeOf(raw) !== Object.prototype || Reflect.ownKeys(raw).length !== Object.keys(descriptors).length || !["artifactSha256 id kind manifestSha256 type", "id observationSha256 outcome type"].includes(names) || Object.values(descriptors).some(item => !("value" in item) || !item.enumerable || typeof item.value !== "string" || item.value.length > 64)) fail("Control command must be a bounded flat primitive record")
|
||||
const command = structuredClone(raw) as Record<string, unknown>
|
||||
if (command.type === "begin") {
|
||||
shape(command, "type id kind manifestSha256 artifactSha256")
|
||||
checkIntent({ id: command.id, epoch: 1, kind: command.kind, manifestSha256: command.manifestSha256, artifactSha256: command.artifactSha256 })
|
||||
} else {
|
||||
shape(command, "type id outcome observationSha256")
|
||||
if (command.type !== "terminal" || !uuid(command.id) || !["observed-success", "observed-failure", "interrupted-unknown"].includes(String(command.outcome)) || !sha(command.observationSha256)) fail("Invalid terminal command")
|
||||
}
|
||||
return command as DatabaseSchemaControlCommand
|
||||
}
|
||||
export function transitionDatabaseSchemaControl(raw: unknown, owner: string, command: DatabaseSchemaControlCommand): DatabaseSchemaControlState {
|
||||
const state = checkDatabaseSchemaControlState(raw)
|
||||
if (!uuid(owner) || state.owner !== owner) fail("Control owner mismatch")
|
||||
if (command.type === "begin") {
|
||||
shape(command, "type id kind manifestSha256 artifactSha256")
|
||||
if (state.intent !== null || state.terminal?.intent.id === command.id) fail("Unresolved or reused intent")
|
||||
const intent = { id: command.id, epoch: state.epoch, kind: command.kind, manifestSha256: command.manifestSha256, artifactSha256: command.artifactSha256 }
|
||||
checkIntent(intent)
|
||||
return { ...state, phase: "INTENT", intent }
|
||||
}
|
||||
if (command.type !== "terminal") fail("Unknown control command")
|
||||
shape(command, "type id outcome observationSha256")
|
||||
if (!state.intent || state.intent.id !== command.id || !sha(command.observationSha256) || !["observed-success", "observed-failure", "interrupted-unknown"].includes(command.outcome)) return fail("Missing matching intent/terminal observation")
|
||||
if (state.intent.epoch !== state.epoch && command.outcome !== "interrupted-unknown") fail("Restart cannot claim old execution outcome")
|
||||
return checkDatabaseSchemaControlState({ ...state, phase: "TERMINAL", intent: null, terminal: { intent: state.intent, outcome: command.outcome, observationSha256: command.observationSha256 } })
|
||||
}
|
||||
export function databaseSchemaAdmission(raw: unknown) {
|
||||
const state = checkDatabaseSchemaControlState(raw)
|
||||
return Object.freeze({ ready: false as const, quarantined: true as const, epoch: state.epoch,
|
||||
reason: state.intent ? "UNRESOLVED_INTENT" as const : "SCHEMA_AND_DATA_VERIFIER_REQUIRED" as const })
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { randomUUID } from "node:crypto"
|
||||
import { basename, dirname, resolve } from "node:path"
|
||||
import { execFile } from "node:child_process"
|
||||
import { writeFile } from "node:fs/promises"
|
||||
|
||||
assert.equal(process.env.MMAPP_OWN_DATABASE_SCHEMA_CONTROL, "true")
|
||||
const databaseUrl = new URL(process.env.DATABASE_URL ?? "")
|
||||
assert.equal(databaseUrl.hostname, "127.0.0.1"); assert.equal(databaseUrl.pathname, "/postgres")
|
||||
assert(process.env.MCP_STATE_DIR && basename(dirname(resolve(process.env.MCP_STATE_DIR))).startsWith("mm-mcp-test-"))
|
||||
const { Pool } = await import("pg")
|
||||
const { pool, closePool } = await import("./index.js")
|
||||
const { applySqlMigrations } = await import("./migrate.js")
|
||||
const { acquireDatabaseSchemaControl } = await import("./database-schema-control.js")
|
||||
const { initialDatabaseSchemaControlState, DatabaseSchemaControlError } = await import("./database-schema-control-model.js")
|
||||
const { runPrivateDatabaseDump } = await import("../services/database-dump-connection.js")
|
||||
const { restorePartitionedDatabase } = await import("../services/system-database-partition-restore.js")
|
||||
type ControlPool = Parameters<typeof acquireDatabaseSchemaControl>[0]
|
||||
const names: string[] = [], cases: string[] = [], handles: Awaited<ReturnType<typeof acquireDatabaseSchemaControl>>[] = []
|
||||
const connections: InstanceType<typeof Pool>[] = []
|
||||
async function database() {
|
||||
const name = "mm_control_" + randomUUID().replaceAll("-", "")
|
||||
assert(/^mm_control_[a-f0-9]{32}$/.test(name))
|
||||
await pool.query('CREATE DATABASE "' + name + '" TEMPLATE template0')
|
||||
const url = new URL(databaseUrl); url.pathname = "/" + name
|
||||
const connection = new Pool({ connectionString: url.toString(), max: 6 })
|
||||
names.push(name); connections.push(connection); return connection
|
||||
}
|
||||
async function seed(connection: InstanceType<typeof Pool>) {
|
||||
await connection.query("CREATE SCHEMA mmapp_control; CREATE TABLE mmapp_control.database_schema_state(id smallint PRIMARY KEY CHECK(id=1),revision bigint NOT NULL CHECK(revision>=0),state jsonb NOT NULL); CREATE TABLE mmapp_control.database_schema_intents(id uuid PRIMARY KEY,intent jsonb NOT NULL,terminal jsonb)")
|
||||
await connection.query("INSERT INTO mmapp_control.database_schema_state VALUES(1,0,$1::jsonb)", [JSON.stringify(initialDatabaseSchemaControlState())])
|
||||
}
|
||||
async function acquire(connection: ControlPool) { const owner = await acquireDatabaseSchemaControl(connection); handles.push(owner); return owner }
|
||||
const command = () => ({ type: "begin" as const, id: randomUUID(), kind: "migration" as const, manifestSha256: "a".repeat(64), artifactSha256: "b".repeat(64) })
|
||||
const terminal = (id: string, outcome: "observed-success" | "observed-failure" | "interrupted-unknown" = "observed-success") => ({ type: "terminal" as const, id, outcome, observationSha256: "c".repeat(64) })
|
||||
const row = async (connection: InstanceType<typeof Pool>) => (await connection.query("SELECT revision::text,state FROM mmapp_control.database_schema_state WHERE id=1")).rows[0]
|
||||
const history = async (connection: InstanceType<typeof Pool>, id: string) => (await connection.query("SELECT intent,terminal FROM mmapp_control.database_schema_intents WHERE id=$1", [id])).rows[0]
|
||||
function lostCommit(connection: InstanceType<typeof Pool>, number: number): ControlPool {
|
||||
return { async connect() {
|
||||
const client = await connection.connect(); let commits = 0
|
||||
return { release(destroy?: boolean) { client.release(destroy) }, async query(sql: string, values?: unknown[]) {
|
||||
const result = await client.query(sql, values)
|
||||
if (sql === "COMMIT" && ++commits === number) throw Error("owned fixture lost actual COMMIT acknowledgement")
|
||||
return { rows: result.rows, command: result.command }
|
||||
} }
|
||||
} }
|
||||
}
|
||||
function abortedCommit(connection: InstanceType<typeof Pool>, number: number): ControlPool {
|
||||
return { async connect() {
|
||||
const client = await connection.connect(); let commits = 0
|
||||
return { release(destroy?: boolean) { client.release(destroy) }, async query(sql: string, values?: unknown[]) {
|
||||
if (sql === "COMMIT" && ++commits === number) {
|
||||
// A real aborted PostgreSQL transaction acknowledges COMMIT as ROLLBACK.
|
||||
await assert.rejects(client.query("SELECT 1/0"))
|
||||
}
|
||||
const result = await client.query(sql, values)
|
||||
return { rows: result.rows, command: result.command }
|
||||
} }
|
||||
} }
|
||||
}
|
||||
async function commandIo(program: string, args: string[]): Promise<Buffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
execFile(program, args, { encoding: "buffer", timeout: 30000, maxBuffer: 32 * 1048576, windowsHide: true }, (error, stdout) => error ? reject(Error("Owned PostgreSQL archive command failed: " + program)) : resolve(stdout))
|
||||
})
|
||||
}
|
||||
try {
|
||||
// The runner's separate terminal restore fixture uses this owned database.
|
||||
// Control protocol scenarios below each use their own independent database.
|
||||
await applySqlMigrations(pool)
|
||||
const missing = await database()
|
||||
await assert.rejects(acquire(missing), DatabaseSchemaControlError)
|
||||
assert.equal((await missing.query("SELECT count(*)::int AS n FROM pg_namespace WHERE nspname='mmapp_control'")).rows[0].n, 0)
|
||||
cases.push("missing external control refuses without startup DDL or enrollment")
|
||||
|
||||
const regular = await database(); await seed(regular)
|
||||
let owner = await acquire(regular)
|
||||
const initial = await owner.read(); assert.equal(initial.state.epoch, 1); assert.equal(initial.revision, "1")
|
||||
assert.equal((await owner.admission()).ready, false)
|
||||
await assert.rejects(acquire(regular), DatabaseSchemaControlError)
|
||||
assert.deepEqual(await owner.read(), initial)
|
||||
cases.push("one checked-out session owns exclusive lease; another owner changes no epoch or revision")
|
||||
const begin = command(), intended = await owner.transition(begin)
|
||||
assert.equal(intended.revision, "2"); assert.equal(intended.state.phase, "INTENT")
|
||||
assert.deepEqual((await history(regular, begin.id)).intent, intended.state.intent)
|
||||
assert.equal((await owner.admission()).reason, "UNRESOLVED_INTENT")
|
||||
cases.push("intent singleton and unique immutable history publish atomically before application dispatch")
|
||||
await owner.close(); owner = await acquire(regular)
|
||||
assert.equal((await owner.read()).state.intent?.id, begin.id)
|
||||
await assert.rejects(owner.transition(terminal(begin.id)), DatabaseSchemaControlError)
|
||||
assert.equal((await history(regular, begin.id)).terminal, null)
|
||||
cases.push("restart preserves old intent and cannot claim an unobserved successful outcome")
|
||||
owner = await acquire(regular)
|
||||
const interrupted = await owner.transition(terminal(begin.id, "interrupted-unknown"))
|
||||
assert.equal(interrupted.state.terminal?.outcome, "interrupted-unknown")
|
||||
assert.equal((await owner.admission()).ready, false)
|
||||
assert.deepEqual((await history(regular, begin.id)).terminal, interrupted.state.terminal)
|
||||
cases.push("interrupted unknown can be recorded once but never enables consumers")
|
||||
const second = command(); await owner.transition(second); await owner.transition(terminal(second.id))
|
||||
const prior = await row(regular)
|
||||
await assert.rejects(owner.transition(begin), DatabaseSchemaControlError)
|
||||
assert.deepEqual(await row(regular), prior)
|
||||
assert.equal((await regular.query("SELECT count(*)::int AS n FROM mmapp_control.database_schema_intents")).rows[0].n, 2)
|
||||
cases.push("reuse of an older completed operation is refused by actual unique history with no singleton advance")
|
||||
|
||||
for (const stage of ["claim", "intent", "terminal"] as const) {
|
||||
const connection = await database(); await seed(connection)
|
||||
const wrapped = lostCommit(connection, stage === "claim" ? 1 : stage === "intent" ? 2 : 3)
|
||||
const intent = command()
|
||||
if (stage === "claim") await assert.rejects(acquire(wrapped), (error: unknown) => error instanceof DatabaseSchemaControlError && error.outcomeUnknown)
|
||||
else {
|
||||
const handle = await acquire(wrapped)
|
||||
if (stage === "terminal") await handle.transition(intent)
|
||||
await assert.rejects(handle.transition(stage === "intent" ? intent : terminal(intent.id)), (error: unknown) => error instanceof DatabaseSchemaControlError && error.outcomeUnknown)
|
||||
await assert.rejects(handle.read(), DatabaseSchemaControlError)
|
||||
}
|
||||
const fresh = await acquire(connection), observed = await fresh.read()
|
||||
assert.equal(observed.state.epoch, 2); assert.equal(observed.state.phase, "QUARANTINED")
|
||||
assert.equal((await fresh.admission()).ready, false)
|
||||
if (stage === "intent") { assert.equal(observed.state.intent?.id, intent.id); assert.equal((await history(connection, intent.id)).terminal, null) }
|
||||
if (stage === "terminal") { assert.equal(observed.state.intent, null); assert.equal(observed.state.terminal?.intent.id, intent.id); assert.equal(observed.state.terminal?.outcome, "observed-success") }
|
||||
await fresh.close()
|
||||
cases.push("actual server COMMIT with lost " + stage + " ACK destroys owner; restart preserves quarantine and durable observation")
|
||||
}
|
||||
for (const stage of ["claim", "intent", "terminal"] as const) {
|
||||
const connection = await database(); await seed(connection)
|
||||
const wrapped = abortedCommit(connection, stage === "claim" ? 1 : stage === "intent" ? 2 : 3)
|
||||
const intent = command()
|
||||
if (stage === "claim") await assert.rejects(acquire(wrapped), (error: unknown) => error instanceof DatabaseSchemaControlError && error.outcomeUnknown)
|
||||
else {
|
||||
const handle = await acquire(wrapped)
|
||||
if (stage === "terminal") await handle.transition(intent)
|
||||
await assert.rejects(handle.transition(stage === "intent" ? intent : terminal(intent.id)), (error: unknown) => error instanceof DatabaseSchemaControlError && error.outcomeUnknown)
|
||||
await assert.rejects(handle.read(), DatabaseSchemaControlError)
|
||||
}
|
||||
// Readback diagnoses the rolled-back publication without replaying it.
|
||||
const saved = await row(connection)
|
||||
assert.equal(saved.revision, stage === "claim" ? "0" : stage === "intent" ? "1" : "2")
|
||||
assert.equal((await history(connection, intent.id))?.terminal ?? null, null)
|
||||
assert.equal(saved.state.intent?.id ?? null, stage === "terminal" ? intent.id : null)
|
||||
const fresh = await acquire(connection)
|
||||
assert.equal((await fresh.admission()).ready, false)
|
||||
if (stage === "terminal") await assert.rejects(fresh.transition(terminal(intent.id)), DatabaseSchemaControlError)
|
||||
await fresh.close()
|
||||
cases.push("actual aborted " + stage + " transaction returns ROLLBACK command; owner discarded without successful publication or replay")
|
||||
}
|
||||
const altered = await database(); await seed(altered)
|
||||
const stale = await acquire(altered)
|
||||
await altered.query("UPDATE mmapp_control.database_schema_state SET revision=revision+1")
|
||||
await assert.rejects(stale.read(), DatabaseSchemaControlError)
|
||||
cases.push("external revision drift invalidates a live owner before admission")
|
||||
const corrupted = await database(); await seed(corrupted)
|
||||
const corruptOwner = await acquire(corrupted), corruptIntent = command(); await corruptOwner.transition(corruptIntent)
|
||||
await corrupted.query("UPDATE mmapp_control.database_schema_intents SET intent=jsonb_set(intent,'{artifactSha256}',to_jsonb($1::text)) WHERE id=$2", ["d".repeat(64), corruptIntent.id])
|
||||
await assert.rejects(corruptOwner.read(), DatabaseSchemaControlError)
|
||||
await assert.rejects(acquire(corrupted), DatabaseSchemaControlError)
|
||||
cases.push("immutable history mismatch invalidates both current owner and new owner claim")
|
||||
const reset = await database(); await seed(reset)
|
||||
const resetOwner = await acquire(reset), resetIntent = command(); await resetOwner.transition(resetIntent)
|
||||
// This database was created by this test in the owned disposable cluster.
|
||||
await reset.query("CREATE TABLE public.owned_fixture(id integer); DROP SCHEMA public CASCADE; CREATE SCHEMA public")
|
||||
assert.equal((await resetOwner.read()).state.intent?.id, resetIntent.id)
|
||||
assert.equal((await history(reset, resetIntent.id)).terminal, null)
|
||||
cases.push("control epoch and unresolved history survive replacement of the public application namespace")
|
||||
const archiveDb = await database(); await seed(archiveDb)
|
||||
await archiveDb.query("CREATE TABLE public.servers(id integer PRIMARY KEY); CREATE TABLE public.schema_migrations(id text PRIMARY KEY); INSERT INTO public.servers VALUES(1)")
|
||||
const archiveOwner = await acquire(archiveDb), archiveIntent = command(); await archiveOwner.transition(archiveIntent)
|
||||
const originalState = await row(archiveDb), archiveUrl = new URL(databaseUrl); archiveUrl.pathname = "/" + names.at(-1)!
|
||||
const safeFile = resolve(process.env.MCP_STATE_DIR!, "control-excluded.dump"), unsafeFile = resolve(process.env.MCP_STATE_DIR!, "control-full.dump")
|
||||
const actualPrivateDump = await runPrivateDatabaseDump(archiveUrl.toString(), { authorize: async () => {} })
|
||||
await writeFile(safeFile, actualPrivateDump, { flag: "wx", mode: 0o600 })
|
||||
const toc = (await commandIo("pg_restore", ["--list", safeFile])).toString("utf8")
|
||||
assert(!toc.includes("mmapp_control"))
|
||||
await archiveDb.query("UPDATE public.servers SET id=2")
|
||||
let replacements = 0
|
||||
await restorePartitionedDatabase(safeFile, archiveUrl.toString(), {
|
||||
command: commandIo, prepareSql: file => writeFile(file, "", { flag: "wx", mode: 0o600 }), beforeTargetReplacement: () => { replacements++ },
|
||||
})
|
||||
assert.equal(replacements, 1)
|
||||
assert.deepEqual((await archiveDb.query("SELECT id FROM public.servers")).rows, [{ id: 1 }])
|
||||
assert.deepEqual(await row(archiveDb), originalState)
|
||||
assert.equal((await archiveOwner.read()).state.intent?.id, archiveIntent.id)
|
||||
cases.push("actual private pg_dump excludes control; actual application restore changes public data while preserving live owner epoch and intent")
|
||||
await commandIo("pg_dump", ["-Fc", "--file", unsafeFile, archiveUrl.toString()])
|
||||
let archiveCommands = 0, preparations = 0
|
||||
await assert.rejects(restorePartitionedDatabase(unsafeFile, archiveUrl.toString(), {
|
||||
command: async (program, args) => { archiveCommands++; return commandIo(program, args) },
|
||||
prepareSql: async () => { preparations++ }, beforeTargetReplacement: () => { replacements++ },
|
||||
}))
|
||||
assert.equal(archiveCommands, 1); assert.equal(preparations, 0); assert.equal(replacements, 1)
|
||||
assert.deepEqual(await row(archiveDb), originalState)
|
||||
cases.push("actual full archive containing control refuses immediately after TOC before SQL decode or target replacement")
|
||||
console.log("Owned database schema control SQL:", JSON.stringify({ cases: cases.length, names: cases, databases: names.length, actualSql: true, lostActualCommitAcks: 3, startupWired: false, readyAuthorized: false, productionDatabaseTouched: false }))
|
||||
} finally {
|
||||
for (const handle of handles) await handle.close().catch(() => {})
|
||||
for (const connection of connections) await connection.end()
|
||||
await closePool()
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { test } from "node:test"
|
||||
import { acquireDatabaseSchemaControl, DATABASE_SCHEMA_CONTROL_SQL as sql, type DatabaseSchemaControlClient } from "./database-schema-control.js"
|
||||
import { initialDatabaseSchemaControlState, DatabaseSchemaControlError, type DatabaseSchemaControlState } from "./database-schema-control-model.js"
|
||||
|
||||
const id = "00000000-0000-4000-8000-000000000001", otherId = "00000000-0000-4000-8000-000000000002", hash = "a".repeat(64)
|
||||
const command = (intentId = id) => ({ type: "begin" as const, id: intentId, kind: "restore" as const, manifestSha256: hash, artifactSha256: hash })
|
||||
/** Inert protocol emulator: does not claim PostgreSQL durability. Root's
|
||||
* private PG suite must independently test these SQL strings/commit windows. */
|
||||
function fixture() {
|
||||
let durable = { revision: "0", state: initialDatabaseSchemaControlState() }, intents = new Map<string, { intent: unknown; terminal: unknown }>()
|
||||
let lock: number | undefined, sessions = 0
|
||||
const calls: string[] = [], releases: boolean[] = []
|
||||
const faults = { at: "", afterCommit: false, commitTag: "COMMIT" as string | undefined }, empty = { rows: [] as Record<string, unknown>[] }
|
||||
const pool = { connect: async (): Promise<DatabaseSchemaControlClient> => {
|
||||
const session = ++sessions
|
||||
let tx: typeof durable | undefined, history: typeof intents | undefined
|
||||
return {
|
||||
query: async (query, values = []) => {
|
||||
calls.push(query)
|
||||
if (query === faults.at && !faults.afterCommit) throw new Error("injected SQL refusal")
|
||||
if (query === sql.acquire) { if (lock !== undefined) return { rows: [{ acquired: false }] }; lock = session; return { rows: [{ acquired: true }] } }
|
||||
if (query === sql.unlock) { assert.equal(lock, session); lock = undefined; return { rows: [{ unlocked: true }] } }
|
||||
if (query === "BEGIN") { assert(!tx); tx = structuredClone(durable); history = structuredClone(intents); return empty }
|
||||
if (query.startsWith("SET LOCAL ")) { assert(tx); return empty }
|
||||
if (query === sql.read) { assert(tx); return { rows: [structuredClone(tx)] } }
|
||||
if (query === sql.beginIntent) { assert(history); if (history.has(String(values[0]))) throw new Error("duplicate intent"); history.set(String(values[0]), { intent: JSON.parse(String(values[1])), terminal: null }); return { rows: [{ id: values[0] }] } }
|
||||
if (query === sql.terminalIntent) { const row = history?.get(String(values[0])); if (!row || row.terminal) return empty; row.terminal = JSON.parse(String(values[1])); return { rows: [{ id: values[0] }] } }
|
||||
if (query === sql.readIntent) { const row = history?.get(String(values[0])); return { rows: row ? [structuredClone(row)] : [] } }
|
||||
if (query === sql.write) { assert(tx); if (values[1] !== tx.revision) return empty; tx = { revision: String(BigInt(tx.revision) + 1n), state: JSON.parse(String(values[0])) as DatabaseSchemaControlState }; return { rows: [structuredClone(tx)] } }
|
||||
if (query === "COMMIT") { assert(tx && history); durable = tx; intents = history; tx = undefined; history = undefined; if (faults.at === query && faults.afterCommit) throw new Error("lost COMMIT ACK"); return { ...empty, command: faults.commitTag } }
|
||||
throw new Error("Unexpected SQL: " + query)
|
||||
},
|
||||
release: destroy => { releases.push(Boolean(destroy)); tx = undefined; history = undefined; if (lock === session) lock = undefined },
|
||||
}
|
||||
} }
|
||||
return { pool, calls, releases, faults, read: () => structuredClone(durable), intents: () => structuredClone(intents), corrupt: (change: (state: DatabaseSchemaControlState) => void) => change(durable.state) }
|
||||
}
|
||||
test("acquisition commits quarantine before returning, no DDL or application SQL", async () => {
|
||||
const f = fixture(), handle = await acquireDatabaseSchemaControl(f.pool)
|
||||
assert.equal(f.read().state.epoch, 1)
|
||||
assert.equal(f.calls.at(-1), "COMMIT")
|
||||
assert.equal((await handle.admission()).ready, false)
|
||||
assert(f.calls.includes("SET LOCAL synchronous_commit = on"))
|
||||
assert(!f.calls.some(query => /CREATE|DROP|public\./.test(query)))
|
||||
await handle.close(); assert.deepEqual(f.releases, [false])
|
||||
})
|
||||
test("second owner cannot acquire a live session lease", async () => {
|
||||
const f = fixture(), first = await acquireDatabaseSchemaControl(f.pool)
|
||||
await assert.rejects(acquireDatabaseSchemaControl(f.pool), DatabaseSchemaControlError)
|
||||
assert.equal(f.read().state.epoch, 1)
|
||||
await first.close()
|
||||
const second = await acquireDatabaseSchemaControl(f.pool)
|
||||
assert.equal(f.read().state.epoch, 2)
|
||||
await second.close()
|
||||
})
|
||||
test("intent and terminal history are atomically published with control CAS", async () => {
|
||||
const f = fixture(), handle = await acquireDatabaseSchemaControl(f.pool)
|
||||
await handle.transition(command())
|
||||
assert(f.intents().has(id)); assert.equal(f.read().state.phase, "INTENT")
|
||||
await handle.transition({ type: "terminal", id, outcome: "observed-success", observationSha256: hash })
|
||||
assert(f.intents().get(id)?.terminal)
|
||||
assert.equal((await handle.admission()).ready, false)
|
||||
await handle.close()
|
||||
})
|
||||
test("historical id reuse refuses after subsequent operations", async () => {
|
||||
const f = fixture(), handle = await acquireDatabaseSchemaControl(f.pool)
|
||||
for (const current of [id, otherId]) {
|
||||
await handle.transition(command(current))
|
||||
await handle.transition({ type: "terminal", id: current, outcome: "observed-success", observationSha256: hash })
|
||||
}
|
||||
await assert.rejects(handle.transition(command()))
|
||||
assert.equal(f.read().state.terminal?.intent.id, otherId)
|
||||
assert.equal(f.releases.at(-1), true)
|
||||
})
|
||||
test("failed CAS rolls back intent publication and discards owner", async () => {
|
||||
const f = fixture(), handle = await acquireDatabaseSchemaControl(f.pool)
|
||||
f.faults.at = sql.write
|
||||
await assert.rejects(handle.transition(command()))
|
||||
assert.equal(f.intents().size, 0); assert.equal(f.read().state.intent, null)
|
||||
await assert.rejects(handle.read())
|
||||
assert.equal(f.releases.at(-1), true)
|
||||
})
|
||||
test("lost intent COMMIT ACK persists unknown intent but poisons old handle", async () => {
|
||||
const f = fixture(), handle = await acquireDatabaseSchemaControl(f.pool)
|
||||
f.faults.at = "COMMIT"; f.faults.afterCommit = true
|
||||
await assert.rejects(handle.transition(command()), error => error instanceof DatabaseSchemaControlError && error.outcomeUnknown)
|
||||
assert.equal(f.read().state.intent?.id, id)
|
||||
await assert.rejects(handle.admission())
|
||||
f.faults.at = ""
|
||||
const restarted = await acquireDatabaseSchemaControl(f.pool)
|
||||
assert.equal(f.read().state.epoch, 2)
|
||||
assert.equal((await restarted.admission()).reason, "UNRESOLVED_INTENT")
|
||||
await assert.rejects(restarted.transition({ type: "terminal", id, outcome: "observed-success", observationSha256: hash }))
|
||||
})
|
||||
test("lost terminal ACK never produces READY after fresh ownership", async () => {
|
||||
const f = fixture(), handle = await acquireDatabaseSchemaControl(f.pool)
|
||||
await handle.transition(command())
|
||||
f.faults.at = "COMMIT"; f.faults.afterCommit = true
|
||||
await assert.rejects(handle.transition({ type: "terminal", id, outcome: "observed-success", observationSha256: hash }))
|
||||
f.faults.at = ""
|
||||
const restarted = await acquireDatabaseSchemaControl(f.pool)
|
||||
assert.equal(f.read().state.terminal?.outcome, "observed-success")
|
||||
assert.equal((await restarted.admission()).ready, false)
|
||||
await restarted.close()
|
||||
})
|
||||
test("restarted owner may durably record interrupted-unknown but stays quarantined", async () => {
|
||||
const f = fixture(), first = await acquireDatabaseSchemaControl(f.pool)
|
||||
await first.transition(command()); await first.close()
|
||||
const next = await acquireDatabaseSchemaControl(f.pool)
|
||||
await next.transition({ type: "terminal", id, outcome: "interrupted-unknown", observationSha256: hash })
|
||||
assert.equal(f.read().state.intent, null)
|
||||
assert.equal((await next.admission()).ready, false)
|
||||
await next.close()
|
||||
})
|
||||
test("concurrent use of one handle refuses without duplicate publication", async () => {
|
||||
const f = fixture(), handle = await acquireDatabaseSchemaControl(f.pool)
|
||||
const first = handle.transition(command())
|
||||
await assert.rejects(handle.transition(command(otherId)))
|
||||
await first
|
||||
assert.equal(f.intents().size, 1)
|
||||
await handle.close(); await handle.close()
|
||||
await assert.rejects(handle.read())
|
||||
})
|
||||
test("caller mutation after transition cannot change durable command or history", async () => {
|
||||
const f = fixture(), handle = await acquireDatabaseSchemaControl(f.pool), mutable = command()
|
||||
const pending = handle.transition(mutable)
|
||||
mutable.id = otherId
|
||||
await pending
|
||||
assert.equal(f.read().state.intent?.id, id)
|
||||
assert(f.intents().has(id)); assert(!f.intents().has(otherId))
|
||||
await handle.close()
|
||||
})
|
||||
test("unknown command policy bytes refuse before SQL and leave owner usable", async () => {
|
||||
const f = fixture(), handle = await acquireDatabaseSchemaControl(f.pool), before = f.calls.length
|
||||
await assert.rejects(handle.transition({ ...command(), policy: { ready: true } } as ReturnType<typeof command>))
|
||||
assert.equal(f.calls.length, before)
|
||||
assert.equal((await handle.admission()).ready, false)
|
||||
await handle.close()
|
||||
})
|
||||
test("tampered state cannot disagree with independently stored intent history", async () => {
|
||||
const f = fixture(), handle = await acquireDatabaseSchemaControl(f.pool)
|
||||
await handle.transition(command())
|
||||
f.corrupt(state => { state.intent!.artifactSha256 = "b".repeat(64) })
|
||||
await assert.rejects(handle.read())
|
||||
assert.equal(f.releases.at(-1), true)
|
||||
await assert.rejects(acquireDatabaseSchemaControl(f.pool))
|
||||
assert.equal(f.read().state.epoch, 1)
|
||||
})
|
||||
test("accessor commands refuse without invocation or database IO", async () => {
|
||||
const f = fixture(), handle = await acquireDatabaseSchemaControl(f.pool), before = f.calls.length
|
||||
let invoked = false
|
||||
const accessor = command()
|
||||
Object.defineProperty(accessor, "id", { enumerable: true, get: () => { invoked = true; return id } })
|
||||
await assert.rejects(handle.transition(accessor))
|
||||
assert.equal(invoked, false); assert.equal(f.calls.length, before)
|
||||
await handle.close()
|
||||
})
|
||||
|
||||
for (const tag of ["ROLLBACK", "", undefined]) {
|
||||
test(`unconfirmed COMMIT command ${String(tag)} destroys owner and never returns a grant`, async () => {
|
||||
const f = fixture(), handle = await acquireDatabaseSchemaControl(f.pool)
|
||||
f.faults.commitTag = tag
|
||||
await assert.rejects(handle.transition(command()), error => error instanceof DatabaseSchemaControlError && error.outcomeUnknown)
|
||||
assert.equal(f.releases.at(-1), true)
|
||||
await assert.rejects(handle.read(), DatabaseSchemaControlError)
|
||||
// Even if the server committed, no readback replays or upgrades the ACK.
|
||||
assert.equal(f.calls.filter(query => query === sql.beginIntent).length, 1)
|
||||
f.faults.commitTag = "COMMIT"
|
||||
const restarted = await acquireDatabaseSchemaControl(f.pool)
|
||||
assert.equal((await restarted.admission()).ready, false)
|
||||
await restarted.close()
|
||||
})
|
||||
test(`claim COMMIT command ${String(tag)} refuses before a usable owner exists`, async () => {
|
||||
const f = fixture()
|
||||
f.faults.commitTag = tag
|
||||
await assert.rejects(acquireDatabaseSchemaControl(f.pool), error => error instanceof DatabaseSchemaControlError && error.outcomeUnknown)
|
||||
assert.deepEqual(f.releases, [true])
|
||||
assert.equal(f.calls.filter(query => query === sql.beginIntent).length, 0)
|
||||
})
|
||||
}
|
||||
|
||||
for (const tag of ["ROLLBACK", "", undefined]) {
|
||||
test(`terminal COMMIT command ${String(tag)} cannot publish an acknowledged outcome`, async () => {
|
||||
const f = fixture(), handle = await acquireDatabaseSchemaControl(f.pool)
|
||||
await handle.transition(command())
|
||||
f.faults.commitTag = tag
|
||||
await assert.rejects(handle.transition({ type: "terminal", id, outcome: "observed-success", observationSha256: hash }), error => error instanceof DatabaseSchemaControlError && error.outcomeUnknown)
|
||||
assert.equal(f.releases.at(-1), true)
|
||||
await assert.rejects(handle.admission(), DatabaseSchemaControlError)
|
||||
assert.equal(f.calls.filter(query => query === sql.terminalIntent).length, 1)
|
||||
f.faults.commitTag = "COMMIT"
|
||||
const restarted = await acquireDatabaseSchemaControl(f.pool)
|
||||
assert.equal((await restarted.admission()).ready, false)
|
||||
await restarted.close()
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { randomUUID } from "node:crypto"
|
||||
import { checkDatabaseSchemaControlCommand, checkDatabaseSchemaControlState, claimDatabaseSchemaOwnership, databaseSchemaAdmission, DatabaseSchemaControlError, transitionDatabaseSchemaControl, type DatabaseSchemaControlCommand, type DatabaseSchemaControlState } from "./database-schema-control-model.js"
|
||||
|
||||
/** Externally provisioned only. Do not include this namespace in application
|
||||
* dumps/restores, public schema reset, or application schema fingerprints.
|
||||
* Restoring the control namespace would roll back fencing epochs. */
|
||||
export const DATABASE_SCHEMA_CONTROL_NAMESPACE = "mmapp_control"
|
||||
export const DATABASE_SCHEMA_CONTROL_LOCK = 8723103
|
||||
export const DATABASE_SCHEMA_CONTROL_SQL = Object.freeze({
|
||||
acquire: "SELECT pg_catalog.pg_try_advisory_lock($1::bigint) AS acquired",
|
||||
read: "SELECT revision::text AS revision, state FROM mmapp_control.database_schema_state WHERE id = 1 FOR UPDATE",
|
||||
write: "UPDATE mmapp_control.database_schema_state SET revision = revision + 1, state = $1::jsonb WHERE id = 1 AND revision = $2::bigint RETURNING revision::text AS revision, state",
|
||||
beginIntent: "INSERT INTO mmapp_control.database_schema_intents (id, intent, terminal) VALUES ($1::uuid, $2::jsonb, NULL) RETURNING id::text AS id",
|
||||
terminalIntent: "UPDATE mmapp_control.database_schema_intents SET terminal = $2::jsonb WHERE id = $1::uuid AND terminal IS NULL RETURNING id::text AS id",
|
||||
readIntent: "SELECT intent, terminal FROM mmapp_control.database_schema_intents WHERE id = $1::uuid FOR SHARE",
|
||||
unlock: "SELECT pg_catalog.pg_advisory_unlock($1::bigint) AS unlocked",
|
||||
})
|
||||
export interface DatabaseSchemaControlClient {
|
||||
query(sql: string, values?: unknown[]): Promise<{ rows: Record<string, unknown>[]; command?: string }>
|
||||
release(destroy?: boolean): void
|
||||
}
|
||||
export interface DatabaseSchemaControlPool { connect(): Promise<DatabaseSchemaControlClient> }
|
||||
export interface DatabaseSchemaControlSnapshot { revision: string; state: DatabaseSchemaControlState }
|
||||
const canonical = (value: unknown): string => JSON.stringify(value, (_key, item: unknown) => item && typeof item === "object" && !Array.isArray(item) ? Object.fromEntries(Object.entries(item).sort(([a], [b]) => a.localeCompare(b))) : item)
|
||||
function snapshot(rows: Record<string, unknown>[]): DatabaseSchemaControlSnapshot {
|
||||
if (rows.length !== 1 || Object.keys(rows[0]).sort().join(" ") !== "revision state" || typeof rows[0].revision !== "string" || !/^(0|[1-9][0-9]{0,18})$/.test(rows[0].revision) || BigInt(rows[0].revision) >= 9223372036854775807n) throw new DatabaseSchemaControlError("Missing/invalid externally provisioned control singleton")
|
||||
return { revision: rows[0].revision, state: checkDatabaseSchemaControlState(rows[0].state) }
|
||||
}
|
||||
/** Owns a checked-out SESSION for its full lifetime. No pool.query, retries,
|
||||
* background process, file state, DDL, automatic enrollment, or SQL dispatch.
|
||||
* A successful returned transition means this client observed COMMIT success;
|
||||
* any uncertain SQL/COMMIT destroys the handle and its session lock. */
|
||||
export async function acquireDatabaseSchemaControl(pool: DatabaseSchemaControlPool) {
|
||||
const client = await pool.connect(), owner = randomUUID()
|
||||
let closed = false, busy = false, revision: string | undefined
|
||||
const destroy = () => { if (!closed) { closed = true; client.release(true) } }
|
||||
async function checkHistory(state: DatabaseSchemaControlState) {
|
||||
const entries = [state.intent && { intent: state.intent, terminal: null }, state.terminal && { intent: state.terminal.intent, terminal: state.terminal }].filter(item => item !== null)
|
||||
for (const expected of entries) {
|
||||
const rows = (await client.query(DATABASE_SCHEMA_CONTROL_SQL.readIntent, [expected.intent.id])).rows
|
||||
if (rows.length !== 1 || Object.keys(rows[0]).sort().join(" ") !== "intent terminal" || canonical(rows[0]) !== canonical(expected)) throw new DatabaseSchemaControlError("Control state disagrees with durable intent history")
|
||||
}
|
||||
}
|
||||
async function transaction(change?: (state: DatabaseSchemaControlState) => DatabaseSchemaControlState, command?: DatabaseSchemaControlCommand) {
|
||||
if (closed || busy) throw new DatabaseSchemaControlError("Closed or concurrent control owner handle")
|
||||
busy = true; let commitSent = false
|
||||
try {
|
||||
await client.query("BEGIN")
|
||||
await client.query("SET LOCAL search_path = pg_catalog")
|
||||
await client.query("SET LOCAL lock_timeout = '5s'")
|
||||
await client.query("SET LOCAL statement_timeout = '10s'")
|
||||
await client.query("SET LOCAL synchronous_commit = on")
|
||||
let current = snapshot((await client.query(DATABASE_SCHEMA_CONTROL_SQL.read)).rows)
|
||||
await checkHistory(current.state)
|
||||
if (revision !== undefined && (current.revision !== revision || current.state.owner !== owner)) throw new DatabaseSchemaControlError("Control ownership/revision changed")
|
||||
if (change) {
|
||||
const next = checkDatabaseSchemaControlState(change(current.state))
|
||||
if (command) {
|
||||
const result = command.type === "begin"
|
||||
? await client.query(DATABASE_SCHEMA_CONTROL_SQL.beginIntent, [command.id, JSON.stringify(next.intent)])
|
||||
: await client.query(DATABASE_SCHEMA_CONTROL_SQL.terminalIntent, [command.id, JSON.stringify(next.terminal)])
|
||||
if (result.rows.length !== 1 || result.rows[0].id !== command.id) throw new DatabaseSchemaControlError("Intent history publication refused")
|
||||
}
|
||||
const updated = snapshot((await client.query(DATABASE_SCHEMA_CONTROL_SQL.write, [JSON.stringify(next), current.revision])).rows)
|
||||
if (BigInt(updated.revision) !== BigInt(current.revision) + 1n || canonical(updated.state) !== canonical(next)) throw new DatabaseSchemaControlError("Control CAS publication mismatch")
|
||||
current = updated
|
||||
await checkHistory(current.state)
|
||||
}
|
||||
commitSent = true
|
||||
const committed = await client.query("COMMIT")
|
||||
if (committed.command !== "COMMIT") throw new DatabaseSchemaControlError("Control COMMIT acknowledgement unavailable")
|
||||
revision = current.revision
|
||||
return structuredClone(current)
|
||||
} catch (cause) {
|
||||
// Discard the checked-out backend: COMMIT may have completed, and a
|
||||
// ROLLBACK/retry cannot turn that uncertainty into a valid receipt.
|
||||
destroy()
|
||||
throw new DatabaseSchemaControlError("Control transaction unconfirmed; owner handle discarded", commitSent, { cause })
|
||||
} finally { busy = false }
|
||||
}
|
||||
try {
|
||||
const lock = await client.query(DATABASE_SCHEMA_CONTROL_SQL.acquire, [DATABASE_SCHEMA_CONTROL_LOCK])
|
||||
if (lock.rows.length !== 1 || lock.rows[0].acquired !== true) throw new DatabaseSchemaControlError("Another database schema controller owns the session lease")
|
||||
await transaction(state => claimDatabaseSchemaOwnership(state, owner))
|
||||
} catch (cause) { destroy(); throw cause }
|
||||
return Object.freeze({
|
||||
/** Durable intent must resolve successfully BEFORE any application write.
|
||||
* Consumers must be drained separately; this lease does not fence legacy
|
||||
* writers that have not adopted this control protocol. */
|
||||
transition: async (rawCommand: DatabaseSchemaControlCommand) => {
|
||||
// Copy and validate synchronously before transaction() reaches BEGIN.
|
||||
const command = checkDatabaseSchemaControlCommand(rawCommand)
|
||||
return transaction(state => transitionDatabaseSchemaControl(state, owner, command), command)
|
||||
},
|
||||
read: () => transaction(),
|
||||
admission: async () => databaseSchemaAdmission((await transaction()).state),
|
||||
close: async () => {
|
||||
if (closed) return
|
||||
if (busy) throw new DatabaseSchemaControlError("Control transaction still active")
|
||||
busy = true
|
||||
try {
|
||||
const result = await client.query(DATABASE_SCHEMA_CONTROL_SQL.unlock, [DATABASE_SCHEMA_CONTROL_LOCK])
|
||||
if (result.rows.length !== 1 || result.rows[0].unlocked !== true) throw new DatabaseSchemaControlError("Control lease release unconfirmed")
|
||||
closed = true; client.release(false)
|
||||
} catch (cause) { destroy(); throw cause } finally { busy = false }
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { test } from "node:test"
|
||||
import { generateKeyPairSync, randomUUID, sign } from "node:crypto"
|
||||
import { mkdtemp, readFile, writeFile, rm, readdir } from "node:fs/promises"
|
||||
import { tmpdir } from "node:os"
|
||||
import path from "node:path"
|
||||
import { captureDatabaseSourceObservation } from "./database-source-observation.js"
|
||||
import type { ProbeStorageSql } from "../services/server-probe-journal-maintenance.js"
|
||||
import { createDatabaseSourceBindingVerifier, readDatabaseSourceBindingRegistry, sourceBindingBytes, sourceBindingCanonical, sourceBindingSha256,
|
||||
DATABASE_SOURCE_SIGNATURE_DOMAINS, DatabaseSourceBindingError } from "./database-source-binding.js"
|
||||
import { provisionDatabaseSourceBindingOffline, databaseSourceBindingOperatorCli } from "../../scripts/database-source-binding-operator.js"
|
||||
|
||||
assert.equal(process.env.MMAPP_OWN_SOURCE_BINDING_SYNTHETIC, "true")
|
||||
assert.equal(process.env.DATABASE_URL, undefined)
|
||||
const options = () => ({ deadlineAt: Date.now() + 10000 })
|
||||
const row = () => ({ system_identifier: "18446744073709551615", database_oid: "16384", database_name: "synthetic",
|
||||
session_user: "source_operator", current_user: "source_operator", in_recovery: false, server_version_num: "180006", backend_pid: 123,
|
||||
transaction_timestamp: "2026-10-31 12:34:56.123456+00", snapshot: "100:110:101,105,109",
|
||||
isolation: "repeatable read", read_only: "on", explicit_transaction: true })
|
||||
function facade(change?: (count: number, value: ReturnType<typeof row>) => Promise<void>) {
|
||||
let count = 0
|
||||
const sql: ProbeStorageSql = { async query() { const value = row(); await change?.(++count, value); return { rows: [value] } } }
|
||||
return sql
|
||||
}
|
||||
const refused = (error: unknown) => error instanceof DatabaseSourceBindingError
|
||||
async function own<T>(work: (root: string) => Promise<T>) {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "mm-source-binding-synthetic-"))
|
||||
try { return await work(root) }
|
||||
finally {
|
||||
const relative = path.relative(path.resolve(tmpdir()), path.resolve(root))
|
||||
assert(relative && !relative.startsWith("..") && !path.isAbsolute(relative) && path.basename(root).startsWith("mm-source-binding-synthetic-"))
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
async function fixture(root: string) {
|
||||
const key = generateKeyPairSync("ed25519"), privateKeyFile = path.join(root, "synthetic-private.pem")
|
||||
await writeFile(privateKeyFile, key.privateKey.export({ type: "pkcs8", format: "pem" }), { flag: "wx" })
|
||||
const sql = facade(), observation = await captureDatabaseSourceObservation(sql, options())
|
||||
const request = { installationId: randomUUID(), sourceIdentityId: randomUUID(), generation: randomUUID(), source: { ...observation.source, inRecovery: false as const } }
|
||||
const issued = await provisionDatabaseSourceBindingOffline({ request, privateKeyFile, outputDirectory: path.join(root, "registry-one") })
|
||||
const pin = issued.deploymentPin, verifier = createDatabaseSourceBindingVerifier(pin)
|
||||
const headPath = path.join(pin.registryDirectory, "head-" + pin.currentHeadSha256 + ".json")
|
||||
const head = JSON.parse(await readFile(headPath, "utf8"))
|
||||
const bindingPath = path.join(pin.registryDirectory, "binding-" + head.body.bindingSha256 + ".json")
|
||||
return { key, privateKeyFile, sql, observation, request, pin, verifier, headPath, bindingPath }
|
||||
}
|
||||
|
||||
test("real offline Ed25519 issuance + bounded registry files + genuine same-facade observation verify without READY/generation claims", () => own(async root => {
|
||||
const f = await fixture(root), result = await f.verifier.verify(f.sql, f.observation, options())
|
||||
assert.equal(result.operatorSignatureVerified, true); assert.equal(result.currentHeadPinVerified, true); assert.equal(result.genuineSourceFactsMatched, true)
|
||||
assert.equal(result.platformProtectionVerified, false); assert.equal(result.generationAuthenticated, false); assert.equal(result.allWriterDrainAuthenticated, false); assert.equal(result.readyAuthorized, false)
|
||||
assert.equal(result.ordinal, 1)
|
||||
for (const name of await readdir(f.pin.registryDirectory)) assert(!(await readFile(path.join(f.pin.registryDirectory, name), "utf8")).includes("PRIVATE KEY"))
|
||||
}))
|
||||
test("runtime registry read never enrolls absent trusted state or creates any files", () => own(async root => {
|
||||
const f = await fixture(root), missing = path.join(root, "not-provisioned")
|
||||
await assert.rejects(readDatabaseSourceBindingRegistry({ ...f.pin, registryDirectory: missing }), refused)
|
||||
await assert.rejects(readdir(missing)); assert.deepEqual((await readdir(root)).sort(), ["registry-one", "synthetic-private.pem"])
|
||||
}))
|
||||
test("runtime-supplied envelope/unknown trust configuration fields refuse", () => own(async root => {
|
||||
const f = await fixture(root)
|
||||
assert.throws(() => createDatabaseSourceBindingVerifier({ ...f.pin, body: f.request } as typeof f.pin))
|
||||
await assert.rejects(readDatabaseSourceBindingRegistry(f.request as unknown as typeof f.pin), refused)
|
||||
}))
|
||||
test("JSON/copies and other SQL facade cannot forge the existing private source observation capability", () => own(async root => {
|
||||
const f = await fixture(root)
|
||||
for (const observation of [structuredClone(f.observation), JSON.parse(JSON.stringify(f.observation)), { ...f.observation }]) await assert.rejects(f.verifier.verify(f.sql, observation, options()), refused)
|
||||
await assert.rejects(f.verifier.verify(facade(), f.observation, options()), refused)
|
||||
}))
|
||||
test("logical clone/dump source with different database OID or cluster identifier refuses even with valid signed old binding", () => own(async root => {
|
||||
const f = await fixture(root)
|
||||
for (const changes of [{ database_oid: "16385" }, { system_identifier: "18446744073709551614" }, { database_name: "dump_clone" }]) {
|
||||
const sql = facade(async (_count, value) => { Object.assign(value, changes) }), observation = await captureDatabaseSourceObservation(sql, options())
|
||||
await assert.rejects(f.verifier.verify(sql, observation, options()), refused)
|
||||
}
|
||||
}))
|
||||
test("genuine same-database observation with role/version drift refuses signed source comparison", () => own(async root => {
|
||||
const f = await fixture(root)
|
||||
for (const changes of [{ current_user: "other_operator" }, { server_version_num: "180007" }]) {
|
||||
const sql = facade(async (_count, value) => { Object.assign(value, changes) }), observation = await captureDatabaseSourceObservation(sql, options())
|
||||
await assert.rejects(f.verifier.verify(sql, observation, options()), refused)
|
||||
}
|
||||
}))
|
||||
test("attacker-generated self-signed registry cannot replace independently pinned public key", () => own(async root => {
|
||||
const f = await fixture(root), attacker = generateKeyPairSync("ed25519")
|
||||
await writeFile(path.join(f.pin.registryDirectory, "operator-public.pem"), attacker.publicKey.export({ type: "spki", format: "pem" }))
|
||||
await assert.rejects(f.verifier.verify(f.sql, f.observation, options()), refused)
|
||||
}))
|
||||
test("missing current head cannot fall back to older filenames or unsigned caller JSON", () => own(async root => {
|
||||
const f = await fixture(root)
|
||||
await rm(f.headPath)
|
||||
await writeFile(path.join(f.pin.registryDirectory, "current.json"), JSON.stringify(f.request))
|
||||
await assert.rejects(f.verifier.verify(f.sql, f.observation, options()), refused)
|
||||
}))
|
||||
test("current head hash and strict canonical bytes reject tamper, duplicate keys and oversized files", () => own(async root => {
|
||||
const f = await fixture(root), original = await readFile(f.headPath)
|
||||
for (const bytes of [Buffer.from("{}\n"), Buffer.from('{"body":{},"body":{},"signature":"x"}\n'), Buffer.alloc(8193, 32), Buffer.concat([original, Buffer.from(" ")])]) {
|
||||
await writeFile(f.headPath, bytes); await assert.rejects(f.verifier.verify(f.sql, f.observation, options()), refused)
|
||||
}
|
||||
}))
|
||||
test("binding tamper refused before any generation/evidence result", () => own(async root => {
|
||||
const f = await fixture(root), value = JSON.parse(await readFile(f.bindingPath, "utf8"))
|
||||
value.body.generation = randomUUID(); await writeFile(f.bindingPath, sourceBindingBytes(value))
|
||||
await assert.rejects(f.verifier.verify(f.sql, f.observation, options()), refused)
|
||||
}))
|
||||
test("valid signature under the wrong domain refuses despite a matching externally pinned head digest", () => own(async root => {
|
||||
const f = await fixture(root), value = JSON.parse(await readFile(f.headPath, "utf8"))
|
||||
value.signature = sign(null, Buffer.from(DATABASE_SOURCE_SIGNATURE_DOMAINS.binding + sourceBindingCanonical(value.body)), f.key.privateKey).toString("base64url")
|
||||
const bytes = sourceBindingBytes(value), digest = sourceBindingSha256(bytes)
|
||||
await writeFile(path.join(f.pin.registryDirectory, "head-" + digest + ".json"), bytes)
|
||||
await assert.rejects(readDatabaseSourceBindingRegistry({ ...f.pin, currentHeadSha256: digest }), refused)
|
||||
}))
|
||||
test("binding signature itself is verified even when a genuine signed head authorizes its exact digest", () => own(async root => {
|
||||
const f = await fixture(root), signedBinding = JSON.parse(await readFile(f.bindingPath, "utf8")), head = JSON.parse(await readFile(f.headPath, "utf8"))
|
||||
const attacker = generateKeyPairSync("ed25519")
|
||||
signedBinding.signature = sign(null, Buffer.from(DATABASE_SOURCE_SIGNATURE_DOMAINS.binding + sourceBindingCanonical(signedBinding.body)), attacker.privateKey).toString("base64url")
|
||||
const bindingBytes = sourceBindingBytes(signedBinding), bindingHash = sourceBindingSha256(bindingBytes)
|
||||
head.body.bindingSha256 = bindingHash
|
||||
head.signature = sign(null, Buffer.from(DATABASE_SOURCE_SIGNATURE_DOMAINS.head + sourceBindingCanonical(head.body)), f.key.privateKey).toString("base64url")
|
||||
const headBytes = sourceBindingBytes(head), headHash = sourceBindingSha256(headBytes)
|
||||
await writeFile(path.join(f.pin.registryDirectory, "binding-" + bindingHash + ".json"), bindingBytes)
|
||||
await writeFile(path.join(f.pin.registryDirectory, "head-" + headHash + ".json"), headBytes)
|
||||
await assert.rejects(readDatabaseSourceBindingRegistry({ ...f.pin, currentHeadSha256: headHash }), refused)
|
||||
}))
|
||||
test("even valid operator signatures cannot authorize unknown binding fields or caller READY claims", () => own(async root => {
|
||||
const f = await fixture(root), signedBinding = JSON.parse(await readFile(f.bindingPath, "utf8")), head = JSON.parse(await readFile(f.headPath, "utf8"))
|
||||
signedBinding.body.readyAuthorized = true
|
||||
signedBinding.signature = sign(null, Buffer.from(DATABASE_SOURCE_SIGNATURE_DOMAINS.binding + sourceBindingCanonical(signedBinding.body)), f.key.privateKey).toString("base64url")
|
||||
const bindingBytes = sourceBindingBytes(signedBinding), bindingHash = sourceBindingSha256(bindingBytes)
|
||||
head.body.bindingSha256 = bindingHash
|
||||
head.signature = sign(null, Buffer.from(DATABASE_SOURCE_SIGNATURE_DOMAINS.head + sourceBindingCanonical(head.body)), f.key.privateKey).toString("base64url")
|
||||
const headBytes = sourceBindingBytes(head), headHash = sourceBindingSha256(headBytes)
|
||||
await writeFile(path.join(f.pin.registryDirectory, "binding-" + bindingHash + ".json"), bindingBytes)
|
||||
await writeFile(path.join(f.pin.registryDirectory, "head-" + headHash + ".json"), headBytes)
|
||||
await assert.rejects(readDatabaseSourceBindingRegistry({ ...f.pin, currentHeadSha256: headHash }), refused)
|
||||
}))
|
||||
test("operator explicit next generation increments ordinal, binds previous pinned head and refuses same generation/reuse", () => own(async root => {
|
||||
const f = await fixture(root)
|
||||
const next = await provisionDatabaseSourceBindingOffline({ request: { ...f.request, generation: randomUUID() }, privateKeyFile: f.privateKeyFile,
|
||||
outputDirectory: path.join(root, "registry-two"), previousPin: f.pin })
|
||||
const verified = await readDatabaseSourceBindingRegistry(next.deploymentPin)
|
||||
assert.equal(verified.body.ordinal, 2); assert.equal(verified.head.previousHeadSha256, f.pin.currentHeadSha256)
|
||||
await assert.rejects(provisionDatabaseSourceBindingOffline({ request: f.request, privateKeyFile: f.privateKeyFile, outputDirectory: path.join(root, "bad"), previousPin: f.pin }))
|
||||
await assert.rejects(provisionDatabaseSourceBindingOffline({ request: f.request, privateKeyFile: f.privateKeyFile, outputDirectory: f.pin.registryDirectory }))
|
||||
}))
|
||||
test("old genuine operator-signed head or binding substituted into current registry refuses rollback under the current independent pin", () => own(async root => {
|
||||
const f = await fixture(root), next = await provisionDatabaseSourceBindingOffline({ request: { ...f.request, generation: randomUUID() }, privateKeyFile: f.privateKeyFile,
|
||||
outputDirectory: path.join(root, "registry-two"), previousPin: f.pin })
|
||||
const oldHead = await readFile(f.headPath), newHead = path.join(next.deploymentPin.registryDirectory, "head-" + next.deploymentPin.currentHeadSha256 + ".json")
|
||||
await writeFile(newHead, oldHead)
|
||||
await assert.rejects(readDatabaseSourceBindingRegistry(next.deploymentPin), refused)
|
||||
}))
|
||||
test("registry replacement during genuine source recheck is refused by second fixed artifact read", () => own(async root => {
|
||||
const f = await fixture(root), sql = facade(async count => { if (count === 4) await writeFile(f.headPath, "{}\n") })
|
||||
const observation = await captureDatabaseSourceObservation(sql, options())
|
||||
await assert.rejects(f.verifier.verify(sql, observation, options()), refused)
|
||||
}))
|
||||
test("abort/expired request refuses without reading any SQL facade", () => own(async root => {
|
||||
const f = await fixture(root), controller = new AbortController(); controller.abort()
|
||||
await assert.rejects(f.verifier.verify(f.sql, f.observation, { deadlineAt: Date.now() - 1 }), refused)
|
||||
await assert.rejects(f.verifier.verify(f.sql, f.observation, { ...options(), signal: controller.signal }), refused)
|
||||
}))
|
||||
test("offline CLI uses explicit bounded files, rejects unknown/duplicate args and never auto-installs deployment config", () => own(async root => {
|
||||
const f = await fixture(root), requestPath = path.join(root, "request.json")
|
||||
await writeFile(requestPath, JSON.stringify({ ...f.request, generation: randomUUID() }))
|
||||
const previous = path.join(root, "previous.json"); await writeFile(previous, JSON.stringify(f.pin))
|
||||
const issued = await databaseSourceBindingOperatorCli(["--request=" + requestPath, "--private-key=" + f.privateKeyFile, "--output=" + path.join(root, "cli-output"), "--previous-pin=" + previous])
|
||||
assert.equal((await readDatabaseSourceBindingRegistry(issued.deploymentPin)).body.ordinal, 2)
|
||||
await assert.rejects(databaseSourceBindingOperatorCli(["--runtime-self-enroll=true"]))
|
||||
await assert.rejects(databaseSourceBindingOperatorCli(["--output=x", "--output=y"]))
|
||||
assert.equal(issued.keyIsolationVerified, false); assert.equal(issued.readyAuthorized, false)
|
||||
}))
|
||||
test("private key material is never accepted as the runtime public artifact", () => own(async root => {
|
||||
const f = await fixture(root); await writeFile(path.join(f.pin.registryDirectory, "operator-public.pem"), await readFile(f.privateKeyFile))
|
||||
await assert.rejects(f.verifier.verify(f.sql, f.observation, options()), refused)
|
||||
}))
|
||||
test("honest indistinguishability boundary: same metadata after same-database restore is signature evidence only, never generation authentication", () => own(async root => {
|
||||
const f = await fixture(root), result = await f.verifier.verify(f.sql, f.observation, options())
|
||||
assert.equal(result.generationAuthenticated, false); assert.equal(result.platformProtectionVerified, false); assert.equal(result.readyAuthorized, false)
|
||||
}))
|
||||
@@ -0,0 +1,112 @@
|
||||
import { createHash, createPublicKey, verify } from "node:crypto"
|
||||
import { constants } from "node:fs"
|
||||
import { lstat, open, realpath } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { z } from "zod"
|
||||
import { assertDatabaseSourceObservationCurrent, type DatabaseSourceObservation, type DatabaseSourceObservationOptions } from "./database-source-observation.js"
|
||||
import type { ProbeStorageSql } from "../services/server-probe-journal-maintenance.js"
|
||||
|
||||
// Public verification only. No private key, signing, enrollment, SQL mutation,
|
||||
// configured pool, provisioning or automatic current-head advancement here.
|
||||
export class DatabaseSourceBindingError extends Error { readonly code = "DATABASE_SOURCE_BINDING_REFUSED" }
|
||||
const fail = (): never => { throw new DatabaseSourceBindingError("Доверенная подпись и текущая привязка источника БД не подтверждены") }
|
||||
const sha = z.string().regex(/^[a-f0-9]{64}$/)
|
||||
const uuid = z.string().uuid()
|
||||
const text = z.string().min(1).max(200).regex(/^[^\x00-\x1f\x7f]+$/)
|
||||
const source = z.object({ systemIdentifier: z.string().regex(/^[1-9][0-9]{0,19}$/).refine(value => BigInt(value) <= 18446744073709551615n),
|
||||
databaseOid: z.number().int().positive().max(4294967295), databaseName: text, sessionUser: text, currentUser: text,
|
||||
inRecovery: z.literal(false), serverVersionNum: z.number().int().min(180000).max(189999) }).strict()
|
||||
export const sourceBindingBodySchema = z.object({ format: z.literal("database-source-binding-v1"), installationId: uuid,
|
||||
sourceIdentityId: uuid, generation: uuid, ordinal: z.number().int().positive().max(Number.MAX_SAFE_INTEGER), source }).strict()
|
||||
export const sourceRegistryHeadSchema = z.object({ format: z.literal("database-source-registry-head-v1"), installationId: uuid,
|
||||
sourceIdentityId: uuid, generation: uuid, ordinal: z.number().int().positive().max(Number.MAX_SAFE_INTEGER), bindingSha256: sha,
|
||||
previousHeadSha256: sha.nullable() }).strict().refine(value => (value.ordinal === 1) === (value.previousHeadSha256 === null))
|
||||
const deployment = z.object({ registryDirectory: z.string().min(1).max(4096), installationId: uuid, publicKeySpkiSha256: sha, currentHeadSha256: sha }).strict()
|
||||
const envelope = z.object({ body: z.unknown(), signature: z.string().regex(/^[A-Za-z0-9_-]{86}$/) }).strict()
|
||||
export type DatabaseSourceBindingBody = z.infer<typeof sourceBindingBodySchema>
|
||||
export type DatabaseSourceRegistryHead = z.infer<typeof sourceRegistryHeadSchema>
|
||||
export type DatabaseSourceDeploymentPin = z.infer<typeof deployment>
|
||||
export const DATABASE_SOURCE_SIGNATURE_DOMAINS = Object.freeze({ binding: "mmapp:database-source-binding:ed25519:v1\n", head: "mmapp:database-source-head:ed25519:v1\n" })
|
||||
|
||||
export function sourceBindingCanonical(value: unknown): string {
|
||||
return JSON.stringify(value, (_key, item: unknown) => item && typeof item === "object" && !Array.isArray(item)
|
||||
? Object.fromEntries(Object.entries(item).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)) : item)
|
||||
}
|
||||
export const sourceBindingSha256 = (bytes: Uint8Array | string) => createHash("sha256").update(bytes).digest("hex")
|
||||
export const sourceBindingBytes = (value: unknown) => Buffer.from(sourceBindingCanonical(value) + "\n", "utf8")
|
||||
function parseExact(bytes: Buffer) {
|
||||
const value: unknown = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes))
|
||||
// Exact canonical bytes also reject duplicate JSON keys, alternate numeric
|
||||
// spellings, trailing content and ambiguous/noncanonical Unicode encodings.
|
||||
if (!sourceBindingBytes(value).equals(bytes)) fail()
|
||||
return envelope.parse(value)
|
||||
}
|
||||
async function boundedFile(filename: string, maximum = 8192): Promise<Buffer> {
|
||||
const before = await lstat(filename)
|
||||
if (!before.isFile() || before.isSymbolicLink() || before.size < 1 || before.size > maximum) fail()
|
||||
const handle = await open(filename, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0))
|
||||
try {
|
||||
const stat = await handle.stat()
|
||||
if (!stat.isFile() || stat.dev !== before.dev || stat.ino !== before.ino || stat.size !== before.size) fail()
|
||||
const bytes = Buffer.alloc(stat.size + 1); let length = 0
|
||||
while (length < bytes.length) { const got = await handle.read(bytes, length, bytes.length - length, length); if (!got.bytesRead) break; length += got.bytesRead }
|
||||
const after = await handle.stat()
|
||||
if (length !== stat.size || after.size !== stat.size || after.mtimeMs !== stat.mtimeMs || after.ctimeMs !== stat.ctimeMs) fail()
|
||||
return bytes.subarray(0, length)
|
||||
} finally { await handle.close() }
|
||||
}
|
||||
function fence(options: DatabaseSourceObservationOptions) {
|
||||
if (!Number.isSafeInteger(options.deadlineAt) || options.deadlineAt <= Date.now() || options.signal?.aborted) fail()
|
||||
}
|
||||
|
||||
/** Input is privileged deployment configuration, never request JSON. Actual
|
||||
* protection/ACL and current-process invalidation are separate deployment gates.
|
||||
* This deliberately grants signature/source evidence only, NOT generation/READY. */
|
||||
export async function readDatabaseSourceBindingRegistry(raw: DatabaseSourceDeploymentPin) {
|
||||
try {
|
||||
const cfg = deployment.parse(raw), directory = path.resolve(cfg.registryDirectory)
|
||||
if (!path.isAbsolute(cfg.registryDirectory) || directory !== await realpath(directory)) fail()
|
||||
const stat = await lstat(directory); if (!stat.isDirectory() || stat.isSymbolicLink()) fail()
|
||||
const publicBytes = await boundedFile(path.join(directory, "operator-public.pem"), 4096)
|
||||
if (publicBytes.toString("ascii").includes("PRIVATE KEY")) fail()
|
||||
const publicKey = createPublicKey(publicBytes)
|
||||
if (publicKey.type !== "public" || publicKey.asymmetricKeyType !== "ed25519") fail()
|
||||
const spki = publicKey.export({ type: "spki", format: "der" })
|
||||
if (sourceBindingSha256(spki) !== cfg.publicKeySpkiSha256) fail()
|
||||
const headBytes = await boundedFile(path.join(directory, "head-" + cfg.currentHeadSha256 + ".json"))
|
||||
if (sourceBindingSha256(headBytes) !== cfg.currentHeadSha256) fail()
|
||||
const signedHead = parseExact(headBytes), head = sourceRegistryHeadSchema.parse(signedHead.body)
|
||||
const check = (domain: string, body: unknown, signature: string) => verify(null, Buffer.from(domain + sourceBindingCanonical(body)), publicKey, Buffer.from(signature, "base64url"))
|
||||
if (head.installationId !== cfg.installationId || !check(DATABASE_SOURCE_SIGNATURE_DOMAINS.head, head, signedHead.signature)) fail()
|
||||
const bindingBytes = await boundedFile(path.join(directory, "binding-" + head.bindingSha256 + ".json"))
|
||||
if (sourceBindingSha256(bindingBytes) !== head.bindingSha256) fail()
|
||||
const signedBinding = parseExact(bindingBytes), body = sourceBindingBodySchema.parse(signedBinding.body)
|
||||
if (!check(DATABASE_SOURCE_SIGNATURE_DOMAINS.binding, body, signedBinding.signature) || body.installationId !== head.installationId ||
|
||||
body.sourceIdentityId !== head.sourceIdentityId || body.generation !== head.generation || body.ordinal !== head.ordinal) fail()
|
||||
return Object.freeze({ body: Object.freeze({ ...body, source: Object.freeze({ ...body.source }) }),
|
||||
head: Object.freeze(head), publicKeySpkiSha256: cfg.publicKeySpkiSha256, headSha256: cfg.currentHeadSha256,
|
||||
platformProtectionVerified: false as const, generationAuthenticated: false as const, readyAuthorized: false as const })
|
||||
} catch { return fail() }
|
||||
}
|
||||
|
||||
/** Genuine same-facade observation is mandatory. Serialized source facts never
|
||||
* become a capability. Registry pin comes only from future privileged assembly. */
|
||||
export function createDatabaseSourceBindingVerifier(raw: DatabaseSourceDeploymentPin) {
|
||||
const cfg = Object.freeze(deployment.parse(raw))
|
||||
return Object.freeze({ async verify(sql: ProbeStorageSql, observation: DatabaseSourceObservation, options: DatabaseSourceObservationOptions) {
|
||||
try {
|
||||
fence(options); await assertDatabaseSourceObservationCurrent(sql, observation, options)
|
||||
const before = await readDatabaseSourceBindingRegistry(cfg); fence(options)
|
||||
if (sourceBindingCanonical(before.body.source) !== sourceBindingCanonical(observation.source)) fail()
|
||||
await assertDatabaseSourceObservationCurrent(sql, observation, options); fence(options)
|
||||
const after = await readDatabaseSourceBindingRegistry(cfg); fence(options)
|
||||
if (sourceBindingCanonical(before) !== sourceBindingCanonical(after)) fail()
|
||||
await assertDatabaseSourceObservationCurrent(sql, observation, options); fence(options)
|
||||
return Object.freeze({ format: "database-source-binding-evidence-v1" as const,
|
||||
installationId: before.body.installationId, sourceIdentityId: before.body.sourceIdentityId, generation: before.body.generation,
|
||||
ordinal: before.body.ordinal, headSha256: before.headSha256, bindingSha256: before.head.bindingSha256,
|
||||
operatorSignatureVerified: true as const, currentHeadPinVerified: true as const, genuineSourceFactsMatched: true as const,
|
||||
platformProtectionVerified: false as const, generationAuthenticated: false as const, allWriterDrainAuthenticated: false as const, readyAuthorized: false as const })
|
||||
} catch { return fail() }
|
||||
} })
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
/** Test-only adopted-writer capture regression fixture. Never imported by
|
||||
* startup, restore, dispatch or READY. Raw/unadopted SQL is NOT fenced. */
|
||||
import { createHash } from "node:crypto"
|
||||
import { resolve } from "node:path"
|
||||
import { pathToFileURL } from "node:url"
|
||||
if (process.env.MMAPP_OWN_SOURCE_CAPTURE !== "true" || !process.env.MMAPP_OWN_SOURCE_CAPTURE_ROOT || process.env.DATABASE_URL) throw Error("Owned capture runner required")
|
||||
const source = (path: string) => pathToFileURL(resolve(process.env.MMAPP_OWN_SOURCE_CAPTURE_ROOT!, "backend/src", path)).href
|
||||
const { PARTITION_WRITER_ADVISORY_KEY } = await import(source("services/partition-write-transaction.ts")) as typeof import("../services/partition-write-transaction.js")
|
||||
const { captureMigrationData } = await import(source("db/migration-data-inventory.ts")) as typeof import("./migration-data-inventory.js")
|
||||
export interface CaptureClient {
|
||||
query(sql: string, values?: unknown[]): Promise<{ rows: Record<string, unknown>[]; command?: string }>
|
||||
release(destroy?: boolean): void
|
||||
}
|
||||
export interface CapturePool { connect(): Promise<CaptureClient> }
|
||||
export interface CapturePolicy { databaseName: string; captureRole: string; runtimeRole: string; timeoutMs: number }
|
||||
export class AdoptedSourceCaptureError extends Error {
|
||||
readonly code = "ADOPTED_SOURCE_CAPTURE_REFUSED"
|
||||
constructor(message: string, readonly outcomeUnknown = false, options?: ErrorOptions) { super(message, options) }
|
||||
}
|
||||
const fail = (message: string): never => { throw new AdoptedSourceCaptureError(message) }
|
||||
const digest = (value: unknown) => createHash("sha256").update(JSON.stringify(value)).digest("hex")
|
||||
export const CAPTURE_SQL = Object.freeze({
|
||||
roleClosure: `WITH RECURSIVE roles(oid) AS (
|
||||
SELECT oid FROM pg_catalog.pg_roles WHERE rolname=$1
|
||||
UNION SELECT m.roleid FROM pg_catalog.pg_auth_members m JOIN roles r ON m.member=r.oid)
|
||||
SELECT r.oid::text AS oid,r.rolname,r.rolsuper,r.rolcreaterole,r.rolcreatedb,r.rolreplication,r.rolbypassrls,r.rolcanlogin
|
||||
FROM pg_catalog.pg_roles r JOIN roles c ON c.oid=r.oid ORDER BY r.oid LIMIT 129`,
|
||||
privilege: `SELECT
|
||||
EXISTS(SELECT 1 FROM pg_catalog.pg_database d WHERE d.datname=pg_catalog.current_database() AND (d.datdba=ANY($1::oid[]) OR EXISTS(SELECT 1 FROM unnest($1::oid[]) r WHERE pg_catalog.has_database_privilege(r,d.oid,'CREATE')))) AS database_owner_or_create,
|
||||
EXISTS(SELECT 1 FROM pg_catalog.pg_namespace n WHERE n.nspname='public' AND (n.nspowner=ANY($1::oid[]) OR EXISTS(SELECT 1 FROM unnest($1::oid[]) r WHERE pg_catalog.has_schema_privilege(r,n.oid,'CREATE')))) AS schema_owner_or_create,
|
||||
EXISTS(SELECT 1 FROM pg_catalog.pg_class c JOIN pg_catalog.pg_namespace n ON n.oid=c.relnamespace WHERE n.nspname='public' AND c.relowner=ANY($1::oid[])) AS relation_owner,
|
||||
EXISTS(SELECT 1 FROM pg_catalog.pg_proc p JOIN pg_catalog.pg_namespace n ON n.oid=p.pronamespace WHERE n.nspname='public' AND p.proowner=ANY($1::oid[])) AS routine_owner,
|
||||
EXISTS(SELECT 1 FROM pg_catalog.pg_proc p JOIN pg_catalog.pg_namespace n ON n.oid=p.pronamespace WHERE p.prosecdef AND n.nspname NOT IN ('pg_catalog','information_schema') AND EXISTS(SELECT 1 FROM unnest($1::oid[]) r WHERE pg_catalog.has_function_privilege(r,p.oid,'EXECUTE'))) AS callable_security_definer`,
|
||||
ownLock: `SELECT count(*)::int AS n FROM pg_catalog.pg_locks WHERE locktype='advisory' AND pid=pg_catalog.pg_backend_pid() AND classid=0 AND objid=$1::oid AND objsubid=1 AND mode='ExclusiveLock' AND granted`,
|
||||
context: `SELECT d.oid::text AS database_oid,d.datname,SESSION_USER::text AS session_user,CURRENT_USER::text AS current_user,
|
||||
pg_catalog.pg_backend_pid() AS backend_pid,c.system_identifier::text AS system_identifier,
|
||||
pg_catalog.pg_current_snapshot()::text AS snapshot,pg_catalog.transaction_timestamp()::text AS transaction_started,
|
||||
pg_catalog.current_setting('transaction_isolation') AS isolation,pg_catalog.current_setting('transaction_read_only') AS read_only,
|
||||
pg_catalog.pg_is_in_recovery() AS in_recovery
|
||||
FROM pg_catalog.pg_database d CROSS JOIN pg_catalog.pg_control_system() c WHERE d.datname=pg_catalog.current_database()`,
|
||||
})
|
||||
function policy(raw: CapturePolicy) {
|
||||
if (!raw || Object.getPrototypeOf(raw) !== Object.prototype || Object.keys(raw).sort().join(" ") !== "captureRole databaseName runtimeRole timeoutMs" ||
|
||||
Object.values(Object.getOwnPropertyDescriptors(raw)).some(d => !("value" in d) || !d.enumerable) || Reflect.ownKeys(raw).length !== 4 ||
|
||||
![raw.databaseName, raw.captureRole, raw.runtimeRole].every(v => typeof v === "string" && /^[a-z][a-z0-9_]{0,62}$/.test(v)) ||
|
||||
raw.captureRole === raw.runtimeRole || !Number.isSafeInteger(raw.timeoutMs) || raw.timeoutMs < 100 || raw.timeoutMs > 30000) fail("Exact bounded trusted capture policy required")
|
||||
return Object.freeze(structuredClone(raw))
|
||||
}
|
||||
/** Owns the actual exclusive lock on the SAME checked-out backend used for
|
||||
* capture. It waits for existing adopted transactions and blocks later adopted
|
||||
* transactions/DDL before their first relation access. No lock callback seam. */
|
||||
export async function openAdoptedSourceCapture(pool: CapturePool, raw: CapturePolicy, options: { signal?: AbortSignal } = {}) {
|
||||
const cfg = policy(raw)
|
||||
if (options.signal?.aborted) fail("Capture aborted before checkout")
|
||||
const client = await pool.connect()
|
||||
const deadlineAt = Date.now() + cfg.timeoutMs
|
||||
let closed = false, busy = false, used = false, transaction = false, commitSent = false
|
||||
const release = (discard: boolean) => {
|
||||
if (!closed) { closed = true; clearTimeout(timer); options.signal?.removeEventListener("abort", onAbort); client.release(discard) }
|
||||
}
|
||||
const destroy = () => release(true)
|
||||
const onAbort = () => destroy()
|
||||
const timer = setTimeout(destroy, cfg.timeoutMs); timer.unref()
|
||||
options.signal?.addEventListener("abort", onAbort, { once: true })
|
||||
const fence = () => {
|
||||
if (closed || options.signal?.aborted || Date.now() >= deadlineAt) { destroy(); fail("Capture closed, aborted or expired") }
|
||||
}
|
||||
const query: CaptureClient["query"] = async (sql, values) => {
|
||||
fence(); const result = await client.query(sql, values); fence(); return result
|
||||
}
|
||||
async function rolePolicy() {
|
||||
const roles = (await query(CAPTURE_SQL.roleClosure, [cfg.runtimeRole])).rows
|
||||
if (!roles.length || roles.length > 128 || !roles.some(r => r.rolname === cfg.runtimeRole && r.rolcanlogin === true) || roles.some(r =>
|
||||
typeof r.oid !== "string" || !/^[1-9][0-9]*$/.test(r.oid) || typeof r.rolname !== "string" || r.rolname.startsWith("pg_") ||
|
||||
[r.rolsuper,r.rolcreaterole,r.rolcreatedb,r.rolreplication,r.rolbypassrls].some(v => v !== false))) fail("Runtime role/membership has privileged bypass")
|
||||
const rows = (await query(CAPTURE_SQL.privilege, [roles.map(r => r.oid)])).rows
|
||||
if (rows.length !== 1 || Object.values(rows[0]).some(v => v !== false)) fail("Runtime role can own/create application schema or execute security-definer bypass")
|
||||
return digest({ roles, privileges: rows[0] })
|
||||
}
|
||||
async function ownedLock() { const rows = (await query(CAPTURE_SQL.ownLock, [PARTITION_WRITER_ADVISORY_KEY])).rows; if (rows.length !== 1 || rows[0].n !== 1) fail("Same-backend exclusive adopted-writer lease missing") }
|
||||
try {
|
||||
await query("SET lock_timeout='" + cfg.timeoutMs + "ms'")
|
||||
await query("SET statement_timeout='" + cfg.timeoutMs + "ms'")
|
||||
const identity = (await query("SELECT pg_catalog.current_database() AS database_name,CURRENT_USER::text AS current_user,SESSION_USER::text AS session_user")).rows
|
||||
if (identity.length !== 1 || identity[0].database_name !== cfg.databaseName || identity[0].current_user !== cfg.captureRole || identity[0].session_user !== cfg.captureRole) fail("Capture connection is not the explicitly provisioned source/operator")
|
||||
// Authenticate privileges before waiting: a superuser/owner application
|
||||
// role must not be mislabeled as an enforceable limited writer.
|
||||
const rolePolicySha256 = await rolePolicy()
|
||||
// SESSION lease in autocommit first: its waiting SELECT must finish BEFORE
|
||||
// BEGIN RR. Taking an xact lock inside RR could pin an old snapshot while
|
||||
// waiting and miss the writer's COMMIT even after the lock is granted.
|
||||
await query("SELECT pg_catalog.pg_advisory_lock($1::bigint)", [PARTITION_WRITER_ADVISORY_KEY]); await ownedLock()
|
||||
return Object.freeze({
|
||||
async capture() {
|
||||
if (closed || busy || used) fail("Capture handle closed/concurrent/reused")
|
||||
used = true; busy = true
|
||||
try {
|
||||
await ownedLock()
|
||||
if (await rolePolicy() !== rolePolicySha256) fail("Role policy changed while acquiring writer drain")
|
||||
await query("BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY"); transaction = true
|
||||
for (const statement 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='5s'",
|
||||
"SET LOCAL row_security=off", "SET LOCAL extra_float_digits=3", "SET LOCAL bytea_output=hex", "SET LOCAL IntervalStyle=postgres", "SET LOCAL client_encoding='UTF8'", "SET LOCAL lc_monetary='C'", "SET LOCAL lc_numeric='C'"]) await query(statement)
|
||||
const first = (await query(CAPTURE_SQL.context)).rows
|
||||
if (first.length !== 1 || first[0].datname !== cfg.databaseName || first[0].current_user !== cfg.captureRole || first[0].session_user !== cfg.captureRole || first[0].isolation !== "repeatable read" || first[0].read_only !== "on" || first[0].in_recovery !== false) fail("Owned source snapshot context unavailable")
|
||||
const captured = await captureMigrationData({ query }, { scope: { applicationNamespaces: ["public"], excludedControlNamespaces: ["mmapp_control"] }, deadlineAt, maxRows: 100000, maxBytes: 16777216 })
|
||||
const last = (await query(CAPTURE_SQL.context)).rows
|
||||
if (JSON.stringify(first) !== JSON.stringify(last)) fail("Source/backend/snapshot changed across schema/data capture")
|
||||
await ownedLock(); commitSent = true
|
||||
const committed = await query("COMMIT")
|
||||
if (committed.command !== "COMMIT") fail("Owned capture COMMIT acknowledgement unavailable")
|
||||
transaction = false
|
||||
// Role catalogs are re-read OUTSIDE the old RR snapshot so membership
|
||||
// changes during capture are not hidden by snapshot consistency.
|
||||
if (await rolePolicy() !== rolePolicySha256) fail("Role policy changed during capture")
|
||||
await ownedLock()
|
||||
return Object.freeze({ format: "adopted-source-capture-v1" as const, source: Object.freeze(structuredClone(first[0])),
|
||||
rolePolicySha256, schemaSha256: captured.schemaSha256, dataSha256: captured.sha256, rowCount: captured.rowCount, sequenceCount: captured.sequences.length,
|
||||
adoptedWriterDrainAuthenticated: true as const, ownedRepeatableReadCaptureAuthenticated: true as const,
|
||||
allWriterDrainAuthenticated: false as const, sourceGenerationAuthenticated: false as const, readyAuthorized: false as const })
|
||||
} catch (cause) {
|
||||
destroy()
|
||||
throw new AdoptedSourceCaptureError("Owned adopted-writer source capture refused; no replay", transaction || commitSent, { cause })
|
||||
} finally { busy = false }
|
||||
},
|
||||
async close() {
|
||||
if (closed) return
|
||||
if (busy) fail("Capture still active")
|
||||
try {
|
||||
const released = (await query("SELECT pg_catalog.pg_advisory_unlock($1::bigint) AS unlocked", [PARTITION_WRITER_ADVISORY_KEY])).rows
|
||||
if (released.length !== 1 || released[0].unlocked !== true) fail("Adopted-writer lease release unknown")
|
||||
await query("RESET lock_timeout"); await query("RESET statement_timeout")
|
||||
release(false)
|
||||
} catch (cause) { destroy(); throw new AdoptedSourceCaptureError("Capture owner release unconfirmed", true, { cause }) }
|
||||
},
|
||||
})
|
||||
} catch (cause) { destroy(); throw cause }
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { randomBytes, randomUUID } from "node:crypto"
|
||||
import type { Pool as PgPool } from "pg"
|
||||
import { basename, resolve, relative, isAbsolute } from "node:path"
|
||||
import { tmpdir } from "node:os"
|
||||
import { readFile } from "node:fs/promises"
|
||||
import { createRequire } from "node:module"
|
||||
import { pathToFileURL } from "node:url"
|
||||
import { openAdoptedSourceCapture, CAPTURE_SQL, AdoptedSourceCaptureError, type CapturePool } from "./database-source-capture-adopted.sql-helper.js"
|
||||
assert.equal(process.env.MMAPP_OWN_SOURCE_CAPTURE, "true")
|
||||
assert(process.env.MMAPP_OWN_SOURCE_CAPTURE_DIRECTORY && basename(process.env.MMAPP_OWN_SOURCE_CAPTURE_DIRECTORY).startsWith("mm-source-capture-"))
|
||||
assert(process.env.MMAPP_OWN_SOURCE_CAPTURE_ROOT)
|
||||
assert.equal(process.env.DATABASE_URL, undefined)
|
||||
const sourceRoot = process.env.MMAPP_OWN_SOURCE_CAPTURE_ROOT!
|
||||
const require = createRequire(resolve(sourceRoot, "backend/package.json"))
|
||||
const { Pool } = require("pg") as typeof import("pg")
|
||||
const source = (path: string) => pathToFileURL(resolve(sourceRoot, "backend/src", path)).href
|
||||
const { withPartitionWrite, PARTITION_WRITER_ADVISORY_KEY } = await import(source("services/partition-write-transaction.ts")) as typeof import("../services/partition-write-transaction.js")
|
||||
const url = new URL(process.env.MMAPP_OWN_SOURCE_CAPTURE_ADMIN_URL ?? "")
|
||||
assert.equal(url.protocol, "postgresql:"); assert.equal(url.hostname, "127.0.0.1"); assert.equal(url.pathname, "/postgres")
|
||||
assert.equal(url.username, "source_operator"); assert(url.password); assert(url.port); assert.equal(url.search, "")
|
||||
const directory = resolve(process.env.MMAPP_OWN_SOURCE_CAPTURE_DIRECTORY!)
|
||||
const relativeDirectory = relative(resolve(tmpdir()), directory)
|
||||
assert(relativeDirectory && !relativeDirectory.startsWith("..") && !isAbsolute(relativeDirectory))
|
||||
assert.equal(resolve(process.cwd()), directory)
|
||||
const owned = JSON.parse(await readFile(resolve(directory, "owned-source-capture.json"), "utf8"))
|
||||
assert.equal(owned.ownerToken, process.env.MMAPP_OWN_SOURCE_CAPTURE_TOKEN)
|
||||
assert.equal(owned.port, Number(url.port)); assert.equal(owned.username, url.username)
|
||||
const admin = new Pool({ connectionString: url.toString(), max: 4 }), pools: PgPool[] = [], names: string[] = [], roles: string[] = [], cases: string[] = []
|
||||
const handles: Awaited<ReturnType<typeof openAdoptedSourceCapture>>[] = []
|
||||
async function fixture() {
|
||||
const suffix = randomUUID().replaceAll("-", ""), name = "source_" + suffix, role = "runtime_" + suffix, password = randomBytes(24).toString("hex")
|
||||
assert(/^source_[a-f0-9]{32}$/.test(name) && /^runtime_[a-f0-9]{32}$/.test(role))
|
||||
roles.push(role); names.push(name)
|
||||
await admin.query('CREATE ROLE "' + role + '" LOGIN PASSWORD \'' + password + "'")
|
||||
await admin.query('CREATE DATABASE "' + name + '" TEMPLATE template0')
|
||||
const target = new URL(url); target.pathname = "/" + name
|
||||
const operator = new Pool({ connectionString: target.toString(), max: 6, connectionTimeoutMillis: 3000 }); pools.push(operator)
|
||||
target.username = role; target.password = password
|
||||
const runtime = new Pool({ connectionString: target.toString(), max: 6, connectionTimeoutMillis: 3000 }); pools.push(runtime)
|
||||
await operator.query("CREATE SCHEMA mmapp_control; CREATE TABLE public.sample(id integer PRIMARY KEY,payload text NOT NULL); INSERT INTO public.sample VALUES(1,'owned-seed'); CREATE SEQUENCE public.sample_seq")
|
||||
await operator.query('GRANT SELECT,INSERT,UPDATE,DELETE ON public.sample TO "' + role + '"; GRANT USAGE,SELECT,UPDATE ON SEQUENCE public.sample_seq TO "' + role + '"')
|
||||
const policy = { databaseName: name, captureRole: "source_operator", runtimeRole: role, timeoutMs: 3000 }
|
||||
async function open(pool: CapturePool = operator, options: { signal?: AbortSignal } = {}) { const h = await openAdoptedSourceCapture(pool, policy, options); handles.push(h); return h }
|
||||
return { name, role, operator, runtime, policy, open }
|
||||
}
|
||||
async function until(check: () => Promise<boolean>) {
|
||||
const deadline = performance.now() + 4000
|
||||
while (!await check()) { if (performance.now() >= deadline) throw Error("Owned SQL condition did not become observable"); await new Promise(r => setTimeout(r, 10)) }
|
||||
}
|
||||
const lockWaiter = (pool: PgPool) => pool.query("SELECT EXISTS(SELECT 1 FROM pg_catalog.pg_locks WHERE locktype='advisory' AND objid=$1::oid AND NOT granted) AS waiting", [PARTITION_WRITER_ADVISORY_KEY]).then(r => r.rows[0].waiting === true)
|
||||
function wrapped(pool: PgPool, hook: (sql: string, after: () => Promise<{ rows: Record<string, unknown>[]; command?: string }>) => Promise<{ rows: Record<string, unknown>[]; command?: string }>): CapturePool {
|
||||
return { async connect() { const c = await pool.connect(); return { release(destroy?: boolean) { c.release(destroy) }, query(sql: string, values?: unknown[]) { return hook(sql, async () => { const r = await c.query(sql, values); return { rows: r.rows, command: r.command } }) } } } }
|
||||
}
|
||||
try {
|
||||
const good = await fixture(), baseline = await good.open(), evidence = await baseline.capture()
|
||||
assert.equal(evidence.rowCount, 1); assert.equal(evidence.sequenceCount, 1); assert.equal(evidence.source.datname, good.name)
|
||||
assert.equal(evidence.source.current_user, "source_operator"); assert.equal(evidence.ownedRepeatableReadCaptureAuthenticated, true)
|
||||
assert.equal(evidence.adoptedWriterDrainAuthenticated, true); assert.equal(evidence.allWriterDrainAuthenticated, false); assert.equal(evidence.sourceGenerationAuthenticated, false); assert.equal(evidence.readyAuthorized, false)
|
||||
assert.match(evidence.schemaSha256, /^[0-9a-f]{64}$/); assert.match(evidence.dataSha256, /^[0-9a-f]{64}$/)
|
||||
await assert.rejects(baseline.capture()); await baseline.close(); cases.push("actual fixed source/schema/data capture owns one RR READ ONLY backend and never asserts whole-writer/generation/READY")
|
||||
|
||||
let releaseWriter!: () => void, writerEntered!: () => void
|
||||
const entered = new Promise<void>(r => { writerEntered = r }), release = new Promise<void>(r => { releaseWriter = r })
|
||||
const firstWriter = withPartitionWrite(async ({ client }) => { await client.query("UPDATE public.sample SET payload='committed-before-capture' WHERE id=1"); await client.query("INSERT INTO public.sample VALUES(3,'newly-committed-before-snapshot')"); writerEntered(); await release }, { connect: () => good.runtime.connect() })
|
||||
let captureTransactionBegan = false
|
||||
const tracedPool = wrapped(good.operator, async (sql, run) => { if (sql.startsWith("BEGIN ISOLATION LEVEL REPEATABLE READ")) captureTransactionBegan = true; return run() })
|
||||
await entered; const opening = good.open(tracedPool); await until(() => lockWaiter(good.operator))
|
||||
assert.equal(captureTransactionBegan, false, "RR snapshot must not start while exclusive session lock is waiting")
|
||||
releaseWriter(); await firstWriter
|
||||
const drained = await opening; assert.equal(captureTransactionBegan, false)
|
||||
const afterWriter = await drained.capture(); assert.equal(captureTransactionBegan, true)
|
||||
assert.equal(afterWriter.rowCount, 2, "capture includes the INSERT committed by the writer that held the shared lock")
|
||||
assert.equal(afterWriter.schemaSha256, evidence.schemaSha256); assert.notEqual(afterWriter.dataSha256, evidence.dataSha256); await drained.close()
|
||||
cases.push("exclusive SESSION drain precedes BEGIN RR: real pending INSERT/UPDATE commits before fresh snapshot and newly committed row is included")
|
||||
|
||||
const blocking = await good.open(); let lateDmlEntered = false
|
||||
const lateDml = withPartitionWrite(async ({ client }) => { lateDmlEntered = true; await client.query("INSERT INTO public.sample VALUES(2,'after-release')") }, { connect: () => good.runtime.connect() })
|
||||
const ddl = await good.operator.connect(); const ddlPid = (await ddl.query("SELECT pg_backend_pid() AS pid")).rows[0].pid
|
||||
const lateDdl = (async () => { await ddl.query("SELECT pg_advisory_lock_shared($1::bigint)", [PARTITION_WRITER_ADVISORY_KEY]); await ddl.query("CREATE TABLE public.admitted_ddl(id integer)"); await ddl.query("SELECT pg_advisory_unlock_shared($1::bigint)", [PARTITION_WRITER_ADVISORY_KEY]); ddl.release() })()
|
||||
await until(async () => (await good.operator.query("SELECT count(*)::int AS n FROM pg_locks WHERE locktype='advisory' AND objid=$1::oid AND NOT granted", [PARTITION_WRITER_ADVISORY_KEY])).rows[0].n === 2)
|
||||
assert.equal(lateDmlEntered, false); const held = await blocking.capture(); assert.equal(held.rowCount, 2)
|
||||
assert.equal((await good.operator.query("SELECT EXISTS(SELECT 1 FROM pg_stat_activity WHERE pid=$1 AND wait_event_type='Lock') AS waiting", [ddlPid])).rows[0].waiting, true)
|
||||
await blocking.close(); await Promise.all([lateDml, lateDdl]); assert.equal(lateDmlEntered, true)
|
||||
cases.push("same real exclusive lease blocks new admitted DML and migration-style DDL before target SQL until capture releases")
|
||||
|
||||
const bypass = await fixture(), bypassHandle = await bypass.open()
|
||||
await bypass.runtime.query("INSERT INTO public.sample VALUES(2,'raw-unadopted-bypass')")
|
||||
assert.equal((await bypassHandle.capture()).rowCount, 2); await bypassHandle.close()
|
||||
cases.push("actual raw runtime SQL bypasses advisory drain, proving existing key cannot certify ALL writers")
|
||||
|
||||
const rr = await fixture(); let inserted = false
|
||||
const rrPool = wrapped(rr.operator, async (sql, run) => {
|
||||
const result = await run()
|
||||
if (sql === CAPTURE_SQL.context && !inserted) { inserted = true; await rr.runtime.query("INSERT INTO public.sample VALUES(2,'concurrent-raw')") }
|
||||
return result
|
||||
})
|
||||
const rrHandle = await rr.open(rrPool), rrEvidence = await rrHandle.capture(); assert.equal(rrEvidence.rowCount, 1)
|
||||
assert.equal((await rr.operator.query("SELECT count(*)::int AS n FROM public.sample")).rows[0].n, 2); await rrHandle.close()
|
||||
cases.push("actual RR snapshot stays coherent while unadopted concurrent DML commits; consistency is not physical quiescence")
|
||||
|
||||
const seq = await fixture(); let sequenceMoved = false
|
||||
const seqPool = wrapped(seq.operator, async (sql, run) => {
|
||||
const result = await run()
|
||||
if (sql.startsWith("/* migration-data:sequence */") && !sequenceMoved) { sequenceMoved = true; await seq.runtime.query("SELECT nextval('public.sample_seq')") }
|
||||
return result
|
||||
})
|
||||
const seqHandle = await seq.open(seqPool); await assert.rejects(seqHandle.capture()); await assert.rejects(seqHandle.capture())
|
||||
cases.push("actual nextval bypass during RR capture is detected by existing before/after sequence inventory and poisons capture")
|
||||
|
||||
const unsafe = await fixture()
|
||||
await admin.query('ALTER ROLE "' + unsafe.role + '" SUPERUSER'); await assert.rejects(unsafe.open()); await admin.query('ALTER ROLE "' + unsafe.role + '" NOSUPERUSER')
|
||||
cases.push("actual superuser runtime refuses before lock/capture")
|
||||
await admin.query('GRANT pg_write_all_data TO "' + unsafe.role + '"'); await assert.rejects(unsafe.open()); await admin.query('REVOKE pg_write_all_data FROM "' + unsafe.role + '"')
|
||||
cases.push("actual indirect builtin write-all role membership refuses")
|
||||
await unsafe.operator.query('GRANT CREATE ON SCHEMA public TO "' + unsafe.role + '"'); await assert.rejects(unsafe.open()); await unsafe.operator.query('REVOKE CREATE ON SCHEMA public FROM "' + unsafe.role + '"')
|
||||
cases.push("actual runtime CREATE privilege on application schema refuses")
|
||||
await unsafe.operator.query('ALTER TABLE public.sample OWNER TO "' + unsafe.role + '"'); await assert.rejects(unsafe.open()); await unsafe.operator.query('ALTER TABLE public.sample OWNER TO source_operator')
|
||||
cases.push("actual application-relation ownership refuses even without superuser flags")
|
||||
await unsafe.operator.query("CREATE FUNCTION public.unsafe_bypass() RETURNS integer LANGUAGE sql SECURITY DEFINER AS 'SELECT 1'")
|
||||
await assert.rejects(unsafe.open()); await unsafe.operator.query("DROP FUNCTION public.unsafe_bypass()")
|
||||
cases.push("actual callable SECURITY DEFINER routine refuses privileged bypass surface")
|
||||
|
||||
const changed = await fixture(); let roleChanged = false
|
||||
const changedPool = wrapped(changed.operator, async (sql, run) => { const r = await run(); if (sql === "COMMIT" && !roleChanged) { roleChanged = true; await admin.query('GRANT pg_write_all_data TO "' + changed.role + '"') }; return r })
|
||||
const changedHandle = await changed.open(changedPool); await assert.rejects(changedHandle.capture()); await admin.query('REVOKE pg_write_all_data FROM "' + changed.role + '"')
|
||||
cases.push("real role drift after RR COMMIT is detected by fresh outside-snapshot privilege read")
|
||||
|
||||
const lost = await fixture()
|
||||
const lostPool = wrapped(lost.operator, async (sql, run) => { const r = await run(); if (sql === "COMMIT") throw Error("owned fixture loses actual read-only COMMIT ACK"); return r })
|
||||
const lostHandle = await lost.open(lostPool)
|
||||
await assert.rejects(lostHandle.capture(), e => e instanceof AdoptedSourceCaptureError && e.outcomeUnknown)
|
||||
await assert.rejects(lostHandle.capture()); const fresh = await lost.open(); await fresh.close()
|
||||
cases.push("lost actual RR COMMIT acknowledgement refuses evidence, destroys owner, and does not replay capture")
|
||||
|
||||
const aborted = await fixture(), abortController = new AbortController()
|
||||
const abortedHandle = await aborted.open(aborted.operator, { signal: abortController.signal })
|
||||
abortController.abort(); await assert.rejects(abortedHandle.capture()); await abortedHandle.close()
|
||||
const afterAbort = await aborted.open(); await afterAbort.close()
|
||||
const beforeAbort = new AbortController(); beforeAbort.abort()
|
||||
await assert.rejects(aborted.open(aborted.operator, { signal: beforeAbort.signal }))
|
||||
cases.push("actual abort destroys owned session, rejects capture/replay and releases exclusive lease; pre-aborted checkout refuses")
|
||||
|
||||
const expired = await fixture()
|
||||
const expiredHandle = await openAdoptedSourceCapture(expired.operator, { ...expired.policy, timeoutMs: 100 })
|
||||
handles.push(expiredHandle)
|
||||
await new Promise(resolve => setTimeout(resolve, 150))
|
||||
await assert.rejects(expiredHandle.capture()); await expiredHandle.close()
|
||||
const afterExpiry = await expired.open(); await afterExpiry.close()
|
||||
cases.push("bounded whole capture lease expires, refuses evidence/replay and releases real exclusive session lock")
|
||||
|
||||
const rolled = await fixture(); let commits = 0, rolledTag: string | undefined
|
||||
// A successful COMMIT cannot be relabeled as ROLLBACK. This separate wrapper
|
||||
// issues division-by-zero on the exact backend immediately before COMMIT.
|
||||
const abortedCommitPool: CapturePool = { async connect() { const c = await rolled.operator.connect(); return {
|
||||
release(discard?: boolean) { c.release(discard) }, async query(sql: string, values?: unknown[]) {
|
||||
if (sql === "COMMIT") { commits++; await assert.rejects(c.query("SELECT 1/0")) }
|
||||
const result = await c.query(sql, values); if (sql === "COMMIT") rolledTag = result.command
|
||||
return { rows: result.rows, command: result.command }
|
||||
},
|
||||
} } }
|
||||
const rolledHandle = await rolled.open(abortedCommitPool)
|
||||
await assert.rejects(rolledHandle.capture(), e => e instanceof AdoptedSourceCaptureError && e.outcomeUnknown)
|
||||
assert.equal(commits, 1); assert.equal(rolledTag, "ROLLBACK"); await assert.rejects(rolledHandle.capture())
|
||||
cases.push("actual aborted read-only COMMIT resolves to ROLLBACK, produces no evidence and never replays capture")
|
||||
|
||||
const wrong = await fixture(); await assert.rejects(openAdoptedSourceCapture(wrong.operator, { ...wrong.policy, captureRole: "other_operator" }))
|
||||
cases.push("source checkout must match explicitly expected database and capture-role/session identity")
|
||||
console.log(JSON.stringify({ cases: cases.length, names: cases, databases: names.length, actualPostgresRoles: roles.length, actualSql: true,
|
||||
actualCurrentProductionPartitionWriterModule: true, importerControlCoveredBySeparatePermanentProfile: true,
|
||||
permanentTestOnlySourceCaptureFixture: true, actualExistingSchemaDataInventory: true, rawUnadoptedBypassDemonstrated: true,
|
||||
ownedRepeatableReadCapture: true, allWriterDrainAuthenticated: false, sourceGenerationAuthenticated: false, readyAuthorized: false,
|
||||
productionDatabaseTouched: false, applicationConfigOrDotenvLoaded: false, noHostUsersGroupsServicesCreated: true }, null, 2))
|
||||
} finally {
|
||||
for (const h of handles) await h.close().catch(() => {})
|
||||
await Promise.all(pools.map(p => p.end()))
|
||||
for (const name of names) await admin.query('DROP DATABASE "' + name + '" WITH (FORCE)')
|
||||
for (const role of roles) await admin.query('DROP ROLE "' + role + '"')
|
||||
await admin.end()
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { randomUUID } from "node:crypto"
|
||||
import { basename, dirname, resolve } from "node:path"
|
||||
|
||||
/** Catalog observation on two disposable databases, not source enrollment. */
|
||||
export async function acceptDatabaseSourceObservationSql() {
|
||||
assert.equal(process.env.MMAPP_OWN_PROBE_STORAGE_JOURNAL, "true")
|
||||
const url = new URL(process.env.DATABASE_URL ?? "")
|
||||
assert.equal(url.hostname, "127.0.0.1"); assert.equal(url.pathname, "/postgres")
|
||||
assert(process.env.MCP_STATE_DIR && basename(dirname(resolve(process.env.MCP_STATE_DIR))).startsWith("mm-mcp-test-"))
|
||||
const { pool } = await import("./index.js")
|
||||
const { Pool } = await import("pg")
|
||||
const { captureDatabaseSourceObservation, assertDatabaseSourceObservationCurrent } = await import("./database-source-observation.js")
|
||||
const name = "owned_probe_source_" + randomUUID().replaceAll("-", "")
|
||||
await pool.query(`CREATE DATABASE "${name}" TEMPLATE template0`)
|
||||
const otherUrl = new URL(url); otherUrl.pathname = "/" + name
|
||||
const other = new Pool({ connectionString: otherUrl.toString(), max: 1, connectionTimeoutMillis: 3000, statement_timeout: 10000 })
|
||||
const observe = async (selected: typeof pool) => {
|
||||
const client = await selected.connect()
|
||||
const sql = { query: async (text: string, values?: unknown[], options?: { signal?: AbortSignal; timeoutMs: number }) => {
|
||||
const config = { text, values, query_timeout: options?.timeoutMs ?? 10000 }
|
||||
return client.query<Record<string, unknown>>(config)
|
||||
} }
|
||||
try {
|
||||
await client.query("BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY")
|
||||
await client.query("SET LOCAL statement_timeout='10s'")
|
||||
await client.query("SET LOCAL search_path=public,pg_catalog")
|
||||
const options = { deadlineAt: Date.now() + 10000 }
|
||||
const source = await captureDatabaseSourceObservation(sql, options)
|
||||
await assertDatabaseSourceObservationCurrent(sql, source, options)
|
||||
assert.equal(source.ready, false); assert.equal(source.generationAuthenticated, false)
|
||||
assert(Object.isFrozen(source) && Object.isFrozen(source.source) && Object.isFrozen(source.snapshot))
|
||||
return source
|
||||
} finally { try { await client.query("ROLLBACK") } finally { client.release(true) } }
|
||||
}
|
||||
try {
|
||||
// Deliberately shadow names only inside the second disposable database.
|
||||
// Qualified source SQL must not resolve this rejecting type/operator.
|
||||
await other.query("CREATE DOMAIN public.text AS pg_catalog.text CHECK(false)")
|
||||
await other.query("CREATE FUNCTION public.source_wrong_eq(pg_catalog.name,pg_catalog.name) RETURNS pg_catalog.bool LANGUAGE sql IMMUTABLE AS 'SELECT false'")
|
||||
await other.query("CREATE OPERATOR public.= (LEFTARG=pg_catalog.name,RIGHTARG=pg_catalog.name,FUNCTION=public.source_wrong_eq)")
|
||||
const original = await observe(pool), another = await observe(other)
|
||||
assert.equal(original.source.systemIdentifier, another.source.systemIdentifier)
|
||||
assert.notEqual(original.source.databaseOid, another.source.databaseOid)
|
||||
assert.notEqual(original.source.databaseName, another.source.databaseName)
|
||||
assert.notEqual(original.snapshot.backendPid, another.snapshot.backendPid)
|
||||
return { actualSql: true, sameClusterTwoDatabases: true, distinctDatabaseOidAndName: true,
|
||||
actualRepeatableReadSourcePin: true, reorderedSearchPathAndShadowTypeOperatorSafe: true, noGenerationAuthorityClaimed: true,
|
||||
applicationDataWrites: 0, productionDatabaseTouched: false }
|
||||
} finally { await other.end(); await pool.query(`DROP DATABASE "${name}"`) }
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { test } from "node:test"
|
||||
import { McpError } from "../modules/mcp/security.js"
|
||||
import type { ProbeStorageSql } from "../services/server-probe-journal-maintenance.js"
|
||||
import { captureDatabaseSourceObservation, assertDatabaseSourceObservationCurrent } from "./database-source-observation.js"
|
||||
|
||||
const options = () => ({ deadlineAt: Date.now() + 2000 })
|
||||
const row = (): Record<string, unknown> => ({ system_identifier: "18446744073709551615", database_oid: "16384", database_name: "fixture",
|
||||
session_user: "operator", current_user: "operator", in_recovery: false, server_version_num: "180006", backend_pid: 1234,
|
||||
transaction_timestamp: "2026-10-25 12:34:56.123456+00", snapshot: "100:110:101,105,109",
|
||||
isolation: "repeatable read", read_only: "on", explicit_transaction: true })
|
||||
function fixture(rows: Record<string, unknown>[][] = [[row()]]) {
|
||||
const calls: { text: string; timeout: number; signal?: AbortSignal }[] = []
|
||||
const sql: ProbeStorageSql = { async query(text, values, queryOptions) {
|
||||
assert.deepEqual(values, []); assert(queryOptions)
|
||||
calls.push({ text, timeout: queryOptions.timeoutMs, signal: queryOptions.signal })
|
||||
return { rows: rows[Math.min(calls.length - 1, rows.length - 1)] }
|
||||
} }
|
||||
return { sql, calls }
|
||||
}
|
||||
const safe = (error: unknown) => error instanceof McpError && error.message === "Текущий источник и снимок БД не подтверждены"
|
||||
|
||||
test("fixed genuine facts are immutable, exact and explicitly lack generation authority", async () => {
|
||||
const f = fixture(), observation = await captureDatabaseSourceObservation(f.sql, options())
|
||||
assert.equal(observation.source.systemIdentifier, "18446744073709551615")
|
||||
assert.equal(observation.source.databaseOid, 16384)
|
||||
assert.equal(observation.snapshot.transactionTimestamp, "2026-10-25 12:34:56.123456+00")
|
||||
assert.equal(observation.ready, false); assert.equal(observation.generationAuthenticated, false); assert.equal(observation.requiresExternalSourceBinding, true)
|
||||
assert(Object.isFrozen(observation)); assert(Object.isFrozen(observation.source)); assert(Object.isFrozen(observation.snapshot))
|
||||
assert.equal(f.calls.length, 2)
|
||||
assert(f.calls.every(call => call.timeout >= 1 && call.timeout <= 10000))
|
||||
assert(f.calls[0].text.includes("pg_catalog.pg_control_system()")); assert(f.calls[0].text.includes("pg_catalog.pg_current_snapshot()"))
|
||||
assert(!/\b(BEGIN|COMMIT|ROLLBACK|INSERT|UPDATE|DELETE|CREATE|ALTER|DROP|pg_export_snapshot)\b/i.test(f.calls[0].text))
|
||||
await assertDatabaseSourceObservationCurrent(f.sql, observation, options()); assert.equal(f.calls.length, 3)
|
||||
})
|
||||
|
||||
test("every source or transaction dimension drifting between reads is refused", async t => {
|
||||
const changes: Record<string, unknown> = { system_identifier: "9223372036854775808", database_oid: "16385", database_name: "other",
|
||||
session_user: "another", current_user: "another", in_recovery: true, server_version_num: "180007", backend_pid: 1235,
|
||||
transaction_timestamp: "2026-10-25 12:34:56.123457+00", snapshot: "100:111:101,105,109" }
|
||||
for (const [key, value] of Object.entries(changes)) await t.test(key, async () => {
|
||||
const f = fixture([[row()], [{ ...row(), [key]: value }]])
|
||||
await assert.rejects(captureDatabaseSourceObservation(f.sql, options()), safe)
|
||||
})
|
||||
})
|
||||
|
||||
test("same-lease verifier rejects changed transaction after fixed data collection", async () => {
|
||||
const f = fixture([[row()], [row()], [{ ...row(), backend_pid: 44 }]])
|
||||
const observation = await captureDatabaseSourceObservation(f.sql, options())
|
||||
await assert.rejects(assertDatabaseSourceObservationCurrent(f.sql, observation, options()), safe)
|
||||
})
|
||||
|
||||
test("JSON/copies and cross-client source records never establish source binding", async () => {
|
||||
const first = fixture(), second = fixture(), observation = await captureDatabaseSourceObservation(first.sql, options())
|
||||
await assert.rejects(assertDatabaseSourceObservationCurrent(first.sql, structuredClone(observation), options()), safe)
|
||||
await assert.rejects(assertDatabaseSourceObservationCurrent(first.sql, { ...observation }, options()), safe)
|
||||
await assert.rejects(assertDatabaseSourceObservationCurrent(second.sql, observation, options()), safe)
|
||||
assert.equal(first.calls.length, 2); assert.equal(second.calls.length, 0)
|
||||
})
|
||||
|
||||
test("expired or aborted requests perform no SQL", async () => {
|
||||
const f = fixture(), controller = new AbortController(); controller.abort()
|
||||
for (const value of [{ deadlineAt: Date.now() - 1 }, { deadlineAt: NaN }, { ...options(), signal: controller.signal }]) {
|
||||
await assert.rejects(captureDatabaseSourceObservation(f.sql, value), safe)
|
||||
}
|
||||
assert.equal(f.calls.length, 0)
|
||||
})
|
||||
|
||||
test("original deadline and signal remain authoritative during later pairing", async () => {
|
||||
const f = fixture(), controller = new AbortController(), original = { deadlineAt: Date.now() + 40, signal: controller.signal }
|
||||
const observation = await captureDatabaseSourceObservation(f.sql, original)
|
||||
controller.abort()
|
||||
await assert.rejects(assertDatabaseSourceObservationCurrent(f.sql, observation, options()), safe)
|
||||
const other = fixture(), deadline = { deadlineAt: Date.now() + 40 }
|
||||
const timed = await captureDatabaseSourceObservation(other.sql, deadline)
|
||||
deadline.deadlineAt += 100000
|
||||
await new Promise(resolve => setTimeout(resolve, 50))
|
||||
await assert.rejects(assertDatabaseSourceObservationCurrent(other.sql, timed, options()), safe)
|
||||
assert.equal(f.calls.length, 2); assert.equal(other.calls.length, 2)
|
||||
})
|
||||
|
||||
test("strict singleton/schema and explicit RR read-only transaction required", async () => {
|
||||
const missing = row(); delete missing.current_user
|
||||
const variants = [[], [row(), row()], [missing], [{ ...row(), extra: "private" }], [{ ...row(), read_only: "off" }],
|
||||
[{ ...row(), isolation: "read committed" }], [{ ...row(), explicit_transaction: false }], [{ ...row(), backend_pid: 1.5 }],
|
||||
[{ ...row(), in_recovery: "false" }], [{ ...row(), transaction_timestamp: "not a timestamp" }],
|
||||
[{ ...row(), transaction_timestamp: "2026-99-99 12:34:56.123456+00" }], [{ ...row(), server_version_num: "120000" }]]
|
||||
for (const rows of variants) await assert.rejects(captureDatabaseSourceObservation(fixture([rows]).sql, options()), safe)
|
||||
})
|
||||
|
||||
test("uint64, OID and snapshot range/order/size validation is fail closed", async () => {
|
||||
const variants = [{ system_identifier: "18446744073709551616" }, { system_identifier: "0" }, { system_identifier: "01" },
|
||||
{ database_oid: "4294967296" }, { database_oid: "0" }, { database_oid: "01" }, { database_oid: "1.1" },
|
||||
{ snapshot: "110:100:" }, { snapshot: "100:110:105,105" }, { snapshot: "100:110:109,105" },
|
||||
{ snapshot: "100:110:99" }, { snapshot: "100:110:110" }, { snapshot: "100:110:0105" },
|
||||
{ snapshot: "100:18446744073709551616:" }, { snapshot: "100:110" }, { snapshot: "100:110::" },
|
||||
{ snapshot: "0:5000:" + Array.from({ length: 4097 }, (_, index) => index).join(",") },
|
||||
{ snapshot: "0:1:" + "1".repeat(65536) }]
|
||||
for (const change of variants) await assert.rejects(captureDatabaseSourceObservation(fixture([[{ ...row(), ...change }]]).sql, options()), safe)
|
||||
const empty = fixture([[{ ...row(), snapshot: "100:100:" }]])
|
||||
assert.equal((await captureDatabaseSourceObservation(empty.sql, options())).snapshot.snapshot, "100:100:")
|
||||
})
|
||||
|
||||
test("provider errors never leak", async () => {
|
||||
const privateSql: ProbeStorageSql = { query: async () => { throw new Error("private-provider password db-url") } }
|
||||
await assert.rejects(captureDatabaseSourceObservation(privateSql, options()), safe)
|
||||
})
|
||||
|
||||
test("abort fences after caller-owned SQL settles and prevents a second read", async () => {
|
||||
let resolve!: (value: { rows: Record<string, unknown>[] }) => void, queries = 0
|
||||
const controller = new AbortController()
|
||||
const sql: ProbeStorageSql = { query: () => { queries++; return new Promise(done => { resolve = done }) } }
|
||||
const pending = captureDatabaseSourceObservation(sql, { ...options(), signal: controller.signal })
|
||||
controller.abort()
|
||||
// Foreign SQL remains caller-owned until it settles; this helper must not
|
||||
// race it and allow a caller to reuse its backend while work is in flight.
|
||||
resolve({ rows: [row()] }); await assert.rejects(pending, safe); assert.equal(queries, 1)
|
||||
})
|
||||
@@ -0,0 +1,120 @@
|
||||
/** Internal source facts from an authenticated caller-owned RR READ ONLY lease.
|
||||
* Trusted SQL must honor timeout/signal; its caller owns whole-callback bounds,
|
||||
* cancellation, destruction and cleanup. This helper never abandons foreign SQL.
|
||||
* Cluster system identifier + database OID do not distinguish physical clones,
|
||||
* restores, or database generations. These facts never grant READY/conversion. */
|
||||
import { z } from "zod"
|
||||
import { McpError } from "../modules/mcp/security.js"
|
||||
import type { ProbeStorageSql } from "../services/server-probe-journal-maintenance.js"
|
||||
|
||||
export interface DatabaseSourceObservationOptions { deadlineAt: number; signal?: AbortSignal }
|
||||
export interface DatabaseSourceObservation {
|
||||
readonly version: 1
|
||||
readonly source: Readonly<{ systemIdentifier: string; databaseOid: number; databaseName: string; sessionUser: string;
|
||||
currentUser: string; inRecovery: boolean; serverVersionNum: number }>
|
||||
readonly snapshot: Readonly<{ backendPid: number; transactionTimestamp: string; snapshot: string }>
|
||||
readonly ready: false
|
||||
readonly generationAuthenticated: false
|
||||
readonly requiresExternalSourceBinding: true
|
||||
}
|
||||
const refused = () => new McpError("DATABASE_SOURCE_UNAVAILABLE", "Текущий источник и снимок БД не подтверждены", 503)
|
||||
const MAX_UINT64 = 18446744073709551615n
|
||||
function uint64(value: string, positive = false) {
|
||||
if (!/^(0|[1-9][0-9]{0,19})$/.test(value)) throw refused()
|
||||
const number = BigInt(value)
|
||||
if (number > MAX_UINT64 || positive && number === 0n) throw refused()
|
||||
return number
|
||||
}
|
||||
function snapshotText(value: string) {
|
||||
if (Buffer.byteLength(value, "utf8") > 65536) throw refused()
|
||||
const parts = value.split(":")
|
||||
if (parts.length !== 3) throw refused()
|
||||
const xmin = uint64(parts[0]), xmax = uint64(parts[1])
|
||||
if (xmin > xmax) throw refused()
|
||||
const active = parts[2] ? parts[2].split(",") : []
|
||||
if (active.length > 4096) throw refused()
|
||||
let previous: bigint | undefined
|
||||
for (const item of active) {
|
||||
const xid = uint64(item)
|
||||
if (xid < xmin || xid >= xmax || previous !== undefined && xid <= previous) throw refused()
|
||||
previous = xid
|
||||
}
|
||||
return value
|
||||
}
|
||||
const text = z.string().min(1).max(200)
|
||||
const rowSchema = z.object({
|
||||
system_identifier: z.string().max(20), database_oid: z.string().max(10), database_name: text,
|
||||
session_user: text, current_user: text, in_recovery: z.boolean(), server_version_num: z.string().regex(/^[1-9][0-9]{0,7}$/),
|
||||
backend_pid: z.number().int().positive().max(2147483647),
|
||||
transaction_timestamp: z.string().max(128).regex(/^[0-9]{4,6}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}(?:\.[0-9]{1,6})?[+-][0-9]{2}(?::[0-9]{2}(?::[0-9]{2})?)?$/),
|
||||
snapshot: z.string().max(65536), isolation: z.literal("repeatable read"), read_only: z.literal("on"), explicit_transaction: z.literal(true),
|
||||
}).strict()
|
||||
// All executable functions and catalogs are qualified. SESSION_USER/CURRENT_USER
|
||||
// are SQL keywords. No search_path-dependent user function is invoked.
|
||||
const SOURCE_SQL = `/* database-source-observation:current */ SELECT
|
||||
control.system_identifier::pg_catalog.text AS system_identifier,d.oid::pg_catalog.text AS database_oid,d.datname::pg_catalog.text AS database_name,
|
||||
SESSION_USER::pg_catalog.text AS session_user,CURRENT_USER::pg_catalog.text AS current_user,
|
||||
pg_catalog.pg_is_in_recovery() AS in_recovery,pg_catalog.current_setting('server_version_num') AS server_version_num,
|
||||
pg_catalog.pg_backend_pid() AS backend_pid,pg_catalog.transaction_timestamp()::pg_catalog.text AS transaction_timestamp,
|
||||
pg_catalog.pg_current_snapshot()::pg_catalog.text AS snapshot,
|
||||
pg_catalog.current_setting('transaction_isolation') AS isolation,pg_catalog.current_setting('transaction_read_only') AS read_only,
|
||||
pg_catalog.transaction_timestamp() OPERATOR(pg_catalog.<) pg_catalog.statement_timestamp() AS explicit_transaction
|
||||
FROM pg_catalog.pg_database d CROSS JOIN pg_catalog.pg_control_system() control
|
||||
WHERE d.datname OPERATOR(pg_catalog.=) pg_catalog.current_database() LIMIT 2`
|
||||
|
||||
const origins = new WeakMap<DatabaseSourceObservation, { sql: ProbeStorageSql; deadlineAt: number; signal?: AbortSignal }>()
|
||||
function fence(options: DatabaseSourceObservationOptions) {
|
||||
if (!Number.isSafeInteger(options.deadlineAt) || Date.now() >= options.deadlineAt || options.signal?.aborted) throw refused()
|
||||
}
|
||||
async function read(sql: ProbeStorageSql, options: DatabaseSourceObservationOptions) {
|
||||
fence(options)
|
||||
const timeoutMs = Math.max(1, Math.min(10000, options.deadlineAt - Date.now()))
|
||||
try {
|
||||
const result = await sql.query(SOURCE_SQL, [], { timeoutMs, signal: options.signal })
|
||||
fence(options)
|
||||
if (!result || !Array.isArray(result.rows) || result.rows.length !== 1) throw refused()
|
||||
const row = rowSchema.parse(result.rows[0])
|
||||
uint64(row.system_identifier, true)
|
||||
if (!/^[1-9][0-9]*$/.test(row.database_oid)) throw refused()
|
||||
const databaseOid = Number(row.database_oid), serverVersionNum = Number(row.server_version_num)
|
||||
if (!Number.isSafeInteger(databaseOid) || databaseOid > 4294967295 || !Number.isSafeInteger(serverVersionNum)
|
||||
|| serverVersionNum < 130000 || !Number.isFinite(Date.parse(row.transaction_timestamp))) throw refused()
|
||||
snapshotText(row.snapshot)
|
||||
return { source: { systemIdentifier: row.system_identifier, databaseOid, databaseName: row.database_name,
|
||||
sessionUser: row.session_user, currentUser: row.current_user, inRecovery: row.in_recovery, serverVersionNum },
|
||||
snapshot: { backendPid: row.backend_pid, transactionTimestamp: row.transaction_timestamp, snapshot: row.snapshot } }
|
||||
} catch { throw refused() }
|
||||
}
|
||||
function equal(left: Awaited<ReturnType<typeof read>>, right: Awaited<ReturnType<typeof read>>) {
|
||||
if (JSON.stringify(left) !== JSON.stringify(right)) throw refused()
|
||||
}
|
||||
|
||||
/** Repeated genuine source metadata plus exact backend/transaction/snapshot pin.
|
||||
* Caller owns authorization, checkout, transaction, cleanup and writer/DDL drain. */
|
||||
export async function captureDatabaseSourceObservation(sql: ProbeStorageSql, rawOptions: DatabaseSourceObservationOptions): Promise<DatabaseSourceObservation> {
|
||||
const options = { ...rawOptions }
|
||||
try {
|
||||
const first = await read(sql, options), second = await read(sql, options)
|
||||
equal(first, second); fence(options)
|
||||
const observation: DatabaseSourceObservation = Object.freeze({ version: 1,
|
||||
source: Object.freeze(first.source), snapshot: Object.freeze(first.snapshot),
|
||||
ready: false, generationAuthenticated: false, requiresExternalSourceBinding: true })
|
||||
origins.set(observation, { sql, ...options })
|
||||
return observation
|
||||
} catch { throw refused() }
|
||||
}
|
||||
|
||||
/** In-process same-lease verifier for surrounding a fixed data/archive read.
|
||||
* JSON, copies and observations from another facade cannot establish a binding. */
|
||||
export async function assertDatabaseSourceObservationCurrent(sql: ProbeStorageSql, expected: DatabaseSourceObservation, options: DatabaseSourceObservationOptions): Promise<void> {
|
||||
try {
|
||||
const origin = origins.get(expected)
|
||||
if (!origin || origin.sql !== sql) throw refused()
|
||||
const signal = origin.signal && options.signal ? AbortSignal.any([origin.signal, options.signal]) : origin.signal ?? options.signal
|
||||
const bounded = { deadlineAt: Math.min(origin.deadlineAt, options.deadlineAt), signal }
|
||||
fence(bounded)
|
||||
const current = await read(sql, bounded)
|
||||
equal({ source: expected.source, snapshot: expected.snapshot }, current)
|
||||
fence(bounded)
|
||||
} catch { throw refused() }
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { isMainThread } from "node:worker_threads"
|
||||
import pg from "pg"
|
||||
import { drizzle } from "drizzle-orm/node-postgres"
|
||||
import { env } from "../config.js"
|
||||
import * as schema from "./schema.js"
|
||||
import { bindSql } from "./sql-bind.js"
|
||||
|
||||
const INT8_OID = 20
|
||||
const DATE_OID = 1082
|
||||
|
||||
pg.types.setTypeParser(INT8_OID, (val) => {
|
||||
const n = Number(val)
|
||||
return Number.isSafeInteger(n) ? n : val
|
||||
})
|
||||
pg.types.setTypeParser(DATE_OID, (val) => val)
|
||||
|
||||
export function createPool(max = 16): pg.Pool {
|
||||
return new pg.Pool({
|
||||
connectionString: env.DATABASE_URL,
|
||||
max,
|
||||
idleTimeoutMillis: 30_000,
|
||||
allowExitOnIdle: process.env.NODE_ENV === "test",
|
||||
connectionTimeoutMillis: 8_000,
|
||||
statement_timeout: 120_000,
|
||||
})
|
||||
}
|
||||
|
||||
export const pool = createPool(isMainThread ? 16 : 4)
|
||||
export const db = drizzle(pool, { schema })
|
||||
|
||||
export async function dbQuery<T extends pg.QueryResultRow = pg.QueryResultRow>(
|
||||
text: string,
|
||||
params?: unknown[] | Record<string, unknown>,
|
||||
): Promise<pg.QueryResult<T>> {
|
||||
const q = bindSql(text, params)
|
||||
return pool.query<T>(q)
|
||||
}
|
||||
|
||||
export async function dbAll<T extends pg.QueryResultRow = pg.QueryResultRow>(
|
||||
text: string,
|
||||
params?: unknown[] | Record<string, unknown>,
|
||||
): Promise<T[]> {
|
||||
const res = await dbQuery<T>(text, params)
|
||||
return res.rows
|
||||
}
|
||||
|
||||
export async function dbGet<T extends pg.QueryResultRow = pg.QueryResultRow>(
|
||||
text: string,
|
||||
params?: unknown[] | Record<string, unknown>,
|
||||
): Promise<T | undefined> {
|
||||
const rows = await dbAll<T>(text, params)
|
||||
return rows[0]
|
||||
}
|
||||
|
||||
export async function withAdvisoryLock<T>(key: number, fn: () => Promise<T>): Promise<T> {
|
||||
const client = await pool.connect()
|
||||
try {
|
||||
await client.query("SELECT pg_advisory_lock($1)", [key])
|
||||
return await fn()
|
||||
} finally {
|
||||
try {
|
||||
await client.query("SELECT pg_advisory_unlock($1)", [key])
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
client.release()
|
||||
}
|
||||
}
|
||||
|
||||
export async function closePool(): Promise<void> {
|
||||
await pool.end()
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/** JSONB from node-pg may already be an object; SQLite leftovers may be strings. */
|
||||
|
||||
export function parseJsonObject(value: unknown): Record<string, unknown> {
|
||||
if (value && typeof value === "object" && !Array.isArray(value)) {
|
||||
return value as Record<string, unknown>
|
||||
}
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
try {
|
||||
const parsed = JSON.parse(value) as unknown
|
||||
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
||||
return parsed as Record<string, unknown>
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
return {}
|
||||
}
|
||||
|
||||
export function parseJsonArray(value: unknown): unknown[] {
|
||||
if (Array.isArray(value)) return value
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
try {
|
||||
const parsed = JSON.parse(value) as unknown
|
||||
if (Array.isArray(parsed)) return parsed
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
export function toJsonb(value: unknown): unknown {
|
||||
if (value == null) return null
|
||||
if (typeof value === "string") {
|
||||
try {
|
||||
return JSON.parse(value) as unknown
|
||||
} catch {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return value
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { readFile } from "node:fs/promises"
|
||||
import { basename,dirname,resolve } from "node:path"
|
||||
import type { Pool } from "pg"
|
||||
import { analyzeMigration,MIGRATION_MANIFEST } from "./migration-manifest.js"
|
||||
|
||||
/** Fixed owned historical prefix, never called by application startup. No
|
||||
* caller-selected ceiling or production schema flag exists. */
|
||||
export async function applyLegacyPollStorageFixtureMigrations(pool:Pool){
|
||||
assert(["MMAPP_OWN_SERVER_POLL_STORAGE","MMAPP_OWN_SERVER_POLL_STORAGE_FRESH"].some(key=>process.env[key]==="true"))
|
||||
const source=new URL(process.env.DATABASE_URL??"");assert.equal(source.hostname,"127.0.0.1");assert.equal(source.pathname,"/postgres")
|
||||
assert(process.env.MCP_STATE_DIR&&basename(dirname(resolve(process.env.MCP_STATE_DIR))).startsWith("mm-mcp-test-"))
|
||||
const pins=MIGRATION_MANIFEST.filter(pin=>pin.file<"0039_")
|
||||
assert.equal(pins.length,38);assert.equal(pins.at(-1)?.file,"0038_server_poll_journal_and_writer_guards.sql")
|
||||
const files=await Promise.all(pins.map(async pin=>{const bytes=await readFile(new URL("../../drizzle/"+pin.file,import.meta.url));analyzeMigration(pin.file,bytes,pin);return {id:pin.file.slice(0,-4),sql:bytes.toString("utf8")}}))
|
||||
const {PARTITION_WRITER_ADVISORY_KEY}=await import("../services/partition-write-transaction.js")
|
||||
const client=await pool.connect()
|
||||
try{
|
||||
await client.query("SET statement_timeout='120s'")
|
||||
await client.query("SELECT pg_advisory_lock_shared($1::bigint)",[PARTITION_WRITER_ADVISORY_KEY])
|
||||
await client.query("CREATE TABLE IF NOT EXISTS schema_migrations(id text PRIMARY KEY,applied_at timestamptz NOT NULL DEFAULT now())")
|
||||
const applied=new Set((await client.query("SELECT id FROM schema_migrations")).rows.map(row=>row.id))
|
||||
assert([...applied].every(id=>files.some(file=>file.id===id)),"Historical poll fixture cannot open a newer schema")
|
||||
for(const file of files)if(!applied.has(file.id)){await client.query(file.sql);await client.query("INSERT INTO schema_migrations(id) VALUES($1) ON CONFLICT DO NOTHING",[file.id])}
|
||||
}finally{client.release(true)}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { readFile } from "node:fs/promises"
|
||||
import { basename, dirname, resolve } from "node:path"
|
||||
import type { Pool } from "pg"
|
||||
import { analyzeMigration, MIGRATION_MANIFEST } from "./migration-manifest.js"
|
||||
|
||||
/** Fixed historical test schema only. Never imported by application startup;
|
||||
* no caller-selected migration ceiling or production environment switch. */
|
||||
export async function applyLegacyProbeFixtureMigrations(pool: Pool) {
|
||||
assert(["MMAPP_OWN_PROBE_JOURNAL", "MMAPP_OWN_PROBE_STORAGE_JOURNAL", "MMAPP_OWN_PROBE_STORAGE_HISTORY", "MMAPP_OWN_PROBE_LEGACY_ORPHANS"].some(key => process.env[key] === "true"))
|
||||
const url = new URL(process.env.DATABASE_URL ?? "")
|
||||
assert.equal(url.hostname, "127.0.0.1"); assert.equal(url.pathname, "/postgres")
|
||||
assert(process.env.MCP_STATE_DIR && basename(dirname(resolve(process.env.MCP_STATE_DIR))).startsWith("mm-mcp-test-"))
|
||||
const pins = MIGRATION_MANIFEST.filter(pin => pin.file < "0037_")
|
||||
assert.equal(pins.length, 36); assert.equal(pins.at(-1)?.file, "0036_diagnostic_cancellations.sql")
|
||||
const files = await Promise.all(pins.map(async pin => {
|
||||
const bytes = await readFile(new URL("../../drizzle/" + pin.file, import.meta.url))
|
||||
analyzeMigration(pin.file, bytes, pin)
|
||||
return { id: pin.file.slice(0, -4), sql: bytes.toString("utf8") }
|
||||
}))
|
||||
const { PARTITION_WRITER_ADVISORY_KEY } = await import("../services/partition-write-transaction.js")
|
||||
const client = await pool.connect()
|
||||
try {
|
||||
await client.query("SET statement_timeout='120s'")
|
||||
await client.query("SELECT pg_advisory_lock_shared($1::bigint)", [PARTITION_WRITER_ADVISORY_KEY])
|
||||
await client.query("CREATE TABLE IF NOT EXISTS schema_migrations(id text PRIMARY KEY,applied_at timestamptz NOT NULL DEFAULT now())")
|
||||
const ids = new Set((await client.query("SELECT id FROM schema_migrations")).rows.map(row => row.id))
|
||||
assert([...ids].every(id => files.some(file => file.id === id)), "Historical fixture cannot open a newer schema")
|
||||
for (const file of files) if (!ids.has(file.id)) {
|
||||
await client.query(file.sql)
|
||||
await client.query("INSERT INTO schema_migrations(id) VALUES($1) ON CONFLICT DO NOTHING", [file.id])
|
||||
}
|
||||
} finally { client.release(true) }
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { readdirSync } from "node:fs"
|
||||
import type { Pool } from "pg"
|
||||
import { applySqlMigrations } from "./migrate.js"
|
||||
import { PartitionWriteError } from "../services/partition-write-transaction.js"
|
||||
|
||||
const files = readdirSync(new URL("../../drizzle/", import.meta.url)).filter(file => /^\d{4}_.+\.sql$/i.test(file))
|
||||
function fixture(pending?: string) {
|
||||
const trace: Array<{ text: string; values: unknown[] }> = [], releases: boolean[] = []
|
||||
let fail: string | undefined, unlock = true, connections = 0
|
||||
const client = {
|
||||
async query(text: string, values: unknown[] = []) {
|
||||
trace.push({ text, values })
|
||||
if (fail && text.includes(fail)) throw Error("Synthetic SQL ACK loss")
|
||||
if (text === "SELECT id FROM schema_migrations") return { rows: files.filter(file => file !== pending).map(file => ({ id: file.replace(/\.sql$/, "") })) }
|
||||
if (text.includes("pg_advisory_unlock_shared")) return { rows: [{ unlocked: unlock }] }
|
||||
return { rows: [] }
|
||||
},
|
||||
release(destroy: boolean) { releases.push(destroy) },
|
||||
}
|
||||
const pool = { connect: async () => { connections++; return client }, query: () => { assert.fail("Migration SQL escaped onto global pool") } } as unknown as Pool
|
||||
return { pool, trace, releases, connects: () => connections, fail: (text: string) => { fail = text }, badUnlock: () => { unlock = false } }
|
||||
}
|
||||
const f = fixture("0007_flow_buckets_nat.sql")
|
||||
await applySqlMigrations(f.pool)
|
||||
assert.equal(f.connects(), 1); assert.deepEqual(f.releases, [false])
|
||||
assert.equal(f.trace[0].text, "SET lock_timeout='10s'"); assert.equal(f.trace[1].text, "SET statement_timeout='120s'")
|
||||
assert.equal(f.trace[2].text, "SELECT pg_advisory_lock_shared($1::bigint)"); assert.deepEqual(f.trace[2].values, [8723110])
|
||||
assert(f.trace[3].text.includes("CREATE TABLE IF NOT EXISTS schema_migrations"))
|
||||
const migration = f.trace.findIndex(row => row.text.includes("ADD COLUMN IF NOT EXISTS nat_src"))
|
||||
const marker = f.trace.findIndex(row => row.values[0] === "0007_flow_buckets_nat")
|
||||
assert(migration > 2 && marker > migration)
|
||||
assert(f.trace.at(-3)?.text.includes("pg_advisory_unlock_shared")); assert.equal(f.trace.at(-2)?.text, "RESET lock_timeout"); assert.equal(f.trace.at(-1)?.text, "RESET statement_timeout")
|
||||
|
||||
for (const failure of ["pg_advisory_lock_shared", "ADD COLUMN IF NOT EXISTS nat_src", "INSERT INTO schema_migrations"]) {
|
||||
const f = fixture("0007_flow_buckets_nat.sql"); f.fail(failure)
|
||||
await assert.rejects(applySqlMigrations(f.pool), error => error instanceof PartitionWriteError && error.outcomeUnknown === (failure !== "pg_advisory_lock_shared"))
|
||||
assert.deepEqual(f.releases, [true]); assert.equal(f.trace.filter(row => row.text.includes(failure)).length, 1)
|
||||
assert(!f.trace.some(row => row.text === "ROLLBACK" || row.text.includes("pg_advisory_unlock_shared")))
|
||||
}
|
||||
const badUnlock = fixture(); badUnlock.badUnlock()
|
||||
await assert.rejects(applySqlMigrations(badUnlock.pool), (error: unknown) => error instanceof PartitionWriteError && error.outcomeUnknown)
|
||||
assert.deepEqual(badUnlock.releases, [true])
|
||||
console.log("Migration partition admission: 5 inert exact-backend/session and no-replay scenarios passed")
|
||||
@@ -0,0 +1,78 @@
|
||||
import { readdirSync, readFileSync } from "node:fs"
|
||||
import { dirname, join } from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import type { Pool } from "pg"
|
||||
import { PARTITION_WRITER_ADVISORY_KEY, PartitionWriteError } from "../services/partition-write-transaction.js"
|
||||
|
||||
const FIRST_MIGRATION = "0000_postgresql.sql"
|
||||
|
||||
function migrationsDir(): string {
|
||||
const here = dirname(fileURLToPath(import.meta.url))
|
||||
const candidates = [
|
||||
join(here, "..", "..", "drizzle"),
|
||||
join(process.cwd(), "drizzle"),
|
||||
join(process.cwd(), "backend", "drizzle"),
|
||||
]
|
||||
for (const dir of candidates) {
|
||||
try {
|
||||
readFileSync(join(dir, FIRST_MIGRATION), "utf8")
|
||||
return dir
|
||||
} catch {
|
||||
/* try next */
|
||||
}
|
||||
}
|
||||
throw new Error("Не найден backend/drizzle/0000_postgresql.sql")
|
||||
}
|
||||
|
||||
function migrationId(file: string): string {
|
||||
return file.replace(/\.sql$/i, "")
|
||||
}
|
||||
|
||||
export async function applySqlMigrations(pool: Pool): Promise<void> {
|
||||
// Migration files can own BEGIN/COMMIT themselves. A session shared lease
|
||||
// therefore spans every file on THIS checked-out backend, including schema
|
||||
// discovery and marker writes; a lock on another pool backend is insufficient.
|
||||
const client = await pool.connect()
|
||||
let released = false, targetSqlSent = false
|
||||
try {
|
||||
await client.query("SET lock_timeout='10s'")
|
||||
await client.query("SET statement_timeout='120s'")
|
||||
await client.query("SELECT pg_advisory_lock_shared($1::bigint)", [PARTITION_WRITER_ADVISORY_KEY])
|
||||
targetSqlSent = true
|
||||
await client.query(`
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
id TEXT PRIMARY KEY,
|
||||
applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
)
|
||||
`)
|
||||
const dir = migrationsDir()
|
||||
const files = readdirSync(dir)
|
||||
.filter((f) => /^\d{4}_.+\.sql$/i.test(f))
|
||||
.sort((a, b) => a.localeCompare(b))
|
||||
const applied = new Set(
|
||||
(await client.query<{ id: string }>(`SELECT id FROM schema_migrations`)).rows.map((r) => r.id),
|
||||
)
|
||||
for (const file of files) {
|
||||
const id = migrationId(file)
|
||||
if (applied.has(id)) continue
|
||||
const sql = readFileSync(join(dir, file), "utf8")
|
||||
await client.query(sql)
|
||||
await client.query(
|
||||
`INSERT INTO schema_migrations (id) VALUES ($1) ON CONFLICT (id) DO NOTHING`,
|
||||
[id],
|
||||
)
|
||||
}
|
||||
const unlocked = await client.query<{ unlocked: boolean }>("SELECT pg_advisory_unlock_shared($1::bigint) AS unlocked", [PARTITION_WRITER_ADVISORY_KEY])
|
||||
if (unlocked.rows[0]?.unlocked !== true) throw new PartitionWriteError(targetSqlSent)
|
||||
await client.query("RESET lock_timeout")
|
||||
await client.query("RESET statement_timeout")
|
||||
released = true; client.release(false)
|
||||
} catch {
|
||||
// A whole migration query may include several committed statements. Never
|
||||
// send ROLLBACK or automatically repeat files after an uncertain boundary.
|
||||
if (!released) { released = true; client.release(true) }
|
||||
throw new PartitionWriteError(targetSqlSent)
|
||||
} finally {
|
||||
if (!released) client.release(true)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { randomUUID } from "node:crypto"
|
||||
import { execFile } from "node:child_process"
|
||||
import { promisify } from "node:util"
|
||||
import { basename, dirname, join, resolve } from "node:path"
|
||||
import { writeFile, unlink, mkdir } from "node:fs/promises"
|
||||
import type { SchemaInventoryClient } from "./migration-schema-inventory.js"
|
||||
|
||||
assert.equal(process.env.MMAPP_OWN_MIGRATION_DATA_INVENTORY, "true")
|
||||
const databaseUrl = new URL(process.env.DATABASE_URL ?? "")
|
||||
assert.equal(databaseUrl.hostname, "127.0.0.1"); assert.equal(databaseUrl.pathname, "/postgres")
|
||||
assert.equal(databaseUrl.username,"mcp_test"); assert(databaseUrl.port&&databaseUrl.port!=="5432"); assert.equal(process.env.NODE_ENV,"test")
|
||||
assert(process.env.MCP_STATE_DIR && basename(dirname(resolve(process.env.MCP_STATE_DIR))).startsWith("mm-mcp-test-"))
|
||||
const { pool, closePool } = await import("./index.js")
|
||||
const { Pool } = await import("pg")
|
||||
const { applySqlMigrations } = await import("./migrate.js")
|
||||
const { captureMigrationData, compareMigrationData } = await import("./migration-data-inventory.js")
|
||||
const execute = promisify(execFile), cases: string[] = []
|
||||
let restored: InstanceType<typeof Pool> | undefined
|
||||
const restoredName = "mm_data_restore_" + randomUUID().replaceAll("-", "")
|
||||
assert(/^mm_data_restore_[a-f0-9]{32}$/.test(restoredName))
|
||||
const archive = join(resolve(process.env.MCP_STATE_DIR!), "owned-migration-data.dump")
|
||||
async function capture(connection = pool, options: Parameters<typeof captureMigrationData>[1] = {}, intercept?: (client: SchemaInventoryClient) => SchemaInventoryClient) {
|
||||
const client = await connection.connect()
|
||||
try {
|
||||
await client.query("BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY")
|
||||
for (const query 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='5s'", "SET LOCAL row_security=off", "SET LOCAL extra_float_digits=3", "SET LOCAL bytea_output=hex", "SET LOCAL IntervalStyle=postgres", "SET LOCAL lc_monetary='C'", "SET LOCAL lc_numeric='C'"]) await client.query(query)
|
||||
return await captureMigrationData(intercept ? intercept(client) : client, options)
|
||||
} finally { await client.query("ROLLBACK"); client.release(true) }
|
||||
}
|
||||
try {
|
||||
await applySqlMigrations(pool)
|
||||
// Plain pg_dump/pg_restore does not copy the source database's own comment.
|
||||
// Match the independently-created target profile before comparing schemas.
|
||||
await pool.query("COMMENT ON DATABASE postgres IS NULL")
|
||||
await pool.query("UPDATE traffic_settings SET enabled=false; UPDATE uptime_settings SET enabled=false,resources_enabled=false,ping_enabled=false,speed_enabled=false")
|
||||
await pool.query("CREATE TABLE mm_data_probe(id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,value text NOT NULL,payload jsonb,amount numeric,at timestamptz)")
|
||||
await pool.query("INSERT INTO mm_data_probe(value,payload,amount,at) VALUES('OWNED_RAW_VALUE_MUST_NOT_ESCAPE','{\"a\":2,\"b\":[1,true]}',123.4500,'2026-10-25T12:34:56+07:00')")
|
||||
await pool.query("ALTER TABLE mm_data_probe ADD COLUMN flag integer NOT NULL DEFAULT 7")
|
||||
await pool.query("CREATE TABLE mm_data_duplicates(value text); INSERT INTO mm_data_duplicates VALUES('same'),('same'),('different')")
|
||||
await pool.query("CREATE TABLE mm_data_arrays(value text[]); INSERT INTO mm_data_arrays VALUES('[0:1]={left,right}'::text[])")
|
||||
await pool.query("CREATE TABLE mm_data_partitioned(k integer,value text) PARTITION BY RANGE(k); CREATE TABLE mm_data_partition_a PARTITION OF mm_data_partitioned FOR VALUES FROM(0) TO(10); CREATE TABLE mm_data_partition_b PARTITION OF mm_data_partitioned FOR VALUES FROM(10) TO(20); INSERT INTO mm_data_partitioned VALUES(1,'a'),(11,'b')")
|
||||
await pool.query("CREATE MATERIALIZED VIEW mm_data_cached AS SELECT value FROM mm_data_duplicates")
|
||||
await pool.query("CREATE VIEW mm_data_never_execute AS SELECT 1/0 AS forbidden")
|
||||
await assert.rejects(pool.query("SELECT * FROM mm_data_never_execute"))
|
||||
const baseline = await capture()
|
||||
assert.equal(baseline.tables.find(row => row.name === "mm_data_partitioned")!.rowCount, 0)
|
||||
assert.equal(baseline.tables.find(row => row.name === "mm_data_partition_a")!.rowCount, 1)
|
||||
assert.equal(baseline.tables.find(row => row.name === "mm_data_partition_b")!.rowCount, 1)
|
||||
assert.equal(baseline.tables.find(row => row.name === "mm_data_cached")!.rowCount, 3)
|
||||
assert(!baseline.tables.some(row => row.name === "mm_data_never_execute"))
|
||||
assert(!JSON.stringify(baseline).includes("OWNED_RAW_VALUE_MUST_NOT_ESCAPE"))
|
||||
assert.equal(baseline.ready, false); assert.equal(baseline.allReplicaQuiescence, false)
|
||||
cases.push("actual PG18 captures private row digests, ONLY physical partition rows and materialized data without executing views or exposing values")
|
||||
await pool.query("DELETE FROM mm_data_duplicates; INSERT INTO mm_data_duplicates VALUES('different'),('same'),('same')")
|
||||
assert.equal(compareMigrationData(baseline, await capture()).equal, true)
|
||||
cases.push("actual row insertion order does not change multiset evidence; duplicate multiplicity is retained")
|
||||
await pool.query("DELETE FROM mm_data_duplicates WHERE ctid=(SELECT ctid FROM mm_data_duplicates WHERE value='same' LIMIT 1)")
|
||||
await assert.rejects(async () => compareMigrationData(baseline, await capture()))
|
||||
await pool.query("INSERT INTO mm_data_duplicates VALUES('same')")
|
||||
cases.push("actual loss of one duplicate changes content evidence despite unchanged logical schema")
|
||||
await pool.query("UPDATE mm_data_arrays SET value=ARRAY['left','right']")
|
||||
assert.equal((await pool.query("SELECT to_jsonb('[0:1]={left,right}'::text[])=to_jsonb(ARRAY['left','right']) AS same")).rows[0].same, true)
|
||||
await assert.rejects(async () => compareMigrationData(baseline, await capture()))
|
||||
await pool.query("UPDATE mm_data_arrays SET value='[0:1]={left,right}'::text[]")
|
||||
cases.push("native array lower bounds remain distinct even when JSONB projections are equal")
|
||||
await assert.rejects(capture(pool, { maxRows: 1 }))
|
||||
await assert.rejects(capture(pool, { maxBytes: 1 }))
|
||||
cases.push("actual row and byte overflow refuse a complete snapshot instead of truncating")
|
||||
await pool.query("INSERT INTO mm_data_duplicates VALUES(repeat('Z',1048577))")
|
||||
await assert.rejects(capture(), error => error instanceof Error && !error.message.includes("ZZZZZ"))
|
||||
await pool.query("DELETE FROM mm_data_duplicates WHERE length(value)>1048576")
|
||||
cases.push("actual oversized row refuses without reflecting its contents")
|
||||
const sequence = baseline.sequences.find(row => row.name === "mm_data_probe_id_seq")!
|
||||
await pool.query("SELECT setval('mm_data_probe_id_seq',$1,true)", [String(BigInt(sequence.lastValue) + 1n)])
|
||||
await assert.rejects(async () => compareMigrationData(baseline, await capture()))
|
||||
await pool.query("SELECT setval('mm_data_probe_id_seq',$1,$2)", [sequence.lastValue, sequence.isCalled])
|
||||
cases.push("actual sequence state participates in equivalence independently of table contents")
|
||||
let wrote = false
|
||||
const during = await capture(pool, {}, client => ({ async query(text, values) {
|
||||
const result = await client.query(text, values)
|
||||
if (!wrote && text.includes("migration-data:declare")) { wrote = true; await pool.query("INSERT INTO mm_data_duplicates VALUES('concurrent-outside-snapshot')") }
|
||||
return result
|
||||
} }))
|
||||
assert(wrote); assert.equal(compareMigrationData(baseline, during).equal, true)
|
||||
await assert.rejects(async () => compareMigrationData(baseline, await capture()))
|
||||
await pool.query("DELETE FROM mm_data_duplicates WHERE value='concurrent-outside-snapshot'")
|
||||
cases.push("actual repeatable-read rows exclude a concurrent committed insert while the next capture observes it; no quiescence grant")
|
||||
let advanced = false
|
||||
await assert.rejects(capture(pool, {}, client => ({ async query(text, values) {
|
||||
const result = await client.query(text, values)
|
||||
if (!advanced && text.includes("migration-data:sequence") && text.includes('"mm_data_probe_id_seq"')) { advanced = true; await pool.query("SELECT setval('mm_data_probe_id_seq',$1,true)", [String(BigInt(sequence.lastValue) + 10n)]) }
|
||||
return result
|
||||
} })))
|
||||
assert(advanced)
|
||||
await pool.query("SELECT setval('mm_data_probe_id_seq',$1,$2)", [sequence.lastValue, sequence.isCalled])
|
||||
cases.push("actual nontransactional sequence change between stability reads refuses complete evidence")
|
||||
await assert.rejects(capture(pool, {}, client => ({ async query(text, values) {
|
||||
if (text.includes("migration-data:context")) await client.query("SET LOCAL row_security=on")
|
||||
return client.query(text, values)
|
||||
} })))
|
||||
cases.push("actual unsafe row-security GUC is refused before private row capture")
|
||||
const beforeRestore = await capture()
|
||||
const pgEnv = { ...process.env, PGPASSWORD: decodeURIComponent(databaseUrl.password) }
|
||||
const args = ["--host", databaseUrl.hostname, "--port", databaseUrl.port, "--username", decodeURIComponent(databaseUrl.username), "--no-password"]
|
||||
const dumped = await execute("pg_dump", [...args, "--format=custom", "--dbname=postgres"], { env: pgEnv, encoding: "buffer", maxBuffer: 16 * 1048576 })
|
||||
await mkdir(dirname(archive), { recursive: true }); await writeFile(archive, dumped.stdout)
|
||||
await pool.query('CREATE DATABASE "' + restoredName + '" TEMPLATE template0')
|
||||
await execute("pg_restore", [...args, "--single-transaction", "--exit-on-error", "--dbname=" + restoredName, archive], { env: pgEnv, maxBuffer: 1048576 })
|
||||
const cloneUrl = new URL(databaseUrl); cloneUrl.pathname = "/" + restoredName
|
||||
restored = new Pool({ connectionString: cloneUrl.toString(), max: 2 })
|
||||
const afterRestore = await capture(restored)
|
||||
assert.equal(compareMigrationData(beforeRestore, afterRestore).equal, true)
|
||||
assert.notEqual(JSON.stringify(beforeRestore.schema.observedPhysical), JSON.stringify(afterRestore.schema.observedPhysical))
|
||||
cases.push("actual pg_dump/pg_restore preserves full logical data and sequence evidence despite fast-default physical-cache differences")
|
||||
await restored.query("CREATE SCHEMA mmapp_control; CREATE TABLE mmapp_control.owned_data_probe(value text); INSERT INTO mmapp_control.owned_data_probe VALUES('OWNED_CONTROL_VALUE_NOT_APPLICATION_DATA')")
|
||||
const scope = { applicationNamespaces: ["public"], excludedControlNamespaces: ["mmapp_control"] }
|
||||
const controlBefore = await capture(restored, { scope })
|
||||
await restored.query("UPDATE mmapp_control.owned_data_probe SET value='CONTROL_CHANGED_OUTSIDE_APPLICATION'")
|
||||
const controlAfter = await capture(restored, { scope })
|
||||
assert.equal(compareMigrationData(controlBefore, controlAfter).equal, true)
|
||||
assert(!controlAfter.tables.some(row => row.namespace === "mmapp_control"))
|
||||
assert(!JSON.stringify(controlBefore).includes("OWNED_CONTROL_VALUE_NOT_APPLICATION_DATA"))
|
||||
cases.push("actual external control table values remain excluded from application data evidence")
|
||||
const altered = structuredClone(afterRestore); altered.tables[0].rowCount++
|
||||
assert.throws(() => compareMigrationData(beforeRestore, altered))
|
||||
cases.push("altered evidence vector is refused; unsigned content equality never grants enrollment or READY")
|
||||
const layout = await (await import("./migration-schema-restore-layout.sql-helper.js")).acceptMigrationSchemaRestoreLayoutSql()
|
||||
cases.push(...layout.names)
|
||||
console.log("Owned migration data inventory SQL:", JSON.stringify({ cases: cases.length, names: cases, actualSql: true, actualDumpRestoreDataEquivalent: true, nativeCalls: 0, readyAuthorized: false, allReplicaQuiescence: false, productionDatabaseTouched: false }))
|
||||
} finally {
|
||||
if (restored) { await restored.end(); restored = undefined }
|
||||
const owned = (await pool.query("SELECT datname,pg_get_userbyid(datdba)=current_user AS owned FROM pg_database WHERE datname=$1", [restoredName])).rows
|
||||
if (owned.length) { assert.equal(owned.length, 1); assert.equal(owned[0].owned, true); assert.equal(owned[0].datname, restoredName); await pool.query('DROP DATABASE "' + restoredName + '"') }
|
||||
await unlink(archive).catch(error => { if ((error as { code?: string }).code !== "ENOENT") throw error })
|
||||
await closePool()
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { createHash } from "node:crypto"
|
||||
import { readFileSync } from "node:fs"
|
||||
import { captureMigrationData, compareMigrationData, MIGRATION_DATA_CONTEXT_QUERY, MigrationDataInventoryError, type CapturedMigrationData } from "./migration-data-inventory.js"
|
||||
import { MIGRATION_SCHEMA_QUERIES, type SchemaSection } from "./migration-schema-inventory.js"
|
||||
|
||||
type Row = Record<string, unknown>
|
||||
const schemaMetadata = { backend_pid: "71", transaction_started: "2026-10-25 10:00:00+00", read_only: "on", isolation: "repeatable read", search_path: "pg_catalog", timezone: "UTC", date_style: "ISO, YMD", standard_strings: "on", server_version: "180000", statement_timeout: "5s" }
|
||||
const metadata = { ...schemaMetadata, row_security: "off", extra_float_digits: "3", bytea_output: "hex", interval_style: "postgres", client_encoding: "UTF8", lc_monetary: "C", lc_numeric: "C" }
|
||||
const relation = (name: string, kind = "r", extra: Row = {}) => ({ namespace: "public", name, kind, persistence: "p", owner: "postgres", acl: null, options: null, comment: null, access_method: ["r", "p", "m"].includes(kind) ? "heap" : null, tablespace: null, toast_options: null, of_type: null, row_security: false, force_row_security: false, replica_identity: "d", is_partition: false, populated: true, partition_key: kind === "p" ? "RANGE (id)" : null, partition_bound: null, ...extra })
|
||||
function schemaFixture() {
|
||||
const data = Object.fromEntries(Object.keys(MIGRATION_SCHEMA_QUERIES).map(key => [key, []])) as unknown as Record<SchemaSection, Row[]>
|
||||
data.database = [{ encoding: "UTF8", collate: "C", ctype: "C", locale_provider: "c", locale: null, locale_rules: null, collation_version: null, default_tablespace: "pg_default", owner: "postgres", acl: null, comment: null }]
|
||||
data.namespaces = [{ namespace: "public", owner: "postgres", acl: null, comment: null }]
|
||||
data.relations = [relation("servers"), relation("parent", "p"), relation("child", "r", { is_partition: true, partition_bound: "FOR VALUES FROM (0) TO (10)" }), relation("cached", "m"), relation("never_execute", "v"), relation("ids", "S")]
|
||||
data.views = ["cached", "never_execute"].map(name => ({ namespace: "public", name, kind: name === "cached" ? "m" : "v", definition: "SELECT 1" }))
|
||||
data.inheritance = [{ namespace: "public", child: "child", parent_namespace: "public", parent: "parent", position: 1, detach_pending: false }]
|
||||
data.sequences = [{ namespace: "public", name: "ids", type: "bigint", start: "1", increment: "1", maximum: "9223372036854775807", minimum: "1", cache: "1", cycle: false, owner_namespace: null, owner_relation: null, owner_column: null, owner_dependency: null }]
|
||||
return data
|
||||
}
|
||||
interface FakeOptions {
|
||||
rows?: Record<string, string[]>
|
||||
metadata?: Row
|
||||
mutate?: (row: Row, kind: string, index: number) => void
|
||||
sequence?: (read: number) => { last_value: string; is_called: boolean }
|
||||
queryError?: string
|
||||
preserveOrder?: boolean
|
||||
}
|
||||
function fake(options: FakeOptions = {}) {
|
||||
const calls: string[] = [], data = schemaFixture()
|
||||
let cursor: string | undefined, offset = 0, reads = 0, rowIndex = 0
|
||||
const envelope = (kind: string, fields: Row = {}) => {
|
||||
const row = { ...metadata, ...options.metadata, ...fields }
|
||||
options.mutate?.(row, kind, rowIndex++)
|
||||
return row
|
||||
}
|
||||
return { calls, data, query: async (sql: string, values: unknown[] = []) => {
|
||||
calls.push(sql)
|
||||
const section = (Object.keys(MIGRATION_SCHEMA_QUERIES) as SchemaSection[]).find(section => MIGRATION_SCHEMA_QUERIES[section] === sql)
|
||||
if (section) {
|
||||
assert.deepEqual(values, ["database", "namespaces"].includes(section) ? [] : [["public"]])
|
||||
return { rows: [{ ...schemaMetadata, objects: structuredClone(data[section]) }] }
|
||||
}
|
||||
assert.deepEqual(values, [])
|
||||
if (sql === MIGRATION_DATA_CONTEXT_QUERY) return { rows: [envelope("context")] }
|
||||
if (sql.startsWith("/* migration-data:sequence */")) return { rows: [envelope("sequence", options.sequence?.(++reads) ?? { last_value: "42", is_called: true })] }
|
||||
if (sql.startsWith("/* migration-data:declare */")) {
|
||||
assert.equal(cursor, undefined)
|
||||
const match = /FROM ONLY "public"\."([^"]+)" AS t/.exec(sql)
|
||||
assert(match); assert.notEqual(match[1], "never_execute")
|
||||
assert(sql.includes('ORDER BY row_text COLLATE pg_catalog."C"'))
|
||||
assert(sql.includes("octet_length(row_text)<=1048576"))
|
||||
assert(sql.includes("pg_catalog.jsonb_build_array(pg_catalog.to_jsonb(t),pg_catalog.record_out(t)::text)::text AS row_text"))
|
||||
cursor = match[1]; offset = 0
|
||||
return { rows: [] }
|
||||
}
|
||||
if (sql === "/* migration-data:fetch */ FETCH FORWARD 16 FROM mm_migration_data_inventory") {
|
||||
assert(cursor)
|
||||
if (options.queryError) throw new Error(options.queryError)
|
||||
const raw = [...(options.rows?.[cursor] ?? [])]
|
||||
if (!options.preserveOrder) raw.sort((a, b) => Buffer.compare(Buffer.from(a), Buffer.from(b)))
|
||||
const batch = raw.slice(offset, offset + 16); offset += batch.length
|
||||
return { rows: batch.map(text => envelope("row", { row_text: Buffer.byteLength(text) <= 1048576 ? text : null, row_bytes: String(Buffer.byteLength(text)) })) }
|
||||
}
|
||||
assert.equal(sql, "/* migration-data:close */ CLOSE mm_migration_data_inventory")
|
||||
assert(cursor); cursor = undefined
|
||||
return { rows: [] }
|
||||
} }
|
||||
}
|
||||
let cases = 0
|
||||
async function test(name: string, work: () => unknown | Promise<unknown>) { await work(); cases++; console.log("ok", name) }
|
||||
const captured = await captureMigrationData(fake({ rows: { servers: ['{"id": 1}', '{"id": 1}', '{"id": 2}'], child: ['{"id": 3}'], cached: ['{"id": 4}'] } }))
|
||||
const clone = () => structuredClone(captured)
|
||||
await test("actual catalog relations drive bounded physical-only cursor capture", () => {
|
||||
assert.equal(captured.rowCount, 5)
|
||||
assert.deepEqual(captured.tables.map(table => table.name), ["cached", "child", "parent", "servers"])
|
||||
assert.equal(captured.tables.find(table => table.name === "parent")?.rowCount, 0)
|
||||
assert.deepEqual(captured.sequences, [{ namespace: "public", name: "ids", lastValue: "42", isCalled: true }])
|
||||
assert(!captured.canonical.includes('"id": 1'))
|
||||
assert(Object.isFrozen(captured)); assert(Object.isFrozen(captured.tables)); assert(Object.isFrozen(captured.schema.sections.relations[0]))
|
||||
assert.equal(captured.ready, false); assert.equal(captured.allReplicaQuiescence, false)
|
||||
assert.deepEqual(compareMigrationData(captured, clone()), { equal: true, evidenceOnly: true, enrollmentAuthorized: false, allReplicaQuiescence: false, ready: false, sha256: captured.sha256 })
|
||||
})
|
||||
await test("ordering is stable and duplicate rows affect the multiset digest", async () => {
|
||||
const original = await captureMigrationData(fake({ rows: { servers: ['{"id": 1}', '{"id": 1}', '{"id": 2}'] } }))
|
||||
const reordered = await captureMigrationData(fake({ rows: { servers: ['{"id": 2}', '{"id": 1}', '{"id": 1}'] } }))
|
||||
assert.equal(original.sha256, reordered.sha256)
|
||||
for (const rows of [['{"id": 1}', '{"id": 2}'], ['{"id": 1}', '{"id": 2}', '{"id": 2}']]) {
|
||||
const different = await captureMigrationData(fake({ rows: { servers: rows } }))
|
||||
assert.throws(() => compareMigrationData(original, different))
|
||||
}
|
||||
})
|
||||
await test("v2 native-record supplement distinguishes identical JSONB with different array bounds", async () => {
|
||||
// PostgreSQL renders these native arrays differently while to_jsonb yields
|
||||
// the same ["a","b"]. The SQL acceptance owner verifies real PG output.
|
||||
const logical = { values: ["a", "b"] }
|
||||
const zeroBased = JSON.stringify([logical, '("[0:1]={a,b}")'])
|
||||
const oneBased = JSON.stringify([logical, '("{a,b}")'])
|
||||
assert.deepEqual(JSON.parse(zeroBased)[0], JSON.parse(oneBased)[0])
|
||||
const zero = await captureMigrationData(fake({ rows: { servers: [zeroBased] } }))
|
||||
const one = await captureMigrationData(fake({ rows: { servers: [oneBased] } }))
|
||||
assert.equal(zero.format, "pg18-application-data-v2")
|
||||
assert.notEqual(zero.tables.find(table => table.name === "servers")!.sha256, one.tables.find(table => table.name === "servers")!.sha256)
|
||||
assert.throws(() => compareMigrationData(zero, one))
|
||||
})
|
||||
await test("batches fetch to exhaustion beyond sixteen rows without truncation", async () => {
|
||||
const client = fake({ rows: { servers: Array.from({ length: 33 }, (_, id) => JSON.stringify({ id })) } })
|
||||
const result = await captureMigrationData(client)
|
||||
assert.equal(result.rowCount, 33)
|
||||
assert.equal(client.calls.filter(sql => sql.includes("migration-data:fetch")).length, 7)
|
||||
assert.equal(client.calls.filter(sql => sql.includes("migration-data:declare")).length, 4)
|
||||
assert.equal(client.calls.filter(sql => sql.includes("migration-data:close")).length, 4)
|
||||
})
|
||||
await test("bounds are fail closed and cleanup closes only the owned cursor", async () => {
|
||||
for (const options of [{ maxRows: 1 }, { maxBytes: 3 }]) {
|
||||
const client = fake({ rows: { servers: ['{"id": 1}', '{"id": 2}'] } })
|
||||
await assert.rejects(() => captureMigrationData(client, options), MigrationDataInventoryError)
|
||||
assert.equal(client.calls.filter(sql => sql.includes("migration-data:close")).length, 4)
|
||||
}
|
||||
const client = fake({ rows: { servers: ['"' + "x".repeat(1048576) + '"'] } })
|
||||
await assert.rejects(() => captureMigrationData(client), MigrationDataInventoryError)
|
||||
assert(client.calls.at(-1)?.includes("migration-data:close"))
|
||||
})
|
||||
await test("invalid trusted bounds are rejected before any client query", async () => {
|
||||
for (const options of [{ maxRows: 0 }, { maxRows: 10000001 }, { maxBytes: 1073741825 }, { maxBytes: 0 }, { deadlineAt: Date.now() - 1 }, { deadlineAt: Date.now() + 121000 }]) {
|
||||
const client = fake(); await assert.rejects(() => captureMigrationData(client, options), MigrationDataInventoryError); assert.equal(client.calls.length, 0)
|
||||
}
|
||||
})
|
||||
await test("unsafe GUCs, backend/transaction drift and per-row drift are refused", async () => {
|
||||
for (const [key, value] of Object.entries({ row_security: "on", extra_float_digits: "0", bytea_output: "escape", interval_style: "iso_8601", client_encoding: "LATIN1", lc_monetary: "en_US.UTF-8", lc_numeric: "de_DE.UTF-8", read_only: "off", isolation: "read committed", timezone: "Europe/Moscow" })) {
|
||||
await assert.rejects(() => captureMigrationData(fake({ metadata: { [key]: value } })), MigrationDataInventoryError)
|
||||
}
|
||||
for (const [key, value] of Object.entries({ backend_pid: "72", transaction_started: "2026-10-25 10:01:00+00", extra_float_digits: "2" })) {
|
||||
await assert.rejects(() => captureMigrationData(fake({ rows: { servers: ['{"id": 1}'] }, mutate: (row, kind) => { if (kind === "row") row[key] = value } })), MigrationDataInventoryError)
|
||||
}
|
||||
})
|
||||
await test("non-MVCC sequence instability and untrusted raw-error leakage are refused", async () => {
|
||||
await assert.rejects(() => captureMigrationData(fake({ sequence: read => ({ last_value: String(read), is_called: true }) })), MigrationDataInventoryError)
|
||||
for (const last_value of ["9223372036854775808", "-9223372036854775809", "-0", "00"]) await assert.rejects(() => captureMigrationData(fake({ sequence: () => ({ last_value, is_called: true }) })), MigrationDataInventoryError)
|
||||
const privateValue = "PRIVATE CUSTOMER RECORD"
|
||||
try { await captureMigrationData(fake({ queryError: privateValue })); assert.fail("must fail") }
|
||||
catch (error) { assert(error instanceof MigrationDataInventoryError); assert(!String(error).includes(privateValue)); assert.equal(error.cause, undefined) }
|
||||
})
|
||||
await test("catalog identity ambiguity and sequence definitions without relations are refused", async () => {
|
||||
const duplicate = fake(); duplicate.data.relations.push({ ...duplicate.data.relations[0], owner: "another" })
|
||||
await assert.rejects(() => captureMigrationData(duplicate), MigrationDataInventoryError)
|
||||
const extra = fake(); extra.data.sequences.push({ ...extra.data.sequences[0], name: "orphan" })
|
||||
await assert.rejects(() => captureMigrationData(extra), MigrationDataInventoryError)
|
||||
})
|
||||
await test("cursor owner is not closed after connection ownership changes", async () => {
|
||||
let declared = false
|
||||
const base = fake({ rows: { servers: ['{"id": 1}'] }, mutate: (row, kind) => { if (declared && (kind === "row" || kind === "context")) row.backend_pid = "999" } })
|
||||
const client = { query: async (sql: string, values?: unknown[]) => {
|
||||
const result = await base.query(sql, values)
|
||||
if (sql.includes("migration-data:declare")) declared = true
|
||||
return result
|
||||
} }
|
||||
await assert.rejects(() => captureMigrationData(client), MigrationDataInventoryError)
|
||||
assert.equal(base.calls.filter(sql => sql.includes("migration-data:close")).length, 0)
|
||||
})
|
||||
await test("concurrent capture cannot take over another capture cursor", async () => {
|
||||
let release!: () => void, entered!: () => void
|
||||
const waiting = new Promise<void>(resolve => { release = resolve }), arrived = new Promise<void>(resolve => { entered = resolve })
|
||||
const base = fake(); let first = true
|
||||
const client = { query: async (sql: string, values?: unknown[]) => {
|
||||
if (first) { first = false; entered(); await waiting }
|
||||
return base.query(sql, values)
|
||||
} }
|
||||
const capture = captureMigrationData(client); await arrived
|
||||
await assert.rejects(() => captureMigrationData(client), MigrationDataInventoryError)
|
||||
release(); await capture
|
||||
await captureMigrationData(client)
|
||||
})
|
||||
await test("out-of-order rows, mismatched byte envelopes and extra fields fail closed", async () => {
|
||||
await assert.rejects(() => captureMigrationData(fake({ rows: { servers: ['{"id": 2}', '{"id": 1}'] }, preserveOrder: true })), MigrationDataInventoryError)
|
||||
for (const mutate of [(row: Row) => { row.row_bytes = "0" }, (row: Row) => { row.surprise = true }]) {
|
||||
await assert.rejects(() => captureMigrationData(fake({ rows: { servers: ['{"id": 1}'] }, mutate: (row, kind) => { if (kind === "row") mutate(row) } })), MigrationDataInventoryError)
|
||||
}
|
||||
})
|
||||
await test("comparison rejects missing/extra/duplicate tables, sequences and tampered hashes/counts/schema", () => {
|
||||
const mutations: ((value: CapturedMigrationData) => void)[] = [
|
||||
value => { value.tables.pop() }, value => { value.tables.push(value.tables[0]) }, value => { value.tables[1] = value.tables[0] },
|
||||
value => { value.sequences = [] }, value => { value.sequences.push(value.sequences[0]) }, value => { value.sequences[0].lastValue = "43" },
|
||||
value => { value.tables[0].sha256 = "a".repeat(64) }, value => { value.tables[0].rowCount++ }, value => { value.rowCount++ },
|
||||
value => { value.tables[0].byteCount++ }, value => { value.sha256 = "a".repeat(64) }, value => { value.schemaSha256 = "a".repeat(64) },
|
||||
value => { value.schema.sections.relations[0].owner = "another" }, value => { value.schema.observedPhysical.sha256 = "a".repeat(64) },
|
||||
value => { Reflect.set(value, "authority", true) }, value => { Reflect.set(value, "ready", true) },
|
||||
]
|
||||
for (const mutate of mutations) { const value = clone(); mutate(value); assert.throws(() => compareMigrationData(captured, value)) }
|
||||
})
|
||||
await test("comparison recomputes an envelope rather than trusting a supplied matching hash", () => {
|
||||
const value = clone(); value.tables[0].sha256 = "a".repeat(64); value.canonical = captured.canonical; value.sha256 = captured.sha256
|
||||
assert.throws(() => compareMigrationData(value, value))
|
||||
const empty = clone(); const table = empty.tables.find(table => table.rowCount === 0)!
|
||||
table.sha256 = createHash("sha256").update("").digest("hex")
|
||||
assert.throws(() => compareMigrationData(empty, empty))
|
||||
})
|
||||
await test("no configured pool, signing, transaction control, mutation or enrollment imports", () => {
|
||||
const source = readFileSync(new URL("./migration-data-inventory.ts", import.meta.url), "utf8")
|
||||
const imports = [...source.matchAll(/from "([^"]+)"/g)].map(match => match[1])
|
||||
assert.deepEqual(imports, ["node:crypto", "./migration-schema-inventory.js"])
|
||||
assert(!/\b(?:INSERT INTO|UPDATE public|DELETE FROM|nextval\(|setval\(|set_config\(|BEGIN TRANSACTION|COMMIT;|ROLLBACK;|createSign\()/.test(source))
|
||||
})
|
||||
console.log(`${cases} migration data inventory tests passed`)
|
||||
@@ -0,0 +1,242 @@
|
||||
import { createHash, type Hash } from "node:crypto"
|
||||
import { captureMigrationSchema, compareMigrationSchemas, type CapturedMigrationSchema, type SchemaInventoryClient, type SchemaInventoryScope } from "./migration-schema-inventory.js"
|
||||
|
||||
/** Independent read-only, unsigned evidence. The caller owns the checked-out
|
||||
* PG18 transaction and its rollback; this module never configures a pool,
|
||||
* changes GUCs, mutates application data, signs or authorizes enrollment. */
|
||||
export class MigrationDataInventoryError extends Error { readonly code = "MIGRATION_DATA_REFUSED" }
|
||||
type Row = Record<string, unknown>
|
||||
type Table = { namespace: string; name: string; kind: "r" | "p" | "m"; rowCount: number; byteCount: number; sha256: string }
|
||||
type Sequence = { namespace: string; name: string; lastValue: string; isCalled: boolean }
|
||||
export interface CapturedMigrationData {
|
||||
format: "pg18-application-data-v2"
|
||||
schema: CapturedMigrationSchema
|
||||
schemaSha256: string
|
||||
tables: Table[]
|
||||
sequences: Sequence[]
|
||||
rowCount: number
|
||||
byteCount: number
|
||||
allReplicaQuiescence: false
|
||||
ready: false
|
||||
canonical: string
|
||||
sha256: string
|
||||
}
|
||||
export interface MigrationDataInventoryOptions { scope?: SchemaInventoryScope; deadlineAt?: number; maxRows?: number; maxBytes?: number }
|
||||
const MAX_ROW_BYTES = 1048576
|
||||
const CURSOR = "mm_migration_data_inventory"
|
||||
const activeClients = new WeakSet<SchemaInventoryClient>()
|
||||
const hash = (text: string) => createHash("sha256").update(text).digest("hex")
|
||||
function fail(message: string): never { throw new MigrationDataInventoryError(message) }
|
||||
function object(value: unknown): value is Row { return !!value && typeof value === "object" && Object.getPrototypeOf(value) === Object.prototype }
|
||||
function keys(value: unknown, expected: string): asserts value is Row {
|
||||
if (!object(value) || JSON.stringify(Object.keys(value).sort()) !== JSON.stringify(expected.split(" ").sort())) fail("Unexpected data evidence shape")
|
||||
}
|
||||
function canonical(value: unknown): string {
|
||||
if (value === null || typeof value === "boolean" || typeof value === "string") return JSON.stringify(value)
|
||||
if (typeof value === "number" && Number.isSafeInteger(value)) return JSON.stringify(value)
|
||||
if (Array.isArray(value)) return "[" + value.map(canonical).join(",") + "]"
|
||||
if (object(value)) return "{" + Object.keys(value).sort().map(key => JSON.stringify(key) + ":" + canonical(value[key])).join(",") + "}"
|
||||
fail("Invalid data evidence value")
|
||||
}
|
||||
function freeze<T>(value: T): T {
|
||||
if (value && typeof value === "object") { for (const child of Object.values(value)) freeze(child); Object.freeze(value) }
|
||||
return value
|
||||
}
|
||||
const quote = (name: string) => '"' + name.replaceAll('"', '""') + '"'
|
||||
const identity = (value: { namespace: string; name: string }) => canonical([value.namespace, value.name])
|
||||
function sequenceValue(value: unknown): value is string {
|
||||
return typeof value === "string" && /^(0|-?[1-9]\d{0,18})$/.test(value) && BigInt(value) >= -9223372036854775808n && BigInt(value) <= 9223372036854775807n
|
||||
}
|
||||
function frame(digest: Hash, text: string) {
|
||||
const bytes = Buffer.from(text, "utf8"), length = Buffer.alloc(8)
|
||||
length.writeBigUInt64BE(BigInt(bytes.length)); digest.update(length); digest.update(bytes)
|
||||
}
|
||||
const metadata = `pg_catalog.pg_backend_pid()::text AS backend_pid,
|
||||
pg_catalog.transaction_timestamp()::text AS transaction_started,
|
||||
pg_catalog.current_setting('transaction_read_only') AS read_only,
|
||||
pg_catalog.current_setting('transaction_isolation') AS isolation,
|
||||
pg_catalog.current_setting('search_path') AS search_path,
|
||||
pg_catalog.current_setting('TimeZone') AS timezone,
|
||||
pg_catalog.current_setting('DateStyle') AS date_style,
|
||||
pg_catalog.current_setting('standard_conforming_strings') AS standard_strings,
|
||||
pg_catalog.current_setting('server_version_num') AS server_version,
|
||||
pg_catalog.current_setting('statement_timeout') AS statement_timeout,
|
||||
pg_catalog.current_setting('row_security') AS row_security,
|
||||
pg_catalog.current_setting('extra_float_digits') AS extra_float_digits,
|
||||
pg_catalog.current_setting('bytea_output') AS bytea_output,
|
||||
pg_catalog.current_setting('IntervalStyle') AS interval_style,
|
||||
pg_catalog.current_setting('client_encoding') AS client_encoding,
|
||||
pg_catalog.current_setting('lc_monetary') AS lc_monetary,
|
||||
pg_catalog.current_setting('lc_numeric') AS lc_numeric`
|
||||
export const MIGRATION_DATA_CONTEXT_QUERY = `/* migration-data:context */ SELECT ${metadata}`
|
||||
const contextKeys = "backend_pid transaction_started read_only isolation search_path timezone date_style standard_strings server_version statement_timeout row_security extra_float_digits bytea_output interval_style client_encoding lc_monetary lc_numeric"
|
||||
const schemaContextKeys = "backend_pid transaction_started read_only isolation search_path timezone date_style standard_strings server_version statement_timeout".split(" ")
|
||||
function context(row: Row, extra = ""): string {
|
||||
keys(row, contextKeys + (extra ? " " + extra : ""))
|
||||
if (typeof row.backend_pid !== "string" || !/^\d+$/.test(row.backend_pid) || typeof row.transaction_started !== "string" || !Number.isFinite(Date.parse(row.transaction_started)) || row.read_only !== "on" || !["repeatable read", "serializable"].includes(String(row.isolation)) || row.search_path !== "pg_catalog" || row.timezone !== "UTC" || row.date_style !== "ISO, YMD" || row.standard_strings !== "on" || typeof row.server_version !== "string" || !/^18\d{4}$/.test(row.server_version) || row.row_security !== "off" || row.extra_float_digits !== "3" || row.bytea_output !== "hex" || row.interval_style !== "postgres" || row.client_encoding !== "UTF8" || row.lc_monetary !== "C" || row.lc_numeric !== "C") fail("PG18 fixed data context and row_security=off required")
|
||||
const timeout = /^(\d+(?:\.\d+)?)(ms|s|min)?$/.exec(String(row.statement_timeout))
|
||||
const milliseconds = timeout ? Number(timeout[1]) * ({ ms: 1, s: 1000, min: 60000 }[timeout[2] ?? "ms"] ?? 0) : 0
|
||||
if (milliseconds < 1 || milliseconds > 10000) fail("Bounded data statement timeout required")
|
||||
return canonical(Object.fromEntries(contextKeys.split(" ").map(key => [key, row[key]])))
|
||||
}
|
||||
function dataRelations(schema: CapturedMigrationSchema) {
|
||||
// Schema validation already refuses foreign/external and unsupported kinds.
|
||||
// Views are definitions only and are never executed for data capture.
|
||||
for (const row of schema.sections.relations) if (!["r", "p", "m", "i", "I", "S", "v", "c"].includes(String(row.kind))) fail("Unsupported data-bearing relation kind")
|
||||
const relations = schema.sections.relations.map(row => ({ namespace: String(row.namespace), name: String(row.name), kind: String(row.kind) }))
|
||||
if (new Set(relations.map(identity)).size !== relations.length) fail("Duplicate captured relation identity")
|
||||
return relations.filter(row => ["r", "p", "m"].includes(row.kind)).map(row => ({ ...row, kind: row.kind as Table["kind"] })).sort((a, b) => identity(a) < identity(b) ? -1 : identity(a) > identity(b) ? 1 : 0)
|
||||
}
|
||||
function sequenceRelations(schema: CapturedMigrationSchema) {
|
||||
const sequences = schema.sections.sequences.map(row => ({ namespace: String(row.namespace), name: String(row.name) })).sort((a, b) => identity(a) < identity(b) ? -1 : identity(a) > identity(b) ? 1 : 0)
|
||||
if (new Set(sequences.map(identity)).size !== sequences.length || sequences.some(sequence => !schema.sections.relations.some(row => row.kind === "S" && row.namespace === sequence.namespace && row.name === sequence.name))) fail("Invalid captured sequence identity")
|
||||
return sequences
|
||||
}
|
||||
function envelope(value: Omit<CapturedMigrationData, "canonical" | "sha256">): string {
|
||||
// Logical schema is content-checked independently. Fast-default physical
|
||||
// caches may differ despite identical logical values; keep them in schema.
|
||||
return canonical({ format: value.format, schemaSha256: value.schemaSha256, tables: value.tables, sequences: value.sequences, rowCount: value.rowCount, byteCount: value.byteCount, allReplicaQuiescence: false, ready: false })
|
||||
}
|
||||
|
||||
/** v2 row text is jsonb_build_array(to_jsonb(t),record_out(t)::text)::text.
|
||||
* The native record supplement preserves array dimensions/lower bounds and
|
||||
* native distinctions that a standalone JSONB conversion can discard. Fixed
|
||||
* numeric/monetary locales also bind native output formatting.
|
||||
* ONLY avoids counting inherited/partition rows twice; C ordering and framed
|
||||
* hashes preserve multiplicity without storing or exposing raw row values.
|
||||
* Sequences are non-MVCC: two stable reads detect some activity, never prove
|
||||
* writer/replica quiescence (including change-and-change-back races). */
|
||||
export async function captureMigrationData(client: SchemaInventoryClient, options: MigrationDataInventoryOptions = {}): Promise<CapturedMigrationData> {
|
||||
const start = Date.now(), deadline = options.deadlineAt ?? start + 60000
|
||||
const maxRows = options.maxRows ?? 1000000, maxBytes = options.maxBytes ?? 134217728
|
||||
if (!Number.isSafeInteger(deadline) || deadline <= start || deadline - start > 120000 || !Number.isSafeInteger(maxRows) || maxRows < 1 || maxRows > 10000000 || !Number.isSafeInteger(maxBytes) || maxBytes < 1 || maxBytes > 1073741824) fail("Invalid data capture bounds")
|
||||
if (activeClients.has(client)) fail("Concurrent data capture on one client refused")
|
||||
activeClients.add(client)
|
||||
let owner = "", baseline: Row = {}, cursorOwned = false
|
||||
const checkDeadline = () => { if (Date.now() >= deadline) fail("Data capture deadline exceeded") }
|
||||
const query = async (sql: string, values: unknown[] = []) => {
|
||||
checkDeadline()
|
||||
let result: Awaited<ReturnType<SchemaInventoryClient["query"]>>
|
||||
try { result = await client.query(sql, values) } catch { fail("Data capture query failed; caller must roll back transaction") }
|
||||
checkDeadline()
|
||||
if (!result || !Array.isArray(result.rows)) fail("Data query result unavailable")
|
||||
return result.rows
|
||||
}
|
||||
const checkContext = async () => {
|
||||
const rows = await query(MIGRATION_DATA_CONTEXT_QUERY)
|
||||
if (rows.length !== 1) fail("Data context unavailable")
|
||||
const current = context(rows[0])
|
||||
if (owner && owner !== current) fail("Data capture changed backend, transaction or context")
|
||||
owner = current; baseline = rows[0]
|
||||
}
|
||||
try {
|
||||
await checkContext()
|
||||
const schema = await captureMigrationSchema({ query: async (sql, values) => {
|
||||
await checkContext()
|
||||
const rows = await query(sql, values)
|
||||
for (const row of rows) for (const key of schemaContextKeys) if (row[key] !== baseline[key]) fail("Schema/data capture context changed")
|
||||
await checkContext()
|
||||
return { rows }
|
||||
} }, { scope: options.scope, deadlineAt: deadline })
|
||||
compareMigrationSchemas(schema, schema)
|
||||
await checkContext()
|
||||
const readSequence = async (sequence: { namespace: string; name: string }): Promise<Sequence> => {
|
||||
const rows = await query(`/* migration-data:sequence */ SELECT ${metadata}, last_value::text AS last_value, is_called FROM ${quote(sequence.namespace)}.${quote(sequence.name)}`)
|
||||
if (rows.length !== 1 || context(rows[0], "last_value is_called") !== owner) fail("Sequence context changed")
|
||||
if (!sequenceValue(rows[0].last_value) || typeof rows[0].is_called !== "boolean") fail("Invalid sequence state")
|
||||
return { ...sequence, lastValue: rows[0].last_value, isCalled: rows[0].is_called }
|
||||
}
|
||||
const sequences: Sequence[] = []
|
||||
for (const sequence of sequenceRelations(schema)) sequences.push(await readSequence(sequence))
|
||||
const tables: Table[] = []
|
||||
let rowCount = 0, byteCount = 0
|
||||
for (const relation of dataRelations(schema)) {
|
||||
await checkContext()
|
||||
const digest = createHash("sha256")
|
||||
frame(digest, "pg18-application-data-table-v2-jsonb-record"); frame(digest, schema.sha256); frame(digest, canonical(relation))
|
||||
const table: Table = { ...relation, rowCount: 0, byteCount: 0, sha256: "" }
|
||||
await query(`/* migration-data:declare */ DECLARE ${CURSOR} NO SCROLL CURSOR WITHOUT HOLD FOR
|
||||
WITH raw AS MATERIALIZED (SELECT pg_catalog.jsonb_build_array(pg_catalog.to_jsonb(t),pg_catalog.record_out(t)::text)::text AS row_text FROM ONLY ${quote(relation.namespace)}.${quote(relation.name)} AS t)
|
||||
SELECT ${metadata}, pg_catalog.octet_length(row_text)::text AS row_bytes,
|
||||
CASE WHEN pg_catalog.octet_length(row_text)<=${MAX_ROW_BYTES} THEN row_text ELSE NULL END AS row_text
|
||||
FROM raw ORDER BY row_text COLLATE pg_catalog."C"`)
|
||||
cursorOwned = true
|
||||
let previous: Buffer | undefined
|
||||
for (;;) {
|
||||
const rows = await query(`/* migration-data:fetch */ FETCH FORWARD 16 FROM ${CURSOR}`)
|
||||
if (rows.length > 16) fail("Data cursor batch exceeds bound")
|
||||
for (const row of rows) {
|
||||
if (context(row, "row_bytes row_text") !== owner) fail("Data row context changed")
|
||||
if (typeof row.row_bytes !== "string" || !/^(0|[1-9]\d{0,9})$/.test(row.row_bytes) || typeof row.row_text !== "string") fail("Data row overflow or invalid shape")
|
||||
const size = Buffer.byteLength(row.row_text), bytes = Buffer.from(row.row_text, "utf8")
|
||||
if (size > MAX_ROW_BYTES || size !== Number(row.row_bytes)) fail("Data row byte bound exceeded")
|
||||
if (previous && Buffer.compare(previous, bytes) > 0) fail("Data cursor order changed")
|
||||
previous = bytes
|
||||
if (++rowCount > maxRows || (byteCount += size) > maxBytes) fail("Data capture row/byte bound exceeded")
|
||||
table.rowCount++; table.byteCount += size; frame(digest, row.row_text)
|
||||
}
|
||||
if (!rows.length) break
|
||||
}
|
||||
await checkContext()
|
||||
await query(`/* migration-data:close */ CLOSE ${CURSOR}`); cursorOwned = false
|
||||
table.sha256 = digest.digest("hex"); tables.push(table)
|
||||
}
|
||||
for (const sequence of sequences) if (canonical(sequence) !== canonical(await readSequence(sequence))) fail("Sequence state changed during data capture")
|
||||
await checkContext()
|
||||
const value = { format: "pg18-application-data-v2" as const, schema, schemaSha256: schema.sha256, tables, sequences, rowCount, byteCount, allReplicaQuiescence: false as const, ready: false as const }
|
||||
const text = envelope(value)
|
||||
return freeze({ ...value, canonical: text, sha256: hash(text) })
|
||||
} catch {
|
||||
// Database/serialization errors can contain row values. Never retain causes
|
||||
// or interpolate driver errors into evidence or user-visible errors.
|
||||
throw new MigrationDataInventoryError("Data capture refused; no complete evidence was produced")
|
||||
} finally {
|
||||
if (cursorOwned) {
|
||||
try {
|
||||
// Never close a cursor belonging to a different backend/transaction.
|
||||
const result = await client.query(MIGRATION_DATA_CONTEXT_QUERY)
|
||||
if (result.rows.length === 1 && context(result.rows[0]) === owner) await client.query(`/* migration-data:close */ CLOSE ${CURSOR}`)
|
||||
} catch { /* An aborted transaction requires caller rollback. */ }
|
||||
}
|
||||
activeClients.delete(client)
|
||||
}
|
||||
}
|
||||
|
||||
/** Recomputes envelope/schema hashes, validates exact table/sequence vectors,
|
||||
* and compares evidence. Row hashes cannot be recomputed without private raw
|
||||
* rows: this detects content tampering, not a forged issuer/reference. */
|
||||
export function compareMigrationData(expected: CapturedMigrationData, observed: CapturedMigrationData) {
|
||||
const checked = (value: CapturedMigrationData) => {
|
||||
keys(value, "format schema schemaSha256 tables sequences rowCount byteCount allReplicaQuiescence ready canonical sha256")
|
||||
if (value.format !== "pg18-application-data-v2" || value.allReplicaQuiescence !== false || value.ready !== false) fail("Unsupported data evidence format/authority")
|
||||
compareMigrationSchemas(value.schema, value.schema)
|
||||
if (value.schemaSha256 !== value.schema.sha256 || !Array.isArray(value.tables) || !Array.isArray(value.sequences)) fail("Invalid data schema binding/vector")
|
||||
const relations = dataRelations(value.schema), sequenceNames = sequenceRelations(value.schema)
|
||||
if (value.tables.length !== relations.length || value.sequences.length !== sequenceNames.length) fail("Incomplete data evidence vector")
|
||||
let count = 0, bytes = 0
|
||||
for (let index = 0; index < value.tables.length; index++) {
|
||||
const table = value.tables[index]
|
||||
keys(table, "namespace name kind rowCount byteCount sha256")
|
||||
if (canonical({ namespace: table.namespace, name: table.name, kind: table.kind }) !== canonical(relations[index]) || !Number.isSafeInteger(table.rowCount) || table.rowCount < 0 || table.rowCount > 10000000 || !Number.isSafeInteger(table.byteCount) || table.byteCount < 0 || table.byteCount > 1073741824 || table.rowCount === 0 && table.byteCount !== 0 || table.rowCount > 0 && (table.byteCount < table.rowCount * 2 || table.byteCount > table.rowCount * MAX_ROW_BYTES) || typeof table.sha256 !== "string" || !/^[a-f0-9]{64}$/.test(table.sha256)) fail("Invalid table evidence identity/count/digest")
|
||||
if (table.rowCount === 0) {
|
||||
const digest = createHash("sha256")
|
||||
frame(digest, "pg18-application-data-table-v2-jsonb-record"); frame(digest, value.schemaSha256); frame(digest, canonical(relations[index]))
|
||||
if (digest.digest("hex") !== table.sha256) fail("Invalid empty-table digest")
|
||||
}
|
||||
count += table.rowCount; bytes += table.byteCount
|
||||
}
|
||||
for (let index = 0; index < value.sequences.length; index++) {
|
||||
const sequence = value.sequences[index]
|
||||
keys(sequence, "namespace name lastValue isCalled")
|
||||
if (identity(sequence) !== identity(sequenceNames[index]) || !sequenceValue(sequence.lastValue) || typeof sequence.isCalled !== "boolean") fail("Invalid sequence evidence")
|
||||
}
|
||||
if (count > 10000000 || bytes > 1073741824 || value.rowCount !== count || value.byteCount !== bytes) fail("Invalid data evidence totals")
|
||||
const text = envelope(value)
|
||||
if (text !== value.canonical || hash(text) !== value.sha256) fail("Data evidence envelope content/digest altered")
|
||||
return text
|
||||
}
|
||||
const reference = checked(expected), actual = checked(observed)
|
||||
compareMigrationSchemas(expected.schema, observed.schema)
|
||||
if (reference !== actual) fail("Observed data differs from captured reference")
|
||||
return freeze({ equal: true as const, evidenceOnly: true as const, enrollmentAuthorized: false as const, allReplicaQuiescence: false as const, ready: false as const, sha256: hash(actual) })
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { createHash } from "node:crypto"
|
||||
import { readFile } from "node:fs/promises"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { MIGRATION_MANIFEST, CURRENT_REQUIRED_TABLES, analyzeMigration, assessLegacyMigrationBaseline, inventoryMigrationDirectory, migrationManifestDigest, MigrationManifestError, parseMigrationStatements, validateMigrationFileSet, type LegacyBaselineEvidence, type MigrationReview } from "./migration-manifest.js"
|
||||
|
||||
let cases = 0
|
||||
async function test(name: string, work: () => Promise<void> | void) { await work(); cases++; console.log("ok", name) }
|
||||
const directory = fileURLToPath(new URL("../../drizzle/", import.meta.url))
|
||||
const inventory = await inventoryMigrationDirectory(directory)
|
||||
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)
|
||||
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(inventory.every(file => file.operations.length === file.statementCount && file.effects.length > 0))
|
||||
let tables = new Set<string>()
|
||||
for (const file of inventory) {
|
||||
if (file.publicTableReset) tables = new Set(["schema_migrations"])
|
||||
for (const operation of file.operations) if (operation.command === "CREATE" && operation.objectKind === "TABLE" && operation.target) tables.add(operation.target.replace(/^public\./, ""))
|
||||
}
|
||||
assert.deepEqual([...tables].sort(), [...CURRENT_REQUIRED_TABLES]); assert(CURRENT_REQUIRED_TABLES.length > 80)
|
||||
})
|
||||
await test("DO BEGIN and stored function BEGIN are never transaction boundaries", () => {
|
||||
const compact = inventory.find(file => file.id === "0002_compact_schema")!
|
||||
assert.equal(compact.transaction, "owner-transaction"); assert.equal(compact.topLevelTransactionControls.length, 0); assert.equal(compact.opaqueBodies[0].kind, "executed-do-body"); assert.equal(compact.publicTableReset, true)
|
||||
assert(compact.effects.some(effect => effect.includes("DESTRUCTIVE")))
|
||||
const endpoint = inventory.find(file => file.id === "0029_mcp_routing_sources")!
|
||||
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"])
|
||||
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)
|
||||
})
|
||||
await test("0041 preserves nonempty full journals and defines only fixed atomic lossless terminal archive transitions", async () => {
|
||||
const source = await readFile(new URL("../../drizzle/0041_server_poll_terminal_archives.sql", import.meta.url), "utf8")
|
||||
const migration = inventory.find(file => file.id === "0041_server_poll_terminal_archives")!
|
||||
assert.equal(migration.opaqueBodies.length, 13)
|
||||
assert.equal(migration.operations[1].command, "DO"); assert.equal(migration.operations[2].command, "LOCK")
|
||||
assert(source.includes("existing poll accounting requires explicit reconciliation"))
|
||||
assert(source.includes("pg_get_constraintdef(oid)='CHECK ((reserved_rows = (admissions * 4019)))'"))
|
||||
assert(source.includes("DROP CONSTRAINT server_poll_storage_usage_check,")); assert(source.includes("DROP CONSTRAINT server_poll_storage_usage_check1,"))
|
||||
assert(source.includes("pg_try_advisory_xact_lock(hashtextextended('mcp-operation:'")); assert(source.includes("FOR UPDATE NOWAIT"))
|
||||
assert(source.includes("pg_try_advisory_xact_lock(hashtextextended('server-poll-execution-v1:mcp:'"))
|
||||
assert(source.includes("pg_try_advisory_xact_lock(hashtextextended('server-poll-receipt-v1:'"))
|
||||
assert(source.includes("o.error_code IS NOT NULL")); assert(source.includes("s.server_id IS NOT NULL"))
|
||||
assert(source.includes("p.created_at>p.approved_at OR p.approved_at>o.created_at OR o.created_at>=LEAST(p.expires_at,a.authority_expires_at)"))
|
||||
assert(source.includes("r.created_at<a.authority_expires_at")); assert(source.includes("created_at>=a.authority_expires_at"))
|
||||
assert(!source.includes("o.finished_at>=a.authority_expires_at")); assert(!source.includes("s.finished_at>=a.authority_expires_at"))
|
||||
assert(source.includes("s.state<>'verified'")); assert(source.includes("o.state<>'applied'")); assert(source.includes("p.approved_by IS NULL"))
|
||||
assert(source.includes("outcome<>'confirmed'")); assert(source.includes("generate_series(0,pubs-1)"))
|
||||
assert(source.includes("NEW.source_sha256<>encode(sha256(convert_to(payload::text,'UTF8')),'hex')"))
|
||||
assert(source.includes("NEW.decoded_bytes<>octet_length(payload::text)")); assert(source.includes("compressed_bytes BETWEEN 1 AND 98048"))
|
||||
assert(source.includes("compaction_xid=pg_current_xact_id()")); assert(!source.includes("mm.poll_storage_compact"))
|
||||
assert(source.includes("ADD COLUMN compaction_claim uuid")); assert(source.includes("ADD COLUMN compaction_claimed_at timestamptz"))
|
||||
assert(source.includes("ADD COLUMN compaction_claim_xid xid8"))
|
||||
assert(source.includes("OLD.compaction_claim IS NULL AND OLD.compaction_claimed_at IS NULL"))
|
||||
assert(source.includes("THEN NEW.compaction_claimed_at:=clock_timestamp(); NEW.compaction_claim_xid:=pg_current_xact_id(); RETURN NEW; END IF"))
|
||||
assert(source.includes("a.compaction_claim IS NULL OR a.compaction_claimed_at IS NULL"))
|
||||
assert(source.includes("a.compaction_claim_xid IS NULL OR a.compaction_claim_xid=pg_current_xact_id()"))
|
||||
assert.equal((source.match(/DEFERRABLE INITIALLY DEFERRED/g) ?? []).length, 3)
|
||||
assert(source.includes("reserved_rows=admissions*4019+compacted_admissions*2"))
|
||||
assert(source.includes("reserved_bytes=admissions*8421376+compacted_admissions*32768+archive_bytes"))
|
||||
assert(source.includes("GREATEST(a.retention_days,retention)")); assert(source.includes("activity:=GREATEST(a.original_activity_at"))
|
||||
assert(CURRENT_REQUIRED_TABLES.includes("server_poll_archives"))
|
||||
assert.equal(migration.operations.filter(operation => operation.command === "INSERT").length, 1)
|
||||
assert(!migration.operations.some(operation => operation.command === "UPDATE" || operation.command === "DELETE"), "No compaction or source rewrite runs during DDL")
|
||||
})
|
||||
await test("0040 additive routing enrollment refuses legacy reserved/proposal collisions and never backfills authority", async () => {
|
||||
const source = await readFile(new URL("../../drizzle/0040_mcp_routing_reservations.sql", import.meta.url), "utf8")
|
||||
const migration = inventory.find(file => file.id === "0040_mcp_routing_reservations")!
|
||||
assert.equal(migration.opaqueBodies.length, 1)
|
||||
assert.equal(migration.operations[1].command, "LOCK"); assert.equal(migration.operations[2].command, "DO")
|
||||
const guard = parseMigrationStatements(source)[2].tokens.find(token => token.kind === "dollar")!.value
|
||||
assert(guard.includes("WHERE mode='reserved'")); assert(guard.includes("pg_catalog.pg_attribute")); assert(guard.includes("pg_catalog.pg_constraint"))
|
||||
assert(source.includes("source_configuration_proof IS NOT NULL")); assert(source.includes("inverse_configuration_sealed IS NULL OR inverse_intent_sealed IS NOT NULL"))
|
||||
assert.equal(migration.operations.filter(operation => operation.command === "INSERT").length, 1, "Only the atomic file marker is inserted")
|
||||
assert(!migration.operations.some(operation => operation.command === "UPDATE" || operation.command === "DELETE"))
|
||||
assert(CURRENT_REQUIRED_TABLES.includes("mcp_routing_reservations")); assert(CURRENT_REQUIRED_TABLES.includes("mcp_routing_reservation_slots"))
|
||||
})
|
||||
await test("fresh storage enrollment locks and refuses history before DDL without backfill", async () => {
|
||||
const migration = inventory.find(file => file.id === "0037_server_probe_storage_v2")!
|
||||
const source = await readFile(new URL("../../drizzle/0037_server_probe_storage_v2.sql", import.meta.url), "utf8")
|
||||
const statements = parseMigrationStatements(source)
|
||||
assert.equal(migration.statementCount, 27)
|
||||
assert.equal(migration.operations[1].command, "LOCK"); assert.equal(migration.operations[2].command, "DO")
|
||||
const lock = statements[1].tokens.map(token => token.value).join(" ")
|
||||
assert(lock.includes("ACCESS EXCLUSIVE MODE"))
|
||||
const guard = statements[2].tokens.find(token => token.kind === "dollar")!.value
|
||||
for (const table of ["server_probe_executions", "server_probe_execution_stages", "server_probe_execution_quarantines", "mcp_diagnostic_cancellations"]) {
|
||||
assert(lock.includes(table.toUpperCase())); assert(guard.includes(`EXISTS(SELECT 1 FROM ${table})`))
|
||||
}
|
||||
for (const table of ["server_probe_storage_usage", "server_probe_storage_admissions", "server_probe_execution_payloads"]) {
|
||||
assert(guard.includes("'" + table + "'")); assert(CURRENT_REQUIRED_TABLES.includes(table))
|
||||
}
|
||||
assert.equal(migration.opaqueBodies.filter(body => body.kind === "executed-do-body").length, 1)
|
||||
assert.equal(migration.opaqueBodies.filter(body => body.kind === "stored-function-body").length, 10)
|
||||
assert.deepEqual(migration.operations.filter(operation => operation.command === "INSERT").map(operation => operation.target), ["server_probe_storage_usage", "schema_migrations"])
|
||||
assert.deepEqual(migration.operations.filter(operation => operation.command === "UPDATE"), [])
|
||||
const seed = statements.find(statement => statement.tokens[0].value === "INSERT" && statement.tokens[2].value === "SERVER_PROBE_STORAGE_USAGE")!
|
||||
assert(!seed.tokens.some(token => token.kind === "word" && token.value === "SELECT"))
|
||||
assert.deepEqual(seed.tokens.filter(token => token.kind === "number").map(token => token.value), ["0", "0", "0"])
|
||||
})
|
||||
await test("poll enrollment fences writers before inspecting unmarked schema and installs exact fixed guards", async () => {
|
||||
const source = await readFile(new URL("../../drizzle/0038_server_poll_journal_and_writer_guards.sql", import.meta.url), "utf8")
|
||||
const statements = parseMigrationStatements(source)
|
||||
const migration = inventory.find(file => file.id === "0038_server_poll_journal_and_writer_guards")!
|
||||
assert.equal(migration.operations[1].command, "DO")
|
||||
const guard = statements[1].tokens.find(token => token.kind === "dollar")!.value
|
||||
const lockAt = guard.indexOf("PERFORM pg_catalog.pg_advisory_xact_lock(8723110::bigint)")
|
||||
assert(lockAt >= 0 && lockAt < guard.indexOf("IF EXISTS"))
|
||||
for (const name of ["server_poll_executions", "server_poll_intents", "server_poll_acks", "server_poll_receipts", "server_poll_quarantines", "server_poll_journal_immutable", "server_partition_writer_guard"]) assert(guard.includes("'" + name + "'"))
|
||||
const { SERVER_POLL_JOURNAL_DDL } = await import("../services/server-poll-journal.js")
|
||||
const { SERVER_PARTITION_WRITER_DDL } = await import("../services/server-partition-maintenance-lease.js")
|
||||
assert(source.includes(SERVER_POLL_JOURNAL_DDL.trim()))
|
||||
assert(source.includes(SERVER_PARTITION_WRITER_DDL.trim()))
|
||||
assert.equal((source.match(/ENABLE ALWAYS TRIGGER server_partition_writer_guard/g) ?? []).length, 12)
|
||||
assert.deepEqual(migration.operations.filter(operation => operation.command === "INSERT").map(operation => operation.target), ["schema_migrations"])
|
||||
assert.equal(migration.opaqueBodies.length, 3)
|
||||
})
|
||||
await test("poll storage enrollment refuses history before any change and preserves reviewed quota/GC bodies", async () => {
|
||||
const source=await readFile(new URL("../../drizzle/0039_server_poll_storage.sql",import.meta.url),"utf8")
|
||||
const proposal=await readFile(new URL("./proposals/server-poll-storage.sql",import.meta.url),"utf8")
|
||||
const migration=inventory.find(file=>file.id==="0039_server_poll_storage")!
|
||||
const statements=parseMigrationStatements(source),historical=parseMigrationStatements(proposal)
|
||||
assert.equal(migration.statementCount,25);assert.equal(migration.opaqueBodies.length,6)
|
||||
assert.equal(migration.operations[1].command,"LOCK");assert.equal(migration.operations[2].command,"DO")
|
||||
const guard=statements[2].tokens.find(token=>token.kind==="dollar")!.value
|
||||
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)
|
||||
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)
|
||||
})
|
||||
await test("comment-only CONCURRENTLY is not an executed nontransactional effect", () => {
|
||||
const index = inventory.find(file => file.id === "0013_scheduler_retention_index")!
|
||||
assert.equal(index.statementCount, 1); assert.equal(index.transaction, "owner-transaction")
|
||||
})
|
||||
await test("nested comments, quoted identifiers, escaped strings and tagged bodies split correctly", () => {
|
||||
const source = `/* BEGIN; /* COMMIT; */ still comment */ DO $tag$ BEGIN PERFORM ';COMMIT'; END; $tag$;
|
||||
CREATE TABLE "semi;COMMIT"(value text DEFAULT 'it''s; BEGIN');
|
||||
INSERT INTO "semi;COMMIT" VALUES(E'back\\slash; END', $$literal;COMMIT;$$); -- BEGIN;`
|
||||
const statements = parseMigrationStatements(source)
|
||||
assert.equal(statements.length, 3); assert.equal(statements[0].tokens[1].kind, "dollar")
|
||||
assert.equal(statements[1].tokens.find(token => token.kind === "identifier")?.value, "semi;COMMIT")
|
||||
assert.equal(statements[2].tokens.filter(token => token.kind === "dollar").length, 1)
|
||||
})
|
||||
await test("unterminated/unsupported source refuses rather than guessing", () => {
|
||||
for (const source of ["DO $$ BEGIN;", "CREATE TABLE x(v text DEFAULT 'oops);", "/* never closes", 'CREATE TABLE "missing(v int);', "CREATE TABLE x(v int)", "\\i arbitrary.sql", "SELECT U&'unsupported';", "SELECT 1;\0"]) assert.throws(() => parseMigrationStatements(source), MigrationManifestError)
|
||||
})
|
||||
await test("split commits, statements after COMMIT and unsupported controls refuse", () => {
|
||||
for (const source of ["BEGIN; CREATE TABLE x(v int); COMMIT; CREATE TABLE y(v int);", "BEGIN; COMMIT; BEGIN; COMMIT;", "SAVEPOINT x; CREATE TABLE x(v int);", "START TRANSACTION; COMMIT;", "BEGIN; ROLLBACK;", "BEGIN WORK; COMMIT;"]) assert.throws(reviewed(source, { transaction: "self-managed-single-transaction" }), MigrationManifestError)
|
||||
})
|
||||
await test("exact synthetic transaction contract and terminal selfmarker are checked", () => {
|
||||
const source = "BEGIN; CREATE TABLE fixture(v int); INSERT INTO schema_migrations(id) VALUES('9000_fixture') ON CONFLICT(id) DO NOTHING; COMMIT;"
|
||||
assert.equal(reviewed(source, { transaction: "self-managed-single-transaction", selfMarker: true })().selfMarker?.atomicWithAllFileEffects, true)
|
||||
assert.throws(reviewed(source.replace("9000_fixture", "9000_foreign"), { transaction: "self-managed-single-transaction", selfMarker: true }), MigrationManifestError)
|
||||
assert.throws(reviewed(source.replace("COMMIT;", "CREATE TABLE later(v int); COMMIT;"), { transaction: "self-managed-single-transaction", selfMarker: true }), MigrationManifestError)
|
||||
})
|
||||
await test("opaque body requires its exact declared semantic review", () => {
|
||||
const source = "DO $$ BEGIN EXECUTE 'DROP TABLE x'; END $$;"
|
||||
assert.throws(reviewed(source), MigrationManifestError)
|
||||
assert.equal(reviewed(source, { opaqueBodies: 1, bodyReview: ["Reviewed inert dynamic destructive fixture"] })().opaqueBodies.length, 1)
|
||||
assert.throws(reviewed("DO 'BEGIN NULL; END';"), MigrationManifestError)
|
||||
})
|
||||
await test("executed nontransactional/unclassified commands fail closed", () => {
|
||||
for (const source of ["CREATE INDEX CONCURRENTLY ix ON t(x);", "SELECT setval('s',1);", "VACUUM x;", "ALTER SYSTEM SET x=1;", "CALL arbitrary();", "COPY x FROM PROGRAM 'x';"]) assert.throws(reviewed(source), MigrationManifestError)
|
||||
})
|
||||
await test("raw byte drift including newline changes refuses pinned inventory", async () => {
|
||||
const pin = MIGRATION_MANIFEST[0], bytes = await readFile(new URL("../../drizzle/" + pin.file, import.meta.url))
|
||||
assert.throws(() => analyzeMigration(pin.file, Buffer.concat([bytes, Buffer.from("\n")]), pin), MigrationManifestError)
|
||||
assert.throws(() => analyzeMigration(pin.file, bytes, { ...pin, transaction: "self-managed-single-transaction" }), MigrationManifestError)
|
||||
})
|
||||
await test("new/missing/duplicate SQL files require an explicit classified pin", () => {
|
||||
const files = MIGRATION_MANIFEST.map(pin => pin.file)
|
||||
validateMigrationFileSet([...files].reverse())
|
||||
for (const changed of [[...files, "0040_unclassified.sql"], files.slice(1), [...files, files[0]], [...files, "manual.sql"]]) assert.throws(() => validateMigrationFileSet(changed), MigrationManifestError)
|
||||
assert(Object.isFrozen(MIGRATION_MANIFEST)); assert(Object.isFrozen(MIGRATION_MANIFEST[0].effects))
|
||||
})
|
||||
const empty: LegacyBaselineEvidence = { inventoryComplete: true, databaseGeneration: "owned-generation", unresolvedAttemptIds: [], markerTable: "absent", markerIds: [], applicationObjects: [] }
|
||||
await test("absent markers never alone authorize fresh execution", () => {
|
||||
assert.equal(assessLegacyMigrationBaseline(empty).state, "clean-database-candidate")
|
||||
assert.equal(assessLegacyMigrationBaseline(empty).dispatchAuthorized, false)
|
||||
assert.equal(assessLegacyMigrationBaseline({ ...empty, inventoryComplete: false }).state, "blocked")
|
||||
assert.equal(assessLegacyMigrationBaseline({ ...empty, applicationObjects: [{ schema: "public", name: "servers", kind: "table" }] }).state, "blocked")
|
||||
assert.equal(assessLegacyMigrationBaseline({ ...empty, unresolvedAttemptIds: ["uncertain0000"] }).state, "blocked")
|
||||
})
|
||||
const complete: LegacyBaselineEvidence = { ...empty, markerTable: "present", markerIds: MIGRATION_MANIFEST.map(pin => pin.file.slice(0, -4)), applicationObjects: CURRENT_REQUIRED_TABLES.map(name => ({ schema: "public", name, kind: "table" })), verifiedSchema: { manifestDigest: migrationManifestDigest(), databaseGeneration: "owned-generation", exactCurrentSchema: true, observedSchemaDigest: "a".repeat(64) } }
|
||||
await test("existing current schema needs exact separately supplied comparator evidence", () => {
|
||||
assert.equal(assessLegacyMigrationBaseline(complete).state, "legacy-current-enrollment-candidate")
|
||||
assert.equal(assessLegacyMigrationBaseline(complete).dispatchAuthorized, false)
|
||||
for (const changed of [{ ...complete, verifiedSchema: undefined }, { ...complete, markerIds: complete.markerIds.slice(0, 1) }, { ...complete, applicationObjects: complete.applicationObjects.slice(1) }, { ...complete, markerIds: [...complete.markerIds, "0001_unknown"] }, { ...complete, verifiedSchema: { ...complete.verifiedSchema!, databaseGeneration: "restored-foreign" } }, { ...complete, verifiedSchema: { ...complete.verifiedSchema!, manifestDigest: "0".repeat(64) } }]) assert.equal(assessLegacyMigrationBaseline(changed).state, "blocked")
|
||||
})
|
||||
|
||||
|
||||
await test("0043 adds a separate one-unit immutable forward ledger with an atomic marker", async () => {
|
||||
const pin = inventory.find(file => file.id === "0043_mcp_routing_forward_charges")!
|
||||
assert.equal(pin.opaqueBodies.length, 3); assert.equal(pin.selfMarker?.atomicWithAllFileEffects, true)
|
||||
const source = await readFile(new URL("../../drizzle/0043_mcp_routing_forward_charges.sql", import.meta.url), "utf8")
|
||||
assert(source.includes("CHECK(charge_units=1)")); assert(source.includes("source_operation_id uuid PRIMARY KEY"))
|
||||
assert(!/REFERENCES|CASCADE|SECURITY DEFINER/.test(source.replace(/--[^\n]*/g, "")))
|
||||
assert(source.includes("BEFORE UPDATE OR DELETE")); assert(source.includes("BEFORE TRUNCATE"))
|
||||
})
|
||||
|
||||
await test("0044 adds only a fixed manual inverse ledger and current-version intent fence in one atomic file", async () => {
|
||||
const pin = inventory.find(file => file.id === "0044_mcp_routing_manual_inverse_charges")!
|
||||
assert.equal(pin.transaction, "self-managed-single-transaction")
|
||||
assert.equal(pin.opaqueBodies.length, 4); assert.equal(pin.selfMarker?.atomicWithAllFileEffects, true)
|
||||
const source = await readFile(new URL("../../drizzle/0044_mcp_routing_manual_inverse_charges.sql", import.meta.url), "utf8")
|
||||
const executable = source.replace(/--[^\n]*/g, "")
|
||||
assert(!/REFERENCES|CASCADE|SECURITY DEFINER/.test(executable), "Logical charge identity must not acquire family lifetime FKs or extra authority")
|
||||
assert(source.includes("CHECK(charge_units=1)")); assert(source.includes("UNIQUE(source_operation_id,source_step_index)"))
|
||||
assert(source.includes("BEFORE UPDATE OR DELETE")); assert(source.includes("BEFORE TRUNCATE"))
|
||||
assert(source.includes("pg_try_advisory_xact_lock(hashtextextended('mcp-runner-admission',0))"))
|
||||
assert(source.includes("p.operation_id='operations.recover' AND p.operation_version=7"))
|
||||
assert(source.includes("sp.operation_id='routing.community.switch' AND sp.operation_version=7"))
|
||||
assert(source.includes("manual inverse requires current exact running recovery"))
|
||||
assert(source.includes("WHERE mode='manual'")); assert(CURRENT_REQUIRED_TABLES.includes("mcp_routing_manual_inverse_charges"))
|
||||
assert.equal(pin.operations.filter(operation => operation.command === "INSERT").length, 1, "Only atomic file marker is inserted; no historical charge backfill")
|
||||
assert(!pin.operations.some(operation => operation.command === "UPDATE" || operation.command === "DELETE"))
|
||||
})
|
||||
|
||||
console.log(`migration-manifest: ${cases} inert parser/inventory/baseline scenarios passed; ${inventory.length} exact files`)
|
||||
@@ -0,0 +1,750 @@
|
||||
import { createHash } from "node:crypto"
|
||||
import { readdir, readFile } from "node:fs/promises"
|
||||
import { join } from "node:path"
|
||||
|
||||
/** Inventory only: no database imports, SQL execution, rewrite or dispatcher. */
|
||||
export type MigrationTransaction = "owner-transaction" | "self-managed-single-transaction"
|
||||
export interface MigrationReview {
|
||||
file: string; sha256: string; transaction: MigrationTransaction; selfMarker: boolean
|
||||
opaqueBodies: number; bodyReview: readonly string[]; effects: readonly string[]
|
||||
publicTableReset?: true
|
||||
}
|
||||
export interface SqlToken { kind: "word" | "identifier" | "string" | "dollar" | "symbol" | "number"; value: string; line: number }
|
||||
export interface MigrationStatement { line: number; endLine: number; tokens: SqlToken[] }
|
||||
export class MigrationManifestError extends Error { readonly code = "MIGRATION_MANIFEST_REFUSED" }
|
||||
const refuse = (message: string): never => { throw new MigrationManifestError(message) }
|
||||
const hash = (value: Uint8Array | string) => createHash("sha256").update(value).digest("hex")
|
||||
|
||||
/** PostgreSQL lexical subset needed by the pinned inventory. Dollar-quoted
|
||||
* bodies/strings/quoted identifiers are opaque: semicolons and BEGIN inside
|
||||
* them never become top-level statements. Unsupported/unterminated input
|
||||
* refuses. This is not a general PL/pgSQL semantic evaluator. */
|
||||
export function parseMigrationStatements(source: string): MigrationStatement[] {
|
||||
if (Buffer.byteLength(source) > 2097152 || source.includes("\0")) refuse("Migration source exceeds lexical bounds")
|
||||
let index = 0, line = 1, count = 0, tokens: SqlToken[] = []
|
||||
const statements: MigrationStatement[] = []
|
||||
const advance = (length: number) => { line += (source.slice(index, index + length).match(/\n/g) ?? []).length; index += length }
|
||||
const push = (kind: SqlToken["kind"], value: string, at: number) => { if (++count > 200000) refuse("Migration token bound exceeded"); tokens.push({ kind, value, line: at }) }
|
||||
while (index < source.length) {
|
||||
const tail = source.slice(index), char = source[index], at = line
|
||||
if (/\s/.test(char)) { advance(1); continue }
|
||||
if (tail.startsWith("--")) { const end = source.indexOf("\n", index); advance((end < 0 ? source.length : end) - index); continue }
|
||||
if (tail.startsWith("/*")) {
|
||||
let depth = 1; advance(2)
|
||||
while (depth && index < source.length) {
|
||||
if (source.startsWith("/*", index)) { depth++; advance(2) }
|
||||
else if (source.startsWith("*/", index)) { depth--; advance(2) }
|
||||
else advance(1)
|
||||
}
|
||||
if (depth) refuse("Unterminated migration block comment")
|
||||
continue
|
||||
}
|
||||
if (char === ";") {
|
||||
if (tokens.length) { statements.push({ line: tokens[0].line, endLine: line, tokens }); tokens = []; if (statements.length > 5000) refuse("Migration statement bound exceeded") }
|
||||
advance(1); continue
|
||||
}
|
||||
const escapeString = /^[eE]'/.test(tail)
|
||||
if (char === "'" || char === '"' || escapeString) {
|
||||
const quote = char === '"' ? '"' : "'"; advance(escapeString ? 2 : 1)
|
||||
let value = "", closed = false
|
||||
while (index < source.length) {
|
||||
if (escapeString && source[index] === "\\") { if (index + 1 >= source.length) refuse("Unterminated escape string"); value += source.slice(index, index + 2); advance(2) }
|
||||
else if (source[index] === quote) {
|
||||
if (source[index + 1] === quote) { value += quote; advance(2) }
|
||||
else { advance(1); closed = true; break }
|
||||
} else { value += source[index]; advance(1) }
|
||||
}
|
||||
if (!closed) refuse("Unterminated migration quote")
|
||||
push(quote === '"' ? "identifier" : "string", value, at); continue
|
||||
}
|
||||
const dollar = /^\$(?:[A-Za-z_][A-Za-z_0-9]*)?\$/.exec(tail)?.[0]
|
||||
if (dollar) {
|
||||
const end = source.indexOf(dollar, index + dollar.length)
|
||||
if (end < 0) refuse("Unterminated migration dollar body")
|
||||
const body = source.slice(index + dollar.length, end)
|
||||
if (Buffer.byteLength(body) > 1048576) refuse("Migration body bound exceeded")
|
||||
push("dollar", body, at); advance(end + dollar.length - index); continue
|
||||
}
|
||||
if (/^[uU]&['"]/.test(tail) || char === "\\") refuse("Unsupported migration lexical construct")
|
||||
const word = /^[A-Za-z_][A-Za-z_0-9$]*/.exec(tail)?.[0]
|
||||
if (word) { push("word", word.toUpperCase(), at); advance(word.length); continue }
|
||||
const number = /^\d+(?:\.\d+)?/.exec(tail)?.[0]
|
||||
if (number) { push("number", number, at); advance(number.length); continue }
|
||||
if (/[(),.=<>:+\-/%\[\]|!?^~*]/.test(char)) { push("symbol", char, at); advance(1); continue }
|
||||
refuse("Unsupported migration token at line " + line)
|
||||
}
|
||||
if (tokens.length) refuse("Migration has an unterminated top-level statement")
|
||||
return statements
|
||||
}
|
||||
const words = (statement: MigrationStatement) => statement.tokens.filter(token => token.kind === "word").map(token => token.value)
|
||||
function target(statement: MigrationStatement) {
|
||||
const tokens = statement.tokens
|
||||
let position = tokens.findIndex(token => token.kind === "word" && ["TABLE", "INDEX", "FUNCTION", "TRIGGER", "INTO"].includes(token.value)) + 1
|
||||
if (!position) return undefined
|
||||
while (tokens[position]?.kind === "word" && ["IF", "NOT", "EXISTS"].includes(tokens[position].value)) position++
|
||||
const first = tokens[position]
|
||||
if (!first || !["word", "identifier"].includes(first.kind)) return undefined
|
||||
const name = first.kind === "word" ? first.value.toLowerCase() : first.value
|
||||
if (tokens[position + 1]?.value === ".") {
|
||||
const second = tokens[position + 2]
|
||||
if (!second || !["word", "identifier"].includes(second.kind)) refuse("Invalid qualified migration target")
|
||||
return name + "." + (second.kind === "word" ? second.value.toLowerCase() : second.value)
|
||||
}
|
||||
return name
|
||||
}
|
||||
export function analyzeMigration(file: string, bytes: Uint8Array, review: MigrationReview) {
|
||||
if (!/^\d{4}_[a-z_0-9]+\.sql$/.test(file) || review.file !== file || !/^[a-f0-9]{64}$/.test(review.sha256) || hash(bytes) !== review.sha256) refuse("Unclassified or changed migration file: " + file)
|
||||
const source = new TextDecoder("utf-8", { fatal: true }).decode(bytes)
|
||||
const statements = parseMigrationStatements(source)
|
||||
if (!statements.length) refuse("Empty migration file")
|
||||
const controls = statements.flatMap((statement, index) => {
|
||||
const first = words(statement)[0]
|
||||
if (!["BEGIN", "START", "COMMIT", "END", "ROLLBACK", "SAVEPOINT", "RELEASE", "PREPARE"].includes(first)) return []
|
||||
if (statement.tokens.length !== 1 || !["BEGIN", "COMMIT"].includes(first)) refuse("Unsupported migration transaction boundary")
|
||||
return [{ index, kind: first, line: statement.line }]
|
||||
})
|
||||
const transaction: MigrationTransaction = controls.length ? "self-managed-single-transaction" : "owner-transaction"
|
||||
if (controls.length && (controls.length !== 2 || controls[0].index !== 0 || controls[0].kind !== "BEGIN" || controls[1].index !== statements.length - 1 || controls[1].kind !== "COMMIT")) refuse("Migration has split or nonterminal transaction boundaries")
|
||||
if (transaction !== review.transaction) refuse("Migration transaction contract changed")
|
||||
const opaque = statements.flatMap((statement, index) => {
|
||||
const head = words(statement).slice(0, 4).join(" "), isDo = head.startsWith("DO"), isFunction = /^CREATE (?:OR REPLACE )?FUNCTION\b/.test(head)
|
||||
const bodies = statement.tokens.filter(token => token.kind === "dollar")
|
||||
if ((isDo || isFunction) && bodies.length !== 1) refuse("Executable/stored migration body requires an explicitly reviewed dollar body")
|
||||
return bodies.map(token => ({ statement: index, line: token.line, sha256: hash(token.value), kind: isDo ? "executed-do-body" : isFunction ? "stored-function-body" : "dollar-literal" }))
|
||||
})
|
||||
if (opaque.length !== review.opaqueBodies || review.bodyReview.length !== opaque.length) refuse("Migration opaque body lacks exact semantic review")
|
||||
const selfMarkers = statements.flatMap((statement, index) => {
|
||||
if (words(statement)[0] !== "INSERT" || !["schema_migrations", "public.schema_migrations"].includes(target(statement) ?? "")) return []
|
||||
const tokens = statement.tokens, literal = tokens.filter(token => token.kind === "string")
|
||||
const shape = tokens.map(token => token.kind === "string" ? "?" : token.value).join(" ")
|
||||
if (!/^INSERT INTO (?:PUBLIC \. )?SCHEMA_MIGRATIONS \( ID \) VALUES \( \? \) ON CONFLICT \( ID \) DO NOTHING$/.test(shape) || literal.length !== 1 || literal[0].value !== file.slice(0, -4)) refuse("Migration self-marker is not the exact declared id INSERT")
|
||||
return [{ statement: index, line: statement.line, id: literal[0].value }]
|
||||
})
|
||||
if (selfMarkers.length !== Number(review.selfMarker) || selfMarkers.length && (transaction !== "self-managed-single-transaction" || selfMarkers[0].statement !== statements.length - 2)) refuse("Migration self-marker is not atomic with all file effects")
|
||||
for (const statement of statements) {
|
||||
const tokens = words(statement), command = tokens[0]
|
||||
if (!["BEGIN", "COMMIT", "CREATE", "ALTER", "INSERT", "DROP", "DO", "LOCK"].includes(command)) refuse("Unclassified migration command: " + command)
|
||||
if (tokens.includes("CONCURRENTLY") || tokens.includes("SETVAL") || tokens.includes("NEXTVAL") || tokens.slice(0, 2).join(" ") === "ALTER SYSTEM") refuse("Migration contains an unclassified nontransactional effect")
|
||||
}
|
||||
return {
|
||||
file, id: file.slice(0, -4), sha256: review.sha256, byteLength: bytes.length, transaction,
|
||||
topLevelTransactionControls: controls, statementCount: statements.length,
|
||||
selfMarker: selfMarkers[0] ? { ...selfMarkers[0], atomicWithAllFileEffects: true } : null,
|
||||
currentOuterMarkerGap: !review.selfMarker,
|
||||
futureDispatcher: transaction === "owner-transaction" ? "may-run-verbatim-in-owned-transaction-with-marker-and-receipt-after-durable-intent" : review.selfMarker ? "verbatim-self-managed-file-with-audited-atomic-legacy-marker-and-separate-durable-receipt" : "verbatim-self-managed-file-needs-durable-pre-intent-and-post-file-terminal-receipt-gap-refusal",
|
||||
effects: [...review.effects], publicTableReset: review.publicTableReset === true,
|
||||
opaqueBodies: opaque.map((body, index) => ({ ...body, review: review.bodyReview[index] })),
|
||||
nontransactionalExecution: "none-observed-in-exact-reviewed-bytes; identity definitions and nondeterministic defaults are separately described",
|
||||
operations: statements.map((statement, index) => ({ statement: index, line: statement.line, endLine: statement.endLine, command: words(statement)[0], objectKind: words(statement).find(word => ["TABLE", "INDEX", "FUNCTION", "TRIGGER"].includes(word)) ?? (words(statement)[0] === "INSERT" ? "TABLE" : null), target: target(statement) ?? null })),
|
||||
}
|
||||
}
|
||||
|
||||
// Exact byte pins and semantic reviews. Additions or any byte change require
|
||||
// an explicit reviewed entry; the inventory never auto-enrolls discovered SQL.
|
||||
const classifiedMigrationPins: readonly MigrationReview[] = [
|
||||
{
|
||||
"file": "0000_postgresql.sql",
|
||||
"sha256": "eb6ce1449add311611634845aa6608327821ca2cbe33c1e7b214dde37d85027e",
|
||||
"transaction": "owner-transaction",
|
||||
"selfMarker": false,
|
||||
"opaqueBodies": 0,
|
||||
"bodyReview": [],
|
||||
"effects": [
|
||||
"Initial public application tables, indexes, FK cascades, identity sequence definitions and settings/data_migration seeds; no marker INSERT in file; generated identity definitions are not evidence of nextval execution."
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "0002_compact_schema.sql",
|
||||
"sha256": "0ffcbb8d54dd386a1d709ee03a2e556f4caee102c1b9ef7b0faeef6019056deb",
|
||||
"transaction": "owner-transaction",
|
||||
"selfMarker": false,
|
||||
"opaqueBodies": 1,
|
||||
"bodyReview": [
|
||||
"Executed DO: catalog loop dynamically DROP TABLE IF EXISTS public.%I CASCADE, excludes ONLY public schema_migrations; data-destructive after commit; no top-level transaction statements hidden in this body."
|
||||
],
|
||||
"effects": [
|
||||
"DESTRUCTIVE executed DO enumerates public pg_tables and DROP TABLE ... CASCADE for every table except schema_migrations; destroys application data and any public recovery tables.",
|
||||
"Recreates compact public tables/indexes/FK cascades and identity definitions; initializes settings/data_migration, which removes the previous import-completion state; no file marker."
|
||||
],
|
||||
"publicTableReset": true
|
||||
},
|
||||
{
|
||||
"file": "0003_backup_s3.sql",
|
||||
"sha256": "59cda0660a0f487330058735ad2ee75021ca236480c28a81a2cf32bb4cd77be7",
|
||||
"transaction": "owner-transaction",
|
||||
"selfMarker": false,
|
||||
"opaqueBodies": 1,
|
||||
"bodyReview": [
|
||||
"Executed DO: catalog existence check and conditional ALTER TABLE backup_entries ADD CONSTRAINT; no transaction control, external call or nontransactional sequence advance."
|
||||
],
|
||||
"effects": [
|
||||
"Creates/seeds backup_storage_settings; adds backup_entries storage/S3 columns; executed DO conditionally adds storage CHECK constraint."
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "0004_geoip_settings.sql",
|
||||
"sha256": "a0c6e7b918df2c43dd4dd2f76aa0fea3c24b6c6e304390708d9c601521b6343c",
|
||||
"transaction": "owner-transaction",
|
||||
"selfMarker": false,
|
||||
"opaqueBodies": 0,
|
||||
"bodyReview": [],
|
||||
"effects": [
|
||||
"Creates/seeds geoip_settings; enabled/update defaults are schema metadata, no downloader/network execution."
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "0005_flow_facts.sql",
|
||||
"sha256": "26254ce080c9bc1b8d33431c4c00ed7f8601d49c035a3bb0c0d9cf00d268c8a1",
|
||||
"transaction": "owner-transaction",
|
||||
"selfMarker": false,
|
||||
"opaqueBodies": 0,
|
||||
"bodyReview": [],
|
||||
"effects": [
|
||||
"Creates partitioned flow_hour_facts and flow_daily_facts parents with server FK cascades; no leaf creation or retention execution."
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "0006_config_revisions.sql",
|
||||
"sha256": "aed6b7abaeb8b895c019b32ee5badf5c68fb457d50a4187fbb5c58a8c8728b8f",
|
||||
"transaction": "owner-transaction",
|
||||
"selfMarker": false,
|
||||
"opaqueBodies": 0,
|
||||
"bodyReview": [],
|
||||
"effects": [
|
||||
"Creates config_revisions with server FK cascade and server/section/time index; retention is comment only."
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "0007_flow_buckets_nat.sql",
|
||||
"sha256": "2cab88bea14f338b72127c3c7e39ab7d88baa2d11a728389724ba5574ecdecdd",
|
||||
"transaction": "owner-transaction",
|
||||
"selfMarker": false,
|
||||
"opaqueBodies": 0,
|
||||
"bodyReview": [],
|
||||
"effects": [
|
||||
"Adds four NAT address/port columns to flow_buckets."
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "0008_alert_outbox_outcomes.sql",
|
||||
"sha256": "d16e97a48da5d957b9797309bc0e7b48a826ef861d6734cf4da1ee756539b825",
|
||||
"transaction": "self-managed-single-transaction",
|
||||
"selfMarker": false,
|
||||
"opaqueBodies": 0,
|
||||
"bodyReview": [],
|
||||
"effects": [
|
||||
"Drops/replaces alert_outbox status CHECK to permit processing/unknown states; no historical row UPDATE."
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "0009_alert_outbox_resolution.sql",
|
||||
"sha256": "e3d66a133862e443320d83213899e96d7de84e1c8144962615895176c8c62b11",
|
||||
"transaction": "self-managed-single-transaction",
|
||||
"selfMarker": false,
|
||||
"opaqueBodies": 0,
|
||||
"bodyReview": [],
|
||||
"effects": [
|
||||
"Adds alert_outbox resolution_version and nonnegative CHECK."
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "0010_ping_measurement_known.sql",
|
||||
"sha256": "4cc2f13a489f3b300d238a205a41b75d495a4aa26e3d1468f26b1f682c4a94aa",
|
||||
"transaction": "owner-transaction",
|
||||
"selfMarker": false,
|
||||
"opaqueBodies": 0,
|
||||
"bodyReview": [],
|
||||
"effects": [
|
||||
"Adds uptime_probe_samples measurement_known default true and observation_error; default backfill changes existing row interpretation transactionally."
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "0011_traffic_collection_state.sql",
|
||||
"sha256": "83e3b6958ea289e594cc68b0646c894c703104ca95a9c73e203886950fc94b9b",
|
||||
"transaction": "owner-transaction",
|
||||
"selfMarker": false,
|
||||
"opaqueBodies": 0,
|
||||
"bodyReview": [],
|
||||
"effects": [
|
||||
"Creates traffic_collection_state with server FK cascade."
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "0012_traffic_counter_identity.sql",
|
||||
"sha256": "491bb39fe85554d16881696a10d5d5732f60ecec1be92a8dd7b9584ce5a8a9b8",
|
||||
"transaction": "owner-transaction",
|
||||
"selfMarker": false,
|
||||
"opaqueBodies": 0,
|
||||
"bodyReview": [],
|
||||
"effects": [
|
||||
"Adds traffic_samples native_counter_identity."
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "0013_scheduler_retention_index.sql",
|
||||
"sha256": "8ae429ba11fa02163bb3a12f25fc8fdd9708b98badf4a37dfc3a5124740ced0e",
|
||||
"transaction": "owner-transaction",
|
||||
"selfMarker": false,
|
||||
"opaqueBodies": 0,
|
||||
"bodyReview": [],
|
||||
"effects": [
|
||||
"Creates ordinary scheduler_runs finished_at index; CONCURRENTLY appears ONLY in comments/deployment advice, not executed SQL."
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "0014_router_logs.sql",
|
||||
"sha256": "6f82eeb26101bf20574a0c6cb1f5778d6e8151290f0733e7896c5b6e26fe985f",
|
||||
"transaction": "owner-transaction",
|
||||
"selfMarker": false,
|
||||
"opaqueBodies": 0,
|
||||
"bodyReview": [],
|
||||
"effects": [
|
||||
"Creates/seeds router_log_settings, collection state, identity-backed log entries and six log indexes; log history deliberately lacks server FK."
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "0015_mcp.sql",
|
||||
"sha256": "13c279fe2ecbabeef1b1c588fbf58a374141d931b45914aac5803efc496496b7",
|
||||
"transaction": "self-managed-single-transaction",
|
||||
"selfMarker": false,
|
||||
"opaqueBodies": 0,
|
||||
"bodyReview": [],
|
||||
"effects": [
|
||||
"Creates initial public MCP settings/connections/credentials/audit/requests/protections/plans/operations/steps/budgets/read leases/grants/secrets/artifacts/OAuth/routing profiles/drift tables and indexes; settings seed; identity definition on audit; no file marker."
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "0016_mcp_routing_observations.sql",
|
||||
"sha256": "7b35e4ca6522a7d0e086c518a9f84bfb7bf6eabc3d6ce3a6f537117cc49b6e8e",
|
||||
"transaction": "self-managed-single-transaction",
|
||||
"selfMarker": false,
|
||||
"opaqueBodies": 0,
|
||||
"bodyReview": [],
|
||||
"effects": [
|
||||
"Creates MCP routing observations and leg index."
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "0017_mcp_controller.sql",
|
||||
"sha256": "bb48c740b4770d979babc2684d9ad7d9e7577083197a74fe00c47e3ce59c1673",
|
||||
"transaction": "self-managed-single-transaction",
|
||||
"selfMarker": false,
|
||||
"opaqueBodies": 0,
|
||||
"bodyReview": [],
|
||||
"effects": [
|
||||
"Adds routing profile credential/generation/policy-version fields and enabled/open-drift indexes."
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "0018_mcp_owner_validation.sql",
|
||||
"sha256": "e52f618f7dfd2085b888032367d0d2b56a4004efa4fd6a0561dc3fb1e5f83a61",
|
||||
"transaction": "self-managed-single-transaction",
|
||||
"selfMarker": false,
|
||||
"opaqueBodies": 0,
|
||||
"bodyReview": [],
|
||||
"effects": [
|
||||
"Adds owner_valid_until default now to connections; transactionally backfills existing owner-validity metadata."
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "0019_mcp_recovery.sql",
|
||||
"sha256": "820899500c0d00c67a9b34dfd6769cd146c7688e39014d8b4b4cc7bfb6180bb0",
|
||||
"transaction": "self-managed-single-transaction",
|
||||
"selfMarker": false,
|
||||
"opaqueBodies": 0,
|
||||
"bodyReview": [],
|
||||
"effects": [
|
||||
"Creates encrypted operation checkpoints."
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "0020_mcp_management.sql",
|
||||
"sha256": "cdd5c527965793060818a4eb600443376470f315e621e451d6ef882840b140ab",
|
||||
"transaction": "self-managed-single-transaction",
|
||||
"selfMarker": false,
|
||||
"opaqueBodies": 0,
|
||||
"bodyReview": [],
|
||||
"effects": [
|
||||
"Creates management channels with server FK cascade."
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "0021_mcp_path_checks.sql",
|
||||
"sha256": "8f7c67a99848ecc6ce48c2573ac95f44bd9222c4c74da2aa3283598dff304d35",
|
||||
"transaction": "self-managed-single-transaction",
|
||||
"selfMarker": false,
|
||||
"opaqueBodies": 0,
|
||||
"bodyReview": [],
|
||||
"effects": [
|
||||
"Creates path checks and latest-check index."
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "0022_mcp_recovery_intents.sql",
|
||||
"sha256": "a40c06e73a552400ee903848f9149923d85d2176e211c6c91681fff61ffc2b04",
|
||||
"transaction": "self-managed-single-transaction",
|
||||
"selfMarker": false,
|
||||
"opaqueBodies": 0,
|
||||
"bodyReview": [],
|
||||
"effects": [
|
||||
"Creates recovery intents tied to operation/profile."
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "0023_mcp_protocol_limits.sql",
|
||||
"sha256": "0feac9580a7f6af649b664cfc129135c5c6a682bc50f0534550ed65f3b82fb10",
|
||||
"transaction": "self-managed-single-transaction",
|
||||
"selfMarker": false,
|
||||
"opaqueBodies": 0,
|
||||
"bodyReview": [],
|
||||
"effects": [
|
||||
"Creates unauthorized request buckets."
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "0024_user_interface_types.sql",
|
||||
"sha256": "dd381cb3a33a41e9dc1ca351c632646f9dce7f018b6f40e93e0c393437c9e7b5",
|
||||
"transaction": "self-managed-single-transaction",
|
||||
"selfMarker": false,
|
||||
"opaqueBodies": 0,
|
||||
"bodyReview": [],
|
||||
"effects": [
|
||||
"Drops/replaces interface type CHECK, adding ipsec."
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "0025_mcp_portal_revocation.sql",
|
||||
"sha256": "c47b01c4976f39f13d77c67606c6681c17fb1c83b0036c9157134ccf322f6f37",
|
||||
"transaction": "self-managed-single-transaction",
|
||||
"selfMarker": false,
|
||||
"opaqueBodies": 0,
|
||||
"bodyReview": [],
|
||||
"effects": [
|
||||
"Creates portal owner revocations/events and event-time index."
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "0026_mcp_owner_claims_issued.sql",
|
||||
"sha256": "5569287afd46676a9e8d738dcce2a143c7125b329ad8d892d2f59719b221e815",
|
||||
"transaction": "self-managed-single-transaction",
|
||||
"selfMarker": false,
|
||||
"opaqueBodies": 0,
|
||||
"bodyReview": [],
|
||||
"effects": [
|
||||
"Adds owner_claims_issued_at default epoch; transactionally backfills legacy claim timestamp."
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "0027_mcp_readiness.sql",
|
||||
"sha256": "c90b16fcd376bac8c907be715b8e2d4ecd4a7171e624b9e93f884dd5f12ac159",
|
||||
"transaction": "owner-transaction",
|
||||
"selfMarker": false,
|
||||
"opaqueBodies": 0,
|
||||
"bodyReview": [],
|
||||
"effects": [
|
||||
"Creates handshake cooldown, credential-expiry/readiness and grant-credential indexes."
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "0028_mcp_artifact_scope.sql",
|
||||
"sha256": "ad76f4f4c981d6cc3dfabdaf31eddf1f65d4ba3444940e6ad6ad5b7f5bc71ac4",
|
||||
"transaction": "owner-transaction",
|
||||
"selfMarker": false,
|
||||
"opaqueBodies": 0,
|
||||
"bodyReview": [],
|
||||
"effects": [
|
||||
"Adds artifact access_scope and expiry index."
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "0029_mcp_routing_sources.sql",
|
||||
"sha256": "4e9f739407a6a6d969c746485f90de3dc0f62c09292f7ccea425697fe6317c5c",
|
||||
"transaction": "self-managed-single-transaction",
|
||||
"selfMarker": false,
|
||||
"opaqueBodies": 2,
|
||||
"bodyReview": [
|
||||
"Stored PL/pgSQL trigger function: assigns gen_random_uuid to NEW epoch only when endpoint fields change, otherwise preserves OLD epoch; RETURN NEW; body is not executed by CREATE.",
|
||||
"Executed DO: conditionally adds mcp_routing_observation_source_proof CHECK; no transaction control or external effects."
|
||||
],
|
||||
"effects": [
|
||||
"Adds server routing_endpoint_epoch with gen_random_uuid default/backfill; creates/replaces epoch trigger function, drops/recreates UPDATE trigger, adds observation source_proof and guarded CHECK.",
|
||||
"Stored trigger function generates fresh endpoint UUID on future endpoint-field changes; function definition is not top-level execution of its body; preserves unsigned historical observations."
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "0030_mcp_scoped_configuration_checkpoint.sql",
|
||||
"sha256": "4f99d83cd803be616e6512c39227eaa63e24e5335e23994af059ce8c6f10c148",
|
||||
"transaction": "self-managed-single-transaction",
|
||||
"selfMarker": false,
|
||||
"opaqueBodies": 0,
|
||||
"bodyReview": [],
|
||||
"effects": [
|
||||
"Adds sealed scoped configuration checkpoint/proof fields."
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "0031_mcp_scoped_recovery.sql",
|
||||
"sha256": "3d6380a57f18b940168f9bf584586ccbe99de0b07293e6dae033ea7071088a3f",
|
||||
"transaction": "self-managed-single-transaction",
|
||||
"selfMarker": false,
|
||||
"opaqueBodies": 0,
|
||||
"bodyReview": [],
|
||||
"effects": [
|
||||
"Creates scoped recovery intents and consistency checks."
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "0032_flow_write_receipts.sql",
|
||||
"sha256": "ca89054c375261f847d1f4a6e87ccf8f83f1f7279f957b89b0ae447b6a468020",
|
||||
"transaction": "owner-transaction",
|
||||
"selfMarker": false,
|
||||
"opaqueBodies": 0,
|
||||
"bodyReview": [],
|
||||
"effects": [
|
||||
"Creates immutable-purpose flow_write_receipts schema; no pruning or receipt insertion in migration."
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "0033_flow_settings_intents.sql",
|
||||
"sha256": "a341295c9a6f4ab09d62dbc7b30c4b0e6c947845a6ceac36bb581ff44964f748",
|
||||
"transaction": "owner-transaction",
|
||||
"selfMarker": false,
|
||||
"opaqueBodies": 0,
|
||||
"bodyReview": [],
|
||||
"effects": [
|
||||
"Creates flow_settings_intents claim schema and seeds traffic_flow_settings."
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "0034_vrf_production_tickets.sql",
|
||||
"sha256": "b5317f326f54e46e3efaf4b1969673d33b6dd2835c12ca61fa7d73abbc9b3d10",
|
||||
"transaction": "owner-transaction",
|
||||
"selfMarker": false,
|
||||
"opaqueBodies": 0,
|
||||
"bodyReview": [],
|
||||
"effects": [
|
||||
"Creates precondition-only VRF production tickets and bounded validity/state CHECKs; no executor capability."
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "0035_server_probe_journal.sql",
|
||||
"sha256": "ccc2a61b8329b083c6a5aac4d68b7dca3cf7cc715e702912a4bd1c15de36706d",
|
||||
"transaction": "self-managed-single-transaction",
|
||||
"selfMarker": true,
|
||||
"opaqueBodies": 3,
|
||||
"bodyReview": [
|
||||
"Stored trigger: reject DELETE/terminal identity changes or quarantined execution, RETURN NEW.",
|
||||
"Stored trigger: lock execution row, reject DELETE/noncontiguous stages/invalid terminal change, RETURN NEW.",
|
||||
"Stored trigger: always reject quarantine mutation; definitions execute no journal DML."
|
||||
],
|
||||
"effects": [
|
||||
"Creates probe executions/stages/quarantines with three stored immutability functions/triggers; one exact file-id marker is penultimate before COMMIT.",
|
||||
"Trigger bodies reject future journal mutations but do not execute UPDATE/DELETE during migration; no existing journal history deletion."
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "0036_diagnostic_cancellations.sql",
|
||||
"sha256": "3a9058ac307cf5b63d35f1eec081a5442baa381d29dd9bc6c1df4dded625f007",
|
||||
"transaction": "self-managed-single-transaction",
|
||||
"selfMarker": true,
|
||||
"opaqueBodies": 1,
|
||||
"bodyReview": [
|
||||
"Stored trigger function always raises immutability exception; not an executable migration transaction BEGIN."
|
||||
],
|
||||
"effects": [
|
||||
"Creates cancellation requests with stored rejecting immutability function/trigger; exact file-id marker is penultimate before COMMIT; no cancellation request inserted."
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "0037_server_probe_storage_v2.sql",
|
||||
"sha256": "b691f5748362e13feeef58b53734feb0ca3e7cc907fc7a803f103f3ce97f0748",
|
||||
"transaction": "self-managed-single-transaction",
|
||||
"selfMarker": true,
|
||||
"opaqueBodies": 11,
|
||||
"bodyReview": [
|
||||
"Executed DO under ACCESS EXCLUSIVE locks refuses any legacy anchor/stage/quarantine/cancellation, any existing named v2 relation in any namespace, or unavailable retention; no backfill or target data write.",
|
||||
"Stored GC predicate requires opt-in transaction GUC, serialized storage advisory lock, elapsed original authority/retention/safety, actual terminal MCP source and exact owner/connection/cancellation mapping; no DELETE is executed.",
|
||||
"Stored admission trigger serializes storage; fresh admission must carry current authority or cancellation; immutable scope and irreversible consume/purge/cancel transitions; DELETE only through lifetime predicate.",
|
||||
"Stored anchor trigger locks matching consumed admission and rejects expired, cancelled, purged or mismatched owner/execution/binding before INSERT/UPDATE.",
|
||||
"Replacement execution trigger preserves immutable identities and terminal receipts, rejects quarantined completion, and permits DELETE only through lifetime predicate.",
|
||||
"Stored stage trigger requires a live consumed matching admission before INSERT/UPDATE under storage advisory lock.",
|
||||
"Stored quarantine trigger requires a committed matching admission under storage advisory lock before INSERT.",
|
||||
"Stored payload trigger rejects UPDATE; INSERT requires live admission, exact terminal identity/proof and no quarantine; DELETE requires prior payload purge or full lifetime predicate.",
|
||||
"Replacement stage trigger preserves serial contiguous execution and immutable received state; DELETE only through lifetime predicate.",
|
||||
"Replacement quarantine trigger rejects mutation except DELETE independently admitted by lifetime predicate.",
|
||||
"Replacement cancellation trigger rejects mutation except DELETE whose exact operation/connection/owner maps to admission and satisfies lifetime predicate."
|
||||
],
|
||||
"effects": [
|
||||
"Atomically locks all four legacy diagnostic tables and refuses any history or unmarked v2 relation before schema DDL; fresh-only, never a historical source-generation/backfill authorization.",
|
||||
"Creates usage/admission/payload tables, one owner-age index and guarded storage functions/triggers; seeds only zero global usage, with no copied payload, admission, cancellation or owner usage.",
|
||||
"Removes inline ciphertext only from the locked empty anchor table and installs separated-payload terminal CHECK; no historical data transformation; exact file marker is penultimate before COMMIT.",
|
||||
"Stored GC definitions schedule nothing and execute no cleanup during migration; the GUC alone is insufficient to authorize future DELETE."
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "0038_server_poll_journal_and_writer_guards.sql",
|
||||
"sha256": "2db0bb00f561dd01ace54066d6649478e06fa26e7e417db113d0801dcdac9fa2",
|
||||
"transaction": "self-managed-single-transaction",
|
||||
"selfMarker": true,
|
||||
"opaqueBodies": 3,
|
||||
"bodyReview": [
|
||||
"Executed DO first owns exclusive transaction advisory key8723110, then refuses any preexisting unmarked poll relation, journal/writer function or writer trigger before schema installation; no user rows copied or inferred.",
|
||||
"Stored immutable journal trigger always raises on UPDATE/DELETE; no mutation executes during function creation.",
|
||||
"Stored partition writer row trigger acquires shared transaction advisory key8723110 and returns OLD for DELETE or NEW otherwise; not a substitute for pre-relation writer admission."
|
||||
],
|
||||
"effects": [
|
||||
"Owns exclusive transaction advisory writer exclusion before target DDL; installs five append-only poll tables and five immutability triggers without inserting poll data.",
|
||||
"Installs ALWAYS row guards on every pinned partition root, inherited by descendants; no ordinary data or partition deletion, exact atomic file marker immediately precedes COMMIT. Mixed unadopted replicas require deployment drain."
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "0039_server_poll_storage.sql",
|
||||
"sha256": "ce3957c2c0196114e85856c93db63fc6e31b61a4866486bc685e017477023f27",
|
||||
"transaction": "self-managed-single-transaction",
|
||||
"selfMarker": true,
|
||||
"opaqueBodies": 6,
|
||||
"bodyReview": [
|
||||
"Executed DO refuses any nonempty prior poll journal, missing authoritative retention, or unmarked storage table/function/trigger before schema changes; no historical rows copied or inferred.",
|
||||
"Stored GC predicate owns the global storage advisory lock and locks the actual current retention row and requires explicit GC mode, original expiry and persisted/current retention after latest activity, with exact terminal MCP poll operation/connection/owner proof; no deletion occurs in the predicate.",
|
||||
"Stored admission guard atomically charges fixed global/owner quotas on fresh identity; only one-token consume and monotonic actual-row metering are allowed; guarded expired all-child-free deletion atomically refunds both counters.",
|
||||
"Stored row guard requires matching charged admission, consumed live identity and bounded private-plan stages; quarantine uses matching original charged scope, and server clock normalizes all journal activity.",
|
||||
"Stored AFTER INSERT meter charges only actual inserted journal rows, bounded to 8MiB and the private stage-row budget; skipped ON CONFLICT rows never charge.",
|
||||
"Stored replacement immutability guard continues to reject every UPDATE; DELETE requires the independently verified complete original lifetime and terminal source, not merely the GC session flag."
|
||||
],
|
||||
"effects": [
|
||||
"Locks five prior journal tables and installs two storage tables, bounded fixed reservation triggers and expiry guards in one transaction; only the global zero-usage seed is inserted.",
|
||||
"No automatic GC/worker/public route activation or backfill is granted; exact file marker immediately precedes COMMIT. Nonempty intermediate installations fail without data loss."
|
||||
]
|
||||
}
|
||||
,{
|
||||
"file": "0040_mcp_routing_reservations.sql",
|
||||
"sha256": "7fd6533442a6756065685c1905cf71af333c284ca4f96e571284602d5e0852ef",
|
||||
"transaction": "self-managed-single-transaction",
|
||||
"selfMarker": true,
|
||||
"opaqueBodies": 1,
|
||||
"bodyReview": [
|
||||
"Executed preflight under exclusive shared-slot lock refuses every legacy reserved row and unmarked proposal relation, column or constraint; no evidence, debit or authority is reconstructed."
|
||||
],
|
||||
"effects": [
|
||||
"Adds two empty reservation ledgers and same-source slot columns and shape constraints, preserving all existing manual ciphertext and proofs; no capabilities, backfill, controller or public dispatch activation.",
|
||||
"Existing legacy reserved data fails before DDL; atomic file marker immediately precedes COMMIT."
|
||||
]
|
||||
}
|
||||
,{
|
||||
"file": "0041_server_poll_terminal_archives.sql",
|
||||
"sha256": "fbb8935ce977cfc507801f01beb8f303d4817ed63c6bdd4c05799768dfaac9d4",
|
||||
"transaction": "self-managed-single-transaction",
|
||||
"selfMarker": true,
|
||||
"opaqueBodies": 13,
|
||||
"bodyReview": [
|
||||
"Executed preflight owns the existing poll storage advisory lock and refuses unmarked archive relations, fields, functions or triggers before schema changes.",
|
||||
"Executed locked preflight checks the entire nonempty previous full-admission ledger and exact original accounting CHECK names/definitions before replacing only those two constraints; no old receipt, ciphertext, proof or identity is rewritten or inferred.",
|
||||
"Stored eligibility predicate locks the admission, nonblockingly acquires real operation/execution/receipt advisory keys and NOWAIT source rows, and requires approved applied error-free MCP source, one verified global step, original plan/approval/admission time ordering, exact receipt mapping, all four confirmed native stages and complete contiguous confirmed publications recorded before original expiry, no quarantine and elapsed original expiry/activity plus safety; final generic publication may finish later without renewing authority.",
|
||||
"Stored source producer returns the complete raw admission, every journal row and exact terminal operation/plan/step in stable order only after eligibility; retains all fields and bounds decoded JSON to16MiB; no archive is produced during migration.",
|
||||
"Stored aggregate predicate compares global and every owner heavy/compact/archive-byte charges with all admission rows and archive presence; no usage certificate or refunded free slot is fabricated.",
|
||||
"Stored archive INSERT guard requires a one-shot compaction claim from a distinct prior SQL transaction and recomputes exact source-text SHA256 and length under ownership locks, validates fixed encoded gzip/encryption bounds and original activity/expiry, and stamps current SQL transaction and time; private archive and historical MAC authentication remains the fixed application producer/reader responsibility.",
|
||||
"Replacement journal immutability function permits DELETE only for a current-transaction immutable archive transition or independently admitted original-lifetime GC; no session flag grants compaction.",
|
||||
"Replacement admission guard permits one independently durable null-to-UUID compaction claim only for an eligible verified full source, with server-stamped time/xid8 and every other column unchanged; caller cannot supply the claim xid and claim cannot reset, retimestamp or change during metering/compaction. It preserves original identities and used counters, atomically charges heavy or compact reservations, refuses replay/reopen and exact-ledger drift, and refunds only original-lifetime child-free GC; old byte/row caps remain unchanged.",
|
||||
"Stored AFTER archive INSERT removes every old stage/receipt/anchor and transitions the matching admission with exact two-row and ciphertext-plus32768 charge; no data is compacted during migration.",
|
||||
"Replacement GC predicate preserves persisted/current retention and original activity/expiry for both layouts; terminal source locks are nonblocking and never lock current connection authority; unknown full records retain their existing full-lifetime GC path.",
|
||||
"Stored archive immutability function rejects every UPDATE and permits DELETE only under the independent full original lifetime GC predicate.",
|
||||
"Stored deferred final guard checks no old journal children survive compaction and the complete final ledger/archive shape; partial deletion, orphan archive or incomplete charge rolls back the whole transaction.",
|
||||
"Stored early row guard rejects every late insertion, including quarantine, against a compacted tombstone; no source or claim is reopened."
|
||||
],
|
||||
"effects": [
|
||||
"Additively preserves nonempty0040 full poll admissions and journal bytes while adding one immutable archive table, full/compact state and exact aggregate accounting under existing fixed owner/global byte and row caps.",
|
||||
"No archive, compaction, historical backfill, current authority renewal, worker schedule, native request or production public activation occurs; only the atomic file marker is inserted immediately before COMMIT."
|
||||
]
|
||||
}
|
||||
,{
|
||||
"file": "0042_server_poll_settings_epoch.sql",
|
||||
"sha256": "b0bcfe9afffda346c9f2fd2ba867ce1708716af0823735583a6bc9217c72de60",
|
||||
"transaction": "self-managed-single-transaction",
|
||||
"selfMarker": true,
|
||||
"opaqueBodies": 1,
|
||||
"bodyReview": [
|
||||
"Stored fixed-target invoker trigger stamps a fresh UUID on INSERT and every complete configuration change; only last_collected_at,last_duration_ms,last_error,updated_at and the epoch itself are excluded from UPDATE comparison. Caller epochs are ignored, unknown future fields remain significant and ENABLE ALWAYS enforces replica-mode writes. No native work or capability is issued."
|
||||
],
|
||||
"effects": [
|
||||
"Locks the three settings tables and additively backfills one non-null UUID column while preserving every existing field; repeated or colliding unmarked DDL refuses atomically.",
|
||||
"Adds one pinned fixed-search-path function and three always row triggers. No journal source, plan, receipt, quota, runtime flag or old migration is rewritten; delayed approval remains inactive until separately integrated."
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "0043_mcp_routing_forward_charges.sql",
|
||||
"sha256": "ef556e6d83dfad47bee7476b9aed0e83127e8dc15649048a6d77b2df079ed84d",
|
||||
"transaction": "self-managed-single-transaction",
|
||||
"selfMarker": true,
|
||||
"opaqueBodies": 3,
|
||||
"bodyReview": [
|
||||
"Executed collision preflight refuses an existing unmarked forward-charge table; all DDL and the exact file marker share one transaction.",
|
||||
"Stored invoker source trigger checks actual pending operation, executing v7 routing plan, profile and connection identity at INSERT. It is consistency only, never a permission or MAC verifier.",
|
||||
"Stored invoker immutable guard refuses UPDATE/TRUNCATE and recent or unresolved DELETE. Retention uses locked current settings and SQL clock; logical historical identities intentionally have no lifetime FK or cascade."
|
||||
],
|
||||
"effects": [
|
||||
"Adds one immutable one-unit persistent-forward charge table, two bounded-read indexes and three triggers; existing0040 two-slot reservations and0031 inverse accounting remain unchanged.",
|
||||
"No backfill, timestamp aging, operation replay, new grants, worker activation, source enrollment or READY occurs. Historical issuer42 artifacts do not attest this schema."
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "0044_mcp_routing_manual_inverse_charges.sql",
|
||||
"sha256": "37b88d1679be9bb47f16ccc8cae7373f20f9c05e7a8f662c3775f18d78bb5613",
|
||||
"transaction": "self-managed-single-transaction",
|
||||
"selfMarker": true,
|
||||
"opaqueBodies": 4,
|
||||
"bodyReview": [
|
||||
"Collision preflight refuses an unmarked manual-inverse table; additive DDL and exact marker commit atomically.",
|
||||
"Invoker source trigger checks actual pending recover7/executing plan, source switch7, profile version and owner/connection; it does not authenticate MAC or confer permission.",
|
||||
"Invoker immutable parent trigger refuses UPDATE/TRUNCATE and retained/current-hour or pending/running DELETE; no family lifetime FK or cascade.",
|
||||
"Invoker manual-intent INSERT fence acquires the existing global admission advisory key nonblockingly and requires exact running recover7/generic intent; old6 fresh claims refuse. Already-claimed old writers still require drained cutover."
|
||||
],
|
||||
"effects": [
|
||||
"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."
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
export const MIGRATION_MANIFEST: readonly MigrationReview[] = Object.freeze(classifiedMigrationPins.map(entry => Object.freeze({ ...entry, bodyReview: Object.freeze([...entry.bodyReview]), effects: Object.freeze([...entry.effects]) })))
|
||||
|
||||
export function validateMigrationFileSet(files: readonly string[]) {
|
||||
if (JSON.stringify([...files].sort()) !== JSON.stringify(MIGRATION_MANIFEST.map(entry => entry.file))) refuse("Migration file set changed; classify additions/removals before dispatch")
|
||||
}
|
||||
|
||||
export async function inventoryMigrationDirectory(directory: string) {
|
||||
const entries = (await readdir(directory, { withFileTypes: true })).filter(entry => entry.name.toLowerCase().endsWith(".sql"))
|
||||
if (entries.some(entry => !entry.isFile())) refuse("Migration SQL entry is not an ordinary file")
|
||||
const files = entries.map(entry => entry.name).sort()
|
||||
validateMigrationFileSet(files)
|
||||
return Promise.all(MIGRATION_MANIFEST.map(async entry => analyzeMigration(entry.file, await readFile(join(directory, entry.file)), entry)))
|
||||
}
|
||||
export const migrationManifestDigest = () => hash(JSON.stringify(MIGRATION_MANIFEST))
|
||||
|
||||
// Mandatory current table presence derived from every pinned CREATE TABLE,
|
||||
// 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 interface LegacyBaselineEvidence {
|
||||
inventoryComplete: boolean; databaseGeneration: string; unresolvedAttemptIds: readonly string[]
|
||||
markerTable: "absent" | "present"; markerIds: readonly string[]
|
||||
applicationObjects: readonly { schema: string; name: string; kind: string }[]
|
||||
/** Receipt from a separately trusted complete schema comparator. This module
|
||||
* only checks its binding; it does not manufacture a live schema attestation. */
|
||||
verifiedSchema?: { manifestDigest: string; databaseGeneration: string; exactCurrentSchema: true; observedSchemaDigest: string }
|
||||
}
|
||||
export function assessLegacyMigrationBaseline(evidence: LegacyBaselineEvidence) {
|
||||
const blocked = (reason: string) => ({ state: "blocked" as const, reason, dispatchAuthorized: false as const })
|
||||
if (!evidence.inventoryComplete || !evidence.databaseGeneration || evidence.unresolvedAttemptIds.length) return blocked("Incomplete inventory, missing generation or unresolved durable attempt")
|
||||
if (evidence.applicationObjects.length > 50000 || evidence.applicationObjects.some(object => !object.schema || !object.name || !object.kind)) return blocked("Malformed or unbounded schema object evidence")
|
||||
const ids = MIGRATION_MANIFEST.map(entry => entry.file.slice(0, -4)), actual = [...evidence.markerIds].sort()
|
||||
if (new Set(actual).size !== actual.length || actual.some(id => !ids.includes(id)) || evidence.markerTable === "absent" && actual.length) return blocked("Unknown, duplicate or inconsistent legacy marker evidence")
|
||||
const objects = evidence.applicationObjects.filter(object => !(object.schema === "public" && object.name === "schema_migrations" && object.kind === "table"))
|
||||
if (!actual.length && !objects.length) return { state: "clean-database-candidate" as const, reason: "Complete object inventory shows no application objects, independently of marker absence", dispatchAuthorized: false as const }
|
||||
if (JSON.stringify(actual) !== JSON.stringify(ids)) return blocked("Unmarked application state or legacy prefix requires private reconciliation; missing marker is not fresh/never-run evidence")
|
||||
const proof = evidence.verifiedSchema
|
||||
const present = new Set(evidence.applicationObjects.filter(object => object.schema === "public" && ["table", "partitioned-table"].includes(object.kind)).map(object => object.name))
|
||||
if (!proof || !proof.exactCurrentSchema || proof.manifestDigest !== migrationManifestDigest() || proof.databaseGeneration !== evidence.databaseGeneration || !/^[a-f0-9]{64}$/.test(proof.observedSchemaDigest) || CURRENT_REQUIRED_TABLES.some(table => !present.has(table))) return blocked("All markers need a separately verified exact current-schema attestation and mandatory table inventory before enrollment")
|
||||
return { state: "legacy-current-enrollment-candidate" as const, reason: "All exact known ids plus complete schema comparator receipt; historical executed file hashes remain unproven", dispatchAuthorized: false as const }
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { randomUUID } from "node:crypto"
|
||||
import { basename, dirname, resolve } from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
|
||||
assert.equal(process.env.MMAPP_OWN_MIGRATION_SCHEMA_INVENTORY, "true")
|
||||
const databaseUrl = new URL(process.env.DATABASE_URL ?? "")
|
||||
assert.equal(databaseUrl.hostname, "127.0.0.1"); assert.equal(databaseUrl.pathname, "/postgres")
|
||||
assert(["postgres:", "postgresql:"].includes(databaseUrl.protocol))
|
||||
assert(process.env.MCP_STATE_DIR && basename(dirname(resolve(process.env.MCP_STATE_DIR))).startsWith("mm-mcp-test-"))
|
||||
|
||||
const { Pool } = await import("pg")
|
||||
const { pool, closePool } = await import("./index.js")
|
||||
const { applySqlMigrations } = await import("./migrate.js")
|
||||
const { inventoryMigrationDirectory, migrationManifestDigest, MIGRATION_MANIFEST } = await import("./migration-manifest.js")
|
||||
const { captureMigrationSchema, compareMigrationSchemas, MigrationSchemaInventoryError } = await import("./migration-schema-inventory.js")
|
||||
const suffix = randomUUID().replaceAll("-", "").slice(0, 12), names: string[] = []
|
||||
const identifier = (value: string) => { assert(/^mm_schema_[a-z0-9_]+$/.test(value)); return '"' + value + '"' }
|
||||
const database = async (label: string, template?: string) => {
|
||||
const name = `mm_schema_${suffix}_${label}`; identifier(name)
|
||||
await pool.query(`CREATE DATABASE ${identifier(name)} TEMPLATE ${template ? identifier(template) : "template0"}`)
|
||||
names.push(name)
|
||||
const url = new URL(databaseUrl); url.pathname = "/" + name
|
||||
return { name, connection: new Pool({ connectionString: url.toString(), max: 2 }) }
|
||||
}
|
||||
async function capture(connection: InstanceType<typeof Pool>, options?: Parameters<typeof captureMigrationSchema>[1]) {
|
||||
const client = await connection.connect()
|
||||
try {
|
||||
await client.query("BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY")
|
||||
await client.query("SET LOCAL search_path=pg_catalog")
|
||||
await client.query("SET LOCAL TimeZone='UTC'")
|
||||
await client.query("SET LOCAL DateStyle='ISO, YMD'")
|
||||
await client.query("SET LOCAL statement_timeout='5s'")
|
||||
const result = await captureMigrationSchema(client, options)
|
||||
await client.query("ROLLBACK")
|
||||
return result
|
||||
} catch (error) { await client.query("ROLLBACK").catch(() => {}); throw error }
|
||||
finally { client.release() }
|
||||
}
|
||||
const cases: string[] = []
|
||||
try {
|
||||
await applySqlMigrations(pool)
|
||||
const inventory = await inventoryMigrationDirectory(fileURLToPath(new URL("../../drizzle/", import.meta.url)))
|
||||
assert.equal(inventory.length, MIGRATION_MANIFEST.length)
|
||||
const reference = await database("reference")
|
||||
let baseline: Awaited<ReturnType<typeof capture>>
|
||||
try {
|
||||
await applySqlMigrations(reference.connection)
|
||||
baseline = await capture(reference.connection)
|
||||
assert.equal(baseline.format, "pg18-application-schema-v3")
|
||||
assert(baseline.sections.relations.length >= 89)
|
||||
assert(baseline.sections.columns.length > 500)
|
||||
assert(baseline.sections.constraints.length > 100)
|
||||
assert(baseline.sections.functions.length > 0)
|
||||
assert.deepEqual(compareMigrationSchemas(baseline, await capture(reference.connection)), { equal: true, evidenceOnly: true, enrollmentAuthorized: false, sha256: baseline.sha256 })
|
||||
cases.push("all36 pinned migrations produce repeatable complete PG18 catalog evidence on an actual checked-out read-only backend")
|
||||
} finally { await reference.connection.end() }
|
||||
const independent = await database("independent")
|
||||
try {
|
||||
await applySqlMigrations(independent.connection)
|
||||
const observed = await capture(independent.connection)
|
||||
try { compareMigrationSchemas(baseline!, observed) } catch (error) {
|
||||
// This reference is built exclusively from the pinned repository SQL;
|
||||
// report field names, not function bodies/default values or user data.
|
||||
const differences = Object.keys(baseline!.sections).flatMap(key => {
|
||||
const section = key as keyof typeof baseline.sections, before = baseline!.sections[section], after = observed.sections[section]
|
||||
if (JSON.stringify(before) === JSON.stringify(after)) return []
|
||||
return [{ section, beforeRows: before.length, afterRows: after.length, fields: [...new Set(before.flatMap((row, i) => Object.keys(row).filter(field => JSON.stringify(row[field]) !== JSON.stringify(after[i]?.[field]))))] }]
|
||||
})
|
||||
console.error("Independent schema differences:", JSON.stringify(differences)); throw error
|
||||
}
|
||||
cases.push("independently migrated database with different physical OIDs matches the same exact logical schema")
|
||||
} finally { await independent.connection.end() }
|
||||
|
||||
const mismatches: [string, string][] = [
|
||||
["table", "CREATE TABLE public.mm_schema_extra(id integer)"],
|
||||
["column-default", "ALTER TABLE public.servers ALTER COLUMN name SET DEFAULT 'owned schema drift'"],
|
||||
["column-type", "ALTER TABLE public.servers ALTER COLUMN name TYPE varchar(999)"],
|
||||
["index", "CREATE INDEX mm_schema_index ON public.servers(host)"],
|
||||
["constraint", "ALTER TABLE public.servers ADD CONSTRAINT mm_schema_port CHECK(port>0) NOT VALID"],
|
||||
["foreign-key", "ALTER TABLE public.servers ADD CONSTRAINT mm_schema_self FOREIGN KEY(id) REFERENCES public.servers(id) DEFERRABLE INITIALLY DEFERRED"],
|
||||
["sequence", "ALTER SEQUENCE public.servers_id_seq INCREMENT BY 2"],
|
||||
["comment", "COMMENT ON TABLE public.servers IS 'owned schema drift'"],
|
||||
["function", "CREATE FUNCTION public.mm_schema_function() RETURNS integer LANGUAGE sql IMMUTABLE AS 'SELECT 7'"],
|
||||
["view", "CREATE VIEW public.mm_schema_view AS SELECT id FROM public.servers"],
|
||||
["partition", "CREATE TABLE public.mm_schema_partitioned(id integer) PARTITION BY RANGE(id); CREATE TABLE public.mm_schema_leaf PARTITION OF public.mm_schema_partitioned FOR VALUES FROM(0) TO(10)"],
|
||||
["trigger", "CREATE FUNCTION public.mm_schema_trigger_fn() RETURNS trigger LANGUAGE plpgsql AS 'BEGIN RETURN NEW; END'; CREATE TRIGGER mm_schema_trigger BEFORE UPDATE ON public.servers FOR EACH ROW EXECUTE FUNCTION public.mm_schema_trigger_fn()"],
|
||||
]
|
||||
for (const [label, sql] of mismatches) {
|
||||
const target = await database(label.replaceAll("-", "_"), reference.name)
|
||||
try {
|
||||
await target.connection.query(sql)
|
||||
const observed = await capture(target.connection)
|
||||
assert.throws(() => compareMigrationSchemas(baseline!, observed), MigrationSchemaInventoryError, label)
|
||||
cases.push("actual " + label + " drift differs from the complete captured baseline")
|
||||
} finally { await target.connection.end() }
|
||||
}
|
||||
const unsupported = await database("unsupported", reference.name)
|
||||
try {
|
||||
await unsupported.connection.query("ALTER TABLE public.servers ENABLE ROW LEVEL SECURITY; CREATE POLICY mm_schema_policy ON public.servers USING(true)")
|
||||
await assert.rejects(capture(unsupported.connection), MigrationSchemaInventoryError)
|
||||
cases.push("actual unsupported RLS policy refuses capture rather than disappearing from equality")
|
||||
} finally { await unsupported.connection.end() }
|
||||
const control = await database("control", reference.name)
|
||||
try {
|
||||
await control.connection.query("CREATE SCHEMA mm_control; CREATE TABLE mm_control.mm_schema_private(id integer)")
|
||||
await assert.rejects(capture(control.connection), MigrationSchemaInventoryError)
|
||||
const scoped = await capture(control.connection, { scope: { applicationNamespaces: ["public"], excludedControlNamespaces: ["mm_control"] } })
|
||||
assert(scoped.sections.namespaces.some(row => row.namespace === "mm_control"))
|
||||
assert(!scoped.sections.relations.some(row => row.namespace === "mm_control"))
|
||||
await control.connection.query("CREATE VIEW public.mm_schema_external_view AS SELECT id FROM mm_control.mm_schema_private")
|
||||
await assert.rejects(capture(control.connection, { scope: { applicationNamespaces: ["public"], excludedControlNamespaces: ["mm_control"] } }), MigrationSchemaInventoryError)
|
||||
cases.push("extra namespace refuses by default; explicit control exclusion is inventoried and application cross-dependency still refuses")
|
||||
} finally { await control.connection.end() }
|
||||
const client = await pool.connect()
|
||||
try { await assert.rejects(captureMigrationSchema(client), MigrationSchemaInventoryError); cases.push("autocommit/read-write connection cannot claim a stable read-only catalog snapshot") }
|
||||
finally { client.release() }
|
||||
console.log("Owned migration schema inventory SQL:", JSON.stringify({ cases: cases.length, names: cases, databases: names.length, actualSql: true, pinnedFiles: inventory.length, manifestDigest: migrationManifestDigest(), logicalSchemaDigest: baseline!.sha256, dispatcherChanged: false, enrollmentAuthorized: false, productionDatabaseTouched: false }))
|
||||
} finally { await closePool() }
|
||||
@@ -0,0 +1,338 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { readFileSync } from "node:fs"
|
||||
import { captureMigrationSchema, compareMigrationSchemas, MIGRATION_SCHEMA_QUERIES, MIGRATION_SCHEMA_PARAMETERS, MigrationSchemaInventoryError, type SchemaSection } from "./migration-schema-inventory.js"
|
||||
|
||||
type Row = Record<string, unknown>
|
||||
type Data = Record<SchemaSection, Row[]>
|
||||
const metadata = { backend_pid: "71", transaction_started: "2026-10-25 10:00:00+00", read_only: "on", isolation: "repeatable read", search_path: "pg_catalog", timezone: "UTC", date_style: "ISO, YMD", standard_strings: "on", server_version: "180000", statement_timeout: "5s" }
|
||||
const relation = (name: string, kind = "r", extra: Row = {}): Row => ({ namespace: "public", name, kind, persistence: "p", owner: "postgres", acl: null, options: null, access_method: ["r", "p"].includes(kind) ? "heap" : kind === "i" ? "btree" : null, tablespace: null, toast_options: null, of_type: null, row_security: false, force_row_security: false, replica_identity: "d", is_partition: false, populated: true, partition_key: null, partition_bound: null, ...extra })
|
||||
const column = (relation = "servers", name = "id", extra: Row = {}): Row => ({ namespace: "public", relation, position: 1, name, dropped: false, type: "bigint", type_modifier: -1, dimensions: 0, not_null: true, identity: "a", generated: "", storage: "p", compression: "", statistics_target: null, has_missing: false, missing_value: null, acl: null, options: null, comment: null, collation: null, default_expression: null, is_local: true, inherited_count: 0, ...extra })
|
||||
function fixture(): Data {
|
||||
const data = Object.fromEntries(Object.keys(MIGRATION_SCHEMA_QUERIES).map(key => [key, []])) as unknown as Data
|
||||
data.database = [{ encoding: "UTF8", collate: "C.UTF-8", ctype: "C.UTF-8", locale_provider: "c", locale: null, locale_rules: null, collation_version: null, default_tablespace: "pg_default", owner: "postgres", acl: null }]
|
||||
data.namespaces = [{ namespace: "public", owner: "pg_database_owner", acl: ["=U/pg_database_owner", "pg_database_owner=UC/pg_database_owner"] }]
|
||||
data.relations = [relation("servers"), relation("servers_id_seq", "S"), relation("servers_pkey", "i"), relation("server_view", "v"), relation("traffic_samples", "p", { partition_key: "RANGE (sampled_at)" }), relation("traffic_samples_d_20261025", "r", { is_partition: true, partition_bound: "FOR VALUES FROM ('2026-10-25') TO ('2026-10-26')" })]
|
||||
data.columns = [column(), column("servers", "computed", { position: 2, identity: "", generated: "s", default_expression: "(id + 1)" })]
|
||||
data.constraints = [{ namespace: "public", name: "servers_pkey", relation: "servers", domain: null, kind: "p", deferrable: false, deferred: false, validated: true, is_local: true, inherited_count: 0, no_inherit: true, enforced: true, period: false, definition: "PRIMARY KEY (id)", parent_namespace: null, parent_relation: null, parent_constraint: null, foreign_namespace: null, foreign_relation: null }]
|
||||
data.indexes = [{ namespace: "public", name: "servers_pkey", relation: "servers", definition: "CREATE UNIQUE INDEX servers_pkey ON public.servers USING btree (id)", unique_index: true, primary_index: true, exclusion: false, immediate: true, clustered: false, valid: true, ready: true, live: true, replica_identity: false, check_xmin: false, nulls_not_distinct: false, attributes: 1, key_attributes: 1, keys: "1", key_options: "0", operator_classes: ["pg_catalog.int8_ops"], collations: ["<none>"], expressions: null, predicate: null }]
|
||||
data.inheritance = [{ namespace: "public", child: "traffic_samples_d_20261025", parent_namespace: "public", parent: "traffic_samples", position: 1, detach_pending: false }]
|
||||
data.sequences = [{ namespace: "public", name: "servers_id_seq", type: "bigint", start: "1", increment: "1", maximum: "9223372036854775807", minimum: "1", cache: "1", cycle: false, owner_namespace: "public", owner_relation: "servers", owner_column: "id", owner_dependency: "i" }]
|
||||
data.views = [{ namespace: "public", name: "server_view", kind: "v", definition: "SELECT id FROM public.servers;" }]
|
||||
data.types = [{ namespace: "public", name: "servers", kind: "c", category: "C", owner: "postgres", acl: null, length: -1, by_value: false, alignment: "d", storage: "x", not_null: false, preferred: false, defined: true, delimiter: ",", dimensions: 0, input_function: "record_in(cstring,oid,integer)", output_function: "record_out(record)", receive_function: "record_recv(internal,oid,integer)", send_function: "record_send(record)", modifier_input: "-", modifier_output: "-", analyze_function: "-", subscript_function: "-", base_type: null, element_type: null, relation_namespace: "public", relation: "servers", default_expression: null, default_literal: null, collation: null, enum_labels: [], range_definition: null }]
|
||||
data.functions = [{ namespace: "public", name: "guard", arguments: "", kind: "f", language: "plpgsql", owner: "postgres", acl: null, definition: "CREATE OR REPLACE FUNCTION public.guard() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RETURN NEW; END $$", external_library: null, security_definer: false, leakproof: false, strict: false, volatility: "v", parallel: "u", cost: "100", rows: "0", support: "-", configuration: null }]
|
||||
data.triggers = [{ namespace: "public", relation: "servers", name: "guard", internal: false, enabled: "A", type: 19, definition: "CREATE TRIGGER guard BEFORE UPDATE ON public.servers FOR EACH ROW EXECUTE FUNCTION public.guard()", function_namespace: "public", function_name: "guard", function_arguments: "", arguments: "", condition: null, deferrable: false, initially_deferred: false, old_table: null, new_table: null, parent_relation: null, parent_trigger: null, constraint_name: null }]
|
||||
for (const section of ["database", "namespaces", "relations", "columns", "constraints", "types", "functions", "triggers"] as const) for (const row of data[section]) row.comment = null
|
||||
for (const row of data.constraints) row.restore_definition = row.kind === "c" ? row.definition : null
|
||||
return data
|
||||
}
|
||||
function client(data: Data, transform: (envelope: Row, section: SchemaSection, index: number) => void = () => {}) {
|
||||
let count = 0
|
||||
const calls: string[] = []
|
||||
return { calls, query: async (sql: string, values?: unknown[]) => {
|
||||
const section = Object.entries(MIGRATION_SCHEMA_QUERIES).find(([, query]) => query === sql)?.[0] as SchemaSection | undefined
|
||||
assert(section, "Only exact fixed catalog SQL may execute")
|
||||
assert.deepEqual(values, ["database", "namespaces"].includes(section) ? [] : [["public"]]); calls.push(section)
|
||||
const envelope: Row = { ...metadata, objects: structuredClone(data[section]) }
|
||||
transform(envelope, section, count++); return { rows: [envelope] }
|
||||
} }
|
||||
}
|
||||
const snapshot = (data = fixture()) => captureMigrationSchema(client(data))
|
||||
let cases = 0
|
||||
async function test(name: string, work: () => Promise<void> | void) { await work(); cases++; console.log("ok", name) }
|
||||
const expected = await snapshot()
|
||||
function constraintTriggerFixture(): Data {
|
||||
const data = fixture()
|
||||
data.constraints.push({ ...data.constraints[0], name: "guard", kind: "t", deferrable: true, deferred: true, definition: "TRIGGER DEFERRABLE INITIALLY DEFERRED" })
|
||||
Object.assign(data.triggers[0], { type: 21, constraint_name: "guard", deferrable: true, initially_deferred: true, definition: "CREATE CONSTRAINT TRIGGER guard AFTER INSERT OR UPDATE ON public.servers DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION public.guard()" })
|
||||
return data
|
||||
}
|
||||
await test("user constraint triggers require a complete portable matching trigger and preserve definition evidence", async () => {
|
||||
const before = await snapshot(constraintTriggerFixture())
|
||||
const data = constraintTriggerFixture(); data.triggers[0].definition += " /* changed */"
|
||||
const after = await snapshot(data)
|
||||
assert.throws(() => compareMigrationSchemas(before, after), MigrationSchemaInventoryError)
|
||||
assert.equal(before.sections.constraints.find(row => row.kind === "t")?.name, "guard")
|
||||
})
|
||||
await test("orphan, mismatched, foreign and parented user constraint triggers refuse", async () => {
|
||||
for (const mutate of [
|
||||
(data: Data) => { data.triggers = [] },
|
||||
(data: Data) => { data.triggers[0].name = "other" },
|
||||
(data: Data) => { data.triggers[0].relation = "other" },
|
||||
(data: Data) => { data.triggers[0].constraint_name = null },
|
||||
(data: Data) => { data.triggers[0].deferrable = false },
|
||||
(data: Data) => { data.triggers[0].initially_deferred = false },
|
||||
(data: Data) => { data.triggers[0].internal = true },
|
||||
(data: Data) => { data.triggers[0].function_namespace = "external" },
|
||||
(data: Data) => { data.constraints[1].domain = "domain" },
|
||||
(data: Data) => { data.constraints[1].foreign_namespace = "public"; data.constraints[1].foreign_relation = "servers" },
|
||||
(data: Data) => { data.constraints[1].parent_namespace = "public"; data.constraints[1].parent_relation = "servers"; data.constraints[1].parent_constraint = "parent" },
|
||||
(data: Data) => { data.triggers[0].parent_relation = "servers"; data.triggers[0].parent_trigger = "parent" },
|
||||
]) { const data = constraintTriggerFixture(); mutate(data); await assert.rejects(snapshot(data), MigrationSchemaInventoryError) }
|
||||
})
|
||||
await test("same canonical actual content compares only as nonauthorizing evidence", async () => {
|
||||
const data = fixture()
|
||||
for (const rows of Object.values(data)) rows.reverse()
|
||||
data.namespaces[0].acl = [...data.namespaces[0].acl as string[]].reverse()
|
||||
const observed = await snapshot(data)
|
||||
assert.equal(expected.sha256, observed.sha256)
|
||||
assert.deepEqual(compareMigrationSchemas(expected, observed), { equal: true, evidenceOnly: true, enrollmentAuthorized: false, sha256: expected.sha256 })
|
||||
})
|
||||
await test("fixed queries cover all required object classes without mutable sequence values", () => {
|
||||
assert.equal(Object.keys(MIGRATION_SCHEMA_QUERIES).length, 13)
|
||||
for (const sql of Object.values(MIGRATION_SCHEMA_QUERIES)) { assert(sql.includes("WITH captured AS MATERIALIZED")); assert(sql.includes("LIMIT ")); assert(!sql.includes("last_value")); assert(!sql.includes("set_config(")); assert(!sql.includes("nextval(")) }
|
||||
assert(MIGRATION_SCHEMA_QUERIES.constraints.includes("conenforced")); assert(MIGRATION_SCHEMA_QUERIES.constraints.includes("conperiod"))
|
||||
assert(MIGRATION_SCHEMA_QUERIES.triggers.includes("CREATE CONSTRAINT TRIGGER <internal>"))
|
||||
})
|
||||
await test("each exact catalog query binds only its declared positional parameters", async () => {
|
||||
assert.deepEqual(Object.keys(MIGRATION_SCHEMA_PARAMETERS).sort(), Object.keys(MIGRATION_SCHEMA_QUERIES).sort())
|
||||
const data = fixture(), calls = new Set<SchemaSection>()
|
||||
await captureMigrationSchema({ query: async (sql, values) => {
|
||||
const section = (Object.keys(MIGRATION_SCHEMA_QUERIES) as SchemaSection[]).find(key => MIGRATION_SCHEMA_QUERIES[key] === sql)
|
||||
assert(section, "Query identity must be the fixed catalog statement")
|
||||
const expectedValues = section === "database" || section === "namespaces" ? [] : [["public"]]
|
||||
const placeholders = [...new Set([...sql.matchAll(/\$(\d+)/g)].map(match => Number(match[1])))].sort((a, b) => a - b)
|
||||
assert.deepEqual(placeholders, expectedValues.length ? [1] : [], section)
|
||||
assert.equal(MIGRATION_SCHEMA_PARAMETERS[section], expectedValues.length ? "application-namespaces" : "none", section)
|
||||
assert.deepEqual(values, expectedValues, section)
|
||||
calls.add(section)
|
||||
return { rows: [{ ...metadata, objects: structuredClone(data[section]) }] }
|
||||
} })
|
||||
assert.deepEqual([...calls].sort(), Object.keys(MIGRATION_SCHEMA_QUERIES).sort())
|
||||
})
|
||||
await test("catalog internal char concatenation operands have explicit text casts", () => {
|
||||
assert(MIGRATION_SCHEMA_QUERIES.unsupported.includes("||a.defaclobjtype::text FROM pg_catalog.pg_default_acl"))
|
||||
// Audit every concatenation against internal-char catalog fields used by this
|
||||
// inventory. Names (nspname/relname/etc.) are PostgreSQL name, not char.
|
||||
const internalChar = /(?:\b\w+\.(?:defaclobjtype|relkind|relpersistence|relreplident|attidentity|attgenerated|attstorage|attcompression|contype|deptype|typtype|typcategory|typalign|typstorage|typdelim|prokind|provolatile|proparallel|tgenabled|datlocprovider))(?![A-Za-z_0-9])/g
|
||||
for (const sql of Object.values(MIGRATION_SCHEMA_QUERIES)) for (const match of sql.matchAll(internalChar)) {
|
||||
const before = sql.slice(0, match.index).trimEnd(), after = sql.slice(match.index! + match[0].length)
|
||||
if (before.endsWith("||") || /^\s*(?:::text)?\s*\|\|/.test(after)) assert(after.startsWith("::text"), match[0])
|
||||
}
|
||||
assert(MIGRATION_SCHEMA_QUERIES.triggers.includes("||t.tgtype::text"))
|
||||
assert(MIGRATION_SCHEMA_QUERIES.triggers.includes("||pt.tgtype::text"))
|
||||
})
|
||||
await test("query failure identifies only its fixed section and preserves original cause", async () => {
|
||||
const original = Object.assign(new Error("untrusted database detail"), { code: "42725" })
|
||||
const sql = client(fixture())
|
||||
await assert.rejects(captureMigrationSchema({ query: async (query, values) => {
|
||||
if (query === MIGRATION_SCHEMA_QUERIES.unsupported) throw original
|
||||
return sql.query(query, values)
|
||||
} }), error => {
|
||||
assert(error instanceof MigrationSchemaInventoryError)
|
||||
assert.equal(error.message, "Catalog query failed: unsupported")
|
||||
assert.equal(error.cause, original)
|
||||
return true
|
||||
})
|
||||
})
|
||||
await test("PG18 null/default, zero/disabled and positive statistics targets remain distinct", async () => {
|
||||
const captured: Awaited<ReturnType<typeof snapshot>>[] = []
|
||||
for (const statisticsTarget of [null, 0, 100]) {
|
||||
const data = fixture(); data.columns[0].statistics_target = statisticsTarget
|
||||
const result = await snapshot(data)
|
||||
assert.equal(result.sections.columns.find(row => row.name === "id")?.statistics_target, statisticsTarget)
|
||||
assert.equal(compareMigrationSchemas(result, await snapshot(data)).equal, true)
|
||||
captured.push(result)
|
||||
}
|
||||
for (let a = 0; a < captured.length; a++) for (let b = a + 1; b < captured.length; b++) {
|
||||
assert.notEqual(captured[a].sha256, captured[b].sha256)
|
||||
assert.throws(() => compareMigrationSchemas(captured[a], captured[b]), MigrationSchemaInventoryError)
|
||||
}
|
||||
assert(MIGRATION_SCHEMA_QUERIES.columns.includes("a.attstattarget::int AS statistics_target"))
|
||||
assert(!MIGRATION_SCHEMA_QUERIES.columns.includes("COALESCE(a.attstattarget"))
|
||||
})
|
||||
await test("nullable statistics target does not permit nonintegers or other null integers", async () => {
|
||||
for (const value of ["100", true, 1.5, Number.NaN, Number.MAX_SAFE_INTEGER + 1]) {
|
||||
const data = fixture(); data.columns[0].statistics_target = value
|
||||
await assert.rejects(snapshot(data), MigrationSchemaInventoryError)
|
||||
}
|
||||
for (const field of ["position", "type_modifier", "dimensions", "inherited_count"]) {
|
||||
const data = fixture(); data.columns[0][field] = null
|
||||
await assert.rejects(snapshot(data), MigrationSchemaInventoryError)
|
||||
}
|
||||
})
|
||||
await test("pinned runtime default caches retain physical evidence but compare the same logical schema", async () => {
|
||||
const migration = readFileSync(new URL("../../drizzle/0018_mcp_owner_validation.sql", import.meta.url), "utf8")
|
||||
assert(migration.includes("ADD COLUMN IF NOT EXISTS owner_valid_until timestamptz NOT NULL DEFAULT now()"))
|
||||
const captures: Awaited<ReturnType<typeof snapshot>>[] = []
|
||||
for (const cached of ['{"2026-10-25 10:00:00+00"}', '{"2026-10-25 10:01:00+00"}', null]) {
|
||||
const data = fixture()
|
||||
Object.assign(data.columns[0], { type: "timestamp with time zone", identity: "", default_expression: "now()", has_missing: cached !== null, missing_value: cached })
|
||||
const result = await snapshot(data)
|
||||
assert.equal(result.sections.columns.find(row => row.name === "id")?.missing_value, cached)
|
||||
assert.equal(result.observedPhysical.columns.find(row => row.name === "id")?.missing_value, cached)
|
||||
captures.push(result)
|
||||
}
|
||||
for (const captured of captures.slice(1)) {
|
||||
assert.equal(compareMigrationSchemas(captures[0], captured).equal, true)
|
||||
assert.equal(captures[0].sha256, captured.sha256)
|
||||
assert.notEqual(captures[0].observedPhysical.sha256, captured.observedPhysical.sha256)
|
||||
}
|
||||
for (const change of [{ default_expression: "clock_timestamp()" }, { type: "timestamp without time zone" }, { not_null: false }, { identity: "a" }]) {
|
||||
const data = fixture()
|
||||
Object.assign(data.columns[0], { type: "timestamp with time zone", identity: "", default_expression: "now()", ...change })
|
||||
const changed = await snapshot(data)
|
||||
assert.throws(() => compareMigrationSchemas(captures[0], changed), MigrationSchemaInventoryError)
|
||||
}
|
||||
})
|
||||
await test("physical cache shapes and independently tampered physical evidence refuse", async () => {
|
||||
for (const change of [{ has_missing: true, missing_value: null }, { has_missing: false, missing_value: "{1}" }, { has_missing: true, missing_value: 1 }, { has_missing: true, missing_value: [1] }]) {
|
||||
const data = fixture(); Object.assign(data.columns[0], change)
|
||||
await assert.rejects(snapshot(data), MigrationSchemaInventoryError)
|
||||
}
|
||||
const tampered = structuredClone(expected); tampered.observedPhysical.columns[0].missing_value = "{forged}"
|
||||
assert.throws(() => compareMigrationSchemas(expected, tampered), MigrationSchemaInventoryError)
|
||||
const rawTampered = structuredClone(expected)
|
||||
Object.assign(rawTampered.sections.columns[0], { has_missing: true, missing_value: "{changed}" })
|
||||
assert.throws(() => compareMigrationSchemas(expected, rawTampered), MigrationSchemaInventoryError)
|
||||
})
|
||||
for (const [name, change] of [
|
||||
["column type", (data: Data) => { data.columns[0].type = "integer" }],
|
||||
["identity", (data: Data) => { data.columns[0].identity = "d" }],
|
||||
["generated expression", (data: Data) => { data.columns[1].default_expression = "(id + 2)" }],
|
||||
["constraint enforcement", (data: Data) => { data.constraints[0].enforced = false }],
|
||||
["index predicate", (data: Data) => { data.indexes[0].predicate = "(id > 1)" }],
|
||||
["partition bound", (data: Data) => { data.relations[5].partition_bound = "DEFAULT" }],
|
||||
["sequence cache", (data: Data) => { data.sequences[0].cache = "10" }],
|
||||
["view definition", (data: Data) => { data.views[0].definition = "SELECT id+1 FROM public.servers;" }],
|
||||
["function body", (data: Data) => { data.functions[0].definition = "different stored source" }],
|
||||
["trigger enabled state", (data: Data) => { data.triggers[0].enabled = "D" }],
|
||||
["TOAST options", (data: Data) => { data.relations[0].toast_options = ["autovacuum_enabled=false"] }],
|
||||
] as const) await test("same table names do not hide " + name + " drift", async () => {
|
||||
const data = fixture(); change(data)
|
||||
assert.deepEqual(data.relations.map(row => row.name), fixture().relations.map(row => row.name))
|
||||
const observed = await snapshot(data)
|
||||
assert.throws(() => compareMigrationSchemas(expected, observed), MigrationSchemaInventoryError)
|
||||
})
|
||||
await test("hash/boolean as attestation and altered captured content are refused", () => {
|
||||
assert.throws(() => compareMigrationSchemas({ sha256: expected.sha256, equal: true } as never, expected), MigrationSchemaInventoryError)
|
||||
const changed = structuredClone(expected); changed.sections.columns[0].type = "changed"
|
||||
assert.throws(() => compareMigrationSchemas(expected, changed), MigrationSchemaInventoryError)
|
||||
})
|
||||
await test("unsupported object classes and external procedural semantics refuse", async () => {
|
||||
for (const mutate of [
|
||||
(data: Data) => { data.unsupported = [{ kind: "publication", identity: "unknown" }] },
|
||||
(data: Data) => { data.relations[0].kind = "f" },
|
||||
(data: Data) => { data.functions[0].language = "c" },
|
||||
(data: Data) => { data.functions[0].kind = "p" },
|
||||
(data: Data) => { data.types[0].kind = "b" },
|
||||
(data: Data) => { data.inheritance[0].detach_pending = true },
|
||||
(data: Data) => { data.triggers[0].function_namespace = "excluded_control" },
|
||||
(data: Data) => { data.namespaces.push({ namespace: "unknown_schema", owner: "postgres", acl: null }) },
|
||||
]) { const data = fixture(); mutate(data); await assert.rejects(snapshot(data), MigrationSchemaInventoryError) }
|
||||
})
|
||||
await test("unsupported preflight runs before any detailed expression/type deparser", async () => {
|
||||
const data = fixture(); data.unsupported = [{ kind: "unsafe-type-io", identity: "public.external_type" }]
|
||||
const sql = client(data)
|
||||
await assert.rejects(captureMigrationSchema(sql), MigrationSchemaInventoryError)
|
||||
assert.deepEqual(sql.calls, ["database", "namespaces", "unsupported"])
|
||||
})
|
||||
await test("unknown/missing row fields, null PG18 semantics and overflow refuse", async () => {
|
||||
for (const transform of [
|
||||
(row: Row) => { row.objects = null },
|
||||
(row: Row) => { row.objects = Array.from({ length: 20001 }, () => fixture().database[0]) },
|
||||
(row: Row) => { (row.objects as Row[])[0].unknown = true },
|
||||
(row: Row) => { delete (row.objects as Row[])[0].encoding },
|
||||
]) await assert.rejects(captureMigrationSchema(client(fixture(), (row, section) => { if (section === "database") transform(row) })), MigrationSchemaInventoryError)
|
||||
const data = fixture(); data.constraints[0].enforced = null; await assert.rejects(snapshot(data), MigrationSchemaInventoryError)
|
||||
})
|
||||
await test("readonly snapshot and fixed PG18 deparse context are mandatory", async () => {
|
||||
for (const replacement of [{ read_only: "off" }, { isolation: "read committed" }, { search_path: "public" }, { timezone: "Europe/Moscow" }, { date_style: "ISO, MDY" }, { server_version: "170000" }, { statement_timeout: "0" }, { statement_timeout: "60s" }]) await assert.rejects(captureMigrationSchema(client(fixture(), row => Object.assign(row, replacement))), MigrationSchemaInventoryError)
|
||||
})
|
||||
await test("backend/transaction/session changes midcapture refuse", async () => {
|
||||
for (const replacement of [{ backend_pid: "72" }, { transaction_started: "2026-10-25 10:01:00+00" }, { server_version: "180001" }]) await assert.rejects(captureMigrationSchema(client(fixture(), (row, _section, index) => { if (index > 0) Object.assign(row, replacement) })), MigrationSchemaInventoryError)
|
||||
})
|
||||
await test("explicit control exclusion is exact and cannot hide unknown namespaces", async () => {
|
||||
const data = fixture(); data.namespaces.push({ namespace: "bootstrap_control", owner: "postgres", acl: null, comment: null })
|
||||
await assert.rejects(snapshot(data), MigrationSchemaInventoryError)
|
||||
const observed = await captureMigrationSchema(client(data), { scope: { applicationNamespaces: ["public"], excludedControlNamespaces: ["bootstrap_control"] } })
|
||||
assert.throws(() => compareMigrationSchemas(expected, observed), MigrationSchemaInventoryError)
|
||||
})
|
||||
await test("invalid scope and expired deadline dispatch zero SQL", async () => {
|
||||
for (const options of [{ scope: { applicationNamespaces: ["public;DROP"] } }, { scope: { applicationNamespaces: ["public"], excludedControlNamespaces: ["public"] } }, { deadlineAt: Date.now() - 1 }]) {
|
||||
const sql = client(fixture()); await assert.rejects(captureMigrationSchema(sql, options), MigrationSchemaInventoryError); assert.equal(sql.calls.length, 0)
|
||||
}
|
||||
})
|
||||
function withDroppedSlot(data: Data): Data {
|
||||
data.columns[1].position = 3
|
||||
data.columns.push(column("servers", "........pg.dropped.2........", { position: 2, dropped: true, type: null, identity: "", not_null: false }))
|
||||
return data
|
||||
}
|
||||
await test("v3 tombstone compaction preserves visible order and exact raw/physical evidence", async () => {
|
||||
const compact = await snapshot(fixture()), gapped = await snapshot(withDroppedSlot(fixture()))
|
||||
assert.equal(gapped.format, "pg18-application-schema-v3")
|
||||
assert.equal(compareMigrationSchemas(gapped, compact).equal, true)
|
||||
assert.equal(gapped.sections.columns.find(c => c.name === "computed")?.position, 3)
|
||||
assert(gapped.sections.columns.some(c => c.dropped === true))
|
||||
assert.notEqual(gapped.observedPhysical.sha256, compact.observedPhysical.sha256)
|
||||
const legacy = structuredClone(compact) as unknown as { format: string }
|
||||
legacy.format = "pg18-application-schema-v2"
|
||||
assert.throws(() => compareMigrationSchemas(legacy as never, legacy as never), /format\/version/)
|
||||
})
|
||||
await test("v3 normalizes every indkey key and INCLUDE slot by live column identity while expression0 remains distinct", async () => {
|
||||
const compact = fixture(), gapped = withDroppedSlot(fixture())
|
||||
for (const [data, keys] of [[compact, "2 0 1"], [gapped, "3 0 1"]] as const) Object.assign(data.indexes[0], {
|
||||
keys, attributes: 3, key_attributes: 2, expressions: "(id + 1)", key_options: "0 0",
|
||||
definition: "CREATE UNIQUE INDEX servers_pkey ON public.servers (computed,((id + 1))) INCLUDE(id)"
|
||||
})
|
||||
const before = await snapshot(gapped), after = await snapshot(compact)
|
||||
assert.equal(compareMigrationSchemas(before, after).equal, true)
|
||||
assert.equal(before.sections.indexes[0].keys, "3 0 1")
|
||||
assert(JSON.parse(before.canonical).sections.indexes[0].keys.includes("expression"))
|
||||
for (const keys of ["1 0 2", "2 0 2", "2 1 1"]) {
|
||||
const changed = structuredClone(compact); changed.indexes[0].keys = keys
|
||||
const observed = await snapshot(changed)
|
||||
assert.throws(() => compareMigrationSchemas(after, observed), MigrationSchemaInventoryError)
|
||||
}
|
||||
})
|
||||
await test("v3 rejects dangling/dropped/system attnums, expression ambiguity, duplicate identity and vector arity", async () => {
|
||||
for (const keys of ["2", "99", "-1", "0", "1 1"]) {
|
||||
const data = withDroppedSlot(fixture()); data.indexes[0].keys = keys
|
||||
await assert.rejects(snapshot(data), MigrationSchemaInventoryError)
|
||||
}
|
||||
const duplicatePosition = fixture(); duplicatePosition.columns[1].position = 1
|
||||
await assert.rejects(snapshot(duplicatePosition), MigrationSchemaInventoryError)
|
||||
const duplicateName = fixture(); duplicateName.columns[1].name = "id"
|
||||
await assert.rejects(snapshot(duplicateName), MigrationSchemaInventoryError)
|
||||
})
|
||||
await test("v3 visible reorder is refused even when index still names the same logical column", async () => {
|
||||
const data = fixture(); data.columns[0].position = 2; data.columns[1].position = 1; data.indexes[0].keys = "2"
|
||||
const observed = await snapshot(data)
|
||||
assert.throws(() => compareMigrationSchemas(expected, observed), MigrationSchemaInventoryError)
|
||||
})
|
||||
await test("v3 drop/readd same middle name cannot hide changed visible order", async () => {
|
||||
const original = fixture(); original.columns.push(column("servers", "tail", { position: 3, identity: "" }))
|
||||
const changed = withDroppedSlot(fixture()); changed.columns.find(c => c.name === "computed")!.position = 4
|
||||
changed.columns.push(column("servers", "tail", { position: 3, identity: "" }))
|
||||
const before = await snapshot(original), after = await snapshot(changed)
|
||||
assert.throws(() => compareMigrationSchemas(before, after), MigrationSchemaInventoryError)
|
||||
})
|
||||
await test("v3 retains inheritance order and every live type/default/collation/identity/generated/index expression/INCLUDE field", async () => {
|
||||
for (const mutate of [
|
||||
(data: Data) => { data.inheritance[0].position = 2 },
|
||||
(data: Data) => { data.columns[1].type = "integer" },
|
||||
(data: Data) => { data.columns[1].collation = "pg_catalog.C" },
|
||||
(data: Data) => { data.columns[1].default_expression = "(id + 9)" },
|
||||
(data: Data) => { data.columns[1].generated = "v" },
|
||||
(data: Data) => { data.columns[0].identity = "d" },
|
||||
(data: Data) => { data.indexes[0].expressions = "(id + 7)" },
|
||||
(data: Data) => { data.indexes[0].definition += " INCLUDE(computed)" },
|
||||
]) { const data = fixture(); mutate(data); const observed = await snapshot(data); assert.throws(() => compareMigrationSchemas(expected, observed), MigrationSchemaInventoryError) }
|
||||
})
|
||||
await test("v3 PostgreSQL CHECK restore rendering preserves logical equality and strict original raw definition fingerprint", async () => {
|
||||
const make = (definition: string, restore = "CHECK (id > 0 AND id < 10)") => {
|
||||
const data = fixture(); data.constraints.push({ ...data.constraints[0], name: "owned_check", kind: "c", no_inherit: false, definition, restore_definition: restore }); return data
|
||||
}
|
||||
const before = await snapshot(make("CHECK (((id > 0) AND (id < 10)))")), after = await snapshot(make("CHECK ((id > 0 AND id < 10))"))
|
||||
assert.equal(compareMigrationSchemas(before, after).equal, true)
|
||||
assert.notEqual(before.observedRaw.sha256, after.observedRaw.sha256)
|
||||
assert.equal(before.observedPhysical.sha256, after.observedPhysical.sha256)
|
||||
const tampered = structuredClone(before); tampered.sections.constraints.find(c => c.name === "owned_check")!.definition = "tampered raw definition"
|
||||
assert.throws(() => compareMigrationSchemas(before, tampered), /raw catalog/)
|
||||
for (const restore of ["CHECK (id > 0 OR id < 10)", "CHECK (id >= 0 AND id < 10)", "CHECK (id IS NULL AND id < 10)"]) {
|
||||
const changed = await snapshot(make(restore, restore)); assert.throws(() => compareMigrationSchemas(before, changed), MigrationSchemaInventoryError)
|
||||
}
|
||||
const unvalidated = make("CHECK (id > 0 AND id < 10)"); unvalidated.constraints.find(c => c.name === "owned_check")!.validated = false
|
||||
const changed = await snapshot(unvalidated); assert.throws(() => compareMigrationSchemas(before, changed), MigrationSchemaInventoryError)
|
||||
})
|
||||
console.log(`migration-schema-inventory v3: ${cases} inert raw-catalog capture/comparison scenarios passed; no configured SQL`)
|
||||
|
||||
@@ -0,0 +1,424 @@
|
||||
import { createHash } from "node:crypto"
|
||||
|
||||
/** Read-only evidence capture. No configured pool, schema mutation, enrollment
|
||||
* decision, attestation/signing or dispatcher import exists in this module. */
|
||||
export interface SchemaInventoryClient { query(sql: string, values?: unknown[]): Promise<{ rows: Record<string, unknown>[] }> }
|
||||
export interface SchemaInventoryScope { applicationNamespaces: readonly string[]; excludedControlNamespaces?: readonly string[] }
|
||||
export class MigrationSchemaInventoryError extends Error { readonly code = "MIGRATION_SCHEMA_REFUSED" }
|
||||
type Json = null | boolean | number | string | Json[] | { [key: string]: Json }
|
||||
function fail(message: string): never { throw new MigrationSchemaInventoryError(message) }
|
||||
const digest = (value: string) => createHash("sha256").update(value).digest("hex")
|
||||
const acl = (value: string) => `CASE WHEN ${value} IS NULL THEN NULL ELSE ARRAY(SELECT x::text FROM pg_catalog.unnest(${value}) x ORDER BY x::text) END`
|
||||
const options = (value: string) => `CASE WHEN ${value} IS NULL THEN NULL ELSE ARRAY(SELECT x FROM pg_catalog.unnest(${value}) x ORDER BY x) END`
|
||||
const application = "n.nspname=ANY($1::text[])"
|
||||
const rowLimit = 20000
|
||||
const metadata = `pg_catalog.pg_backend_pid()::text AS backend_pid,
|
||||
pg_catalog.transaction_timestamp()::text AS transaction_started,
|
||||
pg_catalog.current_setting('transaction_read_only') AS read_only,
|
||||
pg_catalog.current_setting('transaction_isolation') AS isolation,
|
||||
pg_catalog.current_setting('search_path') AS search_path,
|
||||
pg_catalog.current_setting('TimeZone') AS timezone,
|
||||
pg_catalog.current_setting('DateStyle') AS date_style,
|
||||
pg_catalog.current_setting('standard_conforming_strings') AS standard_strings,
|
||||
pg_catalog.current_setting('server_version_num') AS server_version,
|
||||
pg_catalog.current_setting('statement_timeout') AS statement_timeout`
|
||||
const envelope = (section: string, sql: string, limit = rowLimit) => `/* migration-schema:${section} */ WITH captured AS MATERIALIZED (${sql} LIMIT ${limit + 1}) SELECT ${metadata},
|
||||
CASE WHEN (SELECT count(*)>${limit} OR COALESCE(max(pg_catalog.octet_length(pg_catalog.to_jsonb(captured)::text)),0)>1048576 OR COALESCE(sum(pg_catalog.octet_length(pg_catalog.to_jsonb(captured)::text)),0)>33554432 FROM captured)
|
||||
THEN NULL ELSE COALESCE((SELECT pg_catalog.jsonb_agg(pg_catalog.to_jsonb(captured)) FROM captured),'[]'::jsonb) END AS objects`
|
||||
|
||||
/** Fixed catalog contract: only SELECTs, only namespace names as bound values.
|
||||
* LIMIT+1 overflow refuses; nothing is silently truncated or normalized away.
|
||||
* Caller configures one checked-out PG18 repeatable-read READ ONLY transaction
|
||||
* with search_path=pg_catalog, TimeZone=UTC, DateStyle='ISO, YMD', bounded
|
||||
* statement_timeout before capture. Capture never SETs/BEGINs/COMMITs for it. */
|
||||
export const MIGRATION_SCHEMA_QUERIES = Object.freeze({
|
||||
database: envelope("database", `SELECT pg_catalog.pg_encoding_to_char(d.encoding) AS encoding,d.datcollate AS collate,d.datctype AS ctype,
|
||||
d.datlocprovider::text AS locale_provider,pg_catalog.to_jsonb(d)->>'datlocale' AS locale,pg_catalog.to_jsonb(d)->>'daticurules' AS locale_rules,d.datcollversion AS collation_version,
|
||||
ts.spcname AS default_tablespace,pg_catalog.shobj_description(d.oid,'pg_database') AS comment,
|
||||
pg_catalog.pg_get_userbyid(d.datdba) AS owner,${acl("d.datacl")} AS acl FROM pg_catalog.pg_database d JOIN pg_catalog.pg_tablespace ts ON ts.oid=d.dattablespace WHERE d.datname=pg_catalog.current_database()`),
|
||||
namespaces: envelope("namespaces", `SELECT n.nspname AS namespace,pg_catalog.pg_get_userbyid(n.nspowner) AS owner,${acl("n.nspacl")} AS acl,pg_catalog.obj_description(n.oid,'pg_namespace') AS comment
|
||||
FROM pg_catalog.pg_namespace n WHERE n.nspname !~ '^pg_' AND n.nspname<>'information_schema'`),
|
||||
relations: envelope("relations", `SELECT n.nspname AS namespace,c.relname AS name,c.relkind::text AS kind,c.relpersistence::text AS persistence,
|
||||
pg_catalog.pg_get_userbyid(c.relowner) AS owner,${acl("c.relacl")} AS acl,${options("c.reloptions")} AS options,pg_catalog.obj_description(c.oid,'pg_class') AS comment,
|
||||
am.amname AS access_method,t.spcname AS tablespace,${options("toast.reloptions")} AS toast_options,
|
||||
CASE WHEN c.reloftype=0 THEN NULL ELSE pg_catalog.format_type(c.reloftype,NULL) END AS of_type,
|
||||
c.relrowsecurity AS row_security,c.relforcerowsecurity AS force_row_security,
|
||||
c.relreplident::text AS replica_identity,c.relispartition AS is_partition,c.relispopulated AS populated,
|
||||
CASE WHEN c.relkind='p' THEN pg_catalog.pg_get_partkeydef(c.oid) ELSE NULL END AS partition_key,
|
||||
CASE WHEN c.relispartition THEN pg_catalog.pg_get_expr(c.relpartbound,c.oid,false) ELSE NULL END AS partition_bound
|
||||
FROM pg_catalog.pg_class c JOIN pg_catalog.pg_namespace n ON n.oid=c.relnamespace
|
||||
LEFT JOIN pg_catalog.pg_am am ON am.oid=c.relam LEFT JOIN pg_catalog.pg_tablespace t ON t.oid=c.reltablespace LEFT JOIN pg_catalog.pg_class toast ON toast.oid=c.reltoastrelid WHERE ${application}`),
|
||||
columns: envelope("columns", `SELECT n.nspname AS namespace,c.relname AS relation,a.attnum::int AS position,a.attname AS name,a.attisdropped AS dropped,
|
||||
CASE WHEN a.atttypid=0 THEN NULL ELSE pg_catalog.format_type(a.atttypid,a.atttypmod) END AS type,a.atttypmod AS type_modifier,
|
||||
a.attndims::int AS dimensions,a.attnotnull AS not_null,a.attidentity::text AS identity,a.attgenerated::text AS generated,
|
||||
a.attstorage::text AS storage,a.attcompression::text AS compression,a.attstattarget::int AS statistics_target,
|
||||
a.atthasmissing AS has_missing,a.attmissingval::text AS missing_value,${acl("a.attacl")} AS acl,${options("a.attoptions")} AS options,pg_catalog.col_description(c.oid,a.attnum) AS comment,
|
||||
CASE WHEN co.oid IS NULL THEN NULL ELSE cn.nspname||'.'||co.collname END AS collation,
|
||||
pg_catalog.pg_get_expr(ad.adbin,ad.adrelid,false) AS default_expression,a.attislocal AS is_local,a.attinhcount::int AS inherited_count
|
||||
FROM pg_catalog.pg_attribute a JOIN pg_catalog.pg_class c ON c.oid=a.attrelid JOIN pg_catalog.pg_namespace n ON n.oid=c.relnamespace
|
||||
LEFT JOIN pg_catalog.pg_attrdef ad ON ad.adrelid=a.attrelid AND ad.adnum=a.attnum
|
||||
LEFT JOIN pg_catalog.pg_collation co ON co.oid=a.attcollation LEFT JOIN pg_catalog.pg_namespace cn ON cn.oid=co.collnamespace
|
||||
WHERE ${application} AND a.attnum>0`, 100000),
|
||||
constraints: envelope("constraints", `SELECT n.nspname AS namespace,c.conname AS name,r.relname AS relation,t.typname AS domain,c.contype::text AS kind,
|
||||
c.condeferrable AS deferrable,c.condeferred AS deferred,c.convalidated AS validated,c.conislocal AS is_local,c.coninhcount::int AS inherited_count,pg_catalog.obj_description(c.oid,'pg_constraint') AS comment,
|
||||
c.connoinherit AS no_inherit,pg_catalog.to_jsonb(c)->'conenforced' AS enforced,pg_catalog.to_jsonb(c)->'conperiod' AS period,pg_catalog.pg_get_constraintdef(c.oid,false) AS definition,
|
||||
CASE WHEN c.contype='c' THEN pg_catalog.pg_get_constraintdef(c.oid,true) ELSE NULL END AS restore_definition,
|
||||
pn.nspname AS parent_namespace,pr.relname AS parent_relation,pc.conname AS parent_constraint,
|
||||
fn.nspname AS foreign_namespace,fr.relname AS foreign_relation
|
||||
FROM pg_catalog.pg_constraint c JOIN pg_catalog.pg_namespace n ON n.oid=c.connamespace
|
||||
LEFT JOIN pg_catalog.pg_class r ON r.oid=c.conrelid LEFT JOIN pg_catalog.pg_type t ON t.oid=c.contypid
|
||||
LEFT JOIN pg_catalog.pg_constraint pc ON pc.oid=c.conparentid LEFT JOIN pg_catalog.pg_class pr ON pr.oid=pc.conrelid LEFT JOIN pg_catalog.pg_namespace pn ON pn.oid=pr.relnamespace
|
||||
LEFT JOIN pg_catalog.pg_class fr ON fr.oid=c.confrelid LEFT JOIN pg_catalog.pg_namespace fn ON fn.oid=fr.relnamespace WHERE ${application}`),
|
||||
indexes: envelope("indexes", `SELECT n.nspname AS namespace,ic.relname AS name,tc.relname AS relation,
|
||||
pg_catalog.pg_get_indexdef(i.indexrelid) AS definition,i.indisunique AS unique_index,i.indisprimary AS primary_index,
|
||||
i.indisexclusion AS exclusion,i.indimmediate AS immediate,i.indisclustered AS clustered,i.indisvalid AS valid,i.indisready AS ready,i.indislive AS live,
|
||||
i.indisreplident AS replica_identity,i.indcheckxmin AS check_xmin,i.indnullsnotdistinct AS nulls_not_distinct,i.indnatts::int AS attributes,i.indnkeyatts::int AS key_attributes,
|
||||
ARRAY(SELECT onsp.nspname||'.'||op.opcname FROM pg_catalog.unnest(i.indclass) WITH ORDINALITY x(opid,pos) JOIN pg_catalog.pg_opclass op ON op.oid=x.opid JOIN pg_catalog.pg_namespace onsp ON onsp.oid=op.opcnamespace ORDER BY x.pos) AS operator_classes,
|
||||
ARRAY(SELECT COALESCE(cn.nspname||'.'||co.collname,'<none>') FROM pg_catalog.unnest(i.indcollation) WITH ORDINALITY x(colid,pos) LEFT JOIN pg_catalog.pg_collation co ON co.oid=x.colid LEFT JOIN pg_catalog.pg_namespace cn ON cn.oid=co.collnamespace ORDER BY x.pos) AS collations,
|
||||
i.indkey::text AS keys,i.indoption::text AS key_options,pg_catalog.pg_get_expr(i.indexprs,i.indrelid,false) AS expressions,pg_catalog.pg_get_expr(i.indpred,i.indrelid,false) AS predicate
|
||||
FROM pg_catalog.pg_index i JOIN pg_catalog.pg_class ic ON ic.oid=i.indexrelid JOIN pg_catalog.pg_namespace n ON n.oid=ic.relnamespace
|
||||
JOIN pg_catalog.pg_class tc ON tc.oid=i.indrelid WHERE ${application}`),
|
||||
inheritance: envelope("inheritance", `SELECT n.nspname AS namespace,c.relname AS child,pn.nspname AS parent_namespace,p.relname AS parent,i.inhseqno::int AS position,i.inhdetachpending AS detach_pending
|
||||
FROM pg_catalog.pg_inherits i JOIN pg_catalog.pg_class c ON c.oid=i.inhrelid JOIN pg_catalog.pg_namespace n ON n.oid=c.relnamespace
|
||||
JOIN pg_catalog.pg_class p ON p.oid=i.inhparent JOIN pg_catalog.pg_namespace pn ON pn.oid=p.relnamespace WHERE ${application}`),
|
||||
sequences: envelope("sequences", `SELECT n.nspname AS namespace,c.relname AS name,pg_catalog.format_type(s.seqtypid,NULL) AS type,
|
||||
s.seqstart::text AS start,s.seqincrement::text AS increment,s.seqmax::text AS maximum,s.seqmin::text AS minimum,s.seqcache::text AS cache,s.seqcycle AS cycle,
|
||||
own.namespace AS owner_namespace,own.relation AS owner_relation,own.column_name AS owner_column,own.dependency AS owner_dependency
|
||||
FROM pg_catalog.pg_sequence s JOIN pg_catalog.pg_class c ON c.oid=s.seqrelid JOIN pg_catalog.pg_namespace n ON n.oid=c.relnamespace
|
||||
LEFT JOIN LATERAL (SELECT rn.nspname AS namespace,r.relname AS relation,a.attname AS column_name,d.deptype::text AS dependency
|
||||
FROM pg_catalog.pg_depend d JOIN pg_catalog.pg_class r ON r.oid=d.refobjid JOIN pg_catalog.pg_namespace rn ON rn.oid=r.relnamespace
|
||||
JOIN pg_catalog.pg_attribute a ON a.attrelid=r.oid AND a.attnum=d.refobjsubid
|
||||
WHERE d.classid='pg_catalog.pg_class'::regclass AND d.objid=c.oid AND d.refclassid='pg_catalog.pg_class'::regclass AND d.deptype IN ('a','i')) own ON true
|
||||
WHERE ${application}`),
|
||||
views: envelope("views", `SELECT n.nspname AS namespace,c.relname AS name,c.relkind::text AS kind,pg_catalog.pg_get_viewdef(c.oid,false) AS definition
|
||||
FROM pg_catalog.pg_class c JOIN pg_catalog.pg_namespace n ON n.oid=c.relnamespace WHERE ${application} AND c.relkind IN ('v','m')`),
|
||||
types: envelope("types", `SELECT n.nspname AS namespace,t.typname AS name,t.typtype::text AS kind,t.typcategory::text AS category,
|
||||
pg_catalog.pg_get_userbyid(t.typowner) AS owner,${acl("t.typacl")} AS acl,pg_catalog.obj_description(t.oid,'pg_type') AS comment,t.typlen::int AS length,t.typbyval AS by_value,t.typalign::text AS alignment,t.typstorage::text AS storage,
|
||||
t.typnotnull AS not_null,t.typispreferred AS preferred,t.typisdefined AS defined,t.typdelim::text AS delimiter,t.typndims::int AS dimensions,
|
||||
t.typinput::regprocedure::text AS input_function,t.typoutput::regprocedure::text AS output_function,t.typreceive::regprocedure::text AS receive_function,t.typsend::regprocedure::text AS send_function,
|
||||
t.typmodin::regprocedure::text AS modifier_input,t.typmodout::regprocedure::text AS modifier_output,t.typanalyze::regprocedure::text AS analyze_function,t.typsubscript::regprocedure::text AS subscript_function,
|
||||
CASE WHEN t.typbasetype=0 THEN NULL ELSE pg_catalog.format_type(t.typbasetype,t.typtypmod) END AS base_type,
|
||||
CASE WHEN t.typelem=0 THEN NULL ELSE pg_catalog.format_type(t.typelem,NULL) END AS element_type,
|
||||
rn.nspname AS relation_namespace,r.relname AS relation,pg_catalog.pg_get_expr(t.typdefaultbin,0,false) AS default_expression,t.typdefault AS default_literal,
|
||||
CASE WHEN co.oid IS NULL THEN NULL ELSE cn.nspname||'.'||co.collname END AS collation,
|
||||
COALESCE((SELECT pg_catalog.jsonb_agg(e.enumlabel ORDER BY e.enumsortorder) FROM pg_catalog.pg_enum e WHERE e.enumtypid=t.oid),'[]'::jsonb) AS enum_labels,
|
||||
CASE WHEN rg.rngtypid IS NULL THEN NULL ELSE pg_catalog.jsonb_build_object('subtype',pg_catalog.format_type(rg.rngsubtype,NULL),'multirange',pg_catalog.format_type(rg.rngmultitypid,NULL),'canonical',rg.rngcanonical::regprocedure::text,'subdiff',rg.rngsubdiff::regprocedure::text,'opclass',opn.nspname||'.'||op.opcname,'collation',rcn.nspname||'.'||rc.collname) END AS range_definition
|
||||
FROM pg_catalog.pg_type t JOIN pg_catalog.pg_namespace n ON n.oid=t.typnamespace
|
||||
LEFT JOIN pg_catalog.pg_class r ON r.oid=t.typrelid LEFT JOIN pg_catalog.pg_namespace rn ON rn.oid=r.relnamespace
|
||||
LEFT JOIN pg_catalog.pg_collation co ON co.oid=t.typcollation LEFT JOIN pg_catalog.pg_namespace cn ON cn.oid=co.collnamespace
|
||||
LEFT JOIN pg_catalog.pg_range rg ON rg.rngtypid=t.oid OR rg.rngmultitypid=t.oid LEFT JOIN pg_catalog.pg_opclass op ON op.oid=rg.rngsubopc LEFT JOIN pg_catalog.pg_namespace opn ON opn.oid=op.opcnamespace
|
||||
LEFT JOIN pg_catalog.pg_collation rc ON rc.oid=rg.rngcollation LEFT JOIN pg_catalog.pg_namespace rcn ON rcn.oid=rc.collnamespace WHERE ${application}`),
|
||||
functions: envelope("functions", `SELECT n.nspname AS namespace,p.proname AS name,pg_catalog.pg_get_function_identity_arguments(p.oid) AS arguments,p.prokind::text AS kind,
|
||||
l.lanname AS language,pg_catalog.pg_get_userbyid(p.proowner) AS owner,${acl("p.proacl")} AS acl,pg_catalog.obj_description(p.oid,'pg_proc') AS comment,
|
||||
CASE WHEN p.prokind='f' THEN pg_catalog.pg_get_functiondef(p.oid) ELSE NULL END AS definition,p.probin AS external_library,
|
||||
p.prosecdef AS security_definer,p.proleakproof AS leakproof,p.proisstrict AS strict,p.provolatile::text AS volatility,p.proparallel::text AS parallel,
|
||||
p.procost::text AS cost,p.prorows::text AS rows,p.prosupport::regprocedure::text AS support,${options("p.proconfig")} AS configuration
|
||||
FROM pg_catalog.pg_proc p JOIN pg_catalog.pg_namespace n ON n.oid=p.pronamespace JOIN pg_catalog.pg_language l ON l.oid=p.prolang WHERE ${application}`),
|
||||
triggers: envelope("triggers", `SELECT n.nspname AS namespace,c.relname AS relation,
|
||||
CASE WHEN t.tgisinternal THEN 'constraint:'||COALESCE(tcon.conname,'')||':'||fn.nspname||'.'||p.proname||':'||t.tgtype::text ELSE t.tgname END AS name,
|
||||
t.tgisinternal AS internal,t.tgenabled::text AS enabled,t.tgtype::int AS type,pg_catalog.obj_description(t.oid,'pg_trigger') AS comment,
|
||||
CASE WHEN t.tgisinternal THEN pg_catalog.replace(pg_catalog.pg_get_triggerdef(t.oid,false),pg_catalog.format('CREATE CONSTRAINT TRIGGER %I',t.tgname),'CREATE CONSTRAINT TRIGGER <internal>') ELSE pg_catalog.pg_get_triggerdef(t.oid,false) END AS definition,
|
||||
fn.nspname AS function_namespace,p.proname AS function_name,pg_catalog.pg_get_function_identity_arguments(p.oid) AS function_arguments,
|
||||
pg_catalog.encode(t.tgargs,'hex') AS arguments,CASE WHEN t.tgqual IS NULL THEN NULL ELSE pg_catalog.pg_get_triggerdef(t.oid,false) END AS condition,
|
||||
t.tgdeferrable AS deferrable,t.tginitdeferred AS initially_deferred,t.tgoldtable AS old_table,t.tgnewtable AS new_table,
|
||||
pc.relname AS parent_relation,
|
||||
CASE WHEN pt.tgisinternal THEN 'constraint:'||COALESCE(pcon.conname,'')||':'||pfn.nspname||'.'||pf.proname||':'||pt.tgtype::text ELSE pt.tgname END AS parent_trigger,
|
||||
tcon.conname AS constraint_name
|
||||
FROM pg_catalog.pg_trigger t JOIN pg_catalog.pg_class c ON c.oid=t.tgrelid JOIN pg_catalog.pg_namespace n ON n.oid=c.relnamespace
|
||||
JOIN pg_catalog.pg_proc p ON p.oid=t.tgfoid JOIN pg_catalog.pg_namespace fn ON fn.oid=p.pronamespace
|
||||
LEFT JOIN pg_catalog.pg_constraint tcon ON tcon.oid=t.tgconstraint
|
||||
LEFT JOIN pg_catalog.pg_trigger pt ON pt.oid=t.tgparentid LEFT JOIN pg_catalog.pg_class pc ON pc.oid=pt.tgrelid
|
||||
LEFT JOIN pg_catalog.pg_proc pf ON pf.oid=pt.tgfoid LEFT JOIN pg_catalog.pg_namespace pfn ON pfn.oid=pf.pronamespace LEFT JOIN pg_catalog.pg_constraint pcon ON pcon.oid=pt.tgconstraint WHERE ${application}`),
|
||||
unsupported: envelope("unsupported", `SELECT 'extension' AS kind,e.extname AS identity FROM pg_catalog.pg_extension e WHERE e.extname<>'plpgsql'
|
||||
UNION ALL SELECT 'unsafe-type-io',n.nspname||'.'||t.typname FROM pg_catalog.pg_type t JOIN pg_catalog.pg_namespace n ON n.oid=t.typnamespace
|
||||
LEFT JOIN pg_catalog.pg_proc ip ON ip.oid=t.typinput LEFT JOIN pg_catalog.pg_namespace ins ON ins.oid=ip.pronamespace
|
||||
LEFT JOIN pg_catalog.pg_proc op ON op.oid=t.typoutput LEFT JOIN pg_catalog.pg_namespace ons ON ons.oid=op.pronamespace
|
||||
WHERE ${application} AND (t.typtype NOT IN ('c','d','e','r','m') AND NOT(t.typtype='b' AND t.typcategory='A' AND t.typelem<>0 AND ip.proname='array_in' AND op.proname='array_out') OR ins.nspname IS DISTINCT FROM 'pg_catalog' OR ons.nspname IS DISTINCT FROM 'pg_catalog')
|
||||
UNION ALL SELECT 'unsupported-routine',n.nspname||'.'||p.proname FROM pg_catalog.pg_proc p JOIN pg_catalog.pg_namespace n ON n.oid=p.pronamespace JOIN pg_catalog.pg_language l ON l.oid=p.prolang WHERE ${application} AND (p.prokind<>'f' OR l.lanname NOT IN ('sql','plpgsql') OR p.probin IS NOT NULL)
|
||||
UNION ALL SELECT 'unsupported-relation',n.nspname||'.'||c.relname FROM pg_catalog.pg_class c JOIN pg_catalog.pg_namespace n ON n.oid=c.relnamespace LEFT JOIN pg_catalog.pg_am am ON am.oid=c.relam WHERE ${application} AND (c.relkind NOT IN ('r','p','i','I','S','v','m','c') OR c.relpersistence NOT IN ('p','u') OR am.amname IS NOT NULL AND am.amname NOT IN ('heap','btree','hash','gist','gin','spgist','brin'))
|
||||
UNION ALL SELECT 'event-trigger',e.evtname FROM pg_catalog.pg_event_trigger e
|
||||
UNION ALL SELECT 'publication',p.pubname FROM pg_catalog.pg_publication p
|
||||
UNION ALL SELECT 'subscription',s.subname FROM pg_catalog.pg_subscription s
|
||||
UNION ALL SELECT 'policy',n.nspname||'.'||c.relname||'.'||p.polname FROM pg_catalog.pg_policy p JOIN pg_catalog.pg_class c ON c.oid=p.polrelid JOIN pg_catalog.pg_namespace n ON n.oid=c.relnamespace WHERE ${application}
|
||||
UNION ALL SELECT 'rewrite-rule',n.nspname||'.'||c.relname||'.'||r.rulename FROM pg_catalog.pg_rewrite r JOIN pg_catalog.pg_class c ON c.oid=r.ev_class JOIN pg_catalog.pg_namespace n ON n.oid=c.relnamespace WHERE ${application} AND r.rulename<>'_RETURN'
|
||||
UNION ALL SELECT 'foreign-table',n.nspname||'.'||c.relname FROM pg_catalog.pg_foreign_table f JOIN pg_catalog.pg_class c ON c.oid=f.ftrelid JOIN pg_catalog.pg_namespace n ON n.oid=c.relnamespace WHERE ${application}
|
||||
UNION ALL SELECT 'extended-statistics',n.nspname||'.'||s.stxname FROM pg_catalog.pg_statistic_ext s JOIN pg_catalog.pg_namespace n ON n.oid=s.stxnamespace WHERE ${application}
|
||||
UNION ALL SELECT 'collation',n.nspname||'.'||c.collname FROM pg_catalog.pg_collation c JOIN pg_catalog.pg_namespace n ON n.oid=c.collnamespace WHERE ${application}
|
||||
UNION ALL SELECT 'conversion',n.nspname||'.'||c.conname FROM pg_catalog.pg_conversion c JOIN pg_catalog.pg_namespace n ON n.oid=c.connamespace WHERE ${application}
|
||||
UNION ALL SELECT 'operator',n.nspname||'.'||o.oprname FROM pg_catalog.pg_operator o JOIN pg_catalog.pg_namespace n ON n.oid=o.oprnamespace WHERE ${application}
|
||||
UNION ALL SELECT 'operator-class',n.nspname||'.'||o.opcname FROM pg_catalog.pg_opclass o JOIN pg_catalog.pg_namespace n ON n.oid=o.opcnamespace WHERE ${application}
|
||||
UNION ALL SELECT 'operator-family',n.nspname||'.'||o.opfname FROM pg_catalog.pg_opfamily o JOIN pg_catalog.pg_namespace n ON n.oid=o.opfnamespace WHERE ${application}
|
||||
UNION ALL SELECT 'text-search-configuration',n.nspname||'.'||o.cfgname FROM pg_catalog.pg_ts_config o JOIN pg_catalog.pg_namespace n ON n.oid=o.cfgnamespace WHERE ${application}
|
||||
UNION ALL SELECT 'text-search-dictionary',n.nspname||'.'||o.dictname FROM pg_catalog.pg_ts_dict o JOIN pg_catalog.pg_namespace n ON n.oid=o.dictnamespace WHERE ${application}
|
||||
UNION ALL SELECT 'text-search-parser',n.nspname||'.'||o.prsname FROM pg_catalog.pg_ts_parser o JOIN pg_catalog.pg_namespace n ON n.oid=o.prsnamespace WHERE ${application}
|
||||
UNION ALL SELECT 'text-search-template',n.nspname||'.'||o.tmplname FROM pg_catalog.pg_ts_template o JOIN pg_catalog.pg_namespace n ON n.oid=o.tmplnamespace WHERE ${application}
|
||||
UNION ALL SELECT 'default-acl',COALESCE(n.nspname,'global')||':'||a.defaclobjtype::text FROM pg_catalog.pg_default_acl a LEFT JOIN pg_catalog.pg_namespace n ON n.oid=a.defaclnamespace WHERE a.defaclnamespace=0 OR ${application}
|
||||
UNION ALL SELECT 'security-label',s.provider||':'||s.classoid::regclass::text FROM pg_catalog.pg_seclabel s
|
||||
UNION ALL SELECT 'shared-security-label',s.provider||':'||s.classoid::regclass::text FROM pg_catalog.pg_shseclabel s
|
||||
UNION ALL SELECT 'transform',t.trftype::regtype::text FROM pg_catalog.pg_transform t
|
||||
UNION ALL SELECT 'cast',s.typname||'->'||t.typname FROM pg_catalog.pg_cast c JOIN pg_catalog.pg_type s ON s.oid=c.castsource JOIN pg_catalog.pg_namespace sn ON sn.oid=s.typnamespace JOIN pg_catalog.pg_type t ON t.oid=c.casttarget JOIN pg_catalog.pg_namespace tn ON tn.oid=t.typnamespace WHERE sn.nspname=ANY($1::text[]) OR tn.nspname=ANY($1::text[])
|
||||
UNION ALL SELECT 'foreign-wrapper',f.fdwname FROM pg_catalog.pg_foreign_data_wrapper f
|
||||
UNION ALL SELECT 'foreign-server',s.srvname FROM pg_catalog.pg_foreign_server s
|
||||
UNION ALL SELECT 'large-object',m.oid::text FROM pg_catalog.pg_largeobject_metadata m
|
||||
UNION ALL SELECT 'external-schema-dependency',i.identity FROM pg_catalog.pg_depend d CROSS JOIN LATERAL pg_catalog.pg_identify_object(d.refclassid,d.refobjid,d.refobjsubid) i
|
||||
WHERE i.schema IS NOT NULL AND i.schema !~ '^pg_' AND i.schema<>'information_schema' AND NOT(i.schema=ANY($1::text[])) AND (
|
||||
d.classid='pg_catalog.pg_class'::regclass AND EXISTS(SELECT 1 FROM pg_catalog.pg_class c JOIN pg_catalog.pg_namespace n ON n.oid=c.relnamespace WHERE c.oid=d.objid AND ${application})
|
||||
OR d.classid='pg_catalog.pg_proc'::regclass AND EXISTS(SELECT 1 FROM pg_catalog.pg_proc p JOIN pg_catalog.pg_namespace n ON n.oid=p.pronamespace WHERE p.oid=d.objid AND ${application})
|
||||
OR d.classid='pg_catalog.pg_type'::regclass AND EXISTS(SELECT 1 FROM pg_catalog.pg_type t JOIN pg_catalog.pg_namespace n ON n.oid=t.typnamespace WHERE t.oid=d.objid AND ${application})
|
||||
OR d.classid='pg_catalog.pg_rewrite'::regclass AND EXISTS(SELECT 1 FROM pg_catalog.pg_rewrite r JOIN pg_catalog.pg_class c ON c.oid=r.ev_class JOIN pg_catalog.pg_namespace n ON n.oid=c.relnamespace WHERE r.oid=d.objid AND ${application})
|
||||
OR d.classid='pg_catalog.pg_constraint'::regclass AND EXISTS(SELECT 1 FROM pg_catalog.pg_constraint c JOIN pg_catalog.pg_namespace n ON n.oid=c.connamespace WHERE c.oid=d.objid AND ${application})
|
||||
OR d.classid='pg_catalog.pg_trigger'::regclass AND EXISTS(SELECT 1 FROM pg_catalog.pg_trigger t JOIN pg_catalog.pg_class c ON c.oid=t.tgrelid JOIN pg_catalog.pg_namespace n ON n.oid=c.relnamespace WHERE t.oid=d.objid AND ${application})
|
||||
OR d.classid='pg_catalog.pg_attrdef'::regclass AND EXISTS(SELECT 1 FROM pg_catalog.pg_attrdef a JOIN pg_catalog.pg_class c ON c.oid=a.adrelid JOIN pg_catalog.pg_namespace n ON n.oid=c.relnamespace WHERE a.oid=d.objid AND ${application}))`),
|
||||
})
|
||||
export type SchemaSection = keyof typeof MIGRATION_SCHEMA_QUERIES
|
||||
/** Bind contract belongs to each fixed query, including the two global census
|
||||
* queries that intentionally have no namespace placeholder. */
|
||||
export const MIGRATION_SCHEMA_PARAMETERS = Object.freeze({
|
||||
database: "none", namespaces: "none",
|
||||
relations: "application-namespaces", columns: "application-namespaces",
|
||||
constraints: "application-namespaces", indexes: "application-namespaces",
|
||||
inheritance: "application-namespaces", sequences: "application-namespaces",
|
||||
views: "application-namespaces", types: "application-namespaces",
|
||||
functions: "application-namespaces", triggers: "application-namespaces",
|
||||
unsupported: "application-namespaces",
|
||||
} satisfies Record<SchemaSection, "none" | "application-namespaces">)
|
||||
// Preflight only names/catalog flags before any expression/default deparser:
|
||||
// unknown custom type output routines must never be invoked just to inspect a
|
||||
// constant. Caller must separately quiesce concurrent DDL for stable evidence.
|
||||
const sections: SchemaSection[] = ["database", "namespaces", "unsupported", ...(Object.keys(MIGRATION_SCHEMA_QUERIES) as SchemaSection[]).filter(section => !["database", "namespaces", "unsupported"].includes(section))]
|
||||
const keys: Record<SchemaSection, string> = {
|
||||
database: "encoding collate ctype locale_provider locale locale_rules collation_version default_tablespace comment owner acl",
|
||||
namespaces: "namespace owner acl comment",
|
||||
relations: "namespace name kind persistence owner acl options comment access_method tablespace toast_options of_type row_security force_row_security replica_identity is_partition populated partition_key partition_bound",
|
||||
columns: "namespace relation position name dropped type type_modifier dimensions not_null identity generated storage compression statistics_target has_missing missing_value acl options comment collation default_expression is_local inherited_count",
|
||||
constraints: "namespace name relation domain kind deferrable deferred validated is_local inherited_count comment no_inherit enforced period definition restore_definition parent_namespace parent_relation parent_constraint foreign_namespace foreign_relation",
|
||||
indexes: "namespace name relation definition unique_index primary_index exclusion immediate clustered valid ready live replica_identity check_xmin nulls_not_distinct attributes key_attributes keys key_options operator_classes collations expressions predicate",
|
||||
inheritance: "namespace child parent_namespace parent position detach_pending",
|
||||
sequences: "namespace name type start increment maximum minimum cache cycle owner_namespace owner_relation owner_column owner_dependency",
|
||||
views: "namespace name kind definition",
|
||||
types: "namespace name kind category owner acl comment length by_value alignment storage not_null preferred defined delimiter dimensions input_function output_function receive_function send_function modifier_input modifier_output analyze_function subscript_function base_type element_type relation_namespace relation default_expression default_literal collation enum_labels range_definition",
|
||||
functions: "namespace name arguments kind language owner acl comment definition external_library security_definer leakproof strict volatility parallel cost rows support configuration",
|
||||
triggers: "namespace relation name internal enabled type comment definition function_namespace function_name function_arguments arguments condition deferrable initially_deferred old_table new_table parent_relation parent_trigger constraint_name",
|
||||
unsupported: "kind identity",
|
||||
}
|
||||
function canonical(value: unknown, depth = 0): Json {
|
||||
if (depth > 12) fail("Catalog JSON depth exceeds bound")
|
||||
if (value === null || typeof value === "boolean") return value
|
||||
if (typeof value === "number") { if (!Number.isSafeInteger(value)) fail("Nonintegral/unsafe catalog number"); return value }
|
||||
if (typeof value === "string") { if (Buffer.byteLength(value) > 1048576 || value.includes("\0")) fail("Catalog string exceeds bound"); return value }
|
||||
if (Array.isArray(value)) { if (value.length > 100001) fail("Catalog array exceeds bound"); return value.map(item => canonical(item, depth + 1)) }
|
||||
if (!value || typeof value !== "object" || Object.getPrototypeOf(value) !== Object.prototype || Object.keys(value).length > 100) fail("Invalid catalog JSON value")
|
||||
return Object.fromEntries(Object.keys(value).sort().map(key => [key, canonical(Reflect.get(value, key), depth + 1)]))
|
||||
}
|
||||
function scopeValue(scope: SchemaInventoryScope) {
|
||||
if (!scope || !Array.isArray(scope.applicationNamespaces) || scope.excludedControlNamespaces !== undefined && !Array.isArray(scope.excludedControlNamespaces)) fail("Missing exact schema scope")
|
||||
const app = [...scope.applicationNamespaces].sort(), control = [...scope.excludedControlNamespaces ?? []].sort()
|
||||
if (!app.length || app.length + control.length > 16 || new Set([...app, ...control]).size !== app.length + control.length || [...app, ...control].some(name => !/^[A-Za-z_][A-Za-z_0-9]{0,62}$/.test(name) || /^pg_/i.test(name) || name === "information_schema")) fail("Invalid exact schema scope")
|
||||
return { applicationNamespaces: app, excludedControlNamespaces: control }
|
||||
}
|
||||
function session(row: Record<string, unknown>) {
|
||||
const expected = "backend_pid transaction_started read_only isolation search_path timezone date_style standard_strings server_version statement_timeout objects".split(" ").sort()
|
||||
if (JSON.stringify(Object.keys(row).sort()) !== JSON.stringify(expected)) fail("Unexpected catalog envelope")
|
||||
if (typeof row.backend_pid !== "string" || !/^\d+$/.test(row.backend_pid) || typeof row.transaction_started !== "string" || !Number.isFinite(Date.parse(row.transaction_started)) || row.read_only !== "on" || !["repeatable read", "serializable"].includes(String(row.isolation)) || row.search_path !== "pg_catalog" || row.timezone !== "UTC" || row.date_style !== "ISO, YMD" || row.standard_strings !== "on" || typeof row.server_version !== "string" || !/^18\d{4}$/.test(row.server_version)) fail("PG18 fixed-context checked-out readonly snapshot required")
|
||||
const timeout = /^(\d+(?:\.\d+)?)(ms|s|min)?$/.exec(String(row.statement_timeout))
|
||||
const millis = timeout ? Number(timeout[1]) * ({ ms: 1, s: 1000, min: 60000 }[timeout[2] ?? "ms"] ?? 0) : 0
|
||||
if (millis < 1 || millis > 10000) fail("Bounded read-only statement timeout required")
|
||||
return JSON.stringify(Object.fromEntries(expected.filter(key => key !== "objects").map(key => [key, row[key]])))
|
||||
}
|
||||
function rowsFor(section: SchemaSection, raw: unknown) {
|
||||
if (!Array.isArray(raw) || raw.length > (section === "columns" ? 100000 : rowLimit)) fail("Catalog section overflow: " + section)
|
||||
const expected = keys[section].split(" ").sort()
|
||||
const rows = raw.map(value => {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value) || JSON.stringify(Object.keys(value).sort()) !== JSON.stringify(expected)) fail("Unknown catalog row shape: " + section)
|
||||
const row = canonical(value) as Record<string, Json>
|
||||
const booleans = new Set("row_security force_row_security is_partition populated dropped not_null has_missing is_local deferrable deferred validated no_inherit enforced period unique_index primary_index exclusion immediate clustered valid ready live check_xmin nulls_not_distinct detach_pending cycle by_value preferred defined security_definer leakproof strict internal initially_deferred".split(" "))
|
||||
const numbers = new Set("position type_modifier dimensions statistics_target inherited_count attributes key_attributes length".split(" "))
|
||||
for (const [field, item] of Object.entries(row)) {
|
||||
if (booleans.has(field) || field === "replica_identity" && section === "indexes") { if (typeof item !== "boolean") fail("Invalid catalog boolean: " + section + "." + field) }
|
||||
// PG18 pg_attribute.attstattarget is nullable int2: NULL means system
|
||||
// default, while 0 disables statistics. Preserve that distinction.
|
||||
else if (section === "columns" && field === "statistics_target") { if (item !== null && typeof item !== "number") fail("Invalid catalog integer: " + section + "." + field) }
|
||||
else if (numbers.has(field) || field === "type" && section === "triggers") { if (typeof item !== "number") fail("Invalid catalog integer: " + section + "." + field) }
|
||||
else if (["acl", "options", "toast_options", "configuration", "enum_labels", "operator_classes", "collations"].includes(field)) { if (item !== null && (!Array.isArray(item) || item.some(value => typeof value !== "string"))) fail("Invalid catalog string array") }
|
||||
else if (field === "range_definition") { if (item !== null && (!item || Array.isArray(item) || typeof item !== "object" || JSON.stringify(Object.keys(item).sort()) !== JSON.stringify(["canonical", "collation", "multirange", "opclass", "subdiff", "subtype"]) || Object.values(item).some(value => value !== null && typeof value !== "string"))) fail("Invalid range definition") }
|
||||
else if (item !== null && typeof item !== "string") fail("Invalid catalog scalar: " + section + "." + field)
|
||||
}
|
||||
for (const field of ["namespace", "name"]) if (field in row && (typeof row[field] !== "string" || !row[field])) fail("Missing catalog object identity")
|
||||
for (const field of ["acl", "options", "toast_options", "configuration"]) if (Array.isArray(row[field])) {
|
||||
const values = row[field] as string[]
|
||||
if (new Set(values.map(value => field === "acl" ? value : value.split("=", 1)[0])).size !== values.length) fail("Duplicate unordered catalog option")
|
||||
row[field] = [...values].sort()
|
||||
}
|
||||
return row
|
||||
})
|
||||
const serialized = rows.map(row => JSON.stringify(row)).sort()
|
||||
if (new Set(serialized).size !== serialized.length) fail("Duplicate catalog row: " + section)
|
||||
return serialized.map(value => JSON.parse(value) as Record<string, Json>)
|
||||
}
|
||||
function validateSemantics(data: Record<SchemaSection, Record<string, Json>[]>, scope: ReturnType<typeof scopeValue>) {
|
||||
if (data.database.length !== 1 || data.unsupported.length) fail("Unsupported database/schema object class")
|
||||
if (!["b", "c", "i"].includes(String(data.database[0].locale_provider))) fail("Unknown database locale provider")
|
||||
const namespaces = data.namespaces.map(row => row.namespace).sort()
|
||||
if (JSON.stringify(namespaces) !== JSON.stringify([...scope.applicationNamespaces, ...scope.excludedControlNamespaces].sort())) fail("Unexpected or missing application/control namespace")
|
||||
for (const section of sections.filter(section => !["database", "namespaces", "unsupported"].includes(section))) for (const row of data[section]) if (!scope.applicationNamespaces.includes(String(row.namespace))) fail("Catalog row escaped exact namespace scope")
|
||||
for (const row of data.relations) if (!["r", "p", "i", "I", "S", "v", "m", "c"].includes(String(row.kind)) || !["p", "u"].includes(String(row.persistence)) || !["d", "n", "f", "i"].includes(String(row.replica_identity)) || row.access_method !== null && !["heap", "btree", "hash", "gist", "gin", "spgist", "brin"].includes(String(row.access_method))) fail("Unsupported relation or access method")
|
||||
for (const row of data.types) if (!["c", "d", "e", "r", "m"].includes(String(row.kind)) && !(row.kind === "b" && row.category === "A" && typeof row.element_type === "string")) fail("Unsupported user base/pseudo type semantics")
|
||||
for (const row of data.types) if (row.defined !== true || row.kind === "d" && typeof row.base_type !== "string" || ["r", "m"].includes(String(row.kind)) && row.range_definition === null || row.kind === "c" && (row.relation_namespace !== row.namespace || typeof row.relation !== "string")) fail("Incomplete user type semantics")
|
||||
for (const row of data.functions) if (row.kind !== "f" || !["sql", "plpgsql"].includes(String(row.language)) || !["i", "s", "v"].includes(String(row.volatility)) || !["u", "r", "s"].includes(String(row.parallel)) || typeof row.definition !== "string" || row.external_library !== null) fail("Unsupported procedure/aggregate/window/external function semantics")
|
||||
for (const row of data.columns) if (!Number.isSafeInteger(row.position) || Number(row.position) < 1 || !["", "a", "d"].includes(String(row.identity)) || !["", "s", "v"].includes(String(row.generated))) fail("Unsupported column semantics")
|
||||
for (const row of data.columns) if (row.has_missing ? typeof row.missing_value !== "string" : row.missing_value !== null) fail("Invalid fast-default physical metadata")
|
||||
for (const row of data.inheritance) if (!scope.applicationNamespaces.includes(String(row.parent_namespace)) || row.detach_pending !== false) fail("Unsupported external or pending-detach inheritance")
|
||||
for (const row of data.constraints) {
|
||||
if (row.kind === "c" ? typeof row.restore_definition !== "string" || !row.restore_definition : row.restore_definition !== null) fail("Missing fixed PostgreSQL CHECK restore definition")
|
||||
if (row.kind === "t") {
|
||||
// User constraint triggers have a pg_constraint row as well as the full
|
||||
// pg_trigger definition captured below. Join by portable identity rather
|
||||
// than OIDs, which legitimately change after dump/restore.
|
||||
const triggers = data.triggers.filter(trigger => trigger.namespace === row.namespace && trigger.relation === row.relation && trigger.name === row.name && trigger.constraint_name === row.name)
|
||||
if (typeof row.relation !== "string" || row.domain !== null || row.foreign_namespace !== null || row.foreign_relation !== null || row.parent_namespace !== null || row.parent_relation !== null || row.parent_constraint !== null || triggers.length !== 1) fail("Unsupported user constraint trigger shape")
|
||||
const trigger = triggers[0]
|
||||
if (trigger.internal !== false || trigger.deferrable !== row.deferrable || trigger.initially_deferred !== row.deferred || trigger.parent_relation !== null || trigger.parent_trigger !== null || !scope.applicationNamespaces.includes(String(trigger.function_namespace))) fail("Inconsistent user constraint trigger semantics")
|
||||
} else if (!["p", "u", "f", "c", "x", "n"].includes(String(row.kind)) || row.foreign_namespace !== null && !scope.applicationNamespaces.includes(String(row.foreign_namespace))) fail("Unsupported external constraint semantics")
|
||||
}
|
||||
for (const row of data.triggers) if (!["O", "D", "R", "A"].includes(String(row.enabled)) || row.internal && (typeof row.constraint_name !== "string" || row.function_namespace !== "pg_catalog" || !String(row.function_name).startsWith("RI_FKey_")) || !row.internal && !scope.applicationNamespaces.includes(String(row.function_namespace))) fail("Unsupported internal/external trigger semantics")
|
||||
for (const row of data.sequences) if (row.owner_namespace !== null && !scope.applicationNamespaces.includes(String(row.owner_namespace))) fail("Unsupported external sequence ownership")
|
||||
const relationNames = new Set(data.relations.map(row => row.namespace + "." + row.name))
|
||||
for (const row of data.columns) if (!relationNames.has(row.namespace + "." + row.relation)) fail("Column without captured relation")
|
||||
for (const row of data.relations.filter(row => row.kind === "S")) if (!data.sequences.some(sequence => sequence.namespace === row.namespace && sequence.name === row.name)) fail("Missing sequence definition")
|
||||
for (const row of data.relations.filter(row => ["i", "I"].includes(String(row.kind)))) if (!data.indexes.some(index => index.namespace === row.namespace && index.name === row.name)) fail("Missing index definition")
|
||||
for (const row of data.relations.filter(row => ["v", "m"].includes(String(row.kind)))) if (!data.views.some(view => view.namespace === row.namespace && view.name === row.name)) fail("Missing view definition")
|
||||
}
|
||||
export interface CapturedMigrationSchema {
|
||||
format: "pg18-application-schema-v3"
|
||||
scope: ReturnType<typeof scopeValue>
|
||||
serverVersion: string
|
||||
sections: Record<SchemaSection, Record<string, Json>[]>
|
||||
/** Exact observed fast-default history; independently content-checked, never
|
||||
* used as proof of row/data-migration equivalence. */
|
||||
observedPhysical: { columns: Record<string, Json>[]; canonical: string; sha256: string }
|
||||
/** v3 binds ALL raw fields, including original CHECK rendering, tombstones,
|
||||
* numeric index keys and the additive fixed PostgreSQL restore rendering. */
|
||||
observedRaw: { format: "pg18-raw-catalog-v1"; sha256: string }
|
||||
/** v3 restore-portable logical definitions. Raw sections and physical
|
||||
* fingerprint stay exact; dropped slots/attnums are normalized only here. */
|
||||
canonical: string
|
||||
sha256: string
|
||||
}
|
||||
/** Fast-default cached values describe existing tuple history, not the current
|
||||
* default expression. ALTER ADD DEFAULT now() (pinned migration0018) caches its
|
||||
* execution time; a table rewrite clears the cache/flag. Neither proves data
|
||||
* migration equivalence. Keep exact observations separately, never erase them. */
|
||||
function projections(data: CapturedMigrationSchema["sections"]) {
|
||||
const sortRows = (rows: Record<string, Json>[]) => rows.map(row => JSON.stringify(canonical(row))).sort().map(row => JSON.parse(row) as Record<string, Json>)
|
||||
const physicalColumns = sortRows(data.columns.map(row => Object.fromEntries(["namespace", "relation", "position", "name", "has_missing", "missing_value"].map(field => [field, row[field]]))))
|
||||
const physicalCanonical = JSON.stringify(canonical({ columns: physicalColumns }))
|
||||
const observedRaw = { format: "pg18-raw-catalog-v1" as const, sha256: digest(JSON.stringify(canonical({ format: "pg18-raw-catalog-v1", sections: data }))) }
|
||||
// Never mutate raw catalog rows. A pg_dump/restore legitimately omits dropped
|
||||
// slots and compacts physical attnums. Visible order remains semantic.
|
||||
const relationKey = (namespace: Json, relation: Json) => JSON.stringify([namespace, relation])
|
||||
const groups = new Map<string, Record<string, Json>[]>()
|
||||
for (const row of data.columns) {
|
||||
const key = relationKey(row.namespace, row.relation), rows = groups.get(key) ?? []
|
||||
rows.push(row); groups.set(key, rows)
|
||||
}
|
||||
const visibleByAttnum = new Map<string, Map<number, string>>()
|
||||
const columns: Record<string, Json>[] = []
|
||||
for (const [key, rows] of groups) {
|
||||
const ordered = [...rows].sort((a, b) => Number(a.position) - Number(b.position))
|
||||
const positions = new Set<number>(), names = new Set<string>(), visible = new Map<number, string>()
|
||||
let ordinal = 0
|
||||
for (const row of ordered) {
|
||||
const attnum = Number(row.position)
|
||||
if (!Number.isSafeInteger(attnum) || attnum < 1 || attnum > 32767 || positions.has(attnum)) fail("Ambiguous physical column position")
|
||||
positions.add(attnum)
|
||||
if (row.dropped === true) continue
|
||||
if (typeof row.name !== "string" || names.has(row.name)) fail("Ambiguous visible column identity")
|
||||
names.add(row.name); visible.set(attnum, row.name)
|
||||
columns.push({ ...Object.fromEntries(Object.entries(row).filter(([field]) => field !== "has_missing" && field !== "missing_value")), position: ++ordinal })
|
||||
}
|
||||
visibleByAttnum.set(key, visible)
|
||||
}
|
||||
const indexes = data.indexes.map(row => {
|
||||
// indkey covers key AND INCLUDE attributes. Positive attnums must resolve
|
||||
// to the exact live column of THIS indexed relation; zero is an expression
|
||||
// slot, never an absent/dropped column. Definitions/expressions/predicates,
|
||||
// key order, options, opclasses and collations remain independently pinned.
|
||||
if (typeof row.keys !== "string" || !/^(?:0|[1-9][0-9]*)(?: (?:0|[1-9][0-9]*))*$/.test(row.keys) ||
|
||||
!Number.isSafeInteger(row.attributes) || Number(row.attributes) < 1 || Number(row.attributes) > 32767) fail("Unsupported index attribute vector")
|
||||
const tokens = row.keys.split(" ")
|
||||
if (tokens.length !== row.attributes) fail("Index attribute vector arity mismatch")
|
||||
const visible = visibleByAttnum.get(relationKey(row.namespace, row.relation))
|
||||
const keys = tokens.map(token => {
|
||||
if (token === "0") {
|
||||
if (typeof row.expressions !== "string" || !row.expressions) fail("Index expression slot without expression")
|
||||
return ["expression"]
|
||||
}
|
||||
const name = visible?.get(Number(token))
|
||||
if (name === undefined) return fail("Dangling or dropped index column reference")
|
||||
return ["column", name]
|
||||
})
|
||||
return { ...row, keys: JSON.stringify(keys) }
|
||||
})
|
||||
// inheritance.position is inhseqno, not attnum: it and every other catalog
|
||||
// field/definition stay exact. The v3 marker prevents legacy reinterpretation.
|
||||
// PostgreSQL itself removes nonsemantic CHECK deparser grouping; no SQL
|
||||
// regex/rewriting or discarded CHECK is involved. Original false-rendered
|
||||
// text remains in raw sections and observedRaw, while all constraint flags,
|
||||
// identity, validation/enforcement and non-CHECK definitions remain exact.
|
||||
const constraints = data.constraints.map(row => ({ ...Object.fromEntries(Object.entries(row).filter(([field]) => field !== "restore_definition")), definition: row.kind === "c" ? row.restore_definition : row.definition }))
|
||||
const logical = { ...data, columns: sortRows(columns), indexes: sortRows(indexes), constraints: sortRows(constraints) }
|
||||
return { logical, observedPhysical: { columns: physicalColumns, canonical: physicalCanonical, sha256: digest(physicalCanonical) }, observedRaw }
|
||||
}
|
||||
/** Every query envelope must identify the SAME backend + transaction + deparse
|
||||
* context. Mutable sequence last_value/statistics/table data are not schema.
|
||||
* Excluded control namespaces must be explicit and present, never inferred
|
||||
* from marker/table existence. Their objects are outside this evidence scope. */
|
||||
export async function captureMigrationSchema(client: SchemaInventoryClient, options: { scope?: SchemaInventoryScope; deadlineAt?: number } = {}): Promise<CapturedMigrationSchema> {
|
||||
const scope = scopeValue(options.scope ?? { applicationNamespaces: ["public"] }), deadline = options.deadlineAt ?? Date.now() + 60000
|
||||
if (!Number.isSafeInteger(deadline) || deadline <= Date.now() || deadline - Date.now() > 120000) fail("Invalid schema inventory deadline")
|
||||
const data = {} as Record<SchemaSection, Record<string, Json>[]>
|
||||
let owner: string | undefined, serverVersion = "", size = 0
|
||||
for (const section of sections) {
|
||||
if (Date.now() >= deadline) fail("Schema capture deadline expired")
|
||||
const parameters = MIGRATION_SCHEMA_PARAMETERS[section] === "none" ? [] : [[...scope.applicationNamespaces]]
|
||||
let result: Awaited<ReturnType<SchemaInventoryClient["query"]>>
|
||||
try { result = await client.query(MIGRATION_SCHEMA_QUERIES[section], parameters) }
|
||||
catch (cause) { throw new MigrationSchemaInventoryError("Catalog query failed: " + section, { cause }) }
|
||||
if (result.rows.length !== 1) fail("Catalog query envelope unavailable")
|
||||
const row = result.rows[0], current = session(row)
|
||||
if (owner !== undefined && owner !== current) fail("Catalog capture changed backend, transaction or deparse context")
|
||||
owner = current; serverVersion = String(row.server_version)
|
||||
data[section] = rowsFor(section, row.objects)
|
||||
if (section === "unsupported" && data.unsupported.length) fail("Unsupported schema semantics refused before detailed deparsing")
|
||||
if (section === "namespaces" && JSON.stringify(data.namespaces.map(row => row.namespace).sort()) !== JSON.stringify([...scope.applicationNamespaces, ...scope.excludedControlNamespaces].sort())) fail("Unexpected or missing application/control namespace")
|
||||
size += Buffer.byteLength(JSON.stringify(data[section])); if (size > 33554432 || Date.now() >= deadline) fail("Schema capture byte/deadline bound exceeded")
|
||||
}
|
||||
validateSemantics(data, scope)
|
||||
const value = { format: "pg18-application-schema-v3" as const, scope, serverVersion, sections: data }
|
||||
const { logical, observedPhysical, observedRaw } = projections(data)
|
||||
const serialized = JSON.stringify(canonical({ ...value, sections: logical }))
|
||||
return { ...value, observedPhysical, observedRaw, canonical: serialized, sha256: digest(serialized) }
|
||||
}
|
||||
/** Recompute and compare captured CONTENT, never trust a supplied boolean/hash.
|
||||
* Evidence-only equality is not proof that a caller's reference is authorized.
|
||||
* Equality covers logical schema, not cached tuple history or data migration.
|
||||
* Reference generation from all36 pinned files belongs to the owner harness. */
|
||||
export function compareMigrationSchemas(expected: CapturedMigrationSchema, observed: CapturedMigrationSchema) {
|
||||
const checked = (snapshot: CapturedMigrationSchema) => {
|
||||
if (!snapshot || snapshot.format !== "pg18-application-schema-v3" || !/^18\d{4}$/.test(snapshot.serverVersion)) fail("Unsupported captured schema format/version")
|
||||
if (JSON.stringify(Object.keys(snapshot).sort()) !== JSON.stringify(["canonical", "format", "observedPhysical", "observedRaw", "scope", "sections", "serverVersion", "sha256"])) fail("Unexpected captured schema envelope")
|
||||
const scope = scopeValue(snapshot.scope)
|
||||
if (JSON.stringify(Object.keys(snapshot.sections).sort()) !== JSON.stringify([...sections].sort())) fail("Captured schema section set changed")
|
||||
const data = Object.fromEntries(sections.map(section => [section, rowsFor(section, snapshot.sections[section])])) as Record<SchemaSection, Record<string, Json>[]>
|
||||
validateSemantics(data, scope)
|
||||
const { logical, observedPhysical, observedRaw } = projections(data)
|
||||
if (JSON.stringify(canonical(snapshot.observedPhysical)) !== JSON.stringify(canonical(observedPhysical))) fail("Captured physical metadata/content digest was altered")
|
||||
if (JSON.stringify(canonical(snapshot.observedRaw)) !== JSON.stringify(canonical(observedRaw))) fail("Captured raw catalog/content digest was altered")
|
||||
const text = JSON.stringify(canonical({ format: snapshot.format, scope, serverVersion: snapshot.serverVersion, sections: logical }))
|
||||
if (text !== snapshot.canonical || digest(text) !== snapshot.sha256) fail("Captured schema content/digest was altered")
|
||||
return text
|
||||
}
|
||||
const reference = checked(expected), actual = checked(observed)
|
||||
if (reference !== actual) fail("Observed schema differs from the exact captured reference")
|
||||
return Object.freeze({ equal: true as const, evidenceOnly: true as const, enrollmentAuthorized: false as const, sha256: digest(actual) })
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { randomUUID } from "node:crypto"
|
||||
import { execFile } from "node:child_process"
|
||||
import { promisify } from "node:util"
|
||||
import { join, basename, dirname, resolve } from "node:path"
|
||||
import { writeFile, unlink } from "node:fs/promises"
|
||||
import { Pool } from "pg"
|
||||
import { captureMigrationData, compareMigrationData } from "./migration-data-inventory.js"
|
||||
import { compareMigrationSchemas } from "./migration-schema-inventory.js"
|
||||
|
||||
/** Test-only owned-cluster regression. Never called by an application entrypoint. */
|
||||
export async function acceptMigrationSchemaRestoreLayoutSql() {
|
||||
assert.equal(process.env.MMAPP_OWN_MIGRATION_DATA_INVENTORY, "true")
|
||||
const url = new URL(process.env.DATABASE_URL ?? "")
|
||||
assert.equal(url.hostname, "127.0.0.1"); assert.equal(url.pathname, "/postgres")
|
||||
assert.equal(url.username,"mcp_test"); assert(url.port&&url.port!=="5432"); assert.equal(process.env.NODE_ENV,"test")
|
||||
assert(process.env.MCP_STATE_DIR && basename(dirname(resolve(process.env.MCP_STATE_DIR))).startsWith("mm-mcp-test-"))
|
||||
const source = new Pool({ connectionString: url.toString(), max: 3 }), names: string[] = [], cases: string[] = []
|
||||
const pools: Pool[] = []
|
||||
const archive = join(process.env.MCP_STATE_DIR!, "owned-v3-tombstone.dump")
|
||||
async function database(label: string, template = "template0") {
|
||||
const name = "mm_v3_" + label + "_" + randomUUID().replaceAll("-", "")
|
||||
assert(/^mm_v3_[a-z_]+_[a-f0-9]{32}$/.test(name)); assert(template === "template0" || names.includes(template))
|
||||
await source.query('CREATE DATABASE "' + name + '" TEMPLATE "' + template + '"'); names.push(name)
|
||||
const target = new URL(url); target.pathname = "/" + name
|
||||
const pool = new Pool({ connectionString: target.toString(), max: 2 }); pools.push(pool)
|
||||
return { name, pool }
|
||||
}
|
||||
async function snapshot(pool: Pool) {
|
||||
const client = await pool.connect()
|
||||
try {
|
||||
await client.query("BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY")
|
||||
for (const query 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='5s'", "SET LOCAL row_security=off", "SET LOCAL extra_float_digits=3", "SET LOCAL bytea_output=hex", "SET LOCAL IntervalStyle=postgres", "SET LOCAL lc_monetary='C'", "SET LOCAL lc_numeric='C'"]) await client.query(query)
|
||||
return await captureMigrationData(client)
|
||||
} finally { try { await client.query("ROLLBACK") } finally { client.release(true) } }
|
||||
}
|
||||
const execute = promisify(execFile)
|
||||
try {
|
||||
await source.query("COMMENT ON DATABASE postgres IS NULL")
|
||||
await source.query(`CREATE TABLE public.layout_parent(id integer PRIMARY KEY,other integer UNIQUE); INSERT INTO public.layout_parent VALUES(1,1);
|
||||
CREATE TABLE public.layout_probe(
|
||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,doomed text,
|
||||
keep text COLLATE "C" NOT NULL DEFAULT 'owned',optional integer,
|
||||
calc integer GENERATED ALWAYS AS(optional+1) STORED,spare text,tail text,
|
||||
CONSTRAINT layout_fk FOREIGN KEY(optional) REFERENCES public.layout_parent(id),
|
||||
CONSTRAINT layout_nullable CHECK((optional IS NULL OR optional>=0) AND keep<>'blocked'));
|
||||
INSERT INTO public.layout_probe(doomed,optional,spare,tail) VALUES('discarded',1,'spare-preserved','tail-preserved');
|
||||
ALTER TABLE public.layout_probe DROP COLUMN doomed;
|
||||
CREATE INDEX layout_rich_idx ON public.layout_probe(keep COLLATE "C",(optional+2)) INCLUDE(calc) WHERE optional IS NOT NULL;
|
||||
CREATE FUNCTION public.layout_guard_fn() RETURNS trigger LANGUAGE plpgsql AS 'BEGIN RETURN NEW; END';
|
||||
CREATE TRIGGER layout_guard BEFORE UPDATE OF keep ON public.layout_probe FOR EACH ROW WHEN(NEW.keep IS DISTINCT FROM OLD.keep) EXECUTE FUNCTION public.layout_guard_fn()`)
|
||||
const before = await snapshot(source)
|
||||
const args = ["--host", url.hostname, "--port", url.port, "--username", decodeURIComponent(url.username), "--no-password"]
|
||||
const env = { ...process.env, PGPASSWORD: decodeURIComponent(url.password) }
|
||||
const dump = await execute("pg_dump", [...args, "--format=custom", "--dbname=postgres"], { env, encoding: "buffer", timeout: 30000, maxBuffer: 16777216 })
|
||||
await writeFile(archive, dump.stdout)
|
||||
const template = await database("restored")
|
||||
await execute("pg_restore", [...args, "--single-transaction", "--exit-on-error", "--dbname=" + template.name, archive], { env, timeout: 30000, maxBuffer: 1048576 })
|
||||
const restored = await snapshot(template.pool)
|
||||
assert.equal(compareMigrationSchemas(before.schema, restored.schema).equal, true); assert.equal(compareMigrationData(before, restored).equal, true)
|
||||
assert.notEqual(before.schema.observedPhysical.sha256, restored.schema.observedPhysical.sha256)
|
||||
assert.notEqual(before.schema.observedRaw.sha256, restored.schema.observedRaw.sha256)
|
||||
assert.notEqual(before.schema.sections.indexes.find(r => r.name === "layout_rich_idx")!.keys, restored.schema.sections.indexes.find(r => r.name === "layout_rich_idx")!.keys)
|
||||
assert(before.schema.sections.columns.some(r => r.dropped)); assert(!restored.schema.sections.columns.some(r => r.dropped))
|
||||
await template.pool.end()
|
||||
cases.push("real pg_dump/restore compacts tombstone and indkey/INCLUDE attnums, keeps logical schema and actual rows/sequences equal, preserves distinct raw/physical fingerprints")
|
||||
const mismatches: [string, string][] = [
|
||||
["same_middle_name_readded", "ALTER TABLE public.layout_probe DROP COLUMN spare; ALTER TABLE public.layout_probe ADD COLUMN spare text"],
|
||||
["type", "ALTER TABLE public.layout_probe ALTER COLUMN spare TYPE varchar(50)"],
|
||||
["collation", 'ALTER TABLE public.layout_probe ALTER COLUMN spare TYPE text COLLATE "C"'],
|
||||
["default", "ALTER TABLE public.layout_probe ALTER COLUMN keep SET DEFAULT 'changed'"],
|
||||
["generated", "ALTER TABLE public.layout_probe ALTER COLUMN calc SET EXPRESSION AS(optional+9)"],
|
||||
["identity", "ALTER TABLE public.layout_probe ALTER COLUMN id SET GENERATED BY DEFAULT"],
|
||||
["include_reference", 'DROP INDEX public.layout_rich_idx; CREATE INDEX layout_rich_idx ON public.layout_probe(keep COLLATE "C",(optional+2)) INCLUDE(tail) WHERE optional IS NOT NULL'],
|
||||
["index_expression", 'DROP INDEX public.layout_rich_idx; CREATE INDEX layout_rich_idx ON public.layout_probe(keep COLLATE "C",(optional+3)) INCLUDE(calc) WHERE optional IS NOT NULL'],
|
||||
["and_or_grouping", "ALTER TABLE public.layout_probe DROP CONSTRAINT layout_nullable; ALTER TABLE public.layout_probe ADD CONSTRAINT layout_nullable CHECK(optional IS NULL OR(optional>=0 AND keep<>'blocked'))"],
|
||||
["comparison_operator", "ALTER TABLE public.layout_probe DROP CONSTRAINT layout_nullable; ALTER TABLE public.layout_probe ADD CONSTRAINT layout_nullable CHECK((optional IS NULL OR optional>0) AND keep<>'blocked')"],
|
||||
["null_semantics", "ALTER TABLE public.layout_probe DROP CONSTRAINT layout_nullable; ALTER TABLE public.layout_probe ADD CONSTRAINT layout_nullable CHECK((optional IS NOT NULL OR optional>=0) AND keep<>'blocked')"],
|
||||
["not_valid", "ALTER TABLE public.layout_probe DROP CONSTRAINT layout_nullable; ALTER TABLE public.layout_probe ADD CONSTRAINT layout_nullable CHECK((optional IS NULL OR optional>=0) AND keep<>'blocked') NOT VALID"],
|
||||
["fk_reference", "ALTER TABLE public.layout_probe DROP CONSTRAINT layout_fk; ALTER TABLE public.layout_probe ADD CONSTRAINT layout_fk FOREIGN KEY(optional) REFERENCES public.layout_parent(other)"],
|
||||
["trigger_state", "ALTER TABLE public.layout_probe DISABLE TRIGGER layout_guard"],
|
||||
["function_security", "ALTER FUNCTION public.layout_guard_fn() SECURITY DEFINER"],
|
||||
]
|
||||
for (const [label, sql] of mismatches) {
|
||||
const target = await database(label, template.name)
|
||||
try {
|
||||
await target.pool.query(sql)
|
||||
const changed = await snapshot(target.pool)
|
||||
assert.throws(() => compareMigrationSchemas(restored.schema, changed.schema), label)
|
||||
cases.push("actual " + label + " drift remains refused by logical v3")
|
||||
} finally { await target.pool.end() }
|
||||
}
|
||||
const last = await database("same_last_name_readded", template.name)
|
||||
await last.pool.query("ALTER TABLE public.layout_probe DROP COLUMN tail; ALTER TABLE public.layout_probe ADD COLUMN tail text")
|
||||
const lost = await snapshot(last.pool)
|
||||
// Identical final live DDL cannot prove historical data preservation. The
|
||||
// raw fingerprint changes and actual data comparison must detect row loss.
|
||||
assert.equal(compareMigrationSchemas(restored.schema, lost.schema).equal, true)
|
||||
assert.notEqual(restored.schema.observedPhysical.sha256, lost.schema.observedPhysical.sha256)
|
||||
assert.throws(() => compareMigrationData(restored, lost))
|
||||
await last.pool.end(); cases.push("same last-column name/type/order readd is not mistaken for data preservation: actual lost values refuse data equality despite equal live DDL")
|
||||
const unsafe = await database("unsupported_policy", template.name)
|
||||
await unsafe.pool.query("ALTER TABLE public.layout_probe ENABLE ROW LEVEL SECURITY; CREATE POLICY layout_policy ON public.layout_probe USING(true)")
|
||||
await assert.rejects(snapshot(unsafe.pool)); await unsafe.pool.end(); cases.push("unsupported actual RLS policy still refuses capture")
|
||||
return { cases: cases.length, names: cases, actualSql: true, ownedDatabases: names.length, actualDumpRestore: true,
|
||||
logicalSchemaFormat: "pg18-application-schema-v3", rawAndPhysicalDifferencesRetained: true, dataPreservationNotInferredFromDdl: true,
|
||||
allWriterDrainAuthenticated: false, sourceGenerationAuthenticated: false, readyAuthorized: false, productionDatabaseTouched: false }
|
||||
} finally {
|
||||
await Promise.all(pools.map(p => p.end().catch(() => {})))
|
||||
for (const name of [...names].reverse()) await source.query('DROP DATABASE "' + name + '" WITH(FORCE)')
|
||||
await source.end(); await unlink(archive).catch(e => { if (e.code !== "ENOENT") throw e })
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import assert from "node:assert/strict"
|
||||
import {withTrafficFlowPurgeLock} from "./operation-lock.js"
|
||||
let held=false,runs=0,unlocks=0
|
||||
const releases:boolean[]=[]
|
||||
const connect=async()=>({query:async(sql:string,params:number[])=>{assert.deepEqual(params,[123]);if(sql.includes("pg_try_advisory_lock")){const acquired=!held;if(acquired)held=true;return {rows:[{acquired}]}}unlocks++;held=false;return {rows:[{released:true}]}},release:(destroy=false)=>{releases.push(destroy)}})
|
||||
let release!:()=>void
|
||||
const gate=new Promise<void>(resolve=>release=resolve)
|
||||
const first=withTrafficFlowPurgeLock(connect,123,async()=>{runs++;await gate;return "confirmed"})
|
||||
await new Promise<void>(resolve=>setImmediate(resolve))
|
||||
await assert.rejects(withTrafficFlowPurgeLock(connect,123,async()=>{runs++;return "unexpected"}),/уже выполняется/)
|
||||
assert.equal(runs,1);assert.equal(unlocks,0);assert.equal(held,true)
|
||||
release();assert.equal(await first,"confirmed");assert.equal(unlocks,1);assert.deepEqual(releases,[false,false])
|
||||
await assert.rejects(withTrafficFlowPurgeLock(connect,123,async()=>{throw Error("operation unknown")}),/operation unknown/);assert.equal(held,false);assert.equal(unlocks,2)
|
||||
for(const unlock of [false,"throws"]){let destroyed=false;const result=await withTrafficFlowPurgeLock(async()=>({query:async(sql:string)=>{if(sql.includes("pg_try"))return {rows:[{acquired:true}]};if(unlock==="throws")throw Error("unlock unavailable");return {rows:[{released:false}]}},release:(value=false)=>{destroyed=value}}),123,async()=>"committed");assert.equal(result,"committed");assert.equal(destroyed,true)}
|
||||
let released=false,discarded=false
|
||||
await assert.rejects(withTrafficFlowPurgeLock(async()=>({query:async()=>({rows:[{}]}),release:(destroy=false)=>{released=true;discarded=destroy}}),123,async()=>{throw Error("must not run")}),/не подтверждена/);assert.equal(released,true);assert.equal(discarded,true)
|
||||
console.log("NetFlow purge lock: concurrent request rejected before handler, unknown release destroys connection, operation receipt preserved")
|
||||
@@ -0,0 +1,28 @@
|
||||
type Client = {
|
||||
query: (sql: string, params: number[]) => Promise<{rows: Record<string, unknown>[]}>
|
||||
release: (destroy?: boolean) => void
|
||||
}
|
||||
/** Nonblocking session lock: a second destructive operation is rejected rather than queued. */
|
||||
export async function withOperationLock<T>(connect: () => Promise<Client>, key: number, run: () => Promise<T>, busyMessage = "Сброс NetFlow уже выполняется"): Promise<T> {
|
||||
const client = await connect()
|
||||
let owned = false, destroy = false
|
||||
try {
|
||||
destroy = true
|
||||
const result = await client.query("SELECT pg_try_advisory_lock($1) AS acquired", [key])
|
||||
if (result.rows[0]?.acquired === false) { destroy = false; throw new Error(busyMessage) }
|
||||
if (result.rows[0]?.acquired !== true) throw new Error("Блокировка сброса NetFlow не подтверждена")
|
||||
owned = true; destroy = false
|
||||
return await run()
|
||||
} finally {
|
||||
if (owned) {
|
||||
try {
|
||||
const result = await client.query("SELECT pg_advisory_unlock($1) AS released", [key])
|
||||
destroy = result.rows[0]?.released !== true
|
||||
} catch { destroy = true }
|
||||
}
|
||||
// A connection whose unlock failed must not return a held session lock to the pool.
|
||||
client.release(destroy)
|
||||
}
|
||||
}
|
||||
|
||||
export const withTrafficFlowPurgeLock = withOperationLock
|
||||
@@ -0,0 +1,154 @@
|
||||
import {createHash} from 'node:crypto'
|
||||
|
||||
export interface PartitionGuardSql {query(sql:string,values?:unknown[]):Promise<{rows:Record<string,unknown>[];command?:string}>}
|
||||
const KEY=8723110
|
||||
const MANAGED=new Set(['flow_buckets','flow_minute_stats','flow_minute_dims','flow_daily_dims','flow_hour_facts','flow_daily_facts','traffic_samples','servers_rest_ping_samples','uptime_probe_samples','uptime_resource_samples','server_snapshots','internet_path_snapshots'])
|
||||
const ROW='server_partition_writer_guard',STATEMENT='candidate_application_statement_guard',TRUNCATE='candidate_truncate_preadmitted'
|
||||
// Fixed reviewed SQL bodies. Operator provisioning must reuse these exact
|
||||
// definitions; this component cannot issue/enroll any trusted profile/state.
|
||||
const BODIES:Readonly<Record<string,string>>={
|
||||
'public.server_partition_writer_guard':" BEGIN PERFORM pg_advisory_xact_lock_shared(8723110::bigint); IF TG_OP='DELETE' THEN RETURN OLD; END IF; RETURN NEW; END; ",
|
||||
'mmapp_control.candidate_truncate_preadmitted':'\nBEGIN PERFORM mmapp_control.candidate_require_preadmission(); RETURN NULL; END ',
|
||||
'mmapp_control.candidate_require_preadmission':"\nBEGIN\n IF NOT EXISTS(SELECT 1 FROM pg_catalog.pg_locks WHERE pid=pg_catalog.pg_backend_pid()\n AND locktype='advisory' AND classid=0 AND objid=8723110 AND objsubid=1\n AND granted AND mode IN ('ShareLock','ExclusiveLock')) THEN\n RAISE EXCEPTION 'operator SQL requires preheld writer admission' USING ERRCODE='55000';\n END IF;\n PERFORM pg_catalog.pg_advisory_xact_lock_shared(8723110::bigint);\nEND ",
|
||||
}
|
||||
type Acl={grantee:string;grantor:string;privilege:string;grantable:boolean}
|
||||
type Relation={oid:string;name:string;kind:string;partition:boolean;persistence:string;rls:boolean;force_rls:boolean;owner:string;schema_owner:string;acl:Acl[];parents:string[]}
|
||||
type Trigger={oid:string;name:string;type:number;enabled:string;function_oid:string;function_namespace:string;function_name:string;parent_trigger:string;args:number;arg_bytes:number;unconditional:boolean;constraint_oid:string;deferrable:boolean;deferred:boolean;old_table:string|null;new_table:string|null}
|
||||
type Context={pid:number;xid:string|null;actor:string;login:string;role_oid:string;database_oid:string;read_only:string;isolation:string;version:number;admitted:boolean}
|
||||
type FunctionRow={oid:string;namespace:string;name:string;owner:string;schema_owner:string;language:string;return_type:string;return_namespace:string;kind:string;source:string;security_definer:boolean;configuration:string[]|null;strict:boolean;leakproof:boolean;volatility:string;parallel:string;arguments:number;returns_set:boolean;library:string|null;acl:Acl[]}
|
||||
type Policy={parent:Relation;triggers:Trigger[];functions:FunctionRow[];runtime:{oid:string;name:string};capture:{oid:string;name:string};controlOwner:string}
|
||||
function refuse(message:string):never{throw Error('Reviewed partition guard inheritance refused: '+message)}
|
||||
const identifier=(value:string)=>{if(!/^[a-z_][a-z0-9_]{0,62}$/.test(value))refuse('identifier');return '"'+value+'"'}
|
||||
const canonical=(value:unknown):string=>JSON.stringify(value,(_key,item:unknown)=>item&&typeof item==='object'&&!Array.isArray(item)?Object.fromEntries(Object.entries(item).sort(([a],[b])=>a.localeCompare(b))):item)
|
||||
const digest=(value:unknown)=>createHash('sha256').update(canonical(value)).digest('hex')
|
||||
async function rows<T>(client:PartitionGuardSql,sql:string,values:unknown[]=[],bound=32):Promise<T[]>{const result=await client.query(sql,values);if(!Array.isArray(result.rows)||result.rows.length>bound)refuse('catalog bound');return result.rows as unknown as T[]}
|
||||
async function context(client:PartitionGuardSql):Promise<Context>{
|
||||
const result=await rows<Context>(client,`SELECT pg_catalog.pg_backend_pid() AS pid,pg_catalog.pg_current_xact_id_if_assigned()::pg_catalog.text AS xid,
|
||||
CURRENT_USER::pg_catalog.text AS actor,SESSION_USER::pg_catalog.text AS login,r.oid::pg_catalog.text AS role_oid,d.oid::pg_catalog.text AS database_oid,
|
||||
pg_catalog.current_setting('transaction_read_only') AS read_only,pg_catalog.current_setting('transaction_isolation') AS isolation,pg_catalog.current_setting('server_version_num')::pg_catalog.int4 AS version,
|
||||
EXISTS(SELECT 1 FROM pg_catalog.pg_locks WHERE locktype='advisory' AND pid=pg_catalog.pg_backend_pid() AND classid=0 AND objid=$1::pg_catalog.oid AND objsubid=1 AND granted AND mode IN('ShareLock','ExclusiveLock')) AS admitted
|
||||
FROM pg_catalog.pg_roles r CROSS JOIN pg_catalog.pg_database d WHERE r.rolname=CURRENT_USER AND d.datname=pg_catalog.current_database()`,[KEY],1)
|
||||
const value=result[0]
|
||||
if(!value||!Number.isSafeInteger(value.pid)||value.pid<=0||value.actor!==value.login||value.read_only!=='off'||!value.admitted)refuse('owned writable preheld admission')
|
||||
return value
|
||||
}
|
||||
const ACL_SQL=`SELECT a.grantee::pg_catalog.text AS grantee,a.grantor::pg_catalog.text AS grantor,a.privilege_type AS privilege,a.is_grantable AS grantable FROM pg_catalog.aclexplode(COALESCE(c.relacl,pg_catalog.acldefault('r',c.relowner))) a ORDER BY a.grantee,a.privilege_type LIMIT 33`
|
||||
async function relation(client:PartitionGuardSql,name:string):Promise<Relation|undefined>{
|
||||
const result=await rows<Relation>(client,`SELECT c.oid::pg_catalog.text AS oid,c.relname AS name,c.relkind::pg_catalog.text AS kind,c.relispartition AS partition,c.relpersistence::pg_catalog.text AS persistence,c.relrowsecurity AS rls,c.relforcerowsecurity AS force_rls,
|
||||
c.relowner::pg_catalog.text AS owner,n.nspowner::pg_catalog.text AS schema_owner,
|
||||
COALESCE((SELECT pg_catalog.jsonb_agg(x) FROM (${ACL_SQL}) x),'[]'::pg_catalog.jsonb) AS acl,
|
||||
ARRAY(SELECT i.inhparent::pg_catalog.text FROM pg_catalog.pg_inherits i WHERE i.inhrelid=c.oid ORDER BY i.inhseqno) AS parents
|
||||
FROM pg_catalog.pg_class c JOIN pg_catalog.pg_namespace n ON n.oid=c.relnamespace WHERE n.nspname='public' AND c.relname=$1`,[name],1)
|
||||
return result[0]
|
||||
}
|
||||
async function triggers(client:PartitionGuardSql,oid:string):Promise<Trigger[]>{return rows<Trigger>(client,`SELECT t.oid::pg_catalog.text AS oid,t.tgname AS name,t.tgtype::pg_catalog.int4 AS type,t.tgenabled::pg_catalog.text AS enabled,
|
||||
t.tgfoid::pg_catalog.text AS function_oid,fn.nspname AS function_namespace,p.proname AS function_name,t.tgparentid::pg_catalog.text AS parent_trigger,t.tgnargs::pg_catalog.int4 AS args,pg_catalog.octet_length(t.tgargs)::pg_catalog.int4 AS arg_bytes,
|
||||
t.tgqual IS NULL AS unconditional,t.tgconstraint::pg_catalog.text AS constraint_oid,t.tgdeferrable AS deferrable,t.tginitdeferred AS deferred,t.tgoldtable AS old_table,t.tgnewtable AS new_table
|
||||
FROM pg_catalog.pg_trigger t JOIN pg_catalog.pg_proc p ON p.oid=t.tgfoid JOIN pg_catalog.pg_namespace fn ON fn.oid=p.pronamespace WHERE t.tgrelid=$1::pg_catalog.oid AND NOT t.tgisinternal ORDER BY t.tgname LIMIT 9`,[oid],8)}
|
||||
function aclShape(acl:Acl[],owner:string,kind:'table'|'function'='table'):Acl[]{
|
||||
if(!Array.isArray(acl)||acl.length>32||acl.some(item=>!item||item.grantor!==owner||item.grantable!==false||typeof item.grantee!=='string'||typeof item.privilege!=='string'))refuse('ACL/grantor/grant option')
|
||||
if(canonical(acl.filter(item=>item.grantee===owner).map(item=>item.privilege).sort())!==canonical(kind==='function'?['EXECUTE']:['DELETE','INSERT','MAINTAIN','REFERENCES','SELECT','TRIGGER','TRUNCATE','UPDATE']))refuse('owner ACL structure')
|
||||
return acl.filter(item=>item.grantee!==owner).sort((a,b)=>(a.grantee+':'+a.privilege).localeCompare(b.grantee+':'+b.privilege))
|
||||
}
|
||||
function guard(trigger:Trigger|undefined,name:string,type:number,fn:string,parentTrigger='0'){
|
||||
if(!trigger||trigger.name!==name||trigger.type!==type||trigger.function_oid!==fn||trigger.enabled!=='A'||trigger.parent_trigger!==parentTrigger||trigger.args!==0||trigger.arg_bytes!==0||!trigger.unconditional||trigger.constraint_oid!=='0'||trigger.deferrable||trigger.deferred||trigger.old_table!==null||trigger.new_table!==null)refuse('trigger identity/shape')
|
||||
}
|
||||
async function functions(client:PartitionGuardSql):Promise<FunctionRow[]>{return rows<FunctionRow>(client,`SELECT p.oid::pg_catalog.text AS oid,n.nspname AS namespace,p.proname AS name,p.proowner::pg_catalog.text AS owner,n.nspowner::pg_catalog.text AS schema_owner,
|
||||
l.lanname AS language,t.typname AS return_type,tn.nspname AS return_namespace,p.prokind::pg_catalog.text AS kind,p.prosrc AS source,p.prosecdef AS security_definer,p.proconfig AS configuration,p.proisstrict AS strict,p.proleakproof AS leakproof,
|
||||
p.provolatile::pg_catalog.text AS volatility,p.proparallel::pg_catalog.text AS parallel,p.pronargs::pg_catalog.int4 AS arguments,p.proretset AS returns_set,p.probin AS library,
|
||||
COALESCE((SELECT pg_catalog.jsonb_agg(x) FROM (SELECT a.grantee::pg_catalog.text AS grantee,a.grantor::pg_catalog.text AS grantor,a.privilege_type AS privilege,a.is_grantable AS grantable FROM pg_catalog.aclexplode(COALESCE(p.proacl,pg_catalog.acldefault('f',p.proowner))) a ORDER BY a.grantee,a.privilege_type LIMIT 33) x),'[]'::pg_catalog.jsonb) AS acl
|
||||
FROM pg_catalog.pg_proc p JOIN pg_catalog.pg_namespace n ON n.oid=p.pronamespace JOIN pg_catalog.pg_language l ON l.oid=p.prolang JOIN pg_catalog.pg_type t ON t.oid=p.prorettype JOIN pg_catalog.pg_namespace tn ON tn.oid=t.typnamespace
|
||||
WHERE(n.nspname='public' AND p.proname='server_partition_writer_guard')OR(n.nspname='mmapp_control' AND p.proname IN('candidate_require_preadmission','candidate_truncate_preadmitted')) ORDER BY n.nspname,p.proname LIMIT 5`,[],4)}
|
||||
async function policy(client:PartitionGuardSql,parent:Relation,all:Trigger[],ctx:Context):Promise<Policy>{
|
||||
if(!MANAGED.has(parent.name)||ctx.version<180000||ctx.version>=190000||parent.kind!=='p'||parent.partition||parent.persistence!=='p'||parent.rls||parent.force_rls||parent.owner!==ctx.role_oid||parent.schema_owner!==ctx.role_oid||all.length!==3)refuse('managed parent/owner/scope')
|
||||
const fns=await functions(client);if(fns.length!==3)refuse('fixed guard function set')
|
||||
for(const fn of fns){
|
||||
const identity=fn.namespace+'.'+fn.name,body=BODIES[identity]
|
||||
if(!body||fn.source!==body||fn.arguments!==0||fn.kind!=='f'||fn.language!=='plpgsql'||fn.strict||fn.leakproof||fn.volatility!=='v'||fn.parallel!=='u'||fn.returns_set||fn.library!==null||canonical(fn.configuration)!==canonical(['search_path=pg_catalog'])||fn.return_namespace!=='pg_catalog'||fn.return_type!==(fn.name==='candidate_require_preadmission'?'void':'trigger')||fn.security_definer!==(fn.namespace==='mmapp_control')||fn.owner!==fn.schema_owner||fn.namespace==='public'&&fn.owner!==parent.owner||fn.namespace==='mmapp_control'&&fn.owner===parent.owner)refuse('fixed guard function body/security/owner')
|
||||
const other=aclShape(fn.acl,fn.owner,'function'),expected=fn.namespace==='public'?[{grantee:'0',grantor:fn.owner,privilege:'EXECUTE',grantable:false}]:fn.name===TRUNCATE?[{grantee:parent.owner,grantor:fn.owner,privilege:'EXECUTE',grantable:false}]:[]
|
||||
if(canonical(other)!==canonical(expected))refuse('guard function ACL')
|
||||
}
|
||||
const rowFn=fns.find(fn=>fn.namespace==='public')!,truncateFn=fns.find(fn=>fn.name===TRUNCATE)!,controlOwner=truncateFn.owner
|
||||
guard(all.find(t=>t.name===ROW),ROW,31,rowFn.oid);guard(all.find(t=>t.name===STATEMENT),STATEMENT,30,rowFn.oid);guard(all.find(t=>t.name===TRUNCATE),TRUNCATE,34,truncateFn.oid)
|
||||
const acl=aclShape(parent.acl,parent.owner),grantees=[...new Set(acl.map(item=>item.grantee))]
|
||||
if(grantees.length!==2||grantees.includes('0'))refuse('exact two parent grantees')
|
||||
const runtimeId=grantees.find(oid=>canonical(acl.filter(item=>item.grantee===oid).map(item=>item.privilege).sort())===canonical(['DELETE','INSERT','SELECT','UPDATE']))
|
||||
const captureId=grantees.find(oid=>canonical(acl.filter(item=>item.grantee===oid).map(item=>item.privilege))===canonical(['SELECT']))
|
||||
if(!runtimeId||!captureId||runtimeId===captureId)refuse('exact runtime/capture ACL')
|
||||
const roles=await rows<{oid:string;name:string;unsafe:boolean;owner_member:boolean;control_member:boolean;has_membership:boolean;public_usage:boolean;public_create:boolean;control_usage:boolean;control_create:boolean;db_create:boolean;db_temp:boolean;set_replication:boolean;set_event:boolean}>(client,`SELECT r.oid::pg_catalog.text AS oid,r.rolname AS name,
|
||||
(r.rolsuper OR r.rolcreatedb OR r.rolcreaterole OR r.rolreplication OR r.rolbypassrls) AS unsafe,
|
||||
pg_catalog.pg_has_role(r.oid,$1::pg_catalog.oid,'MEMBER') AS owner_member,pg_catalog.pg_has_role(r.oid,$2::pg_catalog.oid,'MEMBER') AS control_member,
|
||||
EXISTS(SELECT 1 FROM pg_catalog.pg_auth_members m WHERE m.member=r.oid OR m.roleid=r.oid) AS has_membership,
|
||||
pg_catalog.has_schema_privilege(r.oid,('public'::pg_catalog.regnamespace)::pg_catalog.oid,'USAGE') AS public_usage,pg_catalog.has_schema_privilege(r.oid,('public'::pg_catalog.regnamespace)::pg_catalog.oid,'CREATE') AS public_create,
|
||||
pg_catalog.has_schema_privilege(r.oid,('mmapp_control'::pg_catalog.regnamespace)::pg_catalog.oid,'USAGE') AS control_usage,pg_catalog.has_schema_privilege(r.oid,('mmapp_control'::pg_catalog.regnamespace)::pg_catalog.oid,'CREATE') AS control_create,
|
||||
pg_catalog.has_database_privilege(r.oid,$4::pg_catalog.oid,'CREATE') AS db_create,pg_catalog.has_database_privilege(r.oid,$4::pg_catalog.oid,'TEMP') AS db_temp,
|
||||
pg_catalog.has_parameter_privilege(r.oid,'session_replication_role','SET') AS set_replication,pg_catalog.has_parameter_privilege(r.oid,'event_triggers','SET') AS set_event
|
||||
FROM pg_catalog.pg_roles r WHERE r.oid=ANY($3::pg_catalog.oid[]) ORDER BY r.oid`,[parent.owner,controlOwner,[parent.owner,runtimeId,captureId],ctx.database_oid],3)
|
||||
if(roles.length!==3)refuse('actor roles')
|
||||
for(const role of roles){identifier(role.name);if(role.unsafe||role.control_member||role.has_membership||role.db_create||role.db_temp||role.set_replication||role.set_event||role.control_create)refuse('role/membership/DDL parameter escape');if(role.oid!==parent.owner&&(role.owner_member||!role.public_usage||role.public_create||role.control_usage))refuse('limited actor schema/ownership')}
|
||||
return {parent,triggers:all,functions:fns,runtime:roles.find(role=>role.oid===runtimeId)!,capture:roles.find(role=>role.oid===captureId)!,controlOwner}
|
||||
}
|
||||
async function validateLeaf(client:PartitionGuardSql,leaf:Relation,parent:Policy,complete:boolean){
|
||||
if(leaf.kind!=='r'||!leaf.partition||leaf.persistence!=='p'||leaf.rls||leaf.force_rls||leaf.owner!==parent.parent.owner||canonical(leaf.parents)!==canonical([parent.parent.oid]))refuse('direct owned ordinary leaf')
|
||||
const all=await triggers(client,leaf.oid),row=parent.triggers.find(t=>t.name===ROW)!,statement=parent.triggers.find(t=>t.name===STATEMENT)!,truncate=parent.triggers.find(t=>t.name===TRUNCATE)!
|
||||
if(all.length!==(complete?3:1))refuse('leaf foreign/partial guards')
|
||||
guard(all.find(t=>t.name===ROW),ROW,31,row.function_oid,row.oid)
|
||||
if(complete){guard(all.find(t=>t.name===STATEMENT),STATEMENT,30,statement.function_oid);guard(all.find(t=>t.name===TRUNCATE),TRUNCATE,34,truncate.function_oid)}
|
||||
const extra=aclShape(leaf.acl,leaf.owner),wanted=aclShape(parent.parent.acl,parent.parent.owner)
|
||||
if(canonical(extra)!==canonical(wanted)&&!(complete===false&&extra.length===0))refuse('leaf foreign/partial ACL')
|
||||
}
|
||||
/** Mechanical inheritance only. Parent SQL is NOT trusted profile issuance.
|
||||
* Protected enabled-host admission remains independently required. */
|
||||
export async function prepareReviewedPartitionGuardInheritance(client:PartitionGuardSql,parentName:string,leafName:string):Promise<()=>Promise<void>>{
|
||||
identifier(parentName);identifier(leafName)
|
||||
const first=await context(client)
|
||||
// This is reentrant ONLY after confirming a preheld same-backend lease; never
|
||||
// first wait for a global admission key while owning relation DDL locks.
|
||||
await client.query('SELECT pg_catalog.pg_advisory_xact_lock_shared($1::pg_catalog.int8)',[KEY])
|
||||
const assigned=await rows<{xid:string}>(client,'SELECT pg_catalog.pg_current_xact_id()::pg_catalog.text AS xid',[],1)
|
||||
const bound={...first,xid:assigned[0]?.xid};if(!bound.xid||!/^[1-9][0-9]*$/.test(bound.xid))refuse('owned full transaction identity')
|
||||
const check=async()=>{if(canonical(await context(client))!==canonical(bound))refuse('backend/transaction/admission changed')}
|
||||
await check()
|
||||
const parent=await relation(client,parentName);if(!parent)refuse('missing parent')
|
||||
const all=await triggers(client,parent.oid)
|
||||
const guarded=all.some(t=>t.name===STATEMENT||t.name===TRUNCATE)
|
||||
let used=false
|
||||
if(!guarded){
|
||||
if(all.length>1||all.length===1&&(all[0].name!==ROW||all[0].type!==31||all[0].enabled!=='A'||all[0].function_namespace!=='public'||all[0].function_name!==ROW||all[0].args!==0||all[0].arg_bytes!==0||!all[0].unconditional))refuse('foreign/partial legacy parent')
|
||||
return async()=>{if(used)refuse('finished continuation');used=true;await check()}
|
||||
}
|
||||
if(bound.isolation!=='read committed')refuse('guarded DDL requires fresh READ COMMITTED metadata')
|
||||
let template=await policy(client,parent,all,bound),existing=await relation(client,leafName)
|
||||
if(existing)await validateLeaf(client,existing,template,true)
|
||||
else{
|
||||
// Only a potentially missing leaf needs CREATE's parent lock. Known full
|
||||
// leaves retain a read-only verification fast path, without a new strong
|
||||
// parent lock on every normal flow flush. Re-read after a racing creator.
|
||||
await client.query('LOCK TABLE public.'+identifier(parentName)+' IN ACCESS EXCLUSIVE MODE')
|
||||
await check()
|
||||
const lockedParent=await relation(client,parentName);if(!lockedParent||lockedParent.oid!==parent.oid)refuse('locked parent changed')
|
||||
template=await policy(client,lockedParent,await triggers(client,lockedParent.oid),bound)
|
||||
existing=await relation(client,leafName)
|
||||
if(existing)await validateLeaf(client,existing,template,true)
|
||||
}
|
||||
const fingerprint=digest(template)
|
||||
return async()=>{
|
||||
if(used)refuse('finished continuation');used=true;await check()
|
||||
const currentParent=await relation(client,parentName);if(!currentParent)refuse('parent disappeared')
|
||||
if(digest(await policy(client,currentParent,await triggers(client,currentParent.oid),bound))!==fingerprint)refuse('parent policy changed')
|
||||
const leaf=await relation(client,leafName);if(!leaf)refuse('missing created leaf')
|
||||
await validateLeaf(client,leaf,template,Boolean(existing))
|
||||
if(!existing){
|
||||
const table='public.'+identifier(leafName)
|
||||
await client.query(`CREATE TRIGGER ${STATEMENT} BEFORE INSERT OR UPDATE OR DELETE ON ${table} FOR EACH STATEMENT EXECUTE FUNCTION public.server_partition_writer_guard()`)
|
||||
await client.query(`ALTER TABLE ${table} ENABLE ALWAYS TRIGGER ${STATEMENT}`)
|
||||
await client.query(`CREATE TRIGGER ${TRUNCATE} BEFORE TRUNCATE ON ${table} FOR EACH STATEMENT EXECUTE FUNCTION mmapp_control.candidate_truncate_preadmitted()`)
|
||||
await client.query(`ALTER TABLE ${table} ENABLE ALWAYS TRIGGER ${TRUNCATE}`)
|
||||
await client.query(`GRANT SELECT,INSERT,UPDATE,DELETE ON TABLE ${table} TO ${identifier(template.runtime.name)}`)
|
||||
await client.query(`GRANT SELECT ON TABLE ${table} TO ${identifier(template.capture.name)}`)
|
||||
}
|
||||
await validateLeaf(client,(await relation(client,leafName))!,template,true);await check()
|
||||
const finalParent=(await relation(client,parentName))!
|
||||
if(digest(await policy(client,finalParent,await triggers(client,finalParent.oid),bound))!==fingerprint)refuse('final parent policy changed')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import assert from "node:assert/strict"
|
||||
import pg, { type PoolClient } from "pg"
|
||||
import { ensurePartitionFor,ensurePartitionsAround,ensurePartitionsBetween,listExpiredPartitions,dropExpiredPartitions,PARTITION_SPECS } from "./partitions.js"
|
||||
import { withPartitionWrite,PartitionWriteError } from "../services/partition-write-transaction.js"
|
||||
|
||||
/** Explicit MODEL for the legacy ROW-only catalog protocol. Real42/114 owned
|
||||
* acceptance supplies SQL truth; these rows confer no source/profile authority.
|
||||
* The real inheritance helper executes unchanged against this bounded model. */
|
||||
function fixture(){
|
||||
const calls:string[]=[],release:boolean[]=[],direct:string[]=[]
|
||||
let fail:string|undefined,retention=14,transaction=false,admitted=false,xid:string|null=null,nextXid=1000
|
||||
const parentOids=new Map<string,string>()
|
||||
const metadata=async(text:string,values?:unknown[])=>{
|
||||
if(text==="BEGIN"){transaction=true;admitted=false;xid=null}
|
||||
if(text.includes("pg_advisory_xact_lock_shared")){assert(transaction,"MODEL admission requires caller BEGIN");assert.deepEqual(values,[8723110],"MODEL tracks the reviewed global admission key");admitted=true}
|
||||
if(text.includes("pg_current_xact_id_if_assigned"))return {rows:[{pid:12345,xid,actor:"model_operator",login:"model_operator",role_oid:"16001",database_oid:"16390",read_only:"off",isolation:"read committed",version:180004,admitted:transaction&&admitted}]}
|
||||
if(text.startsWith("SELECT pg_catalog.pg_current_xact_id()")){assert(transaction,"MODEL XID requires owned transaction");xid??=String(++nextXid);return {rows:[{xid}]}}
|
||||
if(text.includes("c.relname AS name")&&text.includes("n.nspname='public' AND c.relname=$1")){
|
||||
const parent=String(values?.[0]);if(!parentOids.has(parent))parentOids.set(parent,String(20000+parentOids.size))
|
||||
const acl=["DELETE","INSERT","MAINTAIN","REFERENCES","SELECT","TRIGGER","TRUNCATE","UPDATE"].map(privilege=>({grantee:"16001",grantor:"16001",privilege,grantable:false}))
|
||||
return {rows:[{oid:parentOids.get(parent),name:parent,kind:"p",partition:false,persistence:"p",rls:false,force_rls:false,owner:"16001",schema_owner:"16001",acl,parents:[]}]}
|
||||
}
|
||||
if(text.includes("FROM pg_catalog.pg_trigger t")&&text.includes("t.tgrelid=$1"))return {rows:[{oid:"17000",name:"server_partition_writer_guard",type:31,enabled:"A",function_oid:"17001",function_namespace:"public",function_name:"server_partition_writer_guard",parent_trigger:"0",args:0,arg_bytes:0,unconditional:true,constraint_oid:"0",deferrable:false,deferred:false,old_table:null,new_table:null}]}
|
||||
if(text.includes("retention_days"))return {rows:[{retention_days:retention}]}
|
||||
if(text.includes("FROM pg_inherits"))return {rows:values?.[0]==="flow_buckets"?[{relname:"flow_buckets_d_20000101"}]:[]}
|
||||
if(text==="COMMIT"){transaction=false;admitted=false;xid=null}
|
||||
return {rows:[],command:text==="COMMIT"?"COMMIT":"SELECT"}
|
||||
}
|
||||
const client={query:async(input:string|{text:string;values?:unknown[]},values?:unknown[])=>{const text=typeof input==="string"?input:input.text;calls.push(text);if(fail&&text.startsWith(fail)){fail=undefined;throw Error("Synthetic boundary failure")};return metadata(text,typeof input==="string"?values:input.values)},release:(destroy=false)=>{release.push(destroy)}}as unknown as PoolClient
|
||||
const pool=new pg.Pool()
|
||||
pool.connect=(async()=>client)as typeof pool.connect
|
||||
pool.query=(async(text:string,values?:unknown[])=>{direct.push(text);assert(!/^(CREATE|ALTER|DROP)/.test(text),"Global pool issued partition DDL");return metadata(text,values)})as typeof pool.query
|
||||
return {pool,client,calls,release,direct,fail:(value:string)=>{fail=value},retention:(days:number)=>{retention=days}}
|
||||
}
|
||||
let cases=0
|
||||
async function test(name:string,work:()=>Promise<void>){await work();cases++;console.log("ok",name)}
|
||||
const at=new Date("2099-04-08T12:00:00.000Z")
|
||||
await test("standalone fact CREATE and ALTER share admitted backend and transaction",async()=>{const f=fixture();const name=await ensurePartitionFor(f.pool,"flow_daily_facts","month",at);assert.equal(name,"flow_daily_facts_m_209904");const shared=f.calls.findIndex(q=>q.includes("pg_advisory_xact_lock_shared")),create=f.calls.findIndex(q=>q.startsWith("CREATE")),alter=f.calls.findIndex(q=>q.startsWith("ALTER"));assert(shared>=0&&shared<create&&create<alter);assert.equal(f.calls.filter(q=>q==="BEGIN").length,1);assert.equal(f.calls.at(-1),"COMMIT");assert.deepEqual(f.release,[false]);assert.equal(f.direct.length,0)})
|
||||
await test("common authenticated owner borrows existing transaction without nested boundaries",async()=>{const f=fixture();await withPartitionWrite(async owner=>{await ensurePartitionFor(owner.client,"flow_hour_facts","day",at)},{connect:async()=>f.client});assert.equal(f.calls.filter(q=>q==="BEGIN").length,1);assert.equal(f.calls.filter(q=>q==="COMMIT").length,1);const shared=f.calls.flatMap((q,index)=>q.includes("pg_advisory_xact_lock_shared")?[index]:[]);assert.equal(shared.length,2,"One owner admission plus one required reentrant XACT pin, no nested transaction");const checked=f.calls.findIndex(q=>q.includes("pg_current_xact_id_if_assigned")),create=f.calls.findIndex(q=>q.startsWith("CREATE"));assert(shared[0]<checked&&checked<shared[1]&&shared[1]<create,"MODEL preheld check precedes reentrant pin and application DDL")})
|
||||
await test("unowned query-shaped client is refused before DDL",async()=>{const f=fixture();await assert.rejects(ensurePartitionFor(f.client,"traffic_samples","week",at),/authenticated/);assert.equal(f.calls.length,0)})
|
||||
await test("closed authenticated handle cannot issue detached DDL",async()=>{const f=fixture();let closed:PoolClient|undefined;await withPartitionWrite(async owner=>{closed=owner.client},{connect:async()=>f.client});const count=f.calls.length;await assert.rejects(ensurePartitionFor(closed!,"traffic_samples","week",at),/authenticated/);assert.equal(f.calls.length,count)})
|
||||
await test("exact flow proxy uses actual live ALS client and keeps caller boundaries",async()=>{const f=fixture();const {flowWritePool,withFlowWriteClient}=await import("../services/traffic-flow-write-transaction.js");await f.client.query("BEGIN");await f.client.query("SELECT pg_advisory_xact_lock_shared($1::bigint)",[8723110]);await withFlowWriteClient(f.client,()=>ensurePartitionFor(flowWritePool,"flow_hour_facts","day",at));assert.equal(f.calls.filter(q=>q==="BEGIN").length,1);assert(!f.calls.includes("COMMIT"));assert.equal(f.calls.filter(q=>q.startsWith("CREATE")).length,1);assert.equal(f.calls.filter(q=>q.startsWith("ALTER")).length,1)})
|
||||
await test("flow authority lost before DDL sends no publication",async()=>{const f=fixture();const {flowWritePool,withFlowWriteClient}=await import("../services/traffic-flow-write-transaction.js");await assert.rejects(withFlowWriteClient(f.client,()=>ensurePartitionFor(flowWritePool,"flow_buckets","day",at),"shared",async()=>{throw Error("authority lost")}),/authority lost/);assert.equal(f.calls.length,0)})
|
||||
await test("SQL CREATE failure has no ALTER, COMMIT, inverse or retry",async()=>{const f=fixture();f.fail("CREATE");await assert.rejects(ensurePartitionFor(f.pool,"flow_hour_facts","day",at),PartitionWriteError);assert.equal(f.calls.filter(q=>q.startsWith("CREATE")).length,1);assert(!f.calls.some(q=>q.startsWith("ALTER")||q==="COMMIT"||q==="ROLLBACK"));assert.deepEqual(f.release,[true])})
|
||||
await test("unknown COMMIT destroys backend and does not replay DDL",async()=>{const f=fixture();f.fail("COMMIT");await assert.rejects(ensurePartitionFor(f.pool,"flow_buckets","day",at),(error:unknown)=>error instanceof PartitionWriteError&&error.outcomeUnknown);assert.equal(f.calls.filter(q=>q==="COMMIT").length,1);assert.equal(f.calls.filter(q=>q.startsWith("CREATE")).length,1);assert(!f.calls.includes("ROLLBACK"));assert.deepEqual(f.release,[true])})
|
||||
await test("identifier, mismatched known parent kind and invalid UTC instant reject before SQL",async()=>{for(const input of [["traffic_samples;DROP","week",at],["traffic_samples","day",at],["flow_buckets","day",new Date("invalid")]] as const){const f=fixture();await assert.rejects(ensurePartitionFor(f.pool,input[0],input[1],input[2]));assert.equal(f.calls.length,0);assert.equal(f.direct.length,0)}})
|
||||
await test("oversize range rejects entire plan before first DDL",async()=>{const f=fixture();await assert.rejects(ensurePartitionsBetween(f.pool,"flow_buckets","day",new Date("2000-01-01"),new Date("2010-01-01")),/budget/);assert.equal(f.calls.length,0)})
|
||||
await test("all12 parent footprints remain in bounded around plan",async()=>{const f=fixture();await ensurePartitionsAround(f.pool,at);const names=f.calls.filter(q=>q.startsWith("CREATE"));for(const spec of PARTITION_SPECS)assert(names.some(q=>q.includes(" PARTITION OF "+spec.parent+" ")),spec.parent);assert(names.length<2000);assert.equal(f.calls.filter(q=>q==="COMMIT").length,names.length)})
|
||||
await test("invalid configured retention cannot silently reduce maintenance footprint",async()=>{const f=fixture();f.retention(Infinity);await assert.rejects(ensurePartitionsAround(f.pool,at),/retention/);assert.equal(f.calls.length,0)})
|
||||
await test("read-only retention preview still accepts bounded query provider",async()=>{const f=fixture();assert.deepEqual(await listExpiredPartitions({query:f.pool.query.bind(f.pool)},at),[{parent:"flow_buckets",name:"flow_buckets_d_20000101"}]);assert.equal(f.calls.length,0)})
|
||||
await test("DROP callback executes after admission and immediately before owned DROP",async()=>{const f=fixture();await dropExpiredPartitions(f.pool,at,async partition=>{assert.equal(partition.name,"flow_buckets_d_20000101");assert(f.calls.some(q=>q.includes("pg_advisory_xact_lock_shared")));f.calls.push("CALLBACK")});const index=f.calls.indexOf("CALLBACK");assert(f.calls[index+1].startsWith("DROP TABLE"));assert.equal(f.calls.at(-1),"COMMIT")})
|
||||
await test("denied DROP callback sends no DROP and no retry",async()=>{const f=fixture();await assert.rejects(dropExpiredPartitions(f.pool,at,async()=>{throw Error("unapproved")}),PartitionWriteError);assert(!f.calls.some(q=>q.startsWith("DROP")||q==="COMMIT"));assert.deepEqual(f.release,[true])})
|
||||
console.log(`Partition DDL ownership: ${cases} inert MODEL scenarios passed; no SQL/source authority claimed`)
|
||||
@@ -0,0 +1,250 @@
|
||||
import {prepareReviewedPartitionGuardInheritance} from "./partition-guard-inheritance.js"
|
||||
import pg, { type Pool } from "pg"
|
||||
import { isPartitionWriteClient, withPartitionWrite } from "../services/partition-write-transaction.js"
|
||||
|
||||
type PartitionQueries=Pick<Pool,"query">
|
||||
const MAX_PARTITION_OPERATIONS=2000
|
||||
/** CREATE/ALTER/DROP share the exact backend admitted before relation access.
|
||||
* A query-shaped client is never evidence of transaction ownership. */
|
||||
async function withPartitionDdl<T>(target:PartitionQueries,work:(client:PartitionQueries)=>Promise<T>):Promise<T>{
|
||||
if(isPartitionWriteClient(target))return work(target)
|
||||
if(!(target instanceof pg.Pool))throw Error("Partition DDL requires a Pool or a live authenticated partition writer client")
|
||||
// Delay the flow runtime import until an actual pool is used. Importing the
|
||||
// partition catalogue itself must not initialize the configured DB pool.
|
||||
const {flowWritePool,flowWriteContext}=await import("../services/traffic-flow-write-transaction.js")
|
||||
if(target===flowWritePool){
|
||||
const context=flowWriteContext()
|
||||
if(context){
|
||||
const query=(async(...args:unknown[])=>{
|
||||
await context.authorize();if(!context.active||flowWriteContext()!==context)throw Error("Flow partition writer context is closed")
|
||||
const result=await Reflect.apply(context.client.query,context.client,args)
|
||||
await context.authorize();if(!context.active||flowWriteContext()!==context)throw Error("Flow partition writer context is closed")
|
||||
return result
|
||||
})as Pool["query"]
|
||||
return work({query})
|
||||
}
|
||||
}
|
||||
return withPartitionWrite(owner=>work(owner.client),{connect:()=>target.connect()})
|
||||
}
|
||||
|
||||
function validatePartitionInput(parent:string,kind:PartitionKind,ts:Date):void{
|
||||
if(!/^[a-z_][a-z\d_]{0,49}$/.test(parent)||!["day","week","month"].includes(kind)||!(ts instanceof Date)||!Number.isFinite(ts.getTime())||ts.getUTCFullYear()<100||ts.getUTCFullYear()>9998)throw Error("Invalid bounded partition identifier, kind or UTC instant")
|
||||
const spec=PARTITION_SPECS.find(spec=>spec.parent===parent)
|
||||
if(spec&&spec.kind!==kind)throw Error("Partition kind does not match the parent catalogue")
|
||||
}
|
||||
|
||||
export type PartitionKind = "day" | "week" | "month"
|
||||
|
||||
export interface PartitionSpec {
|
||||
parent: string
|
||||
kind: PartitionKind
|
||||
keepDays: number
|
||||
}
|
||||
|
||||
/** Leaf-only: PG forbids storage params on partitioned parents (SQLSTATE 42809). */
|
||||
const FACT_LEAF_STORAGE =
|
||||
"fillfactor = 70, autovacuum_vacuum_scale_factor = 0.05, autovacuum_vacuum_cost_limit = 2000"
|
||||
|
||||
const FACT_PARENTS = new Set(["flow_hour_facts", "flow_daily_facts"])
|
||||
|
||||
export const PARTITION_SPECS: PartitionSpec[] = [
|
||||
{ parent: "flow_buckets", kind: "day", keepDays: 4 },
|
||||
{ parent: "flow_minute_stats", kind: "day", keepDays: 4 },
|
||||
{ parent: "flow_minute_dims", kind: "day", keepDays: 4 },
|
||||
{ parent: "flow_daily_dims", kind: "month", keepDays: 420 },
|
||||
{ parent: "flow_hour_facts", kind: "day", keepDays: 3 },
|
||||
{ parent: "flow_daily_facts", kind: "month", keepDays: 420 },
|
||||
{ parent: "traffic_samples", kind: "week", keepDays: 21 },
|
||||
{ parent: "servers_rest_ping_samples", kind: "week", keepDays: 35 },
|
||||
{ parent: "uptime_probe_samples", kind: "week", keepDays: 21 },
|
||||
{ parent: "uptime_resource_samples", kind: "week", keepDays: 21 },
|
||||
{ parent: "server_snapshots", kind: "week", keepDays: 21 },
|
||||
{ parent: "internet_path_snapshots", kind: "week", keepDays: 21 },
|
||||
]
|
||||
|
||||
const WEEK_SLACK_DAYS = 7
|
||||
|
||||
async function resolveKeepDays(pool: Pick<Pool,"query">): Promise<Map<string, number>> {
|
||||
const map = new Map(PARTITION_SPECS.map((s) => [s.parent, s.keepDays]))
|
||||
const configured:{parents:string[];value:unknown}[]=[]
|
||||
try {
|
||||
const traffic = await pool.query<{ retention_days: number }>(
|
||||
`SELECT retention_days FROM traffic_settings WHERE id = 1`,
|
||||
)
|
||||
if(traffic.rows.length)configured.push({parents:["traffic_samples"],value:traffic.rows[0].retention_days})
|
||||
|
||||
const uptime = await pool.query<{ retention_days: number }>(
|
||||
`SELECT retention_days FROM uptime_settings WHERE id = 1`,
|
||||
)
|
||||
if(uptime.rows.length)configured.push({parents:["uptime_probe_samples","uptime_resource_samples"],value:uptime.rows[0].retention_days})
|
||||
|
||||
const path = await pool.query<{ retention_days: number }>(
|
||||
`SELECT retention_days FROM internet_path_settings WHERE id = 1`,
|
||||
)
|
||||
if(path.rows.length)configured.push({parents:["internet_path_snapshots"],value:path.rows[0].retention_days})
|
||||
} catch {
|
||||
/* settings may be absent mid-migration */
|
||||
}
|
||||
for(const setting of configured){const days=Number(setting.value);if(!Number.isSafeInteger(days)||days<1||days>3660)throw Error("Invalid bounded partition retention setting");for(const parent of setting.parents)map.set(parent,days+WEEK_SLACK_DAYS)}
|
||||
return map
|
||||
}
|
||||
|
||||
function utcDate(d: Date): Date {
|
||||
return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()))
|
||||
}
|
||||
|
||||
function addUtcDays(d: Date, n: number): Date {
|
||||
const x = utcDate(d)
|
||||
x.setUTCDate(x.getUTCDate() + n)
|
||||
return x
|
||||
}
|
||||
|
||||
function startOfWeekUtc(d: Date): Date {
|
||||
const x = utcDate(d)
|
||||
const day = x.getUTCDay() || 7
|
||||
x.setUTCDate(x.getUTCDate() - (day - 1))
|
||||
return x
|
||||
}
|
||||
|
||||
function startOfMonthUtc(d: Date): Date {
|
||||
return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), 1))
|
||||
}
|
||||
|
||||
function fmtDay(d: Date): string {
|
||||
return d.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
function partName(parent: string, kind: PartitionKind, start: Date): string {
|
||||
if (kind === "day") return `${parent}_d_${fmtDay(start).replaceAll("-", "")}`
|
||||
if (kind === "week") return `${parent}_w_${fmtDay(start).replaceAll("-", "")}`
|
||||
return `${parent}_m_${String(start.getUTCFullYear()).padStart(4,"0")}${String(start.getUTCMonth() + 1).padStart(2, "0")}`
|
||||
}
|
||||
|
||||
function rangeFor(kind: PartitionKind, ts: Date): { start: Date; end: Date } {
|
||||
if (kind === "day") {
|
||||
const start = utcDate(ts)
|
||||
return { start, end: addUtcDays(start, 1) }
|
||||
}
|
||||
if (kind === "week") {
|
||||
const start = startOfWeekUtc(ts)
|
||||
return { start, end: addUtcDays(start, 7) }
|
||||
}
|
||||
const start = startOfMonthUtc(ts)
|
||||
const end = new Date(Date.UTC(start.getUTCFullYear(), start.getUTCMonth() + 1, 1))
|
||||
return { start, end }
|
||||
}
|
||||
|
||||
export async function ensurePartitionFor(
|
||||
pool: Pick<Pool, "query">,
|
||||
parent: string,
|
||||
kind: PartitionKind,
|
||||
ts: Date,
|
||||
): Promise<string> {
|
||||
validatePartitionInput(parent,kind,ts)
|
||||
const { start, end } = rangeFor(kind, ts)
|
||||
validatePartitionInput(parent,kind,start);validatePartitionInput(parent,kind,end)
|
||||
const name = partName(parent, kind, start)
|
||||
// Bounds describe UTC instants, independently of the PostgreSQL session zone.
|
||||
// A date-only literal would move a timestamptz boundary by that zone's offset.
|
||||
const from = start.toISOString()
|
||||
const to = end.toISOString()
|
||||
await withPartitionDdl(pool,async client=>{
|
||||
const finishInheritance=await prepareReviewedPartitionGuardInheritance(client,parent,name)
|
||||
await client.query(`CREATE TABLE IF NOT EXISTS ${name} PARTITION OF ${parent} FOR VALUES FROM ('${from}') TO ('${to}')`)
|
||||
if (FACT_PARENTS.has(parent))await client.query(`ALTER TABLE ${name} SET (${FACT_LEAF_STORAGE})`)
|
||||
await finishInheritance()
|
||||
})
|
||||
return name
|
||||
}
|
||||
|
||||
export async function ensurePartitionsBetween(
|
||||
pool: PartitionQueries,
|
||||
parent: string,
|
||||
kind: PartitionKind,
|
||||
from: Date,
|
||||
to: Date,
|
||||
): Promise<void> {
|
||||
validatePartitionInput(parent,kind,from);validatePartitionInput(parent,kind,to)
|
||||
const start = from.getTime() <= to.getTime() ? from : to
|
||||
const end = from.getTime() <= to.getTime() ? to : from
|
||||
const planned:Date[]=[]
|
||||
for (let t = new Date(start.getTime()); t <= end; ) {
|
||||
planned.push(t);if(planned.length*(FACT_PARENTS.has(parent)?2:1)>MAX_PARTITION_OPERATIONS)throw Error("Partition range exceeds the bounded maintenance budget")
|
||||
if (kind === "day") t = addUtcDays(t, 1)
|
||||
else if (kind === "week") t = addUtcDays(t, 7)
|
||||
else t = new Date(Date.UTC(t.getUTCFullYear(), t.getUTCMonth() + 1, 1))
|
||||
}
|
||||
for(const t of planned)await ensurePartitionFor(pool,parent,kind,t)
|
||||
}
|
||||
|
||||
export async function ensurePartitionsAround(pool: PartitionQueries, around = new Date()): Promise<void> {
|
||||
validatePartitionInput("server_snapshots","week",around)
|
||||
const keepDays = await resolveKeepDays(pool)
|
||||
const planned:{parent:string;kind:PartitionKind;at:Date}[]=[]
|
||||
let cost=0
|
||||
for (const spec of PARTITION_SPECS) {
|
||||
const keep = keepDays.get(spec.parent) ?? spec.keepDays
|
||||
const daysAhead = spec.kind === "month" ? 40 : spec.kind === "week" ? 21 : 8
|
||||
const start = addUtcDays(around, -keep)
|
||||
const end = addUtcDays(around, daysAhead)
|
||||
for (let t = new Date(start.getTime()); t < end; ) {
|
||||
planned.push({parent:spec.parent,kind:spec.kind,at:t});cost+=FACT_PARENTS.has(spec.parent)?2:1;if(cost>MAX_PARTITION_OPERATIONS)throw Error("Partition catalogue exceeds the bounded maintenance budget")
|
||||
if (spec.kind === "day") t = addUtcDays(t, 1)
|
||||
else if (spec.kind === "week") t = addUtcDays(t, 7)
|
||||
else t = new Date(Date.UTC(t.getUTCFullYear(), t.getUTCMonth() + 1, 1))
|
||||
}
|
||||
}
|
||||
for(const item of planned)await ensurePartitionFor(pool,item.parent,item.kind,item.at)
|
||||
}
|
||||
|
||||
export interface ExpiredPartition { parent: string; name: string }
|
||||
/** One bounded retention manifest is shared by preview and the actual cleanup. */
|
||||
export async function listExpiredPartitions(pool: Pick<Pool,"query">, around = new Date()): Promise<ExpiredPartition[]> {
|
||||
validatePartitionInput("server_snapshots","week",around)
|
||||
const keepDays = await resolveKeepDays(pool)
|
||||
const expired: ExpiredPartition[] = []
|
||||
for (const spec of PARTITION_SPECS) {
|
||||
const keep = keepDays.get(spec.parent) ?? spec.keepDays
|
||||
const cutoff = addUtcDays(around, -keep)
|
||||
const { rows } = await pool.query<{ relname: string }>(
|
||||
`SELECT c.relname
|
||||
FROM pg_inherits i
|
||||
JOIN pg_class c ON c.oid = i.inhrelid
|
||||
JOIN pg_class p ON p.oid = i.inhparent
|
||||
WHERE p.relname = $1 AND p.relnamespace='public'::regnamespace AND c.relnamespace=p.relnamespace
|
||||
ORDER BY c.relname LIMIT 1001`,
|
||||
[spec.parent],
|
||||
)
|
||||
if(rows.length>1000)throw new Error("Превышен предел разделов для обслуживания")
|
||||
for (const row of rows) {
|
||||
const m = row.relname.match(new RegExp(`^${spec.parent}_([dwm])_(\\d{8}|\\d{6})$`))
|
||||
if (!m) continue
|
||||
if(m[1]!==spec.kind[0])continue
|
||||
const stamp = m[2]
|
||||
let start: Date
|
||||
if (stamp.length === 6) {
|
||||
start = new Date(Date.UTC(Number(stamp.slice(0, 4)), Number(stamp.slice(4, 6)) - 1, 1))
|
||||
} else {
|
||||
start = new Date(`${stamp.slice(0, 4)}-${stamp.slice(4, 6)}-${stamp.slice(6, 8)}T00:00:00Z`)
|
||||
}
|
||||
if(!Number.isFinite(start.getTime())||partName(spec.parent,spec.kind,start)!==row.relname)continue
|
||||
if (start < cutoff) expired.push({parent:spec.parent,name:row.relname})
|
||||
if(expired.length>1000)throw new Error("Превышен предел истёкших разделов")
|
||||
}
|
||||
}
|
||||
return expired
|
||||
}
|
||||
export async function dropExpiredPartitions(pool: PartitionQueries, around = new Date(), beforeDrop?: (partition: ExpiredPartition) => Promise<void>): Promise<void> {
|
||||
const expired=await listExpiredPartitions(pool,around)
|
||||
for(const partition of expired){
|
||||
const quoted=partition.name.replace(/"/g,'""')
|
||||
await withPartitionDdl(pool,async client=>{
|
||||
await beforeDrop?.(partition)
|
||||
await client.query(`DROP TABLE IF EXISTS "public"."${quoted}"`)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export function specForParent(parent: string): PartitionSpec | undefined {
|
||||
return PARTITION_SPECS.find((s) => s.parent === parent)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { createPool } from "./index.js"
|
||||
const original = process.env.NODE_ENV
|
||||
try {
|
||||
for (const mode of ["production", "development", "test"]) {
|
||||
process.env.NODE_ENV = mode
|
||||
const pool = createPool(1)
|
||||
assert.equal(pool.options.allowExitOnIdle, mode === "test")
|
||||
assert.equal(pool.options.idleTimeoutMillis, 30_000)
|
||||
assert.equal(pool.options.max, 1)
|
||||
await pool.end()
|
||||
}
|
||||
} finally {
|
||||
if (original === undefined) delete process.env.NODE_ENV
|
||||
else process.env.NODE_ENV = original
|
||||
}
|
||||
console.log("PG pool: tests may exit on idle; production/development retain normal pool lifecycle, no SQL connection")
|
||||
@@ -0,0 +1,204 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { withPgOrSkip } from "../test/pg.js"
|
||||
import { dbQuery, pool } from "./index.js"
|
||||
import { applySqlMigrations } from "./migrate.js"
|
||||
import { ensurePartitionFor } from "./partitions.js"
|
||||
import { verifyPartitionTimezones } from "../test/partition-timezones.js"
|
||||
|
||||
if (!(await withPgOrSkip())) {
|
||||
console.log("pg-schema.test.ts: skip")
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
await applySqlMigrations(pool)
|
||||
await verifyPartitionTimezones(pool)
|
||||
|
||||
{
|
||||
const { rows } = await dbQuery<{ n: string }>(`SELECT COUNT(*)::text AS n FROM servers`)
|
||||
assert.ok(rows[0])
|
||||
}
|
||||
|
||||
{
|
||||
await dbQuery(`
|
||||
INSERT INTO servers (name, host) VALUES ('pg-schema-test', '127.0.0.1')
|
||||
`)
|
||||
const { rows } = await dbQuery<{ id: number }>(`SELECT id FROM servers WHERE name = 'pg-schema-test' LIMIT 1`)
|
||||
const id = rows[0]?.id
|
||||
assert.ok(id)
|
||||
|
||||
await ensurePartitionFor(pool, "flow_buckets", "day", new Date())
|
||||
const bucketAt = new Date().toISOString()
|
||||
await dbQuery(`
|
||||
INSERT INTO flow_buckets (
|
||||
server_id, bucket_at, src, dst, proto, src_port, dst_port, bytes, packets, in_iface
|
||||
) VALUES ($1, $2, '10.0.0.1', '8.8.8.8', 6, 1, 443, 10, 1, 'wg-flow')
|
||||
ON CONFLICT DO NOTHING
|
||||
`, [id, bucketAt])
|
||||
|
||||
await dbQuery(`DELETE FROM flow_buckets WHERE server_id = $1`, [id])
|
||||
await dbQuery(`DELETE FROM servers WHERE id = $1`, [id])
|
||||
}
|
||||
|
||||
{
|
||||
await dbQuery(`
|
||||
INSERT INTO alert_outbox (id, dedupe_key, payload_json, next_attempt_at)
|
||||
VALUES ('pg-test-1', 'pg-dedupe-key', '{}'::jsonb, now())
|
||||
ON CONFLICT (dedupe_key) DO NOTHING
|
||||
`)
|
||||
let threw = false
|
||||
try {
|
||||
await dbQuery(`
|
||||
INSERT INTO alert_outbox (id, dedupe_key, payload_json, next_attempt_at)
|
||||
VALUES ('pg-test-2', 'pg-dedupe-key', '{}'::jsonb, now())
|
||||
`)
|
||||
} catch {
|
||||
threw = true
|
||||
}
|
||||
assert.equal(threw, true, "alert_outbox.dedupe_key UNIQUE")
|
||||
await dbQuery(`DELETE FROM alert_outbox WHERE dedupe_key = 'pg-dedupe-key'`)
|
||||
}
|
||||
|
||||
{
|
||||
const peers = [{ endpoint: "msk-gw02.rtnt.top:13232", publicKey: "x" }]
|
||||
await dbQuery(
|
||||
`INSERT INTO alert_outbox (id, dedupe_key, payload_json, next_attempt_at)
|
||||
VALUES ('pg-json-arr', 'pg-json-arr', $1::jsonb, now())`,
|
||||
[JSON.stringify(peers)],
|
||||
)
|
||||
const { rows } = await dbQuery<{ payload_json: unknown }>(
|
||||
`SELECT payload_json FROM alert_outbox WHERE id = 'pg-json-arr'`,
|
||||
)
|
||||
assert.equal(Array.isArray(rows[0]?.payload_json), true)
|
||||
await dbQuery(`DELETE FROM alert_outbox WHERE id = 'pg-json-arr'`)
|
||||
|
||||
let arrayAsPgArrayFailed = false
|
||||
try {
|
||||
await dbQuery(
|
||||
`INSERT INTO alert_outbox (id, dedupe_key, payload_json, next_attempt_at)
|
||||
VALUES ('pg-json-bad', 'pg-json-bad', $1, now())`,
|
||||
[peers],
|
||||
)
|
||||
} catch (err) {
|
||||
arrayAsPgArrayFailed = err instanceof Error && /json|22P02/i.test(err.message)
|
||||
}
|
||||
await dbQuery(`DELETE FROM alert_outbox WHERE id = 'pg-json-bad'`).catch(() => undefined)
|
||||
assert.equal(arrayAsPgArrayFailed, true, "JS array must not be bound as jsonb without stringify")
|
||||
}
|
||||
|
||||
{
|
||||
const { rows } = await dbQuery<{ attname: string }>(`
|
||||
SELECT a.attname
|
||||
FROM pg_index i
|
||||
JOIN unnest(i.indkey) WITH ORDINALITY AS k(attnum, ord) ON true
|
||||
JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = k.attnum
|
||||
WHERE i.indrelid = 'traffic_samples'::regclass AND i.indisprimary
|
||||
ORDER BY k.ord
|
||||
`)
|
||||
assert.deepEqual(
|
||||
rows.map((r) => r.attname),
|
||||
["server_id", "sampled_at", "interface_name", "peer_public_key"],
|
||||
"traffic_samples PK без id",
|
||||
)
|
||||
}
|
||||
|
||||
{
|
||||
const { rows } = await dbQuery<{ column_name: string }>(`
|
||||
SELECT column_name FROM information_schema.columns
|
||||
WHERE table_schema = 'public' AND table_name = 'traffic_samples'
|
||||
`)
|
||||
const cols = new Set(rows.map((r) => r.column_name))
|
||||
assert.equal(cols.has("id"), false)
|
||||
assert.equal(cols.has("running"), false)
|
||||
assert.equal(cols.has("disabled"), false)
|
||||
assert.equal(cols.has("flags"), true)
|
||||
}
|
||||
|
||||
{
|
||||
const { rows } = await dbQuery<{ column_name: string; udt_name: string }>(`
|
||||
SELECT column_name, udt_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'public' AND table_name = 'flow_buckets'
|
||||
AND column_name IN ('src', 'dst', 'next_hop', 'proto', 'nat_src', 'nat_dst', 'nat_src_port', 'nat_dst_port')
|
||||
`)
|
||||
const by = Object.fromEntries(rows.map((r) => [r.column_name, r.udt_name]))
|
||||
assert.equal(by.src, "inet")
|
||||
assert.equal(by.dst, "inet")
|
||||
assert.equal(by.next_hop, "inet")
|
||||
assert.equal(by.proto, "int2")
|
||||
assert.equal(by.nat_src, "inet")
|
||||
assert.equal(by.nat_dst, "inet")
|
||||
assert.equal(by.nat_src_port, "int4")
|
||||
assert.equal(by.nat_dst_port, "int4")
|
||||
}
|
||||
|
||||
{
|
||||
const { rows } = await dbQuery<{ indexdef: string }>(`
|
||||
SELECT indexdef FROM pg_indexes
|
||||
WHERE schemaname = 'public' AND tablename = 'traffic_samples'
|
||||
`)
|
||||
const defs = rows.map((r) => r.indexdef.toLowerCase())
|
||||
assert.equal(defs.some((d) => d.includes("using brin")), false, "нет BRIN на traffic_samples")
|
||||
assert.equal(
|
||||
defs.filter((d) => d.includes("idx_traffic_samples_server_iface_time")).length,
|
||||
1,
|
||||
"один btree (server_id, interface_name, sampled_at)",
|
||||
)
|
||||
assert.equal(defs.some((d) => d.includes("idx_traffic_samples_server_time")), false)
|
||||
}
|
||||
|
||||
{
|
||||
const { rows } = await dbQuery<{ column_name: string }>(`
|
||||
SELECT column_name FROM information_schema.columns
|
||||
WHERE table_schema = 'public' AND table_name = 'uptime_resource_samples'
|
||||
`)
|
||||
const cols = new Set(rows.map((r) => r.column_name))
|
||||
assert.equal(cols.has("board_name"), false)
|
||||
assert.equal(cols.has("ros_version"), false)
|
||||
}
|
||||
|
||||
{
|
||||
const mig = await dbQuery<{ id: string }>(
|
||||
`SELECT id FROM schema_migrations WHERE id = '0002_compact_schema'`,
|
||||
)
|
||||
assert.equal(mig.rows.length, 1, "0002 применена")
|
||||
|
||||
await dbQuery(`INSERT INTO servers (name, host) VALUES ('pg-wipe-idempotent', '127.0.0.1')`)
|
||||
await applySqlMigrations(pool)
|
||||
const still = await dbQuery<{ n: string }>(
|
||||
`SELECT COUNT(*)::text AS n FROM servers WHERE name = 'pg-wipe-idempotent'`,
|
||||
)
|
||||
assert.equal(still.rows[0]?.n, "1", "повторный applySqlMigrations не wipe")
|
||||
await dbQuery(`DELETE FROM servers WHERE name = 'pg-wipe-idempotent'`)
|
||||
}
|
||||
|
||||
{
|
||||
const mig = await dbQuery<{ id: string }>(
|
||||
`SELECT id FROM schema_migrations WHERE id = '0006_config_revisions'`,
|
||||
)
|
||||
assert.equal(mig.rows.length, 1, "0006 применена")
|
||||
|
||||
const { rows } = await dbQuery<{ column_name: string; udt_name: string }>(`
|
||||
SELECT column_name, udt_name FROM information_schema.columns
|
||||
WHERE table_schema = 'public' AND table_name = 'config_revisions'
|
||||
`)
|
||||
const by = Object.fromEntries(rows.map((r) => [r.column_name, r.udt_name]))
|
||||
assert.equal(by.payload, "jsonb")
|
||||
assert.equal(by.fingerprint, "text")
|
||||
assert.equal(by.section, "text")
|
||||
}
|
||||
|
||||
{
|
||||
const mig = await dbQuery<{ id: string }>(
|
||||
`SELECT id FROM schema_migrations WHERE id = '0007_flow_buckets_nat'`,
|
||||
)
|
||||
assert.equal(mig.rows.length, 1, "0007 применена")
|
||||
}
|
||||
|
||||
{
|
||||
const marker = await dbQuery<{ sqlite_imported_at: string | null }>(
|
||||
`SELECT sqlite_imported_at FROM data_migration WHERE id = 1`,
|
||||
)
|
||||
assert.ok(marker.rows[0], "data_migration singleton после wipe")
|
||||
}
|
||||
|
||||
console.log("pg-schema.test.ts: ok")
|
||||
@@ -0,0 +1,46 @@
|
||||
-- ISOLATED PROPOSAL ONLY: normal startup does not discover this file.
|
||||
-- Approval-time configuration identity; full execution context remains exact.
|
||||
-- Caller must own the transaction. No migration marker or production activation.
|
||||
LOCK TABLE public.traffic_settings, public.uptime_settings,
|
||||
public.internet_path_settings IN ACCESS EXCLUSIVE MODE;
|
||||
ALTER TABLE public.traffic_settings ADD COLUMN poll_configuration_epoch uuid NOT NULL DEFAULT pg_catalog.gen_random_uuid();
|
||||
ALTER TABLE public.uptime_settings ADD COLUMN poll_configuration_epoch uuid NOT NULL DEFAULT pg_catalog.gen_random_uuid();
|
||||
ALTER TABLE public.internet_path_settings ADD COLUMN poll_configuration_epoch uuid NOT NULL DEFAULT pg_catalog.gen_random_uuid();
|
||||
CREATE FUNCTION public.server_poll_settings_epoch_guard() RETURNS trigger
|
||||
LANGUAGE plpgsql SET search_path = pg_catalog AS $epoch$
|
||||
BEGIN
|
||||
IF TG_TABLE_SCHEMA <> 'public' OR TG_TABLE_NAME NOT IN
|
||||
('traffic_settings', 'uptime_settings', 'internet_path_settings')
|
||||
OR TG_LEVEL <> 'ROW' OR TG_WHEN <> 'BEFORE' THEN
|
||||
RAISE EXCEPTION 'unexpected poll settings epoch trigger target';
|
||||
END IF;
|
||||
IF TG_OP = 'INSERT' THEN
|
||||
-- Never accept an epoch supplied by an importer or application caller.
|
||||
NEW.poll_configuration_epoch := pg_catalog.gen_random_uuid();
|
||||
ELSIF TG_OP = 'UPDATE' THEN
|
||||
-- Every unknown/future field is configuration by default. Only these four
|
||||
-- current collector fields are excluded; created_at stays significant.
|
||||
IF (pg_catalog.to_jsonb(NEW) - ARRAY['poll_configuration_epoch',
|
||||
'last_collected_at', 'last_duration_ms', 'last_error', 'updated_at'])
|
||||
IS DISTINCT FROM
|
||||
(pg_catalog.to_jsonb(OLD) - ARRAY['poll_configuration_epoch',
|
||||
'last_collected_at', 'last_duration_ms', 'last_error', 'updated_at']) THEN
|
||||
NEW.poll_configuration_epoch := pg_catalog.gen_random_uuid();
|
||||
ELSE
|
||||
NEW.poll_configuration_epoch := OLD.poll_configuration_epoch;
|
||||
END IF;
|
||||
ELSE
|
||||
RAISE EXCEPTION 'unexpected poll settings epoch trigger operation';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$epoch$;
|
||||
CREATE TRIGGER server_poll_settings_epoch BEFORE INSERT OR UPDATE ON public.traffic_settings
|
||||
FOR EACH ROW EXECUTE FUNCTION public.server_poll_settings_epoch_guard();
|
||||
CREATE TRIGGER server_poll_settings_epoch BEFORE INSERT OR UPDATE ON public.uptime_settings
|
||||
FOR EACH ROW EXECUTE FUNCTION public.server_poll_settings_epoch_guard();
|
||||
CREATE TRIGGER server_poll_settings_epoch BEFORE INSERT OR UPDATE ON public.internet_path_settings
|
||||
FOR EACH ROW EXECUTE FUNCTION public.server_poll_settings_epoch_guard();
|
||||
ALTER TABLE public.traffic_settings ENABLE ALWAYS TRIGGER server_poll_settings_epoch;
|
||||
ALTER TABLE public.uptime_settings ENABLE ALWAYS TRIGGER server_poll_settings_epoch;
|
||||
ALTER TABLE public.internet_path_settings ENABLE ALWAYS TRIGGER server_poll_settings_epoch;
|
||||
@@ -0,0 +1,170 @@
|
||||
-- ISOLATED PROPOSAL ONLY. Not discovered by normal migration startup.
|
||||
-- Fixed logical quota; physical heap/index/WAL and vacuum remain independent.
|
||||
-- Requires the 0038 poll journal. No backfill, scheduling or public activation.
|
||||
BEGIN;
|
||||
LOCK TABLE server_poll_executions,server_poll_intents,server_poll_acks,server_poll_receipts,server_poll_quarantines IN ACCESS EXCLUSIVE MODE;
|
||||
DO $$ BEGIN
|
||||
IF EXISTS(SELECT 1 FROM server_poll_executions) OR EXISTS(SELECT 1 FROM server_poll_intents)
|
||||
OR EXISTS(SELECT 1 FROM server_poll_acks) OR EXISTS(SELECT 1 FROM server_poll_receipts)
|
||||
OR EXISTS(SELECT 1 FROM server_poll_quarantines)
|
||||
THEN RAISE EXCEPTION 'nonempty poll journal needs explicit reconciliation'; END IF;
|
||||
IF NOT EXISTS(SELECT 1 FROM mcp_settings WHERE id=1 AND requests_retention_days BETWEEN 1 AND 365)
|
||||
THEN RAISE EXCEPTION 'poll retention unavailable'; END IF;
|
||||
END $$;
|
||||
CREATE TABLE server_poll_storage_usage (
|
||||
scope text PRIMARY KEY CHECK(scope='global' OR(scope LIKE 'owner:%' AND length(scope) BETWEEN 7 AND 206)),
|
||||
admissions bigint NOT NULL DEFAULT 0 CHECK(admissions>=0 AND admissions<=128),
|
||||
reserved_rows bigint NOT NULL DEFAULT 0 CHECK(reserved_rows=admissions*4019),
|
||||
reserved_bytes bigint NOT NULL DEFAULT 0 CHECK(reserved_bytes=admissions*8421376),
|
||||
CHECK(scope='global' OR admissions<=8)
|
||||
);
|
||||
CREATE TABLE server_poll_storage_admissions (
|
||||
receipt_id uuid PRIMARY KEY, identity_kind text NOT NULL CHECK(identity_kind IN('mcp','rest')),
|
||||
execution_id uuid NOT NULL, connection_id uuid, owner_id text NOT NULL CHECK(length(owner_id) BETWEEN 1 AND 200 AND btrim(owner_id)=owner_id AND owner_id !~ '[[:cntrl:]]'),
|
||||
binding_proof text NOT NULL CHECK(binding_proof ~ '^[a-f0-9]{64}$'), context_proof text NOT NULL CHECK(context_proof ~ '^[a-f0-9]{64}$'),
|
||||
authority_issued_at timestamptz NOT NULL CHECK(isfinite(authority_issued_at)),
|
||||
authority_expires_at timestamptz NOT NULL CHECK(isfinite(authority_expires_at) AND authority_expires_at>authority_issued_at),
|
||||
max_stages integer NOT NULL CHECK(max_stages BETWEEN 8 AND 2008),
|
||||
claim_token uuid, claim_attempted_at timestamptz, created_at timestamptz NOT NULL DEFAULT clock_timestamp(),
|
||||
retention_days integer NOT NULL CHECK(retention_days BETWEEN 1 AND 365),
|
||||
safety_ms integer NOT NULL DEFAULT 60000 CHECK(safety_ms=60000),
|
||||
gc_not_before timestamptz NOT NULL CHECK(isfinite(gc_not_before)),
|
||||
used_rows integer NOT NULL DEFAULT 0 CHECK(used_rows BETWEEN 0 AND 4019 AND used_rows<=max_stages*2+3),
|
||||
used_bytes bigint NOT NULL DEFAULT 0 CHECK(used_bytes BETWEEN 0 AND 8388608),
|
||||
UNIQUE(identity_kind,execution_id),
|
||||
CHECK((identity_kind='mcp' AND connection_id IS NOT NULL) OR(identity_kind='rest' AND connection_id IS NULL)),
|
||||
CHECK((claim_token IS NOT NULL AND claim_attempted_at IS NULL) OR(claim_token IS NULL AND claim_attempted_at IS NOT NULL)),
|
||||
CHECK(gc_not_before>=authority_expires_at+make_interval(days=>retention_days)+interval '60 seconds')
|
||||
);
|
||||
CREATE INDEX server_poll_storage_expiry ON server_poll_storage_admissions(gc_not_before,receipt_id);
|
||||
INSERT INTO server_poll_storage_usage(scope) VALUES('global');
|
||||
|
||||
-- GUARDS: isolated historical fixtures may be inserted before this boundary;
|
||||
-- production enrollment never copies or inserts historical admissions.
|
||||
CREATE FUNCTION server_poll_storage_gc_allowed(id uuid) RETURNS boolean LANGUAGE plpgsql SET TimeZone='UTC' AS $$
|
||||
DECLARE a 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 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 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
|
||||
SELECT GREATEST(o.finished_at,p.expires_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 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,c;
|
||||
IF NOT FOUND OR terminal_at IS NULL THEN RETURN false; END IF;
|
||||
END IF;
|
||||
SELECT GREATEST(a.authority_expires_at,a.created_at,COALESCE(a.claim_attempted_at,a.created_at),terminal_at,
|
||||
COALESCE((SELECT created_at FROM server_poll_executions WHERE receipt_id=id),a.created_at),
|
||||
COALESCE((SELECT max(created_at) FROM server_poll_intents WHERE receipt_id=id),a.created_at),
|
||||
COALESCE((SELECT max(created_at) FROM server_poll_acks WHERE receipt_id=id),a.created_at),
|
||||
COALESCE((SELECT created_at FROM server_poll_receipts WHERE receipt_id=id),a.created_at),
|
||||
COALESCE((SELECT created_at FROM server_poll_quarantines WHERE receipt_id=id),a.created_at)) INTO activity;
|
||||
RETURN activity+make_interval(days=>GREATEST(a.retention_days,retention))+a.safety_ms*interval '1 millisecond'<clock_timestamp();
|
||||
END $$;
|
||||
|
||||
CREATE FUNCTION server_poll_storage_admission_guard() RETURNS trigger LANGUAGE plpgsql SET TimeZone='UTC' AS $$
|
||||
DECLARE retention integer; charged integer;
|
||||
BEGIN
|
||||
PERFORM pg_advisory_xact_lock(hashtextextended('server-poll-storage-v1',0));
|
||||
IF TG_OP='DELETE' THEN
|
||||
IF NOT server_poll_storage_gc_allowed(OLD.receipt_id) OR EXISTS(SELECT 1 FROM server_poll_executions WHERE receipt_id=OLD.receipt_id)
|
||||
OR EXISTS(SELECT 1 FROM server_poll_intents WHERE receipt_id=OLD.receipt_id) OR EXISTS(SELECT 1 FROM server_poll_acks WHERE receipt_id=OLD.receipt_id)
|
||||
OR EXISTS(SELECT 1 FROM server_poll_receipts WHERE receipt_id=OLD.receipt_id) OR EXISTS(SELECT 1 FROM server_poll_quarantines WHERE receipt_id=OLD.receipt_id)
|
||||
THEN RAISE EXCEPTION 'poll lifetime or children prevent admission deletion'; END IF;
|
||||
UPDATE server_poll_storage_usage SET admissions=admissions-1,reserved_rows=reserved_rows-4019,reserved_bytes=reserved_bytes-8421376
|
||||
WHERE scope IN('global','owner:'||OLD.owner_id) AND admissions>=1 AND reserved_rows>=4019 AND reserved_bytes>=8421376;
|
||||
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(to_jsonb(OLD)-ARRAY['claim_token','claim_attempted_at','used_rows','used_bytes']) IS DISTINCT FROM
|
||||
(to_jsonb(NEW)-ARRAY['claim_token','claim_attempted_at','used_rows','used_bytes']) THEN RAISE EXCEPTION 'poll admission identity is immutable'; 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;
|
||||
SELECT requests_retention_days INTO retention FROM 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 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 server_poll_storage_usage(scope) VALUES('owner:'||NEW.owner_id) ON CONFLICT DO NOTHING;
|
||||
IF(SELECT count(*) FROM server_poll_storage_usage WHERE scope IN('global','owner:'||NEW.owner_id)
|
||||
AND admissions<CASE WHEN scope='global' THEN 128 ELSE 8 END)<>2 THEN RAISE EXCEPTION 'poll admission quota exhausted'; END IF;
|
||||
UPDATE 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 TRIGGER server_poll_storage_admission_guard BEFORE INSERT OR UPDATE OR DELETE ON server_poll_storage_admissions
|
||||
FOR EACH ROW EXECUTE FUNCTION server_poll_storage_admission_guard();
|
||||
|
||||
CREATE FUNCTION server_poll_storage_row_guard() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
DECLARE a server_poll_storage_admissions%ROWTYPE; n integer;
|
||||
BEGIN
|
||||
PERFORM pg_advisory_xact_lock(hashtextextended('server-poll-storage-v1',0));
|
||||
SELECT * INTO a FROM server_poll_storage_admissions WHERE receipt_id=NEW.receipt_id FOR UPDATE;
|
||||
IF NOT FOUND THEN RAISE EXCEPTION 'poll journal needs a committed admission'; END IF;
|
||||
IF TG_TABLE_NAME='server_poll_quarantines' THEN
|
||||
IF NEW.identity_kind<>a.identity_kind OR NEW.execution_id<>a.execution_id OR NEW.context_proof<>a.context_proof
|
||||
THEN RAISE EXCEPTION 'poll quarantine admission mismatch'; END IF;
|
||||
ELSE
|
||||
IF a.claim_token IS NOT NULL OR a.claim_attempted_at IS NULL OR a.authority_expires_at<=clock_timestamp()
|
||||
THEN RAISE EXCEPTION 'poll admission is not consumed and live'; END IF;
|
||||
END IF;
|
||||
IF TG_TABLE_NAME='server_poll_executions' THEN
|
||||
IF NEW.identity_kind<>a.identity_kind OR NEW.execution_id<>a.execution_id OR NEW.context_proof<>a.context_proof OR NEW.binding_proof<>a.binding_proof
|
||||
THEN RAISE EXCEPTION 'poll anchor admission mismatch'; END IF;
|
||||
ELSIF TG_TABLE_NAME IN('server_poll_intents','server_poll_acks') THEN
|
||||
IF NEW.stage LIKE 'publish.%' AND substring(NEW.stage FROM 9)::integer>=a.max_stages-4 THEN RAISE EXCEPTION 'poll stage exceeds private reservation'; END IF;
|
||||
IF TG_TABLE_NAME='server_poll_intents' THEN SELECT count(*) INTO n FROM server_poll_intents WHERE receipt_id=NEW.receipt_id;
|
||||
ELSE SELECT count(*) INTO n FROM server_poll_acks WHERE receipt_id=NEW.receipt_id; END IF;
|
||||
IF n>=a.max_stages THEN RAISE EXCEPTION 'poll stage row budget exhausted'; END IF;
|
||||
END IF;
|
||||
IF TG_TABLE_NAME<>'server_poll_quarantines' AND EXISTS(SELECT 1 FROM server_poll_quarantines WHERE receipt_id=NEW.receipt_id)
|
||||
THEN RAISE EXCEPTION 'poll identity quarantined'; END IF;
|
||||
NEW.created_at:=clock_timestamp();
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
-- Meter actual inserts, not BEFORE attempts: ON CONFLICT DO NOTHING must not
|
||||
-- spend another row/byte allowance for a marker which was never inserted.
|
||||
CREATE FUNCTION server_poll_storage_row_meter() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
DECLARE cost bigint; n integer;
|
||||
BEGIN
|
||||
PERFORM pg_advisory_xact_lock(hashtextextended('server-poll-storage-v1',0));
|
||||
cost:=pg_column_size(NEW);
|
||||
UPDATE server_poll_storage_admissions SET used_rows=used_rows+1,used_bytes=used_bytes+cost
|
||||
WHERE receipt_id=NEW.receipt_id AND used_rows<max_stages*2+3 AND used_bytes<=8388608-cost;
|
||||
GET DIAGNOSTICS n=ROW_COUNT; IF n<>1 THEN RAISE EXCEPTION 'poll journal byte or row budget exhausted'; END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
CREATE TRIGGER server_poll_storage_anchor_guard BEFORE INSERT ON server_poll_executions FOR EACH ROW EXECUTE FUNCTION server_poll_storage_row_guard();
|
||||
CREATE TRIGGER server_poll_storage_intent_guard BEFORE INSERT ON server_poll_intents FOR EACH ROW EXECUTE FUNCTION server_poll_storage_row_guard();
|
||||
CREATE TRIGGER server_poll_storage_ack_guard BEFORE INSERT ON server_poll_acks FOR EACH ROW EXECUTE FUNCTION server_poll_storage_row_guard();
|
||||
CREATE TRIGGER server_poll_storage_receipt_guard BEFORE INSERT ON server_poll_receipts FOR EACH ROW EXECUTE FUNCTION server_poll_storage_row_guard();
|
||||
CREATE TRIGGER server_poll_storage_quarantine_guard BEFORE INSERT ON server_poll_quarantines FOR EACH ROW EXECUTE FUNCTION server_poll_storage_row_guard();
|
||||
CREATE TRIGGER server_poll_storage_anchor_meter AFTER INSERT ON server_poll_executions FOR EACH ROW EXECUTE FUNCTION server_poll_storage_row_meter();
|
||||
CREATE TRIGGER server_poll_storage_intent_meter AFTER INSERT ON server_poll_intents FOR EACH ROW EXECUTE FUNCTION server_poll_storage_row_meter();
|
||||
CREATE TRIGGER server_poll_storage_ack_meter AFTER INSERT ON server_poll_acks FOR EACH ROW EXECUTE FUNCTION server_poll_storage_row_meter();
|
||||
CREATE TRIGGER server_poll_storage_receipt_meter AFTER INSERT ON server_poll_receipts FOR EACH ROW EXECUTE FUNCTION server_poll_storage_row_meter();
|
||||
CREATE TRIGGER server_poll_storage_quarantine_meter AFTER INSERT ON server_poll_quarantines FOR EACH ROW EXECUTE FUNCTION server_poll_storage_row_meter();
|
||||
CREATE OR REPLACE FUNCTION server_poll_journal_immutable() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN
|
||||
IF TG_OP='DELETE' AND server_poll_storage_gc_allowed(OLD.receipt_id) THEN RETURN OLD; END IF;
|
||||
RAISE EXCEPTION 'server poll journal is immutable before its full lifetime elapses';
|
||||
END $$;
|
||||
COMMIT;
|
||||
@@ -0,0 +1,255 @@
|
||||
-- REVIEW / ISOLATED FIXTURE ONLY. Not a migration; never discovered by migrate.
|
||||
-- User-approved bounded metadata GC, isolated fixture only. No scheduling or
|
||||
-- production registration; migrations never discover this proposal.
|
||||
-- Requires 0035, 0036 and mcp_settings. Stop old writers before a separately approved
|
||||
-- real migration. Legacy orphan quarantines deliberately refuse backfill.
|
||||
BEGIN;
|
||||
LOCK TABLE server_probe_executions,server_probe_execution_stages,server_probe_execution_quarantines,mcp_diagnostic_cancellations IN ACCESS EXCLUSIVE MODE;
|
||||
DO $$ BEGIN
|
||||
IF EXISTS(SELECT 1 FROM server_probe_execution_quarantines q WHERE NOT EXISTS(
|
||||
SELECT 1 FROM server_probe_executions e WHERE e.receipt_id=q.receipt_id
|
||||
AND e.identity_kind=q.identity_kind AND e.execution_id=q.execution_id AND e.binding_proof=q.binding_proof))
|
||||
THEN RAISE EXCEPTION 'unmapped probe quarantine prevents storage conversion'; END IF;
|
||||
IF NOT EXISTS(SELECT 1 FROM mcp_settings WHERE id=1 AND requests_retention_days BETWEEN 1 AND 365)
|
||||
THEN RAISE EXCEPTION 'authoritative probe retention setting unavailable'; END IF;
|
||||
END $$;
|
||||
|
||||
CREATE TABLE server_probe_storage_usage (
|
||||
scope text PRIMARY KEY CHECK(scope='global' OR (scope LIKE 'owner:%' AND length(scope) BETWEEN 7 AND 206)),
|
||||
admissions bigint NOT NULL DEFAULT 0 CHECK(admissions>=0),
|
||||
reserved_files bigint NOT NULL DEFAULT 0 CHECK(reserved_files>=0 AND reserved_files<=admissions),
|
||||
reserved_bytes bigint NOT NULL DEFAULT 0 CHECK(reserved_bytes>=0),
|
||||
CHECK(reserved_bytes=admissions*32768+reserved_files*262144)
|
||||
);
|
||||
CREATE TABLE server_probe_storage_admissions (
|
||||
receipt_id uuid PRIMARY KEY,
|
||||
owner_id text NOT NULL CHECK(length(owner_id) BETWEEN 1 AND 200),
|
||||
identity_kind text NOT NULL CHECK(identity_kind IN ('mcp','rest')), execution_id uuid NOT NULL,
|
||||
binding_proof text NOT NULL CHECK(binding_proof ~ '^[a-f0-9]{64}$'),
|
||||
authority_expires_at timestamptz NOT NULL,
|
||||
claim_token uuid, claim_attempted_at timestamptz,
|
||||
purged_at timestamptz, cancelled_at timestamptz, created_at timestamptz NOT NULL DEFAULT clock_timestamp(),
|
||||
retention_days integer NOT NULL CHECK(retention_days BETWEEN 1 AND 365),
|
||||
safety_ms integer NOT NULL DEFAULT 60000 CHECK(safety_ms BETWEEN 60000 AND 86400000),
|
||||
gc_not_before timestamptz NOT NULL DEFAULT 'infinity',
|
||||
CHECK(gc_not_before>=authority_expires_at+make_interval(days=>retention_days)+safety_ms*interval '1 millisecond'),
|
||||
UNIQUE(identity_kind,execution_id),
|
||||
CHECK(claim_token IS NULL OR (claim_attempted_at IS NULL AND purged_at IS NULL AND cancelled_at IS NULL)),
|
||||
CHECK(cancelled_at IS NULL OR (claim_token IS NULL AND claim_attempted_at IS NOT NULL))
|
||||
);
|
||||
CREATE INDEX server_probe_storage_admissions_owner_age ON server_probe_storage_admissions(owner_id,authority_expires_at,receipt_id);
|
||||
CREATE TABLE server_probe_execution_payloads (
|
||||
receipt_id uuid PRIMARY KEY REFERENCES server_probe_executions(receipt_id),
|
||||
receipt_ciphertext text NOT NULL CHECK(octet_length(receipt_ciphertext) BETWEEN 1 AND 262144),
|
||||
receipt_proof text NOT NULL CHECK(receipt_proof ~ '^[a-f0-9]{64}$')
|
||||
);
|
||||
INSERT INTO server_probe_storage_admissions(receipt_id,owner_id,identity_kind,execution_id,binding_proof,authority_expires_at,claim_attempted_at,created_at,retention_days,safety_ms,gc_not_before)
|
||||
SELECT receipt_id,actor_id,identity_kind,execution_id,binding_proof,authority_expires_at,created_at,created_at,
|
||||
(SELECT requests_retention_days FROM mcp_settings WHERE id=1),60000,
|
||||
GREATEST(authority_expires_at,created_at)+make_interval(days=>(SELECT requests_retention_days FROM mcp_settings WHERE id=1))+interval '1 minute' FROM server_probe_executions;
|
||||
-- Legacy cancellation has no receipt/binding proof of its own. Only an exact
|
||||
-- native execution/owner/connection/operation mapping can reuse a charged
|
||||
-- admission. Orphans REFUSE conversion: a separate authenticated preflight and
|
||||
-- backfill proposal is required; target-only evidence never fabricates one.
|
||||
DO $$ BEGIN
|
||||
IF EXISTS(SELECT 1 FROM mcp_diagnostic_cancellations c WHERE NOT EXISTS(
|
||||
SELECT 1 FROM server_probe_storage_admissions a JOIN server_probe_executions e ON e.receipt_id=a.receipt_id
|
||||
JOIN mcp_operations o ON o.id=c.operation_id AND o.connection_id=c.connection_id
|
||||
JOIN mcp_plans p ON p.id=o.plan_id AND p.connection_id=o.connection_id
|
||||
WHERE a.identity_kind='mcp' AND a.execution_id=c.operation_id AND a.owner_id=c.actor_id
|
||||
AND e.actor_id=a.owner_id AND e.identity_kind=a.identity_kind AND e.execution_id=a.execution_id
|
||||
AND e.binding_proof=a.binding_proof AND e.authority_expires_at=a.authority_expires_at
|
||||
AND p.operation_id='servers.probes.run'))
|
||||
THEN RAISE EXCEPTION 'unmapped diagnostic cancellation prevents storage conversion; authenticated backfill required'; END IF;
|
||||
END $$;
|
||||
UPDATE server_probe_storage_admissions a SET cancelled_at=c.created_at
|
||||
FROM mcp_diagnostic_cancellations c WHERE a.identity_kind='mcp' AND a.execution_id=c.operation_id AND a.owner_id=c.actor_id;
|
||||
INSERT INTO server_probe_execution_payloads(receipt_id,receipt_ciphertext,receipt_proof)
|
||||
SELECT receipt_id,receipt_ciphertext,receipt_proof FROM server_probe_executions WHERE state<>'claimed';
|
||||
DO $$ BEGIN
|
||||
IF EXISTS(SELECT 1 FROM server_probe_executions e LEFT JOIN server_probe_execution_payloads p USING(receipt_id)
|
||||
WHERE e.state<>'claimed' AND (p.receipt_id IS NULL OR p.receipt_ciphertext IS DISTINCT FROM e.receipt_ciphertext OR p.receipt_proof IS DISTINCT FROM e.receipt_proof))
|
||||
THEN RAISE EXCEPTION 'probe payload copy mismatch'; END IF;
|
||||
END $$;
|
||||
INSERT INTO server_probe_storage_usage(scope,admissions,reserved_files,reserved_bytes)
|
||||
SELECT 'global',count(*),count(*),count(*)*294912 FROM server_probe_storage_admissions;
|
||||
INSERT INTO server_probe_storage_usage(scope,admissions,reserved_files,reserved_bytes)
|
||||
SELECT 'owner:'||owner_id,count(*),count(*),count(*)*294912 FROM server_probe_storage_admissions GROUP BY owner_id;
|
||||
|
||||
-- PostgreSQL drops table CHECKs dependent on this column together with it.
|
||||
-- Copy verification above precedes the destructive *proposal* transformation.
|
||||
ALTER TABLE server_probe_executions DROP COLUMN receipt_ciphertext;
|
||||
ALTER TABLE server_probe_executions ADD CONSTRAINT server_probe_storage_terminal_state CHECK(
|
||||
(state='claimed' AND receipt_proof IS NULL AND finished_at IS NULL AND attempted_stages IS NULL AND completed_stages IS NULL)
|
||||
OR(state<>'claimed' AND receipt_proof IS NOT NULL AND finished_at IS NOT NULL AND attempted_stages IS NOT NULL AND completed_stages IS NOT NULL));
|
||||
|
||||
-- Predicate only: it performs no DELETE. Every guarded exception below must
|
||||
-- independently satisfy the persisted lifetime, actual current retention and
|
||||
-- original terminal operation. The GUC alone never grants cleanup authority.
|
||||
CREATE FUNCTION 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;
|
||||
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 $$;
|
||||
|
||||
CREATE FUNCTION server_probe_storage_admission_guard() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
DECLARE current_retention integer; activity timestamptz;
|
||||
BEGIN
|
||||
PERFORM pg_advisory_xact_lock(hashtextextended('server-probe-storage-v2',0));
|
||||
IF TG_OP='DELETE' THEN
|
||||
IF server_probe_storage_gc_allowed(OLD.receipt_id) THEN RETURN OLD; END IF;
|
||||
RAISE EXCEPTION 'probe admission lifetime has not elapsed';
|
||||
END IF;
|
||||
SELECT requests_retention_days INTO current_retention FROM mcp_settings WHERE id=1;
|
||||
IF current_retention IS NULL OR current_retention NOT BETWEEN 1 AND 365 THEN RAISE EXCEPTION 'probe retention unavailable'; END IF;
|
||||
IF TG_OP='INSERT' THEN
|
||||
IF NEW.purged_at IS NOT NULL OR (NEW.cancelled_at IS NULL AND
|
||||
(NEW.authority_expires_at<=clock_timestamp() OR NEW.claim_token IS NULL OR NEW.claim_attempted_at IS NOT NULL))
|
||||
OR (NEW.cancelled_at IS NOT NULL AND (NEW.claim_token IS NOT NULL OR NEW.claim_attempted_at IS NULL))
|
||||
THEN RAISE EXCEPTION 'invalid fresh probe admission'; END IF;
|
||||
IF NEW.cancelled_at IS NOT NULL THEN NEW.cancelled_at:=clock_timestamp(); NEW.claim_attempted_at:=NEW.cancelled_at; END IF;
|
||||
NEW.retention_days:=current_retention; RETURN NEW;
|
||||
END IF;
|
||||
IF (to_jsonb(OLD)-ARRAY['claim_token','claim_attempted_at','purged_at','cancelled_at']) IS DISTINCT FROM
|
||||
(to_jsonb(NEW)-ARRAY['claim_token','claim_attempted_at','purged_at','cancelled_at'])
|
||||
THEN RAISE EXCEPTION 'immutable probe admission identity'; END IF;
|
||||
IF OLD.cancelled_at IS DISTINCT FROM NEW.cancelled_at THEN
|
||||
IF OLD.cancelled_at IS NULL AND NEW.cancelled_at IS NOT NULL AND NEW.claim_token IS NULL
|
||||
AND NEW.purged_at IS NOT DISTINCT FROM OLD.purged_at AND NEW.claim_attempted_at IS NOT NULL
|
||||
AND (OLD.claim_attempted_at IS NULL OR NEW.claim_attempted_at IS NOT DISTINCT FROM OLD.claim_attempted_at)
|
||||
THEN NEW.cancelled_at:=clock_timestamp(); IF OLD.claim_attempted_at IS NULL THEN NEW.claim_attempted_at:=NEW.cancelled_at; END IF; RETURN NEW; END IF;
|
||||
RAISE EXCEPTION 'probe cancellation is irreversible';
|
||||
END IF;
|
||||
IF OLD.purged_at IS NULL AND NEW.purged_at IS NULL AND OLD.claim_token IS NOT NULL AND NEW.claim_token IS NULL
|
||||
AND OLD.cancelled_at IS NULL AND OLD.claim_attempted_at IS NULL AND NEW.claim_attempted_at IS NOT NULL AND OLD.authority_expires_at>clock_timestamp()
|
||||
THEN RETURN NEW; END IF;
|
||||
IF OLD.purged_at IS NULL AND NEW.purged_at IS NOT NULL AND NEW.claim_token IS NULL
|
||||
AND NEW.claim_attempted_at IS NOT DISTINCT FROM OLD.claim_attempted_at THEN
|
||||
SELECT GREATEST(OLD.authority_expires_at,COALESCE(e.finished_at,OLD.authority_expires_at),COALESCE(q.created_at,OLD.authority_expires_at)) INTO activity
|
||||
FROM (SELECT 1) x LEFT JOIN server_probe_executions e ON e.receipt_id=OLD.receipt_id
|
||||
LEFT JOIN server_probe_execution_quarantines q ON q.receipt_id=OLD.receipt_id;
|
||||
IF activity<clock_timestamp()-make_interval(days=>GREATEST(OLD.retention_days,current_retention)) THEN RETURN NEW; END IF;
|
||||
END IF;
|
||||
RAISE EXCEPTION 'probe admission transition refused';
|
||||
END $$;
|
||||
CREATE TRIGGER server_probe_storage_admission_guard BEFORE INSERT OR UPDATE OR DELETE ON server_probe_storage_admissions
|
||||
FOR EACH ROW EXECUTE FUNCTION server_probe_storage_admission_guard();
|
||||
|
||||
CREATE FUNCTION server_probe_storage_anchor_guard() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
DECLARE a server_probe_storage_admissions%ROWTYPE;
|
||||
BEGIN
|
||||
PERFORM pg_advisory_xact_lock(hashtextextended('server-probe-storage-v2',0));
|
||||
SELECT * INTO a FROM server_probe_storage_admissions WHERE receipt_id=NEW.receipt_id FOR UPDATE;
|
||||
IF NOT FOUND OR a.owner_id<>NEW.actor_id OR a.identity_kind<>NEW.identity_kind OR a.execution_id<>NEW.execution_id
|
||||
OR a.binding_proof<>NEW.binding_proof OR a.authority_expires_at<>NEW.authority_expires_at
|
||||
OR a.purged_at IS NOT NULL OR a.cancelled_at IS NOT NULL OR a.claim_token IS NOT NULL OR a.claim_attempted_at IS NULL OR a.authority_expires_at<=clock_timestamp()
|
||||
THEN RAISE EXCEPTION 'probe anchor has no live consumed admission'; END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
CREATE TRIGGER server_probe_storage_anchor_guard BEFORE INSERT OR UPDATE ON server_probe_executions
|
||||
FOR EACH ROW EXECUTE FUNCTION server_probe_storage_anchor_guard();
|
||||
|
||||
CREATE OR REPLACE FUNCTION server_probe_execution_immutable() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN
|
||||
IF TG_OP='DELETE' THEN
|
||||
IF server_probe_storage_gc_allowed(OLD.receipt_id) THEN RETURN OLD; END IF;
|
||||
RAISE EXCEPTION 'probe execution lifetime has not elapsed';
|
||||
END IF;
|
||||
IF OLD.state<>'claimed' OR (to_jsonb(OLD)-ARRAY['state','attempted_stages','completed_stages','receipt_proof','finished_at']) IS DISTINCT FROM
|
||||
(to_jsonb(NEW)-ARRAY['state','attempted_stages','completed_stages','receipt_proof','finished_at']) OR NEW.state='claimed'
|
||||
THEN RAISE EXCEPTION 'probe execution identity or terminal receipt is immutable'; END IF;
|
||||
IF EXISTS(SELECT 1 FROM server_probe_execution_quarantines WHERE receipt_id=OLD.receipt_id)
|
||||
THEN RAISE EXCEPTION 'probe execution quarantined'; END IF;
|
||||
RETURN NEW; END $$;
|
||||
|
||||
CREATE FUNCTION server_probe_storage_stage_guard() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN
|
||||
PERFORM pg_advisory_xact_lock(hashtextextended('server-probe-storage-v2',0));
|
||||
PERFORM receipt_id FROM server_probe_storage_admissions WHERE receipt_id=NEW.receipt_id AND purged_at IS NULL AND cancelled_at IS NULL
|
||||
AND claim_token IS NULL AND claim_attempted_at IS NOT NULL AND authority_expires_at>clock_timestamp() FOR UPDATE;
|
||||
IF NOT FOUND THEN RAISE EXCEPTION 'probe stage has no live admission'; END IF;
|
||||
RETURN NEW; END $$;
|
||||
CREATE TRIGGER server_probe_storage_stage_guard BEFORE INSERT OR UPDATE ON server_probe_execution_stages
|
||||
FOR EACH ROW EXECUTE FUNCTION server_probe_storage_stage_guard();
|
||||
|
||||
CREATE FUNCTION server_probe_storage_quarantine_guard() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN
|
||||
PERFORM pg_advisory_xact_lock(hashtextextended('server-probe-storage-v2',0));
|
||||
PERFORM receipt_id FROM server_probe_storage_admissions WHERE receipt_id=NEW.receipt_id AND identity_kind=NEW.identity_kind
|
||||
AND execution_id=NEW.execution_id AND binding_proof=NEW.binding_proof FOR UPDATE;
|
||||
IF NOT FOUND THEN RAISE EXCEPTION 'probe quarantine has no committed matching admission'; END IF;
|
||||
RETURN NEW; END $$;
|
||||
CREATE TRIGGER server_probe_storage_quarantine_guard BEFORE INSERT ON server_probe_execution_quarantines
|
||||
FOR EACH ROW EXECUTE FUNCTION server_probe_storage_quarantine_guard();
|
||||
|
||||
CREATE FUNCTION server_probe_storage_payload_guard() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
DECLARE a server_probe_storage_admissions%ROWTYPE; e server_probe_executions%ROWTYPE;
|
||||
BEGIN
|
||||
PERFORM pg_advisory_xact_lock(hashtextextended('server-probe-storage-v2',0));
|
||||
IF TG_OP='UPDATE' THEN RAISE EXCEPTION 'probe payload is immutable'; END IF;
|
||||
IF TG_OP='DELETE' THEN
|
||||
PERFORM receipt_id FROM server_probe_storage_admissions WHERE receipt_id=OLD.receipt_id AND purged_at IS NOT NULL FOR UPDATE;
|
||||
IF NOT FOUND AND NOT server_probe_storage_gc_allowed(OLD.receipt_id) THEN RAISE EXCEPTION 'probe payload is not purged'; END IF;
|
||||
RETURN OLD;
|
||||
END IF;
|
||||
SELECT * INTO a FROM server_probe_storage_admissions WHERE receipt_id=NEW.receipt_id FOR UPDATE;
|
||||
IF NOT FOUND OR a.purged_at IS NOT NULL OR a.cancelled_at IS NOT NULL OR a.claim_token IS NOT NULL OR a.claim_attempted_at IS NULL OR a.authority_expires_at<=clock_timestamp()
|
||||
THEN RAISE EXCEPTION 'probe payload has no live admission'; END IF;
|
||||
SELECT * INTO e FROM server_probe_executions WHERE receipt_id=NEW.receipt_id FOR UPDATE;
|
||||
IF NOT FOUND OR e.state='claimed' OR e.receipt_proof<>NEW.receipt_proof OR e.actor_id<>a.owner_id OR e.binding_proof<>a.binding_proof
|
||||
OR e.identity_kind<>a.identity_kind OR e.execution_id<>a.execution_id OR EXISTS(SELECT 1 FROM server_probe_execution_quarantines q
|
||||
WHERE q.receipt_id=NEW.receipt_id OR(q.identity_kind=a.identity_kind AND q.execution_id=a.execution_id))
|
||||
THEN RAISE EXCEPTION 'probe payload terminal identity mismatch or quarantine'; END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
CREATE TRIGGER server_probe_storage_payload_guard BEFORE INSERT OR UPDATE OR DELETE ON server_probe_execution_payloads
|
||||
FOR EACH ROW EXECUTE FUNCTION server_probe_storage_payload_guard();
|
||||
-- Preserve every original INSERT/UPDATE rule; only the already-validated
|
||||
-- lifetime predicate can admit DELETE in this isolated proposal.
|
||||
CREATE OR REPLACE FUNCTION server_probe_stage_immutable() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN
|
||||
IF TG_OP='DELETE' THEN
|
||||
IF server_probe_storage_gc_allowed(OLD.receipt_id) THEN RETURN OLD; END IF;
|
||||
RAISE EXCEPTION 'probe stage lifetime has not elapsed';
|
||||
END IF;
|
||||
PERFORM receipt_id FROM server_probe_executions WHERE receipt_id=NEW.receipt_id AND state='claimed' FOR UPDATE;
|
||||
IF NOT FOUND OR EXISTS(SELECT 1 FROM server_probe_execution_quarantines WHERE receipt_id=NEW.receipt_id) THEN RAISE EXCEPTION 'probe attempt is not executable'; END IF;
|
||||
IF TG_OP='INSERT' AND (NEW.stage_index<>(SELECT COALESCE(max(stage_index),0)+1 FROM server_probe_execution_stages WHERE receipt_id=NEW.receipt_id)
|
||||
OR EXISTS(SELECT 1 FROM server_probe_execution_stages WHERE receipt_id=NEW.receipt_id AND state<>'received'))
|
||||
THEN RAISE EXCEPTION 'probe stages must be serial and contiguous'; END IF;
|
||||
IF TG_OP='UPDATE' AND (OLD.state<>'intent' OR NEW.state<>'received' OR (to_jsonb(OLD)-ARRAY['state','outcome','received_at']) IS DISTINCT FROM (to_jsonb(NEW)-ARRAY['state','outcome','received_at']))
|
||||
THEN RAISE EXCEPTION 'probe stage terminal state is immutable'; END IF;
|
||||
RETURN NEW; END $$;
|
||||
CREATE OR REPLACE FUNCTION server_probe_quarantine_immutable() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN
|
||||
IF TG_OP='DELETE' AND server_probe_storage_gc_allowed(OLD.receipt_id) THEN RETURN OLD; END IF;
|
||||
RAISE EXCEPTION 'probe quarantine remains immutable'; END $$;
|
||||
CREATE OR REPLACE FUNCTION mcp_diagnostic_cancellation_immutable() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
DECLARE original_receipt uuid;
|
||||
BEGIN
|
||||
IF TG_OP='DELETE' THEN
|
||||
SELECT a.receipt_id INTO original_receipt FROM server_probe_storage_admissions a
|
||||
JOIN mcp_operations o ON o.id=a.execution_id AND o.connection_id=OLD.connection_id
|
||||
WHERE a.identity_kind='mcp' AND a.execution_id=OLD.operation_id AND a.owner_id=OLD.actor_id;
|
||||
IF FOUND AND server_probe_storage_gc_allowed(original_receipt) THEN RETURN OLD; END IF;
|
||||
END IF;
|
||||
RAISE EXCEPTION 'diagnostic cancellation lifetime has not elapsed';
|
||||
END $$;
|
||||
COMMIT;
|
||||
@@ -0,0 +1,121 @@
|
||||
/** Numbered schema acceptance only. Synthetic legacy rows are never native
|
||||
* execution/checkpoint authority. No controller, router or external network. */
|
||||
import assert from "node:assert/strict"
|
||||
import { randomUUID } from "node:crypto"
|
||||
import { readFile } from "node:fs/promises"
|
||||
import { basename, dirname, isAbsolute, resolve } from "node:path"
|
||||
import { spawn } from "node:child_process"
|
||||
|
||||
async function main() {
|
||||
assert.equal(process.env.MMAPP_OWN_ROUTING_RESERVATION_SCHEMA, "true")
|
||||
assert.equal(process.env.NODE_ENV, "test")
|
||||
const endpoint = new URL(process.env.DATABASE_URL ?? "")
|
||||
assert(["postgres:", "postgresql:"].includes(endpoint.protocol))
|
||||
assert.equal(endpoint.hostname, "127.0.0.1"); assert.equal(endpoint.username, "mcp_test")
|
||||
assert.equal(endpoint.pathname, "/postgres"); assert(endpoint.password)
|
||||
assert(Number.isInteger(Number(endpoint.port)) && Number(endpoint.port) > 0 && Number(endpoint.port) <= 65535)
|
||||
assert.equal(endpoint.search, ""); assert.equal(endpoint.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 { Pool } = await import("pg")
|
||||
const { pool, closePool } = await import("./index.js")
|
||||
const { applySqlMigrations } = await import("./migrate.js")
|
||||
const { MIGRATION_MANIFEST, analyzeMigration } = await import("./migration-manifest.js")
|
||||
const files = await Promise.all(MIGRATION_MANIFEST.map(async pin => {
|
||||
const bytes = await readFile(new URL("../../drizzle/" + pin.file, import.meta.url))
|
||||
analyzeMigration(pin.file, bytes, pin); return { pin, sql: bytes.toString("utf8") }
|
||||
}))
|
||||
assert.equal(files.length, 40); assert.equal(files.at(-1)!.pin.file, "0040_mcp_routing_reservations.sql")
|
||||
const names: string[] = [], clients = new Set<InstanceType<typeof Pool>>(), groups: string[] = []
|
||||
const identifier = (name: string) => { assert(/^mm_rt_schema_[a-f0-9]{32}$/.test(name)); return '"' + name + '"' }
|
||||
const legacy = async () => {
|
||||
const name = "mm_rt_schema_" + randomUUID().replaceAll("-", "")
|
||||
await pool.query(`CREATE DATABASE ${identifier(name)} TEMPLATE template0`); names.push(name)
|
||||
const url = new URL(endpoint); url.pathname = "/" + name
|
||||
const target = new Pool({ connectionString: url.toString(), max: 2 }); clients.add(target)
|
||||
const owner = await target.connect()
|
||||
try {
|
||||
await owner.query("CREATE TABLE schema_migrations(id text PRIMARY KEY,applied_at timestamptz NOT NULL DEFAULT now())")
|
||||
for (const file of files.slice(0, -1)) {
|
||||
if (file.pin.transaction === "owner-transaction") await owner.query("BEGIN")
|
||||
await owner.query(file.sql)
|
||||
if (!file.pin.selfMarker) await owner.query("INSERT INTO schema_migrations(id) VALUES($1)", [file.pin.file.slice(0, -4)])
|
||||
if (file.pin.transaction === "owner-transaction") await owner.query("COMMIT")
|
||||
}
|
||||
} finally { owner.release(true) }
|
||||
return target
|
||||
}
|
||||
const snapshot = async (target: InstanceType<typeof Pool>, projected = false) => {
|
||||
const output: Record<string, unknown[]> = {}
|
||||
for (const table of ["mcp_connections", "mcp_plans", "mcp_operations", "mcp_routing_profiles", "mcp_budget_buckets", "mcp_scoped_recovery_intents", "servers", "traffic_flow_settings"])
|
||||
output[table] = (await target.query(`SELECT to_jsonb(t) ${projected && table === "mcp_scoped_recovery_intents" ? "- ARRAY['reservation_id','slot_phase','inverse_intent_sealed','inverse_intent_proof','inverse_configuration_sealed','inverse_configuration_proof']" : ""} AS value FROM ${table} t ORDER BY to_jsonb(t)::text`)).rows.map(row => row.value)
|
||||
return output
|
||||
}
|
||||
const seed = async (target: InstanceType<typeof Pool>, reserved = false) => {
|
||||
const connection = randomUUID(), profile = randomUUID(), operations: string[] = []
|
||||
await target.query("INSERT INTO mcp_connections(id,name,owner_id,owner_permissions,policy,generation) VALUES($1,'owned schema','owned-schema','[]','{}','owned-generation')", [connection])
|
||||
await target.query("INSERT INTO mcp_routing_profiles(id,connection_id,config,state,enabled,grant_expires_at) VALUES($1,$2,'{}','{}',false,now()+interval '1 hour')", [profile, connection])
|
||||
await target.query("INSERT INTO mcp_budget_buckets(connection_id,kind,bucket,used) VALUES($1,'mutations',date_trunc('hour',now()),7)", [connection])
|
||||
for (const state of ["intent", "verified", "unknown"]) {
|
||||
const plan = randomUUID(), operation = randomUUID(); operations.push(operation)
|
||||
await target.query("INSERT INTO mcp_plans(id,connection_id,operation_id,operation_version,policy_version,generation,payload,evidence,state,expires_at) VALUES($1,$2,'routing.community.switch',7,1,'owned-generation','{\"sealed\":\"owned-plan-bytes\"}','{\"sealed\":\"owned-evidence-bytes\"}','completed',now()+interval '1 hour')", [plan, connection])
|
||||
await target.query("INSERT INTO mcp_operations(id,plan_id,connection_id,idempotency_key,state) VALUES($1,$2,$3,$4,'partial')", [operation, plan, connection, randomUUID()])
|
||||
await target.query(`INSERT INTO mcp_scoped_recovery_intents(source_operation_id,source_step_index,source_receipt_id,source_configuration_proof,recovery_receipt_id,mode,authority_profile_id,authority_profile_version,intent_sealed,intent_proof,state,deadline,verified_sealed,verified_proof)
|
||||
VALUES($1,0,$2,'owned-source-proof',$3,$4,$5,1,'owned-intent-bytes','owned-intent-proof',$6,now()+interval '1 hour',$7,$8)`,
|
||||
[operation, randomUUID(), randomUUID(), reserved ? "reserved" : "manual", profile, state, state === "verified" ? "owned-verified-bytes" : null, state === "verified" ? "owned-verified-proof" : null])
|
||||
}
|
||||
return { connection, profile, operations }
|
||||
}
|
||||
const assertRefused = async (target: InstanceType<typeof Pool>, reason: RegExp) => {
|
||||
const before = await snapshot(target)
|
||||
const owner = await target.connect()
|
||||
try { await assert.rejects(owner.query(files.at(-1)!.sql), reason) }
|
||||
finally { await owner.query("ROLLBACK"); owner.release() }
|
||||
assert.deepEqual(await snapshot(target), before)
|
||||
assert.equal((await target.query("SELECT count(*)::int AS n FROM schema_migrations WHERE id='0040_mcp_routing_reservations'")).rows[0].n, 0)
|
||||
assert.equal((await target.query("SELECT count(*)::int AS n FROM pg_attribute WHERE attrelid='mcp_scoped_recovery_intents'::regclass AND attname='inverse_configuration_proof' AND NOT attisdropped")).rows[0].n, 0)
|
||||
}
|
||||
try {
|
||||
await applySqlMigrations(pool)
|
||||
assert.equal((await pool.query("SELECT count(*)::int AS n FROM schema_migrations")).rows[0].n, 40)
|
||||
for (const table of ["mcp_routing_reservations", "mcp_routing_reservation_slots"])
|
||||
assert.equal((await pool.query(`SELECT count(*)::int AS n FROM ${table}`)).rows[0].n, 0)
|
||||
groups.push("fresh full40 numbered chain creates empty inactive ledgers")
|
||||
const upgraded = await legacy(); await seed(upgraded)
|
||||
const before = await snapshot(upgraded)
|
||||
await applySqlMigrations(upgraded)
|
||||
assert.deepEqual(await snapshot(upgraded, true), before)
|
||||
assert.deepEqual((await upgraded.query("SELECT DISTINCT mode,slot_phase,reservation_id,inverse_intent_sealed,inverse_configuration_sealed FROM mcp_scoped_recovery_intents")).rows,
|
||||
[{ mode: "manual", slot_phase: "recovery-intent", reservation_id: null, inverse_intent_sealed: null, inverse_configuration_sealed: null }])
|
||||
const once = await snapshot(upgraded); await applySqlMigrations(upgraded); assert.deepEqual(await snapshot(upgraded), once)
|
||||
groups.push("nonempty legacy39 manual intent/verified/unknown and profiles/budget/ciphertext preserved; repeated startup skips0040")
|
||||
for (const update of ["source_configuration_proof=NULL", "slot_phase='reserved-attempt'", "inverse_intent_sealed='forged'", "inverse_intent_sealed='forged',inverse_intent_proof='forged'"])
|
||||
await assert.rejects(upgraded.query(`UPDATE mcp_scoped_recovery_intents SET ${update}`), (error: unknown) => (error as { code: string }).code === "23514")
|
||||
assert.deepEqual(await snapshot(upgraded), once)
|
||||
groups.push("real constraints reject manual mode/phase/null source proof/unpaired or reserved inverse evidence")
|
||||
const oldReserved = await legacy(); await seed(oldReserved, true); await assertRefused(oldReserved, /legacy reserved recovery/)
|
||||
groups.push("legacy reserved evidence refuses atomically without fabricated debit, capability or historical backfill")
|
||||
for (const ddl of ["CREATE TABLE mcp_routing_reservations(id uuid)", "ALTER TABLE mcp_scoped_recovery_intents ADD COLUMN reservation_id uuid", "ALTER TABLE mcp_scoped_recovery_intents ADD CONSTRAINT reserved_inverse_evidence_pairs CHECK(true)"]) {
|
||||
const collision = await legacy(); await seed(collision); await collision.query(ddl); await assertRefused(collision, /unmarked routing reservation/)
|
||||
}
|
||||
groups.push("unmarked proposal table/column/constraint refuses atomically; no enrollment marker or subsequent inverse columns")
|
||||
// New process cannot reconstruct either capability from persisted/serialized
|
||||
// kind JSON. No running operation or signed checkpoint is fabricated here.
|
||||
const storeUrl = new URL("../modules/mcp/routing-reservation-store.ts", import.meta.url).href
|
||||
const dbUrl = new URL("./index.ts", import.meta.url).href
|
||||
const script = `import assert from 'node:assert/strict';const {createRoutingReservationStore}=await import(${JSON.stringify(storeUrl)});const store=createRoutingReservationStore();await assert.rejects(store.beginInverseAttempt(JSON.parse('{"kind":"routing-reserved-inverse-allocation"}')),/capability/);await assert.rejects(store.inspectInverseExecution(JSON.parse('{"kind":"routing-reserved-inverse-execution"}')),/capability/);const {closePool}=await import(${JSON.stringify(dbUrl)});await closePool();`
|
||||
await new Promise<void>((done, reject) => {
|
||||
const child = spawn(process.execPath, ["--import", "tsx", "--input-type=module", "-e", script], { stdio: "inherit", windowsHide: true })
|
||||
child.once("error", reject); child.once("exit", code => code === 0 ? done() : reject(Error("Owned capability restart check failed")))
|
||||
})
|
||||
groups.push("fresh process refuses serialized inverse allocation/execution objects; schema does not mint capabilities")
|
||||
console.log(JSON.stringify({ accepted: groups.length, groups, numberedMigration: "0040_mcp_routing_reservations", migrationCount: 40,
|
||||
actualSql: true, proposalDdlExecuted: false, legacyManualDataPreserved: true, automaticBackfill: false,
|
||||
readyAuthorized: false, reservedNativeDispatchEnabled: false, genuineReservedLifecycleAccepted: false }))
|
||||
} finally {
|
||||
try { for (const target of clients) await target.end(); for (const name of names.reverse()) await pool.query(`DROP DATABASE ${identifier(name)}`) }
|
||||
finally { await closePool() }
|
||||
}
|
||||
}
|
||||
await main()
|
||||
@@ -0,0 +1,33 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { readFileSync } from "node:fs"
|
||||
import pg from "pg"
|
||||
import { env } from "../config.js"
|
||||
|
||||
// No bootstrap/migrations against public tables: this test needs only a connection.
|
||||
const pool = new pg.Pool({ connectionString: env.DATABASE_URL, connectionTimeoutMillis: 8000, statement_timeout: 10000 })
|
||||
try {
|
||||
const client = await pool.connect()
|
||||
try {
|
||||
await client.query("BEGIN")
|
||||
// Temporary shadow table: public scheduler history is never changed.
|
||||
await client.query("CREATE TEMP TABLE scheduler_runs(id integer PRIMARY KEY, finished_at timestamptz NOT NULL) ON COMMIT DROP")
|
||||
await client.query(`INSERT INTO scheduler_runs SELECT i, now() - CASE WHEN i=1 THEN interval '60 days' ELSE i * interval '1 second' END FROM generate_series(1,20000) AS i`)
|
||||
await client.query("ANALYZE scheduler_runs")
|
||||
const cutoff = new Date(Date.now() - 30 * 86400_000).toISOString()
|
||||
const plan = async () => JSON.stringify((await client.query("EXPLAIN (FORMAT JSON) SELECT id FROM scheduler_runs WHERE finished_at < $1", [cutoff])).rows)
|
||||
assert.match(await plan(), /Seq Scan/)
|
||||
const migration = readFileSync(new URL("../../drizzle/0013_scheduler_retention_index.sql", import.meta.url), "utf8")
|
||||
await client.query(migration)
|
||||
await client.query(migration)
|
||||
assert.match(await plan(), /Index Scan|Bitmap Index Scan/)
|
||||
assert.deepEqual((await client.query("SELECT id FROM scheduler_runs WHERE finished_at < $1", [cutoff])).rows, [{ id: 1 }])
|
||||
const index = (await client.query("SELECT indisvalid FROM pg_index WHERE indrelid = 'pg_temp.scheduler_runs'::regclass AND indexrelid::regclass::text LIKE '%idx_scheduler_runs_finished_at'")).rows
|
||||
assert.deepEqual(index, [{ indisvalid: true }])
|
||||
console.log("Scheduler retention: actual migration/idempotence/valid index/selective plan/result preservation passed on 20,000 temporary rows")
|
||||
} finally {
|
||||
await client.query("ROLLBACK")
|
||||
client.release()
|
||||
}
|
||||
} finally {
|
||||
await pool.end()
|
||||
}
|
||||
@@ -0,0 +1,908 @@
|
||||
import { sql } from "drizzle-orm"
|
||||
import {
|
||||
bigint,
|
||||
boolean,
|
||||
check,
|
||||
date,
|
||||
doublePrecision,
|
||||
index,
|
||||
inet,
|
||||
integer,
|
||||
jsonb,
|
||||
pgTable,
|
||||
primaryKey,
|
||||
smallint,
|
||||
text,
|
||||
timestamp,
|
||||
uniqueIndex,
|
||||
unique,
|
||||
uuid,
|
||||
} from "drizzle-orm/pg-core"
|
||||
|
||||
function ts(name: string) {
|
||||
return timestamp(name, { withTimezone: true, mode: "string" as const })
|
||||
}
|
||||
|
||||
export const routerLogSettings = pgTable("router_log_settings", {
|
||||
id: integer("id").primaryKey(), enabled: boolean("enabled").notNull().default(false),
|
||||
intervalSec: integer("interval_sec").notNull().default(10), retentionDays: integer("retention_days").notNull().default(14),
|
||||
})
|
||||
export const routerLogCollectionState = pgTable("router_log_collection_state", {
|
||||
serverId: integer("server_id").primaryKey(), serverName: text("server_name").notNull(), host: text("host").notNull(),
|
||||
generation: integer("generation").notNull().default(0), snapshot: jsonb("snapshot").$type<string[]>().notNull().default([]),
|
||||
lastCollectedAt: ts("last_collected_at"), lastError: text("last_error"), gapAt: ts("gap_at"), nextPollAt: ts("next_poll_at"), failures: integer("failures").notNull().default(0),
|
||||
})
|
||||
export const routerLogEntries = pgTable("router_log_entries", {
|
||||
id: bigint("id", { mode: "bigint" }).primaryKey().generatedAlwaysAsIdentity(), serverId: integer("server_id").notNull(),
|
||||
serverName: text("server_name").notNull(), host: text("host").notNull(), routerId: text("router_id").notNull(), rawTime: text("raw_time").notNull(),
|
||||
eventAt: ts("event_at"), receivedAt: ts("received_at").notNull(), effectiveAt: ts("effective_at").notNull(),
|
||||
topics: text("topics").array().notNull(), level: text("level").notNull(), message: text("message").notNull(), generation: integer("generation").notNull(),
|
||||
}, table => [index("router_logs_server_time").on(table.serverId, table.effectiveAt, table.id), index("router_logs_time").on(table.effectiveAt, table.id), index("router_logs_level_time").on(table.level, table.effectiveAt), index("router_logs_topics").using("gin", table.topics), index("router_logs_received").on(table.receivedAt), index("router_logs_server_cursor").on(table.serverId, table.id)])
|
||||
|
||||
function idIdentity() {
|
||||
return bigint("id", { mode: "number" }).generatedByDefaultAsIdentity()
|
||||
}
|
||||
|
||||
function idSingleton() {
|
||||
return bigint("id", { mode: "number" }).primaryKey()
|
||||
}
|
||||
|
||||
function intPkRef() {
|
||||
return bigint("server_id", { mode: "number" }).notNull()
|
||||
}
|
||||
|
||||
// ── servers ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export const servers = pgTable("servers", {
|
||||
id: idIdentity().primaryKey(),
|
||||
name: text("name").notNull().default(""),
|
||||
host: text("host").notNull(),
|
||||
port: integer("port").notNull().default(443),
|
||||
username: text("username").notNull().default("admin"),
|
||||
password: text("password").notNull().default(""),
|
||||
useSsl: boolean("use_ssl").notNull().default(true),
|
||||
verifySsl: boolean("verify_ssl").notNull().default(false),
|
||||
routingEndpointEpoch: uuid("routing_endpoint_epoch").notNull().defaultRandom(),
|
||||
type: text("type", { enum: ["jump-host", "exit-node", "home-router"] })
|
||||
.notNull().default("home-router"),
|
||||
site: text("site").notNull().default(""),
|
||||
country: text("country").notNull().default(""),
|
||||
asn: text("asn").notNull().default(""),
|
||||
comment: text("comment").notNull().default(""),
|
||||
enabled: boolean("enabled").notNull().default(true),
|
||||
lanSubnet: text("lan_subnet").notNull().default(""),
|
||||
wanUplinks: jsonb("wan_uplinks").notNull().default(sql`'[]'::jsonb`),
|
||||
mgmtTunnelIp: text("mgmt_tunnel_ip").notNull().default(""),
|
||||
createdAt: ts("created_at").notNull().defaultNow(),
|
||||
updatedAt: ts("updated_at").notNull().defaultNow(),
|
||||
})
|
||||
|
||||
export const serverSnapshots = pgTable("server_snapshots", {
|
||||
id: idIdentity(),
|
||||
serverId: intPkRef().references(() => servers.id, { onDelete: "cascade" }),
|
||||
polledAt: ts("polled_at").notNull(),
|
||||
status: text("status", { enum: ["online", "offline"] }).notNull(),
|
||||
latencyMs: doublePrecision("latency_ms"),
|
||||
rosVersion: text("ros_version"),
|
||||
boardName: text("board_name"),
|
||||
uptime: text("uptime"),
|
||||
cpuLoad: integer("cpu_load"),
|
||||
freeMemory: bigint("free_memory", { mode: "number" }),
|
||||
totalMemory: bigint("total_memory", { mode: "number" }),
|
||||
identityName: text("identity_name"),
|
||||
rawInterfaces: jsonb("raw_interfaces"),
|
||||
rawIpAddresses: jsonb("raw_ip_addresses"),
|
||||
}, (t) => [
|
||||
primaryKey({ columns: [t.id, t.polledAt] }),
|
||||
index("idx_server_snapshots_server_time").on(t.serverId, t.polledAt),
|
||||
])
|
||||
|
||||
export const filterRules = pgTable("filter_rules", {
|
||||
id: idIdentity().primaryKey(),
|
||||
serverId: intPkRef().references(() => servers.id, { onDelete: "cascade" }),
|
||||
sortOrder: integer("sort_order").notNull().default(0),
|
||||
community: text("community").notNull(),
|
||||
communityName: text("community_name"),
|
||||
action: text("action", { enum: ["route", "blackhole"] }).notNull().default("route"),
|
||||
gateway: text("gateway").notNull().default(""),
|
||||
gatewayTunnelId: text("gateway_tunnel_id").notNull().default(""),
|
||||
description: text("description").notNull().default(""),
|
||||
createdAt: ts("created_at").notNull().defaultNow(),
|
||||
updatedAt: ts("updated_at").notNull().defaultNow(),
|
||||
}, (t) => [
|
||||
index("idx_filter_rules_server_sort").on(t.serverId, t.sortOrder),
|
||||
])
|
||||
|
||||
export const configRevisions = pgTable("config_revisions", {
|
||||
id: text("id").primaryKey(),
|
||||
serverId: intPkRef().references(() => servers.id, { onDelete: "cascade" }),
|
||||
section: text("section", { enum: ["filters", "recursive-routes", "firewall", "wireguard", "ipsec", "gre"] }).notNull(),
|
||||
source: text("source", { enum: ["apply", "rollback", "observed", "copy"] }).notNull(),
|
||||
fingerprint: text("fingerprint").notNull(),
|
||||
payload: jsonb("payload").$type<unknown>().notNull().default(sql`'[]'::jsonb`),
|
||||
note: text("note"),
|
||||
createdAt: ts("created_at").notNull().defaultNow(),
|
||||
}, (t) => [
|
||||
index("idx_config_revisions_server_section_created").on(t.serverId, t.section, t.createdAt),
|
||||
])
|
||||
|
||||
export const recursiveRoutes = pgTable("recursive_routes", {
|
||||
id: idIdentity().primaryKey(),
|
||||
serverId: intPkRef().references(() => servers.id, { onDelete: "cascade" }),
|
||||
sortOrder: integer("sort_order").notNull().default(0),
|
||||
dstAddress: text("dst_address").notNull(),
|
||||
gateway: text("gateway").notNull(),
|
||||
distance: integer("distance").notNull().default(1),
|
||||
scope: integer("scope"),
|
||||
targetScope: integer("target_scope"),
|
||||
routingTable: text("routing_table").notNull().default("main"),
|
||||
checkGateway: text("check_gateway").notNull().default(""),
|
||||
country: text("country").notNull().default(""),
|
||||
comment: text("comment").notNull().default(""),
|
||||
disabled: boolean("disabled").notNull().default(false),
|
||||
createdAt: ts("created_at").notNull().defaultNow(),
|
||||
updatedAt: ts("updated_at").notNull().defaultNow(),
|
||||
}, (t) => [
|
||||
index("idx_recursive_routes_server_sort").on(t.serverId, t.sortOrder),
|
||||
])
|
||||
|
||||
export const trafficSettings = pgTable("traffic_settings", {
|
||||
pollConfigurationEpoch: uuid("poll_configuration_epoch").notNull().defaultRandom(),
|
||||
id: idSingleton(),
|
||||
enabled: boolean("enabled").notNull().default(true),
|
||||
intervalSec: integer("interval_sec").notNull().default(30),
|
||||
retentionDays: integer("retention_days").notNull().default(14),
|
||||
lastCollectedAt: ts("last_collected_at"),
|
||||
lastDurationMs: integer("last_duration_ms"),
|
||||
lastError: text("last_error"),
|
||||
createdAt: ts("created_at").notNull().defaultNow(),
|
||||
updatedAt: ts("updated_at").notNull().defaultNow(),
|
||||
})
|
||||
|
||||
export const serversApiPingSettings = pgTable("servers_api_ping_settings", {
|
||||
id: idSingleton(),
|
||||
enabled: boolean("enabled").notNull().default(false),
|
||||
intervalSec: integer("interval_sec").notNull().default(120),
|
||||
lastCollectedAt: ts("last_collected_at"),
|
||||
lastDurationMs: integer("last_duration_ms"),
|
||||
lastError: text("last_error"),
|
||||
createdAt: ts("created_at").notNull().defaultNow(),
|
||||
updatedAt: ts("updated_at").notNull().defaultNow(),
|
||||
})
|
||||
|
||||
export const serversRestPingSamples = pgTable("servers_rest_ping_samples", {
|
||||
serverId: intPkRef().references(() => servers.id, { onDelete: "cascade" }),
|
||||
sampledAt: ts("sampled_at").notNull(),
|
||||
ok: boolean("ok").notNull(),
|
||||
latencyMs: integer("latency_ms"),
|
||||
error: text("error"),
|
||||
}, (t) => [
|
||||
primaryKey({ columns: [t.serverId, t.sampledAt] }),
|
||||
])
|
||||
|
||||
export const trafficFlowSettings = pgTable("traffic_flow_settings", {
|
||||
id: idSingleton(),
|
||||
enabled: boolean("enabled").notNull().default(false),
|
||||
collectorIp: text("collector_ip").notNull().default("10.255.254.1"),
|
||||
flowListenPort: integer("flow_listen_port").notNull().default(4739),
|
||||
wgListenPort: integer("wg_listen_port").notNull().default(51821),
|
||||
prefix: text("prefix").notNull().default("10.255.254.0/24"),
|
||||
publicEndpoint: text("public_endpoint").notNull().default(""),
|
||||
hostPublicKey: text("host_public_key").notNull().default(""),
|
||||
hostPrivateKey: text("host_private_key").notNull().default(""),
|
||||
hubServerId: bigint("hub_server_id", { mode: "number" }),
|
||||
retentionHours: integer("retention_hours").notNull().default(24),
|
||||
topN: integer("top_n").notNull().default(200),
|
||||
mapServiceMinSharePct: doublePrecision("map_service_min_share_pct").notNull().default(5),
|
||||
lastDatagramAt: ts("last_datagram_at"),
|
||||
lastExporterIp: text("last_exporter_ip"),
|
||||
lastError: text("last_error"),
|
||||
packetsReceived: bigint("packets_received", { mode: "number" }).notNull().default(0),
|
||||
peersJson: jsonb("peers_json").notNull().default(sql`'[]'::jsonb`),
|
||||
createdAt: ts("created_at").notNull().defaultNow(),
|
||||
updatedAt: ts("updated_at").notNull().defaultNow(),
|
||||
})
|
||||
|
||||
export const flowMinuteStats = pgTable("flow_minute_stats", {
|
||||
serverId: bigint("server_id", { mode: "number" }).notNull()
|
||||
.references(() => servers.id, { onDelete: "cascade" }),
|
||||
bucketAt: ts("bucket_at").notNull(),
|
||||
bytes: bigint("bytes", { mode: "number" }).notNull().default(0),
|
||||
packets: bigint("packets", { mode: "number" }).notNull().default(0),
|
||||
uniqueSrc: integer("unique_src").notNull().default(0),
|
||||
uniqueDst: integer("unique_dst").notNull().default(0),
|
||||
conversations: integer("conversations").notNull().default(0),
|
||||
}, (t) => [
|
||||
primaryKey({ columns: [t.serverId, t.bucketAt] }),
|
||||
])
|
||||
|
||||
export const flowMinuteDims = pgTable("flow_minute_dims", {
|
||||
serverId: bigint("server_id", { mode: "number" }).notNull()
|
||||
.references(() => servers.id, { onDelete: "cascade" }),
|
||||
bucketAt: ts("bucket_at").notNull(),
|
||||
dim: text("dim").notNull(),
|
||||
key: text("key").notNull(),
|
||||
bytes: bigint("bytes", { mode: "number" }).notNull().default(0),
|
||||
packets: bigint("packets", { mode: "number" }).notNull().default(0),
|
||||
}, (t) => [
|
||||
primaryKey({ columns: [t.serverId, t.bucketAt, t.dim, t.key] }),
|
||||
index("idx_flow_minute_dims_time").on(t.bucketAt, t.dim),
|
||||
])
|
||||
|
||||
export const flowDailyDims = pgTable("flow_daily_dims", {
|
||||
serverId: bigint("server_id", { mode: "number" }).notNull()
|
||||
.references(() => servers.id, { onDelete: "cascade" }),
|
||||
day: date("day", { mode: "string" }).notNull(),
|
||||
dim: text("dim").notNull(),
|
||||
key: text("key").notNull(),
|
||||
bytes: bigint("bytes", { mode: "number" }).notNull().default(0),
|
||||
packets: bigint("packets", { mode: "number" }).notNull().default(0),
|
||||
}, (t) => [
|
||||
primaryKey({ columns: [t.serverId, t.day, t.dim, t.key] }),
|
||||
index("idx_flow_daily_dims_day").on(t.day, t.dim),
|
||||
])
|
||||
|
||||
/** Hour-grain traffic cube for statistics (≤48h). No secondary indexes. */
|
||||
export const flowHourFacts = pgTable("flow_hour_facts", {
|
||||
serverId: bigint("server_id", { mode: "number" }).notNull()
|
||||
.references(() => servers.id, { onDelete: "cascade" }),
|
||||
bucketAt: ts("bucket_at").notNull(),
|
||||
iface: text("iface").notNull(),
|
||||
country: text("country").notNull(),
|
||||
service: text("service").notNull(),
|
||||
asn: integer("asn").notNull(),
|
||||
bytes: bigint("bytes", { mode: "number" }).notNull().default(0),
|
||||
packets: bigint("packets", { mode: "number" }).notNull().default(0),
|
||||
}, (t) => [
|
||||
primaryKey({ columns: [t.serverId, t.bucketAt, t.iface, t.country, t.service, t.asn] }),
|
||||
])
|
||||
|
||||
/** Daily-grain traffic cube for statistics (long window). No secondary indexes. */
|
||||
export const flowDailyFacts = pgTable("flow_daily_facts", {
|
||||
serverId: bigint("server_id", { mode: "number" }).notNull()
|
||||
.references(() => servers.id, { onDelete: "cascade" }),
|
||||
day: date("day", { mode: "string" }).notNull(),
|
||||
iface: text("iface").notNull(),
|
||||
country: text("country").notNull(),
|
||||
service: text("service").notNull(),
|
||||
asn: integer("asn").notNull(),
|
||||
bytes: bigint("bytes", { mode: "number" }).notNull().default(0),
|
||||
packets: bigint("packets", { mode: "number" }).notNull().default(0),
|
||||
}, (t) => [
|
||||
primaryKey({ columns: [t.serverId, t.day, t.iface, t.country, t.service, t.asn] }),
|
||||
])
|
||||
|
||||
export const flowBuckets = pgTable("flow_buckets", {
|
||||
serverId: intPkRef().references(() => servers.id, { onDelete: "cascade" }),
|
||||
bucketAt: ts("bucket_at").notNull(),
|
||||
src: inet("src").notNull(),
|
||||
dst: inet("dst").notNull(),
|
||||
proto: smallint("proto").notNull().default(0),
|
||||
srcPort: integer("src_port").notNull().default(0),
|
||||
dstPort: integer("dst_port").notNull().default(0),
|
||||
bytes: bigint("bytes", { mode: "number" }).notNull().default(0),
|
||||
packets: bigint("packets", { mode: "number" }).notNull().default(0),
|
||||
inIface: text("in_iface").notNull().default(""),
|
||||
outIface: text("out_iface").notNull().default(""),
|
||||
nextHop: inet("next_hop"),
|
||||
flowStartMs: bigint("flow_start_ms", { mode: "number" }).notNull().default(0),
|
||||
flowEndMs: bigint("flow_end_ms", { mode: "number" }).notNull().default(0),
|
||||
natSrc: inet("nat_src"),
|
||||
natDst: inet("nat_dst"),
|
||||
natSrcPort: integer("nat_src_port").notNull().default(0),
|
||||
natDstPort: integer("nat_dst_port").notNull().default(0),
|
||||
}, (t) => [
|
||||
primaryKey({
|
||||
name: "flow_buckets_pkey",
|
||||
columns: [t.serverId, t.bucketAt, t.src, t.dst, t.proto, t.srcPort, t.dstPort, t.inIface],
|
||||
}),
|
||||
index("idx_flow_buckets_server_time").on(t.serverId, t.bucketAt),
|
||||
])
|
||||
|
||||
export const geoipSettings = pgTable("geoip_settings", {
|
||||
id: idSingleton(),
|
||||
enabled: boolean("enabled").notNull().default(true),
|
||||
updateIntervalSec: integer("update_interval_sec").notNull().default(604800),
|
||||
lastCheckAt: ts("last_check_at"),
|
||||
lastSuccessAt: ts("last_success_at"),
|
||||
lastError: text("last_error"),
|
||||
countryBuildAt: ts("country_build_at"),
|
||||
asnBuildAt: ts("asn_build_at"),
|
||||
etagsJson: jsonb("etags_json").notNull().default(sql`'{}'::jsonb`),
|
||||
createdAt: ts("created_at").notNull().defaultNow(),
|
||||
updatedAt: ts("updated_at").notNull().defaultNow(),
|
||||
})
|
||||
|
||||
export const flowIpMeta = pgTable("flow_ip_meta", {
|
||||
prefix: text("prefix").primaryKey(),
|
||||
asn: integer("asn").notNull().default(0),
|
||||
country: text("country").notNull().default(""),
|
||||
lat: doublePrecision("lat"),
|
||||
lng: doublePrecision("lng"),
|
||||
holder: text("holder").notNull().default(""),
|
||||
ok: integer("ok").notNull().default(1),
|
||||
fetchedAt: ts("fetched_at").notNull(),
|
||||
})
|
||||
|
||||
export const flowAsnMeta = pgTable("flow_asn_meta", {
|
||||
asn: integer("asn").primaryKey(),
|
||||
holder: text("holder").notNull().default(""),
|
||||
fetchedAt: ts("fetched_at").notNull(),
|
||||
})
|
||||
|
||||
export const trafficSamples = pgTable("traffic_samples", {
|
||||
nativeCounterIdentity: text("native_counter_identity"),
|
||||
serverId: intPkRef().references(() => servers.id, { onDelete: "cascade" }),
|
||||
interfaceName: text("interface_name").notNull(),
|
||||
peerPublicKey: text("peer_public_key").notNull().default(""),
|
||||
sampledAt: ts("sampled_at").notNull(),
|
||||
rxBytes: bigint("rx_bytes", { mode: "number" }).notNull().default(0),
|
||||
txBytes: bigint("tx_bytes", { mode: "number" }).notNull().default(0),
|
||||
rxBps: bigint("rx_bps", { mode: "number" }).notNull().default(0),
|
||||
txBps: bigint("tx_bps", { mode: "number" }).notNull().default(0),
|
||||
flags: smallint("flags").notNull().default(0),
|
||||
}, (t) => [
|
||||
primaryKey({ columns: [t.serverId, t.sampledAt, t.interfaceName, t.peerPublicKey] }),
|
||||
index("idx_traffic_samples_server_iface_time").on(t.serverId, t.interfaceName, t.sampledAt),
|
||||
])
|
||||
|
||||
export const uptimeSettings = pgTable("uptime_settings", {
|
||||
pollConfigurationEpoch: uuid("poll_configuration_epoch").notNull().defaultRandom(),
|
||||
id: idSingleton(),
|
||||
enabled: boolean("enabled").notNull().default(true),
|
||||
resourcesEnabled: boolean("resources_enabled").notNull().default(true),
|
||||
pingEnabled: boolean("ping_enabled").notNull().default(true),
|
||||
speedEnabled: boolean("speed_enabled").notNull().default(true),
|
||||
intervalSec: integer("interval_sec").notNull().default(15),
|
||||
probeIntervalSec: integer("probe_interval_sec").notNull().default(15),
|
||||
speedIntervalSec: integer("speed_interval_sec").notNull().default(60),
|
||||
retentionDays: integer("retention_days").notNull().default(14),
|
||||
lastCollectedAt: ts("last_collected_at"),
|
||||
lastDurationMs: integer("last_duration_ms"),
|
||||
lastError: text("last_error"),
|
||||
createdAt: ts("created_at").notNull().defaultNow(),
|
||||
updatedAt: ts("updated_at").notNull().defaultNow(),
|
||||
})
|
||||
|
||||
export const trafficCollectionState = pgTable("traffic_collection_state", {
|
||||
serverId: intPkRef().primaryKey().references(() => servers.id, { onDelete: "cascade" }),
|
||||
serverUpdatedAt: ts("server_updated_at").notNull(),
|
||||
lastAttemptAt: ts("last_attempt_at").notNull(),
|
||||
lastSucceededAt: ts("last_succeeded_at"),
|
||||
status: text("status").notNull(),
|
||||
error: text("error"),
|
||||
})
|
||||
|
||||
export const uptimeProbes = pgTable("uptime_probes", {
|
||||
id: text("id").primaryKey(),
|
||||
srcServerId: bigint("src_server_id", { mode: "number" }).notNull()
|
||||
.references(() => servers.id, { onDelete: "cascade" }),
|
||||
srcInterface: text("src_interface").notNull().default(""),
|
||||
name: text("name").notNull(),
|
||||
target: text("target").notNull(),
|
||||
probeFilter: text("probe_filter").notNull().default("—"),
|
||||
enabled: boolean("enabled").notNull().default(true),
|
||||
intervalSec: integer("interval_sec").notNull().default(0),
|
||||
showOnDashboard: boolean("show_on_dashboard").notNull().default(false),
|
||||
sortOrder: integer("sort_order").notNull().default(0),
|
||||
createdAt: ts("created_at").notNull().defaultNow(),
|
||||
updatedAt: ts("updated_at").notNull().defaultNow(),
|
||||
}, (t) => [
|
||||
index("idx_uptime_probes_server_sort").on(t.srcServerId, t.sortOrder),
|
||||
])
|
||||
|
||||
export const uptimeProbeSamples = pgTable("uptime_probe_samples", {
|
||||
probeId: text("probe_id").notNull().references(() => uptimeProbes.id, { onDelete: "cascade" }),
|
||||
sampledAt: ts("sampled_at").notNull(),
|
||||
rttMs: integer("rtt_ms"),
|
||||
lossPct: integer("loss_pct").notNull().default(0),
|
||||
status: text("status", { enum: ["up", "warn", "down"] }).notNull().default("down"),
|
||||
measurementKnown: boolean("measurement_known").notNull().default(true),
|
||||
observationError: text("observation_error"),
|
||||
}, (t) => [
|
||||
primaryKey({ columns: [t.probeId, t.sampledAt] }),
|
||||
])
|
||||
|
||||
export const uptimeResourceSamples = pgTable("uptime_resource_samples", {
|
||||
serverId: intPkRef().references(() => servers.id, { onDelete: "cascade" }),
|
||||
sampledAt: ts("sampled_at").notNull(),
|
||||
status: text("status", { enum: ["online", "offline"] }).notNull().default("offline"),
|
||||
cpuLoad: integer("cpu_load").notNull().default(0),
|
||||
freeMemory: bigint("free_memory", { mode: "number" }).notNull().default(0),
|
||||
totalMemory: bigint("total_memory", { mode: "number" }).notNull().default(0),
|
||||
freeHddSpace: bigint("free_hdd_space", { mode: "number" }).notNull().default(0),
|
||||
totalHddSpace: bigint("total_hdd_space", { mode: "number" }).notNull().default(0),
|
||||
uptimeSeconds: bigint("uptime_seconds", { mode: "number" }).notNull().default(0),
|
||||
}, (t) => [
|
||||
primaryKey({ columns: [t.serverId, t.sampledAt] }),
|
||||
])
|
||||
|
||||
export const uptimeSpeedProbes = pgTable("uptime_speed_probes", {
|
||||
id: text("id").primaryKey(),
|
||||
srcServerId: bigint("src_server_id", { mode: "number" }).notNull()
|
||||
.references(() => servers.id, { onDelete: "cascade" }),
|
||||
dstServerId: bigint("dst_server_id", { mode: "number" }).notNull()
|
||||
.references(() => servers.id, { onDelete: "cascade" }),
|
||||
srcInterface: text("src_interface").notNull().default(""),
|
||||
dstInterface: text("dst_interface").notNull().default(""),
|
||||
protocol: text("protocol", { enum: ["tcp", "udp"] }).notNull().default("tcp"),
|
||||
direction: text("direction", { enum: ["transmit", "receive", "both"] }).notNull().default("both"),
|
||||
durationSec: integer("duration_sec").notNull().default(10),
|
||||
enabled: boolean("enabled").notNull().default(true),
|
||||
lastRunAt: ts("last_run_at"),
|
||||
lastTxAvgMbps: doublePrecision("last_tx_avg_mbps"),
|
||||
lastRxAvgMbps: doublePrecision("last_rx_avg_mbps"),
|
||||
lastStatus: text("last_status", { enum: ["done", "error"] }),
|
||||
lastError: text("last_error"),
|
||||
lastPingRttMs: integer("last_ping_rtt_ms"),
|
||||
lastPingLossPct: integer("last_ping_loss_pct"),
|
||||
lastPingAt: ts("last_ping_at"),
|
||||
lastPingError: text("last_ping_error"),
|
||||
sortOrder: integer("sort_order").notNull().default(0),
|
||||
createdAt: ts("created_at").notNull().defaultNow(),
|
||||
updatedAt: ts("updated_at").notNull().defaultNow(),
|
||||
}, (t) => [
|
||||
index("idx_uptime_speed_probes_src_sort").on(t.srcServerId, t.sortOrder),
|
||||
])
|
||||
|
||||
export const evobgpSettings = pgTable("evobgp_settings", {
|
||||
id: idSingleton(),
|
||||
baseUrl: text("base_url").notNull().default(""),
|
||||
apiKey: text("api_key").notNull().default(""),
|
||||
enabled: boolean("enabled").notNull().default(false),
|
||||
updatedAt: ts("updated_at").notNull().defaultNow(),
|
||||
})
|
||||
|
||||
export const alertTelegramSettings = pgTable("alert_telegram_settings", {
|
||||
id: idSingleton(),
|
||||
botToken: text("bot_token").notNull().default(""),
|
||||
chatId: text("chat_id").notNull().default(""),
|
||||
messageThreadId: integer("message_thread_id"),
|
||||
updatedAt: ts("updated_at").notNull().defaultNow(),
|
||||
})
|
||||
|
||||
export const acmeSettings = pgTable("acme_settings", {
|
||||
id: idSingleton(),
|
||||
directoryUrl: text("directory_url").notNull().default("https://acme-v02.api.letsencrypt.org/directory"),
|
||||
cloudflareApiToken: text("cloudflare_api_token").notNull().default(""),
|
||||
defaultZoneId: text("default_zone_id").notNull().default(""),
|
||||
accountPrivateKey: text("account_private_key").notNull().default(""),
|
||||
updatedAt: ts("updated_at").notNull().defaultNow(),
|
||||
})
|
||||
|
||||
export const certificateIssueJobs = pgTable("certificate_issue_jobs", {
|
||||
id: text("id").primaryKey(),
|
||||
status: text("status", { enum: ["queued", "running", "done", "failed"] }).notNull().default("queued"),
|
||||
step: text("step").notNull().default("queued"),
|
||||
source: text("source", { enum: ["manual", "scheduler"] }).notNull().default("manual"),
|
||||
serverId: bigint("server_id", { mode: "number" }).notNull()
|
||||
.references(() => servers.id, { onDelete: "cascade" }),
|
||||
certName: text("cert_name").notNull(),
|
||||
domainNames: jsonb("domain_names").notNull(),
|
||||
keyType: text("key_type").notNull().default("rsa2048"),
|
||||
trustStore: text("trust_store").notNull().default("www,api"),
|
||||
requestedAt: ts("requested_at").notNull().defaultNow(),
|
||||
startedAt: ts("started_at"),
|
||||
finishedAt: ts("finished_at"),
|
||||
error: text("error"),
|
||||
})
|
||||
|
||||
export const certificateRenewSettings = pgTable("certificate_renew_settings", {
|
||||
id: idSingleton(),
|
||||
enabled: boolean("enabled").notNull().default(true),
|
||||
intervalSec: integer("interval_sec").notNull().default(21600),
|
||||
renewBeforeDays: integer("renew_before_days").notNull().default(30),
|
||||
lastCollectedAt: ts("last_collected_at"),
|
||||
lastDurationMs: integer("last_duration_ms"),
|
||||
lastError: text("last_error"),
|
||||
updatedAt: ts("updated_at").notNull().defaultNow(),
|
||||
})
|
||||
|
||||
export const backupScheduleSettings = pgTable("backup_schedule_settings", {
|
||||
id: idSingleton(),
|
||||
enabled: boolean("enabled").notNull().default(true),
|
||||
frequency: text("frequency", { enum: ["daily", "weekly", "monthly"] }).notNull().default("daily"),
|
||||
hour: integer("hour").notNull().default(3),
|
||||
minute: integer("minute").notNull().default(0),
|
||||
weekDay: integer("week_day").notNull().default(0),
|
||||
monthDay: integer("month_day").notNull().default(1),
|
||||
keepCount: integer("keep_count").notNull().default(7),
|
||||
format: text("format", { enum: ["rsc", "backup"] }).notNull().default("rsc"),
|
||||
serverIdsJson: jsonb("server_ids_json").notNull().default(sql`'[]'::jsonb`),
|
||||
lastRunAt: ts("last_run_at"),
|
||||
lastDurationMs: integer("last_duration_ms"),
|
||||
lastError: text("last_error"),
|
||||
updatedAt: ts("updated_at").notNull().defaultNow(),
|
||||
})
|
||||
|
||||
export const backupStorageSettings = pgTable("backup_storage_settings", {
|
||||
id: idSingleton(),
|
||||
provider: text("provider", { enum: ["local", "s3"] }).notNull().default("local"),
|
||||
s3Endpoint: text("s3_endpoint").notNull().default(""),
|
||||
s3Region: text("s3_region").notNull().default("us-east-1"),
|
||||
s3Bucket: text("s3_bucket").notNull().default(""),
|
||||
s3Prefix: text("s3_prefix").notNull().default("mikrotik"),
|
||||
s3AccessKeyId: text("s3_access_key_id").notNull().default(""),
|
||||
s3SecretAccessKey: text("s3_secret_access_key").notNull().default(""),
|
||||
s3ForcePathStyle: boolean("s3_force_path_style").notNull().default(true),
|
||||
keepLocalCopy: boolean("keep_local_copy").notNull().default(true),
|
||||
lastTestAt: ts("last_test_at"),
|
||||
lastTestError: text("last_test_error"),
|
||||
updatedAt: ts("updated_at").notNull().defaultNow(),
|
||||
})
|
||||
|
||||
export const backupEntries = pgTable("backup_entries", {
|
||||
id: text("id").primaryKey(),
|
||||
serverId: bigint("server_id", { mode: "number" })
|
||||
.references(() => servers.id, { onDelete: "set null" }),
|
||||
serverName: text("server_name").notNull(),
|
||||
filename: text("filename").notNull(),
|
||||
sizeBytes: bigint("size_bytes", { mode: "number" }).notNull(),
|
||||
kind: text("kind", { enum: ["manual", "auto"] }).notNull().default("manual"),
|
||||
notes: text("notes"),
|
||||
storage: text("storage", { enum: ["local", "s3", "both"] }).notNull().default("local"),
|
||||
s3Key: text("s3_key"),
|
||||
s3Etag: text("s3_etag"),
|
||||
uploadError: text("upload_error"),
|
||||
createdAt: ts("created_at").notNull(),
|
||||
}, (t) => [
|
||||
uniqueIndex("idx_backup_entries_filename").on(t.filename),
|
||||
index("idx_backup_entries_server_created").on(t.serverId, t.createdAt),
|
||||
])
|
||||
|
||||
export const alertGroups = pgTable("alert_groups", {
|
||||
id: text("id").primaryKey(),
|
||||
name: text("name").notNull(),
|
||||
combineMode: text("combine_mode", { enum: ["any", "all"] }).notNull().default("any"),
|
||||
enabled: boolean("enabled").notNull().default(true),
|
||||
cooldownOverride: text("cooldown_override", {
|
||||
enum: ["1м", "5м", "15м", "1ч", "4ч", "24ч"],
|
||||
}),
|
||||
createdAt: ts("created_at").notNull().defaultNow(),
|
||||
updatedAt: ts("updated_at").notNull().defaultNow(),
|
||||
})
|
||||
|
||||
export const alertRules = pgTable("alert_rules", {
|
||||
id: text("id").primaryKey(),
|
||||
name: text("name").notNull(),
|
||||
type: text("type", {
|
||||
enum: ["gre-tunnel", "bgp-peer", "bgp-prefix", "gre-client", "server", "rtt", "loss", "traffic"],
|
||||
}).notNull(),
|
||||
target: text("target").notNull(),
|
||||
condition: text("condition").notNull(),
|
||||
severity: text("severity", { enum: ["critical", "warning", "info"] }).notNull(),
|
||||
enabled: boolean("enabled").notNull().default(true),
|
||||
cooldown: text("cooldown", {
|
||||
enum: ["1м", "5м", "15м", "1ч", "4ч", "24ч"],
|
||||
}).notNull().default("5м"),
|
||||
chatId: text("rule_chat_id").notNull().default(""),
|
||||
confirmStabilitySec: integer("confirm_stability_sec"),
|
||||
recoveryMode: text("recovery_mode", {
|
||||
enum: ["always", "never", "conditional"],
|
||||
}).notNull().default("always"),
|
||||
recoveryStabilitySec: integer("recovery_stability_sec"),
|
||||
groupId: text("group_id").references(() => alertGroups.id, { onDelete: "set null" }),
|
||||
createdAt: ts("created_at").notNull().defaultNow(),
|
||||
updatedAt: ts("updated_at").notNull().defaultNow(),
|
||||
})
|
||||
|
||||
export const alertRuleTargets = pgTable("alert_rule_targets", {
|
||||
id: text("id").primaryKey(),
|
||||
ruleId: text("rule_id").notNull().references(() => alertRules.id, { onDelete: "cascade" }),
|
||||
target: text("target").notNull(),
|
||||
sortIndex: integer("sort_index").notNull().default(0),
|
||||
}, (t) => [
|
||||
index("idx_alert_rule_targets_rule").on(t.ruleId),
|
||||
])
|
||||
|
||||
export const alertRuleConditions = pgTable("alert_rule_conditions", {
|
||||
id: text("id").primaryKey(),
|
||||
ruleId: text("rule_id").notNull().references(() => alertRules.id, { onDelete: "cascade" }),
|
||||
conditionLine: text("condition_line").notNull(),
|
||||
sortIndex: integer("sort_index").notNull().default(0),
|
||||
}, (t) => [
|
||||
index("idx_alert_rule_conditions_rule").on(t.ruleId),
|
||||
])
|
||||
|
||||
export const alertEngineState = pgTable("alert_engine_state", {
|
||||
scopeKey: text("scope_key").primaryKey(),
|
||||
lastFiredAt: text("last_fired_at").notNull().default(""),
|
||||
lastPayloadHash: text("last_payload_hash"),
|
||||
})
|
||||
|
||||
export const alertEnginePrevLive = pgTable("alert_engine_prev_live", {
|
||||
kind: text("kind", { enum: ["gre", "bgp"] }).primaryKey(),
|
||||
payloadJson: jsonb("payload_json").notNull().default(sql`'{}'::jsonb`),
|
||||
updatedAt: ts("updated_at").notNull().defaultNow(),
|
||||
})
|
||||
|
||||
export const alertEngineConfirmPending = pgTable("alert_engine_confirm_pending", {
|
||||
ruleId: text("rule_id").primaryKey(),
|
||||
payloadHash: text("payload_hash").notNull(),
|
||||
sinceAt: ts("since_at").notNull(),
|
||||
})
|
||||
|
||||
export const alertHistory = pgTable("alert_history", {
|
||||
id: text("id").primaryKey(),
|
||||
ruleId: text("rule_id"),
|
||||
groupId: text("group_id"),
|
||||
ruleName: text("rule_name").notNull(),
|
||||
severity: text("severity", { enum: ["critical", "warning", "info"] }).notNull(),
|
||||
message: text("message").notNull(),
|
||||
sentOk: boolean("sent_ok").notNull().default(true),
|
||||
firedAt: ts("fired_at").notNull(),
|
||||
}, (t) => [
|
||||
index("idx_alert_history_fired_at").on(t.firedAt),
|
||||
index("idx_alert_history_rule_id").on(t.ruleId),
|
||||
])
|
||||
|
||||
export const alertOutbox = pgTable("alert_outbox", {
|
||||
id: text("id").primaryKey(),
|
||||
dedupeKey: text("dedupe_key").notNull(),
|
||||
channel: text("channel", { enum: ["telegram"] }).notNull().default("telegram"),
|
||||
status: text("status", { enum: ["pending", "processing", "unknown", "sent", "failed"] }).notNull().default("pending"),
|
||||
resolutionVersion: integer("resolution_version").notNull().default(0),
|
||||
retryCount: integer("retry_count").notNull().default(0),
|
||||
maxRetries: integer("max_retries").notNull().default(3),
|
||||
nextAttemptAt: ts("next_attempt_at").notNull(),
|
||||
payloadJson: jsonb("payload_json").notNull(),
|
||||
createdAt: ts("created_at").notNull().defaultNow(),
|
||||
sentAt: ts("sent_at"),
|
||||
lastError: text("last_error"),
|
||||
}, (t) => [
|
||||
uniqueIndex("idx_alert_outbox_dedupe").on(t.dedupeKey),
|
||||
index("idx_alert_outbox_status_next_attempt").on(t.status, t.nextAttemptAt),
|
||||
])
|
||||
|
||||
export const alertEngineCursor = pgTable("alert_engine_cursor", {
|
||||
id: idSingleton(),
|
||||
lastSourceFinishedAt: ts("last_source_finished_at"),
|
||||
updatedAt: ts("updated_at").notNull().defaultNow(),
|
||||
})
|
||||
|
||||
export const schedulerRuns = pgTable("scheduler_runs", {
|
||||
id: text("id").primaryKey(),
|
||||
jobKey: text("job_key").notNull(),
|
||||
startedAt: ts("started_at").notNull(),
|
||||
finishedAt: ts("finished_at").notNull(),
|
||||
status: text("status", { enum: ["ok", "error"] }).notNull(),
|
||||
error: text("error"),
|
||||
durationMs: integer("duration_ms").notNull().default(0),
|
||||
resultJson: jsonb("result_json"),
|
||||
}, (t) => [
|
||||
index("idx_scheduler_runs_job_time").on(t.jobKey, t.startedAt),
|
||||
index("idx_scheduler_runs_finished_at").on(t.finishedAt),
|
||||
])
|
||||
|
||||
export const events = pgTable("events", {
|
||||
id: text("id").primaryKey(),
|
||||
createdAt: ts("created_at").notNull(),
|
||||
level: text("level", { enum: ["critical", "warning", "info"] }).notNull(),
|
||||
eventType: text("event_type").notNull(),
|
||||
sourceModule: text("source_module").notNull(),
|
||||
title: text("title").notNull(),
|
||||
message: text("message").notNull(),
|
||||
entityType: text("entity_type"),
|
||||
entityId: text("entity_id"),
|
||||
payloadJson: jsonb("payload_json"),
|
||||
}, (t) => [
|
||||
index("idx_events_created_at").on(t.createdAt),
|
||||
index("idx_events_level_created_at").on(t.level, t.createdAt),
|
||||
index("idx_events_source_created_at").on(t.sourceModule, t.createdAt),
|
||||
index("idx_events_event_type_created_at").on(t.eventType, t.createdAt),
|
||||
])
|
||||
|
||||
export const uptimeSpeedTestRuns = pgTable("uptime_speed_test_runs", {
|
||||
id: text("id").primaryKey(),
|
||||
probeId: text("probe_id"),
|
||||
srcServerId: bigint("src_server_id", { mode: "number" }).notNull()
|
||||
.references(() => servers.id, { onDelete: "cascade" }),
|
||||
dstServerId: bigint("dst_server_id", { mode: "number" }).notNull()
|
||||
.references(() => servers.id, { onDelete: "cascade" }),
|
||||
srcInterface: text("src_interface").notNull().default(""),
|
||||
dstInterface: text("dst_interface").notNull().default(""),
|
||||
srcAddress: text("src_address"),
|
||||
dstAddress: text("dst_address"),
|
||||
srcInterfaceAddress:text("src_interface_address"),
|
||||
dstInterfaceAddress:text("dst_interface_address"),
|
||||
protocol: text("protocol", { enum: ["tcp", "udp"] }).notNull().default("tcp"),
|
||||
direction: text("direction", { enum: ["transmit", "receive", "both"] }).notNull().default("both"),
|
||||
durationSec: integer("duration_sec").notNull().default(10),
|
||||
txAvgMbps: doublePrecision("tx_avg_mbps"),
|
||||
rxAvgMbps: doublePrecision("rx_avg_mbps"),
|
||||
pingRttMs: integer("ping_rtt_ms"),
|
||||
pingLossPct: integer("ping_loss_pct"),
|
||||
pingError: text("ping_error"),
|
||||
status: text("status", { enum: ["done", "error"] }).notNull().default("done"),
|
||||
error: text("error"),
|
||||
createdAt: ts("created_at").notNull().defaultNow(),
|
||||
}, (t) => [
|
||||
index("idx_uptime_speed_test_runs_created_at").on(t.createdAt),
|
||||
])
|
||||
|
||||
export const internetPathSettings = pgTable("internet_path_settings", {
|
||||
pollConfigurationEpoch: uuid("poll_configuration_epoch").notNull().defaultRandom(),
|
||||
id: idSingleton(),
|
||||
enabled: boolean("enabled").notNull().default(true),
|
||||
intervalSec: integer("interval_sec").notNull().default(300),
|
||||
retentionDays: integer("retention_days").notNull().default(14),
|
||||
lastCollectedAt: ts("last_collected_at"),
|
||||
lastDurationMs: integer("last_duration_ms"),
|
||||
lastError: text("last_error"),
|
||||
createdAt: ts("created_at").notNull().defaultNow(),
|
||||
updatedAt: ts("updated_at").notNull().defaultNow(),
|
||||
})
|
||||
|
||||
export const appUsers = pgTable("app_users", {
|
||||
id: text("id").primaryKey(),
|
||||
name: text("name").notNull().default(""),
|
||||
login: text("login").notNull().unique(),
|
||||
email: text("email").notNull().default(""),
|
||||
role: text("role", { enum: ["admin", "operator", "viewer"] }).notNull().default("viewer"),
|
||||
active: boolean("active").notNull().default(true),
|
||||
avatar: text("avatar").notNull().default(""),
|
||||
lastSeen: ts("last_seen"),
|
||||
sectionsJson: jsonb("sections_json").notNull().default(sql`'[]'::jsonb`),
|
||||
serversJson: jsonb("servers_json").notNull().default(sql`'[]'::jsonb`),
|
||||
createdAt: ts("created_at").notNull().defaultNow(),
|
||||
updatedAt: ts("updated_at").notNull().defaultNow(),
|
||||
})
|
||||
|
||||
export const userInterfaceBindings = pgTable("user_interface_bindings", {
|
||||
id: text("id").primaryKey(),
|
||||
userId: text("user_id").notNull().references(() => appUsers.id, { onDelete: "cascade" }),
|
||||
serverId: intPkRef().references(() => servers.id, { onDelete: "cascade" }),
|
||||
interfaceName: text("interface_name").notNull(),
|
||||
interfaceType: text("interface_type", { enum: ["ether", "bridge", "vlan", "vxlan", "gre", "wg", "ipsec", "other"] })
|
||||
.notNull().default("other"),
|
||||
peerPublicKey: text("peer_public_key").notNull().default(""),
|
||||
peerName: text("peer_name").notNull().default(""),
|
||||
comment: text("comment").notNull().default(""),
|
||||
createdAt: ts("created_at").notNull().defaultNow(),
|
||||
updatedAt: ts("updated_at").notNull().defaultNow(),
|
||||
}, (t) => [
|
||||
uniqueIndex("idx_user_iface_bind_server_name_peer").on(t.serverId, t.interfaceName, t.peerPublicKey),
|
||||
index("idx_user_iface_bind_user").on(t.userId),
|
||||
])
|
||||
|
||||
export const internetPathSnapshots = pgTable("internet_path_snapshots", {
|
||||
id: idIdentity(),
|
||||
sampledAt: ts("sampled_at").notNull(),
|
||||
payloadJson: jsonb("payload_json").notNull(),
|
||||
}, (t) => [
|
||||
primaryKey({ columns: [t.id, t.sampledAt] }),
|
||||
index("idx_internet_path_snapshots_sampled").on(t.sampledAt),
|
||||
])
|
||||
|
||||
export const dataMigration = pgTable("data_migration", {
|
||||
id: integer("id").primaryKey(),
|
||||
sqliteImportedAt: ts("sqlite_imported_at"),
|
||||
sqlitePath: text("sqlite_path"),
|
||||
sqliteSha256: text("sqlite_sha256"),
|
||||
reportJson: jsonb("report_json"),
|
||||
})
|
||||
|
||||
export type Server = typeof servers.$inferSelect
|
||||
export type ServerInsert = typeof servers.$inferInsert
|
||||
export type Snapshot = typeof serverSnapshots.$inferSelect
|
||||
export type SnapshotInsert = typeof serverSnapshots.$inferInsert
|
||||
export type FilterRuleRow = typeof filterRules.$inferSelect
|
||||
export type ConfigRevisionRow = typeof configRevisions.$inferSelect
|
||||
export type RecursiveRouteRow = typeof recursiveRoutes.$inferSelect
|
||||
export type TrafficSettingsRow = typeof trafficSettings.$inferSelect
|
||||
export type TrafficFlowSettingsRow = typeof trafficFlowSettings.$inferSelect
|
||||
export type FlowBucketRow = typeof flowBuckets.$inferSelect
|
||||
export type FlowIpMetaRow = typeof flowIpMeta.$inferSelect
|
||||
export type FlowAsnMetaRow = typeof flowAsnMeta.$inferSelect
|
||||
export type ServersApiPingSettingsRow = typeof serversApiPingSettings.$inferSelect
|
||||
export type TrafficSampleRow = typeof trafficSamples.$inferSelect
|
||||
export type UptimeSettingsRow = typeof uptimeSettings.$inferSelect
|
||||
export type UptimeProbeRow = typeof uptimeProbes.$inferSelect
|
||||
export type UptimeProbeSampleRow = typeof uptimeProbeSamples.$inferSelect
|
||||
export type UptimeResourceSampleRow = typeof uptimeResourceSamples.$inferSelect
|
||||
export type UptimeSpeedProbeRow = typeof uptimeSpeedProbes.$inferSelect
|
||||
export type UptimeSpeedTestRunRow = typeof uptimeSpeedTestRuns.$inferSelect
|
||||
export type InternetPathSettingsRow = typeof internetPathSettings.$inferSelect
|
||||
export type InternetPathSnapshotRow = typeof internetPathSnapshots.$inferSelect
|
||||
export type SchedulerRunRow = typeof schedulerRuns.$inferSelect
|
||||
export type EventRow = typeof events.$inferSelect
|
||||
export type EvobgpSettingsRow = typeof evobgpSettings.$inferSelect
|
||||
export type AlertTelegramSettingsRow = typeof alertTelegramSettings.$inferSelect
|
||||
export type AcmeSettingsRow = typeof acmeSettings.$inferSelect
|
||||
export type CertificateIssueJobRow = typeof certificateIssueJobs.$inferSelect
|
||||
export type CertificateRenewSettingsRow = typeof certificateRenewSettings.$inferSelect
|
||||
export type BackupScheduleSettingsRow = typeof backupScheduleSettings.$inferSelect
|
||||
export type BackupEntryRow = typeof backupEntries.$inferSelect
|
||||
export type AlertGroupRow = typeof alertGroups.$inferSelect
|
||||
export type AlertRuleRow = typeof alertRules.$inferSelect
|
||||
export type AlertRuleTargetRow = typeof alertRuleTargets.$inferSelect
|
||||
export type AlertEngineStateRow = typeof alertEngineState.$inferSelect
|
||||
export type AlertHistoryRow = typeof alertHistory.$inferSelect
|
||||
export type AlertOutboxRow = typeof alertOutbox.$inferSelect
|
||||
export type AlertEngineCursorRow = typeof alertEngineCursor.$inferSelect
|
||||
export const flowWriteReceipts = pgTable("flow_write_receipts", {
|
||||
batchId: uuid("batch_id").primaryKey(),
|
||||
kind: text("kind", { enum: ["ingest", "facts", "rebuild"] }).notNull(),
|
||||
sourceProof: text("source_proof").notNull(),
|
||||
result: jsonb("result").$type<Record<string, unknown>>().notNull(),
|
||||
committedAt: ts("committed_at").notNull().defaultNow(),
|
||||
})
|
||||
|
||||
export const flowSettingsIntents = pgTable("flow_settings_intents", {
|
||||
receiptId: uuid("receipt_id").notNull(),
|
||||
phase: text("phase", { enum: ["configuration", "listener"] }).notNull(),
|
||||
requestProof: text("request_proof").notNull(),
|
||||
state: text("state", { enum: ["claimed", "completed"] }).notNull().default("claimed"),
|
||||
result: jsonb("result"),
|
||||
createdAt: ts("created_at").notNull().defaultNow(),
|
||||
}, table => [primaryKey({ columns: [table.receiptId, table.phase] })])
|
||||
|
||||
export type AppUserRow = typeof appUsers.$inferSelect
|
||||
export type UserInterfaceBindingRow = typeof userInterfaceBindings.$inferSelect
|
||||
|
||||
// mcp_plans/mcp_connections live in SQL migration 0015; migration 0034
|
||||
// supplies their foreign keys. This receipt never authorizes native execution.
|
||||
export const vrfProductionTickets = pgTable("vrf_production_tickets", {
|
||||
ticketId: uuid("ticket_id").primaryKey(),
|
||||
planId: uuid("plan_id").notNull().unique(),
|
||||
connectionId: uuid("connection_id").notNull(),
|
||||
serverId: bigint("server_id", { mode: "number" }).notNull().references(() => servers.id),
|
||||
ownerId: text("owner_id").notNull(),
|
||||
actionId: text("action_id").notNull(),
|
||||
actionVersion: integer("action_version").notNull(),
|
||||
sourceProof: text("source_proof").notNull(),
|
||||
digest: text("digest").notNull(),
|
||||
ciphertext: text("ciphertext").notNull(),
|
||||
state: text("state", { enum: ["issued", "claimed"] }).notNull().default("issued"),
|
||||
issuedAt: ts("issued_at").notNull(),
|
||||
expiresAt: ts("expires_at").notNull(),
|
||||
claimedAt: ts("claimed_at"),
|
||||
})
|
||||
|
||||
// Historical charge identities deliberately have no lifetime FK/cascade.
|
||||
// Authority and encrypted proof are checked by routing-reservation-store.
|
||||
export const mcpRoutingForwardCharges = pgTable("mcp_routing_forward_charges", {
|
||||
sourceOperationId: uuid("source_operation_id").primaryKey(),
|
||||
sourcePlanId: uuid("source_plan_id").notNull(),
|
||||
sourceStepIndex: integer("source_step_index").notNull(),
|
||||
profileId: uuid("profile_id").notNull(),
|
||||
connectionId: uuid("connection_id").notNull(),
|
||||
chargeUnits: integer("charge_units").notNull().default(1),
|
||||
bindingSealed: text("binding_sealed").notNull(),
|
||||
bindingProof: text("binding_proof").notNull(),
|
||||
createdAt: ts("created_at").notNull(),
|
||||
}, table => [
|
||||
index("mcp_routing_forward_charges_window").on(table.profileId, table.createdAt),
|
||||
index("mcp_routing_forward_charges_retention").on(table.createdAt, table.sourceOperationId),
|
||||
check("mcp_routing_forward_charges_source_step_index_check", sql`${table.sourceStepIndex}=0`),
|
||||
check("mcp_routing_forward_charges_charge_units_check", sql`${table.chargeUnits}=1`),
|
||||
check("mcp_routing_forward_charges_binding_sealed_check", sql`octet_length(${table.bindingSealed}) BETWEEN 1 AND 262144`),
|
||||
check("mcp_routing_forward_charges_binding_proof_check", sql`${table.bindingProof} ~ '^[a-f0-9]{64}$'`),
|
||||
])
|
||||
|
||||
// Immutable logical manual inverse identities intentionally outlive family FKs.
|
||||
export const mcpRoutingManualInverseCharges = pgTable("mcp_routing_manual_inverse_charges", {
|
||||
recoveryOperationId: uuid("recovery_operation_id").primaryKey(),
|
||||
recoveryPlanId: uuid("recovery_plan_id").notNull(),
|
||||
sourceOperationId: uuid("source_operation_id").notNull(),
|
||||
sourcePlanId: uuid("source_plan_id").notNull(),
|
||||
sourceStepIndex: integer("source_step_index").notNull(),
|
||||
profileId: uuid("profile_id").notNull(),
|
||||
profileVersion: integer("profile_version").notNull(),
|
||||
connectionId: uuid("connection_id").notNull(),
|
||||
ownerId: text("owner_id").notNull(),
|
||||
chargeUnits: integer("charge_units").notNull(),
|
||||
bindingSealed: text("binding_sealed").notNull(),
|
||||
bindingProof: text("binding_proof").notNull(),
|
||||
createdAt: ts("created_at").notNull(),
|
||||
}, table => [
|
||||
unique("mcp_manual_inverse_source_step_unique").on(table.sourceOperationId,table.sourceStepIndex),
|
||||
index("mcp_routing_manual_inverse_charges_window").on(table.profileId,table.createdAt),
|
||||
index("mcp_routing_manual_inverse_charges_retention").on(table.createdAt,table.recoveryOperationId),
|
||||
check("mcp_routing_manual_inverse_charges_source_step_index_check",sql`${table.sourceStepIndex}=0`),
|
||||
check("mcp_routing_manual_inverse_charges_profile_version_check",sql`${table.profileVersion}>0`),
|
||||
check("mcp_routing_manual_inverse_charges_owner_id_check",sql`octet_length(${table.ownerId}) BETWEEN 1 AND 1000`),
|
||||
check("mcp_routing_manual_inverse_charges_charge_units_check",sql`${table.chargeUnits}=1`),
|
||||
check("mcp_routing_manual_inverse_charges_binding_sealed_check",sql`octet_length(${table.bindingSealed}) BETWEEN 1 AND 262144`),
|
||||
check("mcp_routing_manual_inverse_charges_binding_proof_check",sql`${table.bindingProof} ~ '^[a-f0-9]{64}$'`),
|
||||
])
|
||||
@@ -0,0 +1,140 @@
|
||||
/** Actual 41→current upgrade (including42 epochs) and pg_dump/pg_restore in two databases owned by the
|
||||
* existing disposable-cluster runner. No runtime activation or external hosts. */
|
||||
import assert from "node:assert/strict"
|
||||
import { randomUUID } from "node:crypto"
|
||||
import { readFile, mkdir, unlink } from "node:fs/promises"
|
||||
import { execFile } from "node:child_process"
|
||||
import { promisify } from "node:util"
|
||||
import { basename, dirname, resolve, join } from "node:path"
|
||||
import { Pool } from "pg"
|
||||
import { MIGRATION_MANIFEST, analyzeMigration } from "./migration-manifest.js"
|
||||
import { applySqlMigrations } from "./migrate.js"
|
||||
import { captureMigrationData, compareMigrationData } from "./migration-data-inventory.js"
|
||||
import { compareMigrationSchemas } from "./migration-schema-inventory.js"
|
||||
import { acceptServerPollSettingsEpochSql } from "../services/server-poll-settings-epoch.sql-helper.js"
|
||||
import { assertServerPollSettingsEpochCatalog, SERVER_POLL_SETTINGS_EPOCH_INVENTORY_SQL, SERVER_POLL_SETTINGS_TABLES } from "../services/server-poll-settings-epoch.js"
|
||||
|
||||
export async function acceptServerPollSettingsEpochMigrationSql(admin: Pool) {
|
||||
assert.equal(process.env.MMAPP_OWN_SERVER_POLL_SETTINGS_EPOCH, "true")
|
||||
assert.equal(process.env.MMAPP_OWN_SERVER_POLL_STORAGE_FRESH, "true")
|
||||
assert.equal(process.env.NODE_ENV, "test")
|
||||
const url = new URL(process.env.DATABASE_URL ?? "")
|
||||
assert.equal(url.hostname, "127.0.0.1"); assert.equal(url.pathname, "/postgres")
|
||||
assert.equal(url.username, "mcp_test"); assert(url.port && url.port !== "5432")
|
||||
const state = resolve(process.env.MCP_STATE_DIR ?? "")
|
||||
assert(basename(dirname(state)).startsWith("mm-mcp-test-"))
|
||||
const names: string[] = [], pools: Pool[] = [], groups: string[] = []
|
||||
const archive = join(state, "owned-settings-epoch-" + randomUUID() + ".dump")
|
||||
const execute = promisify(execFile)
|
||||
const create = async () => {
|
||||
const name = "mm_poll_epoch_" + randomUUID().replaceAll("-", "")
|
||||
assert(/^mm_poll_epoch_[a-f0-9]{32}$/.test(name))
|
||||
await admin.query(`CREATE DATABASE "${name}" TEMPLATE template0`); names.push(name)
|
||||
const target = new URL(url); target.pathname = "/" + name
|
||||
const pool = new Pool({ connectionString: target.toString(), max: 2 }); pools.push(pool)
|
||||
return { name, pool }
|
||||
}
|
||||
const snapshot = async (pool: Pool) => {
|
||||
const client = await pool.connect()
|
||||
try {
|
||||
await client.query("BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY")
|
||||
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'", "SET LOCAL row_security=off",
|
||||
"SET LOCAL extra_float_digits=3", "SET LOCAL bytea_output=hex", "SET LOCAL IntervalStyle=postgres",
|
||||
"SET LOCAL lc_monetary='C'", "SET LOCAL lc_numeric='C'"]) await client.query(sql)
|
||||
return await captureMigrationData(client)
|
||||
} finally { try { await client.query("ROLLBACK") } finally { client.release(true) } }
|
||||
}
|
||||
try {
|
||||
assert.equal(MIGRATION_MANIFEST[41]?.file, "0042_server_poll_settings_epoch.sql")
|
||||
const source = await create(), restored = await create()
|
||||
const owner = await source.pool.connect()
|
||||
try {
|
||||
await owner.query("CREATE TABLE schema_migrations(id text PRIMARY KEY,applied_at timestamptz NOT NULL DEFAULT now())")
|
||||
for (const pin of MIGRATION_MANIFEST.slice(0, 41)) {
|
||||
const bytes = await readFile(new URL("../../drizzle/" + pin.file, import.meta.url))
|
||||
analyzeMigration(pin.file, bytes, pin)
|
||||
if (pin.transaction === "owner-transaction") await owner.query("BEGIN")
|
||||
await owner.query(bytes.toString("utf8"))
|
||||
if (!pin.selfMarker) await owner.query("INSERT INTO schema_migrations(id) VALUES($1)", [pin.file.slice(0, -4)])
|
||||
if (pin.transaction === "owner-transaction") await owner.query("COMMIT")
|
||||
}
|
||||
} finally { owner.release(true) }
|
||||
assert.equal((await source.pool.query("SELECT count(*)::int AS n FROM schema_migrations")).rows[0].n, 41)
|
||||
for (const table of SERVER_POLL_SETTINGS_TABLES) await source.pool.query(`INSERT INTO public.${table}
|
||||
(id,enabled,interval_sec,retention_days,last_collected_at,last_duration_ms,last_error,created_at,updated_at)
|
||||
VALUES(1,false,47,19,'2026-10-30 01:02:03.123456+00',29,'synthetic before upgrade',
|
||||
'2026-10-29 01:02:03.123456+00','2026-10-30 01:02:03.654321+00')
|
||||
ON CONFLICT(id) DO UPDATE SET enabled=excluded.enabled,interval_sec=excluded.interval_sec,
|
||||
retention_days=excluded.retention_days,last_collected_at=excluded.last_collected_at,
|
||||
last_duration_ms=excluded.last_duration_ms,last_error=excluded.last_error,
|
||||
created_at=excluded.created_at,updated_at=excluded.updated_at`)
|
||||
const rows = async (pool: Pool) => {
|
||||
const values = []
|
||||
for (const table of SERVER_POLL_SETTINGS_TABLES) values.push((await pool.query(`SELECT to_jsonb(t) AS value FROM public.${table} t WHERE id=1`)).rows[0].value)
|
||||
return values
|
||||
}
|
||||
const oldRows = await rows(source.pool)
|
||||
// A partially matching unmarked proposal must never leave the first ALTER
|
||||
// committed while a later duplicate column refuses the migration.
|
||||
await source.pool.query("ALTER TABLE public.uptime_settings ADD COLUMN poll_configuration_epoch uuid")
|
||||
const collisionRows = await rows(source.pool)
|
||||
await assert.rejects(applySqlMigrations(source.pool))
|
||||
assert.deepEqual(await rows(source.pool), collisionRows)
|
||||
assert.equal((await source.pool.query("SELECT count(*)::int AS n FROM schema_migrations WHERE id='0042_server_poll_settings_epoch'")).rows[0].n, 0)
|
||||
assert.equal((await source.pool.query("SELECT to_regprocedure('public.server_poll_settings_epoch_guard()') IS NULL AS absent")).rows[0].absent, true)
|
||||
assert.deepEqual((await source.pool.query(`SELECT table_name FROM information_schema.columns
|
||||
WHERE table_schema='public' AND column_name='poll_configuration_epoch' ORDER BY table_name`)).rows,
|
||||
[{ table_name: "uptime_settings" }])
|
||||
await source.pool.query("ALTER TABLE public.uptime_settings DROP COLUMN poll_configuration_epoch")
|
||||
assert.deepEqual(await rows(source.pool), oldRows)
|
||||
groups.push("partial-unmarked-schema-collision-refuses-without-earlier-ddl-or-marker-commit")
|
||||
await applySqlMigrations(source.pool); await applySqlMigrations(source.pool)
|
||||
assert.equal((await source.pool.query("SELECT count(*)::int AS n FROM schema_migrations")).rows[0].n, MIGRATION_MANIFEST.length)
|
||||
const newRows = await rows(source.pool)
|
||||
for (const [index, value] of newRows.entries()) {
|
||||
assert.match(value.poll_configuration_epoch, /^[a-f0-9-]{36}$/)
|
||||
const { poll_configuration_epoch: epoch, ...original } = value
|
||||
assert.equal(typeof epoch, "string"); assert.deepEqual(original, oldRows[index])
|
||||
}
|
||||
groups.push("actual-nonempty-41-to-current-upgrade-preserves-every-old-field-and-microsecond")
|
||||
const afterUpgrade = await rows(source.pool)
|
||||
await applySqlMigrations(source.pool)
|
||||
assert.deepEqual(await rows(source.pool), afterUpgrade)
|
||||
groups.push("normal-startup-repetition-preserves-issued-epochs-and-atomic-marker")
|
||||
await acceptServerPollSettingsEpochSql(source.pool, true)
|
||||
const before = await snapshot(source.pool)
|
||||
const args = ["--host", url.hostname, "--port", url.port, "--username", "mcp_test", "--no-password"]
|
||||
const env = { ...process.env, PGPASSWORD: decodeURIComponent(url.password) }
|
||||
await mkdir(state, { recursive: true })
|
||||
await execute("pg_dump", [...args, "--format=custom", "--file", archive, "--dbname=" + source.name],
|
||||
{ env, timeout: 60000, maxBuffer: 1048576, windowsHide: true })
|
||||
await execute("pg_restore", [...args, "--single-transaction", "--exit-on-error", "--dbname=" + restored.name, archive],
|
||||
{ env, timeout: 60000, maxBuffer: 1048576, windowsHide: true })
|
||||
const after = await snapshot(restored.pool)
|
||||
assert.equal(compareMigrationSchemas(before.schema, after.schema).equal, true)
|
||||
assert.equal(compareMigrationData(before, after).equal, true)
|
||||
assert.deepEqual(await rows(restored.pool), afterUpgrade)
|
||||
assertServerPollSettingsEpochCatalog((await restored.pool.query(SERVER_POLL_SETTINGS_EPOCH_INVENTORY_SQL, [SERVER_POLL_SETTINGS_TABLES])).rows)
|
||||
groups.push("actual-pg-dump-restore-complete-schema-data-and-exact-epoch-preserved")
|
||||
const epoch = (await rows(restored.pool))[1].poll_configuration_epoch
|
||||
await restored.pool.query("UPDATE public.uptime_settings SET last_error='restored collector',updated_at=clock_timestamp() WHERE id=1")
|
||||
assert.equal((await rows(restored.pool))[1].poll_configuration_epoch, epoch)
|
||||
await restored.pool.query("UPDATE public.uptime_settings SET retention_days=retention_days+1 WHERE id=1")
|
||||
const changed = (await rows(restored.pool))[1].poll_configuration_epoch
|
||||
assert.notEqual(changed, epoch)
|
||||
await restored.pool.query("UPDATE public.uptime_settings SET retention_days=retention_days-1 WHERE id=1")
|
||||
const back = (await rows(restored.pool))[1].poll_configuration_epoch
|
||||
assert.notEqual(back, epoch); assert.notEqual(back, changed)
|
||||
groups.push("restored-guard-enforces-runtime-stability-and-configuration-ABA")
|
||||
console.log("Owned settings epoch migration/restore:", JSON.stringify({ groups, groupCount: groups.length,
|
||||
numberedMigrations: MIGRATION_MANIFEST.length, priorFiles: 41, actualPgDumpRestore: true, completeLogicalSchemaAndDataEqual: true,
|
||||
exactEpochsPreservedAcrossRestore: true, runtimeApprovalWired: false, realChrUsed: false }))
|
||||
} finally {
|
||||
for (const pool of pools) await pool.end()
|
||||
for (const name of names.reverse()) {
|
||||
assert(/^mm_poll_epoch_[a-f0-9]{32}$/.test(name)); await admin.query(`DROP DATABASE "${name}"`)
|
||||
}
|
||||
try { await unlink(archive) } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
/** Schema upgrade/negative guards only. Positive archive acceptance must use the
|
||||
* genuine public poll runtime and private historical codec, never these rows. */
|
||||
import assert from "node:assert/strict"
|
||||
import { randomUUID } from "node:crypto"
|
||||
import { readFile } from "node:fs/promises"
|
||||
import { basename, dirname, isAbsolute, resolve } from "node:path"
|
||||
|
||||
assert.equal(process.env.MMAPP_OWN_POLL_ARCHIVE_SCHEMA, "true")
|
||||
assert.equal(process.env.NODE_ENV, "test")
|
||||
const endpoint = new URL(process.env.DATABASE_URL ?? "")
|
||||
assert.equal(endpoint.hostname, "127.0.0.1"); assert.equal(endpoint.username, "mcp_test"); assert(endpoint.password)
|
||||
assert.equal(endpoint.pathname, "/postgres"); assert.equal(endpoint.search, ""); assert.equal(endpoint.hash, "")
|
||||
assert(Number.isInteger(Number(endpoint.port)) && Number(endpoint.port) > 0 && Number(endpoint.port) <= 65535)
|
||||
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 { Pool } = await import("pg")
|
||||
const { pool, closePool } = await import("./index.js")
|
||||
const { MIGRATION_MANIFEST, analyzeMigration } = await import("./migration-manifest.js")
|
||||
const archivePin = MIGRATION_MANIFEST.find(pin => pin.file === "0041_server_poll_terminal_archives.sql")!
|
||||
const migration = await readFile(new URL("../../drizzle/0041_server_poll_terminal_archives.sql", import.meta.url))
|
||||
analyzeMigration(archivePin.file, migration, archivePin)
|
||||
const databases: { name: string; pool: InstanceType<typeof Pool> }[] = [], accepted: string[] = []
|
||||
const prefix40 = async () => {
|
||||
const name = "mm_poll_archive_schema_" + randomUUID().replaceAll("-", "")
|
||||
assert(/^mm_poll_archive_schema_[a-f0-9]{32}$/.test(name))
|
||||
await pool.query(`CREATE DATABASE "${name}" TEMPLATE template0`)
|
||||
const target = new URL(endpoint); target.pathname = "/" + name
|
||||
const connection = new Pool({ connectionString: target.toString(), max: 4 })
|
||||
databases.push({ name, pool: connection })
|
||||
const pins = MIGRATION_MANIFEST.filter(pin => pin.file < "0041_")
|
||||
assert.equal(pins.length, 40); assert.equal(pins.at(-1)?.file, "0040_mcp_routing_reservations.sql")
|
||||
await connection.query("CREATE TABLE schema_migrations(id text PRIMARY KEY,applied_at timestamptz NOT NULL DEFAULT now())")
|
||||
for (const pin of pins) {
|
||||
const bytes = await readFile(new URL("../../drizzle/" + pin.file, import.meta.url)); analyzeMigration(pin.file, bytes, pin)
|
||||
await connection.query(bytes.toString("utf8"))
|
||||
await connection.query("INSERT INTO schema_migrations(id) VALUES($1) ON CONFLICT DO NOTHING", [pin.file.slice(0, -4)])
|
||||
}
|
||||
return connection
|
||||
}
|
||||
const seedFullRest = async (connection: InstanceType<typeof Pool>) => {
|
||||
const receipt = randomUUID(), execution = randomUUID(), token = randomUUID()
|
||||
await connection.query(`INSERT INTO server_poll_storage_admissions(receipt_id,identity_kind,execution_id,owner_id,binding_proof,context_proof,
|
||||
authority_issued_at,authority_expires_at,max_stages,claim_token,retention_days,safety_ms,gc_not_before)
|
||||
VALUES($1,'rest',$2,'owned-archive-schema',$3,$4,clock_timestamp()-interval '1 second',clock_timestamp()+interval '3 minutes',8,$5,1,60000,clock_timestamp()+interval '2 days')`,
|
||||
[receipt, execution, "a".repeat(64), "b".repeat(64), token])
|
||||
await connection.query("UPDATE server_poll_storage_admissions SET claim_token=NULL,claim_attempted_at=clock_timestamp() WHERE receipt_id=$1", [receipt])
|
||||
await connection.query("INSERT INTO server_poll_executions(receipt_id,identity_kind,execution_id,binding_proof,context_ciphertext,context_proof) VALUES($1,'rest',$2,$3,'SYNTHETIC_SCHEMA_FIXTURE_ONLY',$4)", [receipt, execution, "a".repeat(64), "b".repeat(64)])
|
||||
return { receipt, execution }
|
||||
}
|
||||
try {
|
||||
const connection = await prefix40(), { receipt, execution } = await seedFullRest(connection)
|
||||
const originalAdmission = (await connection.query("SELECT to_jsonb(a) AS value FROM server_poll_storage_admissions a WHERE receipt_id=$1", [receipt])).rows[0].value
|
||||
const originalJournal = (await connection.query("SELECT to_jsonb(e) AS value FROM server_poll_executions e WHERE receipt_id=$1", [receipt])).rows[0].value
|
||||
const originalUsage = (await connection.query("SELECT * FROM server_poll_storage_usage ORDER BY scope")).rows
|
||||
console.log("Owned prefix40 poll constraint inventory:", JSON.stringify((await connection.query("SELECT conname,pg_get_constraintdef(oid) AS definition FROM pg_constraint WHERE conrelid='public.server_poll_storage_usage'::regclass AND contype='c' ORDER BY conname")).rows))
|
||||
await connection.query(migration.toString("utf8"))
|
||||
assert.equal(Number((await connection.query("SELECT count(*) AS n FROM schema_migrations")).rows[0].n), 41)
|
||||
assert.deepEqual((await connection.query("SELECT to_jsonb(a)-ARRAY['storage_state','compact_at','archive_bytes','original_activity_at','compaction_claim','compaction_claimed_at','compaction_claim_xid'] AS value FROM server_poll_storage_admissions a WHERE receipt_id=$1", [receipt])).rows[0].value, originalAdmission)
|
||||
assert.deepEqual((await connection.query("SELECT to_jsonb(e) AS value FROM server_poll_executions e WHERE receipt_id=$1", [receipt])).rows[0].value, originalJournal)
|
||||
assert.deepEqual((await connection.query("SELECT * FROM server_poll_storage_usage ORDER BY scope")).rows, originalUsage.map(row => ({ ...row, compacted_admissions: 0, archive_bytes: 0 })))
|
||||
assert.equal((await connection.query("SELECT server_poll_storage_accounting_valid() AS valid")).rows[0].valid, true)
|
||||
assert.equal((await connection.query("SELECT server_poll_storage_source_payload($1) AS value", [receipt])).rows[0].value, null)
|
||||
assert.deepEqual((await connection.query("SELECT compaction_claim,compaction_claimed_at,compaction_claim_xid FROM server_poll_storage_admissions WHERE receipt_id=$1", [receipt])).rows[0], { compaction_claim: null, compaction_claimed_at: null, compaction_claim_xid: null })
|
||||
accepted.push("nonempty40 full REST journal, original admission/proofs/counters preserved; archive source remains unavailable for REST")
|
||||
await assert.rejects(connection.query("UPDATE server_poll_storage_admissions SET storage_state='compacted',compact_at=clock_timestamp(),archive_bytes=1,original_activity_at=created_at WHERE receipt_id=$1", [receipt]))
|
||||
await assert.rejects(connection.query("UPDATE server_poll_storage_admissions SET compaction_claim=$2,compaction_claimed_at=clock_timestamp() WHERE receipt_id=$1", [receipt, randomUUID()]))
|
||||
await assert.rejects(connection.query("UPDATE server_poll_storage_admissions SET compaction_claimed_at=clock_timestamp() WHERE receipt_id=$1", [receipt]))
|
||||
await assert.rejects(connection.query("UPDATE server_poll_storage_admissions SET compaction_claim_xid=pg_current_xact_id() WHERE receipt_id=$1", [receipt]))
|
||||
await assert.rejects(connection.query("UPDATE server_poll_storage_admissions SET compaction_claim=NULL,compaction_claimed_at=NULL WHERE receipt_id=$1", [receipt]))
|
||||
await assert.rejects(connection.query(`INSERT INTO server_poll_storage_admissions(receipt_id,identity_kind,execution_id,owner_id,binding_proof,context_proof,
|
||||
authority_issued_at,authority_expires_at,max_stages,claim_token,retention_days,safety_ms,gc_not_before,compaction_claim,compaction_claimed_at)
|
||||
VALUES($1,'rest',$2,'owned-archive-schema',$3,$3,clock_timestamp()-interval '1 second',clock_timestamp()+interval '3 minutes',8,$4,1,60000,clock_timestamp()+interval '2 days',$5,clock_timestamp())`,
|
||||
[randomUUID(), randomUUID(), "a".repeat(64), randomUUID(), randomUUID()]))
|
||||
await assert.rejects(connection.query(`INSERT INTO server_poll_archives(receipt_id,version,execution_id,connection_id,owner_id,binding_proof,context_proof,source_digest,source_sha256,terminal_digest,
|
||||
original_activity_at,original_expires_at,decoded_bytes,compressed_bytes,ciphertext,ciphertext_bytes,archive_proof)
|
||||
VALUES($1,1,$2,$3,'owned-archive-schema',$4,$4,$4,$4,$4,clock_timestamp(),clock_timestamp(),1,1,$5,$6,$4)`,
|
||||
[receipt, execution, randomUUID(), "a".repeat(64), Buffer.alloc(29).toString("base64"), Buffer.alloc(29).toString("base64").length]))
|
||||
const leased = await connection.connect()
|
||||
try {
|
||||
await leased.query("BEGIN"); await leased.query("SET LOCAL mm.poll_storage_compact='on'")
|
||||
await assert.rejects(leased.query("DELETE FROM server_poll_executions WHERE receipt_id=$1", [receipt]))
|
||||
} finally { await leased.query("ROLLBACK"); leased.release() }
|
||||
assert.deepEqual((await connection.query("SELECT to_jsonb(e) AS value FROM server_poll_executions e WHERE receipt_id=$1", [receipt])).rows[0].value, originalJournal)
|
||||
assert.equal(Number((await connection.query("SELECT count(*) AS n FROM server_poll_archives")).rows[0].n), 0)
|
||||
accepted.push("no archive, prefilled/reset/altered claim, direct state edit or session flag can compact an ineligible source or delete immutable journal")
|
||||
|
||||
const inconsistent = await prefix40(); await seedFullRest(inconsistent)
|
||||
// Deliberately valid OLD scalar quota shape but inconsistent with retained
|
||||
// rows: additive enrollment must refuse rather than resetting the ledger.
|
||||
await inconsistent.query("UPDATE server_poll_storage_usage SET admissions=0,reserved_rows=0,reserved_bytes=0")
|
||||
const before = (await inconsistent.query("SELECT to_jsonb(a) AS value FROM server_poll_storage_admissions a")).rows
|
||||
const target = await inconsistent.connect()
|
||||
try { await assert.rejects(target.query(migration.toString("utf8"))) }
|
||||
finally { await target.query("ROLLBACK"); target.release() }
|
||||
assert.deepEqual((await inconsistent.query("SELECT to_jsonb(a) AS value FROM server_poll_storage_admissions a")).rows, before)
|
||||
assert.equal((await inconsistent.query("SELECT to_regclass('public.server_poll_archives') AS name")).rows[0].name, null)
|
||||
assert.equal(Number((await inconsistent.query("SELECT count(*) AS n FROM schema_migrations")).rows[0].n), 40)
|
||||
accepted.push("inconsistent nonempty40 ledger fails before archive DDL and marker with original source untouched")
|
||||
console.log(JSON.stringify({ accepted: accepted.length, cases: accepted, fullHistoryPreserved: true,
|
||||
positiveArchiveAcceptance: false, nativeOrPublicActivation: false }))
|
||||
} finally {
|
||||
for (const item of databases) {
|
||||
await item.pool.end()
|
||||
assert(/^mm_poll_archive_schema_[a-f0-9]{32}$/.test(item.name))
|
||||
await pool.query(`DROP DATABASE "${item.name}"`)
|
||||
}
|
||||
await closePool()
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { bindSql } from "./sql-bind.js"
|
||||
|
||||
{
|
||||
const q = bindSql("SELECT 1")
|
||||
assert.deepEqual(q, { text: "SELECT 1", values: [] })
|
||||
}
|
||||
|
||||
{
|
||||
const q = bindSql("SELECT * FROM t WHERE a = ? AND b = ?", [1, "x"])
|
||||
assert.equal(q.text, "SELECT * FROM t WHERE a = $1 AND b = $2")
|
||||
assert.deepEqual(q.values, [1, "x"])
|
||||
}
|
||||
|
||||
{
|
||||
const q = bindSql("INSERT INTO t (a, b) VALUES (@a, @b)", { a: 3, b: "y" })
|
||||
assert.equal(q.text, "INSERT INTO t (a, b) VALUES ($1, $2)")
|
||||
assert.deepEqual(q.values, [3, "y"])
|
||||
}
|
||||
|
||||
console.log("sql-bind.test.ts: ok")
|
||||
@@ -0,0 +1,22 @@
|
||||
/** Convert SQLite-style @name / ? placeholders to node-pg $n. */
|
||||
|
||||
export function bindSql(
|
||||
sql: string,
|
||||
params?: unknown[] | Record<string, unknown>,
|
||||
): { text: string; values: unknown[] } {
|
||||
if (params == null) return { text: sql, values: [] }
|
||||
if (Array.isArray(params)) {
|
||||
let i = 0
|
||||
const text = sql.replace(/\?/g, () => {
|
||||
i += 1
|
||||
return `$${i}`
|
||||
})
|
||||
return { text, values: params }
|
||||
}
|
||||
const values: unknown[] = []
|
||||
const text = sql.replace(/@([a-zA-Z_][a-zA-Z0-9_]*)/g, (_, name: string) => {
|
||||
values.push(params[name])
|
||||
return `$${values.length}`
|
||||
})
|
||||
return { text, values }
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
/** Permanent owned PostgreSQL regression candidate. No source-capture TMP
|
||||
* module, app config, application pool or dotenv import is needed. */
|
||||
import assert from "node:assert/strict"
|
||||
import { randomUUID } from "node:crypto"
|
||||
import { basename, resolve } from "node:path"
|
||||
import { pathToFileURL } from "node:url"
|
||||
import { Pool, type PoolClient } from "pg"
|
||||
assert.equal(process.env.MMAPP_OWN_IMPORT_CONTROL, "true")
|
||||
assert(process.env.MMAPP_OWN_IMPORT_DIRECTORY && basename(process.env.MMAPP_OWN_IMPORT_DIRECTORY).startsWith("mm-import-control-"))
|
||||
const adminUrl = new URL(process.env.MMAPP_OWN_IMPORT_ADMIN_URL ?? "")
|
||||
assert.equal(adminUrl.hostname, "127.0.0.1"); assert.equal(adminUrl.pathname, "/postgres")
|
||||
assert.equal(process.env.DATABASE_URL, undefined)
|
||||
// The candidate runner can point at the real repository while this test stays
|
||||
// in TMP. Once integrated, relative production source is the default.
|
||||
const sourceRoot = process.env.MMAPP_OWN_IMPORT_SOURCE_ROOT
|
||||
const source = (relative: string, ownRelative: string) => sourceRoot ? pathToFileURL(resolve(sourceRoot, "backend/src", relative)).href : new URL(ownRelative, import.meta.url).href
|
||||
const { withPartitionWrite, PARTITION_WRITER_ADVISORY_KEY, isPartitionWriteOutcomeUnknown } = await import(source("services/partition-write-transaction.ts", "../services/partition-write-transaction.js"))
|
||||
const { writeSqliteImportMarker, resetSqliteImportSequence } = await import(source("db/sqlite-import-control-writes.ts", "./sqlite-import-control-writes.js"))
|
||||
const admin = new Pool({ connectionString: adminUrl.toString(), max: 2 })
|
||||
const name = "import_control_" + randomUUID().replaceAll("-", "")
|
||||
assert(/^import_control_[a-f0-9]{32}$/.test(name))
|
||||
await admin.query('CREATE DATABASE "' + name + '" TEMPLATE template0')
|
||||
const target = new URL(adminUrl); target.pathname = "/" + name
|
||||
const pool = new Pool({ connectionString: target.toString(), max: 8, connectionTimeoutMillis: 3000 })
|
||||
const held: PoolClient[] = [], releases: (() => void)[] = [], cases: string[] = []
|
||||
async function until(check: () => Promise<boolean>) {
|
||||
const end = performance.now() + 4000
|
||||
while (!await check()) { if (performance.now() >= end) throw Error("Owned lock state not observed"); await new Promise(r => setTimeout(r, 10)) }
|
||||
}
|
||||
function fault(mode: "lost-commit" | "aborted-commit" | "rollback-after-setval") {
|
||||
const commands: string[] = [], destroyed: boolean[] = []
|
||||
let commitTag: string | undefined
|
||||
const adapted = { async connect() { const client = await pool.connect(); return {
|
||||
release(destroy = false) { destroyed.push(destroy); client.release(destroy) }, async query(text: string, values?: unknown[]) {
|
||||
commands.push(text)
|
||||
if (text === "COMMIT" && mode === "aborted-commit") await assert.rejects(client.query("SELECT 1/0"), (e: unknown) => !!e && typeof e === "object" && "code" in e && e.code === "22012")
|
||||
const result = await client.query(text, values)
|
||||
if (text === "COMMIT") commitTag = result.command
|
||||
if (text === "COMMIT" && mode === "lost-commit") throw Error("Owned fixture loses actual COMMIT ACK")
|
||||
if (text.startsWith("SELECT pg_catalog.setval") && mode === "rollback-after-setval") {
|
||||
assert.equal((await client.query("ROLLBACK")).command, "ROLLBACK")
|
||||
throw Error("Owned fixture reports failure after actual setval and acknowledged ROLLBACK")
|
||||
}
|
||||
return result
|
||||
},
|
||||
} } } as unknown as Pool
|
||||
return { pool: adapted, commands, destroyed, tag: () => commitTag }
|
||||
}
|
||||
async function lastSequence() { return (await pool.query("SELECT last_value::text AS value FROM public.import_rows_id_seq")).rows[0].value }
|
||||
async function markerPath() { return (await pool.query("SELECT sqlite_path FROM public.data_migration WHERE id=1")).rows[0].sqlite_path }
|
||||
try {
|
||||
await pool.query("CREATE TABLE public.data_migration(id integer PRIMARY KEY,sqlite_imported_at timestamptz,sqlite_path text,sqlite_sha256 text,report_json jsonb); INSERT INTO public.data_migration(id) VALUES(1); CREATE TABLE public.import_rows(id bigserial PRIMARY KEY); INSERT INTO public.import_rows(id) VALUES(42); CREATE TABLE public.no_sequence(id integer PRIMARY KEY)")
|
||||
const exclusive = await pool.connect(); held.push(exclusive)
|
||||
await exclusive.query("SELECT pg_advisory_lock($1::bigint)", [PARTITION_WRITER_ADVISORY_KEY])
|
||||
const marker = writeSqliteImportMarker(pool, "owned.sqlite", "a".repeat(64), JSON.stringify({ import_rows: { copied: 1 } }))
|
||||
const sequence = resetSqliteImportSequence(pool, "import_rows")
|
||||
await until(async () => (await pool.query("SELECT count(*)::int AS n FROM pg_locks WHERE locktype='advisory' AND objid=$1::oid AND NOT granted", [PARTITION_WRITER_ADVISORY_KEY])).rows[0].n === 2)
|
||||
assert.equal(await markerPath(), null); assert.equal(await lastSequence(), "1")
|
||||
assert.equal((await exclusive.query("SELECT pg_advisory_unlock($1::bigint) AS unlocked", [PARTITION_WRITER_ADVISORY_KEY])).rows[0].unlocked, true)
|
||||
await marker; assert.equal(await sequence, "sequence-set"); assert.equal(await lastSequence(), "42")
|
||||
const saved = (await pool.query("SELECT sqlite_path,sqlite_sha256,report_json FROM public.data_migration WHERE id=1")).rows[0]
|
||||
assert.equal(saved.sqlite_path, "owned.sqlite"); assert.equal(saved.sqlite_sha256, "a".repeat(64)); assert.deepEqual(saved.report_json, { import_rows: { copied: 1 } })
|
||||
cases.push("marker and setval use same real shared admission before SQL, wait for exclusive drain and preserve values")
|
||||
assert.equal(await resetSqliteImportSequence(pool, "no_sequence"), "sequence-absent")
|
||||
cases.push("absent serial identity is explicit compatibility no-op")
|
||||
|
||||
let entered!: () => void, release!: () => void
|
||||
const ready = new Promise<void>(r => { entered = r }), waiting = new Promise<void>(r => { release = r }); releases.push(release)
|
||||
const writer = withPartitionWrite(async ({ client }: { client: PoolClient }) => { await client.query("UPDATE public.data_migration SET sqlite_path='already-admitted' WHERE id=1"); entered(); await waiting }, { connect: () => pool.connect() })
|
||||
await ready
|
||||
const drain = exclusive.query("SELECT pg_advisory_lock($1::bigint)", [PARTITION_WRITER_ADVISORY_KEY])
|
||||
await until(async () => (await pool.query("SELECT EXISTS(SELECT 1 FROM pg_locks WHERE locktype='advisory' AND objid=$1::oid AND NOT granted) AS waiting", [PARTITION_WRITER_ADVISORY_KEY])).rows[0].waiting === true)
|
||||
release(); await writer; await drain; assert.equal(await markerPath(), "already-admitted")
|
||||
await exclusive.query("SELECT pg_advisory_unlock($1::bigint)", [PARTITION_WRITER_ADVISORY_KEY])
|
||||
cases.push("exclusive drain waits for already-admitted writer COMMIT")
|
||||
|
||||
const lostMarker = fault("lost-commit")
|
||||
await assert.rejects(writeSqliteImportMarker(lostMarker.pool, "lost-ack.sqlite", "b".repeat(64), "{}"), isPartitionWriteOutcomeUnknown)
|
||||
assert.equal(lostMarker.tag(), "COMMIT"); assert.equal(await markerPath(), "lost-ack.sqlite")
|
||||
assert.equal(lostMarker.commands.filter(q => q.startsWith("UPDATE data_migration")).length, 1); assert.equal(lostMarker.commands.filter(q => q === "COMMIT").length, 1)
|
||||
assert(!lostMarker.commands.includes("ROLLBACK")); assert.deepEqual(lostMarker.destroyed, [true])
|
||||
cases.push("lost actual marker COMMIT ACK is UNKNOWN, one update and no second transition/replay")
|
||||
|
||||
await pool.query("INSERT INTO public.import_rows(id) VALUES(77)")
|
||||
const lostSequence = fault("lost-commit")
|
||||
await assert.rejects(resetSqliteImportSequence(lostSequence.pool, "import_rows"), isPartitionWriteOutcomeUnknown)
|
||||
assert.equal(await lastSequence(), "77"); assert.equal(lostSequence.commands.filter(q => q.startsWith("SELECT pg_catalog.setval")).length, 1)
|
||||
cases.push("lost actual sequence COMMIT ACK propagates UNKNOWN with setval already visible and no replay")
|
||||
|
||||
await pool.query("INSERT INTO public.import_rows(id) VALUES(88)")
|
||||
const rolledSequence = fault("rollback-after-setval")
|
||||
await assert.rejects(resetSqliteImportSequence(rolledSequence.pool, "import_rows"), isPartitionWriteOutcomeUnknown)
|
||||
assert.equal(await lastSequence(), "88"); assert.equal(rolledSequence.commands.filter(q => q.startsWith("SELECT pg_catalog.setval")).length, 1)
|
||||
cases.push("acknowledged actual ROLLBACK does not undo setval; post-sequence failure remains UNKNOWN")
|
||||
|
||||
const abortedDml = fault("aborted-commit")
|
||||
await assert.rejects(withPartitionWrite(async ({ client }: { client: PoolClient }) => { await client.query("INSERT INTO public.no_sequence VALUES(999)") }, { connect: () => abortedDml.pool.connect() }), isPartitionWriteOutcomeUnknown)
|
||||
assert.equal(abortedDml.tag(), "ROLLBACK"); assert.equal((await pool.query("SELECT count(*)::int AS n FROM public.no_sequence")).rows[0].n, 0)
|
||||
assert.equal(abortedDml.commands.filter(q => q === "COMMIT").length, 1); assert(!abortedDml.commands.includes("ROLLBACK")); assert.deepEqual(abortedDml.destroyed, [true])
|
||||
cases.push("real aborted DML COMMIT resolves ROLLBACK and is refused without success/retry/second transition")
|
||||
const abortedMarker = fault("aborted-commit")
|
||||
await assert.rejects(writeSqliteImportMarker(abortedMarker.pool, "must-not-publish.sqlite", "c".repeat(64), "{}"), isPartitionWriteOutcomeUnknown)
|
||||
assert.equal(abortedMarker.tag(), "ROLLBACK"); assert.equal(await markerPath(), "lost-ack.sqlite")
|
||||
cases.push("real aborted marker COMMIT=>ROLLBACK cannot publish success")
|
||||
await pool.query("INSERT INTO public.import_rows(id) VALUES(99)")
|
||||
const abortedSequence = fault("aborted-commit")
|
||||
await assert.rejects(resetSqliteImportSequence(abortedSequence.pool, "import_rows"), isPartitionWriteOutcomeUnknown)
|
||||
assert.equal(abortedSequence.tag(), "ROLLBACK"); assert.equal(await lastSequence(), "99")
|
||||
cases.push("real aborted setval COMMIT=>ROLLBACK refuses success while sequence effect honestly remains")
|
||||
await assert.rejects(resetSqliteImportSequence(pool, "missing_import_table"))
|
||||
cases.push("non-compatibility SQL errors propagate; blanket catch and replay absent")
|
||||
console.log(JSON.stringify({ cases: cases.length, names: cases, actualSql: true, genuineCommitRollbackCases: 3,
|
||||
actualCurrentProductionPartitionWriter: true, actualCurrentProductionImporterHelpers: true, sourceCaptureTmpDependency: false,
|
||||
allWriterDrainAuthenticated: false, readyAuthorized: false, productionDatabaseTouched: false, hostUsersGroupsServicesChanged: false }, null, 2))
|
||||
} finally {
|
||||
for (const release of releases) release()
|
||||
for (const c of held) c.release(true)
|
||||
await pool.end(); await admin.query('DROP DATABASE "' + name + '" WITH (FORCE)'); await admin.end()
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { Pool } from "pg"
|
||||
import { withPartitionWrite, PartitionWriteError, isPartitionWriteOutcomeUnknown } from "../services/partition-write-transaction.js"
|
||||
/** Uses the same
|
||||
* admission already used by import batches, on the marker's actual backend. */
|
||||
export async function writeSqliteImportMarker(pool: Pool, sqlitePath: string, sqliteSha256: string, reportJson: string): Promise<void> {
|
||||
await withPartitionWrite(async ({ client }) => {
|
||||
await client.query(`UPDATE data_migration
|
||||
SET sqlite_imported_at = now(), sqlite_path = $1, sqlite_sha256 = $2, report_json = $3::jsonb
|
||||
WHERE id = 1`, [sqlitePath, sqliteSha256, reportJson])
|
||||
}, { connect: () => pool.connect() })
|
||||
}
|
||||
|
||||
/** Missing serial metadata is the explicit supported compatibility no-op.
|
||||
* Never catch arbitrary SQL errors or unknown COMMIT outcomes. setval itself
|
||||
* is NONTRANSACTIONAL: a later rollback/ACK loss cannot undo its visible value. */
|
||||
export async function resetSqliteImportSequence(pool: Pool, table: string): Promise<"sequence-absent" | "sequence-set"> {
|
||||
if (typeof table !== "string" || !/^[a-z_][a-z0-9_]{0,62}$/.test(table)) throw Error("Fixed importer relation identifier required")
|
||||
let sequenceMutationSent = false
|
||||
try { return await withPartitionWrite(async ({ client }) => {
|
||||
const rows = (await client.query<{ sequence_oid: string | null }>("SELECT pg_catalog.pg_get_serial_sequence($1,'id')::pg_catalog.regclass::pg_catalog.oid::text AS sequence_oid", ["public." + table])).rows
|
||||
if (rows.length !== 1) throw Error("Importer identity metadata unavailable")
|
||||
if (rows[0].sequence_oid === null) return "sequence-absent" as const
|
||||
if (!/^[1-9][0-9]{0,9}$/.test(rows[0].sequence_oid)) throw Error("Importer sequence identity unavailable")
|
||||
sequenceMutationSent = true
|
||||
await client.query(`SELECT pg_catalog.setval($1::pg_catalog.oid::pg_catalog.regclass,
|
||||
GREATEST(COALESCE((SELECT MAX(id) FROM "public"."${table}"),1),1),true)`, [rows[0].sequence_oid])
|
||||
return "sequence-set" as const
|
||||
}, { connect: () => pool.connect() }) }
|
||||
catch (error) {
|
||||
// The surrounding SQL transaction may be known rolled back, while setval
|
||||
// was already visible. Never feed that case into known-failure/retry lanes.
|
||||
if (sequenceMutationSent && !isPartitionWriteOutcomeUnknown(error)) throw new PartitionWriteError(true)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { readFile } from "node:fs/promises"
|
||||
import { runInNewContext } from "node:vm"
|
||||
import ts from "typescript"
|
||||
|
||||
// Execute the actual private copyTable implementation with inert SQLite/SQL
|
||||
// providers. No native SQLite file, configured pool or external DB is opened.
|
||||
const source = await readFile(new URL("./sqlite-import.ts", import.meta.url), "utf8")
|
||||
const compiled = ts.transpileModule(source + "\nexport { copyTable };\n", { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } }).outputText
|
||||
class UnknownCommit extends Error { readonly outcomeUnknown = true }
|
||||
async function fixture(count: number, unknownCommit = false) {
|
||||
let consumed = 0, connects = 0
|
||||
const events: string[] = [], inserted: unknown[][] = []
|
||||
const rows = Array.from({ length: count }, (_, index) => ({ server_id: 1, sampled_at: "2026-10-25T00:00:00Z", interface_name: `synthetic-${index}` }))
|
||||
const sqlite = { prepare(text: string) { return {
|
||||
get() { if (text.includes("sqlite_master")) return { name: "traffic_samples" }; if (text.includes("MIN(")) return { a: rows[0]?.sampled_at, b: rows.at(-1)?.sampled_at }; return { c: count } },
|
||||
*iterate() { for (const row of rows) { consumed++; yield row } },
|
||||
} } }
|
||||
const client = { async query(text: string, values: unknown[]) { assert(text.startsWith("INSERT INTO traffic_samples")); events.push("INSERT"); inserted.push(values); return { rows: [] } } }
|
||||
const pool = { async connect() { connects++; return client }, query: async () => { assert.fail("Copy SQL escaped owned client") } }
|
||||
const modules: Record<string, unknown> = {
|
||||
"node:crypto": {}, "node:fs": {}, "better-sqlite3": {}, "../config.js": { env: {} }, "./traffic-flags.js": { encodeTrafficFlags: () => 0 },
|
||||
"./sqlite-import-control-writes.js": { writeSqliteImportMarker: () => assert.fail("Marker not reached in copyTable-only fixture"), resetSqliteImportSequence: () => assert.fail("Non-identity fixture must not reset a sequence") },
|
||||
"./partitions.js": { specForParent: () => ({ kind: "week" }), ensurePartitionsBetween: async () => { events.push("admitted DDL helper") } },
|
||||
"../services/partition-write-transaction.js": {
|
||||
withPartitionWrite: async (work: (owner: { client: typeof client }) => Promise<unknown>, options: { connect: () => Promise<typeof client> }) => {
|
||||
assert.equal(await options.connect(), client)
|
||||
events.push("BEGIN", "SHARED 8723110"); await work({ client }); events.push("COMMIT")
|
||||
if (unknownCommit) throw new UnknownCommit("Synthetic lost COMMIT ACK")
|
||||
},
|
||||
isPartitionWriteOutcomeUnknown: (error: unknown) => error instanceof UnknownCommit,
|
||||
},
|
||||
}
|
||||
const exports = {} as { copyTable: (...args: unknown[]) => Promise<{ copied: number; skipped: number }> }
|
||||
runInNewContext(compiled, { exports, require: (name: string) => { assert(name in modules, name); return modules[name] }, console: { log: () => {} }, Date, Error, Promise, Set, Map, String, Number, Object, JSON, Buffer }, { filename: "sqlite-import.ts" })
|
||||
const spec = { table: "traffic_samples", columns: [["server_id", "int"], ["sampled_at", "ts"], ["interface_name", "text"]], timeCol: "sampled_at" }
|
||||
return { run: (strict: boolean) => exports.copyTable(sqlite, pool, spec, { strict, fullHistory: true, rejects: [] }), events, inserted, consumed: () => consumed, connects: () => connects }
|
||||
}
|
||||
const confirmed = await fixture(1001)
|
||||
const report = await confirmed.run(false)
|
||||
assert.equal(report.copied, 1001); assert.equal(report.skipped, 0); assert.equal(confirmed.connects(), 2)
|
||||
assert.equal(confirmed.events[0], "admitted DDL helper")
|
||||
assert.equal(confirmed.events.filter(event => event === "SHARED 8723110").length, 2)
|
||||
assert.equal(confirmed.inserted[0].length, 3000); assert.equal(confirmed.inserted[1].length, 3)
|
||||
for (const strict of [true, false]) {
|
||||
const f = await fixture(1001, true)
|
||||
await assert.rejects(f.run(strict), UnknownCommit)
|
||||
assert.equal(f.consumed(), 1000, "Unknown commit escapes the source-row catch even in non-strict import")
|
||||
assert.equal(f.connects(), 1); assert.equal(f.inserted.length, 1)
|
||||
assert.equal(f.events.filter(event => event === "COMMIT").length, 1)
|
||||
}
|
||||
console.log("SQLite import partition admission: 3 actual-source inert chunk/count/no-replay scenarios passed")
|
||||
@@ -0,0 +1,521 @@
|
||||
import { createHash } from "node:crypto"
|
||||
import { existsSync, readFileSync } from "node:fs"
|
||||
import Database from "better-sqlite3"
|
||||
import type { Pool } from "pg"
|
||||
import { env } from "../config.js"
|
||||
import { ensurePartitionsBetween, specForParent } from "./partitions.js"
|
||||
import { encodeTrafficFlags } from "./traffic-flags.js"
|
||||
import { withPartitionWrite, isPartitionWriteOutcomeUnknown } from "../services/partition-write-transaction.js"
|
||||
import { writeSqliteImportMarker, resetSqliteImportSequence } from "./sqlite-import-control-writes.js"
|
||||
|
||||
export interface ImportReport {
|
||||
sqlitePath: string
|
||||
sqliteSha256: string
|
||||
tables: Record<string, { sqlite: number; copied: number; skipped: number }>
|
||||
rejects: string[]
|
||||
durationMs: number
|
||||
}
|
||||
|
||||
const SNAPSHOT_RETENTION_DAYS = 14
|
||||
|
||||
type ColKind = "ts" | "date" | "bool" | "json" | "json-null" | "bigint-id" | "int" | "text" | "num" | "flags" | "inet"
|
||||
|
||||
const INSERT_CHUNK = 1000
|
||||
|
||||
interface TableCopy {
|
||||
table: string
|
||||
columns: Array<[string, ColKind]>
|
||||
timeCol?: string
|
||||
retentionDays?: number
|
||||
identity?: boolean
|
||||
upsert?: boolean
|
||||
}
|
||||
|
||||
const TABLES: TableCopy[] = [
|
||||
{ table: "servers", identity: true, columns: [
|
||||
["id", "int"], ["name", "text"], ["host", "text"], ["port", "int"], ["username", "text"],
|
||||
["password", "text"], ["use_ssl", "bool"], ["verify_ssl", "bool"], ["type", "text"],
|
||||
["site", "text"], ["country", "text"], ["asn", "text"], ["comment", "text"], ["enabled", "bool"],
|
||||
["lan_subnet", "text"], ["wan_uplinks", "json"], ["mgmt_tunnel_ip", "text"],
|
||||
["created_at", "ts"], ["updated_at", "ts"],
|
||||
]},
|
||||
{ table: "traffic_settings", upsert: true, columns: [
|
||||
["id", "int"], ["enabled", "bool"], ["interval_sec", "int"], ["retention_days", "int"],
|
||||
["last_collected_at", "ts"], ["last_duration_ms", "int"], ["last_error", "text"],
|
||||
["created_at", "ts"], ["updated_at", "ts"],
|
||||
]},
|
||||
{ table: "servers_api_ping_settings", upsert: true, columns: [
|
||||
["id", "int"], ["enabled", "bool"], ["interval_sec", "int"],
|
||||
["last_collected_at", "ts"], ["last_duration_ms", "int"], ["last_error", "text"],
|
||||
["created_at", "ts"], ["updated_at", "ts"],
|
||||
]},
|
||||
{ table: "traffic_flow_settings", upsert: true, columns: [
|
||||
["id", "int"], ["enabled", "bool"], ["collector_ip", "text"], ["flow_listen_port", "int"],
|
||||
["wg_listen_port", "int"], ["prefix", "text"], ["public_endpoint", "text"],
|
||||
["host_public_key", "text"], ["host_private_key", "text"], ["hub_server_id", "int"],
|
||||
["retention_hours", "int"], ["top_n", "int"], ["map_service_min_share_pct", "num"],
|
||||
["last_datagram_at", "ts"], ["last_exporter_ip", "text"], ["last_error", "text"],
|
||||
["packets_received", "int"], ["peers_json", "json"], ["created_at", "ts"], ["updated_at", "ts"],
|
||||
]},
|
||||
{ table: "uptime_settings", upsert: true, columns: [
|
||||
["id", "int"], ["enabled", "bool"], ["resources_enabled", "bool"], ["ping_enabled", "bool"],
|
||||
["speed_enabled", "bool"], ["interval_sec", "int"], ["probe_interval_sec", "int"],
|
||||
["speed_interval_sec", "int"], ["retention_days", "int"], ["last_collected_at", "ts"],
|
||||
["last_duration_ms", "int"], ["last_error", "text"], ["created_at", "ts"], ["updated_at", "ts"],
|
||||
]},
|
||||
{ table: "evobgp_settings", upsert: true, columns: [
|
||||
["id", "int"], ["base_url", "text"], ["api_key", "text"], ["enabled", "bool"], ["updated_at", "ts"],
|
||||
]},
|
||||
{ table: "alert_telegram_settings", upsert: true, columns: [
|
||||
["id", "int"], ["bot_token", "text"], ["chat_id", "text"], ["message_thread_id", "int"], ["updated_at", "ts"],
|
||||
]},
|
||||
{ table: "acme_settings", upsert: true, columns: [
|
||||
["id", "int"], ["directory_url", "text"], ["cloudflare_api_token", "text"],
|
||||
["default_zone_id", "text"], ["account_private_key", "text"], ["updated_at", "ts"],
|
||||
]},
|
||||
{ table: "certificate_renew_settings", upsert: true, columns: [
|
||||
["id", "int"], ["enabled", "bool"], ["interval_sec", "int"], ["renew_before_days", "int"],
|
||||
["last_collected_at", "ts"], ["last_duration_ms", "int"], ["last_error", "text"], ["updated_at", "ts"],
|
||||
]},
|
||||
{ table: "backup_schedule_settings", upsert: true, columns: [
|
||||
["id", "int"], ["enabled", "bool"], ["frequency", "text"], ["hour", "int"], ["minute", "int"],
|
||||
["week_day", "int"], ["month_day", "int"], ["keep_count", "int"], ["format", "text"],
|
||||
["server_ids_json", "json"], ["last_run_at", "ts"], ["last_duration_ms", "int"],
|
||||
["last_error", "text"], ["updated_at", "ts"],
|
||||
]},
|
||||
{ table: "backup_storage_settings", upsert: true, columns: [
|
||||
["id", "int"], ["provider", "text"], ["s3_endpoint", "text"], ["s3_region", "text"],
|
||||
["s3_bucket", "text"], ["s3_prefix", "text"], ["s3_access_key_id", "text"],
|
||||
["s3_secret_access_key", "text"], ["s3_force_path_style", "bool"], ["keep_local_copy", "bool"],
|
||||
["last_test_at", "ts"], ["last_test_error", "text"], ["updated_at", "ts"],
|
||||
]},
|
||||
{ table: "internet_path_settings", upsert: true, columns: [
|
||||
["id", "int"], ["enabled", "bool"], ["interval_sec", "int"], ["retention_days", "int"],
|
||||
["last_collected_at", "ts"], ["last_duration_ms", "int"], ["last_error", "text"],
|
||||
["created_at", "ts"], ["updated_at", "ts"],
|
||||
]},
|
||||
{ table: "alert_engine_cursor", upsert: true, columns: [
|
||||
["id", "int"], ["last_source_finished_at", "ts"], ["updated_at", "ts"],
|
||||
]},
|
||||
{ table: "filter_rules", identity: true, columns: [
|
||||
["id", "int"], ["server_id", "int"], ["sort_order", "int"], ["community", "text"],
|
||||
["community_name", "text"], ["action", "text"], ["gateway", "text"],
|
||||
["gateway_tunnel_id", "text"], ["description", "text"], ["created_at", "ts"], ["updated_at", "ts"],
|
||||
]},
|
||||
{ table: "recursive_routes", identity: true, columns: [
|
||||
["id", "int"], ["server_id", "int"], ["sort_order", "int"], ["dst_address", "text"],
|
||||
["gateway", "text"], ["distance", "int"], ["scope", "int"], ["target_scope", "int"],
|
||||
["routing_table", "text"], ["check_gateway", "text"], ["country", "text"], ["comment", "text"],
|
||||
["disabled", "bool"], ["created_at", "ts"], ["updated_at", "ts"],
|
||||
]},
|
||||
{ table: "server_snapshots", identity: true, timeCol: "polled_at", retentionDays: SNAPSHOT_RETENTION_DAYS, columns: [
|
||||
["id", "int"], ["server_id", "int"], ["polled_at", "ts"], ["status", "text"], ["latency_ms", "num"],
|
||||
["ros_version", "text"], ["board_name", "text"], ["uptime", "text"], ["cpu_load", "int"],
|
||||
["free_memory", "int"], ["total_memory", "int"], ["identity_name", "text"],
|
||||
["raw_interfaces", "json-null"], ["raw_ip_addresses", "json-null"],
|
||||
]},
|
||||
{ table: "traffic_samples", timeCol: "sampled_at", retentionDays: 14, columns: [
|
||||
["server_id", "int"], ["interface_name", "text"], ["peer_public_key", "text"],
|
||||
["sampled_at", "ts"], ["rx_bytes", "int"], ["tx_bytes", "int"], ["rx_bps", "int"], ["tx_bps", "int"],
|
||||
["flags", "flags"],
|
||||
]},
|
||||
{ table: "servers_rest_ping_samples", timeCol: "sampled_at", retentionDays: 30, columns: [
|
||||
["server_id", "int"], ["sampled_at", "ts"], ["ok", "bool"], ["latency_ms", "int"], ["error", "text"],
|
||||
]},
|
||||
{ table: "flow_buckets", timeCol: "bucket_at", retentionDays: 2, columns: [
|
||||
["server_id", "int"], ["bucket_at", "ts"], ["src", "inet"], ["dst", "inet"], ["proto", "int"],
|
||||
["src_port", "int"], ["dst_port", "int"], ["bytes", "int"], ["packets", "int"],
|
||||
["in_iface", "text"], ["out_iface", "text"], ["next_hop", "inet"],
|
||||
["flow_start_ms", "int"], ["flow_end_ms", "int"],
|
||||
["nat_src", "inet"], ["nat_dst", "inet"], ["nat_src_port", "int"], ["nat_dst_port", "int"],
|
||||
]},
|
||||
{ table: "flow_minute_stats", timeCol: "bucket_at", retentionDays: 3, columns: [
|
||||
["server_id", "int"], ["bucket_at", "ts"], ["bytes", "int"], ["packets", "int"],
|
||||
["unique_src", "int"], ["unique_dst", "int"], ["conversations", "int"],
|
||||
]},
|
||||
{ table: "flow_minute_dims", timeCol: "bucket_at", retentionDays: 3, columns: [
|
||||
["server_id", "int"], ["bucket_at", "ts"], ["dim", "text"], ["key", "text"], ["bytes", "int"], ["packets", "int"],
|
||||
]},
|
||||
{ table: "flow_daily_dims", timeCol: "day", retentionDays: 396, columns: [
|
||||
["server_id", "int"], ["day", "date"], ["dim", "text"], ["key", "text"], ["bytes", "int"], ["packets", "int"],
|
||||
]},
|
||||
{ table: "flow_ip_meta", columns: [
|
||||
["prefix", "text"], ["asn", "int"], ["country", "text"], ["lat", "num"], ["lng", "num"],
|
||||
["holder", "text"], ["ok", "int"], ["fetched_at", "ts"],
|
||||
]},
|
||||
{ table: "flow_asn_meta", columns: [
|
||||
["asn", "int"], ["holder", "text"], ["fetched_at", "ts"],
|
||||
]},
|
||||
{ table: "uptime_probes", columns: [
|
||||
["id", "text"], ["src_server_id", "int"], ["src_interface", "text"], ["name", "text"],
|
||||
["target", "text"], ["probe_filter", "text"], ["enabled", "bool"], ["interval_sec", "int"],
|
||||
["show_on_dashboard", "bool"], ["sort_order", "int"], ["created_at", "ts"], ["updated_at", "ts"],
|
||||
]},
|
||||
{ table: "uptime_probe_samples", timeCol: "sampled_at", retentionDays: 14, columns: [
|
||||
["probe_id", "text"], ["sampled_at", "ts"], ["rtt_ms", "int"], ["loss_pct", "int"], ["status", "text"],
|
||||
]},
|
||||
{ table: "uptime_resource_samples", timeCol: "sampled_at", retentionDays: 14, columns: [
|
||||
["server_id", "int"], ["sampled_at", "ts"], ["status", "text"], ["cpu_load", "int"],
|
||||
["free_memory", "int"], ["total_memory", "int"], ["free_hdd_space", "int"], ["total_hdd_space", "int"],
|
||||
["uptime_seconds", "int"],
|
||||
]},
|
||||
{ table: "uptime_speed_probes", columns: [
|
||||
["id", "text"], ["src_server_id", "int"], ["dst_server_id", "int"], ["src_interface", "text"],
|
||||
["dst_interface", "text"], ["protocol", "text"], ["direction", "text"], ["duration_sec", "int"],
|
||||
["enabled", "bool"], ["last_run_at", "ts"], ["last_tx_avg_mbps", "num"], ["last_rx_avg_mbps", "num"],
|
||||
["last_status", "text"], ["last_error", "text"], ["last_ping_rtt_ms", "int"],
|
||||
["last_ping_loss_pct", "int"], ["last_ping_at", "ts"], ["last_ping_error", "text"],
|
||||
["sort_order", "int"], ["created_at", "ts"], ["updated_at", "ts"],
|
||||
]},
|
||||
{ table: "uptime_speed_test_runs", columns: [
|
||||
["id", "text"], ["probe_id", "text"], ["src_server_id", "int"], ["dst_server_id", "int"],
|
||||
["src_interface", "text"], ["dst_interface", "text"], ["src_address", "text"], ["dst_address", "text"],
|
||||
["src_interface_address", "text"], ["dst_interface_address", "text"], ["protocol", "text"],
|
||||
["direction", "text"], ["duration_sec", "int"], ["tx_avg_mbps", "num"], ["rx_avg_mbps", "num"],
|
||||
["ping_rtt_ms", "int"], ["ping_loss_pct", "int"], ["ping_error", "text"], ["status", "text"],
|
||||
["error", "text"], ["created_at", "ts"],
|
||||
]},
|
||||
{ table: "app_users", columns: [
|
||||
["id", "text"], ["name", "text"], ["login", "text"], ["email", "text"], ["role", "text"],
|
||||
["active", "bool"], ["avatar", "text"], ["last_seen", "ts"], ["sections_json", "json"],
|
||||
["servers_json", "json"], ["created_at", "ts"], ["updated_at", "ts"],
|
||||
]},
|
||||
{ table: "user_interface_bindings", columns: [
|
||||
["id", "text"], ["user_id", "text"], ["server_id", "int"], ["interface_name", "text"],
|
||||
["interface_type", "text"], ["peer_public_key", "text"], ["peer_name", "text"],
|
||||
["comment", "text"], ["created_at", "ts"], ["updated_at", "ts"],
|
||||
]},
|
||||
{ table: "alert_groups", columns: [
|
||||
["id", "text"], ["name", "text"], ["combine_mode", "text"], ["enabled", "bool"],
|
||||
["cooldown_override", "text"], ["created_at", "ts"], ["updated_at", "ts"],
|
||||
]},
|
||||
{ table: "alert_rules", columns: [
|
||||
["id", "text"], ["name", "text"], ["type", "text"], ["target", "text"], ["condition", "text"],
|
||||
["severity", "text"], ["enabled", "bool"], ["cooldown", "text"], ["rule_chat_id", "text"],
|
||||
["confirm_stability_sec", "int"], ["recovery_mode", "text"], ["recovery_stability_sec", "int"],
|
||||
["group_id", "text"], ["created_at", "ts"], ["updated_at", "ts"],
|
||||
]},
|
||||
{ table: "alert_rule_targets", columns: [
|
||||
["id", "text"], ["rule_id", "text"], ["target", "text"], ["sort_index", "int"],
|
||||
]},
|
||||
{ table: "alert_rule_conditions", columns: [
|
||||
["id", "text"], ["rule_id", "text"], ["condition_line", "text"], ["sort_index", "int"],
|
||||
]},
|
||||
{ table: "alert_engine_state", columns: [
|
||||
["scope_key", "text"], ["last_fired_at", "text"], ["last_payload_hash", "text"],
|
||||
]},
|
||||
{ table: "alert_engine_prev_live", columns: [
|
||||
["kind", "text"], ["payload_json", "json"], ["updated_at", "ts"],
|
||||
]},
|
||||
{ table: "alert_engine_confirm_pending", columns: [
|
||||
["rule_id", "text"], ["payload_hash", "text"], ["since_at", "ts"],
|
||||
]},
|
||||
{ table: "alert_history", columns: [
|
||||
["id", "text"], ["rule_id", "text"], ["group_id", "text"], ["rule_name", "text"],
|
||||
["severity", "text"], ["message", "text"], ["sent_ok", "bool"], ["fired_at", "ts"],
|
||||
]},
|
||||
{ table: "alert_outbox", columns: [
|
||||
["id", "text"], ["dedupe_key", "text"], ["channel", "text"], ["status", "text"],
|
||||
["retry_count", "int"], ["max_retries", "int"], ["next_attempt_at", "ts"],
|
||||
["payload_json", "json"], ["created_at", "ts"], ["sent_at", "ts"], ["last_error", "text"],
|
||||
]},
|
||||
{ table: "certificate_issue_jobs", columns: [
|
||||
["id", "text"], ["status", "text"], ["step", "text"], ["source", "text"],
|
||||
["server_id", "bigint-id"], ["cert_name", "text"], ["domain_names", "json"],
|
||||
["key_type", "text"], ["trust_store", "text"], ["requested_at", "ts"],
|
||||
["started_at", "ts"], ["finished_at", "ts"], ["error", "text"],
|
||||
]},
|
||||
{ table: "backup_entries", columns: [
|
||||
["id", "text"], ["server_id", "bigint-id"], ["server_name", "text"], ["filename", "text"],
|
||||
["size_bytes", "int"], ["kind", "text"], ["notes", "text"], ["created_at", "ts"],
|
||||
]},
|
||||
{ table: "scheduler_runs", timeCol: "started_at", retentionDays: 30, columns: [
|
||||
["id", "text"], ["job_key", "text"], ["started_at", "ts"], ["finished_at", "ts"],
|
||||
["status", "text"], ["error", "text"], ["duration_ms", "int"], ["result_json", "json-null"],
|
||||
]},
|
||||
{ table: "events", timeCol: "created_at", retentionDays: 30, columns: [
|
||||
["id", "text"], ["created_at", "ts"], ["level", "text"], ["event_type", "text"],
|
||||
["source_module", "text"], ["title", "text"], ["message", "text"],
|
||||
["entity_type", "text"], ["entity_id", "text"], ["payload_json", "json-null"],
|
||||
]},
|
||||
{ table: "internet_path_snapshots", identity: true, timeCol: "sampled_at", retentionDays: 14, columns: [
|
||||
["id", "int"], ["sampled_at", "ts"], ["payload_json", "json"],
|
||||
]},
|
||||
]
|
||||
|
||||
function parseTs(value: unknown, strict: boolean, rejects: string[], ctx: string): string | null {
|
||||
if (value == null || value === "") return null
|
||||
const s = String(value)
|
||||
try {
|
||||
if (/^\d{4}-\d{2}-\d{2}T/.test(s)) return new Date(s).toISOString()
|
||||
if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}/.test(s)) {
|
||||
return new Date(`${s.replace(" ", "T")}Z`).toISOString()
|
||||
}
|
||||
const d = new Date(s)
|
||||
if (!Number.isNaN(d.getTime())) return d.toISOString()
|
||||
} catch {
|
||||
/* fallthrough */
|
||||
}
|
||||
const msg = `${ctx}: неразобранный timestamp ${s}`
|
||||
if (strict) throw new Error(msg)
|
||||
rejects.push(msg)
|
||||
return null
|
||||
}
|
||||
|
||||
function parseDate(value: unknown): string | null {
|
||||
if (value == null || value === "") return null
|
||||
return String(value).slice(0, 10)
|
||||
}
|
||||
|
||||
function parseBool(value: unknown): boolean {
|
||||
return value === true || value === 1 || value === "1" || value === "true"
|
||||
}
|
||||
|
||||
function parseJson(value: unknown, fallback: unknown): unknown {
|
||||
if (value == null || value === "") return fallback
|
||||
if (typeof value !== "string") return value
|
||||
try {
|
||||
return JSON.parse(value) as unknown
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
function parseInet(value: unknown): string | null {
|
||||
const s = String(value ?? "").trim()
|
||||
if (!s) return null
|
||||
return s
|
||||
}
|
||||
|
||||
function coerce(
|
||||
kind: ColKind,
|
||||
value: unknown,
|
||||
strict: boolean,
|
||||
rejects: string[],
|
||||
ctx: string,
|
||||
row?: Record<string, unknown>,
|
||||
): unknown {
|
||||
switch (kind) {
|
||||
case "ts":
|
||||
return parseTs(value, strict, rejects, ctx)
|
||||
case "date":
|
||||
return parseDate(value)
|
||||
case "bool":
|
||||
return parseBool(value)
|
||||
case "json":
|
||||
// Always JSON text. JS arrays must not go to node-pg as values — it encodes
|
||||
// them as PG arrays (`{...}`), which jsonb rejects (22P02).
|
||||
return JSON.stringify(parseJson(value, []))
|
||||
case "json-null":
|
||||
return value == null || value === "" ? null : JSON.stringify(parseJson(value, null))
|
||||
case "bigint-id": {
|
||||
const t = String(value ?? "").trim()
|
||||
if (!t) return null
|
||||
const n = Number(t)
|
||||
if (!Number.isFinite(n)) {
|
||||
const msg = `${ctx}: server_id ${t}`
|
||||
if (strict) throw new Error(msg)
|
||||
rejects.push(msg)
|
||||
return null
|
||||
}
|
||||
return n
|
||||
}
|
||||
case "int":
|
||||
if (value == null || value === "") return null
|
||||
return Number(value)
|
||||
case "num":
|
||||
if (value == null || value === "") return null
|
||||
return Number(value)
|
||||
case "text":
|
||||
return value == null ? "" : String(value)
|
||||
case "flags":
|
||||
return encodeTrafficFlags(parseBool(row?.running), parseBool(row?.disabled))
|
||||
case "inet":
|
||||
return parseInet(value)
|
||||
default:
|
||||
return value == null ? null : String(value)
|
||||
}
|
||||
}
|
||||
|
||||
function sqliteTableExists(sqlite: Database.Database, name: string): boolean {
|
||||
const row = sqlite.prepare(
|
||||
`SELECT 1 AS ok FROM sqlite_master WHERE type = 'table' AND name = ?`,
|
||||
).get(name) as { ok?: number } | undefined
|
||||
return Boolean(row)
|
||||
}
|
||||
|
||||
function placeholderFor(kind: ColKind, index: number): string {
|
||||
if (kind === "json" || kind === "json-null") return `$${index}::jsonb`
|
||||
if (kind === "inet") return `$${index}::inet`
|
||||
return `$${index}`
|
||||
}
|
||||
|
||||
async function precreatePartitions(
|
||||
sqlite: Database.Database,
|
||||
pool: Pool,
|
||||
spec: TableCopy,
|
||||
where: string,
|
||||
): Promise<void> {
|
||||
const part = specForParent(spec.table)
|
||||
if (!part || !spec.timeCol) return
|
||||
const bounds = sqlite.prepare(
|
||||
`SELECT MIN(${spec.timeCol}) AS a, MAX(${spec.timeCol}) AS b FROM ${spec.table}${where}`,
|
||||
).get() as { a?: unknown; b?: unknown }
|
||||
if (bounds?.a == null || bounds?.b == null) return
|
||||
const isDate = spec.columns.find((c) => c[0] === spec.timeCol)?.[1] === "date"
|
||||
const fromIso = isDate
|
||||
? `${String(bounds.a).slice(0, 10)}T00:00:00Z`
|
||||
: parseTs(bounds.a, false, [], spec.table)
|
||||
const toIso = isDate
|
||||
? `${String(bounds.b).slice(0, 10)}T00:00:00Z`
|
||||
: parseTs(bounds.b, false, [], spec.table)
|
||||
if (!fromIso || !toIso) return
|
||||
await ensurePartitionsBetween(pool, spec.table, part.kind, new Date(fromIso), new Date(toIso))
|
||||
}
|
||||
|
||||
async function copyTable(
|
||||
sqlite: Database.Database,
|
||||
pool: Pool,
|
||||
spec: TableCopy,
|
||||
opts: { strict: boolean; fullHistory: boolean; rejects: string[] },
|
||||
): Promise<{ sqlite: number; copied: number; skipped: number }> {
|
||||
if (!sqliteTableExists(sqlite, spec.table)) {
|
||||
return { sqlite: 0, copied: 0, skipped: 0 }
|
||||
}
|
||||
let where = ""
|
||||
if (spec.timeCol && spec.retentionDays && !opts.fullHistory) {
|
||||
const cutoff = new Date(Date.now() - spec.retentionDays * 86400_000).toISOString()
|
||||
where = ` WHERE ${spec.timeCol} >= '${cutoff.replace("T", " ").slice(0, 19)}' OR ${spec.timeCol} >= '${cutoff}'`
|
||||
}
|
||||
const total = (sqlite.prepare(`SELECT COUNT(*) AS c FROM ${spec.table}${where}`).get() as { c: number }).c
|
||||
await precreatePartitions(sqlite, pool, spec, where)
|
||||
const cols = spec.columns.map(([c]) => c)
|
||||
const conflictSql = spec.upsert
|
||||
? `ON CONFLICT (id) DO UPDATE SET ${cols.filter((c) => c !== "id").map((c) => `${c} = EXCLUDED.${c}`).join(", ")}`
|
||||
: `ON CONFLICT DO NOTHING`
|
||||
let copied = 0
|
||||
let skipped = 0
|
||||
const stmt = sqlite.prepare(`SELECT * FROM ${spec.table}${where}`)
|
||||
const batch: unknown[][] = []
|
||||
const flush = async () => {
|
||||
if (batch.length === 0) return
|
||||
const n = spec.columns.length
|
||||
const valuesSql = batch.map((_, i) =>
|
||||
`(${spec.columns.map(([, kind], j) => placeholderFor(kind, i * n + j + 1)).join(", ")})`,
|
||||
).join(", ")
|
||||
const insertSql = `INSERT INTO ${spec.table} (${cols.join(", ")}) VALUES ${valuesSql} ${conflictSql}`
|
||||
try {
|
||||
await withPartitionWrite(async ({ client }) => {
|
||||
await client.query(insertSql, batch.flat())
|
||||
}, { connect: () => pool.connect() })
|
||||
// Count only confirmed commits. Unknown COMMIT must escape the ETL row
|
||||
// rejection/fallback lane and must never replay the source batch.
|
||||
copied += batch.length
|
||||
} catch (err) {
|
||||
if (isPartitionWriteOutcomeUnknown(err)) throw err
|
||||
const detail = err instanceof Error ? err.message : String(err)
|
||||
throw new Error(`${spec.table}: ${detail}`)
|
||||
} finally {
|
||||
batch.length = 0
|
||||
}
|
||||
}
|
||||
for (const row of stmt.iterate() as Iterable<Record<string, unknown>>) {
|
||||
try {
|
||||
const values = spec.columns.map(([col, kind]) =>
|
||||
coerce(kind, row[col], opts.strict, opts.rejects, `${spec.table}.${col}`, row),
|
||||
)
|
||||
if (spec.table === "certificate_issue_jobs" && values[4] == null) {
|
||||
skipped += 1
|
||||
continue
|
||||
}
|
||||
batch.push(values)
|
||||
if (batch.length >= INSERT_CHUNK) await flush()
|
||||
} catch (err) {
|
||||
if (isPartitionWriteOutcomeUnknown(err)) throw err
|
||||
skipped += 1
|
||||
const msg = `${spec.table}: ${err instanceof Error ? err.message : String(err)}`
|
||||
opts.rejects.push(msg)
|
||||
if (opts.strict) throw err
|
||||
}
|
||||
}
|
||||
await flush()
|
||||
if (spec.identity) await resetSqliteImportSequence(pool, spec.table)
|
||||
return { sqlite: total, copied, skipped }
|
||||
}
|
||||
|
||||
export function sqliteFileLooksPresent(path: string): boolean {
|
||||
if (!existsSync(path) || path === ":memory:") return false
|
||||
try {
|
||||
const buf = readFileSync(path)
|
||||
return buf.subarray(0, 16).toString("utf8").startsWith("SQLite format 3")
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export async function shouldImportSqlite(pool: Pool, sqlitePath: string): Promise<boolean> {
|
||||
if (!sqliteFileLooksPresent(sqlitePath)) return false
|
||||
const marker = await pool.query<{ sqlite_imported_at: string | null }>(
|
||||
`SELECT sqlite_imported_at FROM data_migration WHERE id = 1`,
|
||||
)
|
||||
if (marker.rows[0]?.sqlite_imported_at) return false
|
||||
const servers = await pool.query<{ c: string }>(`SELECT COUNT(*)::text AS c FROM servers`)
|
||||
if (Number(servers.rows[0]?.c ?? 0) > 0) {
|
||||
console.warn(
|
||||
"SQLite → PostgreSQL: повтор недописанного импорта (маркера нет, servers уже не пустые)",
|
||||
)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
export async function importSqliteToPostgres(
|
||||
pool: Pool,
|
||||
sqlitePath: string,
|
||||
opts?: { dryRun?: boolean; strict?: boolean; fullHistory?: boolean },
|
||||
): Promise<ImportReport> {
|
||||
const started = Date.now()
|
||||
const rejects: string[] = []
|
||||
const sqlite = new Database(sqlitePath, { readonly: true, fileMustExist: true })
|
||||
try {
|
||||
sqlite.pragma("journal_mode = WAL")
|
||||
} catch {
|
||||
/* readonly */
|
||||
}
|
||||
const sha = createHash("sha256").update(readFileSync(sqlitePath)).digest("hex")
|
||||
const report: ImportReport = {
|
||||
sqlitePath,
|
||||
sqliteSha256: sha,
|
||||
tables: {},
|
||||
rejects,
|
||||
durationMs: 0,
|
||||
}
|
||||
const fullHistory = opts?.fullHistory ?? env.sqliteImportFullHistory
|
||||
const strict = opts?.strict ?? true
|
||||
if (opts?.dryRun) {
|
||||
for (const spec of TABLES) {
|
||||
if (!sqliteTableExists(sqlite, spec.table)) {
|
||||
report.tables[spec.table] = { sqlite: 0, copied: 0, skipped: 0 }
|
||||
continue
|
||||
}
|
||||
const c = (sqlite.prepare(`SELECT COUNT(*) AS c FROM ${spec.table}`).get() as { c: number }).c
|
||||
report.tables[spec.table] = { sqlite: c, copied: 0, skipped: 0 }
|
||||
}
|
||||
sqlite.close()
|
||||
report.durationMs = Date.now() - started
|
||||
return report
|
||||
}
|
||||
try {
|
||||
for (const spec of TABLES) {
|
||||
console.log(`SQLite → PostgreSQL: таблица ${spec.table}`)
|
||||
report.tables[spec.table] = await copyTable(sqlite, pool, spec, { strict, fullHistory, rejects })
|
||||
}
|
||||
await writeSqliteImportMarker(pool, sqlitePath, sha, JSON.stringify(report.tables))
|
||||
report.durationMs = Date.now() - started
|
||||
if (strict && rejects.length > 0) {
|
||||
throw new Error(`SQLite import strict: ${rejects.length} rejects\n${rejects.slice(0, 20).join("\n")}`)
|
||||
}
|
||||
return report
|
||||
} finally {
|
||||
sqlite.close()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import assert from "node:assert/strict"
|
||||
|
||||
/** node-pg encodes a JS array as a PostgreSQL array (`{...}`), not JSON (`[...]`). */
|
||||
{
|
||||
const peers = [{ endpoint: "msk-gw02.rtnt.top:13232" }]
|
||||
const asJson = JSON.stringify(peers)
|
||||
assert.equal(asJson.startsWith("["), true)
|
||||
assert.equal(asJson.includes("msk-gw02.rtnt.top:13232"), true)
|
||||
}
|
||||
|
||||
console.log("sqlite-json.test.ts: ok")
|
||||
@@ -0,0 +1,22 @@
|
||||
import assert from "node:assert/strict"
|
||||
import {
|
||||
decodeTrafficSampleFlags,
|
||||
encodeTrafficFlags,
|
||||
trafficFlagDisabled,
|
||||
trafficFlagRunning,
|
||||
} from "./traffic-flags.js"
|
||||
|
||||
assert.equal(encodeTrafficFlags(true, false), 1)
|
||||
assert.equal(encodeTrafficFlags(false, true), 2)
|
||||
assert.equal(encodeTrafficFlags(true, true), 3)
|
||||
assert.equal(encodeTrafficFlags(false, false), 0)
|
||||
assert.equal(trafficFlagRunning(1), true)
|
||||
assert.equal(trafficFlagDisabled(1), false)
|
||||
assert.deepEqual(decodeTrafficSampleFlags(1), { running: true, disabled: false, countersKnown: false, statusKnown:false })
|
||||
assert.deepEqual(decodeTrafficSampleFlags(0), { running: false, disabled: false, countersKnown: false, statusKnown:false })
|
||||
assert.deepEqual(decodeTrafficSampleFlags(undefined), { running: false, disabled: false, countersKnown: false, statusKnown:false })
|
||||
assert.equal(encodeTrafficFlags(true,false,true),5)
|
||||
assert.deepEqual(decodeTrafficSampleFlags(5), {running:true,disabled:false,countersKnown:true,statusKnown:false})
|
||||
assert.deepEqual(decodeTrafficSampleFlags(encodeTrafficFlags(true,false,true,true)),{running:true,disabled:false,countersKnown:true,statusKnown:true})
|
||||
|
||||
console.log("traffic-flags.test.ts: ok")
|
||||
@@ -0,0 +1,40 @@
|
||||
export const TRAFFIC_FLAG_RUNNING = 1
|
||||
export const TRAFFIC_FLAG_DISABLED = 2
|
||||
export const TRAFFIC_FLAG_COUNTERS_KNOWN = 4
|
||||
export const TRAFFIC_FLAG_STATUS_KNOWN = 8
|
||||
export const TRAFFIC_FLAG_CONTINUITY_KNOWN = 16
|
||||
export const TRAFFIC_FLAG_CONTINUITY_RECORDED = 32
|
||||
|
||||
export function encodeTrafficFlags(running: boolean, disabled: boolean, countersKnown = false, statusKnown = false, continuityKnown?: boolean): number {
|
||||
return (running ? TRAFFIC_FLAG_RUNNING : 0) | (disabled ? TRAFFIC_FLAG_DISABLED : 0)
|
||||
| (countersKnown ? TRAFFIC_FLAG_COUNTERS_KNOWN : 0)
|
||||
| (statusKnown ? TRAFFIC_FLAG_STATUS_KNOWN : 0)
|
||||
| (continuityKnown === undefined ? 0 : TRAFFIC_FLAG_CONTINUITY_RECORDED | (continuityKnown ? TRAFFIC_FLAG_CONTINUITY_KNOWN : 0))
|
||||
}
|
||||
|
||||
/** Undefined retains the documented legacy boundary; new producers explicitly mark both outcomes. */
|
||||
export function trafficCounterContinuity(flags: number): boolean | undefined {
|
||||
return flags & TRAFFIC_FLAG_CONTINUITY_RECORDED ? !!(flags & TRAFFIC_FLAG_CONTINUITY_KNOWN) : undefined
|
||||
}
|
||||
|
||||
export function trafficFlagRunning(flags: number | null | undefined): boolean {
|
||||
return ((Number(flags) || 0) & TRAFFIC_FLAG_RUNNING) !== 0
|
||||
}
|
||||
|
||||
export function trafficFlagDisabled(flags: number | null | undefined): boolean {
|
||||
return ((Number(flags) || 0) & TRAFFIC_FLAG_DISABLED) !== 0
|
||||
}
|
||||
|
||||
export function decodeTrafficSampleFlags(flags: number | null | undefined): {
|
||||
running: boolean
|
||||
disabled: boolean
|
||||
countersKnown: boolean
|
||||
statusKnown: boolean
|
||||
} {
|
||||
return {
|
||||
running: trafficFlagRunning(flags),
|
||||
disabled: trafficFlagDisabled(flags),
|
||||
countersKnown: ((Number(flags) || 0) & TRAFFIC_FLAG_COUNTERS_KNOWN) !== 0,
|
||||
statusKnown: ((Number(flags) || 0) & TRAFFIC_FLAG_STATUS_KNOWN) !== 0,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
import Fastify, { type FastifyError, type FastifyInstance } from "fastify"
|
||||
import cors from "@fastify/cors"
|
||||
import { serializerCompiler, validatorCompiler } from "@fastify/type-provider-zod"
|
||||
import { monitorEventLoopDelay } from "node:perf_hooks"
|
||||
import { env } from "./config.js"
|
||||
import { initDatabase } from "./db/bootstrap.js"
|
||||
import { closePool } from "./db/index.js"
|
||||
import authPlugin, { requireApiAuth } from "./plugins/auth.js"
|
||||
import { installDatabaseRequestGate } from "./plugins/database-request-gate.js"
|
||||
import serversRoutes from "./routes/servers.js"
|
||||
import bgpRoutes from "./routes/bgp.js"
|
||||
import ospfRoutes from "./routes/ospf.js"
|
||||
import execRoutes from "./routes/exec.js"
|
||||
import filtersRoutes from "./routes/filters.js"
|
||||
import recursiveRoutes from "./routes/recursive-routes.js"
|
||||
import trafficRoutes from "./routes/traffic.js"
|
||||
import trafficFlowRoutes from "./routes/traffic-flow.js"
|
||||
import geoipRoutes from "./routes/geoip.js"
|
||||
import serversApiPingRoutes from "./routes/servers-api-ping.js"
|
||||
import uptimeRoutes from "./routes/uptime.js"
|
||||
import networkRoutes from "./routes/network.js"
|
||||
import internetPathRoutes from "./routes/internet-path.js"
|
||||
import evobgpRoutes from "./routes/evobgp.js"
|
||||
import { createStorageProbeRoutes } from "./routes/probe-storage-handler.js"
|
||||
import schedulerRoutes from "./routes/scheduler.js"
|
||||
import sidebarCountsRoutes from "./routes/sidebar-counts.js"
|
||||
import alertsRoutes from "./routes/alerts.js"
|
||||
import backupsRoutes from "./routes/backups.js"
|
||||
import certificatesRoutes from "./routes/certificates.js"
|
||||
import systemDatabaseRoutes from "./routes/system-database.js"
|
||||
import eventsRoutes from "./routes/events.js"
|
||||
import wireguardRoutes from "./routes/wireguard.js"
|
||||
import ipsecRoutes from "./routes/ipsec.js"
|
||||
import vxlanRoutes from "./routes/vxlan.js"
|
||||
import containersRoutes from "./routes/containers.js"
|
||||
import firewallRoutes from "./routes/firewall.js"
|
||||
import greRoutes from "./routes/gre.js"
|
||||
import usersRoutes from "./routes/users.js"
|
||||
import statisticsRoutes from "./routes/statistics.js"
|
||||
import logsRoutes from "./routes/logs.js"
|
||||
import mcpRoutes from "./routes/mcp.js"
|
||||
import mcpTransport from "./routes/mcp-transport.js"
|
||||
import { mcpOauth } from "./routes/mcp-oauth.js"
|
||||
import { mcpPortal } from "./routes/mcp-portal.js"
|
||||
import { startMcpWorkers } from "./modules/mcp/workers.js"
|
||||
import { refreshScheduler, stopScheduler } from "./services/scheduler.js"
|
||||
import { getFlowWorkerHealth, startTrafficFlowListener, stopTrafficFlowListener } from "./services/traffic-flow-ingest.js"
|
||||
import { agentSocket } from "./services/traffic-flow-agent-client.js"
|
||||
import { reconcileFlowHost, withFlowConfigurationLock } from "./services/traffic-flow-host-agent.js"
|
||||
import { initGeoip } from "./services/traffic-flow-geoip.js"
|
||||
|
||||
import { initializeAutomaticSchedulerRoot } from "./services/scheduler-automatic-root.js"
|
||||
|
||||
const eventLoopDelay = monitorEventLoopDelay({ resolution: 20 })
|
||||
eventLoopDelay.enable()
|
||||
|
||||
export async function buildApp(opts?: {
|
||||
logger?: boolean
|
||||
startScheduler?: boolean
|
||||
onRoute?: (route: { method: string | string[]; url: string }) => void
|
||||
}): Promise<FastifyInstance> {
|
||||
initializeAutomaticSchedulerRoot() // Explicit trusted bootstrap, including startScheduler:false.
|
||||
const usePrettyLogger =
|
||||
opts?.logger !== false && process.env.NODE_ENV !== "production"
|
||||
const app = Fastify({
|
||||
bodyLimit: 2 * 1024 * 1024,
|
||||
requestTimeout: 10 * 60 * 1000,
|
||||
logger:
|
||||
opts?.logger === false
|
||||
? false
|
||||
: usePrettyLogger
|
||||
? {
|
||||
transport: {
|
||||
target: "pino-pretty",
|
||||
options: {
|
||||
colorize: true,
|
||||
translateTime: "HH:MM:ss",
|
||||
ignore: "pid,hostname",
|
||||
},
|
||||
},
|
||||
}
|
||||
: true,
|
||||
})
|
||||
|
||||
app.setValidatorCompiler(validatorCompiler)
|
||||
if (opts?.onRoute) app.addHook("onRoute", opts.onRoute)
|
||||
app.setSerializerCompiler(serializerCompiler)
|
||||
|
||||
app.setErrorHandler((error: FastifyError, request, reply) => {
|
||||
const status = typeof error.statusCode === "number" && error.statusCode >= 400
|
||||
? error.statusCode
|
||||
: 500
|
||||
if (status >= 500) {
|
||||
request.log.error(error)
|
||||
return reply.status(status).send({ error: "Внутренняя ошибка сервера" })
|
||||
}
|
||||
const message = error instanceof Error ? error.message : "Ошибка запроса"
|
||||
return reply.status(status).send({ error: message })
|
||||
})
|
||||
|
||||
await app.register(cors, {
|
||||
origin: env.CORS_ORIGIN,
|
||||
exposedHeaders: ["Content-Disposition", "X-System-Database-Outcome"],
|
||||
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
||||
})
|
||||
|
||||
await app.register(authPlugin)
|
||||
|
||||
app.get("/health", async () => ({
|
||||
status: "ok",
|
||||
timestamp: new Date().toISOString(),
|
||||
version: process.env.APP_VERSION ?? "dev",
|
||||
eventLoopDelayMs: Math.round(eventLoopDelay.mean / 1e6),
|
||||
flowWorker: getFlowWorkerHealth(),
|
||||
}))
|
||||
|
||||
app.get("/api/auth/config", async () => ({
|
||||
required: env.authRequired,
|
||||
portal_url: env.authPortalUrl,
|
||||
issuer: env.authIssuer,
|
||||
}))
|
||||
|
||||
if (env.authRequired) {
|
||||
app.addHook("preHandler", requireApiAuth)
|
||||
}
|
||||
|
||||
installDatabaseRequestGate(app)
|
||||
await app.register(serversRoutes, { prefix: "/api/servers" })
|
||||
await app.register(bgpRoutes, { prefix: "/api" })
|
||||
await app.register(ospfRoutes, { prefix: "/api" })
|
||||
await app.register(execRoutes, { prefix: "/api" })
|
||||
await app.register(filtersRoutes, { prefix: "/api" })
|
||||
await app.register(recursiveRoutes, { prefix: "/api" })
|
||||
await app.register(trafficRoutes, { prefix: "/api" })
|
||||
await app.register(trafficFlowRoutes, { prefix: "/api" })
|
||||
await app.register(geoipRoutes, { prefix: "/api" })
|
||||
await app.register(serversApiPingRoutes, { prefix: "/api" })
|
||||
await app.register(uptimeRoutes, { prefix: "/api" })
|
||||
await app.register(networkRoutes, { prefix: "/api" })
|
||||
await app.register(internetPathRoutes, { prefix: "/api" })
|
||||
await app.register(evobgpRoutes, { prefix: "/api" })
|
||||
await app.register(createStorageProbeRoutes(), { prefix: "/api" })
|
||||
await app.register(schedulerRoutes, { prefix: "/api" })
|
||||
await app.register(sidebarCountsRoutes, { prefix: "/api" })
|
||||
await app.register(alertsRoutes, { prefix: "/api" })
|
||||
await app.register(backupsRoutes, { prefix: "/api" })
|
||||
await app.register(certificatesRoutes, { prefix: "/api" })
|
||||
await app.register(systemDatabaseRoutes, { prefix: "/api" })
|
||||
await app.register(eventsRoutes, { prefix: "/api" })
|
||||
await app.register(wireguardRoutes, { prefix: "/api" })
|
||||
await app.register(ipsecRoutes, { prefix: "/api" })
|
||||
await app.register(vxlanRoutes, { prefix: "/api" })
|
||||
await app.register(containersRoutes, { prefix: "/api" })
|
||||
await app.register(firewallRoutes, { prefix: "/api" })
|
||||
await app.register(greRoutes, { prefix: "/api" })
|
||||
await app.register(usersRoutes, { prefix: "/api" })
|
||||
await app.register(statisticsRoutes, { prefix: "/api" })
|
||||
await app.register(logsRoutes, { prefix: "/api" })
|
||||
await app.register(mcpRoutes, { prefix: "/api" })
|
||||
await app.register(mcpTransport)
|
||||
await app.register(mcpOauth)
|
||||
await app.register(mcpPortal)
|
||||
|
||||
if (opts?.startScheduler !== false) {
|
||||
const stopMcpWorkers = startMcpWorkers(() => app.log.warn("MCP: фоновая проверка не завершена; изменения не повторяются"))
|
||||
await refreshScheduler()
|
||||
await initGeoip()
|
||||
await startTrafficFlowListener()
|
||||
if (agentSocket()) {
|
||||
try { await withFlowConfigurationLock(reconcileFlowHost) }
|
||||
catch { app.log.warn("NetFlow: агент хоста не синхронизирован; проверьте состояние в настройках NetFlow") }
|
||||
}
|
||||
app.addHook("onClose", async () => {
|
||||
await stopMcpWorkers()
|
||||
stopScheduler()
|
||||
stopTrafficFlowListener()
|
||||
})
|
||||
}
|
||||
|
||||
return app
|
||||
}
|
||||
|
||||
const isMain =
|
||||
process.argv[1] &&
|
||||
(process.argv[1].endsWith("index.ts") || process.argv[1].endsWith("index.js"))
|
||||
|
||||
if (isMain) {
|
||||
try {
|
||||
await initDatabase()
|
||||
const app = await buildApp()
|
||||
let shuttingDown = false
|
||||
const shutdown = async (code: number) => {
|
||||
if (shuttingDown) return
|
||||
shuttingDown = true
|
||||
try {
|
||||
stopTrafficFlowListener()
|
||||
await app.close()
|
||||
await closePool()
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
} finally {
|
||||
process.exit(code)
|
||||
}
|
||||
}
|
||||
process.on("SIGTERM", () => { void shutdown(0) })
|
||||
process.on("SIGINT", () => { void shutdown(0) })
|
||||
process.on("uncaughtException", (err) => {
|
||||
console.error(err)
|
||||
void shutdown(1)
|
||||
})
|
||||
process.on("unhandledRejection", (reason) => {
|
||||
console.error(reason)
|
||||
void shutdown(1)
|
||||
})
|
||||
await app.listen({ port: env.PORT, host: "0.0.0.0" })
|
||||
console.log(
|
||||
`\n🚀 MikroTik Manager Backend running at http://localhost:${env.PORT}`,
|
||||
)
|
||||
console.log(` Docs / test: http://localhost:${env.PORT}/health`)
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { hasPermission, permissionForRequest } from "./permissions.js"
|
||||
|
||||
assert.equal(hasPermission(["mm:servers:write"], "mm:servers:read"), true)
|
||||
assert.equal(hasPermission(["mm:servers:admin"], "mm:servers:write"), true)
|
||||
assert.equal(hasPermission(["mm:servers:read"], "mm:servers:write"), false)
|
||||
|
||||
assert.equal(
|
||||
permissionForRequest("GET", "/api/servers"),
|
||||
"mm:servers:read",
|
||||
)
|
||||
assert.equal(
|
||||
permissionForRequest("POST", "/api/servers"),
|
||||
"mm:servers:write",
|
||||
)
|
||||
assert.equal(
|
||||
permissionForRequest("GET", "/api/system/database/backup"),
|
||||
"mm:settings:admin",
|
||||
)
|
||||
assert.equal(
|
||||
permissionForRequest("GET", "/api/statistics"),
|
||||
"mm:traffic:read",
|
||||
)
|
||||
assert.equal(
|
||||
permissionForRequest("POST", "/api/traffic/flow/purge"),
|
||||
"mm:traffic:write",
|
||||
)
|
||||
assert.equal(
|
||||
permissionForRequest("GET", "/api/unknown-thing"),
|
||||
"mm:dashboard:read",
|
||||
)
|
||||
assert.equal(
|
||||
permissionForRequest("GET", "/api/wireguard"),
|
||||
"mm:network:read",
|
||||
)
|
||||
assert.equal(
|
||||
permissionForRequest("POST", "/api/wireguard/interfaces"),
|
||||
"mm:network:write",
|
||||
)
|
||||
assert.equal(
|
||||
permissionForRequest("GET", "/api/firewall/all"),
|
||||
"mm:network:read",
|
||||
)
|
||||
assert.equal(
|
||||
permissionForRequest("GET", "/api/gre/tunnels"),
|
||||
"mm:network:read",
|
||||
)
|
||||
assert.equal(
|
||||
permissionForRequest("POST", "/api/gre/tunnels"),
|
||||
"mm:network:write",
|
||||
)
|
||||
assert.equal(
|
||||
permissionForRequest("GET", "/api/users"),
|
||||
"mm:users:read",
|
||||
)
|
||||
assert.equal(
|
||||
permissionForRequest("POST", "/api/users"),
|
||||
"mm:users:write",
|
||||
)
|
||||
assert.equal(hasPermission(["mm:settings:admin"], "mm:users:read"), true)
|
||||
assert.equal(hasPermission(["mm:settings:admin"], "mm:users:write"), true)
|
||||
assert.equal(hasPermission(["mm:dashboard:read"], "mm:users:write"), false)
|
||||
|
||||
assert.equal(permissionForRequest("GET","/api/servers/1/ospf"),"mm:network:read")
|
||||
assert.equal(permissionForRequest("POST","/api/servers/1/ospf/optimize/preview?x=1"),"mm:network:read")
|
||||
assert.equal(permissionForRequest("POST","/api/servers/1/ospf/optimize"),"mm:network:write")
|
||||
assert.equal(permissionForRequest("POST","/api/servers/1/ospf/optimize/preview/extra"),"mm:network:write")
|
||||
assert.equal(permissionForRequest("PUT","/api/servers/1"),"mm:servers:write")
|
||||
assert.equal(hasPermission(["mm:settings:admin"], "mm:mcp:read"), true)
|
||||
assert.equal(hasPermission(["mm:settings:admin"], "mm:mcp:approve"), true)
|
||||
assert.equal(hasPermission(["mm:settings:admin"], "mm:mcp:pause"), true)
|
||||
assert.equal(hasPermission(["mm:mcp:read"], "mm:mcp:admin"), false)
|
||||
assert.equal(permissionForRequest("GET", "/api/mcp/catalog"), "mm:mcp:read")
|
||||
assert.equal(permissionForRequest("POST", "/api/mcp/policy/preview"), "mm:mcp:admin")
|
||||
assert.equal(permissionForRequest("GET", "/api/mcp/not-yet-implemented"), "mm:mcp:admin")
|
||||
assert.equal(permissionForRequest("GET", "/api/mcp-other"), "mm:dashboard:read")
|
||||
console.log("permissions.test.ts: ok")
|
||||
@@ -0,0 +1,225 @@
|
||||
/**
|
||||
* Portal JWT RBAC helpers (mirrors @authportal/shared hasPermission).
|
||||
* Format: mm:<section>:<read|write|admin>
|
||||
*/
|
||||
|
||||
export type AuthUser = {
|
||||
id: string
|
||||
email: string
|
||||
name: string
|
||||
apps: string[]
|
||||
permissions: string[]
|
||||
isAdmin?: boolean
|
||||
expiresAt?: number
|
||||
issuedAt?: number
|
||||
}
|
||||
|
||||
export function hasPermission(
|
||||
granted: readonly string[],
|
||||
required: string,
|
||||
): boolean {
|
||||
if (granted.includes(required)) return true
|
||||
if ((required.startsWith("mm:users:") || required.startsWith("mm:mcp:")) && granted.includes("mm:settings:admin")) {
|
||||
return true
|
||||
}
|
||||
const parts = required.split(":")
|
||||
if (parts.length !== 3) return false
|
||||
const [app, section, action] = parts
|
||||
if (action === "read") {
|
||||
return (
|
||||
granted.includes(`${app}:${section}:write`) ||
|
||||
granted.includes(`${app}:${section}:admin`)
|
||||
)
|
||||
}
|
||||
if (action === "write") {
|
||||
return granted.includes(`${app}:${section}:admin`)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type Rule = {
|
||||
methods: string[]
|
||||
match: (path: string) => boolean
|
||||
permission: string
|
||||
}
|
||||
|
||||
const RULES: Rule[] = [
|
||||
{ methods: ["GET"], match: p => /^\/api\/mcp\/oauth\/requests\//.test(p), permission: "mm:mcp:read" },
|
||||
{ methods: ["POST"], match: p => p === "/api/mcp/oauth/consent", permission: "mm:mcp:read" },
|
||||
{ methods: ["POST"], match: p => /^\/api\/mcp\/connections\/[^/]+\/revalidate$/.test(p), permission: "mm:mcp:read" },
|
||||
{ methods: ["GET"], match: p => p === "/api/mcp/catalog", permission: "mm:mcp:read" },
|
||||
{ methods: ["GET"], match: p => p.startsWith("/api/mcp/devices/"), permission: "mm:mcp:read" },
|
||||
{ methods: ["GET"], match: p => /^\/api\/mcp\/(?:overview|connections|requests|plans|operations|protections|drift|audit|artifacts|routing-profiles)(?:\/|$)/.test(p), permission: "mm:mcp:read" },
|
||||
{ methods: ["POST"], match: p => /^\/api\/mcp\/plans\/(?:[^/]+)\/decision$/.test(p), permission: "mm:mcp:approve" },
|
||||
{ methods: ["POST"], match: p => ["/api/mcp/pause", "/api/mcp/stop-automation"].includes(p), permission: "mm:mcp:pause" },
|
||||
{ methods: ["GET", "POST", "PUT", "PATCH", "DELETE"], match: p => p === "/api/mcp" || p.startsWith("/api/mcp/"), permission: "mm:mcp:admin" },
|
||||
{ methods: ["GET", "PUT"], match: p => p === "/api/logs/settings", permission: "mm:settings:admin" },
|
||||
{ methods: ["GET"], match: p => p === "/api/logs" || p.startsWith("/api/logs/"), permission: "mm:servers:read" },
|
||||
{ methods: ["GET"], match: path => path === "/api/traffic/flow/host-files", permission: "mm:traffic:write" },
|
||||
{ methods: ["GET"], match: path => path === "/api/geoip", permission: "mm:traffic:read" },
|
||||
{ methods: ["PUT", "POST"], match: path => path === "/api/geoip" || path === "/api/geoip/update", permission: "mm:traffic:write" },
|
||||
{
|
||||
methods: ["GET"],
|
||||
match: p => /^\/api\/servers\/(?:\d+|:[A-Za-z]\w*)\/ospf(?:\/|$)/.test(p),
|
||||
permission: "mm:network:read",
|
||||
},
|
||||
{
|
||||
methods: ["POST"],
|
||||
match: p => /^\/api\/servers\/(?:\d+|:[A-Za-z]\w*)\/ospf\/optimize\/preview$/.test(p),
|
||||
permission: "mm:network:read",
|
||||
},
|
||||
{
|
||||
methods: ["POST", "PUT", "PATCH", "DELETE"],
|
||||
match: p => /^\/api\/servers\/(?:\d+|:[A-Za-z]\w*)\/ospf(?:\/|$)/.test(p),
|
||||
permission: "mm:network:write",
|
||||
},
|
||||
|
||||
{
|
||||
methods: ["GET", "POST", "PUT", "PATCH", "DELETE"],
|
||||
match: (p) =>
|
||||
p.startsWith("/api/system") ||
|
||||
p.startsWith("/api/scheduler") ||
|
||||
p.startsWith("/api/evobgp"),
|
||||
permission: "mm:settings:admin",
|
||||
},
|
||||
{
|
||||
methods: ["GET"],
|
||||
match: (p) => p.startsWith("/api/users"),
|
||||
permission: "mm:users:read",
|
||||
},
|
||||
{
|
||||
methods: ["POST", "PUT", "PATCH", "DELETE"],
|
||||
match: (p) => p.startsWith("/api/users"),
|
||||
permission: "mm:users:write",
|
||||
},
|
||||
{
|
||||
methods: ["GET"],
|
||||
match: (p) => p.startsWith("/api/sidebar-counts") || p.startsWith("/api/events"),
|
||||
permission: "mm:dashboard:read",
|
||||
},
|
||||
{
|
||||
methods: ["GET"],
|
||||
match: (p) => p.startsWith("/api/servers"),
|
||||
permission: "mm:servers:read",
|
||||
},
|
||||
{
|
||||
methods: ["POST", "PUT", "PATCH", "DELETE"],
|
||||
match: (p) => p.startsWith("/api/servers"),
|
||||
permission: "mm:servers:write",
|
||||
},
|
||||
{
|
||||
methods: ["GET"],
|
||||
match: (p) => p.startsWith("/api/filters"),
|
||||
permission: "mm:filters:read",
|
||||
},
|
||||
{
|
||||
methods: ["POST", "PUT", "PATCH", "DELETE"],
|
||||
match: (p) => p.startsWith("/api/filters"),
|
||||
permission: "mm:filters:write",
|
||||
},
|
||||
{
|
||||
methods: ["GET"],
|
||||
match: (p) => p.startsWith("/api/bgp"),
|
||||
permission: "mm:bgp:read",
|
||||
},
|
||||
{
|
||||
methods: ["POST", "PUT", "PATCH", "DELETE"],
|
||||
match: (p) => p.startsWith("/api/bgp"),
|
||||
permission: "mm:bgp:write",
|
||||
},
|
||||
{
|
||||
methods: ["GET"],
|
||||
match: (p) => p.startsWith("/api/uptime"),
|
||||
permission: "mm:uptime:read",
|
||||
},
|
||||
{
|
||||
methods: ["POST", "PUT", "PATCH", "DELETE"],
|
||||
match: (p) => p.startsWith("/api/uptime"),
|
||||
permission: "mm:uptime:write",
|
||||
},
|
||||
{
|
||||
methods: ["GET"],
|
||||
match: (p) => p.startsWith("/api/traffic") || p.startsWith("/api/statistics"),
|
||||
permission: "mm:traffic:read",
|
||||
},
|
||||
{
|
||||
methods: ["POST", "PUT", "PATCH", "DELETE"],
|
||||
match: (p) => p.startsWith("/api/traffic"),
|
||||
permission: "mm:traffic:write",
|
||||
},
|
||||
{
|
||||
methods: ["GET"],
|
||||
match: (p) => p.startsWith("/api/alerts"),
|
||||
permission: "mm:alerts:read",
|
||||
},
|
||||
{
|
||||
methods: ["POST", "PUT", "PATCH", "DELETE"],
|
||||
match: (p) => p.startsWith("/api/alerts"),
|
||||
permission: "mm:alerts:write",
|
||||
},
|
||||
{
|
||||
methods: ["GET"],
|
||||
match: (p) => p.startsWith("/api/backups"),
|
||||
permission: "mm:backups:read",
|
||||
},
|
||||
{
|
||||
methods: ["POST", "PUT", "PATCH", "DELETE"],
|
||||
match: (p) => p.startsWith("/api/backups"),
|
||||
permission: "mm:backups:write",
|
||||
},
|
||||
{
|
||||
methods: ["GET"],
|
||||
match: (p) => p.startsWith("/api/certificates"),
|
||||
permission: "mm:certificates:read",
|
||||
},
|
||||
{
|
||||
methods: ["POST", "PUT", "PATCH", "DELETE"],
|
||||
match: (p) => p.startsWith("/api/certificates"),
|
||||
permission: "mm:certificates:write",
|
||||
},
|
||||
{
|
||||
methods: ["GET"],
|
||||
match: (p) =>
|
||||
p.startsWith("/api/network") ||
|
||||
p.startsWith("/api/ospf") ||
|
||||
p.startsWith("/api/recursive") ||
|
||||
p.startsWith("/api/probes") ||
|
||||
p.startsWith("/api/internet-path") ||
|
||||
p.startsWith("/api/exec") ||
|
||||
p.startsWith("/api/wireguard") ||
|
||||
p.startsWith("/api/ipsec") ||
|
||||
p.startsWith("/api/firewall") ||
|
||||
p.startsWith("/api/gre"),
|
||||
permission: "mm:network:read",
|
||||
},
|
||||
{
|
||||
methods: ["POST", "PUT", "PATCH", "DELETE"],
|
||||
match: (p) =>
|
||||
p.startsWith("/api/network") ||
|
||||
p.startsWith("/api/ospf") ||
|
||||
p.startsWith("/api/recursive") ||
|
||||
p.startsWith("/api/probes") ||
|
||||
p.startsWith("/api/internet-path") ||
|
||||
p.startsWith("/api/exec") ||
|
||||
p.startsWith("/api/wireguard") ||
|
||||
p.startsWith("/api/ipsec") ||
|
||||
p.startsWith("/api/firewall") ||
|
||||
p.startsWith("/api/gre"),
|
||||
permission: "mm:network:write",
|
||||
},
|
||||
]
|
||||
|
||||
/** Resolve required permission for method+path, or null if public / unknown. */
|
||||
export function permissionForRequest(
|
||||
method: string,
|
||||
path: string,
|
||||
): string | null {
|
||||
const m = method.toUpperCase() === "HEAD" ? "GET" : method.toUpperCase()
|
||||
const pathname = path.split("?")[0] ?? path
|
||||
for (const rule of RULES) {
|
||||
if (!rule.methods.includes(m)) continue
|
||||
if (rule.match(pathname)) return rule.permission
|
||||
}
|
||||
if (pathname.startsWith("/api/")) return "mm:dashboard:read"
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
export const PRODUCT_NAME = "MikrotikManager"
|
||||
export const LEGACY_PRODUCT_NAME = "RouterLists"
|
||||
|
||||
export function hasManagedCommentPrefix(comment: string): boolean {
|
||||
const value = comment.trim()
|
||||
return value.startsWith(`${PRODUCT_NAME}:`) || value.startsWith(`${LEGACY_PRODUCT_NAME}:`)
|
||||
}
|
||||
|
||||
export function hasManagedRecursiveComment(comment: string): boolean {
|
||||
const value = comment.trim()
|
||||
return (
|
||||
value.startsWith(`${PRODUCT_NAME}:recursive`) ||
|
||||
value.startsWith(`${LEGACY_PRODUCT_NAME}:recursive`)
|
||||
)
|
||||
}
|
||||
|
||||
export function managedComment(label: string): string {
|
||||
return `${PRODUCT_NAME}: ${label}`
|
||||
}
|
||||
|
||||
export function managedRecursiveComment(comment?: string | null): string {
|
||||
const stripped = stripManagedRecursiveComment(comment ?? "")
|
||||
return stripped ? `${PRODUCT_NAME}:recursive ${stripped}` : `${PRODUCT_NAME}:recursive`
|
||||
}
|
||||
|
||||
const LEGACY_RECURSIVE_PREFIX = /^recursive:\s*/i
|
||||
|
||||
export function stripManagedRecursiveComment(comment: string): string {
|
||||
const value = comment.trim()
|
||||
if (!value) return ""
|
||||
const managedPrefixes = [
|
||||
`${PRODUCT_NAME}:recursive`,
|
||||
`${LEGACY_PRODUCT_NAME}:recursive`,
|
||||
]
|
||||
for (const prefix of managedPrefixes) {
|
||||
if (value.startsWith(prefix)) return value.slice(prefix.length).trim()
|
||||
}
|
||||
if (LEGACY_RECURSIVE_PREFIX.test(value)) {
|
||||
return value.replace(LEGACY_RECURSIVE_PREFIX, "").trim()
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
/** Owned recursive route: MM/legacy prefix or old `recursive:` mask. */
|
||||
export function isOwnedRecursiveComment(comment: string | undefined): boolean {
|
||||
if (!comment) return false
|
||||
const value = comment.trim()
|
||||
return hasManagedRecursiveComment(value) || LEGACY_RECURSIVE_PREFIX.test(value)
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { and, desc, eq, gte, lte } from "drizzle-orm"
|
||||
import { db } from "../../../db/index.js"
|
||||
import { events } from "../../../db/schema.js"
|
||||
import type { EventItem, EventLevel, EventSourceModule } from "@mmapp/contracts/events"
|
||||
|
||||
export type EventInsertInput = {
|
||||
id: string
|
||||
createdAt: string
|
||||
level: EventLevel
|
||||
eventType: string
|
||||
sourceModule: EventSourceModule
|
||||
title: string
|
||||
message: string
|
||||
entityType?: string
|
||||
entityId?: string
|
||||
payload?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export type ListEventsParams = {
|
||||
limit: number
|
||||
level?: EventLevel
|
||||
sourceModule?: EventSourceModule
|
||||
from?: string
|
||||
to?: string
|
||||
}
|
||||
|
||||
import { parseJsonObject } from "../../../db/json.js"
|
||||
|
||||
function mapEventRow(row: typeof events.$inferSelect): EventItem {
|
||||
return {
|
||||
id: row.id,
|
||||
createdAt: row.createdAt,
|
||||
level: row.level,
|
||||
eventType: row.eventType,
|
||||
sourceModule: row.sourceModule as EventSourceModule,
|
||||
title: row.title,
|
||||
message: row.message,
|
||||
entityType: row.entityType ?? null,
|
||||
entityId: row.entityId ?? null,
|
||||
payload: parseJsonObject(row.payloadJson),
|
||||
}
|
||||
}
|
||||
|
||||
export async function insertEventsBatch(items: EventInsertInput[]) {
|
||||
if (items.length === 0) return
|
||||
await db.insert(events)
|
||||
.values(
|
||||
items.map((item) => ({
|
||||
id: item.id,
|
||||
createdAt: item.createdAt,
|
||||
level: item.level,
|
||||
eventType: item.eventType,
|
||||
sourceModule: item.sourceModule,
|
||||
title: item.title,
|
||||
message: item.message,
|
||||
entityType: item.entityType ?? null,
|
||||
entityId: item.entityId ?? null,
|
||||
payloadJson: item.payload ?? null,
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
export async function listEvents(params: ListEventsParams): Promise<EventItem[]> {
|
||||
const where = and(
|
||||
params.level ? eq(events.level, params.level) : undefined,
|
||||
params.sourceModule ? eq(events.sourceModule, params.sourceModule) : undefined,
|
||||
params.from ? gte(events.createdAt, params.from) : undefined,
|
||||
params.to ? lte(events.createdAt, params.to) : undefined,
|
||||
)
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(events)
|
||||
.where(where)
|
||||
.orderBy(desc(events.createdAt))
|
||||
.limit(params.limit)
|
||||
return rows.map(mapEventRow)
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { randomUUID } from "node:crypto"
|
||||
import {
|
||||
appendEventSchema,
|
||||
listEventsQuerySchema,
|
||||
type AppendEventBody,
|
||||
type EventItem,
|
||||
type ListEventsQuery,
|
||||
} from "@mmapp/contracts/events"
|
||||
import { insertEventsBatch, listEvents, type EventInsertInput } from "../repository/events-repository.js"
|
||||
import { executeGuardedApplicationWrite } from "../../../services/config-mutation-policy.js"
|
||||
|
||||
function normalizeEventInput(input: AppendEventBody): EventInsertInput {
|
||||
return {
|
||||
id: randomUUID(),
|
||||
createdAt: input.createdAt ?? new Date().toISOString(),
|
||||
level: input.level,
|
||||
eventType: input.eventType.trim(),
|
||||
sourceModule: input.sourceModule,
|
||||
title: input.title.trim(),
|
||||
message: input.message.trim(),
|
||||
entityType: input.entityType?.trim() || undefined,
|
||||
entityId: input.entityId?.trim() || undefined,
|
||||
payload: input.payload ?? {},
|
||||
}
|
||||
}
|
||||
|
||||
export async function appendEvent(input: AppendEventBody) {
|
||||
const parsed = appendEventSchema.parse(input)
|
||||
await insertEventsBatch([normalizeEventInput(parsed)])
|
||||
}
|
||||
|
||||
export async function appendEvents(inputs: AppendEventBody[]) {
|
||||
const rows = prepareEventRows(inputs)
|
||||
await insertEventsBatch(rows)
|
||||
}
|
||||
|
||||
/** Stable server-generated identities let an approved batch be verified without replay. */
|
||||
export function prepareEventRows(inputs: AppendEventBody[]): EventInsertInput[] {
|
||||
return inputs.map(entry => normalizeEventInput(appendEventSchema.parse(entry)))
|
||||
}
|
||||
|
||||
/** Client batch endpoint only. Internal collector/alert event publication retains
|
||||
* its existing appendEvent/appendEvents contract and does not acquire this gate. */
|
||||
export async function appendClientEventRows(rows: EventInsertInput[], beforeInsert?: () => Promise<void>) {
|
||||
return executeGuardedApplicationWrite({ kind: "events-journal", id: "global" }, "all", async () => {
|
||||
await beforeInsert?.()
|
||||
await insertEventsBatch(rows)
|
||||
}, async () => rows.map(row => ({ kind: "event", id: row.id })))
|
||||
}
|
||||
|
||||
export async function readEvents(query: Partial<ListEventsQuery>): Promise<EventItem[]> {
|
||||
const parsed = listEventsQuerySchema.parse(query)
|
||||
return await listEvents(parsed)
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { acmeSettingsActionSchema, ACME_CLOUDFLARE_SECRET_PURPOSE } from "./acme-settings-actions.js"
|
||||
assert.deepEqual(acmeSettingsActionSchema.parse({}), { settings: {} })
|
||||
assert.deepEqual(acmeSettingsActionSchema.parse({ settings: { defaultZoneId: null }, secretRef: null }), { settings: { defaultZoneId: null }, secretRef: null })
|
||||
for (const input of [
|
||||
{ cloudflareApiToken: "PLAINTEXT" }, { settings: { cloudflareApiToken: "PLAINTEXT" } },
|
||||
{ secretRef: "not-a-ref" }, { secretRef: "00000000-0000-4000-8000-000000000001", clearToken: true },
|
||||
...["https://user:[email protected]/directory", "https://example.com/directory?secret=token", "file:///private/key", "https://example.com/directory#token"].map(directoryUrl => ({ settings: { directoryUrl } })),
|
||||
]) assert.equal(acmeSettingsActionSchema.safeParse(input).success, false)
|
||||
assert.equal(acmeSettingsActionSchema.safeParse({ settings: { directoryUrl: "https://directory.example/acme", defaultZoneId: "" }, secretRef: "00000000-0000-4000-8000-000000000001" }).success, true)
|
||||
assert.equal(ACME_CLOUDFLARE_SECRET_PURPOSE, "acme-cloudflare:https://api.cloudflare.com/client/v4")
|
||||
console.log("✓ ACME MCP schema: all public fields, reference replacement/explicit clear, no plaintext or credential-bearing URL")
|
||||
@@ -0,0 +1,114 @@
|
||||
import { z } from "zod"
|
||||
import { randomUUID } from "node:crypto"
|
||||
import { pool } from "../../db/index.js"
|
||||
import { hasPermission } from "../../lib/permissions.js"
|
||||
import { performAcmeSettingsUpdate, readAcmeSettingsRecord } from "../../services/acme-settings-update.js"
|
||||
import { planAcmeSettingsUpdate, publicAcmeSettings } from "../../services/acme-settings-update-model.js"
|
||||
import { assertVerifiedConfigurationAccess } from "../../services/config-mutation-policy.js"
|
||||
import { effectiveMcpConnection } from "./store.js"
|
||||
import { decryptSecret, fingerprint, privateSecretFingerprint, redact, McpError } from "./security.js"
|
||||
import { registerMcpService } from "./service-registry.js"
|
||||
import type { add } from "./actions.js"
|
||||
|
||||
/** Cloudflare credentials are sent exclusively to this provider, never the ACME directory. */
|
||||
export const ACME_CLOUDFLARE_SECRET_PURPOSE = "acme-cloudflare:https://api.cloudflare.com/client/v4"
|
||||
const directory = z.string().url().max(2048).refine(value => {
|
||||
try { const url = new URL(value); return ["http:","https:"].includes(url.protocol) && !url.username && !url.password && !url.hash && !url.search } catch { return false }
|
||||
}, "ACME directory должен быть HTTP(S) URL без credentials/query/hash")
|
||||
const fields = z.object({ directoryUrl: directory.optional(), defaultZoneId: z.string().max(4096).nullable().optional() }).strict()
|
||||
export const acmeSettingsActionSchema = z.object({ settings: fields.default({}), secretRef: z.string().uuid().nullable().optional() }).strict()
|
||||
const fail = (message: string, code = "STATE_CONFLICT") => new McpError(code, message, 409)
|
||||
async function authorize(input: Record<string, unknown>, signal?: AbortSignal) {
|
||||
signal?.throwIfAborted()
|
||||
const principal = z.object({ connectionId: z.string().uuid(), credentialId: z.string().uuid(), credentialExpiresAt: z.string() }).strict().parse(input.principal)
|
||||
const connection = await effectiveMcpConnection(principal)
|
||||
if (connection.owner_id !== input.ownerId || !connection.policy.scope.allowGlobal || !hasPermission(connection.policy.permissions, "mm:certificates:write") || !hasPermission(connection.owner_permissions, "mm:certificates:write")) throw fail("Право изменения настроек ACME отозвано", "PERMISSION_DENIED")
|
||||
if (fingerprint(connection.policy) !== input.policyProof) throw fail("Допуск изменился", "RESET_REQUIRED")
|
||||
const ids = (await pool.query<{ id: number }>("SELECT id FROM servers ORDER BY id LIMIT 1001")).rows.map(row => Number(row.id))
|
||||
if (ids.length > 1000 || ids.some(id => !Number.isSafeInteger(id) || id <= 0 || !connection.policy.scope.serverIds.includes(id))) throw fail("Настройки ACME затрагивают весь каталог серверов", "SERVER_DENIED")
|
||||
signal?.throwIfAborted()
|
||||
return ids
|
||||
}
|
||||
async function reference(input: Record<string, unknown>) {
|
||||
if (!input.secretRef) return undefined
|
||||
const secret = (await pool.query("SELECT CASE WHEN octet_length(purpose)<=512 THEN purpose ELSE NULL END AS purpose,server_id,CASE WHEN octet_length(ciphertext)<=16384 THEN ciphertext ELSE NULL END AS ciphertext,created_by,xmin::text AS revision FROM mcp_secrets WHERE id=$1 AND created_by=$2", [input.secretRef,input.ownerId])).rows[0]
|
||||
if (!secret || typeof secret.ciphertext !== "string" || secret.server_id !== null || secret.created_by !== input.ownerId || secret.purpose !== ACME_CLOUDFLARE_SECRET_PURPOSE) throw fail("Токен не выдан владельцу для Cloudflare API", "SECRET_UNAVAILABLE")
|
||||
let value: string
|
||||
try { value = await decryptSecret(secret.ciphertext) } catch { throw fail("Токен Cloudflare недоступен", "SECRET_UNAVAILABLE") }
|
||||
if (!value.trim() || Buffer.byteLength(value,"utf8") > 4096 || /[\r\n\u0000]/.test(value)) throw fail("Формат выданного токена Cloudflare не поддерживается", "SECRET_UNAVAILABLE")
|
||||
return { value, proof: await privateSecretFingerprint(fingerprint(secret), "acme-settings-reference-v1") }
|
||||
}
|
||||
async function source(input: Record<string, unknown>, signal?: AbortSignal) {
|
||||
// Full scope is checked before either the settings secrets or reference are read.
|
||||
const ids = await authorize(input, signal), row = await readAcmeSettingsRecord()
|
||||
if (row && (!directory.safeParse(row.directoryUrl).success || typeof row.defaultZoneId !== "string" || row.defaultZoneId.length > 4096)) throw fail("Текущая конфигурация ACME не поддерживается безопасным адаптером", "STATE_UNAVAILABLE")
|
||||
const busy = (await pool.query("SELECT id FROM certificate_issue_jobs WHERE status IN ('queued','running') ORDER BY id LIMIT 1")).rows.length > 0
|
||||
if (busy) throw fail("ACME занято выпуском сертификата")
|
||||
const ref = await reference(input)
|
||||
const protections = (await pool.query("SELECT id,xmin::text AS revision,CASE WHEN octet_length(object_ref::text)<=65536 THEN object_ref ELSE NULL END AS object_ref FROM mcp_protections WHERE object_ref->>'serverId'=ANY($1::text[]) OR (object_ref->>'kind'='server' AND object_ref->>'id'=ANY($1::text[])) OR object_ref->>'kind'='acme-settings' ORDER BY id LIMIT 1001", [ids.map(String)])).rows
|
||||
if (protections.length > 1000 || protections.some(row=>!row.object_ref)) throw fail("Превышен предел защит ACME", "STATE_UNAVAILABLE")
|
||||
if (fingerprint(await authorize(input, signal)) !== fingerprint(ids)) throw fail("Каталог серверов изменился при чтении ACME")
|
||||
// Sealed plans round-trip through JSON; optional public DTO fields must not
|
||||
// turn into a different source fingerprint merely because JSON drops undefined.
|
||||
const state = { ...publicAcmeSettings(row), defaultZoneId: row?.defaultZoneId ?? "", updatedAt: row?.updatedAt ?? null, proof: await privateSecretFingerprint(fingerprint({ row: row ?? null, ids, protections, reference: ref?.proof ?? null }), "acme-settings-source-v2") }
|
||||
return { ids, row, ref, state }
|
||||
}
|
||||
export function registerAcmeSettingsActions(register: typeof add) {
|
||||
const current = async (input: Record<string, unknown>) => (await source(input)).state
|
||||
registerMcpService("certificates.acme.update", { current,
|
||||
execute: async input => {
|
||||
if (input.proofRevision !== 2 || !z.string().uuid().safeParse(input.receiptId).success) throw fail("Нет совместимого proof настроек ACME", "UNSUPPORTED_CAPABILITY")
|
||||
const before = await source(input)
|
||||
if (fingerprint(before.state) !== input.beforeFingerprint) throw fail("Настройки ACME, каталог или ссылка на токен изменились после подтверждения")
|
||||
const patch = { ...fields.parse(input.settings), ...(input.secretRef === null ? { cloudflareApiToken: null } : before.ref ? { cloudflareApiToken: before.ref.value } : {}) }
|
||||
const fence = async () => {
|
||||
await assertVerifiedConfigurationAccess()
|
||||
if (fingerprint((await source(input)).state) !== input.beforeFingerprint) throw fail("Настройки ACME изменились перед SQL")
|
||||
await assertVerifiedConfigurationAccess()
|
||||
}
|
||||
let dispatched = false
|
||||
try {
|
||||
await fence()
|
||||
const result = await performAcmeSettingsUpdate(patch, { expected: before.row ?? null, beforeWrite: async () => { await fence(); dispatched = true }, afterWrite: async () => { await assertVerifiedConfigurationAccess(); await authorize(input) } })
|
||||
const after = await source(input)
|
||||
const desired = { ...planAcmeSettingsUpdate(before.row, patch, result.saved.updatedAt), revision: result.saved.revision }
|
||||
if (fingerprint(after.row) !== fingerprint(result.saved) || fingerprint(desired) !== fingerprint(after.row)) return { configurationSaved: false, receiptId: input.receiptId, configurationMismatch: true, attempts: 1, automaticReplay: false }
|
||||
return { configurationSaved: true, receiptId: input.receiptId, settings: publicAcmeSettings(after.row), stateProof: after.state.proof, providerTested: false, certificateIssued: false, attempts: 1, automaticReplay: false }
|
||||
} catch (error) {
|
||||
if (dispatched) throw new McpError("MUTATION_OUTCOME_UNKNOWN", "Итог записи ACME не подтверждён; автоматический повтор запрещён", 502)
|
||||
throw error
|
||||
}
|
||||
},
|
||||
verify: async (input, result) => {
|
||||
const receipt = result as { configurationSaved?: boolean; stateProof?: string; receiptId?: unknown } | null
|
||||
return receipt?.configurationSaved === true && receipt.receiptId === input.receiptId && (await current(input)).proof === receipt.stateProof
|
||||
},
|
||||
reconcile: async (input, allow) => {
|
||||
if (input.proofRevision !== 2 || !z.string().uuid().safeParse(input.receiptId).success || typeof input.beforeFingerprint !== "string") throw fail("Нет совместимого proof и checkpoint настроек ACME", "UNSUPPORTED_CAPABILITY")
|
||||
await allow(); const before = await current(input)
|
||||
const principal = input.principal as { connectionId: string }
|
||||
const checkpoints = (await pool.query("SELECT s.result FROM mcp_operation_steps s JOIN mcp_operations o ON o.id=s.operation_id WHERE o.connection_id=$1 AND s.state='verified' AND s.intended_state=$2::jsonb AND s.result->>'configurationSaved'='true' AND s.result->>'receiptId'=$3 LIMIT 2", [principal.connectionId,JSON.stringify(redact({kind:"service",service:"certificates.acme.update",input})),input.receiptId])).rows
|
||||
const receipt = checkpoints.length === 1 ? checkpoints[0].result : undefined
|
||||
const desired = typeof receipt?.stateProof === "string" && /^[a-f0-9]{64}$/.test(receipt.stateProof) && receipt.stateProof === before.proof
|
||||
await allow(); const after = await current(input)
|
||||
if (fingerprint(after) !== fingerprint(before)) throw fail("Настройки ACME изменились во время сверки")
|
||||
// Only an exact verified checkpoint can attribute the final write; an
|
||||
// unknown attempt cannot adopt a later token with coincidentally equal values.
|
||||
return { matchesDesiredState: desired, matchesOriginalState: fingerprint(after) === input.beforeFingerprint }
|
||||
},
|
||||
})
|
||||
register("certificates.acme.update", "mm_acme_settings_update_plan", "Сохранить настройки ACME/Cloudflare с отдельной ссылкой на токен", "certificates", "mm:certificates:write", "write", "global", acmeSettingsActionSchema, {
|
||||
prepare: async (input, context) => {
|
||||
const request = { ...input, principal: context.principal, ownerId: context.connection.owner_id, policyProof: fingerprint(context.connection.policy) }
|
||||
const before = await source(request, context.signal)
|
||||
const objects = [{ kind: "acme-settings", id: "1" }, ...before.ids.map(serverId => ({ kind: "server", id: String(serverId), serverId }))]
|
||||
const protections = await pool.query("SELECT object_ref FROM mcp_protections WHERE object_ref->>'serverId'=ANY($1::text[]) OR (object_ref->>'kind'='server' AND object_ref->>'id'=ANY($1::text[])) OR object_ref->>'kind'='acme-settings' LIMIT 1001", [before.ids.map(String)])
|
||||
if (protections.rows.length > 1000) throw fail("Превышен предел защит ACME", "STATE_UNAVAILABLE")
|
||||
for (const { object_ref: ref } of protections.rows) objects.push(ref)
|
||||
if (fingerprint((await source(request, context.signal)).state) !== fingerprint(before.state)) throw fail("Настройки ACME изменились при подготовке")
|
||||
return { target: { scope: "global" }, objects, snapshots: [], before: before.state, requiresHuman: true, mutationCost: 1, verificationScope: "configuration",
|
||||
steps: [{ kind: "service", service: "certificates.acme.update", input: { ...request, proofRevision: 2, receiptId: randomUUID(), beforeFingerprint: fingerprint(before.state) } }],
|
||||
warnings: ["Глобальные directoryUrl и defaultZoneId влияют на последующие выпуски сертификатов для всех серверов. Активные/ожидающие выпуски блокируют изменение.", "Пропущенный secretRef сохраняет токен; null очищает его; выданная ссылка заменяет токен. Ссылка принадлежит владельцу и имеет purpose acme-cloudflare:https://api.cloudflare.com/client/v4.", "Изменяется только SQL-конфигурация. Нет запросов к ACME/Cloudflare, проверки токена, выпуска сертификата или изменения DNS. Существующий account key сохраняется.", "Неопределённый результат не повторяется и не подтверждается совпадением значений; требуется самостоятельная сверка человеком."] }
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { z } from "zod"
|
||||
import { pool } from "../../db/index.js"
|
||||
import { hasPermission } from "../../lib/permissions.js"
|
||||
import { performAcmeSettingsTest } from "../../services/acme-settings-test.js"
|
||||
import { assertVerifiedConfigurationAccess } from "../../services/config-mutation-policy.js"
|
||||
import { effectiveMcpConnection } from "./store.js"
|
||||
import { fingerprint, privateSecretFingerprint, McpError } from "./security.js"
|
||||
import { registerMcpService } from "./service-registry.js"
|
||||
import type { add } from "./actions.js"
|
||||
|
||||
const fail = (message: string, code = "STATE_CONFLICT") => new McpError(code, message, 409)
|
||||
/** Token material is never part of the public state or service input. */
|
||||
export async function readAcmeTestSource(input: Record<string, unknown>, signal?: AbortSignal) {
|
||||
const authorize = async () => {
|
||||
if (signal?.aborted) throw fail("Проверка отменена", "READ_CANCELLED")
|
||||
const principal = z.object({ connectionId: z.string().uuid(), credentialId: z.string().uuid(), credentialExpiresAt: z.string() }).strict().parse(input.principal)
|
||||
const current = await effectiveMcpConnection(principal)
|
||||
if (current.owner_id !== input.ownerId || !current.policy.scope.allowGlobal || !hasPermission(current.policy.permissions, "mm:certificates:write") || !hasPermission(current.owner_permissions, "mm:certificates:write")) throw fail("Право проверки ACME отозвано", "PERMISSION_DENIED")
|
||||
if (fingerprint(current.policy) !== input.policyProof) throw fail("Допуск изменился", "RESET_REQUIRED")
|
||||
const ids = (await pool.query<{ id: number }>("SELECT id FROM servers ORDER BY id LIMIT 1001")).rows.map(row => row.id)
|
||||
if (ids.length > 1000 || ids.some(id => !current.policy.scope.serverIds.includes(id))) throw fail("Проверка ACME требует полный каталог серверов", "SERVER_DENIED")
|
||||
if (signal?.aborted) throw fail("Проверка отменена", "READ_CANCELLED")
|
||||
return ids
|
||||
}
|
||||
const ids = await authorize()
|
||||
const row = (await pool.query<{ token: string | null; updated: unknown }>(`SELECT CASE WHEN octet_length(cloudflare_api_token)<=4096 THEN cloudflare_api_token ELSE NULL END AS token, updated_at AS updated FROM acme_settings WHERE id=1`)).rows[0]
|
||||
if (!row || typeof row.token !== "string" || !row.token.trim() || /[\r\n\u0000]/.test(row.token)) throw fail("Сохранённый токен Cloudflare недоступен", "SECRET_UNAVAILABLE")
|
||||
if (fingerprint(await authorize()) !== fingerprint(ids)) throw fail("Каталог серверов изменился")
|
||||
return { ids, token: row.token.trim(), publicState: { tokenConfigured: true, destination: "https://api.cloudflare.com/client/v4/zones?per_page=1", proof: await privateSecretFingerprint(fingerprint({ row, ids }), "acme-token-test-v1") } }
|
||||
}
|
||||
|
||||
export function registerAcmeTestActions(register: typeof add) {
|
||||
const current = async (input: Record<string, unknown>) => (await readAcmeTestSource(input)).publicState
|
||||
registerMcpService("certificates.acme.test", {
|
||||
current,
|
||||
execute: async input => {
|
||||
const source = await readAcmeTestSource(input)
|
||||
const fence = async () => {
|
||||
await assertVerifiedConfigurationAccess()
|
||||
if (fingerprint(await current(input)) !== input.beforeFingerprint) throw fail("Настройки ACME изменились после подтверждения")
|
||||
}
|
||||
await fence()
|
||||
const result = await performAcmeSettingsTest({}, { readStored: async () => source.token, beforeDispatch: fence, afterDispatch: fence })
|
||||
if (result.outcome === "unknown") throw new McpError("MUTATION_OUTCOME_UNKNOWN", "Ответ Cloudflare не подтверждён; автоматический повтор запрещён", 502)
|
||||
return { providerAccepted: result.ok, outcome: result.outcome, attempts: 1, configurationChanged: false, certificateIssued: false, dnsChanged: false }
|
||||
},
|
||||
verify: async (_input, result) => (result as { providerAccepted?: boolean } | null)?.providerAccepted === true,
|
||||
reconcile: async (_input, authorize) => { await authorize(); return { matchesDesiredState: false, matchesOriginalState: false } },
|
||||
})
|
||||
register("certificates.acme.test", "mm_acme_settings_test_plan", "Проверить сохранённый Cloudflare token одним запросом", "certificates", "mm:certificates:write", "write", "global", z.object({}).strict(), {
|
||||
prepare: async (input, context) => {
|
||||
const request = { ...input, principal: context.principal, ownerId: context.connection.owner_id, policyProof: fingerprint(context.connection.policy) }
|
||||
const source = await readAcmeTestSource(request, context.signal)
|
||||
const objects = [{ kind: "acme-settings", id: "1" }, { kind: "acme-settings-test", id: "1" }, ...source.ids.map(serverId => ({ kind: "server", id: String(serverId), serverId }))]
|
||||
const protections = await pool.query("SELECT object_ref FROM mcp_protections WHERE object_ref->>'serverId'=ANY($1::text[]) OR (object_ref->>'kind'='server' AND object_ref->>'id'=ANY($1::text[])) OR object_ref->>'kind' IN ('acme-settings','acme-settings-test') LIMIT 1001", [source.ids.map(String)])
|
||||
if (protections.rows.length > 1000) throw fail("Превышен предел защит ACME", "STATE_UNAVAILABLE")
|
||||
for (const { object_ref: ref } of protections.rows) objects.push(ref)
|
||||
if (fingerprint((await readAcmeTestSource(request, context.signal)).publicState) !== fingerprint(source.publicState)) throw fail("Настройки ACME изменились во время подготовки")
|
||||
return { target: { scope: "global" }, objects, snapshots: [], before: source.publicState, requiresHuman: true, mutationCost: 1, verificationScope: "acknowledgement",
|
||||
steps: [{ kind: "service", service: "certificates.acme.test", input: { ...request, beforeFingerprint: fingerprint(source.publicState) } }],
|
||||
warnings: ["Один GET Cloudflare /zones с сохранённым токеном. Настройки, DNS, ACME-аккаунт и сертификаты не изменяются.", "Ответ подтверждает только доступ к Cloudflare API. Unknown не повторяется; сверка не выполняет новый запрос.", "Для проверки другого токена человек использует REST UI; модель не принимает и не получает plaintext токена."] }
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { z } from "zod"
|
||||
import type { PoolClient } from "pg"
|
||||
import type { McpOperationDescriptor, McpPolicyPreviewRequest } from "@mmapp/contracts/mcp"
|
||||
import type { ConnectionRow, McpPrincipal } from "./store.js"
|
||||
import type { RosSnapshot, RosStep } from "./resources.js"
|
||||
|
||||
export interface ActionContext {
|
||||
connection: ConnectionRow; principal: McpPrincipal; signal?: AbortSignal
|
||||
/** Process-internal persistent controller identity; never parsed from tool JSON. */
|
||||
persistentRoutingProfile?: { id: string; version: number }
|
||||
/** Available only during target resolution, never retained by the read/prepare handler. */
|
||||
transactionClient?: Pick<PoolClient, "query">
|
||||
}
|
||||
export type ChangeStep = { kind: "ros"; step: RosStep } | { kind: "service"; service: string; input: Record<string, unknown> }
|
||||
export interface ChangeEvidence {
|
||||
/** Explicit opt-in on typed VRF plans; adds private precondition evidence,
|
||||
* never permission for native companion side effects. */
|
||||
vrfProductionAdmission?: true;
|
||||
target: McpPolicyPreviewRequest["target"];
|
||||
objects: McpPolicyPreviewRequest["objects"];
|
||||
snapshots: RosSnapshot[];
|
||||
steps: ChangeStep[];
|
||||
/** Server-calculated upper bound for batched service mutations. */
|
||||
mutationCost?: number;
|
||||
before?: unknown;
|
||||
requiresHuman?: boolean;
|
||||
verificationScope?: "configuration" | "acknowledgement";
|
||||
validUntil?: string;
|
||||
observationChecks?: Array<{ id: string; srcServerId: number; dstServerId: number; srcInterface: string; family: string; table: string; fingerprint: string; validUntil: string }>;
|
||||
ospfOwnerGraph?: { family: "ipv4" | "ipv6"; sourceSecretProofs: Array<{ serverId: number; proof: string }>; instances: Array<{ serverId: number; fingerprint: string }> };
|
||||
managementChannels?: Array<{ channelId: string; serverId: number; expiresAt: string }>;
|
||||
recovery?: { step: ChangeStep; expected: unknown; validUntil: string; authorityProfileId: string };
|
||||
healthChecks?: Array<{ serverId: number; resource: string; id: string; field: "active" | "resolved" | "running" | "state" | "bfd-up"; phase?: "after"; identity?: Record<string, string> }>;
|
||||
warnings: string[];
|
||||
}
|
||||
export interface McpAction {
|
||||
operation: McpOperationDescriptor;
|
||||
schema: z.ZodObject;
|
||||
target: (input: Record<string, unknown>, context: ActionContext) => McpPolicyPreviewRequest["target"] | Promise<McpPolicyPreviewRequest["target"]>;
|
||||
read?: (input: Record<string, unknown>, context: ActionContext) => Promise<unknown>;
|
||||
prepare?: (input: Record<string, unknown>, context: ActionContext) => Promise<ChangeEvidence>;
|
||||
}
|
||||
@@ -0,0 +1,547 @@
|
||||
import { registerIpsecServerDeleteActions } from "./ipsec-server-delete-actions.js"
|
||||
import { registerIpsecServerInitActions } from "./ipsec-server-init-actions.js"
|
||||
import { registerIpsecRevisionActions } from "./ipsec-revision-actions.js"
|
||||
import { registerWireguardRevisionActions } from "./wireguard-revision-actions.js"
|
||||
import { registerFlowHostKeysActions } from "./flow-host-keys-actions.js"
|
||||
import { registerIpsecUserCreateActions } from "./ipsec-user-create-actions.js"
|
||||
import { registerFlowSettingsActions } from "./flow-settings-actions.js"
|
||||
import { registerIpsecExistingCertificateExportActions } from "./ipsec-existing-certificate-export-registration.js"
|
||||
import { registerIpsecUserUpdateActions } from "./ipsec-user-update-actions.js"
|
||||
import { prepareIpsecNativeWrite } from "./ipsec-native-write-actions.js"
|
||||
import { isIP } from "node:net"
|
||||
import { z } from "zod"
|
||||
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 { pool } from "../../db/index.js"
|
||||
import { getServerReadById } from "../servers/service/servers-service.js"
|
||||
import { listUsers, getUserById, listInterfaceCatalog, getInterfaceCatalogState } from "../users/service/users-service.js"
|
||||
import { getLogSettings } from "../../services/router-logs-collector.js"
|
||||
import { readLogPage, readLogSummary, readLogContext, readLogEntry } from "../../services/router-logs-query.js"
|
||||
import { logWhere } from "../../services/router-logs-model.js"
|
||||
import { readBoundedLogExportRows, serializeLogExport, withLogExportSlot, LogExportLimitError, LogExportBusyError } from "../../services/router-logs-export.js"
|
||||
import { assertLogExportAccess, createLogExportArtifact, registerLogArtifactActions } from "./log-artifacts.js"
|
||||
import { registerAlertsOutboxActions } from "./alerts-outbox-actions.js"
|
||||
import { gatewayReferencesTable, isDynamicRouteInsideTable } from "../../services/routing-gateway-reference.js"
|
||||
import { readLogStream } from "../../services/router-logs-query.js"
|
||||
import { subscribeLogDevice } from "../../services/router-logs-collector.js"
|
||||
import { rosResources, readRosSnapshot, resourceById, routerClient, validateRosStep, splitOspfPassiveUpdate } from "./resources.js"
|
||||
import { registerMcpOperation, listMcpOperations } from "./registry.js"
|
||||
import { accessGeneration, decryptSecret, encryptSecret, fingerprint, McpError, redact } from "./security.js"
|
||||
import { validatePrincipal } from "./store.js"
|
||||
import type { McpAction, ActionContext, ChangeEvidence } from "./action-types.js"
|
||||
import { registerRoutingActions } from "./routing-actions.js"
|
||||
import { registerApplicationActions } from "./application-actions.js"
|
||||
import { registerProbeActions } from "./probe-actions.js"
|
||||
import { registerServerProbeActions } from "./server-probe-registration.js"
|
||||
import { registerServerPollAction } from "./server-poll-registration.js"
|
||||
import { registerSpeedTestActions } from "./speed-test-actions.js"
|
||||
import { registerManualApiPingActions } from "./manual-api-ping-actions.js"
|
||||
import { registerManualUptimeActions } from "./manual-uptime-actions.js"
|
||||
import { registerManualPingGroupActions } from "./manual-ping-group-actions.js"
|
||||
import { registerManualTrafficActions } from "./manual-traffic-actions.js"
|
||||
import { registerTrafficLiveActions } from "./traffic-live-actions.js"
|
||||
import { registerTrafficHistoryActions } from "./traffic-history-actions.js"
|
||||
import { registerSidebarCountsActions } from "./sidebar-counts-actions.js"
|
||||
import { registerTrafficUserHistoryActions } from "./traffic-user-history-actions.js"
|
||||
import { registerBgpExportActions } from "./bgp-export-actions.js"
|
||||
import { registerBgpPrefixActions } from "./bgp-prefix-actions.js"
|
||||
import { registerFilterWriteActions } from "./filter-write-actions.js"
|
||||
import { registerFlowCatalogActions } from "./flow-catalog-actions.js"
|
||||
import { registerFlowAnalyticsActions } from "./flow-analytics-actions.js"
|
||||
import { registerFlowMapActions } from "./flow-map-actions.js"
|
||||
import { registerFlowLiveActions } from "./flow-live-actions.js"
|
||||
import { registerAcmeTestActions } from "./acme-test-actions.js"
|
||||
import { registerAcmeSettingsActions } from "./acme-settings-actions.js"
|
||||
import { registerGeoipUpdateActions } from "./geoip-update-actions.js"
|
||||
import { registerBackupDownloadActions } from "./backup-download-actions.js"
|
||||
import { registerFlowOverlayActions } from "./flow-overlay-actions.js"
|
||||
import { registerWireguardInterfaceCreateActions } from "./wireguard-interface-create-actions.js"
|
||||
import { registerWireguardPeerCreateActions } from "./wireguard-peer-create-actions.js"
|
||||
import { registerHostFilesActions } from "./host-files-actions.js"
|
||||
import { registerTelegramSettingsActions } from "./telegram-settings-actions.js"
|
||||
import { registerTelegramTestActions } from "./telegram-test-actions.js"
|
||||
import { registerOspfStateActions } from "./ospf-state-actions.js"
|
||||
import { registerOspfRouteProofActions } from "./ospf-route-proof-actions.js"
|
||||
import { registerLegacyOspfActions } from "./ospf-legacy-actions.js"
|
||||
import { prepareWireguardInterfaceDeletion, registerWireguardInterfaceDeleteActions } from "./wireguard-interface-delete-actions.js"
|
||||
import { prepareWireguardPeerUpdate, registerWireguardPeerUpdateActions } from "./wireguard-peer-update-actions.js"
|
||||
import { wireguardPeerNativeUpdate } from "../../services/wireguard-peer-update.js"
|
||||
import { prepareWireguardPeerDeletion, registerWireguardPeerDeleteActions } from "./wireguard-peer-delete-actions.js"
|
||||
import { registerFirewallActions } from "./firewall-actions.js"
|
||||
import { registerGreMutationActions } from "./gre-mutation-actions.js"
|
||||
import { registerGreRevisionActions } from "./gre-revision-actions.js"
|
||||
import { prepareGreNativeChange, registerGreNativeServices } from "./gre-native-actions.js"
|
||||
import { registerVpnCatalogActions } from "./vpn-catalog-actions.js"
|
||||
import { registerEvoBgpActions } from "./evobgp-actions.js"
|
||||
import { registerRoutingProfileActions } from "./routing-profiles.js"
|
||||
import { registerRoutingControls } from "./routing-controls.js"
|
||||
import { registerLifecycleActions } from "./lifecycle-actions.js"
|
||||
import { registerRecoveryActions } from "./recovery-actions.js"
|
||||
import { registerPathActions } from "./path-verification.js"
|
||||
import { registerDatabaseRestore } from "./database-restore.js"
|
||||
import { registerDatabaseBackupActions } from "./database-backup-actions.js"
|
||||
import { registerTerminalDiagnosticActions } from "./terminal-diagnostic-actions.js"
|
||||
import { registerEventsBatchActions } from "./events-batch-actions.js"
|
||||
import { registerGlobalReconciliationActions } from "./global-reconciliation-actions.js"
|
||||
import { registerWireguardInterfaceUpdateActions } from "./wireguard-interface-update-actions.js"
|
||||
import { registerWireguardImportActions } from "./wireguard-import-actions.js"
|
||||
import { registerWireguardExportActions } from "./wireguard-export-actions.js"
|
||||
import { registerServerConnectionActions } from "./server-connection-actions.js"
|
||||
import { prepareIpsecPeerDeletion, registerIpsecPeerDeleteActions } from "./ipsec-peer-delete-actions.js"
|
||||
import { registerIpsecUserDeleteActions } from "./ipsec-user-delete-actions.js"
|
||||
import { prepareNativeIpsecCertificateDeletion, registerIpsecCertificateDeleteActions } from "./ipsec-certificate-delete-actions.js"
|
||||
import { prepareNativeIpsecPeerUpdate, registerIpsecPeerUpdateActions } from "./ipsec-peer-update-actions.js"
|
||||
import { registerFlowPurgeActions } from "./flow-purge-actions.js"
|
||||
import { registerFlowFactsRebuildActions } from "./flow-facts-rebuild-actions.js"
|
||||
import { wireguardInterfaceNativeUpdate } from "../../services/wireguard-interface-update.js"
|
||||
import { addRoutingDependencies } from "./routing-dependencies.js"
|
||||
import { registerConfigHistory } from "./config-history.js"
|
||||
import { registerDeviceCatalogActions } from "./device-catalog-actions.js"
|
||||
import { registerRecursiveRecovery } from "./recursive-recovery.js"
|
||||
import { registerNetworkHostActions } from "./network-host-actions.js"
|
||||
import { registerBgpFilterBindingActions } from "./bgp-filter-binding-actions.js"
|
||||
import { registerBgpCommunityPolicyActions } from "./bgp-community-policy-actions.js"
|
||||
|
||||
const serverId = z.number().int().positive().max(Number.MAX_SAFE_INTEGER)
|
||||
const rowId = z.string().trim().min(1).max(200)
|
||||
const paging = { limit: z.number().int().min(1).max(500).default(100), offset: z.number().int().min(0).max(1000000).default(0) }
|
||||
const scope = (input: Record<string, unknown>, context: ActionContext) => ({ scope: "servers" as const, serverIds: typeof input.serverId === "number" ? [input.serverId] : context.connection.policy.scope.serverIds })
|
||||
const actions: McpAction[] = []
|
||||
export function add(id: string, toolName: string, title: string, section: string, permission: string | readonly string[] | null, effect: McpOperationDescriptor["effect"], targetScope: McpOperationDescriptor["scope"], schema: z.ZodObject, handlers: Pick<McpAction, "read" | "prepare"> & Partial<Pick<McpAction, "target">> & { version?: number }) {
|
||||
const { version = 1, ...implementationHandlers } = handlers
|
||||
const operation: McpOperationDescriptor = {
|
||||
id, toolName, title, section, effect, scope: targetScope, risk: effect === "read" ? "ordinary" : "dangerous",
|
||||
version, implementation: "available", requiredPermissions: typeof permission === "string" ? [permission] : permission ? [...permission] : [],
|
||||
annotations: { readOnlyHint: effect === "read", destructiveHint: effect === "write", idempotentHint: effect === "read", openWorldHint: true },
|
||||
}
|
||||
registerMcpOperation(operation)
|
||||
actions.push({ operation, schema, target: targetScope === "servers" ? scope : () => ({ scope: targetScope }), ...implementationHandlers })
|
||||
}
|
||||
|
||||
registerConfigHistory(add)
|
||||
registerDeviceCatalogActions(add)
|
||||
registerRecursiveRecovery(add)
|
||||
registerNetworkHostActions(add)
|
||||
registerBgpFilterBindingActions(add)
|
||||
registerBgpCommunityPolicyActions(add)
|
||||
registerTrafficHistoryActions(add)
|
||||
registerSidebarCountsActions(add)
|
||||
registerLogArtifactActions(add)
|
||||
registerWireguardExportActions(add)
|
||||
registerAlertsOutboxActions(add)
|
||||
registerTrafficUserHistoryActions(add)
|
||||
registerBgpExportActions(add)
|
||||
registerBgpPrefixActions(add)
|
||||
registerFilterWriteActions(add)
|
||||
|
||||
for (const resource of rosResources) {
|
||||
add(`ros.${resource.id}.list`, `mm_${resource.id}_list`, resource.title, resource.permission.split(":")[1], resource.permission + ":read", "read", "servers", z.object({ serverId, ...paging }).strict(), {
|
||||
read: async input => {
|
||||
const { client } = await routerClient(Number(input.serverId)), raw = await client.get<unknown>(resource.path)
|
||||
const rows = Array.isArray(raw) ? raw : [raw], offset = Number(input.offset), limit = Number(input.limit)
|
||||
return { items: redact(rows.slice(offset, offset + limit)), total: rows.length, nextOffset: offset + limit < rows.length ? offset + limit : null, observedAt: new Date().toISOString() }
|
||||
},
|
||||
})
|
||||
if (resource.readOnly) continue
|
||||
for (const action of ["create", "update", "delete"] as const) {
|
||||
if (action === "create" && !resource.create || action === "delete" && !resource.remove) continue
|
||||
const schema = action === "create" || ["ip_settings", "ipv6_settings"].includes(resource.id)
|
||||
? z.object({ serverId, fields: resource.fields.default({}), ...(resource.id === "vrf" ? { productionAdmission: z.literal(true).optional() } : {}) }).strict()
|
||||
: z.object({ serverId, id: rowId.optional(), fields: resource.fields.default({}), ...(resource.id === "vrf" ? { productionAdmission: z.literal(true).optional() } : {}) }).strict()
|
||||
const delegated = resource.id === "wireguard" && action !== "create" || resource.id === "wireguard_peer" || resource.id === "gre" && action !== "create" || resource.id === "ipsec_peer" && action !== "create" || resource.id === "certificate" && action === "delete" || resource.id === "container" && action === "delete"
|
||||
const previousVersion = action === "create" ? (["ospf_template", "wireguard_peer", "wireguard"].includes(resource.id) ? 3 : 2) : resource.id === "ipsec_peer" || resource.id === "certificate" && action === "delete" ? 2 : resource.id === "wireguard" && action === "update" ? 3 : resource.id === "ospf_template" && action !== "delete" || resource.id === "wireguard_peer" || resource.id === "wireguard" ? 2 : 1
|
||||
add(`ros.${resource.id}.${action}`, `mm_${resource.id}_${action}_plan`, `${resource.title}: ${action}`, resource.permission.split(":")[1], resource.id === "certificate" && action === "update" ? ["mm:certificates:write", "mm:network:write"] : resource.permission + ":write", "write", "servers", schema, {
|
||||
version: resource.id === "ipsec_peer" ? (action === "create" ? 5 : 3) : resource.id === "certificate" ? (action === "update" ? 4 : 3) : previousVersion + (delegated ? 0 : 1) + (resource.id === "ospf_template" ? 1 : 0) + (resource.id === "vrf" ? 1 : 0),
|
||||
prepare: async (input, context) => {
|
||||
if (resource.id === "vrf" && input.productionAdmission !== true) throw new McpError("UNSUPPORTED_CAPABILITY", "VRF требует явного productionAdmission:true для проверенного native executor; raw план недоступен", 409)
|
||||
const sid = Number(input.serverId), id = input.id as string | undefined
|
||||
const fields = validateRosStep(resource, action, input.fields, id)
|
||||
if (resource.id === "ipsec_peer" && action === "create") return prepareIpsecNativeWrite("ros.ipsec_peer.create", sid, undefined, input.fields, context)
|
||||
if (resource.id === "certificate" && action === "update") return prepareIpsecNativeWrite("ros.certificate.update", sid, id, input.fields, context)
|
||||
if (resource.id === "ipsec_peer" && action === "delete") return prepareIpsecPeerDeletion(sid, String(id), context, false)
|
||||
if (resource.id === "ipsec_peer" && action === "update") return prepareNativeIpsecPeerUpdate(sid, String(id), input.fields, context)
|
||||
if (resource.id === "certificate" && action === "delete") return prepareNativeIpsecCertificateDeletion(sid, String(id), context)
|
||||
if (resource.id === "wireguard" && action === "delete") return prepareWireguardInterfaceDeletion(sid, String(id), context, false)
|
||||
if (resource.id === "wireguard_peer" && action === "delete") return prepareWireguardPeerDeletion(sid, String(id), context)
|
||||
if (resource.id === "wireguard_peer" && action === "update") return prepareWireguardPeerUpdate(sid, String(id), wireguardPeerNativeUpdate(input.fields as Record<string, unknown>), context)
|
||||
if (resource.id === "wireguard" && action === "update") return findAction("mm_wireguard_interface_edit_plan").prepare!({ serverId: sid, interfaceId: String(id), patch: wireguardInterfaceNativeUpdate(input.fields as Record<string, unknown>) }, context)
|
||||
if (resource.id === "wireguard_peer" && action === "create") {
|
||||
const { interface: parentReference, ...peerFields } = fields
|
||||
const parents = (await readRosSnapshot(sid, "wireguard")).rows.filter(row => row[".id"] === parentReference || row.name === parentReference)
|
||||
if (parents.length !== 1 || typeof parents[0].name !== "string") throw new McpError("STATE_CONFLICT", "Родитель WireGuard не найден однозначно", 409)
|
||||
return findAction("mm_wireguard_peer_add_plan").prepare!({ serverId: sid, interfaceName: parents[0].name, ...wireguardPeerNativeUpdate(peerFields) }, context)
|
||||
}
|
||||
if (resource.id === "container" && action === "delete") return findAction("mm_container_remove_plan").prepare!({ serverId: sid, id }, context)
|
||||
if (resource.id === "gre" && action !== "create") return prepareGreNativeChange(sid,action,id,input.fields)
|
||||
for (const key of ["address", "dst-address"] as const) {
|
||||
if (fields[key]) {
|
||||
const [ip, prefix] = fields[key].split("/")
|
||||
const expected = resource.id.startsWith("ipv6") ? 6 : 4
|
||||
if (isIP(ip) !== expected || prefix !== undefined && (!/^\d+$/.test(prefix) || Number(prefix) > (expected === 6 ? 128 : 32))) throw new McpError("INVALID_INPUT", "Некорректный IP-префикс", 400)
|
||||
}
|
||||
}
|
||||
const before = await readRosSnapshot(sid, resource.id)
|
||||
const current = action === "create" ? undefined : id ? before.rows.find(row => row[".id"] === id) : before.rows[0]
|
||||
if (action !== "create" && !current) throw new McpError("NOT_FOUND", "Объект RouterOS не найден", 404)
|
||||
if (action !== "create" && (current?.dynamic === "true" || current?.builtin === "true" || current?.default === "true")) throw new McpError("PROTECTED_OBJECT", "Системный или динамический объект нельзя изменять")
|
||||
if (resource.id.endsWith("settings") && Object.keys(fields).some(key => !(key in (current ?? {})))) throw new McpError("UNSUPPORTED_CAPABILITY", "Устройство не подтвердило поддержку свойства", 409)
|
||||
const references: ChangeEvidence["objects"] = [{ kind: resource.id, id: id ?? String(fields.name ?? "new"), serverId: sid }]
|
||||
if (resource.id.endsWith("settings")) references.push({ kind: resource.id, id: "singleton", serverId: sid })
|
||||
const snapshots = [before]
|
||||
if (["ospf_instance", "ospf_area", "ospf_template", "bfd_configuration", "bgp_instance", "bgp_connection", "vrf"].includes(resource.id)) await addRoutingDependencies(sid, resource.id, action, current, fields, snapshots, references)
|
||||
if (["ipv4_route", "ipv6_route", "routing_rule", "bgp_connection"].includes(resource.id)) {
|
||||
const tables = await readRosSnapshot(sid, "routing_table")
|
||||
snapshots.push(tables)
|
||||
const tableNames = new Set([current?.["routing-table"], fields["routing-table"], resource.id === "routing_rule" ? current?.table : undefined, resource.id === "routing_rule" ? fields.table : undefined].filter(value => typeof value === "string" && value.length))
|
||||
if (["ipv4_route", "ipv6_route"].includes(resource.id) && !tableNames.size) tableNames.add("main")
|
||||
for (const name of tableNames) {
|
||||
const table = tables.rows.find(row => row.name === name)
|
||||
if (!table || table.disabled === "true") throw new McpError("STATE_CONFLICT", "Таблица маршрутизации отсутствует или отключена", 409)
|
||||
references.push({ kind: "routing_table", id: String(table[".id"]), serverId: sid })
|
||||
}
|
||||
}
|
||||
if (resource.id === "routing_table" && action !== "create" && (action === "delete" || fields.name && fields.name !== current?.name || fields.disabled === "true")) {
|
||||
// Keep exact dependency snapshots in the sealed plan: a route/rule added
|
||||
// after approval must also prevent deleting or renaming its table.
|
||||
const tableName = String(current?.name ?? "")
|
||||
if (!tableName) throw new McpError("STATE_UNAVAILABLE", "Не подтверждено имя таблицы маршрутизации", 409)
|
||||
for (const dependency of ["ipv4_route", "ipv6_route", "routing_rule", "vrf", "ospf_instance", "bgp_instance", "bgp_connection"]) {
|
||||
const snapshot = await readRosSnapshot(sid, dependency)
|
||||
snapshots.push(snapshot)
|
||||
let inUse: boolean
|
||||
try { inUse = snapshot.rows.some(row => row["routing-table"] === tableName || dependency === "routing_rule" && row.table === tableName || dependency === "vrf" && row.name === tableName || ["ipv4_route", "ipv6_route"].includes(dependency) && gatewayReferencesTable(row.gateway, [tableName, String(current?.[".id"])])) }
|
||||
catch { throw new McpError("STATE_UNAVAILABLE", "Не подтверждены gateway dependencies таблицы", 409) }
|
||||
if (inUse) throw new McpError("STATE_CONFLICT", "Таблица используется маршрутом, gateway, правилом, VRF или протоколом маршрутизации", 409)
|
||||
}
|
||||
}
|
||||
// Inventory proves references and captures dependency changes between preview and apply.
|
||||
const interfaceResources = ["ethernet", "bridge", "vlan", "gre", "wireguard", "vxlan"]
|
||||
if ([...interfaceResources, "bridge_port", "bridge_vlan", "vrf", "ipv4_address", "ipv6_address", "ospf_template", "bfd_configuration"].includes(resource.id)) {
|
||||
for (const dependency of [...interfaceResources, "bridge_port", "bridge_vlan", "ipv4_address", "ipv6_address", "vrf"]) {
|
||||
if (dependency === resource.id || snapshots.some(snapshot => snapshot.resource === dependency)) continue
|
||||
try { snapshots.push(await readRosSnapshot(sid, dependency)) }
|
||||
catch { throw new McpError("STATE_CONFLICT", "Не удалось подтвердить зависимости интерфейса", 409) }
|
||||
}
|
||||
const inventory = snapshots.filter(snapshot => interfaceResources.includes(snapshot.resource)).flatMap(snapshot => snapshot.rows.map(row => ({ resource: snapshot.resource, row })))
|
||||
const interfaces = new Set(inventory.map(item => String(item.row.name)))
|
||||
for (const field of ["interface", "bridge"] as const) if (fields[field] && !interfaces.has(fields[field])) throw new McpError("INVALID_INPUT", "Неизвестный родительский интерфейс", 400)
|
||||
const vrfMembers = ["vrf", "ospf_template", "bfd_configuration"].includes(resource.id) ? [current?.interfaces, fields.interfaces].filter((value): value is string => typeof value === "string" && !["all", "none"].includes(value)).flatMap(value => value.split(",")) : []
|
||||
if (resource.id === "vrf" && fields.interfaces !== undefined && (!fields.interfaces.trim() || fields.interfaces.split(",").includes("all"))) throw new McpError("INVALID_INPUT", "Выберите конкретные интерфейсы VRF или none", 400)
|
||||
const referenced = [current?.interface, current?.bridge, fields.interface, fields.bridge, ...vrfMembers, ...(resource.id === "bridge_vlan" ? [fields.tagged, fields.untagged].filter(Boolean).flatMap(value => value.split(",")) : [])].filter((value): value is string => typeof value === "string" && value.length > 0).map(value => value.trim())
|
||||
for (const name of new Set(referenced)) {
|
||||
const parent = inventory.find(item => item.row.name === name)
|
||||
if (!parent) throw new McpError("INVALID_INPUT", "Требуется явный существующий интерфейс", 400)
|
||||
references.push({ kind: parent.resource, id: String(parent.row[".id"]), serverId: sid })
|
||||
}
|
||||
if (resource.id === "bridge_vlan") {
|
||||
for (const iface of [fields.tagged, fields.untagged].filter(Boolean).flatMap(value => value.split(","))) if (!interfaces.has(iface.trim())) throw new McpError("INVALID_INPUT", "Неизвестный участник VLAN", 400)
|
||||
if (fields.tagged?.split(",").some(iface => fields.untagged?.split(",").includes(iface))) throw new McpError("INVALID_INPUT", "Порт не может быть одновременно tagged и untagged", 400)
|
||||
}
|
||||
const existingName = String(current?.name ?? "")
|
||||
if ((action === "delete" || fields.name && fields.name !== existingName) && existingName) {
|
||||
if (interfaceResources.includes(resource.id)) for (const dependency of ["ospf_template", "bfd_configuration"]) snapshots.push(await readRosSnapshot(sid, dependency))
|
||||
const dependent = snapshots.filter(snapshot => snapshot.resource !== resource.id).some(snapshot => snapshot.rows.some(row => {
|
||||
if (resource.id === "vrf" && ["ipv4_route", "ipv6_route"].includes(snapshot.resource) && isDynamicRouteInsideTable(row, [existingName, String(current?.[".id"])])) return false
|
||||
return Object.values(row).some(value => typeof value === "string" && value.split(",").includes(existingName))
|
||||
}))
|
||||
if (dependent) throw new McpError("STATE_CONFLICT", "Объект используется: сначала подготовьте явное изменение зависимостей", 409)
|
||||
}
|
||||
}
|
||||
const placeBeforeId = resource.id === "vrf" && action === "create" ? before.rows[0]?.[".id"] : undefined
|
||||
if (resource.id === "vrf" && action === "create" && typeof placeBeforeId !== "string") throw new McpError("STATE_CONFLICT", "Не подтверждён порядок существующих VRF", 409)
|
||||
return { target: { scope: "servers", serverIds: [sid] }, objects: references, snapshots,
|
||||
...(resource.id === "vrf" && input.productionAdmission === true ? { vrfProductionAdmission: true as const } : {}),
|
||||
steps: splitOspfPassiveUpdate({ serverId: sid, resource: resource.id, action, id, fields, ...(typeof placeBeforeId === "string" ? { placeBeforeId } : {}) }).map(step => ({ kind: "ros" as const, step })),
|
||||
requiresHuman: true,
|
||||
warnings: ["Нативное изменение может затронуть управление устройством и требует подтверждения человеком.", "Автоматическое каскадное изменение зависимостей не выполняется.", ...(placeBeforeId ? [`VRF создаётся перед записью ${placeBeforeId}; порядок определяет принадлежность интерфейсов.`] : [])],
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
for (const definition of [
|
||||
{ id: "interfaces.update", tool: "mm_interfaces_update_plan", title: "План изменения интерфейса", resources: ["ethernet", "bridge", "bridge_port", "bridge_vlan", "vlan", "gre", "wireguard", "vxlan"] },
|
||||
{ id: "firewall.update", tool: "mm_firewall_rule_update_plan", title: "План изменения правила firewall", resources: ["ip_filter", "ip_nat", "ip_mangle", "ip_raw", "ipv6_filter", "ipv6_nat", "ipv6_mangle", "ipv6_raw"] },
|
||||
] as const) {
|
||||
add(definition.id, definition.tool, definition.title, "network", "mm:network:write", "write", "servers", z.object({ serverId, resource: z.enum(definition.resources), id: rowId, fields: z.record(z.string().max(100), z.union([z.string().max(1000), z.number().finite(), z.boolean()])) }).strict(), {
|
||||
version: definition.id === "interfaces.update" ? 4 : 2,
|
||||
prepare: async (input, context) => {
|
||||
const resource = resourceById(String(input.resource)), fields = resource.fields.safeParse(input.fields)
|
||||
if (!fields.success) throw new McpError("INVALID_INPUT", "Поля не соответствуют типизированной схеме выбранного объекта", 400)
|
||||
const handler = actions.find(action => action.operation.id === `ros.${resource.id}.update`)
|
||||
if (!handler?.prepare) throw new McpError("UNSUPPORTED_CAPABILITY", "Этот объект не поддерживает изменение", 409)
|
||||
return handler.prepare({ serverId: input.serverId, id: input.id, fields: fields.data }, context)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
add("capabilities.read", "mm_capabilities_get", "Доступные возможности", "mcp", null, "read", "connection", z.object({}).strict(), {
|
||||
read: async (_input, context) => ({ operations: listMcpOperations().filter(operation => operation.implementation === "available" && context.connection.policy.scope.operationIds.includes(operation.id)), schemaVersion: 1 }),
|
||||
})
|
||||
add("servers.list", "mm_servers_list", "Серверы", "servers", "mm:servers:read", "read", "servers", z.object({ serverId: serverId.optional(), ...paging }).strict(), {
|
||||
read: async (input, context) => {
|
||||
const ids = typeof input.serverId === "number" ? [input.serverId] : context.connection.policy.scope.serverIds
|
||||
const { rows } = await pool.query<{ id: number }>("SELECT id FROM servers WHERE id=ANY($1::bigint[]) ORDER BY id LIMIT $2 OFFSET $3", [ids, input.limit, input.offset])
|
||||
return { items: await Promise.all(rows.map(row => getServerReadById(Number(row.id)))), observedAt: new Date().toISOString() }
|
||||
},
|
||||
})
|
||||
function scopedUser(user: Awaited<ReturnType<typeof getUserById>> | null, context: ActionContext) {
|
||||
if (!user) throw new McpError("NOT_FOUND", "Пользователь не найден", 404)
|
||||
const allowed = context.connection.policy.scope.serverIds
|
||||
const hidden = user.bindings.some(binding => !allowed.includes(binding.serverId)) || user.servers.some(server => !allowed.includes(Number(server.serverId)))
|
||||
return { ...user, bindings: user.bindings.filter(binding => allowed.includes(binding.serverId)), servers: user.servers.filter(server => allowed.includes(Number(server.serverId))), incomplete: hidden }
|
||||
}
|
||||
add("users.list", "mm_users_list", "Пользователи", "users", "mm:users:read", "read", "global", z.object(paging).strict(), {
|
||||
read: async (input, context) => ({ items: (await listUsers()).slice(Number(input.offset), Number(input.offset) + Number(input.limit)).map(user => scopedUser(user, context)) }),
|
||||
})
|
||||
add("users.get", "mm_users_get", "Пользователь по ID", "users", "mm:users:read", "read", "global", z.object({ id: rowId }).strict(), {
|
||||
read: async (input, context) => ({ user: scopedUser(await getUserById(String(input.id)), context) }),
|
||||
})
|
||||
add("users.interfaces.read", "mm_user_interfaces_list", "Каталог интерфейсов для привязок пользователей", "users", "mm:users:read", "read", "servers", z.object({ serverId }).strict(), {
|
||||
read: async input => ({ interfaces: await listInterfaceCatalog(Number(input.serverId)) }),
|
||||
})
|
||||
add("users.interfaces.state", "mm_user_interfaces_state_get", "Состояние каталога интерфейсов для привязок", "users", "mm:users:read", "read", "servers", z.object({ serverId }).strict(), {
|
||||
read: async input => getInterfaceCatalogState(Number(input.serverId)),
|
||||
})
|
||||
for (const action of ["create", "update", "delete", "binding_add", "binding_remove"] as const) {
|
||||
const schema = action === "create" ? z.object({ user: appUserCreateSchema.omit({ role: true, sections: true, servers: true }).strict() }).strict()
|
||||
: action === "update" ? z.object({ id: rowId, user: appUserUpdateSchema.omit({ role: true, sections: true, servers: true }).strict() }).strict()
|
||||
: action === "binding_add" ? z.object({ id: rowId, binding: userBindingCreateSchema }).strict()
|
||||
: action === "binding_remove" ? z.object({ id: rowId, bindingId: rowId }).strict()
|
||||
: z.object({ id: rowId }).strict()
|
||||
add(`users.${action}`, `mm_users_${action}_plan`, `Пользователи: ${action}`, "users", "mm:users:write", "write", "global", schema, {
|
||||
prepare: async (input, context) => {
|
||||
const before = input.id ? await getUserById(String(input.id)) : null
|
||||
if (input.id && scopedUser(before, context).incomplete) throw new McpError("SERVER_DENIED", "Есть зависимости за пределами доступа")
|
||||
const user = input.user as { servers?: { serverId: string }[] } | undefined
|
||||
const binding = input.binding as { serverId?: number } | undefined
|
||||
const ids = [...new Set([...(user?.servers ?? []).map(server => Number(server.serverId)), ...(binding?.serverId ? [binding.serverId] : []),
|
||||
...(before?.bindings ?? []).map(binding => binding.serverId), ...(before?.servers ?? []).map(server => Number(server.serverId))])]
|
||||
if (ids.some(id => !context.connection.policy.scope.serverIds.includes(id))) throw new McpError("SERVER_DENIED", "Сервер не разрешён")
|
||||
return { target: { scope: "global" }, objects: [{ kind: "user", id: String(input.id ?? "new") }, ...ids.map(id => ({ kind: "server", id: String(id), serverId: id }))], snapshots: [], before,
|
||||
steps: [{ kind: "service", service: `users.${action}`, input }], warnings: ["Изменение /users не меняет JWT-права портала."] }
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
add("users.permissions.update", "mm_users_permissions_update_plan", "Роль и права каталога /users", "users", "mm:settings:admin", "write", "global", z.object({ id: rowId, permissions: z.object({ role: appUserRoleSchema.optional(), sections: z.array(sectionPermSchema.strict()).max(100).optional(), servers: z.array(serverPermSchema.strict()).max(1000).optional() }).strict().refine(value => Object.keys(value).length > 0, "Нет изменений прав") }).strict(), {
|
||||
prepare: async (input, context) => {
|
||||
const before = await getUserById(String(input.id)), visible = scopedUser(before, context)
|
||||
if (visible.incomplete) throw new McpError("SERVER_DENIED", "Права пользователя затрагивают скрытые серверы")
|
||||
const permissions = input.permissions as { servers?: { serverId: string }[] }
|
||||
const serverIds = [...new Set([...(before?.servers ?? []).map(server => Number(server.serverId)), ...(before?.bindings ?? []).map(binding => binding.serverId), ...(permissions.servers ?? []).map(server => Number(server.serverId))])]
|
||||
if (serverIds.some(serverId => !Number.isSafeInteger(serverId) || !context.connection.policy.scope.serverIds.includes(serverId))) throw new McpError("SERVER_DENIED", "Назначаемые права выходят за область подключения")
|
||||
if (serverIds.length && (await pool.query("SELECT id FROM servers WHERE id=ANY($1::bigint[])", [serverIds])).rows.length !== serverIds.length) throw new McpError("NOT_FOUND", "Назначаемый сервер отсутствует", 404)
|
||||
return { target: { scope: "global" }, objects: [{ kind: "user", id: String(input.id) }, ...serverIds.map(serverId => ({ kind: "server", id: String(serverId), serverId }))], snapshots: [], before, requiresHuman: true, steps: [{ kind: "service", service: "users.update", input: { id: input.id, user: input.permissions } }], warnings: ["Роль и права относятся к каталогу /users и не изменяют JWT или аккаунты портала."] }
|
||||
},
|
||||
})
|
||||
|
||||
const logInput = z.object({ serverId: serverId.optional(), from: z.string().datetime({ offset: true }).optional(), to: z.string().datetime({ offset: true }).optional(),
|
||||
levels: z.array(z.enum(["critical", "error", "warning", "info", "debug", "unknown"])).max(6).default([]), topics: z.array(z.string().max(100)).max(30).default([]),
|
||||
q: z.string().max(500).default(""), preset: z.enum(["", "login-failure", "interfaces", "routing", "firewall"]).default(""),
|
||||
limit: z.number().int().min(1).max(200).default(100), cursor: z.string().max(3000).optional(),
|
||||
}).strict()
|
||||
function logFilters(input: Record<string, unknown>, context: ActionContext) {
|
||||
return { ...logFiltersSchema.parse({ ...input, levels: (input.levels as string[]).join(","), topics: (input.topics as string[]).join(",") }), allowedServerIds: context.connection.policy.scope.serverIds }
|
||||
}
|
||||
add("logs.query", "mm_logs_query", "История логов", "logs", "mm:servers:read", "read", "servers", logInput, {
|
||||
read: async (input, context) => {
|
||||
const filters = logFilters(input, context), generation = await accessGeneration()
|
||||
let cursor: string | undefined, watermark: string | undefined
|
||||
if (input.cursor) {
|
||||
try {
|
||||
const state = JSON.parse(await decryptSecret(String(input.cursor))) as { generation: string; scope: string; cursor: string; watermark: string; from: string }
|
||||
if (input.from === undefined) filters.from = state.from
|
||||
if (state.generation !== generation || state.scope !== fingerprint({ filters, connection: context.connection.id })) throw new Error("cursor")
|
||||
cursor = state.cursor; watermark = state.watermark
|
||||
} catch { throw new McpError("RESET_REQUIRED", "Курсор устарел или относится к другим фильтрам", 409) }
|
||||
}
|
||||
const result = await readLogPage(filters, { cursor, watermark, limit: Number(input.limit) })
|
||||
return { ...result, nextCursor: result.nextCursor ? await encryptSecret(JSON.stringify({ generation, scope: fingerprint({ filters, connection: context.connection.id }), cursor: result.nextCursor, watermark: result.watermark, from: filters.from })) : null }
|
||||
},
|
||||
})
|
||||
add("logs.summary", "mm_logs_summary_get", "Статистика логов", "logs", "mm:servers:read", "read", "servers", logInput, {
|
||||
read: async (input, context) => { const { settings: _settings, ...summary } = await readLogSummary(logFilters(input, context)); return summary },
|
||||
})
|
||||
for (const context of [false, true]) add(context ? "logs.context" : "logs.get", context ? "mm_logs_context_get" : "mm_logs_get", "Запись журнала", "logs", "mm:servers:read", "read", "servers", z.object({ serverId, id: z.string().regex(/^\d{1,20}$/) }).strict(), {
|
||||
read: async input => {
|
||||
const entry = await readLogEntry(String(input.id))
|
||||
if (!entry || entry.serverId !== input.serverId) throw new McpError("NOT_FOUND", "Событие не найдено", 404)
|
||||
return context ? readLogContext(String(input.id)) : entry
|
||||
},
|
||||
})
|
||||
add("logs.follow", "mm_logs_follow", "Новые события", "logs", "mm:servers:read", "read", "servers", logInput.safeExtend({ serverId, cursor: z.string().max(8192).optional(), waitSeconds: z.number().int().min(0).max(30).default(30) }), {
|
||||
read: async (input, context) => {
|
||||
const { server } = await routerClient(Number(input.serverId)); void server
|
||||
const release = subscribeLogDevice(Number(input.serverId)), until = Date.now() + Number(input.waitSeconds) * 1000
|
||||
try {
|
||||
const filters = logFilters(input, context), generation = await accessGeneration()
|
||||
let cursor = "0"
|
||||
if (input.cursor) {
|
||||
try {
|
||||
const saved = JSON.parse(await decryptSecret(String(input.cursor)))
|
||||
filters.from = saved.from
|
||||
if (saved.kind !== "follow" || saved.generation !== generation || saved.scope !== fingerprint({ filters, connection: context.connection.id }) || !/^\d{1,20}$/.test(saved.cursor)) throw new Error("cursor")
|
||||
cursor = saved.cursor
|
||||
} catch { throw new McpError("RESET_REQUIRED", "Курсор follow устарел или относится к другим фильтрам", 409) }
|
||||
} else {
|
||||
cursor = (await pool.query("SELECT COALESCE(max(id),0)::text AS cursor FROM router_log_entries WHERE server_id=$1", [input.serverId])).rows[0].cursor
|
||||
}
|
||||
while (true) {
|
||||
const current = await validatePrincipal(context.principal)
|
||||
if (current.policy.version !== context.connection.policy.version) throw new McpError("RESET_REQUIRED", "Права или область доступа изменились; начните новое чтение", 409)
|
||||
const batch = await readLogStream(filters, cursor)
|
||||
cursor = batch.cursor
|
||||
if (batch.entries.length || !batch.enabled || Date.now() >= until || context.signal?.aborted) return { ...batch, cursor: await encryptSecret(JSON.stringify({ kind: "follow", generation, scope: fingerprint({ filters, connection: context.connection.id }), cursor, from: filters.from })) }
|
||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||
}
|
||||
} finally { release() }
|
||||
},
|
||||
})
|
||||
add("logs.export", "mm_logs_export", "Экспорт логов", "logs", "mm:servers:read", "read", "servers", logInput.safeExtend({ format: z.enum(["csv", "json"]).default("json") }), {
|
||||
read: async (input, context) => {
|
||||
const where = logWhere(logFilters(input, context))
|
||||
const serverIds = input.serverId ? [Number(input.serverId)] : context.connection.policy.scope.serverIds
|
||||
await assertLogExportAccess(context, serverIds)
|
||||
try {
|
||||
return await withLogExportSlot(async () => {
|
||||
const rows = await readBoundedLogExportRows(where)
|
||||
await assertLogExportAccess(context, serverIds)
|
||||
const masked = rows.map(row => redact(row)) as Record<string, unknown>[]
|
||||
const format = input.format as "json" | "csv", content = serializeLogExport(masked, format, false)
|
||||
return { ...await createLogExportArtifact(content, format, serverIds, context), rows: rows.length }
|
||||
})
|
||||
} catch (error) { await assertLogExportAccess(context, serverIds); if (error instanceof LogExportLimitError) throw new McpError("EXPORT_LIMIT", error.message, 422); if (error instanceof LogExportBusyError) throw new McpError("EXPORT_BUSY", error.message, 429); throw error }
|
||||
},
|
||||
})
|
||||
add("logs.settings.read", "mm_logs_settings_get", "Настройки логов", "logs", "mm:settings:admin", "read", "global", z.object({}).strict(), { read: async () => getLogSettings() })
|
||||
add("logs.settings.update", "mm_logs_settings_update_plan", "Изменение сборщика логов", "logs", "mm:settings:admin", "write", "global", z.object({ settings: logSettingsPatchSchema }).strict(), {
|
||||
prepare: async (input, context) => {
|
||||
const ids = (await pool.query<{ id: number }>("SELECT id FROM servers")).rows.map(row => Number(row.id))
|
||||
if (ids.some(id => !context.connection.policy.scope.serverIds.includes(id))) throw new McpError("SERVER_DENIED", "Глобальная настройка сборщика затрагивает скрытые серверы")
|
||||
return { target: { scope: "global" }, objects: ids.map(serverId => ({ kind: "server", id: String(serverId), serverId })), snapshots: [], before: await getLogSettings(), steps: [{ kind: "service", service: "logs.settings.update", input }], warnings: ["Отключение сбора и уменьшение retention влияют на последующую доступность истории."] }
|
||||
},
|
||||
})
|
||||
|
||||
export function listActions(): readonly McpAction[] { return actions }
|
||||
export function findAction(toolName: string): McpAction {
|
||||
const action = actions.find(candidate => candidate.operation.toolName === toolName)
|
||||
if (!action) throw new McpError("UNKNOWN_OPERATION", "Инструмент не реализован", 404)
|
||||
return action
|
||||
}
|
||||
export function findActionByOperation(id: string): McpAction {
|
||||
const action = actions.find(candidate => candidate.operation.id === id)
|
||||
if (!action) throw new McpError("UNKNOWN_OPERATION", "Операция не реализована", 404)
|
||||
return action
|
||||
}
|
||||
|
||||
async function assertPlanReadScope(plan: Awaited<ReturnType<typeof import("./runtime.js")["loadPlan"]>>, context: ActionContext) {
|
||||
if (plan.connection_id !== context.connection.id) throw new McpError("NOT_FOUND", "План не найден", 404)
|
||||
if (!context.connection.policy.scope.operationIds.includes(plan.operation_id)) throw new McpError("OPERATION_DENIED", "Исходное действие не входит в текущий допуск")
|
||||
const source = findActionByOperation(plan.operation_id)
|
||||
if (source.operation.requiredPermissions.some(permission => !hasPermission(context.connection.policy.permissions, permission) || !hasPermission(context.connection.owner_permissions, permission))) throw new McpError("PERMISSION_DENIED", "Право исходного действия отозвано")
|
||||
const evidence = JSON.parse(await decryptSecret(plan.evidence.sealed)) as ChangeEvidence
|
||||
const ids = [...(evidence.target.scope === "servers" ? evidence.target.serverIds : []), ...evidence.objects.flatMap(object => object.serverId === undefined ? [] : [object.serverId])]
|
||||
if (ids.some(id => !context.connection.policy.scope.serverIds.includes(id)) || evidence.target.scope === "global" && !context.connection.policy.scope.allowGlobal) throw new McpError("SERVER_DENIED", "План находится вне текущего допуска")
|
||||
}
|
||||
add("changes.read", "mm_change_get", "Просмотр своего плана", "mcp", null, "read", "connection", z.object({ planId: z.string().uuid() }).strict(), {
|
||||
read: async (input, context) => {
|
||||
const { loadPlan, publicPlan } = await import("./runtime.js")
|
||||
const plan = await loadPlan(String(input.planId))
|
||||
await assertPlanReadScope(plan, context)
|
||||
return publicPlan(plan)
|
||||
},
|
||||
})
|
||||
add("changes.apply", "mm_change_apply", "Применить свой план", "mcp", null, "write", "connection", z.object({ planId: z.string().uuid(), idempotencyKey: z.string().min(1).max(200) }).strict(), {
|
||||
read: async (input, context) => (await import("./runtime.js")).applyPlan(context.principal, String(input.planId), String(input.idempotencyKey)),
|
||||
})
|
||||
add("changes.cancel", "mm_change_cancel", "Отменить свой незапущенный план", "mcp", null, "read", "connection", z.object({ planId: z.string().uuid() }).strict(), {
|
||||
read: async (input, context) => {
|
||||
const { transaction, audit } = await import("./store.js")
|
||||
await assertPlanReadScope(await (await import("./runtime.js")).loadPlan(String(input.planId)), context)
|
||||
return transaction(async client => {
|
||||
const { rows } = await client.query("UPDATE mcp_plans SET state='cancelled' WHERE id=$1 AND connection_id=$2 AND state IN ('draft','awaiting-approval','approved') RETURNING id", [input.planId, context.connection.id])
|
||||
if (!rows.length) throw new McpError("STATE_CONFLICT", "План не найден или уже запущен", 409)
|
||||
await audit(client, context.connection.owner_id, "plan.cancel", context.connection.id, { planId: input.planId })
|
||||
return { cancelled: true }
|
||||
})
|
||||
},
|
||||
})
|
||||
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)
|
||||
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 }
|
||||
},
|
||||
})
|
||||
|
||||
registerRoutingActions(add)
|
||||
registerRoutingProfileActions(add)
|
||||
registerRoutingControls(add)
|
||||
registerLifecycleActions(add)
|
||||
registerRecoveryActions(add)
|
||||
registerPathActions(add)
|
||||
registerApplicationActions(add)
|
||||
registerProbeActions(add)
|
||||
registerServerProbeActions(add)
|
||||
registerServerPollAction(add)
|
||||
registerSpeedTestActions(add)
|
||||
registerManualApiPingActions(add)
|
||||
registerManualUptimeActions(add)
|
||||
registerManualPingGroupActions(add)
|
||||
registerManualTrafficActions(add)
|
||||
registerTrafficLiveActions(add)
|
||||
registerFlowCatalogActions(add)
|
||||
registerFlowAnalyticsActions(add)
|
||||
registerFlowMapActions(add)
|
||||
registerFlowLiveActions(add)
|
||||
registerAcmeTestActions(add)
|
||||
registerAcmeSettingsActions(add)
|
||||
registerGeoipUpdateActions(add)
|
||||
registerBackupDownloadActions(add)
|
||||
registerFlowOverlayActions(add)
|
||||
registerWireguardInterfaceCreateActions(add)
|
||||
registerWireguardPeerCreateActions(add)
|
||||
registerHostFilesActions(add)
|
||||
registerTelegramSettingsActions(add)
|
||||
registerTelegramTestActions(add)
|
||||
registerOspfStateActions(add)
|
||||
registerOspfRouteProofActions(add)
|
||||
registerLegacyOspfActions(add)
|
||||
registerWireguardPeerDeleteActions(add)
|
||||
registerWireguardPeerUpdateActions(add)
|
||||
registerWireguardInterfaceDeleteActions(add)
|
||||
registerFirewallActions(add)
|
||||
registerGreMutationActions(add)
|
||||
registerGreRevisionActions(add)
|
||||
registerGreNativeServices()
|
||||
registerVpnCatalogActions(add)
|
||||
registerEvoBgpActions(add)
|
||||
registerDatabaseRestore(add)
|
||||
registerDatabaseBackupActions(add)
|
||||
registerTerminalDiagnosticActions(add)
|
||||
registerEventsBatchActions(add)
|
||||
registerGlobalReconciliationActions(add)
|
||||
registerWireguardInterfaceUpdateActions(add)
|
||||
registerWireguardImportActions(add)
|
||||
registerServerConnectionActions(add)
|
||||
registerIpsecPeerDeleteActions(add)
|
||||
registerIpsecUserDeleteActions(add)
|
||||
registerIpsecUserUpdateActions(add)
|
||||
registerIpsecServerDeleteActions(add)
|
||||
registerIpsecServerInitActions(add)
|
||||
registerIpsecRevisionActions(add)
|
||||
registerWireguardRevisionActions(add)
|
||||
registerIpsecCertificateDeleteActions(add)
|
||||
registerIpsecPeerUpdateActions(add)
|
||||
registerFlowPurgeActions(add)
|
||||
registerFlowFactsRebuildActions(add)
|
||||
registerFlowHostKeysActions(add)
|
||||
registerIpsecUserCreateActions(add)
|
||||
registerFlowSettingsActions(add)
|
||||
registerIpsecExistingCertificateExportActions(add)
|
||||
@@ -0,0 +1,60 @@
|
||||
import { z } from "zod"
|
||||
import { alertsOutboxRetrySchema, alertsOutboxRetryReceiptSchema } from "@mmapp/contracts/alerts"
|
||||
import { pool } from "../../db/index.js"
|
||||
import { readOutboxRetrySource, retryUnknownOutbox, OutboxRetrySourceUnavailable, type OutboxRetrySource } from "../../services/alert-engine/outbox.js"
|
||||
import { OutboxRetryConflict } from "../../services/alerts-outbox-retry.js"
|
||||
import { privateSecretFingerprint, McpError } from "./security.js"
|
||||
import { registerMcpService } from "./service-registry.js"
|
||||
import type { add } from "./actions.js"
|
||||
|
||||
const operation = "alerts.outbox.retry"
|
||||
const schema = alertsOutboxRetrySchema.safeExtend({ id: z.string().min(1).max(200).refine(value => value === value.trim() && !/[\u0000-\u001f\u007f]/.test(value)) })
|
||||
const sourceProof = (source: OutboxRetrySource) => privateSecretFingerprint(JSON.stringify(source), "alerts-outbox-retry")
|
||||
async function dependencyIds() {
|
||||
const serverIds = (await pool.query<{ id: number }>("SELECT id FROM servers ORDER BY id LIMIT 1001")).rows.map(row => Number(row.id))
|
||||
if (serverIds.length > 999) throw new McpError("STATE_UNAVAILABLE", "Каталог зависимостей очереди превышает 999 серверов", 409)
|
||||
return serverIds
|
||||
}
|
||||
async function current(input: Record<string, unknown>) {
|
||||
const serverIds = await dependencyIds()
|
||||
const permitted = input.serverIds
|
||||
if (Array.isArray(permitted) && serverIds.some(id => !permitted.includes(id))) throw new McpError("SERVER_DENIED", "Глобальная очередь содержит зависимости вне области плана", 403)
|
||||
let source: OutboxRetrySource | null
|
||||
try { source = await readOutboxRetrySource(String(input.id)) }
|
||||
catch (error) { if (error instanceof OutboxRetrySourceUnavailable) throw new McpError("STATE_UNAVAILABLE", error.message, 409); throw error }
|
||||
return { serverIds, value: source ? { id: source.id, channel: source.channel, status: source.status, resolutionVersion: source.resolution_version, proof: await sourceProof(source) } : null }
|
||||
}
|
||||
|
||||
export function registerAlertsOutboxActions(register: typeof add) {
|
||||
registerMcpService(operation, {
|
||||
current,
|
||||
execute: async input => {
|
||||
const retry = alertsOutboxRetrySchema.parse({ expectedVersion: input.expectedVersion, acknowledgedDuplicateRisk: input.acknowledgedDuplicateRisk })
|
||||
try {
|
||||
return await retryUnknownOutbox(String(input.id), retry, async source => {
|
||||
if (typeof input.sourceProof !== "string" || await sourceProof(source) !== input.sourceProof) throw new McpError("STATE_CONFLICT", "Запись уведомления изменилась после подтверждения плана", 409)
|
||||
if (JSON.stringify(await dependencyIds()) !== JSON.stringify(input.serverIds)) throw new McpError("STATE_CONFLICT", "Каталог серверов изменился после подтверждения плана", 409)
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof OutboxRetryConflict) throw new McpError("STATE_CONFLICT", error.message, 409)
|
||||
if (error instanceof OutboxRetrySourceUnavailable) throw new McpError("STATE_UNAVAILABLE", error.message, 409)
|
||||
throw error
|
||||
}
|
||||
},
|
||||
verify: async (input, result) => {
|
||||
const receipt = alertsOutboxRetryReceiptSchema.safeParse(result)
|
||||
return receipt.success && receipt.data.id === input.id && receipt.data.resolutionVersion === Number(input.expectedVersion) + 1
|
||||
},
|
||||
})
|
||||
register(operation, "mm_alerts_outbox_retry_plan", "Повтор уведомления с неизвестным результатом", "alerts", "mm:alerts:write", "write", "global", schema, {
|
||||
prepare: async (input, context) => {
|
||||
const before = await current({ ...input, serverIds: context.connection.policy.scope.serverIds })
|
||||
if (!before.value) throw new McpError("NOT_FOUND", "Запись очереди не найдена", 404)
|
||||
if (before.value.channel !== "telegram" || before.value.status !== "unknown" || before.value.resolutionVersion !== input.expectedVersion) throw new McpError("STATE_CONFLICT", "Обновите очередь: повтор разрешён только для unknown с текущей версией", 409)
|
||||
if (before.serverIds.some(id => !context.connection.policy.scope.serverIds.includes(id))) throw new McpError("SERVER_DENIED", "Глобальная очередь требует доступа ко всему каталогу серверов", 403)
|
||||
return { target: { scope: "global" }, objects: [{ kind: "alerts-outbox", id: String(input.id) }, ...before.serverIds.map(serverId => ({ kind: "server", id: String(serverId), serverId }))],
|
||||
snapshots: [], before, steps: [{ kind: "service", service: operation, input: { ...input, sourceProof: before.value.proof, serverIds: before.serverIds } }], requiresHuman: true,
|
||||
verificationScope: "acknowledgement", warnings: ["Предыдущее уведомление могло быть доставлено. Повтор может создать дубликат у получателя; требуется отдельное подтверждение человеком.", "Подтверждается только повторная постановка в очередь. Фоновый отправитель может отправить сообщение после выполнения плана; доставка получателю не подтверждается.", "Изменение записи или каталога серверов инвалидирует план. Текст и адресат остаются в исходной записи и не передаются нейросети."] }
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
import { z } from "zod"
|
||||
import { geoipSettingsPatchSchema } from "@mmapp/contracts/geoip"
|
||||
import { pingProbeWriteSchema, speedProbeWriteSchema, type PingProbeWrite } from "@mmapp/contracts"
|
||||
import { serverCreateSchema, serverUpdateSchema } from "@mmapp/contracts/servers"
|
||||
import { putRulesSchema } from "@mmapp/contracts/alerts"
|
||||
import { statisticsQuerySchema, statisticsPivotQuerySchema } from "@mmapp/contracts/statistics"
|
||||
import { putBackupScheduleSettingsSchema } from "@mmapp/contracts/backups"
|
||||
import { readBackupJob } from "../../services/backup-job-store.js"
|
||||
import { projectScopedBackupJob } from "../../services/backup-job-read.js"
|
||||
import { registerBackupStorageActions } from "./backup-storage-actions.js"
|
||||
import { pool } from "../../db/index.js"
|
||||
import { createServer, updateServer, deleteServer, listServerSnapshots } from "../servers/service/servers-service.js"
|
||||
import { resolveRosSrcIpv4 } from "../../utils/ros-src-address.js"
|
||||
import { getServerRowById } from "../servers/repository/servers-repository.js"
|
||||
import { getAlertsOverview, replaceAlertsConfig } from "../../services/alerts-service.js"
|
||||
import { getSchedulerStatus, JOB_KEYS, listSchedulerRuns, refreshScheduler, runSchedulerJobNow } from "../../services/scheduler.js"
|
||||
import { getBackupById, getBackupScheduleSettings, getBackupStorageSettings, runBackupForServer, restoreBackupToDevice, deleteBackupRecord, updateBackupScheduleSettings, testBackupStorageConnection, syncBackupsFromS3 } from "../../services/backup-service.js"
|
||||
import { getCertificateRenewSettings, updateCertificateRenewSettings, getAcmeSettingsPublic } from "../../services/certificates-service.js"
|
||||
import { getAlertsOutboxOverview } from "../../services/alert-engine/outbox.js"
|
||||
import { readEvents } from "../events/service/events-service.js"
|
||||
import { buildFlowAnalytics, getFlowMonthly } from "../../services/traffic-flow-analytics.js"
|
||||
import { getTrafficSettings, updateTrafficSettings } from "../../services/traffic-collector.js"
|
||||
import { readUptimeSettings, updateUptimeSettings, readProbeRows, replaceProbes } from "../../services/uptime-collector.js"
|
||||
import { readSpeedProbeCatalog, replaceSpeedProbeCatalog } from "../../services/speed-probe-catalog.js"
|
||||
import { getTrafficFlowSettingsRow, toTrafficFlowSettingsDto } from "../../services/traffic-flow-settings.js"
|
||||
import { getFlowListenerState } from "../../services/traffic-flow-ingest.js"
|
||||
import { withFlowConfigurationLock, getFlowHostStatus, reconcileFlowHost } from "../../services/traffic-flow-host-agent.js"
|
||||
import { agentSocket } from "../../services/traffic-flow-agent-client.js"
|
||||
import { getStatistics, getStatisticsPivot, pivotDimsConflict } from "../../services/statistics-aggregate.js"
|
||||
import { getServersApiPingSettings, updateServersApiPingSettings } from "../../services/servers-rest-ping-collector.js"
|
||||
import { getInternetPathSettings, updateInternetPathSettings } from "../../services/internet-path-collector.js"
|
||||
import { getGeoipSettings, updateGeoipSettings } from "../../services/geoip-settings.js"
|
||||
import { buildGeoipStatus } from "../../services/geoip-status.js"
|
||||
import { publicEvoBgpSettings, readEvoBgpSettings } from "../../services/evobgp-settings.js"
|
||||
import { readEvoBgpCatalogPage } from "./evobgp-catalog.js"
|
||||
import { readWanRuntimeForServer } from "../../services/wan-runtime.js"
|
||||
import { readTrafficConfiguration, readUptimeConfiguration, readServersApiPingConfiguration } from "./telemetry-settings-configuration.js"
|
||||
import { registerMcpService } from "./service-registry.js"
|
||||
import { decryptSecret, fingerprint, McpError } from "./security.js"
|
||||
import type { add } from "./actions.js"
|
||||
import type { ActionContext, ChangeEvidence } from "./action-types.js"
|
||||
|
||||
const sid = z.number().int().positive().max(Number.MAX_SAFE_INTEGER)
|
||||
const paging = { limit: z.number().int().min(1).max(200).default(100), offset: z.number().int().min(0).max(100000).default(0) }
|
||||
async function serverSecret(input: Record<string, unknown>, context: ActionContext) {
|
||||
const config = input.server as Record<string, unknown>, reference = input.passwordRef
|
||||
if (!reference) return config
|
||||
const { rows: [secret] } = await pool.query("SELECT * FROM mcp_secrets WHERE id=$1 AND created_by=$2", [reference, context.connection.owner_id])
|
||||
const existing = input.serverId ? await getServerRowById(Number(input.serverId)) : undefined
|
||||
const host = String(config.host ?? existing?.host ?? ""), port = Number(config.port ?? existing?.port ?? 443)
|
||||
if (!secret || secret.purpose !== `server-login:${host}:${port}` || secret.server_id && Number(secret.server_id) !== input.serverId) throw new McpError("SECRET_UNAVAILABLE", "Секрет не выдан этому назначению")
|
||||
return { ...config, password: await decryptSecret(secret.ciphertext) }
|
||||
}
|
||||
export function registerApplicationActions(register: typeof add) {
|
||||
registerBackupStorageActions(register)
|
||||
register("servers.wan-runtime.read", "mm_server_wan_runtime_get", "WAN IPv4/main: шлюз, интерфейс и адреса без доказательства forwarding", "servers", "mm:servers:read", "read", "servers", z.object({ serverId: sid, ...paging }).strict(), {
|
||||
read: async input => {
|
||||
const server = await getServerRowById(Number(input.serverId))
|
||||
if (!server) throw new McpError("NOT_FOUND", "Сервер не найден", 404)
|
||||
const result = await readWanRuntimeForServer(server), offset = Number(input.offset), limit = Number(input.limit)
|
||||
const uplinks = result.uplinks.slice(offset, offset + limit).map(uplink => ({ ...uplink, addresses: uplink.addresses.slice(0, 100), addressesTruncated: uplink.addresses.length > 100 }))
|
||||
return { ...result, serverId: server.id, observedAt: new Date().toISOString(), uplinks, totalUplinks: result.uplinks.length, nextOffset: offset + limit < result.uplinks.length ? offset + limit : null,
|
||||
incomplete: result.activeDefaultsTruncated || uplinks.length < result.uplinks.length || uplinks.some(uplink => uplink.addressesTruncated) }
|
||||
},
|
||||
})
|
||||
register("servers.snapshots.read", "mm_server_snapshots_get", "История состояния выбранного сервера", "servers", "mm:servers:read", "read", "servers", z.object({ serverId: sid, limit: z.number().int().min(1).max(200).default(50) }).strict(), {
|
||||
read: async input => {
|
||||
const server = await getServerRowById(Number(input.serverId))
|
||||
if (!server) throw new McpError("NOT_FOUND", "Сервер не найден", 404)
|
||||
const snapshots = await listServerSnapshots(server.id, Number(input.limit))
|
||||
return { serverId: server.id, snapshots, incomplete: snapshots.length === Number(input.limit) }
|
||||
},
|
||||
})
|
||||
register("servers.source-address.read", "mm_server_source_address_get", "IPv4 адрес управляющего host, без подтверждения адреса интерфейса", "servers", "mm:servers:read", "read", "servers", z.object({ serverId: sid }).strict(), {
|
||||
read: async input => {
|
||||
const server = await getServerRowById(Number(input.serverId))
|
||||
if (!server) throw new McpError("NOT_FOUND", "Сервер не найден", 404)
|
||||
return { host: server.host, ipv4: await resolveRosSrcIpv4(server.host), purpose: "management-host-resolution", interfaceAddressVerified: false }
|
||||
},
|
||||
})
|
||||
function query(id: string, tool: string, title: string, permission: string, read: NonNullable<Parameters<typeof register>[8]["read"]>, schema = z.object({}).strict()) {
|
||||
register(id, tool, title, permission.split(":")[1], permission, "read", "global", schema, { read: async (input, context) => {
|
||||
const ids = (await pool.query<{ id: number }>("SELECT id FROM servers")).rows.map(row => Number(row.id))
|
||||
const { rows: grants } = await pool.query("SELECT payload FROM mcp_grants WHERE connection_id=$1 AND revoked_at IS NULL AND payload->>'credentialId'=$2", [context.connection.id, context.principal.credentialId])
|
||||
if (ids.some(id => !context.connection.policy.scope.serverIds.includes(id) || grants.some(grant => !grant.payload.scope?.serverIds?.includes(id)))) throw new McpError("SERVER_DENIED", "Глобальный обзор содержит серверы вне эффективной области доступа")
|
||||
return read(input, context)
|
||||
} })
|
||||
}
|
||||
function change(id: string, tool: string, title: string, permission: string, schema: z.ZodObject,
|
||||
current: (input: Record<string, unknown>) => Promise<unknown>, execute: (input: Record<string, unknown>) => Promise<unknown>, serverScoped = false) {
|
||||
const dependencyIds = async () => (await pool.query<{ id: number }>("SELECT id FROM servers ORDER BY id LIMIT 1001")).rows.map(row => Number(row.id))
|
||||
const observed = async (input: Record<string, unknown>) => serverScoped ? current(input) : { value: await current(input), serverIds: await dependencyIds() }
|
||||
const verifiedConfiguration = ["alerts.replace", "backups.delete", "uptime.ping.probes.replace", "uptime.speed.probes.replace", "netflow.host.reconcile"].includes(id) || id.endsWith(".update")
|
||||
registerMcpService(id, { current: observed, execute, verify: async (input, result) => {
|
||||
if (input.settings && typeof input.settings === "object") {
|
||||
const saved = await current(input) as Record<string, unknown>
|
||||
return !!saved && Object.entries(input.settings).every(([key, value]) => fingerprint(saved[key]) === fingerprint(value))
|
||||
}
|
||||
if (id === "alerts.replace") return !!result && fingerprint(await current(input)) === fingerprint({ revision: (result as { revision: unknown }).revision })
|
||||
if (id === "backups.delete") return !await getBackupById(String(input.backupId))
|
||||
if (id === "netflow.host.reconcile") {
|
||||
const status = await getFlowHostStatus()
|
||||
return status.reachable && status.interfaceUp && status.configMatches === true
|
||||
}
|
||||
if (Array.isArray(input.probes)) {
|
||||
const saved = await current(input) as Record<string, unknown>[]
|
||||
return saved.length === input.probes.length && input.probes.every((probe, index) => Object.entries(probe).every(([key, value]) => fingerprint(saved[index][key]) === fingerprint(key === "intervalSec" && Number(value) > 0 ? Math.max(5, Number(value)) : value)))
|
||||
}
|
||||
return result !== undefined
|
||||
} })
|
||||
register(id, tool, title, permission.split(":")[1], permission, "write", serverScoped ? "servers" : "global", schema, {
|
||||
...(["traffic.settings.update", "uptime.settings.update", "servers.api-ping.settings.update"].includes(id) ? { version: 2 } : {}),
|
||||
prepare: async (input, context) => {
|
||||
const serverId = typeof input.serverId === "number" ? input.serverId : undefined
|
||||
if (serverId && !context.connection.policy.scope.serverIds.includes(serverId)) throw new McpError("SERVER_DENIED", "Сервер не разрешён")
|
||||
const before = await observed(input), ids = serverScoped ? [serverId!] : (before as { serverIds: number[] }).serverIds
|
||||
if (ids.some(id => !context.connection.policy.scope.serverIds.includes(id))) throw new McpError("SERVER_DENIED", "Глобальная настройка затрагивает серверы вне выданной области")
|
||||
const objects: ChangeEvidence["objects"] = ids.map(serverId => ({ kind: "server", id: String(serverId), serverId }))
|
||||
const domain = ({ "traffic.settings.update": "traffic-settings", "uptime.settings.update": "uptime-settings", "alerts.replace": "alerts-configuration", "backups.schedule.update": "backup-schedule", "certificates.renew.update": "certificate-renewal", "netflow.host.reconcile": "netflow-host", "servers.api-ping.settings.update": "servers-api-ping-settings", "internet-path.settings.update": "internet-path-settings" } as Record<string, string>)[id]
|
||||
if (domain) objects.push({ kind: domain, id: "1" })
|
||||
if (id === "geoip.settings.update") objects.push({ kind: "geoip-settings", id: "1" })
|
||||
if (Array.isArray(input.probes)) {
|
||||
if (input.probes.some(probe => [probe.srcServerId, probe.dstServerId].filter(value => value !== undefined).some(value => !ids.includes(Number(value))))) throw new McpError("SERVER_DENIED", "Проба ссылается на неизвестный или недоступный сервер")
|
||||
const old = (before as { value: { id: string }[] }).value
|
||||
objects.push(...[...new Set([...old.map(probe => probe.id), ...input.probes.map(probe => String(probe.id))])].map(probeId => ({ kind: id.includes(".ping.") ? "ping-probe" : "speed-probe", id: probeId })))
|
||||
}
|
||||
return { target: serverScoped ? { scope: "servers", serverIds: [serverId!] } : { scope: "global" }, objects, snapshots: [], before, steps: [{ kind: "service", service: id, input }], verificationScope: verifiedConfiguration ? "configuration" : "acknowledgement", warnings: [...(id === "geoip.settings.update" ? ["При включённом GeoIP обновление расписания запускает проверку зеркала сразу и затем по интервалу. Подтверждается сохранение настроек; доставка и готовность баз проверяются отдельно через состояние GeoIP. Отключение не отменяет уже начатую загрузку."] : []), ...(!verifiedConfiguration ? ["Подтверждается приём/завершение команды сервисом. Итоговое состояние внешней системы требует отдельной проверки."] : []), ...(Array.isArray(input.probes) ? ["Заменяется весь каталог проб, включая удаление отсутствующих ID. Включённые bandwidth-пробы создают повторяющуюся сетевую нагрузку по расписанию."] : []), ...(serverScoped ? [] : ["Глобальная операция затрагивает всё приложение. Проверен полный текущий каталог серверов; его изменение инвалидирует план."])], requiresHuman: true }
|
||||
},
|
||||
})
|
||||
}
|
||||
for (const action of ["create", "update", "delete"] as const) {
|
||||
const schema = action === "delete" ? z.object({ serverId: sid }).strict() : z.object({ ...(action === "update" ? { serverId: sid } : {}), server: (action === "create" ? serverCreateSchema.omit({ password: true }) : serverUpdateSchema.omit({ password: true })).strict(), passwordRef: z.string().uuid().optional() }).strict()
|
||||
const id = `servers.${action}`
|
||||
registerMcpService(id, {
|
||||
current: async input => input.serverId ? getServerRowById(Number(input.serverId)) : null,
|
||||
execute: async input => {
|
||||
if (action === "delete") { await deleteServer(Number(input.serverId)); return { deleted: true } }
|
||||
const config = action === "create" ? serverCreateSchema.parse(input.resolvedServer) : serverUpdateSchema.parse(input.resolvedServer)
|
||||
return action === "create" ? createServer(config as z.infer<typeof serverCreateSchema>) : updateServer(Number(input.serverId), config)
|
||||
},
|
||||
verify: async (input, result) => {
|
||||
if (action === "delete") return !await getServerRowById(Number(input.serverId))
|
||||
const id = action === "create" ? Number((result as { id?: number } | undefined)?.id) : Number(input.serverId)
|
||||
const saved = await getServerRowById(id)
|
||||
if (!saved) return false
|
||||
const expected = action === "create" ? serverCreateSchema.parse(input.resolvedServer) : serverUpdateSchema.parse(input.resolvedServer)
|
||||
return Object.entries(expected).every(([key, value]) => fingerprint(saved[key as keyof typeof saved]) === fingerprint(value))
|
||||
},
|
||||
})
|
||||
register(id, `mm_servers_${action}_plan`, `Серверы: ${action}`, "servers", "mm:servers:write", "write", action === "create" ? "global" : "servers", schema, {
|
||||
prepare: async (input, context) => {
|
||||
const before = input.serverId ? await getServerRowById(Number(input.serverId)) : null
|
||||
if (action !== "create" && !before) throw new McpError("NOT_FOUND", "Сервер не найден", 404)
|
||||
const config = input.server as Record<string, unknown> | undefined
|
||||
if (action === "update" && ["host", "port", "username", "useSsl", "verifySsl"].some(key => config?.[key] !== undefined && config[key] !== before?.[key as keyof typeof before]) && !input.passwordRef) throw new McpError("SECRET_UNAVAILABLE", "Для изменения параметров подключения нужен секрет, привязанный к назначению")
|
||||
const resolvedServer = action === "delete" ? undefined : await serverSecret(input, context)
|
||||
return { target: action === "create" ? { scope: "global" } : { scope: "servers", serverIds: [Number(input.serverId)] }, objects: input.serverId ? [{ kind: "server", id: String(input.serverId), serverId: Number(input.serverId) }] : [], snapshots: [], before,
|
||||
steps: [{ kind: "service", service: id, input: { ...input, resolvedServer } }], requiresHuman: true, warnings: ["Новый сервер не добавляется автоматически в разрешения MCP."] }
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
query("alerts.read", "mm_alerts_get", "Оповещения и история", "mm:alerts:read", async () => getAlertsOverview())
|
||||
query("alerts.outbox.read", "mm_alerts_outbox_get", "Очередь уведомлений", "mm:alerts:read", async () => getAlertsOutboxOverview())
|
||||
change("alerts.replace", "mm_alerts_update_plan", "Настройка правил оповещений", "mm:alerts:write", z.object({ configuration: putRulesSchema }).strict(),
|
||||
async () => { const result = await getAlertsOverview(); return { revision: result.revision } },
|
||||
async input => { const result = await replaceAlertsConfig(putRulesSchema.parse(input.configuration)); await refreshScheduler(); return result })
|
||||
query("scheduler.read", "mm_scheduler_get", "Состояние планировщика", "mm:settings:admin", async () => getSchedulerStatus())
|
||||
query("evobgp.settings.read", "mm_evobgp_settings_get", "Публичные настройки интеграции EvoBGP", "mm:settings:admin", async () => publicEvoBgpSettings(await readEvoBgpSettings()))
|
||||
query("evobgp.catalog.read", "mm_evobgp_catalog_get", "Каталог EvoBGP: модули, ASN, префиксы и Community", "mm:settings:admin", readEvoBgpCatalogPage, z.object(paging).strict())
|
||||
const trafficSettings = z.object({ enabled: z.boolean().optional(), intervalSec: z.number().int().min(5).max(3600).optional(), retentionDays: z.number().int().min(1).max(365).optional() }).strict().refine(value => Object.keys(value).length > 0)
|
||||
const uptimeSettings = z.object({ enabled: z.boolean().optional(), resourcesEnabled: z.boolean().optional(), pingEnabled: z.boolean().optional(), speedEnabled: z.boolean().optional(), intervalSec: z.number().int().min(5).max(86400).optional(), probeIntervalSec: z.number().int().min(5).max(86400).optional(), speedIntervalSec: z.number().int().min(10).max(86400).optional(), retentionDays: z.number().int().min(1).max(365).optional() }).strict().refine(value => Object.keys(value).length > 0)
|
||||
query("traffic.settings.read", "mm_traffic_settings_get", "Настройки сбора трафика", "mm:traffic:read", async () => getTrafficSettings())
|
||||
query("geoip.read", "mm_geoip_get", "Состояние локальных баз GeoIP и обновлений", "mm:traffic:read", async () => buildGeoipStatus())
|
||||
const geoipSettings = geoipSettingsPatchSchema.strict().refine(value => Object.keys(value).length > 0)
|
||||
change("geoip.settings.update", "mm_geoip_settings_update_plan", "План настроек обновления GeoIP", "mm:traffic:write", z.object({ settings: geoipSettings }).strict(), async () => {
|
||||
const settings = await getGeoipSettings()
|
||||
return { enabled: settings.enabled, updateIntervalSec: settings.updateIntervalSec }
|
||||
}, async input => {
|
||||
const settings = await updateGeoipSettings(geoipSettings.parse(input.settings))
|
||||
await refreshScheduler()
|
||||
return { settings, verificationScope: "configuration-persistence" }
|
||||
})
|
||||
change("traffic.settings.update", "mm_traffic_settings_update_plan", "План настроек сбора трафика", "mm:traffic:write", z.object({ settings: trafficSettings }).strict(), readTrafficConfiguration, async input => { const result = await updateTrafficSettings(trafficSettings.parse(input.settings)); await refreshScheduler(); return result })
|
||||
query("uptime.settings.read", "mm_uptime_settings_get", "Настройки мониторинга и проб", "mm:uptime:read", async () => readUptimeSettings())
|
||||
change("uptime.settings.update", "mm_uptime_settings_update_plan", "План расписания мониторинга и проб", "mm:uptime:write", z.object({ settings: uptimeSettings }).strict(), readUptimeConfiguration, async input => { const result = await updateUptimeSettings(uptimeSettings.parse(input.settings)); await refreshScheduler(); return result })
|
||||
query("netflow.settings.read", "mm_netflow_settings_get", "Публичные настройки NetFlow", "mm:traffic:read", async () => toTrafficFlowSettingsDto(getFlowListenerState()))
|
||||
query("scheduler.runs", "mm_scheduler_runs_list", "История заданий", "mm:settings:admin", async input => listSchedulerRuns({ limit: Number(input.limit), offset: Number(input.offset), jobKey: input.jobKey as string | undefined }), z.object({ ...paging, jobKey: z.enum(JOB_KEYS).optional() }).strict())
|
||||
query("servers.api-ping.settings.read", "mm_servers_api_ping_settings_get", "Настройки API-ping серверов", "mm:servers:read", async () => getServersApiPingSettings())
|
||||
change("servers.api-ping.settings.update", "mm_servers_api_ping_settings_update_plan", "План API-ping серверов", "mm:servers:write", z.object({ settings: z.object({ enabled: z.boolean().optional(), intervalSec: z.number().int().min(10).max(86400).optional() }).strict().refine(value => Object.keys(value).length > 0) }).strict(), readServersApiPingConfiguration, async input => { const result = await updateServersApiPingSettings(input.settings as Parameters<typeof updateServersApiPingSettings>[0]); await refreshScheduler(); return result })
|
||||
query("internet-path.settings.read", "mm_internet_path_settings_get", "Настройки наблюдений Internet path", "mm:network:read", async () => getInternetPathSettings())
|
||||
const readInternetPathConfiguration = async () => {
|
||||
const row = await getInternetPathSettings()
|
||||
// Collection also updates updatedAt: bind plans to configuration, not run status.
|
||||
return { enabled: row.enabled, intervalSec: row.intervalSec, retentionDays: row.retentionDays }
|
||||
}
|
||||
change("internet-path.settings.update", "mm_internet_path_settings_update_plan", "План сбора Internet path", "mm:network:write", z.object({ settings: z.object({ enabled: z.boolean().optional(), intervalSec: z.number().int().min(30).max(86400).optional(), retentionDays: z.number().int().min(1).max(365).optional() }).strict().refine(value => Object.keys(value).length > 0) }).strict(), readInternetPathConfiguration, async input => { const result = await updateInternetPathSettings(input.settings as Parameters<typeof updateInternetPathSettings>[0]); await refreshScheduler(); return result })
|
||||
query("netflow.host.read", "mm_netflow_host_get", "Состояние хоста NetFlow", "mm:settings:admin", async () => getFlowHostStatus())
|
||||
change("netflow.host.reconcile", "mm_netflow_host_reconcile_plan", "План применения сохранённой конфигурации хоста NetFlow", "mm:settings:admin", z.object({}).strict(), async () => {
|
||||
if (!agentSocket()) throw new McpError("UNSUPPORTED_CAPABILITY", "Локальный агент NetFlow не настроен", 409)
|
||||
const row = await getTrafficFlowSettingsRow()
|
||||
return { configurationHash: fingerprint({ collectorIp: row.collectorIp, prefix: row.prefix, flowListenPort: row.flowListenPort, wgListenPort: row.wgListenPort, hostPublicKey: row.hostPublicKey, hostPrivateKey: row.hostPrivateKey, peersJson: row.peersJson }) }
|
||||
}, async () => withFlowConfigurationLock(() => reconcileFlowHost()))
|
||||
const pingCatalog = async () => (await readProbeRows()).map(row => ({ id: row.id, srcServerId: row.srcServerId, srcInterface: row.srcInterface, name: row.name, target: row.target, filter: row.probeFilter, enabled: row.enabled, showOnDashboard: row.showOnDashboard, intervalSec: row.intervalSec }))
|
||||
const speedCatalog = async () => (await readSpeedProbeCatalog()).map(row => ({ id: row.id, srcServerId: row.srcServerId, dstServerId: row.dstServerId, srcInterface: row.srcInterface, dstInterface: row.dstInterface, protocol: row.protocol, direction: row.direction, durationSec: row.durationSec, enabled: row.enabled }))
|
||||
query("uptime.ping.probes.read", "mm_ping_probes_get", "Каталог Ping-проб", "mm:uptime:read", pingCatalog)
|
||||
query("uptime.speed.probes.read", "mm_speed_probes_get", "Каталог bandwidth-проб", "mm:uptime:read", speedCatalog)
|
||||
const boundedCatalog = (item: z.ZodObject) => z.object({ probes: z.array(item.strict()).max(500).refine(rows => new Set(rows.map(row => row.id)).size === rows.length, "Повторный ID пробы") }).strict()
|
||||
change("uptime.ping.probes.replace", "mm_ping_probes_replace_plan", "План полного каталога Ping-проб", "mm:uptime:write", boundedCatalog(pingProbeWriteSchema), pingCatalog, async input => { await replaceProbes(input.probes as PingProbeWrite[]); await refreshScheduler(); return { saved: true } })
|
||||
change("uptime.speed.probes.replace", "mm_speed_probes_replace_plan", "План полного каталога bandwidth-проб", "mm:uptime:write", boundedCatalog(speedProbeWriteSchema.safeExtend({ durationSec: z.number().int().min(3).max(30) })), speedCatalog, async input => { await replaceSpeedProbeCatalog(input.probes as Parameters<typeof replaceSpeedProbeCatalog>[0]); await refreshScheduler(); return { saved: true } })
|
||||
change("scheduler.run", "mm_scheduler_run_plan", "Запуск задания", "mm:settings:admin", z.object({ jobKey: z.enum(JOB_KEYS) }).strict(), async () => null, async input => runSchedulerJobNow(String(input.jobKey)))
|
||||
change("scheduler.refresh", "mm_scheduler_refresh_plan", "Перечитать расписания", "mm:settings:admin", z.object({}).strict(), async () => null, async () => { await refreshScheduler(); return { refreshed: true } })
|
||||
query("events.read", "mm_events_list", "События приложения", "mm:dashboard:read", async input => ({ events: await readEvents({ limit: Number(input.limit) }) }), z.object({ limit: z.number().int().min(1).max(200).default(100) }).strict())
|
||||
|
||||
register("backups.jobs.read", "mm_backup_job_get", "Состояние фоновой backup-задачи приложения", "backups", "mm:backups:read", "read", "servers", z.object({ jobId: z.string().min(1).max(200), ...paging }).strict(), {
|
||||
read: async (input, context) => {
|
||||
let result: ReturnType<typeof projectScopedBackupJob>
|
||||
try { result = projectScopedBackupJob(readBackupJob(String(input.jobId)), new Set(context.connection.policy.scope.serverIds), Number(input.offset), Number(input.limit)) }
|
||||
catch { throw new McpError("STATE_UNAVAILABLE", "Прогресс backup-задачи не подтверждён", 503) }
|
||||
if (!result) throw new McpError("NOT_FOUND", "Backup-задача не найдена в разрешённой области", 404)
|
||||
const rows = await pool.query("SELECT id FROM servers WHERE id=ANY($1::bigint[])", [result.serverIds])
|
||||
if (rows.rows.length !== result.serverIds.length) throw new McpError("NOT_FOUND", "Устройства backup-задачи отсутствуют в каталоге", 404)
|
||||
return result
|
||||
},
|
||||
})
|
||||
register("backups.list", "mm_backups_list", "Резервные копии", "backups", "mm:backups:read", "read", "servers", z.object({ serverId: sid, ...paging }).strict(), {
|
||||
read: async input => {
|
||||
const { rows } = await pool.query<{ id: string }>("SELECT id FROM backup_entries WHERE server_id=$1 ORDER BY created_at DESC LIMIT $2 OFFSET $3", [String(input.serverId), input.limit, input.offset])
|
||||
return { items: await Promise.all(rows.map(row => getBackupById(row.id))) }
|
||||
},
|
||||
})
|
||||
change("backups.create", "mm_backups_create_plan", "Создание backup", "mm:backups:write", z.object({ serverId: sid, notes: z.string().max(1000).default("") }).strict(), async input => getServerRowById(Number(input.serverId)), async input => runBackupForServer(String(input.serverId), "manual", String(input.notes)), true)
|
||||
for (const action of ["restore", "delete"] as const) {
|
||||
change(`backups.${action}`, `mm_backups_${action}_plan`, `Backup: ${action}`, "mm:backups:write", z.object({ serverId: sid, backupId: z.string().min(1).max(200) }).strict(), async input => {
|
||||
const backup = await getBackupById(String(input.backupId)); if (!backup || backup.serverId !== input.serverId) throw new McpError("NOT_FOUND", "Резервная копия не найдена", 404); return backup
|
||||
}, async input => action === "restore" ? restoreBackupToDevice(String(input.backupId)) : deleteBackupRecord(String(input.backupId)), true)
|
||||
}
|
||||
query("backups.schedule.read", "mm_backup_schedule_get", "Расписание backup", "mm:backups:read", async () => getBackupScheduleSettings())
|
||||
query("backups.storage.read", "mm_backup_storage_get", "Состояние хранилища backup", "mm:backups:read", async () => getBackupStorageSettings())
|
||||
change("backups.schedule.update", "mm_backup_schedule_update_plan", "Изменение расписания backup", "mm:backups:write", z.object({ settings: putBackupScheduleSettingsSchema }).strict(), async () => getBackupScheduleSettings(), async input => { const result = await updateBackupScheduleSettings(putBackupScheduleSettingsSchema.parse(input.settings)); await refreshScheduler(); return result })
|
||||
change("backups.storage.test", "mm_backup_storage_test_plan", "Проверка хранилища backup", "mm:backups:write", z.object({}).strict(), async () => getBackupStorageSettings(), async () => testBackupStorageConnection())
|
||||
change("backups.storage.sync", "mm_backup_storage_sync_plan", "Синхронизация backup из S3", "mm:backups:write", z.object({}).strict(), async () => null, async () => syncBackupsFromS3())
|
||||
query("certificates.acme.read", "mm_acme_settings_get", "Публичные настройки ACME", "mm:certificates:read", async () => getAcmeSettingsPublic())
|
||||
query("certificates.renew.read", "mm_certificate_renew_get", "Настройки продления сертификатов", "mm:certificates:read", async () => getCertificateRenewSettings())
|
||||
change("certificates.renew.update", "mm_certificate_renew_update_plan", "Настройка продления сертификатов", "mm:certificates:write", z.object({ settings: z.object({ enabled: z.boolean().optional(), intervalSec: z.number().int().min(60).max(86400).optional(), renewBeforeDays: z.number().int().min(1).max(365).optional() }).strict() }).strict(), async () => getCertificateRenewSettings(), async input => updateCertificateRenewSettings(input.settings as Parameters<typeof updateCertificateRenewSettings>[0]))
|
||||
|
||||
for (const table of ["traffic_samples", "uptime_resource_samples", "uptime_probe_samples"] as const) {
|
||||
register(`monitoring.${table}.read`, `mm_${table}_query`, `История: ${table}`, "monitoring", table === "traffic_samples" ? "mm:traffic:read" : "mm:uptime:read", "read", "servers", z.object({ serverId: sid, ...paging }).strict(), {
|
||||
read: async input => {
|
||||
if (table === "uptime_probe_samples") {
|
||||
const { rows } = await pool.query("SELECT s.* FROM uptime_probe_samples s JOIN uptime_probes p ON p.id=s.probe_id WHERE p.src_server_id=$1 ORDER BY sampled_at DESC LIMIT $2 OFFSET $3", [input.serverId, input.limit, input.offset]); return { items: rows }
|
||||
}
|
||||
const { rows } = await pool.query(`SELECT * FROM ${table} WHERE server_id=$1 ORDER BY sampled_at DESC LIMIT $2 OFFSET $3`, [input.serverId, input.limit, input.offset]); return { items: rows }
|
||||
},
|
||||
})
|
||||
}
|
||||
register("monitoring.flow.analytics", "mm_netflow_analytics_get", "NetFlow выбранного сервера", "traffic", "mm:traffic:read", "read", "servers", z.object({ serverId: sid, minutes: z.number().int().min(1).max(1440).default(15), interface: z.string().min(1).max(100).optional() }).strict(), {
|
||||
read: async input => {
|
||||
const result = await buildFlowAnalytics({ serverId: Number(input.serverId), minutes: Number(input.minutes), iface: input.interface as string | undefined, skipHeavy: true })
|
||||
return { serverId: input.serverId, source: "PostgreSQL/flow_buckets", observedAt: new Date().toISOString(), bpsNow: result.bpsNow, bytes: result.bytes, packets: result.packets, conversations: result.conversations, uniqueSrc: result.uniqueSrc, uniqueDst: result.uniqueDst, rxSeries: result.rxSeries, txSeries: result.txSeries, applications: result.applications, protocols: result.protocols, countries: result.countries, categories: result.categories, services: result.services,
|
||||
sources: result.sources.map(row => ({ ...row, label: row.id })), destinations: result.destinations.map(row => ({ ...row, label: row.id })) }
|
||||
},
|
||||
})
|
||||
register("monitoring.flow.monthly", "mm_netflow_monthly_get", "Месячная статистика NetFlow выбранного сервера", "traffic", "mm:traffic:read", "read", "servers", z.object({ serverId: sid, month: z.string().regex(/^\d{4}-(0[1-9]|1[0-2])$/) }).strict(), {
|
||||
read: async input => ({ serverId: input.serverId, source: "PostgreSQL/flow_daily_dims", observedAt: new Date().toISOString(), statistics: await getFlowMonthly(String(input.month), Number(input.serverId), { maxKeys: 10000 }) }),
|
||||
})
|
||||
const period = { serverId: sid, from: z.string().date(), to: z.string().date() }
|
||||
for (const pivot of [false, true]) {
|
||||
const schema = (pivot ? statisticsPivotQuerySchema : statisticsQuerySchema).extend(period).strict().superRefine((input, context) => {
|
||||
const span = Date.parse(input.to) - Date.parse(input.from)
|
||||
if (span < 0 || span > 30 * 86400000) context.addIssue({ code: "custom", message: "Статистика MCP ограничена 31 календарным днём" })
|
||||
})
|
||||
register(pivot ? "statistics.pivot" : "statistics.read", pivot ? "mm_statistics_pivot_get" : "mm_statistics_get", pivot ? "Сводная статистика выбранного сервера" : "Статистика выбранного сервера", "traffic", "mm:traffic:read", "read", "servers", schema, {
|
||||
read: async input => {
|
||||
if (!pivot) return getStatistics(statisticsQuerySchema.parse(input))
|
||||
const parsed = statisticsPivotQuerySchema.parse(input)
|
||||
if (pivotDimsConflict(parsed.row, parsed.col)) throw new McpError("INVALID_INPUT", "Строки и столбцы сводной должны отличаться", 400)
|
||||
return getStatisticsPivot(parsed)
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import assert from "node:assert/strict"
|
||||
process.env.AUTH_REQUIRED="true"
|
||||
process.env.AUTH_JWT_SECRET="mcp-owned-application-acceptance-secret"
|
||||
process.env.AUTH_ISSUER="https://auth.test.local"
|
||||
const {pool,closePool}=await import("../../db/index.js")
|
||||
const {applySqlMigrations}=await import("../../db/migrate.js")
|
||||
const {updateMcpSettings}=await import("./store.js")
|
||||
const {buildApp}=await import("../../index.js")
|
||||
const actor={id:"application-acceptance-owner",apps:["mm"],permissions:["mm:settings:admin","mm:network:admin"],expiresAt:Date.now()+3600000}
|
||||
let app:Awaited<ReturnType<typeof buildApp>>|undefined
|
||||
try{
|
||||
await applySqlMigrations(pool)
|
||||
assert.equal((await pool.query("SELECT count(*)::int AS n FROM servers")).rows[0].n,0,"Disposable empty database required")
|
||||
await pool.query("INSERT INTO servers(id,name,host,port,use_ssl,enabled) VALUES(1,'owned-fixture','127.0.0.1',9,false,false)")
|
||||
await pool.query("INSERT INTO geoip_settings(id,enabled) VALUES(1,false) ON CONFLICT(id) DO UPDATE SET enabled=false")
|
||||
await pool.query("INSERT INTO traffic_flow_settings(id) VALUES(1) ON CONFLICT(id) DO NOTHING")
|
||||
await pool.query("INSERT INTO backup_storage_settings(id) VALUES(1) ON CONFLICT(id) DO NOTHING")
|
||||
await updateMcpSettings({enabled:true},actor)
|
||||
app=await buildApp({startScheduler:false});await app.ready()
|
||||
await (await import("./routing-community-context-acceptance.js")).acceptRouteCommunityContextMcp(actor)
|
||||
await (await import("../../test/mcp-acme-test.js")).verifyMcpAcmeTest(actor)
|
||||
await (await import("../../test/mcp-acme-settings.js")).verifyMcpAcmeSettings(actor)
|
||||
await (await import("../../test/mcp-geoip-update.js")).verifyMcpGeoipUpdate(actor)
|
||||
await (await import("../../test/mcp-flow-overlay.js")).verifyMcpFlowOverlay(actor)
|
||||
await (await import("../../test/mcp-backup-download.js")).verifyMcpBackupDownload(actor,app)
|
||||
await (await import("./wireguard-interface-create-acceptance.js")).acceptWireguardInterfaceCreation(actor)
|
||||
await (await import("./wireguard-peer-create-acceptance.js")).acceptWireguardPeerCreation(actor)
|
||||
await (await import("../../test/mcp-database-backup.js")).verifyMcpDatabaseBackup(actor,app)
|
||||
}finally{
|
||||
await pool.query("UPDATE servers SET enabled=false").catch(()=>{})
|
||||
// Database export resumes shared background work even for this scheduler-free
|
||||
// app fixture. Drain it before closing the pool, as the full integration does.
|
||||
await (await import("../../services/scheduler.js")).stopSchedulerForDatabaseOperation()
|
||||
await (await import("../../services/traffic-flow-ingest.js")).stopTrafficFlowListenerForDatabaseOperation()
|
||||
await app?.close();await closePool()
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { hasPermission } from "../../lib/permissions.js"
|
||||
|
||||
interface RegistrationPolicy {
|
||||
policy: { scope: { operationIds: readonly string[] }; permissions: readonly string[]; mode: string }
|
||||
owner_permissions: readonly string[]
|
||||
}
|
||||
interface RegisteredAction { operation: { id: string; requiredPermissions: readonly string[]; effect: string } }
|
||||
/** Exact existing server-registration policy. Priority and SDK registration
|
||||
* consume the SAME returned action objects; this does not replace runtime
|
||||
* revalidation or manufacture effective/temporary-grant authorization.
|
||||
*/
|
||||
export function authorizedMcpBindings<T extends RegisteredAction>(actions: readonly T[], connection: RegistrationPolicy): T[] {
|
||||
return actions.filter(action => {
|
||||
const descriptor = action.operation
|
||||
return connection.policy.scope.operationIds.includes(descriptor.id) &&
|
||||
descriptor.requiredPermissions.every(permission => hasPermission(connection.policy.permissions, permission) && hasPermission(connection.owner_permissions, permission)) &&
|
||||
(connection.policy.mode !== "observer" || descriptor.effect === "read")
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { z } from "zod"
|
||||
import { pool } from "../../db/index.js"
|
||||
import { accessGeneration, fingerprint, McpError } from "./security.js"
|
||||
import { effectiveMcpConnection } from "./store.js"
|
||||
import { registerMcpService } from "./service-registry.js"
|
||||
import { createBackupDownloadArtifact, backupDownloadAuthority, backupDownloadAuthoritySchema, readBackupDownloadSource, verifyBackupDownloadArtifact } from "./backup-download-artifact.js"
|
||||
import type { add } from "./actions.js"
|
||||
|
||||
export function registerBackupDownloadActions(register: typeof add) {
|
||||
const authority=(input:Record<string,unknown>)=>backupDownloadAuthoritySchema.parse(input.authority)
|
||||
const current=async(input:Record<string,unknown>)=>({proof:(await readBackupDownloadSource(authority(input))).proof})
|
||||
registerMcpService("backups.download",{current,
|
||||
execute:async input=>createBackupDownloadArtifact(authority(input),String(input.expectedProof)),
|
||||
verify:async(input,result)=>{
|
||||
const receipt=result as {artifactId?:string;humanDownloadOnly?:boolean;backupContentReturned?:boolean}|null
|
||||
return receipt?.humanDownloadOnly===true && receipt.backupContentReturned===false && !!receipt.artifactId && await verifyBackupDownloadArtifact(receipt.artifactId,authority(input),String(input.expectedProof))
|
||||
},
|
||||
})
|
||||
register("backups.download","mm_backup_download_plan","Подготовить владельцу скачивание полной резервной копии после человеческого подтверждения","backups","mm:backups:write","write","servers",z.object({backupId:z.string().min(1).max(200)}).strict(),{
|
||||
target:async(input,context)=>{
|
||||
const row=(await (context.transactionClient??pool).query("SELECT server_id FROM backup_entries WHERE id=$1",[input.backupId])).rows[0]
|
||||
if(!row?.server_id)throw new McpError("SERVER_DENIED","Принадлежность резервной копии не подтверждена",403)
|
||||
return {scope:"servers",serverIds:[Number(row.server_id)]}
|
||||
},
|
||||
prepare:async(input,context)=>{
|
||||
const authorize=async()=>{
|
||||
if(context.signal?.aborted)throw new McpError("READ_CANCELLED","Подготовка скачивания отменена",409)
|
||||
const fresh=await effectiveMcpConnection(context.principal)
|
||||
if(fingerprint(fresh.policy)!==fingerprint(context.connection.policy))throw new McpError("RESET_REQUIRED","Допуск изменился; подготовьте скачивание заново",409)
|
||||
}
|
||||
await authorize()
|
||||
const row=(await pool.query("SELECT server_id FROM backup_entries WHERE id=$1",[input.backupId])).rows[0]
|
||||
if(!row?.server_id)throw new McpError("SERVER_DENIED","Принадлежность резервной копии не подтверждена",403)
|
||||
const access=backupDownloadAuthority(context.principal,context.connection.owner_id,context.connection.policy,await accessGeneration(),String(input.backupId),Number(row.server_id))
|
||||
const state=await readBackupDownloadSource(access,authorize,context.signal)
|
||||
const objects=[{kind:"backup-download",id:String(input.backupId),serverId:access.serverId},{kind:"backup",id:String(input.backupId),serverId:access.serverId},{kind:"server",id:String(access.serverId),serverId:access.serverId}]
|
||||
const protections=await pool.query("SELECT object_ref FROM mcp_protections WHERE object_ref->>'serverId'=$1 OR (object_ref->>'kind'='server' AND object_ref->>'id'=$1) LIMIT 1001",[String(access.serverId)])
|
||||
if(protections.rows.length>1000)throw new McpError("STATE_UNAVAILABLE","Превышен предел защит резервной копии",409)
|
||||
for(const {object_ref:ref} of protections.rows)objects.push(ref)
|
||||
await authorize()
|
||||
return {target:{scope:"servers",serverIds:[access.serverId]},objects,snapshots:[],before:{proof:state.proof},requiresHuman:true,mutationCost:1,
|
||||
steps:[{kind:"service",service:"backups.download",input:{authority:access,expectedProof:state.proof,beforeFingerprint:fingerprint({proof:state.proof})}}],
|
||||
warnings:["Полная резервная копия может содержать пароли, приватные ключи и закрытую конфигурацию RouterOS. Подтверждение разрешает раскрытие только вошедшему владельцу через web API на 15 минут.","Модель получает только ссылку и контрольную сумму; содержимое резервной копии в MCP не возвращается. Файл ограничен 8 МиБ, чтение — 45 секундами.","Отзыв, пауза, смена прав, владельца, поколения доступа, metadata, содержимого или подключения S3 блокируют выдачу. Для нового экспорта требуется новый человеческий план; автоматического повтора нет."]}
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { createHash, randomUUID, timingSafeEqual } from "node:crypto"
|
||||
import { z } from "zod"
|
||||
import type { PoolClient } from "pg"
|
||||
import { pool } from "../../db/index.js"
|
||||
import { hasPermission, type AuthUser } from "../../lib/permissions.js"
|
||||
import { readBackupContent, type BackupMeta } from "../../services/backup-service.js"
|
||||
import { assertVerifiedConfigurationAccess, executeGuardedApplicationWrite } from "../../services/config-mutation-policy.js"
|
||||
import { assertLogArtifactBudget } from "./log-artifacts.js"
|
||||
import { assertPortalClaimsCurrent } from "./portal-revocation.js"
|
||||
import { effectiveMcpConnection, transaction, type McpPrincipal } from "./store.js"
|
||||
import { accessGeneration, decryptSecret, encryptSecret, fingerprint, McpError, privateSecretFingerprint } from "./security.js"
|
||||
|
||||
const MAX_BYTES = 8 * 1048576, MAX_CIPHER = 16 * 1048576
|
||||
const digest = z.string().regex(/^[a-f0-9]{64}$/)
|
||||
const principalSchema = z.object({ connectionId: z.string().uuid(), credentialId: z.string().uuid(), credentialExpiresAt: z.iso.datetime() }).strict()
|
||||
export const backupDownloadAuthoritySchema = z.object({ principal: principalSchema, ownerId: z.string().min(1).max(200), policyProof: digest, generation: digest, backupId: z.string().min(1).max(200), serverId: z.number().int().positive().safe() }).strict()
|
||||
export type BackupDownloadAuthority = z.infer<typeof backupDownloadAuthoritySchema>
|
||||
const scopeSchema = z.object({ version: z.literal(1), kind: z.literal("backup-download"), authority: backupDownloadAuthoritySchema, sourceProof: digest,
|
||||
bytes: z.number().int().min(0).max(MAX_BYTES), sha256: digest, encryptedSha256: digest, proof: digest }).strict()
|
||||
type Header = { id: string; connection_id: string; filename: string; media_type: string; expires_at: Date }
|
||||
type Meta = Header & { access_scope: z.infer<typeof scopeSchema>; encrypted_bytes: number }
|
||||
const hash = (value: string | Buffer) => createHash("sha256").update(value).digest("hex")
|
||||
const sign = (value: unknown) => privateSecretFingerprint(fingerprint(value), "backup-download-artifact-v1")
|
||||
const signed = (header: Header, scope: Omit<z.infer<typeof scopeSchema>, "proof">) => ({ id:header.id,connection_id:header.connection_id,filename:header.filename,media_type:header.media_type,expires_at:header.expires_at.toISOString(),scope })
|
||||
const fail = (message: string, code = "ARTIFACT_INVALID") => new McpError(code, message, 409)
|
||||
|
||||
export async function assertBackupDownloadAccess(raw: unknown, client: Pick<PoolClient, "query"> = pool) {
|
||||
const authority = backupDownloadAuthoritySchema.parse(raw), connection = await effectiveMcpConnection(authority.principal, client)
|
||||
if (connection.owner_id !== authority.ownerId || !hasPermission(connection.owner_permissions,"mm:backups:write") || !hasPermission(connection.policy.permissions,"mm:backups:write") || !connection.policy.scope.operationIds.includes("backups.download") || !connection.policy.scope.serverIds.includes(authority.serverId)) throw new McpError("PERMISSION_DENIED","Нет допуска к полной резервной копии",403)
|
||||
if (fingerprint(connection.policy) !== authority.policyProof || await accessGeneration() !== authority.generation) throw fail("Допуск изменился; подготовьте скачивание заново","RESET_REQUIRED")
|
||||
const row = (await client.query("SELECT server_id FROM backup_entries WHERE id=$1 AND EXISTS(SELECT 1 FROM servers WHERE id=backup_entries.server_id)",[authority.backupId])).rows[0]
|
||||
if (!row || Number(row.server_id)!==authority.serverId) throw fail("Принадлежность резервной копии не подтверждена","SERVER_DENIED")
|
||||
const denied=(await client.query("SELECT 1 FROM mcp_protections WHERE object_ref->>'protection'='deny' AND (object_ref->>'serverId'=$1 OR (object_ref->>'kind'='server' AND object_ref->>'id'=$1) OR (object_ref->>'kind' IN ('backup','backup-download') AND object_ref->>'id'=$2)) LIMIT 1",[String(authority.serverId),authority.backupId])).rows[0]
|
||||
if(denied)throw fail("Резервная копия заблокирована защитой объекта","PROTECTED_OBJECT")
|
||||
return { connection, serverIds: [authority.serverId] }
|
||||
}
|
||||
|
||||
async function sourceMetadata(authority: BackupDownloadAuthority) {
|
||||
const row=(await pool.query(`SELECT CASE WHEN octet_length(to_jsonb(b)::text)<=32768 THEN to_jsonb(b) ELSE NULL END AS metadata,b.xmin::text AS revision FROM backup_entries b WHERE id=$1`,[authority.backupId])).rows[0]
|
||||
const storage=(await pool.query(`SELECT CASE WHEN octet_length(to_jsonb(s)::text)<=32768 THEN to_jsonb(s) ELSE NULL END AS metadata,s.xmin::text AS revision FROM backup_storage_settings s WHERE id=1`)).rows[0]
|
||||
const protections=(await pool.query("SELECT CASE WHEN octet_length(object_ref::text)<=32768 THEN object_ref ELSE NULL END AS object_ref,xmin::text AS revision FROM mcp_protections WHERE object_ref->>'serverId'=$1 OR (object_ref->>'kind'='server' AND object_ref->>'id'=$1) OR (object_ref->>'kind' IN ('backup','backup-download') AND object_ref->>'id'=$2) ORDER BY object_ref::text LIMIT 1001",[String(authority.serverId),authority.backupId])).rows
|
||||
if(!row?.metadata || (storage && !storage.metadata)) throw fail("Источник резервной копии превышает предел или отсутствует","STATE_UNAVAILABLE")
|
||||
if(protections.length>1000 || protections.some(row=>!row.object_ref))throw fail("Защиты резервной копии превышают предел","STATE_UNAVAILABLE")
|
||||
return {backup:row.metadata,backupRevision:row.revision,storage:storage?.metadata??null,storageRevision:storage?.revision??null,protections}
|
||||
}
|
||||
export async function readBackupDownloadSource(authority: BackupDownloadAuthority, authorize: () => Promise<unknown> = async()=>{}, signal?: AbortSignal) {
|
||||
const deadline=AbortSignal.timeout(45000), bounded=signal?AbortSignal.any([signal,deadline]):deadline
|
||||
const check=async()=>{bounded.throwIfAborted();await authorize();await assertBackupDownloadAccess(authority);bounded.throwIfAborted()}
|
||||
await check()
|
||||
const before=await sourceMetadata(authority)
|
||||
await check()
|
||||
let content:Buffer
|
||||
let origin: "s3" | "local" | undefined
|
||||
try { content=await readBackupContent({id:authority.backupId} as BackupMeta,{maxBytes:MAX_BYTES,signal:bounded,authorize:check,sourceRead:source=>{origin=source}}) }
|
||||
catch {await check();throw fail("Файл резервной копии недоступен, превышает 8 МиБ или его чтение прервано","STATE_UNAVAILABLE")}
|
||||
await check()
|
||||
const after=await sourceMetadata(authority)
|
||||
if(fingerprint(before)!==fingerprint(after))throw fail("Источник изменился во время чтения","STATE_CONFLICT")
|
||||
await check()
|
||||
if(!origin)throw fail("Источник файла не подтверждён","STATE_UNAVAILABLE")
|
||||
const filename=String(after.backup.filename).replace(/[^a-zA-Z0-9._-]/g,"_").slice(0,255) || "backup.bin"
|
||||
return {content,filename,serverIds:[authority.serverId],proof:await sign({metadata:after,origin,content:content.toString("base64")})}
|
||||
}
|
||||
|
||||
export async function createBackupDownloadArtifact(authority: BackupDownloadAuthority, expectedProof: string) {
|
||||
const authorize = assertVerifiedConfigurationAccess
|
||||
return executeGuardedApplicationWrite({kind:"backup-download",id:authority.backupId},(await assertBackupDownloadAccess(authority)).serverIds,async()=>{
|
||||
const source = await readBackupDownloadSource(authority,authorize)
|
||||
if (source.proof!==expectedProof) throw fail("Резервная копия изменилась после подтверждения","STATE_CONFLICT")
|
||||
const ciphertext = await encryptSecret(source.content.toString("base64"))
|
||||
if(Buffer.byteLength(ciphertext)>MAX_CIPHER) throw fail("Зашифрованный пакет превышает предел","EXPORT_LIMIT")
|
||||
const header: Header = { id: randomUUID(), connection_id: authority.principal.connectionId, filename: source.filename, media_type: "application/octet-stream", expires_at: new Date(Date.now()+900000) }
|
||||
const access = { version: 1 as const, kind: "backup-download" as const, authority, sourceProof: source.proof, bytes: source.content.length, sha256: hash(source.content), encryptedSha256: hash(ciphertext) }
|
||||
const scope = scopeSchema.parse({...access,proof:await sign(signed(header,access))})
|
||||
await authorize()
|
||||
if ((await readBackupDownloadSource(authority,authorize)).proof!==source.proof) throw fail("Конфигурация изменилась во время формирования пакета","STATE_CONFLICT")
|
||||
await transaction(async client=>{
|
||||
await client.query("SELECT id FROM mcp_connections WHERE id=$1 FOR UPDATE",[header.connection_id])
|
||||
await assertBackupDownloadAccess(authority,client)
|
||||
await client.query("SELECT pg_advisory_xact_lock(hashtextextended('mcp-log-artifact-storage',0))")
|
||||
await client.query("DELETE FROM mcp_artifacts WHERE expires_at<=now()")
|
||||
const usage = (await client.query(`SELECT count(*) FILTER(WHERE connection_id=$1)::text AS files,COALESCE(sum(octet_length(ciphertext)) FILTER(WHERE connection_id=$1),0)::text AS own_bytes,COALESCE(sum(octet_length(ciphertext)),0)::text AS total_bytes FROM mcp_artifacts`,[header.connection_id])).rows[0]
|
||||
assertLogArtifactBudget(BigInt(usage.files),BigInt(usage.own_bytes),BigInt(usage.total_bytes),Buffer.byteLength(ciphertext))
|
||||
await client.query("INSERT INTO mcp_artifacts(id,connection_id,filename,media_type,ciphertext,access_scope,expires_at) VALUES($1,$2,$3,$4,$5,$6::jsonb,$7)",[header.id,header.connection_id,header.filename,header.media_type,ciphertext,JSON.stringify(scope),header.expires_at])
|
||||
})
|
||||
await authorize();await assertBackupDownloadAccess(authority)
|
||||
return {artifactId:header.id,downloadPath:`/api/mcp/artifacts/${header.id}`,expiresInSeconds:900,bytes:access.bytes,sha256:access.sha256,humanDownloadOnly:true,backupContentReturned:false,configurationApplied:false}
|
||||
})
|
||||
}
|
||||
|
||||
async function loadMeta(id: string): Promise<Meta> {
|
||||
const row = (await pool.query<Meta>("/* MCP backup artifact metadata */ SELECT id,connection_id,filename,media_type,expires_at,CASE WHEN octet_length(access_scope::text)<=32768 THEN access_scope ELSE NULL END AS access_scope,octet_length(ciphertext)::int AS encrypted_bytes FROM mcp_artifacts WHERE id=$1 AND expires_at>now() AND octet_length(filename)<=1024 AND octet_length(media_type)<=200",[id])).rows[0]
|
||||
if (!row) throw new McpError("NOT_FOUND","Пакет отсутствует или срок истёк",404)
|
||||
const scope = scopeSchema.safeParse(row.access_scope)
|
||||
if (!scope.success || !(row.expires_at instanceof Date) || !Number.isFinite(row.expires_at.getTime()) || !Number.isSafeInteger(row.encrypted_bytes) || row.encrypted_bytes>MAX_CIPHER) throw fail("Метаданные пакета не подтверждены")
|
||||
const {proof,...access} = scope.data, expected = await sign(signed(row,access))
|
||||
if (!timingSafeEqual(Buffer.from(proof,"hex"),Buffer.from(expected,"hex")) || row.connection_id!==access.authority.principal.connectionId) throw fail("Подпись пакета не совпала")
|
||||
return {...row,access_scope:scope.data}
|
||||
}
|
||||
export async function verifyBackupDownloadArtifact(id: string, authority: BackupDownloadAuthority, sourceProof: string) {
|
||||
const meta=await loadMeta(id)
|
||||
await assertBackupDownloadAccess(authority)
|
||||
if(fingerprint(meta.access_scope.authority)!==fingerprint(authority) || meta.access_scope.sourceProof!==sourceProof)return false
|
||||
const payload=(await pool.query<{ciphertext:string}>("SELECT ciphertext FROM mcp_artifacts WHERE id=$1 AND access_scope->>'proof'=$2 AND expires_at>now() AND octet_length(ciphertext)<=16777216",[id,meta.access_scope.proof])).rows[0]
|
||||
await assertBackupDownloadAccess(authority)
|
||||
return !!payload && hash(payload.ciphertext)===meta.access_scope.encryptedSha256 && (await readBackupDownloadSource(authority)).proof===sourceProof
|
||||
}
|
||||
/** Receipt listing validates sealed metadata and access without reading source or artifact payload. */
|
||||
export async function humanBackupDownloadMetadata(id: string, actor: AuthUser, checkClaims = assertPortalClaimsCurrent) {
|
||||
const meta = await loadMeta(id), scope = meta.access_scope
|
||||
await checkClaims(actor)
|
||||
if (actor.id !== scope.authority.ownerId || !hasPermission(actor.permissions, "mm:backups:write")) throw new McpError("PERMISSION_DENIED", "Файл доступен только владельцу", 403)
|
||||
await assertBackupDownloadAccess(scope.authority)
|
||||
return scope
|
||||
}
|
||||
export async function readHumanBackupDownloadArtifact(id: string, actor: AuthUser, checkClaims = assertPortalClaimsCurrent, signal?:AbortSignal) {
|
||||
const deadline=AbortSignal.timeout(45000),bounded=signal?AbortSignal.any([signal,deadline]):deadline
|
||||
bounded.throwIfAborted()
|
||||
const meta=await loadMeta(id), scope=meta.access_scope
|
||||
const authorize=async()=>{
|
||||
bounded.throwIfAborted()
|
||||
await checkClaims(actor)
|
||||
if(actor.id!==scope.authority.ownerId || !hasPermission(actor.permissions,"mm:backups:write")) throw new McpError("PERMISSION_DENIED","Резервная копия доступна только владельцу с правом управления backup",403)
|
||||
await assertBackupDownloadAccess(scope.authority)
|
||||
if(meta.expires_at.getTime()<=Date.now()) throw fail("Срок пакета истёк")
|
||||
}
|
||||
await authorize()
|
||||
if((await readBackupDownloadSource(scope.authority,authorize,bounded)).proof!==scope.sourceProof) throw fail("Резервная копия изменилась; подготовьте новый пакет","STATE_CONFLICT")
|
||||
const payload=(await pool.query<{ciphertext:string}>("/* MCP backup artifact payload */ SELECT ciphertext FROM mcp_artifacts WHERE id=$1 AND access_scope->>'proof'=$2 AND expires_at>now() AND octet_length(ciphertext)<=16777216",[id,scope.proof])).rows[0]
|
||||
await authorize()
|
||||
if(!payload || hash(payload.ciphertext)!==scope.encryptedSha256) throw fail("Зашифрованный пакет изменился")
|
||||
let content: Buffer
|
||||
try{content=Buffer.from(await decryptSecret(payload.ciphertext),"base64")}catch{throw fail("Пакет не расшифрован")}
|
||||
await authorize()
|
||||
if(Buffer.byteLength(content)!==scope.bytes || hash(content)!==scope.sha256) throw fail("Размер или SHA-256 пакета не совпал")
|
||||
if((await readBackupDownloadSource(scope.authority,authorize,bounded)).proof!==scope.sourceProof) throw fail("Конфигурация изменилась во время выдачи пакета","STATE_CONFLICT")
|
||||
await authorize()
|
||||
return {filename:meta.filename,mediaType:meta.media_type,content}
|
||||
}
|
||||
|
||||
export function backupDownloadAuthority(principal: McpPrincipal, ownerId: string, policy: unknown, generation: string, backupId:string, serverId:number) {
|
||||
return backupDownloadAuthoritySchema.parse({principal,ownerId,policyProof:fingerprint(policy),generation,backupId,serverId})
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { backupStorageSecretPurpose as purpose } from "./backup-storage-actions.js"
|
||||
const destination={s3Endpoint:"https://s3.example.test",s3Region:"us-east-1",s3Bucket:"backups"}
|
||||
assert.equal(purpose(destination),"backup-s3:https://s3.example.test|us-east-1|backups")
|
||||
assert.equal(purpose({...destination,s3Endpoint:""}),"backup-s3:aws-default|us-east-1|backups")
|
||||
assert.equal(purpose({...destination,s3Endpoint:" https://s3.example.test ",s3Bucket:" backups "}),purpose(destination))
|
||||
for(const patch of [{s3Endpoint:"https://user:[email protected]"},{s3Endpoint:"https://s3.example.test?secret=x"},{s3Endpoint:"https://s3.example.test#x"},{s3Endpoint:"ftp://s3.example.test"},{s3Region:""},{s3Bucket:" "},{s3Region:"a|b"},{s3Bucket:"a|b"}])assert.throws(()=>purpose({...destination,...patch}))
|
||||
assert.notEqual(purpose({...destination,s3Endpoint:"https://other.example.test"}),purpose(destination))
|
||||
assert.notEqual(purpose({...destination,s3Bucket:"other"}),purpose(destination))
|
||||
console.log("Backup S3 secret destination: exact endpoint/region/bucket binding, AWS-default target, delimiter collision, URL credentials and incomplete target guards passed")
|
||||
@@ -0,0 +1,82 @@
|
||||
import { z } from "zod"
|
||||
import { pool } from "../../db/index.js"
|
||||
import { readBackupStorageMutationState, updateBackupStorageSettings } from "../../services/backup-service.js"
|
||||
import { decryptSecret, fingerprint, privateSecretFingerprint, McpError } from "./security.js"
|
||||
import { registerMcpService } from "./service-registry.js"
|
||||
import type { add } from "./actions.js"
|
||||
import type { ChangeEvidence } from "./action-types.js"
|
||||
|
||||
const endpoint=z.string().trim().max(350).refine(value=>{
|
||||
if(!value)return true
|
||||
try {const url=new URL(value);return ["http:","https:"].includes(url.protocol)&&!url.username&&!url.password&&!url.search&&!url.hash}catch{return false}
|
||||
},"Endpoint должен быть HTTP(S) URL без credentials/query/hash")
|
||||
const destinationPart=z.string().trim().max(64).refine(value=>!/[|\r\n]/.test(value),"Недопустимый разделитель назначения S3")
|
||||
const fields=z.object({provider:z.enum(["local","s3"]).optional(),s3Endpoint:endpoint.optional(),s3Region:destinationPart.optional(),s3Bucket:destinationPart.optional(),s3Prefix:z.string().max(1024).optional(),s3AccessKeyId:z.string().max(256).optional(),s3ForcePathStyle:z.boolean().optional(),keepLocalCopy:z.boolean().optional()}).strict()
|
||||
const schema=z.object({settings:fields.default({}),secretRef:z.string().uuid().optional()}).strict().refine(value=>Object.keys(value.settings).length>0||Boolean(value.secretRef),"Нужно изменение настроек или ссылка на секрет")
|
||||
type State=Awaited<ReturnType<typeof readBackupStorageMutationState>>
|
||||
export function backupStorageSecretPurpose(configuration:Pick<State["configuration"],"s3Endpoint"|"s3Region"|"s3Bucket">) {
|
||||
const parsed=endpoint.safeParse(configuration.s3Endpoint)
|
||||
const region=destinationPart.safeParse(configuration.s3Region),bucket=destinationPart.safeParse(configuration.s3Bucket)
|
||||
if(!parsed.success||!region.success||!bucket.success||!region.data||!bucket.data)throw new McpError("STATE_CONFLICT","Не подтверждены endpoint/region/bucket назначения S3",409)
|
||||
const purpose=`backup-s3:${parsed.data||"aws-default"}|${region.data}|${bucket.data}`
|
||||
if(purpose.length>512)throw new McpError("INVALID_INPUT","Назначение секрета S3 превышает предел",400)
|
||||
return purpose
|
||||
}
|
||||
async function selectedServers() {
|
||||
const ids=(await pool.query<{id:number}>("SELECT id FROM servers ORDER BY id LIMIT 1001")).rows.map(row=>Number(row.id))
|
||||
if(ids.length>1000)throw new McpError("STATE_UNAVAILABLE","Каталог серверов превышает предел проверки",503)
|
||||
return ids
|
||||
}
|
||||
async function resolveReference(input:Record<string,unknown>,state:State) {
|
||||
if(!input.secretRef)return undefined
|
||||
const next={...state.configuration,...fields.parse(input.settings)}
|
||||
const {rows:[secret]}=await pool.query("SELECT purpose,server_id,ciphertext,created_by FROM mcp_secrets WHERE id=$1 AND created_by=$2",[input.secretRef,input.ownerId])
|
||||
if(!secret||secret.server_id!==null||secret.purpose!==backupStorageSecretPurpose(next))throw new McpError("SECRET_UNAVAILABLE","Секрет не выдан владельцу для выбранного хранилища S3",403)
|
||||
const parsed=z.string().min(1).max(4096).safeParse(await decryptSecret(secret.ciphertext))
|
||||
if(!parsed.success)throw new McpError("SECRET_UNAVAILABLE","Формат выданного секрета S3 не поддерживается",403)
|
||||
const value=parsed.data
|
||||
return {value,referenceFingerprint:fingerprint(secret)}
|
||||
}
|
||||
async function current(input:Record<string,unknown>) {
|
||||
const serverIds=await selectedServers(),state=await readBackupStorageMutationState()
|
||||
if(!fields.required().safeParse(state.configuration).success)throw new McpError("STATE_UNAVAILABLE","Текущая конфигурация S3 не подтверждена; endpoint не должен содержать credentials",503)
|
||||
const next={...state.configuration,...fields.parse(input.settings)}
|
||||
if(state.busy)throw new McpError("STATE_CONFLICT","Хранилище занято созданием или синхронизацией архивов",409)
|
||||
if(state.hasRemoteEntries&&(next.provider!=="s3"||(["s3Endpoint","s3Region","s3Bucket","s3ForcePathStyle"] as const).some(key=>next[key]!==state.configuration[key])))throw new McpError("STATE_CONFLICT","Существующие S3-архивы требуют сохранения исходного подключения",409)
|
||||
// An empty bucket cannot construct an S3 client. Clearing dormant settings is
|
||||
// allowed, but restoring a usable destination requires its own reference.
|
||||
if(state.secretConfigured&&next.s3Bucket.trim()&&!input.secretRef&&(["s3Endpoint","s3Region","s3Bucket"] as const).some(key=>next[key]!==state.configuration[key]))throw new McpError("SECRET_UNAVAILABLE","Перенос сохранённого секрета на новое назначение S3 требует выданной этому назначению ссылки",403)
|
||||
const reference=await resolveReference(input,state)
|
||||
return {...state,serverIds,secretRefFingerprint:reference?.referenceFingerprint??null}
|
||||
}
|
||||
export function registerBackupStorageActions(register:typeof add) {
|
||||
registerMcpService("backups.storage.update",{current,execute:async input=>{
|
||||
const before=await current(input)
|
||||
if(fingerprint(before)!==input.beforeFingerprint)throw new McpError("STATE_CONFLICT","Настройки, секрет, архивы или серверы изменились после подтверждения",409)
|
||||
const reference=await resolveReference(input,before)
|
||||
if((reference?.referenceFingerprint??null)!==input.secretRefFingerprint)throw new McpError("STATE_CONFLICT","Ссылка на секрет изменилась перед записью",409)
|
||||
const patch={...fields.parse(input.settings),...(reference?{s3SecretAccessKey:reference.value}:{})}
|
||||
await updateBackupStorageSettings(patch)
|
||||
return {configurationSaved:true,configurationFingerprint:fingerprint({...before.configuration,...fields.parse(input.settings)}),secretFingerprint:reference?await privateSecretFingerprint(reference.value,"backup-storage"):before.secretFingerprint,storageTestVerified:false}
|
||||
},verify:async(_input,result)=>{
|
||||
const receipt=result as {configurationSaved?:boolean;configurationFingerprint?:string;secretFingerprint?:string}|null
|
||||
if(receipt?.configurationSaved!==true)return false
|
||||
const saved=await readBackupStorageMutationState()
|
||||
return saved.secretFingerprint===receipt.secretFingerprint&&fingerprint(saved.configuration)===receipt.configurationFingerprint
|
||||
}})
|
||||
register("backups.storage.update","mm_backup_storage_update_plan","Изменение конфигурации backup-хранилища с отдельной ссылкой на секрет","backups","mm:backups:write","write","global",schema,{
|
||||
prepare:async(input,context)=>{
|
||||
const ids=await selectedServers()
|
||||
if(ids.some(id=>!context.connection.policy.scope.serverIds.includes(id)))throw new McpError("SERVER_DENIED","Глобальное хранилище затрагивает серверы вне разрешённой области",403)
|
||||
const request={...input,ownerId:context.connection.owner_id},before=await current(request)
|
||||
if(before.serverIds.join(",")!==ids.join(","))throw new McpError("STATE_CONFLICT","Каталог серверов изменился во время подготовки",409)
|
||||
const objects:ChangeEvidence["objects"]=[{kind:"backup-storage",id:"1"},...ids.map(serverId=>({kind:"server",id:String(serverId),serverId}))]
|
||||
const {rows:protections}=await pool.query("SELECT object_ref FROM mcp_protections WHERE object_ref->>'serverId'=ANY($1::text[]) OR (object_ref->>'kind'='server' AND object_ref->>'id'=ANY($1::text[])) LIMIT 1001",[ids.map(String)])
|
||||
if(protections.length>1000)throw new McpError("STATE_UNAVAILABLE","Превышен предел проверки защит",503)
|
||||
for(const {object_ref:ref} of protections)objects.push({kind:ref.kind,id:ref.id,serverId:ref.serverId})
|
||||
return {target:{scope:"global"},objects,snapshots:[],before,requiresHuman:true,mutationCost:1,
|
||||
steps:[{kind:"service",service:"backups.storage.update",input:{...request,beforeFingerprint:fingerprint(before),secretRefFingerprint:before.secretRefFingerprint}}],
|
||||
warnings:["Глобальная конфигурация backup-хранилища изменяется для всех серверов приложения.","Смена credentials/prefix может повлиять на последующие загрузки и доступ к архивам. Проверка S3 выполняется отдельным подтверждённым действием.","Секрет передаётся только через выданную владельцу ссылку с purpose backup-s3:<endpoint или aws-default>|<region>|<bucket>.","Занятое созданием/синхронизацией хранилище отклонит изменение; автоматического повтора нет."]}
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
import { MIGRATION_MANIFEST } from "../../db/migration-manifest.js"
|
||||
/** Owned SQL-only acceptance of the genuine production runner's private service
|
||||
* context. No RouterOS request, bandwidth admission or production registration. */
|
||||
import assert from "node:assert/strict"
|
||||
import { randomUUID, createHash } from "node:crypto"
|
||||
import { setTimeout as delay } from "node:timers/promises"
|
||||
import { isDeepStrictEqual } from "node:util"
|
||||
import type { McpPrincipal } from "./store.js"
|
||||
import type { VerifiedMcpServiceExecution } from "../../services/config-mutation-policy.js"
|
||||
import { assertOwnedBandwidthSql } from "../../services/bandwidth-admission-process.sql-helper.js"
|
||||
|
||||
export async function acceptBandwidthServiceContextSql(serverIds: readonly number[]) {
|
||||
assertOwnedBandwidthSql()
|
||||
assert.equal(serverIds.length, 3); assert.equal(new Set(serverIds).size, 3)
|
||||
assert(serverIds.every(id => Number.isSafeInteger(id) && id > 0))
|
||||
const { pool } = await import("../../db/index.js")
|
||||
const { z } = await import("zod")
|
||||
const { mcpConnectionCreateSchema } = await import("@mmapp/contracts/mcp")
|
||||
const { add } = await import("./actions.js")
|
||||
const { registerMcpService } = await import("./service-registry.js")
|
||||
const { createMcpConnection, authenticateMcpToken } = await import("./store.js")
|
||||
const { invokeMcpTool, approvePlan, applyPlan } = await import("./runtime.js")
|
||||
const { captureVerifiedMcpServiceExecution, inspectVerifiedMcpServiceExecution } = await import("../../services/config-mutation-policy.js")
|
||||
const { readCurrentMcpServiceExecution } = await import("./service-execution-origin.js")
|
||||
const { readMcpTemporaryGrantOrigin } = await import("./temporary-grant-origin.js")
|
||||
const ids = [...serverIds].sort((a, b) => a - b)
|
||||
assert.equal((await pool.query("SELECT id FROM public.servers WHERE id=ANY($1::bigint[])", [ids])).rows.length, 3)
|
||||
assert.equal(Number((await pool.query("SELECT count(*) AS n FROM public.schema_migrations")).rows[0].n), MIGRATION_MANIFEST.length)
|
||||
const originalEnabled = (await pool.query("SELECT enabled FROM public.mcp_settings WHERE id=1")).rows[0]?.enabled
|
||||
assert.equal(typeof originalEnabled, "boolean")
|
||||
const suffix = randomUUID().replaceAll("-", ""), operationName = "fixture.bandwidth.context." + suffix
|
||||
const serviceName = "fixture.bandwidth.service-context." + suffix, toolName = "mm_fixture_bw_context_" + suffix
|
||||
const secret = "SYNTHETIC_PRIVATE_SERVICE_PASSWORD_" + randomUUID()
|
||||
const before = { fixture: "owned-sealed-service-state" }, connections: string[] = [], tokens: VerifiedMcpServiceExecution[] = []
|
||||
const checks: string[] = [], outcomes: boolean[] = []
|
||||
type Expected = { principal: McpPrincipal; ownerId: string; connectionId: string; approved: boolean; evidenceExpiry: number;grantExpiresAt:number|null;grantRowId:string|null }
|
||||
const expectedPlans = new Map<string, Expected>(), seenSteps = new Map<string, number[]>()
|
||||
let preparedExpiry = 0, verified = 0, currentPlanChecks = 0, rejectedPlanChanges = 0, rejectedGrantChanges=0, pendingReplayRefusals=0
|
||||
const privateInput = (stepIndex: number) => ({ fixtureStep: stepIndex, password: secret, serverIds: ids })
|
||||
const safe = (value: unknown) => { assert(!JSON.stringify(value).includes(secret), "Private service material escaped") }
|
||||
registerMcpService(serviceName, {
|
||||
current: async () => before,
|
||||
verify: async (_input, result) => {
|
||||
assert.deepEqual(result, { fixtureServiceReceipt: true }); safe(result); verified++; return true
|
||||
},
|
||||
execute: async (input, context) => {
|
||||
assert(context && context.phase === "forward")
|
||||
const token = await captureVerifiedMcpServiceExecution(), inspected = await inspectVerifiedMcpServiceExecution(token)
|
||||
const originalScope={deadlineAt:Date.now()+10000}
|
||||
assert(isDeepStrictEqual(await readCurrentMcpServiceExecution(token,originalScope),inspected));currentPlanChecks++
|
||||
const expected = expectedPlans.get(inspected.planId); assert(expected)
|
||||
const currentGrant=await readMcpTemporaryGrantOrigin(pool,inspected.connectionId,inspected.principal.credentialId)
|
||||
assert.equal(inspected.originalTemporaryGrantProof,currentGrant.proof);assert.equal(inspected.originalTemporaryGrantExpiresAt,expected.grantExpiresAt)
|
||||
assert.equal(context.operationId, inspected.operationId); assert.equal(context.stepIndex, inspected.stepIndex)
|
||||
assert.equal(inspected.stepIndex, input.fixtureStep)
|
||||
assert(isDeepStrictEqual(input, privateInput(inspected.stepIndex)), "Decrypted runner step input mismatch")
|
||||
assert(isDeepStrictEqual(inspected.serviceInput, privateInput(inspected.stepIndex)), "Private inspected service input mismatch")
|
||||
assert(inspected.serviceInput.password === secret, "Private inspected credential mismatch")
|
||||
assert.equal(inspected.operationName, operationName); assert.equal(inspected.serviceName, serviceName); assert.equal(inspected.phase, "forward")
|
||||
assert.equal(inspected.operationVersion, 2); assert.deepEqual(inspected.principal, expected.principal)
|
||||
assert.equal(inspected.connectionId, expected.connectionId); assert.equal(inspected.ownerId, expected.ownerId)
|
||||
assert.equal(inspected.approved, expected.approved); assert.equal(inspected.serverWide, true)
|
||||
assert.deepEqual([...inspected.serverIds].sort((a, b) => a - b), ids)
|
||||
assert.deepEqual(inspected.objects, ids.map(serverId => ({ kind: "server", id: String(serverId), serverId })))
|
||||
const row = (await pool.query(`SELECT o.state AS operation_state,p.state AS plan_state,s.state AS step_state,
|
||||
o.plan_id,o.connection_id,p.operation_id,p.operation_version,p.policy_version,p.generation,p.expires_at,
|
||||
p.approved_by,p.evidence,p.payload,c.owner_id,c.owner_valid_until,c.policy,s.intended_state
|
||||
FROM public.mcp_operations o JOIN public.mcp_plans p ON p.id=o.plan_id
|
||||
JOIN public.mcp_connections c ON c.id=o.connection_id
|
||||
JOIN public.mcp_operation_steps s ON s.operation_id=o.id AND s.step_index=$2 WHERE o.id=$1`, [context.operationId, context.stepIndex])).rows[0]
|
||||
assert(row); assert.equal(row.operation_state, "running"); assert.equal(row.plan_state, "executing"); assert.equal(row.step_state, "intent")
|
||||
assert.equal(row.plan_id, inspected.planId); assert.equal(row.connection_id, inspected.connectionId); assert.equal(row.owner_id, inspected.ownerId)
|
||||
assert.equal(row.operation_id, inspected.operationName); assert.equal(row.operation_version, inspected.operationVersion)
|
||||
assert.equal(row.policy_version, inspected.policyVersion); assert.equal(row.generation, inspected.generation)
|
||||
assert.equal(inspected.sealedEvidenceProof,createHash("sha256").update(row.evidence.sealed).digest("hex"))
|
||||
assert.equal(inspected.sealedPayloadProof,createHash("sha256").update(row.payload.sealed).digest("hex"))
|
||||
assert.equal(!!row.approved_by, expected.approved)
|
||||
safe(row.intended_state); assert.equal(row.intended_state.input.password, "[redacted]")
|
||||
if(inspected.stepIndex===0){
|
||||
const replacementId=randomUUID(),grantPayload={credentialId:inspected.principal.credentialId,id:randomUUID(),startsAt:new Date(Date.now()-1000).toISOString(),expiresAt:new Date(Date.now()+100000).toISOString(),scope:{serverIds:ids,operationIds:[operationName],allowGlobal:false}}
|
||||
if(expected.grantRowId){
|
||||
const originalGrant=(await pool.query("SELECT payload FROM public.mcp_grants WHERE id=$1",[expected.grantRowId])).rows[0].payload
|
||||
await pool.query("UPDATE public.mcp_grants SET payload=jsonb_set(payload,'{expiresAt}',to_jsonb($2::text)) WHERE id=$1",[expected.grantRowId,new Date(expected.grantExpiresAt!+1000).toISOString()])
|
||||
try{await assert.rejects(readCurrentMcpServiceExecution(token,{deadlineAt:Date.now()+10000}));rejectedGrantChanges++}
|
||||
finally{await pool.query("UPDATE public.mcp_grants SET payload=$2::jsonb WHERE id=$1",[expected.grantRowId,JSON.stringify(originalGrant)])}
|
||||
await pool.query("UPDATE public.mcp_grants SET revoked_at=now() WHERE id=$1",[expected.grantRowId])
|
||||
try{
|
||||
await pool.query("INSERT INTO public.mcp_grants(id,connection_id,payload) VALUES($1,$2,$3::jsonb)",[replacementId,inspected.connectionId,JSON.stringify(originalGrant)])
|
||||
await assert.rejects(readCurrentMcpServiceExecution(token,{deadlineAt:Date.now()+10000}));rejectedGrantChanges++
|
||||
}finally{await pool.query("DELETE FROM public.mcp_grants WHERE id=$1",[replacementId]);await pool.query("UPDATE public.mcp_grants SET revoked_at=NULL WHERE id=$1",[expected.grantRowId])}
|
||||
}else{
|
||||
await pool.query("INSERT INTO public.mcp_grants(id,connection_id,payload) VALUES($1,$2,$3::jsonb)",[replacementId,inspected.connectionId,JSON.stringify(grantPayload)])
|
||||
try{await assert.rejects(readCurrentMcpServiceExecution(token,{deadlineAt:Date.now()+10000}));rejectedGrantChanges++}
|
||||
finally{await pool.query("DELETE FROM public.mcp_grants WHERE id=$1",[replacementId])}
|
||||
}
|
||||
await readCurrentMcpServiceExecution(token,{deadlineAt:Date.now()+10000})
|
||||
// Only this fixture's active step/plan is changed and restored. The
|
||||
// runtime is awaiting this handler; no native operation is involved.
|
||||
const changes=[
|
||||
{change:"UPDATE public.mcp_operation_steps SET intended_state='{}'::jsonb WHERE operation_id=$1 AND step_index=0",restore:"UPDATE public.mcp_operation_steps SET intended_state=$2::jsonb WHERE operation_id=$1 AND step_index=0",id:inspected.operationId,value:JSON.stringify(row.intended_state)},
|
||||
{change:"UPDATE public.mcp_plans SET evidence=jsonb_set(evidence,'{sealed}',to_jsonb((evidence->>'sealed')||'!')) WHERE id=$1",restore:"UPDATE public.mcp_plans SET evidence=$2::jsonb WHERE id=$1",id:inspected.planId,value:JSON.stringify(row.evidence)},
|
||||
{change:"UPDATE public.mcp_plans SET payload=jsonb_set(payload,'{sealed}',to_jsonb((payload->>'sealed')||'!')) WHERE id=$1",restore:"UPDATE public.mcp_plans SET payload=$2::jsonb WHERE id=$1",id:inspected.planId,value:JSON.stringify(row.payload)},
|
||||
{change:"UPDATE public.mcp_plans SET operation_version=operation_version+1 WHERE id=$1",restore:"UPDATE public.mcp_plans SET operation_version=$2 WHERE id=$1",id:inspected.planId,value:row.operation_version},
|
||||
]
|
||||
for(const change of changes){
|
||||
await pool.query(change.change,[change.id])
|
||||
try{await assert.rejects(readCurrentMcpServiceExecution(token,{deadlineAt:Date.now()+10000}));rejectedPlanChanges++}
|
||||
finally{await pool.query(change.restore,[change.id,change.value])}
|
||||
await readCurrentMcpServiceExecution(token,{deadlineAt:Date.now()+10000});currentPlanChecks++
|
||||
}
|
||||
}
|
||||
assert.equal(inspected.originalExpiresAt, Math.min(row.expires_at.getTime(), expected.evidenceExpiry,
|
||||
Date.parse(expected.principal.credentialExpiresAt), Date.parse(row.policy.expiresAt), row.owner_valid_until.getTime(),expected.grantExpiresAt??Infinity))
|
||||
assert(inspected.originalExpiresAt > Date.now()); assert(Object.isFrozen(inspected)); assert(Object.isFrozen(inspected.serviceInput))
|
||||
await assert.rejects(inspectVerifiedMcpServiceExecution({ ...token }))
|
||||
if (tokens.length) await assert.rejects(inspectVerifiedMcpServiceExecution(tokens[tokens.length - 1]))
|
||||
tokens.push(token)
|
||||
const seen = seenSteps.get(inspected.planId) ?? []; seen.push(inspected.stepIndex); seenSteps.set(inspected.planId, seen)
|
||||
outcomes.push(inspected.approved)
|
||||
return { fixtureServiceReceipt: true }
|
||||
},
|
||||
})
|
||||
add(operationName, toolName, "Owned bandwidth runner context fixture", "servers", "mm:servers:write", "write", "servers", z.object({}).strict(), {
|
||||
version: 2, target: () => ({ scope: "servers", serverIds: ids }), prepare: async () => {
|
||||
preparedExpiry = Date.now() + 120000
|
||||
return { target: { scope: "servers", serverIds: ids }, objects: ids.map(serverId => ({ kind: "server" as const, id: String(serverId), serverId })), snapshots: [],
|
||||
steps: [0, 1].map(stepIndex => ({ kind: "service" as const, service: serviceName, input: privateInput(stepIndex) })),
|
||||
before, warnings: [], validUntil: new Date(preparedExpiry).toISOString() }
|
||||
},
|
||||
})
|
||||
try {
|
||||
await pool.query("UPDATE public.mcp_settings SET enabled=true WHERE id=1")
|
||||
await assert.rejects(captureVerifiedMcpServiceExecution())
|
||||
for (const approved of [true, false]) {
|
||||
const actor = { id: randomUUID(), email: "[email protected]", name: "Synthetic owned runner authority", apps: ["mm"],
|
||||
permissions: ["mm:servers:write", "mm:servers:read", "mm:mcp:approve"], issuedAt: Date.now() - 1000, expiresAt: Date.now() + 300000 }
|
||||
const access = await createMcpConnection(mcpConnectionCreateSchema.parse({ name: "Owned runner private context", mode: approved ? "approval" : "autonomous",
|
||||
expiresAt: new Date(actor.expiresAt).toISOString(), permissions: ["mm:servers:write", "mm:servers:read"],
|
||||
scope: { serverIds: ids, operationIds: [operationName], allowGlobal: false },
|
||||
...(approved ? {} : { dangerousPolicy: "explicit-allow", autonomousDangerousOperationIds: [operationName] }),
|
||||
limits: { callsPerMinute: 120, mutationsPerHour: 1000 } }), actor)
|
||||
connections.push(access.connection.id)
|
||||
const principal = await authenticateMcpToken(access.token)
|
||||
let grantRowId:string|null=null,grantExpiresAt:number|null=null
|
||||
if(approved){
|
||||
grantRowId=randomUUID();grantExpiresAt=Date.now()+90000
|
||||
await pool.query("INSERT INTO public.mcp_grants(id,connection_id,payload) VALUES($1,$2,$3::jsonb)",[grantRowId,access.connection.id,JSON.stringify({credentialId:principal.credentialId,id:randomUUID(),startsAt:new Date(Date.now()-1000).toISOString(),expiresAt:new Date(grantExpiresAt).toISOString(),scope:{serverIds:ids,operationIds:[operationName],allowGlobal:false}})])
|
||||
}
|
||||
const prepared = await invokeMcpTool(principal, toolName, {}) as { planId: string; state: string }
|
||||
safe(prepared); assert.equal(prepared.state, approved ? "awaiting-approval" : "draft")
|
||||
expectedPlans.set(prepared.planId, { principal, ownerId: actor.id, connectionId: access.connection.id, approved, evidenceExpiry: preparedExpiry,grantExpiresAt,grantRowId })
|
||||
if (approved) await approvePlan(prepared.planId, actor, true)
|
||||
const operation = await applyPlan(principal, prepared.planId, randomUUID())
|
||||
let terminal: { state: string; result: unknown; error_code: unknown } | undefined
|
||||
for (const until = Date.now() + 30000; Date.now() < until;) {
|
||||
const row = (await pool.query("SELECT state,result,error_code FROM public.mcp_operations WHERE id=$1", [operation.id])).rows[0]
|
||||
assert(row)
|
||||
if (!["pending", "running"].includes(row.state)) { terminal = row; break }
|
||||
await delay(20)
|
||||
}
|
||||
assert(terminal, "Owned runner did not reach terminal state"); assert.equal(terminal.state, "applied", "Owned runner did not apply SQL-only fixture steps")
|
||||
safe(terminal); assert.deepEqual(seenSteps.get(prepared.planId), [0, 1])
|
||||
const steps = (await pool.query("SELECT state,intended_state,result FROM public.mcp_operation_steps WHERE operation_id=$1 ORDER BY step_index", [operation.id])).rows
|
||||
assert.equal(steps.length, 2); assert(steps.every(row => row.state === "verified")); safe(steps)
|
||||
assert.equal((await pool.query("SELECT approved_by FROM public.mcp_plans WHERE id=$1", [prepared.planId])).rows[0].approved_by !== null, approved)
|
||||
for (const token of tokens) await assert.rejects(inspectVerifiedMcpServiceExecution(token))
|
||||
// Simulate an owned pending row surviving without its admission closure.
|
||||
// Re-apply may return it, but must never manufacture a new grant pin or
|
||||
// schedule another service continuation from the current access alone.
|
||||
const previousTokens=tokens.length
|
||||
await pool.query("UPDATE public.mcp_operations SET state='pending' WHERE id=$1",[operation.id])
|
||||
try{
|
||||
const replay=await applyPlan(principal,prepared.planId,randomUUID())
|
||||
assert.equal(replay.id,operation.id);await delay(150)
|
||||
assert.equal((await pool.query("SELECT state FROM public.mcp_operations WHERE id=$1",[operation.id])).rows[0].state,"pending")
|
||||
assert.equal(tokens.length,previousTokens);pendingReplayRefusals++
|
||||
}finally{await pool.query("UPDATE public.mcp_operations SET state='applied' WHERE id=$1",[operation.id])}
|
||||
checks.push(approved ? "human-approved genuine production runner: two exact sealed service steps, SQL running/executing/intent provenance, private decrypted input and redacted public intent; tokens expire on step exit"
|
||||
: "explicit autonomous dangerous-operation grant: two genuine service steps with approved=false, same principal/plan/generation/expiry provenance, verification and no human approval fabrication")
|
||||
}
|
||||
assert.equal(verified, 4); assert.deepEqual(outcomes, [true, true, false, false]);assert.equal(currentPlanChecks,12);assert.equal(rejectedPlanChanges,8);assert.equal(rejectedGrantChanges,3)
|
||||
assert.equal(pendingReplayRefusals,2)
|
||||
return { checks, flows: 2, verifiedServiceSteps: verified, productionRunnerTokens: tokens.length, currentPlanChecks, rejectedPlanChanges, rejectedGrantChanges,pendingReplayRefusals, nativeRequests: 0, operatorAuthoritySynthetic: true, productionActivation: false }
|
||||
} finally {
|
||||
try { await pool.query("UPDATE public.mcp_settings SET enabled=$1 WHERE id=1", [originalEnabled]) }
|
||||
finally {
|
||||
try {
|
||||
if (connections.length) {
|
||||
await pool.query("DELETE FROM public.mcp_operation_checkpoints WHERE operation_id IN (SELECT id FROM public.mcp_operations WHERE connection_id=ANY($1::uuid[]))", [connections])
|
||||
await pool.query("DELETE FROM public.mcp_operation_steps WHERE operation_id IN (SELECT id FROM public.mcp_operations WHERE connection_id=ANY($1::uuid[]))", [connections])
|
||||
await pool.query("DELETE FROM public.mcp_operations WHERE connection_id=ANY($1::uuid[])", [connections])
|
||||
await pool.query("DELETE FROM public.mcp_plans WHERE connection_id=ANY($1::uuid[])", [connections])
|
||||
await pool.query("DELETE FROM public.mcp_requests WHERE connection_id=ANY($1::uuid[])", [connections])
|
||||
await pool.query("DELETE FROM public.mcp_connections WHERE id=ANY($1::uuid[])", [connections])
|
||||
}
|
||||
}
|
||||
finally { tokens.splice(0); expectedPlans.clear() }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { z } from "zod"
|
||||
import { pool } from "../../db/index.js"
|
||||
import { bgpCommunityPolicySchema, compileBgpCommunityPolicy, readCompiledBgpCommunityPolicy } from "../../services/bgp-community-policy-model.js"
|
||||
import { readOwnedRoutingPolicy, mutateOwnedRoutingPolicy } from "../../services/owned-routing-policy.js"
|
||||
import { nativeBgpRows } from "../../services/bgp-export-model.js"
|
||||
import { stableBgpPolicyRow } from "../../services/bgp-filter-binding-model.js"
|
||||
import { executeGuardedApplicationWrite } from "../../services/config-mutation-policy.js"
|
||||
import { routerClient } from "./resources.js"
|
||||
import { fingerprint, McpError, privateSecretFingerprint } from "./security.js"
|
||||
import { registerMcpService } from "./service-registry.js"
|
||||
import { routingFilterBodiesMatch } from "../../services/routing-filter-body.js"
|
||||
import type { ChangeEvidence } from "./action-types.js"
|
||||
import type { add } from "./actions.js"
|
||||
|
||||
const schema = bgpCommunityPolicySchema.safeExtend({ serverId: z.number().int().positive().safe(), action: z.enum(["replace", "delete"]).default("replace") }).superRefine((value, context) => {
|
||||
if (value.action === "delete" && (value.rules.length || value.defaultAction !== "reject")) context.addIssue({ code: "custom", message: "Удаление принимает пустые rules и defaultAction=reject" })
|
||||
})
|
||||
const service = "routing.bgp.community.policy"
|
||||
const paths = { bgp_connection: "/routing/bgp/connection", bgp_template: "/routing/bgp/template", bgp_instance: "/routing/bgp/instance", ospf_instance: "/routing/ospf/instance", rip_instance: "/routing/rip/instance", isis_instance: "/routing/isis/instance", routing_filter_select: "/routing/filter/select-rule", routing_table: "/routing/table", vrf: "/ip/vrf" }
|
||||
const proof = (sid: number, policy: string, kind: string, value: unknown) => privateSecretFingerprint(fingerprint(value), `bgp-community-policy:${sid}:${policy}:${kind}`)
|
||||
async function state(input: Record<string, unknown>) {
|
||||
try {
|
||||
const serverId = Number(input.serverId), compiled = compileBgpCommunityPolicy({ policyId: input.policyId, rules: input.rules, defaultAction: input.defaultAction })
|
||||
const { server, client } = await routerClient(serverId), serverProof = await proof(serverId, compiled.policy.policyId, "server", server)
|
||||
if (input.expectedServerProof !== undefined && input.expectedServerProof !== serverProof) throw new McpError("STATE_CONFLICT", "Назначение или credentials policy изменились; новый источник не опрашивается", 409)
|
||||
const native = await readOwnedRoutingPolicy(client, compiled.chain, compiled.comment)
|
||||
const snapshots = await Promise.all(Object.entries(paths).map(async ([kind, path]) => {
|
||||
const rows = nativeBgpRows(await client.get(path)); if (rows.length > 1000) throw new McpError("LIMIT_EXCEEDED", "Каталог policy dependencies превышает 1000 строк", 409)
|
||||
return { kind, rows }
|
||||
}))
|
||||
const references: ChangeEvidence["objects"] = []
|
||||
for (const snapshot of snapshots) for (const row of snapshot.rows) {
|
||||
if (Object.entries(row).some(([key, value]) => (key.includes("filter") || key === "do-where" || snapshot.kind === "routing_filter_select" && key === "chain") && (typeof value === "string" ? value.split(",").includes(compiled.chain) : Array.isArray(value) && value.includes(compiled.chain)))) references.push({ kind: snapshot.kind, id: row[".id"], serverId })
|
||||
}
|
||||
const queryProof = []
|
||||
// Native positive queries cover omitted/effective binding properties, including
|
||||
// every template, rather than treating a missing GET key as a known false.
|
||||
for (const [kind, path] of [["bgp_connection", "/routing/bgp/connection"], ["bgp_template", "/routing/bgp/template"]] as const) {
|
||||
const rows = nativeBgpRows(await client.post(path + "/print", { ".proplist": ".id,input.filter,output.filter-chain", ".query": ["input.filter=" + compiled.chain, "output.filter-chain=" + compiled.chain, "#|"] }))
|
||||
const catalog = snapshots.find(row => row.kind === kind)!.rows
|
||||
if (rows.length > 1000 || rows.some(row => !catalog.some(source => source[".id"] === row[".id"]) || row["input.filter"] !== compiled.chain && row["output.filter-chain"] !== compiled.chain)) throw new McpError("STATE_UNAVAILABLE", "Не подтверждён native query зависимостей policy", 409)
|
||||
queryProof.push({ kind, rows: rows.map(stableBgpPolicyRow) })
|
||||
references.push(...rows.map(row => ({ kind, id: row[".id"], serverId })))
|
||||
}
|
||||
for (const row of native.rows.filter(row => row[".id"] !== native.current?.[".id"])) {
|
||||
if (typeof row.rule !== "string") throw new McpError("STATE_UNAVAILABLE", "Не подтверждён код других цепочек при проверке references", 409)
|
||||
const jumps = row.rule.match(/\bjump\b/gi) ?? [], targets = [...row.rule.matchAll(/\bjump\s+(?:"([^"\\]*)"|([A-Za-z0-9_.:@/-]+))\s*(?:[;}]|$)/gi)]
|
||||
if (jumps.length !== targets.length && input.action === "delete") throw new McpError("STATE_UNAVAILABLE", "Opaque jump references не подтверждены; удаление запрещено", 409)
|
||||
if (targets.some(match => (match[1] ?? match[2]) === compiled.chain)) references.push({ kind: "routing_filter", id: row[".id"], serverId })
|
||||
}
|
||||
const uniqueReferences = [...new Map(references.map(row => [fingerprint(row), row])).values()]
|
||||
if (input.action === "delete" && uniqueReferences.length) throw new McpError("STATE_CONFLICT", "Policy используется routing client/template/jump; сначала нужен отдельный план отвязки", 409)
|
||||
const protections = (await pool.query("SELECT object_ref FROM mcp_protections WHERE object_ref->>'serverId'=$1 OR (object_ref->>'kind'='server' AND object_ref->>'id'=$1) LIMIT 1001", [String(serverId)])).rows
|
||||
if (protections.length > 1000) throw new McpError("LIMIT_EXCEEDED", "Policy protections превышают 1000 объектов", 409)
|
||||
const objects: ChangeEvidence["objects"] = [{ kind: "bgp-community-policy", id: compiled.policy.policyId, serverId }, ...native.rows.map(row => ({ kind: "routing_filter", id: row[".id"], serverId })),
|
||||
...snapshots.flatMap(snapshot => snapshot.rows.map(row => ({ kind: snapshot.kind, id: row[".id"], serverId }))), ...protections.map(({ object_ref: ref }) => ({ kind: ref.kind, id: ref.id, serverId }))]
|
||||
const uniqueObjects = [...new Map(objects.map(row => [fingerprint(row), row])).values()]
|
||||
if (uniqueObjects.length > 1000) throw new McpError("LIMIT_EXCEEDED", "Policy dependency closure превышает 1000 объектов", 409)
|
||||
const desired = input.action === "delete" ? null : compiled.body
|
||||
const dependencyProof = await proof(serverId, compiled.policy.policyId, "dependencies", { serverProof, snapshots: snapshots.map(snapshot => ({ kind: snapshot.kind, rows: snapshot.rows.map(stableBgpPolicyRow) })), queryProof, protections })
|
||||
const previousPolicy = native.current ? readCompiledBgpCommunityPolicy(compiled.policy.policyId, native.current.rule) : null
|
||||
const before = { serverId, policyId: compiled.policy.policyId, chain: compiled.chain, nativeId: native.current?.[".id"] ?? null, exists: !!native.current, currentDisabled: native.current?.disabled ?? null, previousPolicy, previousPolicyRecognized: !!previousPolicy, previousBodyBytes: native.current ? Buffer.byteLength(String(native.current.rule), "utf8") : 0, references: uniqueReferences, nativeReferenceQueryMatches: queryProof.reduce((sum, query) => sum + query.rows.length, 0),
|
||||
desiredPolicy: compiled.policy, desiredAction: input.action, compiledBytes: Buffer.byteLength(compiled.body, "utf8"), serverProof, dependencyProof,
|
||||
nativeProof: await proof(serverId, compiled.policy.policyId, "native", native.proofRows), compiledProof: await proof(serverId, compiled.policy.policyId, "compiled", desired),
|
||||
nativeDispatch: desired === null ? !!native.current : !native.current || !routingFilterBodiesMatch(native.current.rule, desired) || native.current.disabled !== "false" }
|
||||
return { server, client, compiled, native, desired, before, objects: uniqueObjects }
|
||||
} catch (error) { if (error instanceof McpError) throw error; throw new McpError("STATE_UNAVAILABLE", "Не подтверждены policy или routing dependencies; запись не выполняется", 409) }
|
||||
}
|
||||
export function registerBgpCommunityPolicyActions(register: typeof add) {
|
||||
registerMcpService(service, {
|
||||
current: async input => (await state(input)).before,
|
||||
execute: async input => {
|
||||
const initial = await state(input)
|
||||
if (fingerprint(initial.before) !== input.beforeFingerprint) throw new McpError("STATE_CONFLICT", "Policy или зависимости изменились после подтверждения", 409)
|
||||
return executeGuardedApplicationWrite({ kind: "bgp-community-policy", id: initial.compiled.policy.policyId }, [initial.server.id], async () => {
|
||||
const result = await mutateOwnedRoutingPolicy(initial.client, initial.compiled.chain, initial.compiled.comment, initial.desired, async native => {
|
||||
const live = await state(input)
|
||||
if (fingerprint(live.before) !== input.beforeFingerprint || fingerprint(native.proofRows) !== fingerprint(live.native.proofRows)) throw new McpError("STATE_CONFLICT", "Policy изменена перед native dispatch", 409)
|
||||
})
|
||||
return { nativeDispatch: result.nativeDispatch, action: result.action, configurationVerified: true, forwardingVerified: false, nativeProof: await proof(initial.server.id, initial.compiled.policy.policyId, "native", result.after.proofRows), dependencyProof: initial.before.dependencyProof }
|
||||
}, async () => initial.objects)
|
||||
},
|
||||
verify: async (input, result) => {
|
||||
const after = await state(input), receipt = result as { nativeProof: string; dependencyProof: string }
|
||||
const nativeMatches = after.before.nativeProof === receipt.nativeProof, dependenciesMatch = after.before.dependencyProof === receipt.dependencyProof
|
||||
const desiredMatches = after.desired === null ? !after.native.current : routingFilterBodiesMatch(after.native.current?.rule, after.desired) && after.native.current?.disabled === "false"
|
||||
if (!nativeMatches || !dependenciesMatch || !desiredMatches) console.warn("bgp-community-policy.verification.mismatch", { serverId: after.server.id, nativeMatches, dependenciesMatch, desiredMatches })
|
||||
return nativeMatches && dependenciesMatch && desiredMatches
|
||||
},
|
||||
})
|
||||
register("routing.bgp.community.policy", "mm_bgp_community_policy_plan", "Человеческий план owned export-цепочки: CIDR и стандартные Community, без raw script; привязка BGP отдельным планом", "bgp", ["mm:filters:write", "mm:bgp:write", "mm:network:read"], "write", "servers", schema, { prepare: async input => {
|
||||
const saved = await state(input)
|
||||
return { target: { scope: "servers", serverIds: [saved.server.id] }, objects: saved.objects, snapshots: [], before: saved.before, mutationCost: 1, requiresHuman: true, verificationScope: "configuration",
|
||||
steps: [{ kind: "service", service, input: { ...input, beforeFingerprint: fingerprint(saved.before), expectedServerProof: saved.before.serverProof } }],
|
||||
warnings: ["Полностью заменяется и включается одна owned цепочка mmapp-bgp-out-policyId. CIDR приводятся к адресу сети, порядок правил сохраняется. Default reject может отозвать все остальные префиксы.",
|
||||
"Set/append/clear меняют только стандартные Community. Цепочка не привязывается к peer автоматически; все использующие её routing clients затрагиваются её изменением.",
|
||||
"Удаление запрещено при native BGP/template, других routing client и jump references. Opaque jump при удалении требует отдельного анализа.",
|
||||
"Никакого автоматического refresh/повтора/rollback. Обмен attributes, выбор маршрута и forwarding требуют отдельной сетевой проверки.",
|
||||
...(saved.native.current && !saved.before.previousPolicyRecognized ? ["Текущее тело owned policy не распознано как точный результат компилятора. PreviousPolicy неизвестна; смысл native кода не восстановлен. Замена удалит это тело целиком."] : [])] }
|
||||
} })
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { z } from "zod"
|
||||
import { readStaticBgpConnection } from "../../services/bgp-connection-read.js"
|
||||
import { bgpExportConnection, bgpExportSources, bgpRedistributionMode, bgpTemplateReferences, nativeBgpExportValue, nativeBgpRows, type BgpExportMode, type NativeBgpRow } from "../../services/bgp-export-model.js"
|
||||
import { readOspfEffectiveDisabled } from "../../services/ospf-effective-disabled.js"
|
||||
import { executeGuardedApplicationWrite } from "../../services/config-mutation-policy.js"
|
||||
import { fingerprint, McpError, privateSecretFingerprint } from "./security.js"
|
||||
import { readRosSnapshot, routerClient, type ConfigurationRow } from "./resources.js"
|
||||
import { registerMcpService } from "./service-registry.js"
|
||||
import type { ChangeEvidence } from "./action-types.js"
|
||||
import type { add } from "./actions.js"
|
||||
|
||||
const modeSchema = z.enum(["disabled", "connected", "static"]).describe("Connected/static перераспределяют соответствующие маршруты таблицы peer. Disabled очищает только redistribution; output.network/default-originate и сам peer не отключаются.")
|
||||
const schema = z.object({ serverId: z.number().int().positive().safe(), id: z.string().trim().min(1).max(200), mode: modeSchema }).strict()
|
||||
const service = "routing.bgp.export"
|
||||
const modes = (values: BgpExportMode[]) => [...new Set(values)].sort() as BgpExportMode[]
|
||||
const configurationProof = (serverId: number, id: string, value: unknown) => privateSecretFingerprint(fingerprint(value), `bgp-export-connection:${serverId}:${id}`)
|
||||
function parseState<T>(phase: string, parse: () => T): T {
|
||||
try { return parse() } catch (error) {
|
||||
if (error instanceof McpError) throw error
|
||||
throw new McpError("STATE_UNAVAILABLE", "Не подтверждён этап BGP export: " + phase, 409)
|
||||
}
|
||||
}
|
||||
|
||||
async function readState(input: Record<string, unknown>) {
|
||||
const serverId = Number(input.serverId), id = String(input.id), mode = modeSchema.parse(input.mode)
|
||||
const { server, client } = await routerClient(serverId)
|
||||
const serverProof = await privateSecretFingerprint(fingerprint(server), `bgp-export-server:${serverId}`)
|
||||
if (input.expectedServerProof !== undefined && input.expectedServerProof !== serverProof) throw new McpError("STATE_CONFLICT", "Назначение или credentials сервера BGP изменились; новое подключение не опрашивается", 409)
|
||||
const { connection: hydrated } = await readStaticBgpConnection(client, id)
|
||||
const localResponse = await client.post("/routing/bgp/connection/get", { ".id": id, "value-name": "output.redistribute" })
|
||||
const localMode = parseState("локальное output.redistribute", () => bgpRedistributionMode(localResponse))
|
||||
const templates = nativeBgpRows(await client.get<unknown>("/routing/bgp/template"))
|
||||
const usedTemplates = new Map<string, { row: NativeBgpRow; mode: BgpExportMode; references: string[]; localMode: BgpExportMode | null }>()
|
||||
const resolveTemplate = async (reference: string, stack: string[] = []): Promise<BgpExportMode> => {
|
||||
const found = templates.filter(row => row[".id"] === reference || row.name === reference)
|
||||
if (found.length !== 1 || stack.length >= 16 || stack.includes(found[0][".id"])) throw new McpError("STATE_UNAVAILABLE", "Не подтверждён граф шаблонов BGP", 409)
|
||||
const row = found[0], cached = usedTemplates.get(row[".id"])
|
||||
if (typeof row.name !== "string" || !row.name.trim() || row.name.length > 100) throw new McpError("STATE_UNAVAILABLE", "Не подтверждено имя шаблона BGP", 409)
|
||||
if (cached) return cached.mode
|
||||
if (usedTemplates.size >= 100) throw new McpError("LIMIT_EXCEEDED", "Граф BGP превышает 100 шаблонов", 409)
|
||||
const [effective] = await readOspfEffectiveDisabled<NativeBgpRow & { disabled?: string }>([row], nativeId => client.post("/routing/bgp/template/get", { ".id": nativeId, "value-name": "disabled" }))
|
||||
if (!["false", "no"].includes(String(effective.disabled))) throw new McpError("STATE_CONFLICT", "Используемый шаблон BGP отключён или его состояние неизвестно", 409)
|
||||
const setting = bgpRedistributionMode(await client.post("/routing/bgp/template/get", { ".id": row[".id"], "value-name": "output.redistribute" }))
|
||||
const refs = bgpTemplateReferences(await client.post("/routing/bgp/template/get", { ".id": row[".id"], "value-name": "templates" }))
|
||||
const parents: BgpExportMode[] = []
|
||||
for (const ref of refs) parents.push(await resolveTemplate(ref, [...stack, row[".id"]]))
|
||||
if (new Set(parents).size > 1) throw new McpError("STATE_UNAVAILABLE", "Шаблоны BGP задают неоднозначный набор источников", 409)
|
||||
const result = setting ?? parents[0] ?? "disabled"
|
||||
const stable = { ...Object.fromEntries(Object.entries(effective).filter(([key]) => !["inactive", "invalid", "running"].includes(key) && !key.startsWith("debug."))), ".id": row[".id"], disabled: "false" }
|
||||
usedTemplates.set(row[".id"], { row: stable, mode: result, references: refs, localMode: setting })
|
||||
return result
|
||||
}
|
||||
const templateRefs = bgpTemplateReferences(await client.post("/routing/bgp/connection/get", { ".id": id, "value-name": "templates" }))
|
||||
const inherited: BgpExportMode[] = []
|
||||
for (const ref of templateRefs) inherited.push(await resolveTemplate(ref))
|
||||
if (new Set(inherited).size > 1) throw new McpError("STATE_UNAVAILABLE", "BGP-подключение наследует неоднозначные источники", 409)
|
||||
const inheritedMode = inherited[0] ?? "disabled", currentMode = localMode ?? inheritedMode
|
||||
if (mode === "disabled" && currentMode !== "disabled" && inheritedMode !== "disabled") throw new McpError("STATE_CONFLICT", "Очистка output.redistribute может вернуть экспорт из шаблона; сначала измените наследование отдельным планом", 409)
|
||||
const connection = parseState("static/dynamic, disabled и явный instance/VRF/table", () => bgpExportConnection(hydrated, currentMode))
|
||||
const sourceModes = input.sourceModes === undefined ? modes([currentMode, mode]) : modes(z.array(modeSchema).min(1).max(3).parse(input.sourceModes))
|
||||
if (!sourceModes.includes(currentMode) || !sourceModes.includes(mode)) throw new McpError("STATE_CONFLICT", "Набор источников экспорта изменился", 409)
|
||||
const parentIds = ["bgp_instance", "vrf", "routing_table", "ipv4_address", "ipv6_address", "routing_filter"]
|
||||
const parents = await Promise.all(parentIds.map(resource => readRosSnapshot(serverId, resource)))
|
||||
if (parents.some(snapshot => snapshot.rows.length > 1000)) throw new McpError("LIMIT_EXCEEDED", "Каталог зависимостей BGP превышает 1000 объектов", 409)
|
||||
if (parents.filter(snapshot => ["ipv4_address", "ipv6_address", "routing_filter"].includes(snapshot.resource)).some(snapshot => snapshot.rows.some(row => typeof row[".id"] !== "string" || !row[".id"].trim() || row[".id"].length > 200))) throw new McpError("STATE_UNAVAILABLE", "Не подтверждены native IDs адресов или фильтров BGP", 409)
|
||||
const rows = (resource: string) => parents.find(snapshot => snapshot.resource === resource)!.rows
|
||||
const unique = (resource: string, name: unknown) => {
|
||||
const found = rows(resource).filter(row => row.name === name)
|
||||
if (found.length !== 1 || typeof found[0][".id"] !== "string" || found[0].disabled === "true") throw new McpError("STATE_CONFLICT", "Не подтверждена зависимость экспорта BGP: " + resource, 409)
|
||||
return found[0]
|
||||
}
|
||||
const instance = unique("bgp_instance", connection.instance), vrf = unique("vrf", connection.vrf), table = unique("routing_table", connection["routing-table"])
|
||||
if (instance.vrf !== connection.vrf || instance["routing-table"] !== connection["routing-table"] || vrf.name !== table.name) throw new McpError("STATE_CONFLICT", "Экспорт требует одинаковые явные instance/connection VRF и таблицу", 409)
|
||||
const [v4, v6] = await Promise.all([client.get<unknown>("/ip/route"), client.get<unknown>("/ipv6/route")])
|
||||
const sources = [...parseState("IPv4 source origins/table/prefixes", () => bgpExportSources(v4, String(table.name), sourceModes, 4)), ...parseState("IPv6 source origins/table/prefixes", () => bgpExportSources(v6, String(table.name), sourceModes, 6))]
|
||||
if (sources.length > 500) throw new McpError("LIMIT_EXCEEDED", "Экспорт BGP превышает 500 исходных маршрутов", 409)
|
||||
const objects: ChangeEvidence["objects"] = [
|
||||
{ kind: "bgp-export", id, serverId }, { kind: "bgp_connection", id, serverId },
|
||||
{ kind: "bgp_instance", id: String(instance[".id"]), serverId }, { kind: "vrf", id: String(vrf[".id"]), serverId }, { kind: "routing_table", id: String(table[".id"]), serverId },
|
||||
...sources.map(row => ({ kind: row.family === 4 ? "ipv4_route" : "ipv6_route", id: row.id, serverId })),
|
||||
...parents.filter(snapshot => ["ipv4_address", "ipv6_address", "routing_filter"].includes(snapshot.resource)).flatMap(snapshot => snapshot.rows.map(row => ({ kind: snapshot.resource, id: String(row[".id"]), serverId }))),
|
||||
...[...usedTemplates.keys()].map(nativeId => ({ kind: "bgp_template", id: nativeId, serverId })),
|
||||
]
|
||||
if (objects.some(row => !row.id || row.id === "undefined") || objects.length > 1000) throw new McpError("STATE_UNAVAILABLE", "Не подтверждены зависимости экспорта BGP", 409)
|
||||
const parentProof = parents.map(snapshot => ({ resource: snapshot.resource, rows: ["bgp_instance", "vrf", "routing_table"].includes(snapshot.resource) ? snapshot.rows.filter(row => row[".id"] === ({ bgp_instance: instance, vrf, routing_table: table } as Record<string, ConfigurationRow>)[snapshot.resource][".id"]) : snapshot.rows }))
|
||||
const templateProof = [...usedTemplates.values()].sort((a, b) => a.row[".id"].localeCompare(b.row[".id"]))
|
||||
const before = { serverId, serverProof, id, instance: connection.instance, vrf: connection.vrf, table: connection["routing-table"], mode: currentMode, localMode, inheritedMode,
|
||||
templates: templateProof.map(value => ({ id: value.row[".id"], name: value.row.name, mode: value.mode })),
|
||||
sourceModes, sources: sources.map(({ proof: _proof, ...row }) => row),
|
||||
connectionProof: await configurationProof(serverId, id, connection), sourceProof: await privateSecretFingerprint(fingerprint({ serverProof, parentProof, sources, templateProof }), `bgp-export-sources:${serverId}:${id}`) }
|
||||
return { server, client, connection, before, objects }
|
||||
}
|
||||
async function state(input: Record<string, unknown>) {
|
||||
try { return await readState(input) }
|
||||
catch (error) { if (error instanceof McpError) throw error; throw new McpError("STATE_UNAVAILABLE", "Не подтверждены effective export, происхождение маршрутов или явный контекст BGP", 409) }
|
||||
}
|
||||
async function current(input: Record<string, unknown>) { return (await state(input)).before }
|
||||
|
||||
export function registerBgpExportActions(register: typeof add) {
|
||||
registerMcpService(service, {
|
||||
current,
|
||||
execute: async input => {
|
||||
const before = await state(input)
|
||||
if (fingerprint(before.before) !== input.beforeFingerprint) throw new McpError("STATE_CONFLICT", "Подключение или источники BGP изменились после подтверждения", 409)
|
||||
const mode = modeSchema.parse(input.mode), expected = bgpExportConnection({ ...before.connection, ".id": String(input.id) }, mode)
|
||||
const receipt = { mode, connectionProof: await configurationProof(Number(input.serverId), String(input.id), expected), sourceProof: before.before.sourceProof, nativeDispatch: before.before.mode !== mode, forwardingVerified: false }
|
||||
if (before.before.mode !== mode) await executeGuardedApplicationWrite({ kind: "bgp-export", id: String(input.id) }, [Number(input.serverId)], async () => {
|
||||
const live = await state(input)
|
||||
if (fingerprint(live.before) !== input.beforeFingerprint) throw new McpError("STATE_CONFLICT", "Источники BGP изменились перед записью", 409)
|
||||
await live.client.patch(`/routing/bgp/connection/${encodeURIComponent(String(input.id))}`, { "output.redistribute": nativeBgpExportValue(mode) })
|
||||
}, async () => before.objects)
|
||||
return receipt
|
||||
},
|
||||
verify: async (input, result) => {
|
||||
const expected = result as { mode: string; connectionProof: string; sourceProof: string }, saved = (await state(input)).before
|
||||
return saved.mode === expected.mode && saved.connectionProof === expected.connectionProof && saved.sourceProof === expected.sourceProof
|
||||
},
|
||||
})
|
||||
register("routing.bgp.export", "mm_bgp_export_plan", "Человеческий план перераспределения connected/static в BGP с проверкой источников; disabled отключает только перераспределение", "bgp", ["mm:bgp:write", "mm:network:read", "mm:filters:read"], "write", "servers", schema, {
|
||||
prepare: async input => {
|
||||
const initial = await state(input), before = initial.before, request = { ...input, sourceModes: before.sourceModes, beforeFingerprint: fingerprint(before), expectedServerProof: before.serverProof }
|
||||
return { target: { scope: "servers", serverIds: [Number(input.serverId)] }, objects: initial.objects, snapshots: [], before,
|
||||
steps: [{ kind: "service", service, input: request }], mutationCost: 1, requiresHuman: true, verificationScope: "configuration",
|
||||
warnings: ["Изменяется output.redistribute одного BGP-подключения. Connected/static экспортируют подходящие маршруты его явной таблицы; это не список разрешённых префиксов.",
|
||||
"Существующие input/output filters не изменяются; они могут разрешать или отклонять экспорт. Их конфигурация и исходные маршруты запечатаны в плане.",
|
||||
"Отключается только перераспределение connected/static. Output networks и default-originate не меняются; очистка в активный inherited export из шаблона отклоняется.",
|
||||
"Включение экспорта требует отдельного человеческого подтверждения. Проверяется конфигурация, обмен префиксами и forwarding требуют отдельных наблюдений.",
|
||||
"Поля TCP MD5 и исходные данные фильтров не выводятся: приватные proof привязаны к закрытому серверному ключу. Неопределённая запись не повторяется автоматически."] }
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { z } from "zod"
|
||||
import { pool } from "../../db/index.js"
|
||||
import { readStaticBgpConnection } from "../../services/bgp-connection-read.js"
|
||||
import { nativeBgpRows, bgpTemplateReferences, type NativeBgpRow } from "../../services/bgp-export-model.js"
|
||||
import { bgpFilterName, bgpFilterProperty, effectiveBgpFilter, stableBgpPolicyRow, type BgpFilterDirection } from "../../services/bgp-filter-binding-model.js"
|
||||
import { readOspfEffectiveDisabled } from "../../services/ospf-effective-disabled.js"
|
||||
import { executeGuardedApplicationWrite } from "../../services/config-mutation-policy.js"
|
||||
import { canonicalVrfMembers } from "../../services/vrf-members.js"
|
||||
import { fingerprint, McpError, privateSecretFingerprint } from "./security.js"
|
||||
import { routerClient } from "./resources.js"
|
||||
import { registerMcpService } from "./service-registry.js"
|
||||
import type { ChangeEvidence } from "./action-types.js"
|
||||
import type { add } from "./actions.js"
|
||||
|
||||
const name = z.string().max(100).refine(value => !/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/.test(value))
|
||||
const schema = z.object({ serverId: z.number().int().positive().safe(), id: z.string().regex(/^\*[A-Za-z0-9]+$/).max(200), direction: z.enum(["input", "output"]), chain: name }).strict()
|
||||
const service = "routing.bgp.filter.bind"
|
||||
const proof = (sid: number, id: string, domain: string, value: unknown) => privateSecretFingerprint(fingerprint(value), `bgp-filter-binding:${sid}:${id}:${domain}`)
|
||||
|
||||
async function readState(input: Record<string, unknown>) {
|
||||
const serverId = Number(input.serverId), id = String(input.id), desired = String(input.chain), property = bgpFilterProperty(input.direction as BgpFilterDirection)
|
||||
const { server, client } = await routerClient(serverId), serverProof = await proof(serverId, id, "server", server)
|
||||
if (input.expectedServerProof !== undefined && input.expectedServerProof !== serverProof) throw new McpError("STATE_CONFLICT", "Назначение или credentials BGP изменились; новый источник не опрашивается", 409)
|
||||
const { connection, connections } = await readStaticBgpConnection(client, id)
|
||||
if (connections.length > 1000) throw new McpError("LIMIT_EXCEEDED", "Каталог BGP превышает 1000 подключений", 409)
|
||||
for (const key of ["instance", "vrf", "routing-table"]) if (connection[key] === undefined || connection[key] === "") {
|
||||
const value = bgpFilterName(await client.post("/routing/bgp/connection/get", { ".id": id, "value-name": key }))
|
||||
if (!value) throw new McpError("STATE_UNAVAILABLE", "Не подтверждён effective контекст BGP: " + key, 409)
|
||||
connection[key] = value
|
||||
}
|
||||
const templates = nativeBgpRows(await client.get("/routing/bgp/template"))
|
||||
if (templates.length > 100) throw new McpError("LIMIT_EXCEEDED", "Каталог BGP templates превышает 100", 409)
|
||||
const used = new Map<string, { row: NativeBgpRow; local: string | null; references: string[]; effective: string }>()
|
||||
const resolve = async (reference: string, stack: string[] = []): Promise<string> => {
|
||||
const found = templates.filter(row => row[".id"] === reference || row.name === reference)
|
||||
if (found.length !== 1 || stack.length >= 16 || stack.includes(found[0][".id"])) throw new Error("BGP template graph not confirmed")
|
||||
const row = found[0], cached = used.get(row[".id"])
|
||||
if (cached) return cached.effective
|
||||
const [hydrated] = await readOspfEffectiveDisabled<NativeBgpRow & { disabled?: string }>([row], nativeId => client.post("/routing/bgp/template/get", { ".id": nativeId, "value-name": "disabled" }))
|
||||
if (!["false", "no"].includes(String(hydrated.disabled))) throw new Error("BGP template is disabled or unknown")
|
||||
const local = bgpFilterName(await client.post("/routing/bgp/template/get", { ".id": row[".id"], "value-name": property }))
|
||||
const references = bgpTemplateReferences(await client.post("/routing/bgp/template/get", { ".id": row[".id"], "value-name": "templates" }))
|
||||
const parents = []; for (const ref of references) parents.push(await resolve(ref, [...stack, row[".id"]]))
|
||||
const effective = effectiveBgpFilter(local, parents)
|
||||
used.set(row[".id"], { row: { ...stableBgpPolicyRow(hydrated), ".id": row[".id"] }, local, references, effective }); return effective
|
||||
}
|
||||
const localChain = bgpFilterName(await client.post("/routing/bgp/connection/get", { ".id": id, "value-name": property }))
|
||||
const references = bgpTemplateReferences(await client.post("/routing/bgp/connection/get", { ".id": id, "value-name": "templates" }))
|
||||
const parents = []; for (const ref of references) parents.push(await resolve(ref))
|
||||
const inheritedChain = new Set(parents).size > 1 ? null : parents[0] ?? "", effectiveChain = effectiveBgpFilter(localChain, parents)
|
||||
if (desired === "" && inheritedChain !== "") throw new McpError("STATE_CONFLICT", "Очистка local binding включила бы inherited filter; нужен отдельный план шаблона", 409)
|
||||
const snapshots = await Promise.all([["bgp_instance", "/routing/bgp/instance"], ["vrf", "/ip/vrf"], ["routing_table", "/routing/table"]].map(async ([resource, path]) => ({ resource, rows: nativeBgpRows(await client.get(path)) })))
|
||||
if (snapshots.some(row => row.rows.length > 1000)) throw new Error("BGP context exceeds 1000 rows")
|
||||
const unique = (kind: string, reference: unknown) => {
|
||||
const rows = snapshots.find(row => row.resource === kind)!.rows.filter(row => row.name === reference || row[".id"] === reference)
|
||||
if (rows.length !== 1 || rows[0].disabled === "true" || [true, "true", "yes"].includes(rows[0].invalid as boolean | string)) throw new Error("BGP context not confirmed")
|
||||
return rows[0]
|
||||
}
|
||||
const instance = unique("bgp_instance", connection.instance), vrf = unique("vrf", connection.vrf), table = unique("routing_table", connection["routing-table"])
|
||||
const [effectiveInstance] = await readOspfEffectiveDisabled<NativeBgpRow & { disabled?: string }>([instance], nativeId => client.post("/routing/bgp/instance/get", { ".id": nativeId, "value-name": "disabled" }))
|
||||
if (!["false", "no"].includes(String(effectiveInstance.disabled))) throw new Error("BGP instance disabled/unknown")
|
||||
Object.assign(instance, effectiveInstance)
|
||||
for (const key of ["vrf", "routing-table"]) if (instance[key] === undefined || instance[key] === "") {
|
||||
const value = bgpFilterName(await client.post("/routing/bgp/instance/get", { ".id": String(instance[".id"]), "value-name": key }))
|
||||
if (!value) throw new Error("BGP instance effective context not confirmed")
|
||||
instance[key] = value
|
||||
}
|
||||
if (unique("vrf", instance.vrf)[".id"] !== vrf[".id"] || unique("routing_table", instance["routing-table"])[".id"] !== table[".id"] || vrf.name !== table.name) throw new Error("BGP instance/connection contexts disagree")
|
||||
const filters = nativeBgpRows(await client.get("/routing/filter/rule"))
|
||||
if (filters.length > 1000) throw new McpError("LIMIT_EXCEEDED", "Каталог BGP filters превышает 1000 строк", 409)
|
||||
const selected = filters.filter(row => row.chain === desired)
|
||||
if (desired && !selected.length) throw new McpError("NOT_FOUND", "Выбранная цепочка фильтров не найдена", 404)
|
||||
if (selected.some(row => [true, "true", "yes"].includes(row.invalid as boolean | string))) throw new McpError("STATE_CONFLICT", "Выбранная цепочка содержит invalid rule", 409)
|
||||
const effectiveFilters = desired ? await readOspfEffectiveDisabled<NativeBgpRow & { disabled?: string }>(selected, nativeId => client.post("/routing/filter/rule/get", { ".id": nativeId, "value-name": "disabled" })) : []
|
||||
const protections = (await pool.query("SELECT object_ref FROM mcp_protections WHERE object_ref->>'serverId'=$1 OR (object_ref->>'kind'='server' AND object_ref->>'id'=$1) LIMIT 1001", [String(serverId)])).rows
|
||||
if (protections.length > 1000) throw new Error("BGP protection closure exceeds limit")
|
||||
const objects: ChangeEvidence["objects"] = [{ kind: "bgp-policy", id, serverId }, { kind: "bgp_connection", id, serverId },
|
||||
...[["bgp_instance", instance], ["vrf", vrf], ["routing_table", table]].map(([kind, row]) => ({ kind: String(kind), id: String((row as Record<string, unknown>)[".id"]), serverId })),
|
||||
...filters.map(row => ({ kind: "routing_filter", id: row[".id"], serverId })), ...[...used.keys()].map(nativeId => ({ kind: "bgp_template", id: nativeId, serverId })),
|
||||
...protections.map(({ object_ref: ref }) => ({ kind: ref.kind, id: ref.id, serverId }))]
|
||||
const uniqueObjects = [...new Map(objects.map(row => [fingerprint(row), row])).values()]
|
||||
if (uniqueObjects.length > 1000) throw new Error("BGP dependency closure exceeds limit")
|
||||
const canonicalConnection = { ...stableBgpPolicyRow(connection), [property]: effectiveChain }
|
||||
const dependencyProof = await proof(serverId, id, "dependencies", { serverProof, otherConnections: connections.filter(row => row[".id"] !== id).map(stableBgpPolicyRow), instance: stableBgpPolicyRow(instance), vrf: { ...stableBgpPolicyRow(vrf), ...(vrf.interfaces !== undefined ? { interfaces: canonicalVrfMembers(vrf.interfaces) } : {}) }, table: stableBgpPolicyRow(table),
|
||||
filters: filters.map(stableBgpPolicyRow), used: [...used.values()], references, protections })
|
||||
const before = { serverId, id, direction: input.direction, property, instance: instance.name, vrf: vrf.name, table: table.name, localChain, inheritedChain, effectiveChain,
|
||||
selectedRuleCount: selected.length, selectedEnabledRuleCount: effectiveFilters.filter(row => ["false", "no"].includes(String(row.disabled))).length, serverProof, dependencyProof,
|
||||
connectionProof: await proof(serverId, id, "connection", canonicalConnection) }
|
||||
return { server, client, connection: canonicalConnection, before, objects: uniqueObjects }
|
||||
}
|
||||
async function state(input: Record<string, unknown>) {
|
||||
try { return await readState(input) } catch (error) { if (error instanceof McpError) throw error; throw new McpError("STATE_UNAVAILABLE", "Не подтверждены BGP binding, templates или routing dependencies", 409) }
|
||||
}
|
||||
export function registerBgpFilterBindingActions(register: typeof add) {
|
||||
registerMcpService(service, {
|
||||
current: async input => (await state(input)).before,
|
||||
execute: async input => {
|
||||
const initial = await state(input)
|
||||
if (fingerprint(initial.before) !== input.beforeFingerprint) throw new McpError("STATE_CONFLICT", "BGP policy изменилась после подтверждения", 409)
|
||||
const desired = String(input.chain), nativeDispatch = desired ? initial.before.localChain !== desired : initial.before.localChain !== null && initial.before.localChain !== ""
|
||||
const result = { desired, nativeDispatch, dependencyProof: initial.before.dependencyProof, connectionProof: await proof(initial.server.id, String(input.id), "connection", { ...initial.connection, [initial.before.property]: desired }), forwardingVerified: false }
|
||||
await executeGuardedApplicationWrite({ kind: "bgp-policy", id: String(input.id) }, [initial.server.id], async () => {
|
||||
const live = await state(input)
|
||||
if (fingerprint(live.before) !== input.beforeFingerprint) throw new McpError("STATE_CONFLICT", "BGP dependencies изменились перед dispatch", 409)
|
||||
if (nativeDispatch) await live.client.patch("/routing/bgp/connection/" + encodeURIComponent(String(input.id)), { [live.before.property]: desired })
|
||||
}, async () => initial.objects)
|
||||
return result
|
||||
},
|
||||
verify: async (input, result) => {
|
||||
const receipt = result as { desired: string; connectionProof: string; dependencyProof: string }, after = (await state(input)).before
|
||||
return after.effectiveChain === receipt.desired && (receipt.desired ? after.localChain === receipt.desired : !after.localChain) && after.connectionProof === receipt.connectionProof && after.dependencyProof === receipt.dependencyProof
|
||||
},
|
||||
})
|
||||
register("routing.bgp.filter.bind", "mm_bgp_filter_binding_plan", "Человеческий план локальной input/output привязки BGP к существующей цепочке; пустая chain очищает local binding", "bgp", ["mm:bgp:write", "mm:filters:read", "mm:network:read"], "write", "servers", schema, {
|
||||
prepare: async input => {
|
||||
const initial = await state(input)
|
||||
return { target: { scope: "servers", serverIds: [initial.server.id] }, objects: initial.objects, snapshots: [], before: initial.before, requiresHuman: true, verificationScope: "configuration", mutationCost: 1,
|
||||
steps: [{ kind: "service", service, input: { ...input, beforeFingerprint: fingerprint(initial.before), expectedServerProof: initial.before.serverProof } }],
|
||||
warnings: ["Меняется одна локальная привязка BGP; цепочка не создаётся и её код не изменяется. Изменение относится ко всем AFI/префиксам этого peer и может повлиять на выбор/экспорт маршрутов.",
|
||||
"Фильтры/templates и контекст VRF/таблицы запечатаны. Очистка в непустой inherited filter запрещена; шаблоны не изменяются неявно.",
|
||||
"Семантика jumps/opaque правил и доступность пути этим планом не подтверждаются. Пустая или полностью отключённая цепочка может отклонить все префиксы.",
|
||||
"Только подтверждение человеком; после partial/unknown нужен новый анализ. Автоматического повтора, rollback или refresh BGP не выполняется."] }
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { z } from "zod"
|
||||
import { hasPermission } from "../../lib/permissions.js"
|
||||
import { bgpPrefixRows } from "../../services/bgp-prefix-model.js"
|
||||
import { nativeBgpRows } from "../../services/bgp-export-model.js"
|
||||
import { getServerRowById } from "../servers/repository/servers-repository.js"
|
||||
import { routerClient } from "./resources.js"
|
||||
import { effectiveMcpConnection } from "./store.js"
|
||||
import { fingerprint, McpError } from "./security.js"
|
||||
import type { ActionContext } from "./action-types.js"
|
||||
import type { add } from "./actions.js"
|
||||
|
||||
const permissions = ["mm:bgp:read", "mm:network:read"]
|
||||
async function access(context: ActionContext) {
|
||||
if (context.signal?.aborted) throw new McpError("READ_CANCELLED", "Чтение BGP префиксов отменено", 409)
|
||||
const current = await effectiveMcpConnection(context.principal)
|
||||
if (permissions.some(permission => !hasPermission(current.owner_permissions, permission) || !hasPermission(current.policy.permissions, permission))) throw new McpError("PERMISSION_DENIED", "Право чтения BGP/сети отозвано", 403)
|
||||
if (fingerprint(current.policy) !== fingerprint(context.connection.policy)) throw new McpError("RESET_REQUIRED", "Область чтения BGP изменилась", 409)
|
||||
}
|
||||
export function registerBgpPrefixActions(register: typeof add) {
|
||||
register("routing.bgp.prefixes.read", "mm_bgp_prefixes_get", "Наблюдаемые BGP-префиксы IPv4/IPv6 в явной таблице; active nullable, без доказательства forwarding или attribution к peer", "bgp", permissions, "read", "servers", z.object({
|
||||
serverId: z.number().int().positive().safe(), family: z.enum(["ipv4", "ipv6"]).default("ipv4"),
|
||||
table: z.string().trim().min(1).max(100).refine(value => !/[\u0000-\u001f\u007f]/.test(value)).default("main"),
|
||||
offset: z.number().int().min(0).max(10000).default(0), limit: z.number().int().min(1).max(200).default(100),
|
||||
}).strict(), { read: async (input, context) => {
|
||||
await access(context)
|
||||
const { server, client } = await routerClient(Number(input.serverId)), startedAt = new Date().toISOString(), table = String(input.table)
|
||||
const tableState = async () => {
|
||||
await access(context)
|
||||
const selected = nativeBgpRows(await client.get("/routing/table", 10000, context.signal)).filter(row => row.name === table)
|
||||
if (selected.length !== 1) throw new McpError("STATE_UNAVAILABLE", "Таблица BGP не найдена или неоднозначна", 409)
|
||||
return fingerprint(selected)
|
||||
}
|
||||
try {
|
||||
const before = await tableState()
|
||||
await access(context)
|
||||
const family = input.family === "ipv4" ? 4 : 6
|
||||
const rows = bgpPrefixRows(await client.get(`${family === 4 ? "/ip/route" : "/ipv6/route"}?routing-table=${encodeURIComponent(table)}`, 10000, context.signal), table, family)
|
||||
if (before !== await tableState()) throw new McpError("STATE_CONFLICT", "Таблица BGP изменилась во время чтения", 409)
|
||||
const fresh = await getServerRowById(server.id)
|
||||
if (!fresh || fingerprint(fresh) !== fingerprint(server)) throw new McpError("STATE_CONFLICT", "Источник BGP изменился во время чтения", 409)
|
||||
await access(context)
|
||||
const offset = Number(input.offset), limit = Number(input.limit), items = rows.slice(offset, offset + limit)
|
||||
return { serverId: server.id, family: input.family, table, items, total: rows.length, nextOffset: offset + limit < rows.length ? offset + limit : null, incomplete: items.length < rows.length,
|
||||
startedAt, observedAt: new Date().toISOString(), timestampKind: "backend-receipt", fingerprint: fingerprint(rows), source: "routeros-route-view", atomicSnapshot: false,
|
||||
sessionAttributionVerified: false, advertisedRoutesVerified: false, forwardingVerified: false }
|
||||
} catch (error) { await access(context); if (error instanceof McpError) throw error; throw new McpError("STATE_UNAVAILABLE", "BGP route view недоступен, неоднозначен или превышает 10000 строк; успешный пустой ответ не подтверждён", 409) }
|
||||
} })
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { z } from "zod"
|
||||
import { and, eq, inArray, asc } from "drizzle-orm"
|
||||
import { db } from "../../db/index.js"
|
||||
import { servers } from "../../db/schema.js"
|
||||
import type { ActionContext } from "./action-types.js"
|
||||
|
||||
export const catalogPaging = z.object({ serverId: z.number().int().positive().safe().optional(), serverOffset: z.number().int().min(0).max(10000).default(0), serverLimit: z.number().int().min(1).max(10).default(5), offset: z.number().int().min(0).max(100000).default(0), limit: z.number().int().min(1).max(200).default(100) }).strict()
|
||||
|
||||
/** Resolve only the policy already intersected with the credential's grant,
|
||||
* before constructing clients or polling a device. */
|
||||
export async function catalogDevices(input: Record<string, unknown>, context: ActionContext, options: { includeDisabled?: boolean } = {}) {
|
||||
const ids = input.serverId ? [Number(input.serverId)] : context.connection.policy.scope.serverIds
|
||||
const eligible = ids.length ? await db.select().from(servers).where(and(inArray(servers.id, ids), ...(options.includeDisabled ? [] : [eq(servers.enabled, true)]))).orderBy(asc(servers.id)) : []
|
||||
const start = Number(input.serverOffset), selected = eligible.slice(start, start + Number(input.serverLimit))
|
||||
return { selected, serverIds: selected.map(row => row.id), totalServers: eligible.length, nextServerOffset: start + selected.length < eligible.length ? start + selected.length : null, incomplete: selected.length < eligible.length }
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { ChangeEvidence } from "./action-types.js"
|
||||
import { McpError } from "./security.js"
|
||||
|
||||
export function changeMutationCost(evidence: Pick<ChangeEvidence, "steps" | "mutationCost">): number {
|
||||
const cost = evidence.mutationCost
|
||||
if (cost !== undefined && (!Number.isSafeInteger(cost) || cost < 1 || cost > 10000 || cost < evidence.steps.length)) throw new McpError("STATE_CONFLICT", "Не подтверждена стоимость пакетного плана", 409)
|
||||
return Math.max(1, evidence.steps.length, cost ?? 0)
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { randomUUID } from "node:crypto"
|
||||
import { readFile, writeFile } from "node:fs/promises"
|
||||
import { setTimeout as delay } from "node:timers/promises"
|
||||
import { pool, closePool } from "../../db/index.js"
|
||||
import { applySqlMigrations } from "../../db/migrate.js"
|
||||
import { mcpConnectionCreateSchema } from "@mmapp/contracts/mcp"
|
||||
import { createMcpConnection, authenticateMcpToken, updateMcpSettings } from "./store.js"
|
||||
import { findAction, listActions } from "./actions.js"
|
||||
import { invokeMcpTool, approvePlan, applyPlan } from "./runtime.js"
|
||||
import { readRosSnapshot, routerClient } from "./resources.js"
|
||||
import { MikrotikClient, MikrotikError } from "../../services/mikrotik.js"
|
||||
import { McpError } from "./security.js"
|
||||
import type { AuthUser } from "../../lib/permissions.js"
|
||||
import { acceptChrOverlay, cleanupChrOverlay } from "./chr-overlay-acceptance.js"
|
||||
import { cleanupChrCommunityGatewayResolvers } from "./chr-bgp-community-acceptance.js"
|
||||
import { cleanupChrBgpFixture } from "./chr-bgp-acceptance.js"
|
||||
import { chrNativeFailure } from "./chr-native-failure.js"
|
||||
import { readChrRecoverySource } from "./chr-recovery-source.js"
|
||||
import { cleanupChrOspf } from "./chr-ospf-acceptance.js"
|
||||
import { chrFixtureVrf, chrOspfForwarding } from "./chr-fixture-context.js"
|
||||
import { acceptChrOspfPassive } from "./chr-ospf-passive.js"
|
||||
import { acceptChrWireguardInterface } from "./chr-wireguard-interface.js"
|
||||
import { acceptChrWireguardPeer } from "./chr-wireguard-peer.js"
|
||||
import { acceptChrWireguardInterfaceUpdate } from "./chr-wireguard-interface-update.js"
|
||||
|
||||
// Explicit lab allowlist: cannot accidentally point this mutating test at production.
|
||||
const hosts = (process.env.MMAPP_TEST_CHR_HOSTS ?? "").split(",").map(value => value.trim()).filter(Boolean)
|
||||
assert.ok(hosts.length > 0 && hosts.length <= 2 && new Set(hosts).size === hosts.length)
|
||||
assert.ok(hosts.every(host => ["194.226.121.150", "104.165.244.7"].includes(host)))
|
||||
const username = process.env.MMAPP_TEST_CHR_USER, password = process.env.MMAPP_TEST_CHR_PASSWORD
|
||||
assert.ok(username && password, "Explicit lab credentials required")
|
||||
const tunnelPorts = process.env.MMAPP_TEST_CHR_TUNNEL_PORTS?.split(",").map(Number)
|
||||
if (tunnelPorts) assert.ok(tunnelPorts.length === hosts.length && tunnelPorts.every(port => Number.isInteger(port) && port >= 1024 && port <= 65535))
|
||||
const actor: AuthUser = { id: "chr-acceptance-owner", apps: ["mm"], permissions: ["mm:settings:admin", "mm:network:admin", "mm:bgp:admin", "mm:servers:admin", "mm:filters:read"], expiresAt: Date.now() + 3600000 }
|
||||
const marker = "mcp-test-" + randomUUID().slice(0, 8)
|
||||
const routingAcceptance = process.env.MMAPP_TEST_CHR_ROUTING === "true"
|
||||
const overlayAcceptance = process.env.MMAPP_TEST_CHR_OVERLAY === "true"
|
||||
const ospfAcceptance = process.env.MMAPP_TEST_CHR_OSPF === "true"
|
||||
const ospfSourceAcceptance = process.env.MMAPP_TEST_CHR_OSPF_SOURCES === "true"
|
||||
const ospfForwarding = chrOspfForwarding()
|
||||
const ospfPassiveOnly = process.env.MMAPP_TEST_CHR_OSPF_PASSIVE_ONLY === "true"
|
||||
const wireguardPeerOnly = process.env.MMAPP_TEST_CHR_WG_PEER_ONLY === "true"
|
||||
const wireguardPeerUpdate = process.env.MMAPP_TEST_CHR_WG_PEER_UPDATE === "true"
|
||||
const wireguardInterfaceDelete = process.env.MMAPP_TEST_CHR_WG_INTERFACE_DELETE === "true"
|
||||
const wireguardInterfaceUpdate = process.env.MMAPP_TEST_CHR_WG_INTERFACE_UPDATE === "true"
|
||||
assert.ok(!wireguardInterfaceDelete || wireguardPeerOnly && !wireguardPeerUpdate, "WireGuard interface cascade requires its isolated peer fixture mode")
|
||||
assert.ok(!wireguardPeerUpdate || wireguardPeerOnly, "WireGuard peer update requires its isolated peer scenario")
|
||||
assert.ok(!ospfPassiveOnly || !ospfAcceptance && !overlayAcceptance && !routingAcceptance, "Passive-only mode must be isolated from forwarding scenarios")
|
||||
assert.ok(!ospfSourceAcceptance || ospfAcceptance, "Source-bound OSPF acceptance requires OSPF")
|
||||
const ecmpAcceptance = process.env.MMAPP_TEST_CHR_ECMP === "true"
|
||||
const bgpAcceptance = process.env.MMAPP_TEST_CHR_BGP === "true"
|
||||
const bgpExportAcceptance = process.env.MMAPP_TEST_CHR_BGP_EXPORT === "true"
|
||||
const bgpForwardAcceptance = process.env.MMAPP_TEST_CHR_BGP_FORWARD === "true"
|
||||
const bgpFilterBindingAcceptance = process.env.MMAPP_TEST_CHR_BGP_FILTER_BINDING === "true"
|
||||
const bgpCommunityAcceptance = process.env.MMAPP_TEST_CHR_BGP_COMMUNITY === "true"
|
||||
assert.ok(!wireguardPeerOnly || hosts.length === 1 && !ospfPassiveOnly && !overlayAcceptance && !ospfAcceptance && !routingAcceptance && !bgpAcceptance && !ecmpAcceptance, "WireGuard peer mode requires one isolated device")
|
||||
assert.ok(!wireguardInterfaceUpdate || !wireguardPeerOnly && !wireguardInterfaceDelete && !wireguardPeerUpdate && !ospfPassiveOnly && !overlayAcceptance && !ospfAcceptance && !routingAcceptance && !bgpAcceptance && !ecmpAcceptance, "WireGuard interface update requires its isolated disabled fixture mode")
|
||||
assert.ok(!ospfForwarding || ospfSourceAcceptance && !bgpAcceptance && !ecmpAcceptance, "OSPF forwarding requires its isolated source-bound scenario")
|
||||
assert.ok(process.env.MMAPP_TEST_CHR_COMMUNITY_GATEWAY_RESOLVER !== "true" || bgpCommunityAcceptance && process.env.MMAPP_TEST_CHR_COMMUNITY_LITERAL_GATEWAY !== "true")
|
||||
assert.ok(process.env.MMAPP_TEST_CHR_BGP_BINDING_FAULTS !== "false" || bgpCommunityAcceptance)
|
||||
const overlayCleanupOnly = process.env.MMAPP_TEST_CHR_OVERLAY_CLEANUP_ONLY === "true"
|
||||
assert.ok(!overlayCleanupOnly || overlayAcceptance && process.env.MMAPP_TEST_CHR_OVERLAY_CLEANUP_MARKER)
|
||||
if (bgpFilterBindingAcceptance) actor.permissions.push("mm:filters:write")
|
||||
assert.ok(!ospfAcceptance || overlayAcceptance, "OSPF acceptance requires the isolated overlay")
|
||||
assert.ok(!ecmpAcceptance || overlayAcceptance, "ECMP acceptance requires the isolated overlay")
|
||||
assert.ok(!bgpAcceptance || overlayAcceptance, "BGP acceptance requires the isolated overlay")
|
||||
assert.ok(!bgpExportAcceptance || bgpAcceptance, "BGP prefix acceptance requires BGP and the isolated overlay")
|
||||
assert.ok(!bgpForwardAcceptance || bgpExportAcceptance, "BGP forwarding probes require prefix acceptance")
|
||||
assert.ok(!bgpFilterBindingAcceptance || bgpForwardAcceptance, "BGP filter-binding acceptance requires prefix and packet acceptance")
|
||||
assert.ok(!bgpCommunityAcceptance || bgpFilterBindingAcceptance, "Community propagation requires filter-binding and packet acceptance")
|
||||
const requestGapText = process.env.MMAPP_TEST_CHR_REQUEST_GAP_MS ?? "0"
|
||||
assert.ok(["0", "100", "250", "500"].includes(requestGapText))
|
||||
const requestGapMs = Number(requestGapText)
|
||||
let pacing = Promise.resolve(), lastRequestAt = 0
|
||||
const pace = async () => {
|
||||
if (!requestGapMs) return
|
||||
const ready = pacing.then(async () => { const wait = requestGapMs - (Date.now() - lastRequestAt); if (wait > 0) await delay(wait); lastRequestAt = Date.now() })
|
||||
pacing = ready.catch(() => {}); await ready
|
||||
}
|
||||
const report: Record<string, unknown> = { startedAt: new Date().toISOString(), marker, tools: listActions().length, nativeRequestGapMs: requestGapMs, checks: [], complete: false, ...(ospfSourceAcceptance ? { optimizerRevision: findAction("mm_ospf_optimizer_plan").operation.version, measurementRevision: findAction("mm_routing_measurement_plan").operation.version, sourceMigration: "0029_mcp_routing_sources" } : {}) }
|
||||
const checks = report.checks as Record<string, unknown>[]
|
||||
if (wireguardPeerOnly) Object.assign(report, { fixtureSchema: wireguardPeerUpdate ? "wg-peer-update-v1" : "wg-peer-delete-v1", ...(wireguardPeerUpdate ? { businessUpdateRevision: findAction("mm_wireguard_peer_edit_plan").operation.version, nativeUpdateRevision: findAction("mm_wireguard_peer_update_plan").operation.version } : {}), businessDeleteRevision: findAction("mm_wireguard_peer_remove_plan").operation.version, nativeDeleteRevision: findAction("mm_wireguard_peer_delete_plan").operation.version })
|
||||
if (wireguardInterfaceDelete) Object.assign(report, { fixtureSchema: "wg-interface-delete-v1", businessInterfaceDeleteRevision: findAction("mm_wireguard_interface_remove_plan").operation.version, nativeInterfaceDeleteRevision: findAction("mm_wireguard_delete_plan").operation.version })
|
||||
if (ospfForwarding) Object.assign(report, { ospfForwarding: true, fixtureSchema: "ospf-forward-v1", routeProofOperation: "routing.ospf.route.proof.read", vrfNames: [1, 2].map(id => chrFixtureVrf(marker, id)) })
|
||||
const reportUrl = new URL(wireguardInterfaceDelete ? "../../../../docs/mcp-chr-wireguard-interface-delete-acceptance.json" : wireguardPeerUpdate ? "../../../../docs/mcp-chr-wireguard-peer-update-acceptance.json" : wireguardPeerOnly ? "../../../../docs/mcp-chr-wireguard-peer-delete-acceptance.json" : ospfPassiveOnly ? "../../../../docs/mcp-chr-ospf-passive-toggle-acceptance.json" : ospfForwarding ? (overlayCleanupOnly ? "../../../../docs/mcp-chr-ospf-forward-recovery.json" : "../../../../docs/mcp-chr-ospf-forward-acceptance.json") : ospfSourceAcceptance ? (overlayCleanupOnly ? "../../../../docs/mcp-chr-ospf-source-recovery.json" : "../../../../docs/mcp-chr-ospf-source-acceptance.json") : bgpCommunityAcceptance ? "../../../../docs/mcp-chr-bgp-community-acceptance.json" : bgpFilterBindingAcceptance ? "../../../../docs/mcp-chr-bgp-filter-binding-acceptance.json" : bgpForwardAcceptance ? "../../../../docs/mcp-chr-bgp-forward-acceptance.json" : bgpExportAcceptance ? "../../../../docs/mcp-chr-bgp-prefix-acceptance.json" : bgpAcceptance ? "../../../../docs/mcp-chr-bgp-acceptance.json" : ecmpAcceptance ? "../../../../docs/mcp-chr-ecmp-acceptance.json" : ospfAcceptance ? "../../../../docs/mcp-chr-ospf-acceptance.json" : overlayAcceptance ? "../../../../docs/mcp-chr-overlay-acceptance.json" : routingAcceptance ? "../../../../docs/mcp-chr-path-acceptance.json" : "../../../../docs/mcp-chr-acceptance.json", import.meta.url)
|
||||
let checkpointQueue = Promise.resolve()
|
||||
const wgUpdateReportUrl = new URL(`../../../../docs/mcp-chr-wg-interface-update-${marker.slice(-8)}-acceptance.json`, import.meta.url)
|
||||
if (wireguardInterfaceUpdate) Object.assign(report, { fixtureSchema: "wg-interface-update-v2", privateProofDomain: "wireguard-interface-update-v2", businessRevision: findAction("mm_wireguard_interface_edit_plan").operation.version, nativeAliasRevision: findAction("mm_wireguard_update_plan").operation.version, forwardingVerified: false })
|
||||
const checkpoint = () => {
|
||||
const payload = JSON.stringify(report, null, 2) + "\n"
|
||||
const pending = checkpointQueue.catch(() => {}).then(() => writeFile(wireguardInterfaceUpdate ? wgUpdateReportUrl : overlayCleanupOnly && !ospfSourceAcceptance ? new URL("../../../../docs/mcp-chr-overlay-recovery.json", import.meta.url) : reportUrl, payload))
|
||||
checkpointQueue = pending; return pending
|
||||
}
|
||||
const originalPost = MikrotikClient.prototype.post, originalPatch = MikrotikClient.prototype.patch
|
||||
const originalGet = MikrotikClient.prototype.get, originalDelete = MikrotikClient.prototype.delete
|
||||
const originalPut = MikrotikClient.prototype.put
|
||||
const captureNativeFailure = async (error: unknown, method: string, path: string, startedAt: number) => {
|
||||
if (checks.filter(row => row.phase === "native-request-failure").length >= 256) return
|
||||
checks.push(chrNativeFailure(error, method, path, startedAt)); await checkpoint()
|
||||
}
|
||||
MikrotikClient.prototype.get = async function<T>(path: string, timeoutMs?: number, signal?: AbortSignal, maxResponseBytes?: number): Promise<T> {
|
||||
await pace(); const startedAt = Date.now()
|
||||
try { return await originalGet.call(this, path, timeoutMs, signal, maxResponseBytes) as T } catch (error) { await captureNativeFailure(error, "GET", path, startedAt); throw error }
|
||||
}
|
||||
MikrotikClient.prototype.delete = async function(path: string, timeoutMs?: number, beforeDispatch?:()=>Promise<unknown>): Promise<void> {
|
||||
await pace(); const startedAt = Date.now()
|
||||
try { const result = await originalDelete.call(this, path, timeoutMs, beforeDispatch); if (wireguardInterfaceDelete) { checks.push({ phase: "wg-cascade-native-delete-ack", path, at: new Date().toISOString() }); await checkpoint() } return result } catch (error) { await captureNativeFailure(error, "DELETE", path, startedAt); throw error }
|
||||
}
|
||||
MikrotikClient.prototype.put = async function<T>(path: string, body: Record<string, string>, timeoutMs?: number, beforeDispatch?:()=>Promise<unknown>): Promise<T> {
|
||||
await pace(); const startedAt = Date.now()
|
||||
try { return await originalPut.call(this, path, body, timeoutMs, beforeDispatch) as T } catch (error) { await captureNativeFailure(error, "PUT", path, startedAt); throw error }
|
||||
}
|
||||
const captureParser = async (error: unknown, path: string) => {
|
||||
if (!bgpCommunityAcceptance || !(error instanceof MikrotikError) || error.statusCode !== 400 || !path.startsWith("/routing/filter/rule")) return
|
||||
let detail = ""
|
||||
try { const parsed = JSON.parse(error.body); if (typeof parsed.detail === "string") detail = parsed.detail } catch { /* Never save headers/raw provider payloads. */ }
|
||||
detail = detail.replaceAll(password!, "[redacted]").replace(/(?:password|secret|token|private-key)\s*[=:]\s*[^\s;,]+/gi, "[redacted]").slice(0, 500)
|
||||
checks.push({ phase: "community-owned-parser-error", path, nativeStatus: 400, detail }); await checkpoint()
|
||||
}
|
||||
{
|
||||
MikrotikClient.prototype.post = async function<T>(path: string, body: Record<string, string | string[]>, timeoutMs?: number, signal?: AbortSignal): Promise<T> {
|
||||
await pace(); const startedAt = Date.now()
|
||||
try { return await originalPost.call(this, path, body, timeoutMs, signal) as T } catch (error) { await captureNativeFailure(error, "POST", path, startedAt); await captureParser(error, path); throw error }
|
||||
}
|
||||
MikrotikClient.prototype.patch = async function<T>(path: string, body: Record<string, string>, timeoutMs?: number, beforeDispatch?:()=>Promise<unknown>): Promise<T> {
|
||||
await pace(); const startedAt = Date.now()
|
||||
try { return await originalPatch.call(this, path, body, timeoutMs, beforeDispatch) as T } catch (error) { await captureNativeFailure(error, "PATCH", path, startedAt); await captureParser(error, path); throw error }
|
||||
}
|
||||
}
|
||||
try {
|
||||
await checkpoint()
|
||||
await applySqlMigrations(pool)
|
||||
assert.equal((await pool.query("SELECT count(*)::int AS n FROM servers")).rows[0].n, 0, "Acceptance requires an empty disposable database")
|
||||
await updateMcpSettings({ enabled: true }, actor)
|
||||
for (const [index, host] of hosts.entries()) {
|
||||
const serverId = index + 1
|
||||
await pool.query("INSERT INTO servers(id,name,host,port,use_ssl,username,password) VALUES($1,$2,$3,$4,false,$5,$6)", [serverId, marker, tunnelPorts ? "127.0.0.1" : host, tunnelPorts?.[index] ?? 80, username, password])
|
||||
}
|
||||
const operationIds = ["routing.topology.read", "routing.path.verify", "operations.recover", "operations.reconcile", "ros.bridge.list", "ros.bridge.create", "ros.bridge.update", "ros.bridge.delete", "ros.ipv4_route.list", "ros.ipv6_route.list", ...["routing_table", "ipv4_route"].flatMap(resource => ["create", "update", "delete"].map(action => `ros.${resource}.${action}`))]
|
||||
if (overlayAcceptance) operationIds.push(...["gre", "vrf", "ipv4_address", "ipv6_address"].flatMap(resource => ["create", "update", "delete"].map(action => `ros.${resource}.${action}`)))
|
||||
if (wireguardPeerOnly) operationIds.push("network.wireguard.peers.delete", "ros.wireguard.create", "ros.wireguard.delete", "ros.wireguard_peer.create", "ros.wireguard_peer.delete")
|
||||
if (wireguardInterfaceDelete) operationIds.push("network.wireguard.interfaces.delete", ...["ipv4_address", "ipv6_address"].flatMap(resource => ["create", "delete"].map(action => `ros.${resource}.${action}`)))
|
||||
if (wireguardPeerUpdate) operationIds.push("network.wireguard.peers.update", "ros.wireguard_peer.update")
|
||||
if (wireguardInterfaceUpdate) operationIds.push("network.wireguard.interfaces.update", "ros.wireguard.create", "ros.wireguard.update", "ros.wireguard.delete")
|
||||
if (ospfForwarding) operationIds.push("routing.ospf.route.proof.read")
|
||||
if (ospfAcceptance) operationIds.push("diagnostics.bandwidth", "routing.ospf.optimize", ...["ospf_instance", "ospf_area", "ospf_template", "bfd_configuration"].flatMap(resource => ["create", "update", "delete"].map(action => `ros.${resource}.${action}`)))
|
||||
if (ospfPassiveOnly) operationIds.push(...["ospf_instance", "ospf_area", "ospf_template", "vrf"].flatMap(resource => ["create", "update", "delete"].map(action => `ros.${resource}.${action}`)))
|
||||
if (ecmpAcceptance) operationIds.push("routing.ecmp.update", "ros.ipv6_route.create", "ros.ipv6_route.delete")
|
||||
if (bgpAcceptance) operationIds.push(...["bgp_instance", "bgp_connection"].flatMap(resource => ["create", "update", "delete"].map(action => `ros.${resource}.${action}`)))
|
||||
if (bgpExportAcceptance) operationIds.push("routing.bgp.export", "routing.bgp.prefixes.read")
|
||||
if (bgpFilterBindingAcceptance) operationIds.push("routing.bgp.filter.bind", "filters.rules.replace")
|
||||
if (bgpCommunityAcceptance) operationIds.push("routing.bgp.community.policy")
|
||||
if (process.env.MMAPP_TEST_CHR_COMMUNITY_GATEWAY_RESOLVER === "true") operationIds.push("ros.ipv6_route.create", "ros.ipv6_route.delete")
|
||||
const issued = await createMcpConnection(mcpConnectionCreateSchema.parse({ name: marker, mode: "approval", expiresAt: new Date(Date.now() + (bgpCommunityAcceptance || ospfAcceptance ? 2700000 : 1800000)).toISOString(), permissions: ["mm:network:read", "mm:network:write", ...(bgpAcceptance ? ["mm:bgp:read", "mm:bgp:write"] : []), ...(bgpExportAcceptance ? ["mm:filters:read"] : []), ...(bgpFilterBindingAcceptance ? ["mm:filters:write"] : [])], scope: { serverIds: hosts.map((_, index) => index + 1), operationIds, allowGlobal: false }, limits: { callsPerMinute: 120, concurrentReads: 4, mutationsPerHour: bgpCommunityAcceptance ? 240 : ospfAcceptance || ecmpAcceptance || bgpAcceptance ? 160 : 40, serversPerPlan: 2, objectsPerPlan: 100 } }), actor)
|
||||
const principal = await authenticateMcpToken(issued.token)
|
||||
const execute = async (tool: string, args: Record<string, unknown>) => {
|
||||
// Preserve the stage and IDs even if the disposable DB is removed after an
|
||||
// interrupted acceptance. Never persist tool arguments or provider payloads.
|
||||
const trace: Record<string, unknown> = { phase: "mcp-operation", tool, serverId: typeof args.serverId === "number" ? args.serverId : undefined, stage: "prepare", state: "preparing" }
|
||||
checks.push(trace); await checkpoint()
|
||||
try {
|
||||
const plan = await invokeMcpTool(principal, tool, args) as { planId: string; state: string }
|
||||
trace.planId = plan.planId; trace.stage = "approval"
|
||||
assert.equal(plan.state, "awaiting-approval")
|
||||
await assert.rejects(applyPlan(principal, plan.planId, randomUUID()))
|
||||
await approvePlan(plan.planId, actor, true)
|
||||
trace.stage = "apply"
|
||||
const key = randomUUID(), operation = await applyPlan(principal, plan.planId, key)
|
||||
trace.operationId = operation.id; trace.stage = "verification"; trace.state = "running"; await checkpoint()
|
||||
assert.equal((await applyPlan(principal, plan.planId, key)).id, operation.id)
|
||||
for (let attempt = 0; attempt < (wireguardInterfaceDelete && tool === "mm_wireguard_interface_remove_plan" ? 960 : 240); attempt++) {
|
||||
const row = (await pool.query("SELECT state FROM mcp_operations WHERE id=$1", [operation.id])).rows[0]
|
||||
if (!["pending", "running"].includes(row.state)) {
|
||||
trace.state = row.state; trace.stage = "terminal"
|
||||
if (row.state !== "applied") {
|
||||
const detail = (await pool.query("SELECT error_code FROM mcp_operations WHERE id=$1", [operation.id])).rows[0]
|
||||
trace.errorCode = detail.error_code
|
||||
console.error({ tool, state: row.state, errorCode: detail.error_code })
|
||||
if (tool === "mm_routing_table_create_plan") console.error({ tableReadBack: (await readRosSnapshot(Number(args.serverId), "routing_table")).rows.filter(row => row.name === marker) })
|
||||
}
|
||||
await checkpoint()
|
||||
assert.equal(row.state, "applied", `Operation ${operation.id} ended ${row.state}`)
|
||||
const steps = (await pool.query("SELECT state FROM mcp_operation_steps WHERE operation_id=$1", [operation.id])).rows
|
||||
assert.ok(steps.length > 0 && steps.every(step => step.state === "verified"))
|
||||
return operation.id
|
||||
}
|
||||
await delay(500)
|
||||
}
|
||||
throw new Error(`Operation ${operation.id} remains pending; do not automatically retry writes`)
|
||||
} catch (error) {
|
||||
if (["preparing", "running"].includes(String(trace.state))) trace.state = "unconfirmed"
|
||||
trace.errorCode ??= error instanceof McpError ? error.code : "TEST_STAGE_UNCONFIRMED"
|
||||
await checkpoint(); throw error
|
||||
}
|
||||
}
|
||||
const cleanupMarker = process.env.MMAPP_TEST_CHR_CLEANUP_MARKER
|
||||
const overlayCleanupMarker = process.env.MMAPP_TEST_CHR_OVERLAY_CLEANUP_MARKER
|
||||
let recoveryBaseline: { serverId: number; resource: string; fingerprint: string }[] = []
|
||||
if (overlayCleanupMarker) {
|
||||
assert.ok(overlayAcceptance, "Overlay cleanup requires explicit overlay mode")
|
||||
if (overlayCleanupOnly) {
|
||||
const recoveryReport = process.env.MMAPP_TEST_CHR_OVERLAY_RECOVERY_REPORT ?? "mcp-chr-bgp-community-interrupted-overlay.json"
|
||||
assert.match(recoveryReport, /^mcp-chr-[a-z0-9-]+\.json$/)
|
||||
const previous = JSON.parse(await readFile(new URL("../../../../docs/" + recoveryReport, import.meta.url), "utf8"))
|
||||
assert.equal(previous.marker, overlayCleanupMarker)
|
||||
assert.equal(previous.complete, false)
|
||||
assert.ok(typeof previous.finishedAt === "string" && Number.isFinite(Date.parse(previous.finishedAt)) && Date.parse(previous.finishedAt) <= Date.now(), "Recovery refuses a report whose original process has not finished")
|
||||
const recoverySource = readChrRecoverySource(previous, overlayCleanupMarker)
|
||||
assert.equal(recoverySource.ospfForwarding, ospfForwarding, "Recovery fixture mode must match the source report")
|
||||
recoveryBaseline = recoverySource.baselines
|
||||
checks.push({ phase: "recovery-source", report: recoveryReport, kind: recoverySource.kind, originalMarker: overlayCleanupMarker, originalFinishedAt: previous.finishedAt, baselines: recoveryBaseline.length }); await checkpoint()
|
||||
if (recoverySource.kind === "ospf") {
|
||||
assert.ok(ospfAcceptance, "OSPF recovery requires explicitly scoped OSPF permissions")
|
||||
await cleanupChrOspf(overlayCleanupMarker, execute)
|
||||
}
|
||||
if (bgpAcceptance) await cleanupChrBgpFixture(overlayCleanupMarker, execute)
|
||||
if (process.env.MMAPP_TEST_CHR_COMMUNITY_GATEWAY_RESOLVER === "true") {
|
||||
const recordedRoutes = previous.checks.filter((row: { phase?: string }) => row.phase === "community-gateway-resolver-created").flatMap((row: { nativeRoutes?: unknown }) => { assert.ok(Array.isArray(row.nativeRoutes) && row.nativeRoutes.length === 1); return row.nativeRoutes })
|
||||
await cleanupChrCommunityGatewayResolvers(overlayCleanupMarker, execute, recordedRoutes)
|
||||
}
|
||||
}
|
||||
await cleanupChrOverlay(hosts, overlayCleanupMarker, execute)
|
||||
checks.push({ interruptedOverlayRemoved: overlayCleanupMarker })
|
||||
}
|
||||
if (cleanupMarker) {
|
||||
assert.match(cleanupMarker, /^mcp-test-[0-9a-f]{8}$/)
|
||||
for (const [index, host] of hosts.entries()) {
|
||||
const serverId = index + 1, { client } = await routerClient(serverId)
|
||||
const tables = (await readRosSnapshot(serverId, "routing_table")).rows.filter(row => row.name === cleanupMarker)
|
||||
if (!tables.length) continue
|
||||
assert.ok(tables.every(row => row.fib === "false"), "Cleanup only accepts isolated non-FIB tables")
|
||||
assert.ok(!(await client.get<Record<string, string>[]>("/routing/rule")).some(row => row.table === cleanupMarker))
|
||||
for (const resource of ["ipv4_route", "ipv6_route"]) assert.ok(!(await readRosSnapshot(serverId, resource)).rows.some(row => row["routing-table"] === cleanupMarker), "Interrupted table must be empty before cleanup")
|
||||
for (const row of tables) await execute("mm_routing_table_delete_plan", { serverId, id: row[".id"] })
|
||||
checks.push({ host, interruptedRunCleanup: cleanupMarker, emptyNonFibTableRemoved: true })
|
||||
}
|
||||
}
|
||||
if (overlayCleanupOnly) {
|
||||
for (const baseline of recoveryBaseline) assert.equal((await readRosSnapshot(baseline.serverId, baseline.resource)).fingerprint, baseline.fingerprint, "Interrupted overlay baseline not restored")
|
||||
checks.push({ phase: "interrupted-overlay-baseline-restored", marker: overlayCleanupMarker, restoredFingerprints: recoveryBaseline.length, newFixtureCreated: false }); await checkpoint()
|
||||
} else if (wireguardInterfaceUpdate) await acceptChrWireguardInterfaceUpdate(marker, principal, hosts.map((_, index) => index + 1), execute, checks, checkpoint)
|
||||
else if (wireguardInterfaceDelete) await acceptChrWireguardInterface(marker, principal, execute, checks, checkpoint)
|
||||
else if (wireguardPeerOnly) await acceptChrWireguardPeer(marker, principal, execute, checks, checkpoint, wireguardPeerUpdate)
|
||||
else if (ospfPassiveOnly) await acceptChrOspfPassive(marker, execute, checks, checkpoint)
|
||||
else if (overlayAcceptance) await acceptChrOverlay(hosts, marker, principal, execute, checks, checkpoint)
|
||||
else for (const [index, host] of hosts.entries()) {
|
||||
const serverId = index + 1, { client } = await routerClient(serverId)
|
||||
const resource = await client.get<Record<string, unknown>>("/system/resource")
|
||||
const baseline = await readRosSnapshot(serverId, "bridge")
|
||||
assert.ok(!baseline.rows.some(row => row.name === marker))
|
||||
const routeBaseline = await Promise.all(["ipv4_route", "ipv6_route"].map(resource => readRosSnapshot(serverId, resource)))
|
||||
const tableBaseline = await readRosSnapshot(serverId, "routing_table")
|
||||
assert.ok(!tableBaseline.rows.some(row => row.name === marker))
|
||||
const topology = await invokeMcpTool(principal, "mm_routing_topology_get", { serverId }) as { resources: Record<string, { observed: boolean; items: unknown[] }>; observedPath: boolean }
|
||||
assert.equal(topology.observedPath, false)
|
||||
assert.ok(topology.resources.interfaces.observed && topology.resources.ipv4.observed && topology.resources.ipv6.observed)
|
||||
let pathProof: unknown
|
||||
if (routingAcceptance && hosts.length === 2) {
|
||||
const peerId = serverId === 1 ? 2 : 1, { client: peerClient } = await routerClient(peerId)
|
||||
const addresses = await client.get<Record<string, string>[]>("/ip/address"), peerAddresses = await peerClient.get<Record<string, string>[]>("/ip/address")
|
||||
const source = addresses.filter(row => row.interface === "ether1" && row.disabled !== "true" && row.address.split("/")[0] === host), peer = peerAddresses.filter(row => row.interface === "ether1" && row.disabled !== "true" && row.address.split("/")[0] === hosts[peerId - 1])
|
||||
assert.equal(source.length, 1); assert.equal(peer.length, 1)
|
||||
// Two physical devices validate the command/parser and direct control path.
|
||||
// JumpHost/ExitNode collapse to the peer; this is explicitly not a 3-node failover lab.
|
||||
const profile = { name: marker, serverId, community: "65000:65000", family: "ipv4", probeVrf: "main", candidates: [{ id: "direct-lab-path", jumpHostId: peerId, exitNodeId: peerId, wanInterface: "ether1", downstreamInterface: "ether1", gateway: peer[0].address.split("/")[0], gatewayRef: { resource: "ipv4_route", id: "not-used-by-probe" }, controlProbe: { targetInterface: "ether1", targetAddress: hosts[peerId - 1], sourceAddress: host, expectedHops: [{ serverId: peerId, interface: "ether1", address: hosts[peerId - 1] }] } }] }
|
||||
pathProof = await invokeMcpTool(principal, "mm_route_path_verify", { profile, candidateId: "direct-lab-path", proofPurpose: "control-path" })
|
||||
assert.equal((pathProof as { status: string }).status, "passed", JSON.stringify(pathProof))
|
||||
}
|
||||
await assert.rejects(invokeMcpTool(principal, "mm_bridge_list", { serverId: 99999 }))
|
||||
// Never touch an existing interface: an empty disabled bridge has no ports or addresses.
|
||||
let cleanup = false
|
||||
try {
|
||||
cleanup = true // Includes ambiguous create outcomes: finally re-reads before attempting cleanup.
|
||||
await execute("mm_bridge_create_plan", { serverId, fields: { name: marker, disabled: true, comment: marker } })
|
||||
let own = (await readRosSnapshot(serverId, "bridge")).rows.filter(row => row.name === marker && row.comment === marker)
|
||||
assert.equal(own.length, 1); assert.equal(own[0].disabled, "true")
|
||||
const id = own[0][".id"]
|
||||
const updateOperationId = await execute("mm_bridge_update_plan", { serverId, id, fields: { comment: marker + ":verified" } })
|
||||
own = (await readRosSnapshot(serverId, "bridge")).rows.filter(row => row[".id"] === id)
|
||||
assert.equal(own[0].comment, marker + ":verified")
|
||||
await execute("mm_operation_recovery_plan", { operationId: updateOperationId })
|
||||
own = (await readRosSnapshot(serverId, "bridge")).rows.filter(row => row[".id"] === id)
|
||||
assert.equal(own[0].comment, marker)
|
||||
await execute("mm_bridge_delete_plan", { serverId, id })
|
||||
cleanup = false
|
||||
} finally {
|
||||
if (cleanup) {
|
||||
const own = (await readRosSnapshot(serverId, "bridge")).rows.filter(row => row.name === marker && [marker, marker + ":verified"].includes(String(row.comment)) && row.disabled === "true")
|
||||
// Bounded cleanup uses the same approved executor; no broad name matching or direct DELETE.
|
||||
for (const row of own) await execute("mm_bridge_delete_plan", { serverId, id: row[".id"] })
|
||||
}
|
||||
}
|
||||
assert.equal((await readRosSnapshot(serverId, "bridge")).fingerprint, baseline.fingerprint, "Bridge configuration must return to its baseline")
|
||||
if (routingAcceptance) {
|
||||
// No rules or interfaces select this unique non-FIB table. Its routes never enter
|
||||
// packet forwarding; existing main/default/management routes remain untouched.
|
||||
try {
|
||||
await execute("mm_routing_table_create_plan", { serverId, fields: { name: marker, fib: false } })
|
||||
const tables = (await readRosSnapshot(serverId, "routing_table")).rows.filter(row => row.name === marker)
|
||||
assert.equal(tables.length, 1); assert.equal(tables[0].fib, "false")
|
||||
await execute("mm_ipv4_route_create_plan", { serverId, fields: { "dst-address": "192.0.2.0/24", "routing-table": marker, gateway: "ether1", distance: 10, disabled: true, comment: marker } })
|
||||
const own = (await readRosSnapshot(serverId, "ipv4_route")).rows.filter(row => row.comment === marker && row["routing-table"] === marker)
|
||||
assert.equal(own.length, 1)
|
||||
await assert.rejects(invokeMcpTool(principal, "mm_routing_table_delete_plan", { serverId, id: tables[0][".id"] }), /Таблица используется/)
|
||||
const id = own[0][".id"]
|
||||
const operationId = await execute("mm_ipv4_route_update_plan", { serverId, id, fields: { distance: 20, disabled: false } })
|
||||
let current = (await readRosSnapshot(serverId, "ipv4_route")).rows.find(row => row[".id"] === id)!
|
||||
assert.equal(current.distance, "20"); assert.equal(current.disabled, "false")
|
||||
await execute("mm_operation_recovery_plan", { operationId })
|
||||
current = (await readRosSnapshot(serverId, "ipv4_route")).rows.find(row => row[".id"] === id)!
|
||||
assert.equal(current.distance, "10"); assert.equal(current.disabled, "true")
|
||||
checks.push({ host, isolatedRouting: { tableFib: false, prefix: "192.0.2.0/24", approvedRouteCreateUpdate: true, enabledReadBack: true, usedTableDeletionRejected: true, approvedInverseRestoredDistanceAndDisabled: true, forwardingTest: false } })
|
||||
} finally {
|
||||
// Exact ownership and destination checks, including ambiguous create results.
|
||||
const own = (await readRosSnapshot(serverId, "ipv4_route")).rows.filter(row => row.comment === marker && row["routing-table"] === marker && row["dst-address"] === "192.0.2.0/24")
|
||||
for (const row of own) await execute("mm_ipv4_route_delete_plan", { serverId, id: row[".id"] })
|
||||
assert.ok(!(await readRosSnapshot(serverId, "ipv4_route")).rows.some(row => row["routing-table"] === marker))
|
||||
for (const row of (await readRosSnapshot(serverId, "routing_table")).rows.filter(row => row.name === marker)) await execute("mm_routing_table_delete_plan", { serverId, id: row[".id"] })
|
||||
}
|
||||
assert.equal((await readRosSnapshot(serverId, "routing_table")).fingerprint, tableBaseline.fingerprint, "Routing table configuration must return to baseline")
|
||||
}
|
||||
for (const before of routeBaseline) assert.equal((await readRosSnapshot(serverId, before.resource)).fingerprint, before.fingerprint, "Existing route configuration changed during acceptance")
|
||||
checks.push({ host, routerOs: resource.version, topology: Object.fromEntries(Object.entries(topology.resources).map(([key, value]) => [key, { observed: value.observed, count: value.items.length }])), approvedCreateUpdateDelete: true, approvedInversePlan: true, pathProof, twoDeviceControlPathOnly: routingAcceptance, idempotency: true, readBack: true, bridgeBaselineRestored: true, routeConfigurationUnchanged: true })
|
||||
console.log(`✓ ${host}: actual topology, approval, create/update/delete, read-back, idempotency, cleanup`)
|
||||
}
|
||||
report.complete = true
|
||||
} finally {
|
||||
MikrotikClient.prototype.post = originalPost; MikrotikClient.prototype.patch = originalPatch
|
||||
MikrotikClient.prototype.get = originalGet; MikrotikClient.prototype.delete = originalDelete
|
||||
MikrotikClient.prototype.put = originalPut
|
||||
report.finishedAt = new Date().toISOString()
|
||||
await checkpoint()
|
||||
await closePool()
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { fingerprint } from "./security.js"
|
||||
import assert from "node:assert/strict"
|
||||
import { randomUUID } from "node:crypto"
|
||||
import { writeFile } from "node:fs/promises"
|
||||
import { setTimeout as delay } from "node:timers/promises"
|
||||
import { pool, closePool } from "../../db/index.js"
|
||||
import { applySqlMigrations } from "../../db/migrate.js"
|
||||
import { mcpConnectionCreateSchema } from "@mmapp/contracts/mcp"
|
||||
import { createMcpConnection, authenticateMcpToken, updateMcpSettings } from "./store.js"
|
||||
import { invokeMcpTool, approvePlan, applyPlan } from "./runtime.js"
|
||||
import { readRosSnapshot, routerClient } from "./resources.js"
|
||||
import type { AuthUser } from "../../lib/permissions.js"
|
||||
|
||||
const hosts = (process.env.MMAPP_TEST_CHR_HOSTS ?? "").split(",").map(value => value.trim()).filter(Boolean)
|
||||
assert.equal(hosts.length, 2); assert.equal(new Set(hosts).size, 2)
|
||||
assert.ok(hosts.every(host => ["194.226.121.150", "104.165.244.7"].includes(host)))
|
||||
const ports = process.env.MMAPP_TEST_CHR_TUNNEL_PORTS?.split(",").map(Number)
|
||||
if (ports) assert.ok(ports.length === 2 && ports.every(port => Number.isInteger(port) && port >= 1024 && port <= 65535))
|
||||
assert.ok(process.env.MMAPP_TEST_CHR_USER && process.env.MMAPP_TEST_CHR_PASSWORD)
|
||||
const marker = "mcp-test-" + randomUUID().slice(0, 8)
|
||||
const actor: AuthUser = { id: "chr-address-list-owner", apps: ["mm"], permissions: ["mm:settings:admin", "mm:network:admin", "mm:servers:admin"], expiresAt: Date.now() + 3600000 }
|
||||
const checks: Record<string, unknown>[] = []
|
||||
const report = { startedAt: new Date().toISOString(), marker, complete: false, packetMatchingVerified: false, fixtures: "unused-address-list-v4-v6-permanent-and-timed", checks, finishedAt: "" }
|
||||
const save = () => writeFile(new URL("../../../../docs/mcp-chr-address-list-acceptance.json", import.meta.url), JSON.stringify(report, null, 2) + "\n")
|
||||
try {
|
||||
await applySqlMigrations(pool)
|
||||
assert.equal((await pool.query("SELECT count(*)::int AS n FROM servers")).rows[0].n, 0)
|
||||
await updateMcpSettings({ enabled: true }, actor)
|
||||
for (const [index, host] of hosts.entries()) await pool.query("INSERT INTO servers(id,name,host,port,use_ssl,username,password) VALUES($1,$2,$3,$4,false,$5,$6)", [index + 1, marker, ports ? "127.0.0.1" : host, ports?.[index] ?? 80, process.env.MMAPP_TEST_CHR_USER, process.env.MMAPP_TEST_CHR_PASSWORD])
|
||||
const operationIds = ["firewall.addressLists.create", "firewall.addressLists.update", "firewall.addressLists.toggle", "firewall.addressLists.delete"]
|
||||
const access = await createMcpConnection(mcpConnectionCreateSchema.parse({ name: marker, mode: "approval", expiresAt: new Date(Date.now() + 1800000).toISOString(), permissions: ["mm:network:write"], limits: { callsPerMinute: 120, mutationsPerHour: 100 }, scope: { serverIds: [1, 2], operationIds, allowGlobal: false } }), actor)
|
||||
const principal = await authenticateMcpToken(access.token)
|
||||
const execute = async (tool: string, input: Record<string, unknown>) => {
|
||||
const plan = await invokeMcpTool(principal, tool, input) as { planId: string }
|
||||
await approvePlan(plan.planId, actor, true)
|
||||
const key = randomUUID(), operation = await applyPlan(principal, plan.planId, key)
|
||||
for (let attempt = 0; attempt < 200; attempt++) {
|
||||
const row = (await pool.query("SELECT state,error_code FROM mcp_operations WHERE id=$1", [operation.id])).rows[0]
|
||||
if (!["pending", "running"].includes(row.state)) {
|
||||
assert.equal(row.state, "applied", tool + ": " + row.state + "/" + row.error_code)
|
||||
assert.equal((await applyPlan(principal, plan.planId, key)).id, operation.id)
|
||||
return operation.id
|
||||
}
|
||||
await delay(300)
|
||||
}
|
||||
throw Error("Operation pending; never replay lab writes automatically")
|
||||
}
|
||||
await save()
|
||||
for (const [index, host] of hosts.entries()) {
|
||||
const serverId = index + 1, { client } = await routerClient(serverId)
|
||||
const resources = ["ip_filter", "ip_nat", "ip_mangle", "ip_raw", "ipv6_filter", "ipv6_nat", "ipv6_mangle", "ipv6_raw"]
|
||||
const baseline = await Promise.all(resources.map(resource => readRosSnapshot(serverId, resource)))
|
||||
const paths = ["/ip/firewall/address-list", "/ipv6/firewall/address-list"]
|
||||
const addressBaseline = await Promise.all(paths.map(path => client.get<Record<string, string>[]>(path)))
|
||||
assert.ok(addressBaseline.every(rows => rows.length === 0), "Lab refuses existing address-list entries")
|
||||
checks.push({ host, phase: "preflight", baselineFingerprints: baseline.map(({ resource, fingerprint }) => ({ resource, fingerprint })), addressFingerprints: addressBaseline.map(fingerprint) }); await save()
|
||||
try {
|
||||
for (const family of ["ip", "ip6"] as const) {
|
||||
const path = family === "ip" ? paths[0] : paths[1]
|
||||
const fields = { list: marker, address: family === "ip" ? "198.18.250.1/32" : "2001:db8:250::1/128", disabled: true, comment: marker }
|
||||
const invoke = (operation: string, extra: Record<string, unknown>) => execute("mm_firewall_address_list_" + operation + "_plan", { serverId, address: { family, operation, ...extra } })
|
||||
await invoke("create", { fields })
|
||||
let rows = await client.get<Record<string, string>[]>(path)
|
||||
assert.equal(rows.length, 1); const rosId = rows[0][".id"]
|
||||
assert.equal(rows[0].disabled, "true"); assert.equal(rows[0].list, marker)
|
||||
await invoke("update", { rosId, fields: { ...fields, address: family === "ip" ? "198.18.250.0-198.18.250.255" : "2001:db8:250:0:0:0:0:1/64", comment: marker + " edited" } })
|
||||
await invoke("toggle", { rosId, disabled: false })
|
||||
rows = await client.get<Record<string, string>[]>(path); assert.equal(rows[0].disabled, "false")
|
||||
await invoke("toggle", { rosId, disabled: true }); await invoke("delete", { rosId })
|
||||
assert.deepEqual(await client.get(path), [])
|
||||
await invoke("create", { fields: { ...fields, disabled: false, timeout: "2m" } })
|
||||
rows = await client.get<Record<string, string>[]>(path); assert.equal(rows.length, 1); assert.equal(rows[0].dynamic, "true")
|
||||
const timedId = rows[0][".id"]
|
||||
const timedPlan = await invokeMcpTool(principal, "mm_firewall_address_list_delete_plan", { serverId, address: { family, operation: "delete", rosId: timedId } }) as { planId: string }
|
||||
await delay(1500); await approvePlan(timedPlan.planId, actor, true)
|
||||
const timedDelete = await applyPlan(principal, timedPlan.planId, randomUUID())
|
||||
for (let attempt = 0; attempt < 200; attempt++) {
|
||||
const state = (await pool.query("SELECT state FROM mcp_operations WHERE id=$1", [timedDelete.id])).rows[0].state
|
||||
if (!["pending", "running"].includes(state)) { assert.equal(state, "applied"); break }
|
||||
if (attempt === 199) throw Error("Timed delete pending; do not replay")
|
||||
await delay(300)
|
||||
}
|
||||
assert.deepEqual(await client.get(path), [])
|
||||
checks.push({ host, family, phase: "permanent-crud-and-countdown-delete", idempotencyVerified: true, unusedList: true, nativeTimedFields: { dynamic: rows[0].dynamic, timeout: rows[0].timeout } }); await save()
|
||||
}
|
||||
} finally {
|
||||
for (const family of ["ip", "ip6"]) {
|
||||
const path = family === "ip" ? paths[0] : paths[1]
|
||||
for (const row of await client.get<Record<string, string>[]>(path)) if (row.list === marker && row.comment?.startsWith(marker)) await execute("mm_firewall_address_list_delete_plan", { serverId, address: { family, operation: "delete", rosId: row[".id"] } })
|
||||
}
|
||||
for (const snapshot of baseline) assert.equal((await readRosSnapshot(serverId, snapshot.resource)).fingerprint, snapshot.fingerprint, "Firewall baseline changed")
|
||||
for (const [index, path] of paths.entries()) assert.equal(fingerprint(await client.get(path)), fingerprint(addressBaseline[index]), "Address-list baseline changed")
|
||||
checks.push({ host, phase: "cleanup", baselineRestored: true }); await save()
|
||||
}
|
||||
console.log("✓ CHR " + serverId + ": IPv4/IPv6 address-list CRUD, native canonicalization, finite countdown deletion and cleanup")
|
||||
}
|
||||
report.complete = true; report.finishedAt = new Date().toISOString(); await save()
|
||||
} finally { await closePool() }
|
||||
Loaded 100 of 3835 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user