@volter/teams 0.2.11 → 0.2.13

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/event-client.mjs CHANGED
@@ -1,8 +1,59 @@
1
1
  // Public custody door: consumers follow HTTP events without resolving the Teams binary or reading its files.
2
+ // Streams are per team install, not per process: a follow reads through this machine's connector (the daemon's
3
+ // `events` door, one stream the connector holds) whenever it serves the selected context's team and credential, and
4
+ // opens a stream to the server only when it does not (another machine's team, no connector, one source's events).
2
5
  import { dirname } from 'node:path';
3
- import { teamsHome } from './home.mjs';
4
- import { fromContext } from './context.mjs';
6
+ import { teamsHome, paths } from './home.mjs';
7
+ import { fromContext, WorkspaceContexts } from './context.mjs';
8
+ import { openLocalMachine } from './client.mjs';
9
+ export { retryAfterMs, spentClause } from './http-client.mjs';
10
+
5
11
  export function eventClient({supercodeHome=dirname(teamsHome()),context: name,fetch}={}) {
6
12
  const {client,context}=fromContext({supercodeHome,name,fetch});
7
- return {team:client.team(context.team_id),context:{name,org:context.team_id,principal:context.principal_id,server:context.origin}};
13
+ // The machine this home connected to the team: its daemon may serve the follow, and a stream to the server names it,
14
+ // so the server counts the stream against this install.
15
+ let machine=null;try{machine=new WorkspaceContexts(supercodeHome).connector(context.server_id,context.team_id).read()?.machine?.id??null;}catch{}
16
+ client.machine=machine;
17
+ const team=client.team(context.team_id),remote=team.events.follow;
18
+ team.events.follow=(query={},options={})=>follow({socket:paths(teamsHome()).socket,context,machine,query,options,remote});
19
+ return {team,context:{name,org:context.team_id,principal:context.principal_id,server:context.origin}};
20
+ }
21
+
22
+ async function* follow({socket,context,machine,query,options,remote}) {
23
+ const stream=machine&&Object.keys(query).every(key=>key==='kinds'||key==='source'&&query.source==null)?await openLocal(socket,context,query,options.after):null;
24
+ if(!stream){yield* remote(query,options);return;}
25
+ yield* frames(stream,options.signal);
26
+ }
27
+
28
+ /** The connector's `events` door for this context, or null when this machine's daemon does not serve it. */
29
+ async function openLocal(socket,context,query,after) {
30
+ let channel;
31
+ try{channel=await openLocalMachine(socket,{timeoutMs:2000});}catch{return null;}
32
+ try{
33
+ const kinds=query.kinds==null?null:(Array.isArray(query.kinds)?[...query.kinds]:String(query.kinds).split(',')).sort();
34
+ const stream=await channel.open('events',{server_id:context.server_id,team_id:context.team_id,credential_ref:context.credential_ref,kinds,source:null,after:after??null},{timeoutMs:5000});
35
+ // The door's first frames (a whole snapshot) arrive with its opening: its reader is attached in this same tick, with
36
+ // its close, or the stream replays them to no data listener and they are lost.
37
+ const reader={stream,queue:[],closed:null,wake:null};
38
+ stream.on('data',chunk=>{reader.queue.push(chunk);reader.wake?.();});
39
+ stream.on('close',reason=>{reader.closed={reason};reader.wake?.();channel.close();});
40
+ return reader;
41
+ }catch{channel.close();return null;}
42
+ }
43
+
44
+ /** The door's frames as the server's: one JSON frame per message, a terminal frame ends it, a drop is retryable. */
45
+ async function* frames(reader,signal) {
46
+ const {stream,queue}=reader;
47
+ const abort=()=>stream.close('aborted');signal?.addEventListener('abort',abort,{once:true});
48
+ try{
49
+ for(;;){
50
+ while(queue.length){const frame=JSON.parse(queue.shift());yield frame;if(frame.terminal)return;}
51
+ if(signal?.aborted)return;
52
+ if(reader.closed){
53
+ const reason=String(reader.closed.reason??'closed');
54
+ throw Object.assign(new Error(`local published events: ${reason}`),reason.startsWith('invalid_request')?{code:'invalid_request',status:400,retryable:false}:{code:'unavailable',retryable:true});
55
+ }
56
+ await new Promise(resolve=>{reader.wake=resolve;});reader.wake=null;
57
+ }
58
+ }finally{signal?.removeEventListener('abort',abort);if(!stream.closed)stream.close('done');}
8
59
  }
