@volter/teams 0.2.7 → 0.2.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/http-client.mjs +14 -1
- package/package.json +1 -1
- package/server/core.mjs +1 -1
- package/server/events.mjs +14 -0
- package/server/http.mjs +23 -2
- package/server/published-events.mjs +9 -9
package/http-client.mjs
CHANGED
|
@@ -1,11 +1,24 @@
|
|
|
1
1
|
const trimOrigin=value=>{const u=new URL(value),loopback=u.hostname==='localhost'||u.hostname==='::1'||u.hostname==='[::1]'||/^127(?:\.\d{1,3}){3}$/.test(u.hostname);if(!['http:','https:'].includes(u.protocol)||u.username||u.password||u.search||u.hash||(u.pathname!==''&&u.pathname!=='/')||(u.protocol==='http:'&&!loopback))throw new TypeError('server must be an HTTPS origin (HTTP is loopback-only)');return u.origin;};
|
|
2
2
|
const randomKey=()=>globalThis.crypto.randomUUID().replaceAll('-','')+globalThis.crypto.randomUUID().replaceAll('-','');
|
|
3
|
+
// The caller a request names to the server (X-Supercode-Caller): the process's command words and the agent session it
|
|
4
|
+
// runs in, so the server can say who spent a credential's allowance. Only lowercase command words are kept (never an
|
|
5
|
+
// argument's value); a browser sends none.
|
|
6
|
+
export function callerName(env=globalThis.process?.env,argv=globalThis.process?.argv){
|
|
7
|
+
if(!env||!argv)return null;
|
|
8
|
+
// A header value is ASCII: anything else would make every request throw.
|
|
9
|
+
if(env.SUPERCODE_CALLER_NAME)return String(env.SUPERCODE_CALLER_NAME).replace(/[^\x20-\x7e]/g,'?').slice(0,120);
|
|
10
|
+
const script=String(argv[1]??'').split(/[\\/]/).pop().replace(/\.[cm]?js$/,'')||'node',words=[];
|
|
11
|
+
for(const arg of argv.slice(2)){if(arg.startsWith('-'))continue;if(!/^[a-z][a-z0-9-]{0,30}$/.test(arg)||words.length===2)break;words.push(arg);}
|
|
12
|
+
const session=env.CLAUDE_CODE_SESSION_ID?`claude-code:${env.CLAUDE_CODE_SESSION_ID.slice(0,8)}`:env.CODEX_SESSION_ID?`codex:${env.CODEX_SESSION_ID.slice(0,8)}`:null;
|
|
13
|
+
return ([script,...words].join(' ')+(session?` in ${session}`:'')).replace(/[^\x20-\x7e]/g,'?').slice(0,120);
|
|
14
|
+
}
|
|
15
|
+
const CALLER=callerName();
|
|
3
16
|
export class TeamsClient {
|
|
4
17
|
constructor({server,credential,fetch:fetchImpl=(...args)=>globalThis.fetch(...args)}){this.server=trimOrigin(server);this.credential=credential;this.fetch=fetchImpl;if(typeof fetchImpl!=='function')throw new TypeError('fetch is required');}
|
|
5
18
|
async request(method,path,{body,signal,idempotencyKey,ifMatch}={}) {
|
|
6
19
|
const key=idempotencyKey??(method==='GET'?null:randomKey());let delay=250,last;
|
|
7
20
|
for(let attempt=0;attempt<5;attempt++) { try {
|
|
8
|
-
const response=await this.fetch(this.server+path,{method,signal,redirect:'manual',headers:{Accept:'application/json',Authorization:`Bearer ${this.credential}`,...(body!==undefined?{'Content-Type':'application/json'}:{}),...(key?{'Idempotency-Key':key}:{}),...(ifMatch!==undefined?{'If-Match':String(ifMatch)}:{})},body:body===undefined?undefined:JSON.stringify(body)});
|
|
21
|
+
const response=await this.fetch(this.server+path,{method,signal,redirect:'manual',headers:{Accept:'application/json',Authorization:`Bearer ${this.credential}`,...(CALLER?{'X-Supercode-Caller':CALLER}:{}),...(body!==undefined?{'Content-Type':'application/json'}:{}),...(key?{'Idempotency-Key':key}:{}),...(ifMatch!==undefined?{'If-Match':String(ifMatch)}:{})},body:body===undefined?undefined:JSON.stringify(body)});
|
|
9
22
|
if(response.type==='opaqueredirect'||(response.status>=300&&response.status<400))throw Object.assign(new Error('cross-origin redirects are refused'),{code:'invalid_request',status:400,retryable:false});
|
|
10
23
|
const value=response.status===204?null:await response.json();if(!response.ok)throw Object.assign(new Error(value?.error?.message??`HTTP ${response.status}`),value?.error,{status:response.status});return value;
|
|
11
24
|
} catch(error) { last=error;if(method!=='GET'||signal?.aborted||!(!error.status||error.retryable)||attempt===4)throw error;await retryWait(delay,signal);delay=Math.min(delay*2,5000); } }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/teams",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.9",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Volter Teams, the neutral team plane: machine identity and daemon core, the local door and server link, admission from machine grants, product doors, and the Teams server's neutral routes (company RFC 0008).",
|
|
6
6
|
"license": "MIT",
|
package/server/core.mjs
CHANGED
|
@@ -20,7 +20,7 @@ export function createTeamsApi({clock=productionClock,ids=productionIds,publicOr
|
|
|
20
20
|
if(!Number.isSafeInteger(uploadLimitBytes)||uploadLimitBytes<1||uploadLimitBytes>256*1024*1024)throw new TypeError('uploadLimitBytes must be between 1 and 268435456');
|
|
21
21
|
let store=null,serverId=null,localOrigin=null,harnessRoutes=null;
|
|
22
22
|
const events=new TeamEvents();
|
|
23
|
-
return {clock,ids,events,publicOrigin,appRootSecret,uploadLimitBytes,rateState:new Map(),openStreams:new Map(),securityEvents:[],
|
|
23
|
+
return {clock,ids,events,publicOrigin,appRootSecret,uploadLimitBytes,rateState:new Map(),rateSpenders:new Map(),rateReported:new Map(),openStreams:new Map(),securityEvents:[],
|
|
24
24
|
get store(){return store;},get serverId(){return serverId;},get localOrigin(){return localOrigin;},get harnessRoutes(){return harnessRoutes;},
|
|
25
25
|
attach(next){store=next.store??store;if(next.serverId!==undefined)serverId=next.serverId;if(next.localOrigin!==undefined)localOrigin=next.localOrigin;if(next.harnessRoutes!==undefined)harnessRoutes=next.harnessRoutes;},
|
|
26
26
|
};
|
package/server/events.mjs
CHANGED
|
@@ -1,3 +1,17 @@
|
|
|
1
1
|
import { EventEmitter } from 'node:events';
|
|
2
2
|
export class TeamEvents extends EventEmitter { publish(teamId,event){this.emit(`team:${teamId}`,event); if(event.session_id)this.emit(`session:${teamId}:${event.session_id}`,event);} subscribe(key,fn){this.on(key,fn);return()=>this.off(key,fn);} close(){this.emit('shutdown');this.removeAllListeners();} }
|
|
3
3
|
export function writeSse(res,event){const cursor=event.cursor??'';res.write(`id: ${cursor}\nevent: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`);}
|
|
4
|
+
// A stream's backlog cap is met by waiting for the response to flush, never by measuring the server's own unflushed bytes:
|
|
5
|
+
// a Durable Object's SQLite answers as microtasks, so a snapshot written in one turn never lets the response drain, and a
|
|
6
|
+
// 1 MiB backlog checked there cut every reader of a large team as slow (217 Task heads, 1,022 KB, with a reader that only
|
|
7
|
+
// counted). Only a reader that takes nothing for the patience window is slow. Resolves 'ready', 'closed' or 'slow'.
|
|
8
|
+
export const SSE_BACKLOG_BYTES=1048576, SSE_PATIENCE_MS=30000;
|
|
9
|
+
export function sseRoom(res,bytes,{cap=SSE_BACKLOG_BYTES,patienceMs=SSE_PATIENCE_MS}={}){
|
|
10
|
+
// An event larger than the cap still goes out whole once nothing is waiting: a snapshot of any size completes.
|
|
11
|
+
if(res.writableLength===0||res.writableLength+bytes<=cap)return Promise.resolve('ready');
|
|
12
|
+
return new Promise(resolve=>{
|
|
13
|
+
const done=outcome=>{clearTimeout(timer);res.off('drain',drained);res.off('close',ended);resolve(outcome);};
|
|
14
|
+
const drained=()=>done('ready'),ended=()=>done('closed'),timer=setTimeout(()=>done('slow'),patienceMs);
|
|
15
|
+
res.on('drain',drained);res.on('close',ended);
|
|
16
|
+
});
|
|
17
|
+
}
|
package/server/http.mjs
CHANGED
|
@@ -33,7 +33,7 @@ export function createHttpHandler(api){return async(req,res)=>{const requestId=a
|
|
|
33
33
|
// Any page may call with a bearer it holds: the only credential is that header, never a
|
|
34
34
|
// cookie, so a caller's origin admits nothing and a preflight is answered before auth.
|
|
35
35
|
for(const [name,value] of Object.entries(CORS))res.setHeader(name,value);
|
|
36
|
-
if(req.method==='OPTIONS'){res.writeHead(204,{'Access-Control-Allow-Methods':'GET, POST, PATCH, DELETE','Access-Control-Allow-Headers':'Authorization, Content-Type, Idempotency-Key, If-Match, Last-Event-ID, Range','Access-Control-Max-Age':'86400'});res.end();return;}
|
|
36
|
+
if(req.method==='OPTIONS'){res.writeHead(204,{'Access-Control-Allow-Methods':'GET, POST, PATCH, DELETE','Access-Control-Allow-Headers':'Authorization, Content-Type, Idempotency-Key, If-Match, Last-Event-ID, Range, X-Supercode-Caller','Access-Control-Max-Age':'86400'});res.end();return;}
|
|
37
37
|
if(api.publicOrigin){const expected=new URL(api.publicOrigin);if(req.headers.host!==expected.host)fail(400,'invalid_request','request Host does not match configured public origin');}
|
|
38
38
|
const url=new URL(req.url,'http://local'); const path=url.pathname;
|
|
39
39
|
// The hosted viewer's files (viewer.mjs): public; the page's delegated token is checked at every door it opens.
|
|
@@ -56,7 +56,8 @@ export function createHttpHandler(api){return async(req,res)=>{const requestId=a
|
|
|
56
56
|
if(!auth)fail(401,'authentication_required','authentication required');auth.now=api.clock.nowMs();auth.clock=api.clock;
|
|
57
57
|
const bulk=/\/captures\/[^/]+\/parts\/\d+$/.test(path)||/\/uploads\/[^/]+\/chunks\/\d+$/.test(path);// A machine's own credential syncs its live sessions as they change (a capture is a begin and a commit), so it
|
|
58
58
|
// gets the bulk allowance for ordinary requests too; a person's or a service's stays at 120 a minute.
|
|
59
|
-
|
|
59
|
+
const rateKey=`credential:${auth.credential_id}:${bulk?'bulk':'ordinary'}`;noteSpender(api,rateKey,req,path);
|
|
60
|
+
try{checkRate(api,rateKey,bulk||auth.kind==='machine'?600:120,bulk||auth.kind==='machine'?60:30);}catch(error){throw spentBy(api,rateKey,auth,error);}
|
|
60
61
|
if(req.method==='GET'&&path==='/api/v1/me'){if(!['user','service'].includes(auth.kind))fail(403,'forbidden','credential kind cannot use this operation');const teams=await api.store.query('SELECT t.id,t.name,t.revision,m.role FROM memberships m JOIN teams t ON t.id=m.team_id WHERE m.principal_id=? AND m.active=1 AND (? IS NULL OR m.team_id=?)',[auth.principal_id,auth.team_id,auth.team_id]);return send(res,200,{server_id:api.serverId,principal:{id:auth.principal_id,kind:auth.principal_kind,display_name:auth.display_name},teams:teams.map(x=>({team:{id:x.id,name:x.name,revision:x.revision},role:x.role}))},requestId);}
|
|
61
62
|
if(req.method==='POST'&&path==='/api/v1/me/identities')return await linkIdentity(api,req,res,auth,requestId);
|
|
62
63
|
if(req.method==='GET'&&path==='/api/v1/teams')return await listTeams(api,res,auth,url,requestId);
|
|
@@ -108,6 +109,26 @@ export function createHttpHandler(api){return async(req,res)=>{const requestId=a
|
|
|
108
109
|
if(api.harnessRoutes&&await api.harnessRoutes.team({api,req,res,auth,url,path,sub,requestId}))return;
|
|
109
110
|
fail(404,'not_found','not found');
|
|
110
111
|
}catch(error){const code=error.code??(error instanceof ContractError?'invalid_request':'internal_error');const status=error.status??({invalid_request:400,authentication_required:401,forbidden:403,not_found:404,source_revoked:403,session_deleted:410,request_too_large:413,batch_gap:409,capture_conflict:409,source_paused:409,resync_required:409,revision_mismatch:409,conflict:409,rate_limited:429}[code]??500);if(!res._auditRecord){api.securityEvents.push({at:api.clock.nowMs(),request_id:requestId,status,code,authenticated:Boolean(res._authenticated)});if(api.securityEvents.length>1000)api.securityEvents.shift();}send(res,status,validationFailure(Object.assign(error,{code}),requestId),requestId);}};}
|
|
112
|
+
// Who spent a credential's allowance, as this server saw it: the credential's last minute of requests by route and by
|
|
113
|
+
// the caller each client names (X-Supercode-Caller, a process's command words). A refusal carries it to the caller and
|
|
114
|
+
// writes it to the log (at most every 10 s per credential), so the spender is named, not guessed.
|
|
115
|
+
const ID_SEGMENT=/^(?:[a-z]+_[A-Za-z0-9_]{6,}|[^/]*[:%@][^/]*|[0-9a-f-]{16,}|\d+)$/;
|
|
116
|
+
const routeOf=path=>path.replace(/^\/api\/v1/,'').split('/').map(part=>ID_SEGMENT.test(part)?'*':part).join('/');
|
|
117
|
+
function noteSpender(api,key,req,path){
|
|
118
|
+
const now=api.clock.nowMs(),list=api.rateSpenders.get(key)??[];while(list.length&&now-list[0].at>60000)list.shift();
|
|
119
|
+
if(list.length<2000)list.push({at:now,route:`${req.method} ${routeOf(path)}`,caller:String(req.headers['x-supercode-caller']??'unnamed').slice(0,120)});
|
|
120
|
+
api.rateSpenders.set(key,list);
|
|
121
|
+
// A credential idle for a minute keeps no tally.
|
|
122
|
+
if(api.rateSpenders.size>256)for(const [other,entries] of api.rateSpenders)if(!entries.length||now-entries.at(-1).at>60000){api.rateSpenders.delete(other);api.rateReported.delete(other);}
|
|
123
|
+
}
|
|
124
|
+
function spentBy(api,key,auth,error){
|
|
125
|
+
if(error?.code!=='rate_limited')return error;
|
|
126
|
+
const now=api.clock.nowMs(),list=(api.rateSpenders.get(key)??[]).filter(entry=>now-entry.at<=60000);
|
|
127
|
+
const top=field=>Object.entries(list.reduce((counts,entry)=>(counts[entry[field]]=(counts[entry[field]]??0)+1,counts),{})).sort((a,b)=>b[1]-a[1]).slice(0,8).map(([name,count])=>({name,count}));
|
|
128
|
+
const spent={requests:list.length,by_caller:top('caller'),by_route:top('route')};
|
|
129
|
+
if(now-(api.rateReported.get(key)??0)>=10000){api.rateReported.set(key,now);console.warn(`rate limited: ${auth.kind} credential ${auth.credential_id} (${auth.principal_id}) made ${list.length} requests in the last minute; by caller ${spent.by_caller.map(x=>`${x.name}=${x.count}`).join(', ')}; by route ${spent.by_route.map(x=>`${x.name}=${x.count}`).join(', ')}`);}
|
|
130
|
+
error.details={...(error.details??{}),spent_last_minute:spent};return error;
|
|
131
|
+
}
|
|
111
132
|
function checkRate(api,key,perMinute,burst){const now=api.clock.nowMs(),state=api.rateState.get(key)??{tokens:burst,at:now};state.tokens=Math.min(burst,state.tokens+(now-state.at)*(perMinute/60000));state.at=now;if(state.tokens<1)fail(429,'rate_limited','rate limit exceeded',{retry_after_seconds:Math.ceil((1-state.tokens)/(perMinute/60000)/1000)});state.tokens-=1;api.rateState.set(key,state);}
|
|
112
133
|
function redactSecretReplay(path,body){if(body===null||typeof body!=='object')return body;const copy=structuredClone(body);if(path.endsWith('/invitations')&©.data)delete copy.data.enrollment_code;if(path.endsWith('/machines')&©.data)copy.data.credential=null;if(path.endsWith('/credentials')||path.endsWith('/delegations'))copy.data=null;if((path.endsWith('/apps')||path.endsWith('/secret'))&©.data)delete copy.data.secret;if(path.endsWith('/installations')&©.data)delete copy.data.code;return copy;}
|
|
113
134
|
|
|
@@ -3,7 +3,7 @@ import { appAuthentication } from './apps.mjs';
|
|
|
3
3
|
import { isAdmin } from './auth.mjs';
|
|
4
4
|
import { exact } from './request-json.mjs';
|
|
5
5
|
import { canonicalJson } from './wire.mjs';
|
|
6
|
-
import { writeSse } from './events.mjs';
|
|
6
|
+
import { sseRoom, writeSse } from './events.mjs';
|
|
7
7
|
import { APP_EVENTS, PUBLISHABLE_EVENTS, eventReadAction } from './event-catalogue.mjs';
|
|
8
8
|
import { EVENT_DATA_LIMIT } from '../event-limits.mjs';
|
|
9
9
|
|
|
@@ -99,7 +99,7 @@ async function eventStream({api,req,res,auth,url,requestId}){
|
|
|
99
99
|
let closed=false,running=null,dirty=false;
|
|
100
100
|
const close=()=>{if(closed)return;closed=true;clearInterval(timer);unsubscribe();req.off('close',close);api.events.off('shutdown',close);api.openStreams.set(auth.credential_id,Math.max(0,(api.openStreams.get(auth.credential_id)??1)-1));res.end();};
|
|
101
101
|
const terminal=(type,reason)=>{if(!closed)writeSse(res,{type,reason,terminal:true});close();};
|
|
102
|
-
const write=event=>{if(closed)return false;
|
|
102
|
+
const write=async event=>{if(closed)return false;const room=await sseRoom(res,Buffer.byteLength(JSON.stringify(event)));if(closed||room==='closed')return false;if(room==='slow'){terminal('resync_required','slow_reader');return false;}writeSse(res,event);return true;};
|
|
103
103
|
const readable=async event=>{
|
|
104
104
|
const current=await liveAuth(api,auth);
|
|
105
105
|
if(current.epoch!==auth.epoch)fail(409,'resync_required','authorization changed during event delivery');
|
|
@@ -120,21 +120,21 @@ async function eventStream({api,req,res,auth,url,requestId}){
|
|
|
120
120
|
{kind:'one',sql:'SELECT COALESCE((SELECT max(sequence) FROM org_events WHERE team_id=?),(SELECT pruned_through FROM event_retention WHERE team_id=?),0) last',params:[auth.team_id,auth.team_id]},
|
|
121
121
|
{kind:'query',sql:'SELECT r.sequence FROM event_resources r JOIN org_events e ON e.sequence=r.sequence WHERE r.team_id=? AND (? IS NULL OR r.source_id=?) AND (? IS NULL OR r.resource_kind IN (SELECT value FROM json_each(?)) OR e.kind IN (SELECT value FROM json_each(?))) ORDER BY r.source_id,r.resource_kind,r.resource_id',params:[auth.team_id,query.source,query.source,filterResources,filterResources,filterKinds]},
|
|
122
122
|
]);
|
|
123
|
-
cursor=head.last;write({type:'snapshot_begin'});
|
|
123
|
+
cursor=head.last;if(!await write({type:'snapshot_begin'}))return;
|
|
124
124
|
for(let at=0;at<resources.length;at+=32){
|
|
125
125
|
const sequences=resources.slice(at,at+32).map(row=>row.sequence);
|
|
126
126
|
const rows=await api.store.query('SELECT * FROM org_events WHERE team_id=? AND sequence IN (SELECT value FROM json_each(?)) ORDER BY sequence',[auth.team_id,JSON.stringify(sequences)]);
|
|
127
127
|
if(rows.length!==sequences.length){terminal('resync_required','snapshot_retention');return;}
|
|
128
|
-
for(const row of rows){const event=eventOf(row);if(await readable(event))if(!write({type:'snapshot_item',sequence:row.sequence,data:event}))return;}
|
|
128
|
+
for(const row of rows){const event=eventOf(row);if(await readable(event))if(!await write({type:'snapshot_item',sequence:row.sequence,data:event}))return;}
|
|
129
129
|
}
|
|
130
|
-
write({type:'snapshot_end',cursor:codec.encode(cursor)});
|
|
130
|
+
if(!await write({type:'snapshot_end',cursor:codec.encode(cursor)}))return;
|
|
131
131
|
}
|
|
132
132
|
for(;;){
|
|
133
133
|
const rows=await api.store.query('SELECT * FROM org_events WHERE team_id=? AND sequence>? AND (? IS NULL OR source_id=?) AND (? IS NULL OR kind IN (SELECT value FROM json_each(?))) ORDER BY sequence LIMIT 128',[auth.team_id,cursor,query.source,query.source,filterKinds,filterKinds]);
|
|
134
|
-
if(!rows.length){cursor=Math.max(cursor,bounds.last);write({type:'checkpoint',cursor:codec.encode(cursor)});break;}
|
|
135
|
-
for(const row of rows){if(closed)return;cursor=row.sequence;const event=eventOf(row);if(await readable(event)
|
|
134
|
+
if(!rows.length){cursor=Math.max(cursor,bounds.last);await write({type:'checkpoint',cursor:codec.encode(cursor)});break;}
|
|
135
|
+
for(const row of rows){if(closed)return;cursor=row.sequence;const event=eventOf(row);if(await readable(event)&&!await write({type:'event',sequence:row.sequence,cursor:codec.encode(cursor),data:event}))return;}
|
|
136
136
|
// Advance even when the reader was not authorized for a resource; never leak its data.
|
|
137
|
-
write({type:'checkpoint',cursor:codec.encode(cursor)});if(rows.length<128)break;
|
|
137
|
+
if(!await write({type:'checkpoint',cursor:codec.encode(cursor)}))return;if(rows.length<128)break;
|
|
138
138
|
}
|
|
139
139
|
};
|
|
140
140
|
const enqueue=()=>{dirty=true;if(running)return running;running=(async()=>{while(dirty&&!closed){dirty=false;await drain();}})().catch(error=>terminal([401,403].includes(error.status)?'access_revoked':'resync_required',error.code??'unavailable')).finally(()=>{running=null;});return running;};
|
|
@@ -142,5 +142,5 @@ async function eventStream({api,req,res,auth,url,requestId}){
|
|
|
142
142
|
const timer=setInterval(()=>void enqueue().then(()=>{if(!closed)res.write(': heartbeat\n\n');}),15000);timer.unref?.();
|
|
143
143
|
req.once('close',close);api.events.once('shutdown',close);
|
|
144
144
|
res.writeHead(200,{'Content-Type':'text/event-stream','Cache-Control':'no-store','X-Request-ID':requestId});await enqueue();
|
|
145
|
-
if(!closed)write({type:'checkpoint',cursor:codec.encode(cursor)});
|
|
145
|
+
if(!closed)await write({type:'checkpoint',cursor:codec.encode(cursor)});
|
|
146
146
|
}
|