package/http-client.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /** Neutral HTTP transport. Product extensions keep their payloads in their own contracts. */
2
- export interface RequestOptions { signal?: AbortSignal; idempotencyKey?: string; ifMatch?: number }
2
+ export interface RequestOptions { signal?: AbortSignal; idempotencyKey?: string; ifMatch?: number; /** this call site's name, appended to the process's X-Supercode-Caller */ caller?: string }
3
3
  export interface StreamOptions extends RequestOptions { after?: string }
4
4
  export type Query = Record<string, unknown>;
5
5
  export interface Result<T = unknown> { version: number; data: T; [key: string]: unknown }
@@ -139,8 +139,10 @@ export interface TeamClient {
139
139
  };
140
140
  }
141
141
  export declare class TeamsClient {
142
- constructor(options: {server: string; credential: string; fetch?: typeof fetch});
142
+ /** `machine`: this install's enrolled machine id, named on event streams so the server counts them per install. */
143
+ constructor(options: {server: string; credential: string; fetch?: typeof fetch; machine?: string | null});
143
144
  server: string;
145
+ machine: string | null;
144
146
  request<T = unknown>(method: string, path: string, options?: RequestOptions & {body?: unknown}): Promise<T>;
145
147
  me(options?: RequestOptions): Promise<unknown>;
146
148
  capabilities(options?: RequestOptions): Promise<{version: 1; server_id: string; protocol: 'teams.v1'; operations: string[]; auth_modes: string[]}>;
@@ -149,3 +151,6 @@ export declare class TeamsClient {
149
151
  redeemAppCode(body: unknown, options?: RequestOptions): Promise<unknown>;
150
152
  team(id: string): TeamClient;
151
153
  }
154
+ /** Milliseconds a stream refused for load waits before asking again (the server's retry_after, at least 5 s), else null. */
155
+ export declare function retryAfterMs(error: unknown, response?: Response | null): number | null;
156
+ export declare function spentClause(error: unknown): string;
package/http-client.mjs CHANGED
@@ -13,17 +13,20 @@ export function callerName(env=globalThis.process?.env,argv=globalThis.process?.
13
13
  return ([script,...words].join(' ')+(session?` in ${session}`:'')).replace(/[^\x20-\x7e]/g,'?').slice(0,120);
14
14
  }
15
15
  const CALLER=callerName();
16
+ // A call site may name itself after the process (`caller: 'board-relay publish'`), so one process's doors are told
17
+ // apart in the server's tally; the process's own words are kept, the site's words always fit.
18
+ const callerHeader=site=>{if(!CALLER)return {};if(!site)return {'X-Supercode-Caller':CALLER};const named=String(site).replace(/[^\x20-\x7e]/g,'?').slice(0,60);return {'X-Supercode-Caller':`${CALLER.slice(0,119-named.length)} ${named}`};};
16
19
  // Every write to a Teams server is redacted (@volter/teams/redact), but a sign-in or credential exchange, whose body is
17
20
  // the credential it hands over.
18
21
  const SIGN_IN=/\/(?:login|apps\/token|invitations\/accept|credentials|me\/identities)(?:\/|$)/;
19
22
  // Node only (it reads custody files); a browser sends what its person typed, and a bundler must not pull it in.
20
23
  const { redactValue } = globalThis.process?.versions?.node ? await import(`./redact${'.mjs'}`) : { redactValue: (value) => value };
21
24
  export class TeamsClient {
22
- 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');}
23
- async request(method,path,{body,signal,idempotencyKey,ifMatch}={}) {
25
+ constructor({server,credential,fetch:fetchImpl=(...args)=>globalThis.fetch(...args),machine=null}){this.server=trimOrigin(server);this.credential=credential;this.fetch=fetchImpl;this.machine=machine;if(typeof fetchImpl!=='function')throw new TypeError('fetch is required');}
26
+ async request(method,path,{body,signal,idempotencyKey,ifMatch,caller}={}) {
24
27
  const key=idempotencyKey??(method==='GET'?null:randomKey());let delay=250,last;
25
28
  for(let attempt=0;attempt<5;attempt++) { try {
26
- 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(SIGN_IN.test(path)?body:redactValue(body))});
29
+ const response=await this.fetch(this.server+path,{method,signal,redirect:'manual',headers:{Accept:'application/json',Authorization:`Bearer ${this.credential}`,...callerHeader(caller),...(body!==undefined?{'Content-Type':'application/json'}:{}),...(key?{'Idempotency-Key':key}:{}),...(ifMatch!==undefined?{'If-Match':String(ifMatch)}:{})},body:body===undefined?undefined:JSON.stringify(SIGN_IN.test(path)?body:redactValue(body))});
27
30
  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});
28
31
  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;
29
32
  } 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); } }
@@ -227,13 +230,28 @@ async function* attachmentEvents(root, base, attachment, { signal } = {}) {
227
230
  }
228
231
  const query=q=>{const p=new URLSearchParams();for(const [k,v]of Object.entries(q))if(v!==undefined)p.set(k,String(v));return p.size?'?'+p:'';};
229
232
  const retryWait=(delay,signal)=>new Promise((resolve,reject)=>{const done=()=>{signal?.removeEventListener('abort',abort);resolve();},timer=setTimeout(done,delay),abort=()=>{clearTimeout(timer);signal?.removeEventListener('abort',abort);reject(signal.reason);};signal?.addEventListener('abort',abort,{once:true});});
230
- async function sseError(response){let value=null;try{value=await response.json();}catch{}return Object.assign(new Error(value?.error?.message??`SSE HTTP ${response.status}`),value?.error,{status:response.status,retryable:value?.error?.retryable??(response.status===429||response.status>=500)});}
231
- async function* events(root,path,{signal,after}={}){
233
+ async function sseError(response){let value=null;try{value=await response.json();}catch{}return Object.assign(new Error(value?.error?.message??`SSE HTTP ${response.status}`),value?.error,{status:response.status,retryable:value?.error?.retryable??(response.status===429||response.status>=500),retryAfterMs:retryAfterMs(value?.error,response)});}
234
+ // How long a refused stream waits before asking again: the server's retry_after (its body, else Retry-After), at least
235
+ // 5 s; a refusal that names none waits 30 s. Null for an error that is not a refusal for load.
236
+ export function retryAfterMs(error,response=null){
237
+ if(typeof error?.retryAfterMs==='number')return error.retryAfterMs;
238
+ if(error?.code!=='rate_limited'&&response?.status!==429&&error?.status!==429)return null;
239
+ const named=error?.details?.retry_after_seconds??error?.retry_after_seconds??response?.headers?.get?.('retry-after'),seconds=named==null||named===''?NaN:Number(named);
240
+ return Math.max(5000,Number.isFinite(seconds)?seconds*1000:30000);
241
+ }
242
+ // Who spent the credential's last minute, as a refusal for the rate limit carries it (the server's tally by caller and
243
+ // route), for a log line: `; spent 118/min: caller a=60, b=40; route POST /x=70`. Empty for any other error.
244
+ export function spentClause(error){
245
+ const spent=error?.code==='rate_limited'?error.details?.spent_last_minute:null;if(!spent)return '';
246
+ const top=list=>(Array.isArray(list)?list:[]).slice(0,3).map(entry=>`${entry.name}=${entry.count}`).join(', ')||'none';
247
+ return `; spent ${spent.requests}/min: caller ${top(spent.by_caller)}; route ${top(spent.by_route)}`;
248
+ }
249
+ async function* events(root,path,{signal,after,caller}={}){
232
250
  let cursor=after??null,lastYielded=null,lastError=null;
233
251
  for(let attempt=0;attempt<5&&!signal?.aborted;attempt++){
234
252
  const suffix=cursor?path+(path.includes('?')?'&':'?')+'after='+encodeURIComponent(cursor):path;
235
253
  try{
236
- const response=await root.fetch(root.server+suffix,{signal,redirect:'manual',headers:{Accept:'text/event-stream',Authorization:`Bearer ${root.credential}`,...(cursor?{'Last-Event-ID':cursor}:{})}});
254
+ const response=await root.fetch(root.server+suffix,{signal,redirect:'manual',headers:{Accept:'text/event-stream',Authorization:`Bearer ${root.credential}`,...callerHeader(caller),...(root.machine?{'X-Supercode-Machine':root.machine}:{}),...(cursor?{'Last-Event-ID':cursor}:{})}});
237
255
  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});
238
256
  if(!response.ok)throw await sseError(response);
239
257
  let buffer='';
@@ -245,7 +263,7 @@ async function* events(root,path,{signal,after}={}){
245
263
  }
246
264
  }
247
265
  lastError=Object.assign(new Error('SSE stream ended before a terminal event'),{retryable:true});
248
- }catch(error){if(signal?.aborted)return;if(error.retryable===false||error.status&&error.status!==429&&error.status<500)throw error;lastError=error;}
266
+ }catch(error){if(signal?.aborted)return;if(error.retryable===false||error.status&&error.status!==429&&error.status<500||error.retryAfterMs)throw error;lastError=error;}
249
267
  if(attempt<4)await retryWait(Math.min(250*2**attempt,5000),signal).catch(()=>{});
250
268
  }
251
269
  if(!signal?.aborted&&lastError)throw lastError;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/teams",
3
- "version": "0.2.11",
3
+ "version": "0.2.13",
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/events.mjs CHANGED
@@ -15,3 +15,23 @@ export function sseRoom(res,bytes,{cap=SSE_BACKLOG_BYTES,patienceMs=SSE_PATIENCE
15
15
  res.on('drain',drained);res.on('close',ended);
16
16
  });
17
17
  }
18
+ // Open event streams are counted per team install: a credential on one machine (a machine credential's own machine, or
19
+ // the enrolled machine of this team its request names in X-Supercode-Machine), else the credential with no machine.
20
+ // An install's connector holds one published-events stream that its local readers share (dispatchers, agents, apps on
21
+ // that machine read through its door), and its people open a few viewers beside it (session follows, a catalogue
22
+ // watch): one stream, one more while a dropped one is still being noticed, about five viewers, and headroom makes 10.
23
+ // A credential's streams over all its machines stop at 40, so a leaked token cannot hold the server's connections.
24
+ export const STREAMS_PER_INSTALL=10, STREAMS_PER_CREDENTIAL=40;
25
+ // A slot frees only when another stream of the install closes; a refused reader asks again no sooner than this.
26
+ export const STREAM_RETRY_SECONDS=30;
27
+ /** Count one open stream for `auth`'s install, refusing past the limits; resolves the release (idempotent). */
28
+ export async function holdStream(api,auth,req){
29
+ const named=String(req.headers['x-supercode-machine']??'');
30
+ let machine=auth.kind==='machine'?auth.machine_id:'';
31
+ if(!machine&&/^mach_[A-Za-z0-9_-]{1,64}$/.test(named)&&await api.store.one('SELECT 1 FROM machines WHERE team_id=? AND id=? AND revoked_at IS NULL',[auth.team_id,named]))machine=named;
32
+ const keys=[`install:${auth.credential_id}\0${machine}`,`credential:${auth.credential_id}`],limits=[STREAMS_PER_INSTALL,STREAMS_PER_CREDENTIAL];
33
+ if(keys.some((key,at)=>(api.openStreams.get(key)??0)>=limits[at]))throw Object.assign(new Error(`too many open event streams for this ${machine?'machine':'credential'}`),{status:429,code:'rate_limited',details:{retry_after_seconds:STREAM_RETRY_SECONDS,streams_per_install:STREAMS_PER_INSTALL,machine:machine||null}});
34
+ for(const key of keys)api.openStreams.set(key,(api.openStreams.get(key)??0)+1);
35
+ let held=true;
36
+ return ()=>{if(!held)return;held=false;for(const key of keys){const next=(api.openStreams.get(key)??1)-1;if(next>0)api.openStreams.set(key,next);else api.openStreams.delete(key);}};
37
+ }
@@ -1,5 +1,5 @@
1
1
  // A retained machine transition log for Teams mailbox subscriptions.
2
- import { writeSse } from './events.mjs';
2
+ import { writeSse, holdStream } from './events.mjs';
3
3
 
4
4
  export function machineTransition(api, team, machine, linked) {
5
5
  // Serialize the read/change/event across asynchronous store calls. A replaced
@@ -37,11 +37,9 @@ export async function reconcileMachinePresence(api) {
37
37
  export async function machineEventStream({api,req,res,auth,url,requestId,fail}) {
38
38
  const after = url.searchParams.get('after') ?? req.headers['last-event-id'];
39
39
  if (after != null && (!/^\d+$/.test(after) || !Number.isSafeInteger(Number(after)))) fail(400,'invalid_request','after must be a machine event cursor');
40
- const count = api.openStreams.get(auth.credential_id) ?? 0;
41
- if (count >= 10) fail(429,'rate_limited','too many open streams');
42
- api.openStreams.set(auth.credential_id,count+1);
40
+ const release = await holdStream(api,auth,req);
43
41
  let closed=false, cursor=after==null?null:Number(after), work=Promise.resolve();
44
- 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(); };
42
+ const close=()=>{ if(closed)return;closed=true;clearInterval(timer);unsubscribe();req.off('close',close);api.events.off('shutdown',close);release();res.end(); };
45
43
  const write=event=>{if(closed)return;if(res.writableLength>1024*1024){close();return;}writeSse(res,event);};
46
44
  const drain=async()=>{
47
45
  if(closed)return;
@@ -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 { sseRoom, writeSse } from './events.mjs';
6
+ import { sseRoom, writeSse, holdStream } 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
 
@@ -159,10 +159,9 @@ async function eventStream({api,req,res,auth,url,requestId}){
159
159
  if([...url.searchParams.keys()].some(key=>!['after','source','kinds'].includes(key))||query.kinds?.some(kind=>!APP_EVENTS.includes(kind)))fail(400,'invalid_request','invalid event subscription');
160
160
  auth=await liveAuth(api,auth);let codec=cursorCodec(api,auth,query);
161
161
  const after=url.searchParams.get('after')??req.headers['last-event-id'];let cursor=after==null?null:codec.decode(after);
162
- const count=api.openStreams.get(auth.credential_id)??0;if(count>=10)fail(429,'rate_limited','too many event subscriptions');
163
- api.openStreams.set(auth.credential_id,count+1);
162
+ const release=await holdStream(api,auth,req);
164
163
  let closed=false,running=null,dirty=false;
165
- 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();};
164
+ const close=()=>{if(closed)return;closed=true;clearInterval(timer);unsubscribe();req.off('close',close);api.events.off('shutdown',close);release();res.end();};
166
165
  const terminal=(type,reason)=>{if(!closed)writeSse(res,{type,reason,terminal:true});close();};
167
166
  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;};
168
167
  const readable=async event=>{