@volter/teams 0.1.0

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.
@@ -0,0 +1,64 @@
1
+ // The Teams server, independent of its host. `createTeamsApi` builds the API
2
+ // object every route and door reads; a host attaches a store (`api.attach`),
3
+ // bootstraps the server identity once, serves requests through http.mjs's
4
+ // handler and machine-link.mjs's doors, and runs `maintain` about once a minute.
5
+ // Hosts: index.mjs (a Node process on a data directory) and ../worker (a
6
+ // Cloudflare Durable Object).
7
+ import { randomBytes } from 'node:crypto';
8
+ import { hashToken } from './auth.mjs';
9
+ import { TeamEvents } from './events.mjs';
10
+
11
+ const prefix={server:'srv',team:'team',user:'usr',service:'svc',group:'grp',session:'ses',message:'msg',evidence:'evi',correlation:'cor',source:'src',machine:'mach',capture:'cap',upload:'upload',grant:'grant',operation:'op',continuation:'cont',invitation:'inv',credential:'cred',request:'req',root:'root',incarnation:'inc',rule:'rule',attachment:'att',event:'evt',audit:'audit'};
12
+ const productionIds={next(kind){return `${prefix[kind]??kind}_${randomBytes(16).toString('hex')}`;}};
13
+ const productionClock={nowMs:()=>Date.now()};
14
+ export { productionIds, productionClock };
15
+
16
+ export function createTeamsApi({clock=productionClock,ids=productionIds,publicOrigin=null,identityIssuer=null,uploadLimitBytes=256*1024*1024}={}) {
17
+ // An identity service people may sign in with (protocol P02 federation); its tokens name this server's public origin.
18
+ identityIssuer=normalizePublicOrigin(identityIssuer);
19
+ if(identityIssuer&&!publicOrigin)throw new TypeError('an identity issuer needs the public origin its tokens name');
20
+ if(typeof clock?.nowMs!=='function'||typeof ids?.next!=='function')throw new TypeError('clock.nowMs and ids.next are required');
21
+ if(!Number.isSafeInteger(uploadLimitBytes)||uploadLimitBytes<1||uploadLimitBytes>256*1024*1024)throw new TypeError('uploadLimitBytes must be between 1 and 268435456');
22
+ let store=null,serverId=null,localOrigin=null,harnessRoutes=null;
23
+ const events=new TeamEvents();
24
+ return {clock,ids,events,publicOrigin,identityIssuer,uploadLimitBytes,rateState:new Map(),openStreams:new Map(),securityEvents:[],
25
+ get store(){return store;},get serverId(){return serverId;},get localOrigin(){return localOrigin;},get harnessRoutes(){return harnessRoutes;},
26
+ 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;},
27
+ };
28
+ }
29
+
30
+ /** Validate a configured public origin: an HTTP(S) origin with no path, credentials, query or fragment. */
31
+ export function normalizePublicOrigin(publicOrigin){
32
+ if(!publicOrigin)return null;
33
+ const parsed=new URL(publicOrigin);
34
+ if(!['http:','https:'].includes(parsed.protocol)||parsed.username||parsed.password||parsed.search||parsed.hash||(parsed.pathname!==''&&parsed.pathname!=='/'))throw new TypeError('publicOrigin must be an HTTP(S) origin');
35
+ return parsed.origin;
36
+ }
37
+
38
+ /**
39
+ * The server's identity, created once: server and team ids, an owner and the owner's credential
40
+ * from `bootstrap` {server_id, team_id, principal_id, credential_id, token}. Returns the server id.
41
+ */
42
+ export async function bootstrapServer(api,{initTeam,bootstrap}){
43
+ const meta=await api.store.one("SELECT value FROM server_meta WHERE key='server_id'");
44
+ if(meta)return meta.value;
45
+ const now=api.clock.nowMs(),{server_id:sid,team_id:tid,principal_id:uid,credential_id:cid,token}=bootstrap;
46
+ await api.store.transaction([
47
+ {sql:'INSERT INTO server_meta(key,value) VALUES (?,?)',params:['server_id',sid]},{sql:'INSERT INTO server_meta(key,value) VALUES (?,?)',params:['default_team_id',tid]},
48
+ {sql:'INSERT INTO principals(id,kind,display_name) VALUES (?,?,?)',params:[uid,'user','Owner']},{sql:'INSERT INTO teams(id,name) VALUES (?,?)',params:[tid,initTeam??'Team']},
49
+ {sql:'INSERT INTO memberships(team_id,principal_id,role) VALUES (?,?,?)',params:[tid,uid,'owner']},{sql:'INSERT INTO credentials(id,token_hash,principal_id,kind,team_id,created_at) VALUES (?,?,?,?,?,?)',params:[cid,hashToken(token),uid,'user',null,now]}
50
+ ]);
51
+ return sid;
52
+ }
53
+
54
+ /** Expire cursors, idempotency records, staged captures and uploads, and old journal rows. */
55
+ export async function maintain(api){const now=api.clock.nowMs();await api.store.transaction([
56
+ {sql:'DELETE FROM cursors WHERE expires_at<=?',params:[now]},
57
+ {sql:'DELETE FROM global_cursors WHERE expires_at<=?',params:[now]},
58
+ {sql:'DELETE FROM global_idempotency WHERE expires_at<=?',params:[now]},
59
+ {sql:'INSERT OR IGNORE INTO capture_expiries(team_id,source_id,id,expired_at) SELECT team_id,source_id,id,? FROM captures WHERE state=\'staging\' AND created_at+86400000<=?',params:[now,now]},
60
+ {sql:'DELETE FROM captures WHERE state=\'staging\' AND created_at+86400000<=?',params:[now]},
61
+ {sql:'INSERT OR IGNORE INTO capture_expiries(team_id,source_id,id,expired_at) SELECT team_id,source_id,id,? FROM uploads WHERE committed=0 AND created_at+86400000<=?',params:[now,now]},
62
+ {sql:'DELETE FROM uploads WHERE committed=0 AND created_at+86400000<=?',params:[now]},
63
+ {sql:'DELETE FROM journal WHERE created_at+86400000<=?',params:[now]},
64
+ ]);}
@@ -0,0 +1,3 @@
1
+ import { EventEmitter } from 'node:events';
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
+ export function writeSse(res,event){const cursor=event.cursor??'';res.write(`id: ${cursor}\nevent: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`);}
@@ -0,0 +1,211 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { bearer, hashToken, canManage, isAdmin, makeToken } from './auth.mjs';
3
+ import { ContractError, canonicalJson, validationFailure, cursorHash, cursorToken, sha256 } from './wire.mjs';
4
+ import { machineGrantRoute, machineGrantRows, closeMachineChannels, closeChannelsFor } from './machine-link.mjs';
5
+ import { exact, parseJson as parseRequestJson } from './request-json.mjs';
6
+ import { identityOf } from './identity.mjs';
7
+
8
+ class HttpError extends Error { constructor(status,code,message,details=null){super(message);this.status=status;this.code=code;this.details=details;} }
9
+ const fail=(status,code,message,details)=>{throw new HttpError(status,code,message,details)};
10
+ const parseJson=req=>parseRequestJson(req,fail);
11
+ const result=(api,auth,data)=>({version:1,context:{server_id:api.serverId,team_id:auth.team_id,principal_id:auth.principal_id},data});
12
+ function sideEffectStatements(api,res,status,body,now,targetId=null,journal=false){const statements=[];if(status<400&&res._idempotencyRecord){const record=res._idempotencyRecord,safe=redactSecretReplay(record.path,body);statements.push({sql:'INSERT INTO idempotency(team_id,principal_id,route,key,request_digest,response_status,response_json,expires_at) VALUES (?,?,?,?,?,?,?,?)',params:[record.teamId,record.principalId,record.route,record.key,record.requestDigest,status,journal?{$journalJsonValue:safe}:JSON.stringify(safe),now+86400000]});}if(res._auditRecord){const record=res._auditRecord;statements.push({sql:'INSERT INTO audit(team_id,id,actor_id,operation,target_id,outcome,revision,created_at) VALUES (?,?,?,?,?,?,?,?)',params:[record.teamId,api.ids.next('audit'),record.actorId,record.operation,targetId,status<400?'allowed':'denied',journal?{$journalRevision:true}:null,now]});}return statements;}
13
+ async function persistSideEffects(api,res,status,body){const statements=sideEffectStatements(api,res,status,body,api.clock.nowMs());if(statements.length)await api.store.transaction(statements);}
14
+ function send(res,status,body,requestId,headers={}){const deliver=()=>{res.writeHead(status,{'Content-Type':'application/json; charset=utf-8','Cache-Control':'no-store','X-Request-ID':requestId,...headers});res.end(body===null?'':JSON.stringify(body));};if(!res._sideEffectsCommitted&&res._api&&(res._idempotencyRecord||res._auditRecord))return persistSideEffects(res._api,res,status,body).then(deliver);deliver();}
15
+ async function guardedMutation(api,res,auth,guards,statements,{status,body,targetId=null,journal=false}){const now=api.clock.nowMs(),all=[...statements,...sideEffectStatements(api,res,status,body,now,targetId,journal)],result=journal?await api.store.journalTransaction(auth.team_id,guards,all):await api.store.guardedTransaction(guards,all);res._sideEffectsCommitted=true;return result;}
16
+ const requireRevision=(req,current,label='resource')=>{const expected=Number(String(req.headers['if-match']??'').replaceAll('"',''));if(!Number.isSafeInteger(expected))fail(400,'invalid_request','If-Match is required');if(expected!==current)fail(409,'revision_mismatch',`${label} revision changed`);return expected;};
17
+ const CORS={'Access-Control-Allow-Origin':'*','Access-Control-Expose-Headers':'ETag, X-Request-ID, Content-Range'};
18
+ const esc=(x)=>String(x).replace(/[\\%_]/g,'\\$&');
19
+
20
+ export function createHttpHandler(api){return async(req,res)=>{const requestId=api.ids.next('request');res._api=api;try{
21
+ // Any page may call with a bearer it holds: the only credential is that header, never a
22
+ // cookie, so a caller's origin admits nothing and a preflight is answered before auth.
23
+ for(const [name,value] of Object.entries(CORS))res.setHeader(name,value);
24
+ 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;}
25
+ 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');}
26
+ const url=new URL(req.url,'http://local'); const path=url.pathname;
27
+ if(api.harnessRoutes&&await api.harnessRoutes.preAuth({api,req,res,path,requestId}))return;
28
+ const auth=await authenticate(api,bearer(req.headers.authorization));res._authenticated=Boolean(auth);
29
+ if(api.harnessRoutes&&await api.harnessRoutes.authed({api,req,res,auth,path,requestId}))return;
30
+ if(req.method==='POST'&&path==='/api/v1/invitations/accept'){checkRate(api,`login:${req.socket.remoteAddress}`,10,5);const body=await parseJson(req);return await acceptInvite(api,res,body,requestId);}
31
+ if(req.method==='POST'&&path==='/api/v1/login/volter'){checkRate(api,`login:${req.socket.remoteAddress}`,10,5);const body=await parseJson(req);return await identityLogin(api,res,body,requestId);}
32
+ if(!auth)fail(401,'authentication_required','authentication required');auth.now=api.clock.nowMs();auth.clock=api.clock;
33
+ const bulk=/\/captures\/[^/]+\/parts\/\d+$/.test(path)||/\/uploads\/[^/]+\/chunks\/\d+$/.test(path);checkRate(api,`credential:${auth.credential_id}:${bulk?'bulk':'ordinary'}`,bulk?600:120,bulk?60:30);
34
+ 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);}
35
+ if(req.method==='POST'&&path==='/api/v1/me/identities')return await linkIdentity(api,req,res,auth,requestId);
36
+ if(req.method==='GET'&&path==='/api/v1/capabilities')return send(res,200,{version:1,server_id:api.serverId,protocol:'teams.v1',operations:['catalog','search','read','follow','access','ingest'],auth_modes:['token','invitation',...(api.identityIssuer?['volter']:[])],...(api.identityIssuer?{identity:{issuer:api.identityIssuer,audience:api.publicOrigin}}:{})},requestId);
37
+ if(req.method==='GET'&&path==='/api/v1/teams')return await listTeams(api,res,auth,url,requestId);
38
+ if(req.method==='POST'&&path==='/api/v1/teams')return await createTeam(api,req,res,auth,path,requestId);
39
+ const match=/^\/api\/v1\/teams\/([^/]+)(\/.*)?$/.exec(path);if(!match)fail(404,'not_found','not found');const teamId=match[1],sub=match[2]??'';
40
+ if(auth.kind==='user'&&auth.team_id===null){const membership=await api.store.one('SELECT role,active FROM memberships WHERE team_id=? AND principal_id=?',[teamId,auth.principal_id]);if(!membership?.active)fail(401,'authentication_required','authentication required');auth.role=membership.role;auth.active=membership.active;auth.team_id=teamId;auth.group_ids=(await api.store.query('SELECT group_id FROM group_members WHERE team_id=? AND principal_id=?',[teamId,auth.principal_id])).map(x=>x.group_id);}else if(auth.team_id!==teamId||!auth.active)fail(401,'authentication_required','authentication required');
41
+ if(req.method!=='GET')res._auditRecord={teamId:auth.team_id,actorId:auth.principal_id,operation:`${req.method} ${path}`};
42
+ if(['machine','ingest'].includes(auth.kind)){
43
+ const machineDoor=auth.kind==='machine'&&(/^\/machines\/[^/]+\/configuration$/.test(sub)||sub===`/machines/${auth.machine_id}/heartbeat`||sub.startsWith(`/machines/${auth.machine_id}/effects`)||new RegExp(`^/machines/${auth.machine_id}/attachments/[^/]+(?:/frames)?$`).test(sub));
44
+ const sourceDoor=sub.startsWith(`/sources/${auth.source_id}/captures`)||sub.startsWith(`/sources/${auth.source_id}/uploads`);
45
+ if(!machineDoor&&!sourceDoor)fail(403,'forbidden','credential kind cannot use this operation');
46
+ }
47
+ let m;if(api.harnessRoutes&&await api.harnessRoutes.beforeIdempotency({api,req,res,auth,url,path,sub,requestId}))return;
48
+ const intrinsicPart=req.method==='PUT'&&(/\/captures\/[^/]+\/parts\/\d+$/.test(sub)||/\/uploads\/[^/]+\/chunks\/\d+$/.test(sub)),intrinsicReplay=intrinsicPart||(req.method==='POST'&&(/\/captures\/[^/]+\/commit$/.test(sub)||/\/effects\/[^/]+\/(?:claim|result)$/.test(sub)));
49
+ if(['POST','PATCH','DELETE'].includes(req.method)&&!intrinsicPart){const key=req.headers['idempotency-key'];if(typeof key!=='string'||key.length<16||key.length>128||!/^[\x20-\x7e]+$/.test(key))fail(400,'invalid_request','Idempotency-Key must be 16..128 printable ASCII characters');if(!intrinsicReplay){const body=req.method==='DELETE'?null:await parseJson(req),route=`${req.method} ${path}`,requestDigest=sha256(canonicalJson({method:req.method,path,if_match:req.headers['if-match']??null,body})),old=await api.store.one('SELECT * FROM idempotency WHERE team_id=? AND principal_id=? AND route=? AND key=? AND expires_at>?',[auth.team_id,auth.principal_id,route,key,api.clock.nowMs()]);if(old){if(old.request_digest!==requestDigest)fail(409,'idempotency_conflict','idempotency key was reused with a different request');await authorizeReplay(api,auth,req.method,sub,body);return send(res,old.response_status,JSON.parse(old.response_json),requestId);}res._idempotencyRecord={teamId:auth.team_id,principalId:auth.principal_id,route,key,requestDigest,path};}}
50
+ if(req.method==='POST'&&sub==='/invitations')return await createInvitation(api,req,res,auth,requestId);
51
+ if(req.method==='POST'&&sub==='/credentials')return await createCredential(api,req,res,auth,requestId);
52
+ if(req.method==='DELETE'&&(m=/^\/credentials\/([^/]+)$/.exec(sub)))return await revokeCredential(api,req,res,auth,m[1],requestId);
53
+ if(req.method==='GET'&&sub==='/members')return await listMembers(api,res,auth,url,requestId);
54
+ if(req.method==='PATCH'&&(m=/^\/members\/([^/]+)$/.exec(sub)))return await updateMember(api,req,res,auth,m[1],requestId);
55
+ if(req.method==='DELETE'&&(m=/^\/members\/([^/]+)$/.exec(sub)))return await removeMember(api,req,res,auth,m[1],requestId);
56
+ if(req.method==='GET'&&sub==='/groups')return await groupRoute(api,req,res,auth,null,null,url,requestId);
57
+ if(req.method==='POST'&&sub==='/groups')return await groupRoute(api,req,res,auth,null,null,url,requestId);
58
+ if(req.method==='DELETE'&&(m=/^\/groups\/([^/]+)$/.exec(sub)))return await groupRoute(api,req,res,auth,m[1],null,url,requestId);
59
+ if((req.method==='PUT'||req.method==='DELETE')&&(m=/^\/groups\/([^/]+)\/members\/([^/]+)$/.exec(sub)))return await groupRoute(api,req,res,auth,m[1],m[2],url,requestId);
60
+ if(req.method==='POST'&&sub==='/machines')return await enroll(api,req,res,auth,requestId);
61
+ if(req.method==='GET'&&sub==='/machines')return await machineAdminRoute(api,req,res,auth,null,false,requestId);
62
+ if(req.method==='GET'&&(m=/^\/machines\/([^/]+)$/.exec(sub)))return await machineAdminRoute(api,req,res,auth,m[1],false,requestId);
63
+ if(req.method==='GET'&&(m=/^\/machines\/([^/]+)\/roots$/.exec(sub)))return await machineAdminRoute(api,req,res,auth,m[1],true,requestId);
64
+ if(req.method==='DELETE'&&(m=/^\/machines\/([^/]+)$/.exec(sub)))return await revokeMachine(api,req,res,auth,m[1],requestId);
65
+ if(req.method==='POST'&&(m=/^\/machines\/([^/]+)\/heartbeat$/.exec(sub)))return await heartbeat(api,req,res,auth,m[1],requestId);
66
+ if(req.method==='GET'&&(m=/^\/machines\/([^/]+)\/configuration$/.exec(sub)))return await machineConfiguration(api,res,auth,m[1],requestId);
67
+ if((m=/^\/machines\/([^/]+)\/access(?:\/([^/]+))?$/.exec(sub)))return await machineGrantRoute({api,req,res,auth,machineId:m[1],grantId:m[2],url,requestId,parseJson,exact,fail,send,result,guardedMutation,requireRevision});
68
+ if(req.method==='POST'&&sub==='/sources')return await createSource(api,req,res,auth,requestId);
69
+ if(req.method==='GET'&&sub==='/sources')return await listSources(api,res,auth,url,requestId);
70
+ if(req.method==='GET'&&(m=/^\/sources\/([^/]+)$/.exec(sub)))return await getSource(api,res,auth,m[1],requestId);
71
+ if(req.method==='PATCH'&&(m=/^\/sources\/([^/]+)$/.exec(sub)))return await patchSource(api,req,res,auth,m[1],requestId);
72
+ if(req.method==='POST'&&(m=/^\/sources\/([^/]+)\/credentials$/.exec(sub)))return await sourceCredential(api,req,res,auth,m[1],requestId);
73
+ if(api.harnessRoutes&&await api.harnessRoutes.team({api,req,res,auth,url,path,sub,requestId}))return;
74
+ fail(404,'not_found','not found');
75
+ }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);}};}
76
+ 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);}
77
+ function redactSecretReplay(path,body){if(body===null||typeof body!=='object')return body;const copy=structuredClone(body);if(path.endsWith('/invitations')&&copy.data)delete copy.data.enrollment_code;if(path.endsWith('/machines')&&copy.data)copy.data.credential=null;if(path.endsWith('/credentials'))copy.data=null;return copy;}
78
+
79
+ async function authorizeReplay(api,auth,method,sub,body){
80
+ const now=api.clock.nowMs();
81
+ let match;
82
+ if((match=/^\/sources\/([^/]+)\/captures$/.exec(sub))){const source=await api.store.one('SELECT state,machine_id,visibility FROM sources WHERE team_id=? AND id=?',[auth.team_id,match[1]]);if(!source||source.state!=='active'||auth.source_id!==match[1])fail(403,'forbidden','source authorization changed');if(auth.kind==='machine'){const rule=await api.store.one("SELECT 1 ok FROM machines m JOIN sync_rules r ON r.team_id=m.team_id AND r.machine_id=m.id WHERE m.team_id=? AND m.id=? AND m.revoked_at IS NULL AND r.id=? AND r.source_id=? AND r.state='active' AND r.revision=? AND r.visibility=?",[auth.team_id,auth.machine_id,body?.rule_id,match[1],body?.rule_revision,body?.visibility]);if(!rule)fail(403,'forbidden','sync rule authorization changed');}else if(auth.kind!=='ingest'||body?.visibility!==source.visibility)fail(403,'forbidden','source policy changed');return;}
83
+ if((match=/^\/machines\/([^/]+)\/heartbeat$/.exec(sub))&&auth.kind==='machine'){const machine=await api.store.one('SELECT 1 ok FROM machines WHERE team_id=? AND id=? AND revoked_at IS NULL',[auth.team_id,match[1]]);if(!machine||auth.machine_id!==match[1])fail(403,'forbidden','machine authorization changed');return;}
84
+ if(!['user','service'].includes(auth.kind))fail(403,'forbidden','credential kind cannot replay this operation');
85
+ if(sub==='/invitations'||sub==='/groups'||sub==='/sources'||/^\/members\//.test(sub)||/^\/groups\//.test(sub)){if(!isAdmin(auth))fail(403,'forbidden','administrative authorization changed');return;}
86
+ if(method==='POST'&&sub==='/machines'){if(auth.role==='viewer')fail(403,'forbidden','machine enrollment authorization changed');return;}
87
+ if((match=/^\/sessions\/([^/]+)(?:\/access(?:\/[^/]+)?)?$/.exec(sub))){const session=await api.store.one('SELECT * FROM sessions WHERE team_id=? AND id=? AND deleted_at IS NULL',[auth.team_id,match[1]]);if(!session||!canManage({...auth,now},session,await grants(api,auth.team_id,match[1])))fail(404,'not_found','not found');return;}
88
+ if((match=/^\/sessions\/([^/]+)\/continuations$/.exec(sub))){await api.store.authorizedSession({teamId:auth.team_id,principalId:auth.principal_id,credentialId:auth.credential_id,ref:match[1],now});return;}
89
+ if((match=/^\/continuations\/([^/]+)\/(authorize-destination|launch|complete-local)$/.exec(sub))){const continuation=await api.store.one('SELECT * FROM continuations WHERE team_id=? AND id=?',[auth.team_id,match[1]]);if(!continuation)fail(404,'not_found','continuation not found');const value=JSON.parse(continuation.value_json);if(match[2]==='authorize-destination'){const machine=await api.store.one('SELECT 1 ok FROM machines WHERE team_id=? AND id=? AND custodian_id=? AND revoked_at IS NULL',[auth.team_id,value.destination?.machine_id,auth.principal_id]);if(!machine)fail(404,'not_found','destination machine not found');}else{if(continuation.requester_id!==auth.principal_id)fail(404,'not_found','continuation not found');await api.store.authorizedSession({teamId:auth.team_id,principalId:auth.principal_id,credentialId:auth.credential_id,ref:continuation.source_session_id,now});}return;}
90
+ if((match=/^\/machines\/([^/]+)/.exec(sub))){const machine=await api.store.one('SELECT custodian_id,revoked_at FROM machines WHERE team_id=? AND id=?',[auth.team_id,match[1]]);if(!machine||machine.revoked_at!=null||machine.custodian_id!==auth.principal_id&&!isAdmin(auth))fail(404,'not_found','not found');return;}
91
+ if((match=/^\/sources\/([^/]+)(?:\/captures)?$/.exec(sub))){const source=await api.store.one('SELECT custodian_id,state FROM sources WHERE team_id=? AND id=?',[auth.team_id,match[1]]);if(!source||source.state==='revoked'||source.custodian_id!==auth.principal_id&&!isAdmin(auth))fail(403,'forbidden','source authorization changed');return;}
92
+ if((match=/^\/sync-rules\/([^/]+)/.exec(sub))){const rule=await api.store.one('SELECT m.custodian_id,m.revoked_at FROM sync_rules r JOIN machines m ON m.team_id=r.team_id AND m.id=r.machine_id WHERE r.team_id=? AND r.id=?',[auth.team_id,match[1]]);if(!rule||rule.revoked_at!=null||rule.custodian_id!==auth.principal_id)fail(404,'not_found','not found');return;}
93
+ if(sub==='/sync-rules'&&method==='POST'){if(auth.role==='viewer')fail(403,'forbidden','sync authorization changed');return;}
94
+ if(sub==='/correlations'&&method==='POST'){if(auth.role==='viewer')fail(403,'forbidden','correlation authorization changed');return;}
95
+ }
96
+
97
+ export async function authenticate(api,token){if(!token)return null;const digest=hashToken(token);const auth=await api.store.one(`SELECT c.id credential_id,c.kind,c.team_id,c.source_id,c.machine_id,c.principal_id,p.kind principal_kind,p.display_name,m.role,m.active FROM credentials c JOIN principals p ON p.id=c.principal_id LEFT JOIN memberships m ON m.team_id=c.team_id AND m.principal_id=c.principal_id WHERE c.token_hash=? AND c.revoked_at IS NULL AND (c.expires_at IS NULL OR c.expires_at>?)`,[digest,api.clock.nowMs()]);if(auth?.team_id)auth.group_ids=(await api.store.query('SELECT group_id FROM group_members WHERE team_id=? AND principal_id=?',[auth.team_id,auth.principal_id])).map(x=>x.group_id);return auth;}
98
+ async function grants(api,team,id){return api.store.query('SELECT * FROM session_grants WHERE team_id=? AND session_id=?',[team,id]);}
99
+ const authGuards=(auth,_admissionNow)=>{
100
+ const now=auth.clock?.nowMs()??_admissionNow;
101
+ return [
102
+ {sql:'SELECT 1 ok FROM credentials WHERE id=? AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at>?)',params:[auth.credential_id,now],code:'authentication_required',status:401,message:'credential changed'},
103
+ {sql:'SELECT 1 ok FROM memberships WHERE team_id=? AND principal_id=? AND active=1',params:[auth.team_id,auth.principal_id],code:'authentication_required',status:401,message:'membership changed'}
104
+ ];
105
+ };
106
+ async function listTeams(api,res,auth,url,requestId){
107
+ if(!['user','service'].includes(auth.kind))fail(403,'forbidden','credential kind cannot use this operation');
108
+ const allowed=new Set(['limit','cursor']);for(const key of url.searchParams.keys())if(!allowed.has(key))fail(400,'invalid_request',`unknown query field ${key}`);
109
+ const limit=url.searchParams.has('limit')?Number(url.searchParams.get('limit')):50;if(!Number.isSafeInteger(limit)||limit<1||limit>100)fail(400,'invalid_request','limit must be 1..100');const normalized={limit,credential_team_id:auth.team_id},now=api.clock.nowMs();let rows;
110
+ if(url.searchParams.has('cursor')){const cursor=await api.store.one('SELECT * FROM global_cursors WHERE id=? AND principal_id=?',[cursorHash(url.searchParams.get('cursor')),auth.principal_id]);if(!cursor||cursor.expires_at<=now||cursor.credential_team_id!==auth.team_id||canonicalJson(JSON.parse(cursor.query_json))!==canonicalJson(normalized))fail(409,'resync_required','team cursor expired or scope changed');rows=JSON.parse(cursor.snapshot_json);const active=await api.store.query('SELECT team_id FROM memberships WHERE principal_id=? AND active=1',[auth.principal_id]),allowedTeams=new Set(active.map(item=>item.team_id));rows=rows.filter(item=>allowedTeams.has(item.id));}else rows=await api.store.query('SELECT t.id,t.name,t.revision FROM memberships m JOIN teams t ON t.id=m.team_id WHERE m.principal_id=? AND m.active=1 AND (? IS NULL OR t.id=?) ORDER BY t.name,t.id',[auth.principal_id,auth.team_id,auth.team_id]);
111
+ const page=rows.slice(0,limit),rest=rows.slice(limit);let next=null;if(rest.length){next=cursorToken();await api.store.exec('INSERT INTO global_cursors(id,principal_id,credential_team_id,kind,query_json,snapshot_json,expires_at) VALUES (?,?,?,?,?,?,?)',[cursorHash(next),auth.principal_id,auth.team_id,'teams',JSON.stringify(normalized),JSON.stringify(rest),now+900000]);}
112
+ send(res,200,{version:1,context:{server_id:api.serverId,principal_id:auth.principal_id},items:page,next_cursor:next},requestId);
113
+ }
114
+ async function createTeam(api,req,res,auth,path,requestId){
115
+ if(auth.kind!=='user')fail(403,'forbidden','only users may create teams');
116
+ const body=await parseJson(req);exact(body,['name']);if(typeof body.name!=='string'||!body.name.trim())fail(400,'invalid_request','team name is required');
117
+ const key=req.headers['idempotency-key'];if(typeof key!=='string'||key.length<16||key.length>128||!/^[\x20-\x7e]+$/.test(key))fail(400,'invalid_request','Idempotency-Key must be 16..128 printable ASCII characters');
118
+ const route=`POST ${path}`,requestDigest=sha256(canonicalJson({method:'POST',path,if_match:null,body})),now=api.clock.nowMs(),old=await api.store.one('SELECT * FROM global_idempotency WHERE principal_id=? AND route=? AND key=? AND expires_at>?',[auth.principal_id,route,key,now]);if(old){if(old.request_digest!==requestDigest)fail(409,'idempotency_conflict','idempotency key was reused with a different request');const stored=JSON.parse(old.response_json),membership=await api.store.one('SELECT 1 ok FROM memberships WHERE team_id=? AND principal_id=? AND active=1',[stored.context.team_id,auth.principal_id]);if(!membership)fail(403,'forbidden','created team access changed');return send(res,old.response_status,stored,requestId);}
119
+ const id=api.ids.next('team'),response={version:1,context:{server_id:api.serverId,team_id:id,principal_id:auth.principal_id},data:{id,name:body.name,revision:1}};await api.store.guardedTransaction([{sql:"SELECT 1 ok FROM credentials WHERE id=? AND principal_id=? AND kind='user' AND team_id IS NULL AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at>?)",params:[auth.credential_id,auth.principal_id,now],code:'authentication_required',status:401,message:'global user credential changed'}],[{sql:'INSERT INTO teams(id,name) VALUES (?,?)',params:[id,body.name]},{sql:"INSERT INTO memberships(team_id,principal_id,role) VALUES (?,?,'owner')",params:[id,auth.principal_id]},{sql:'INSERT INTO global_idempotency(principal_id,route,key,request_digest,response_status,response_json,expires_at) VALUES (?,?,?,?,?,?,?)',params:[auth.principal_id,route,key,requestDigest,201,JSON.stringify(response),now+86400000]},{sql:'INSERT INTO audit(team_id,id,actor_id,operation,target_id,outcome,created_at) VALUES (?,?,?,?,?,?,?)',params:[id,api.ids.next('audit'),auth.principal_id,route,id,'allowed',now]}]);res._sideEffectsCommitted=true;send(res,201,response,requestId);
120
+ }
121
+ async function createCredential(api,req,res,auth,requestId){
122
+ if(auth.kind!=='user')fail(403,'forbidden','only users may issue credentials');const body=await parseJson(req);exact(body,['kind','expires_in_ms']);const maxExpiry=body.kind==='service'?7776000000:2592000000;if(!['user','service'].includes(body.kind)||!Number.isSafeInteger(body.expires_in_ms)||body.expires_in_ms<1||body.expires_in_ms>maxExpiry)fail(400,'invalid_request','invalid credential request');
123
+ const id=api.ids.next('credential'),token=makeToken(id),now=api.clock.nowMs(),expiresAt=now+body.expires_in_ms,response=result(api,auth,{token,kind:body.kind,expires_at:expiresAt});await guardedMutation(api,res,auth,authGuards(auth,now),[{sql:'INSERT INTO credentials(id,token_hash,principal_id,kind,team_id,expires_at,created_at) VALUES (?,?,?,?,?,?,?)',params:[id,hashToken(token),auth.principal_id,body.kind,body.kind==='user'?null:auth.team_id,expiresAt,now]}],{status:201,body:response,targetId:id});send(res,201,response,requestId);
124
+ }
125
+ async function revokeCredential(api,req,res,auth,id,requestId){
126
+ if(!['user','service'].includes(auth.kind))fail(403,'forbidden','credential kind cannot use this operation');const credential=await api.store.one('SELECT * FROM credentials WHERE id=? AND revoked_at IS NULL',[id]);if(!credential)fail(404,'not_found','not found');const globalUser=credential.kind==='user'&&credential.team_id===null;if(globalUser?credential.principal_id!==auth.principal_id:credential.team_id!==auth.team_id||credential.principal_id!==auth.principal_id&&!isAdmin(auth))fail(404,'not_found','not found');
127
+ const now=api.clock.nowMs(),guards=[...authGuards(auth,now),{sql:"SELECT 1 ok FROM credentials c WHERE c.id=? AND c.revoked_at IS NULL AND ((c.kind='user' AND c.team_id IS NULL AND c.principal_id=?) OR (c.team_id=? AND (c.principal_id=? OR ?=1))) AND (c.id!=? OR c.kind!='user' OR c.team_id IS NOT NULL OR NOT EXISTS (SELECT 1 FROM memberships m WHERE m.principal_id=c.principal_id AND m.role='owner' AND m.active=1) OR EXISTS (SELECT 1 FROM credentials other WHERE other.principal_id=c.principal_id AND other.kind='user' AND other.team_id IS NULL AND other.id!=c.id AND other.revoked_at IS NULL AND (other.expires_at IS NULL OR other.expires_at>?)))",params:[id,auth.principal_id,auth.team_id,auth.principal_id,isAdmin(auth)?1:0,auth.credential_id,now],code:'conflict',status:409,message:'credential changed or would lock out owner'}];await guardedMutation(api,res,auth,guards,[{sql:'UPDATE credentials SET revoked_at=? WHERE id=?',params:[now,id]}],{status:204,body:null,targetId:id});send(res,204,null,requestId);
128
+ }
129
+ async function createInvitation(api,req,res,auth,requestId){if(!isAdmin(auth))fail(403,'forbidden','forbidden');const body=await parseJson(req);exact(body,['display_name','role','expires_in_ms']);if(!['admin','member','viewer'].includes(body.role)||!Number.isSafeInteger(body.expires_in_ms)||body.expires_in_ms<1||body.expires_in_ms>604800000)fail(400,'invalid_request','invalid invitation');const pid=api.ids.next('user'),iid=api.ids.next('invitation'),secret=`sci_${iid}.${randomBytes(32).toString('base64url')}`,now=api.clock.nowMs(),exp=now+body.expires_in_ms,response=result(api,auth,{id:iid,role:body.role,expires_at:exp,enrollment_code:secret});await guardedMutation(api,res,auth,authGuards(auth,now),[{sql:'INSERT INTO principals(id,kind,display_name) VALUES (?,?,?)',params:[pid,'user',body.display_name]},{sql:'INSERT INTO invitations(team_id,id,principal_id,secret_hash,role,expires_at) VALUES (?,?,?,?,?,?)',params:[auth.team_id,iid,pid,hashToken(secret),body.role,exp]}],{status:201,body:response,targetId:iid});send(res,201,response,requestId);}
130
+ async function acceptInvite(api,res,body,requestId){exact(body,['enrollment_code','access_token']);
131
+ // Accepted with an identity service's token, the new member is linked to it from the start.
132
+ const person=body.access_token===undefined?null:await identityOf(api,body.access_token);if(body.access_token!==undefined&&!person)fail(401,'authentication_required','the identity token did not verify');const digest=hashToken(body.enrollment_code??'');const inv=await api.store.one('SELECT i.*,p.display_name,t.name team_name,t.revision team_revision FROM invitations i JOIN principals p ON p.id=i.principal_id JOIN teams t ON t.id=i.team_id WHERE i.secret_hash=?',[digest]),now=api.clock.nowMs();if(!inv||inv.consumed_at!=null||inv.revoked_at!=null||inv.expires_at<=now)fail(401,'authentication_required','invalid invitation');const cid=api.ids.next('credential'),token=makeToken(cid);await api.store.guardedTransaction([{sql:'SELECT 1 ok FROM invitations WHERE id=? AND consumed_at IS NULL AND revoked_at IS NULL AND expires_at>?',params:[inv.id,now],code:'authentication_required',status:401,message:'invalid invitation'}],[{sql:'UPDATE invitations SET consumed_at=? WHERE id=?',params:[now,inv.id]},{sql:'INSERT INTO memberships(team_id,principal_id,role) VALUES (?,?,?)',params:[inv.team_id,inv.principal_id,inv.role]},...(person?[{sql:'INSERT INTO principal_identities(issuer,subject,principal_id,linked_at) VALUES (?,?,?,?)',params:[person.issuer,person.subject,inv.principal_id,now]}]:[]),{sql:'INSERT INTO credentials(id,token_hash,principal_id,kind,team_id,expires_at,created_at) VALUES (?,?,?,?,?,?,?)',params:[cid,hashToken(token),inv.principal_id,'user',null,now+2592000000,now]},{sql:'UPDATE teams SET auth_epoch=auth_epoch+1 WHERE id=?',params:[inv.team_id]}]);send(res,201,{principal:{id:inv.principal_id,kind:'user',display_name:inv.display_name},credential:{token,kind:'user',expires_at:now+2592000000},team:{id:inv.team_id,name:inv.team_name,revision:inv.team_revision}},requestId);}
133
+ async function removeMember(api,req,res,auth,pid,requestId){if(!isAdmin(auth))fail(403,'forbidden','forbidden');const target=await api.store.one('SELECT * FROM memberships WHERE team_id=? AND principal_id=? AND active=1',[auth.team_id,pid]);if(!target)fail(404,'not_found','not found');requireRevision(req,target.revision,'membership');if(target.role==='owner'&&auth.role!=='owner')fail(403,'forbidden','only owners may remove owners');const now=api.clock.nowMs(),guards=[...authGuards(auth,now),{sql:"SELECT 1 ok FROM memberships WHERE team_id=? AND principal_id=? AND active=1 AND revision=? AND (role!='owner' OR (SELECT count(*) FROM memberships WHERE team_id=? AND role='owner' AND active=1)>1)",params:[auth.team_id,pid,target.revision,auth.team_id],code:'conflict',status:409,message:'membership changed or last owner would be removed'}],statements=[{sql:'UPDATE memberships SET active=0,revision=revision+1 WHERE team_id=? AND principal_id=?',params:[auth.team_id,pid]},{sql:'UPDATE credentials SET revoked_at=? WHERE team_id=? AND principal_id=?',params:[now,auth.team_id,pid]},{sql:"UPDATE sources SET state='paused',revision=revision+1 WHERE team_id=? AND custodian_id=? AND state='active'",params:[auth.team_id,pid]},{sql:'UPDATE teams SET auth_epoch=auth_epoch+1 WHERE id=?',params:[auth.team_id]}];await guardedMutation(api,res,auth,guards,statements,{status:204,body:null,targetId:pid});api.events.publish(auth.team_id,{type:'authorization_changed',principal_id:pid});closeChannelsFor(api,auth.team_id,{principalId:pid,reason:'membership_removed'});send(res,204,null,requestId);}
134
+ /** Federation (P02): a verified identity token is a member's sign-in, answered with the same 30-day credential an invitation gives. */
135
+ async function identityLogin(api,res,body,requestId){
136
+ exact(body,['access_token','team_id']);if(typeof body.team_id!=='string')fail(400,'invalid_request','team_id is required');
137
+ const person=await identityOf(api,body.access_token);if(!person)fail(401,'authentication_required','the identity token did not verify');
138
+ const row=await api.store.one('SELECT p.id principal_id,p.display_name,t.id team_id,t.name team_name,t.revision team_revision FROM principal_identities i JOIN principals p ON p.id=i.principal_id JOIN memberships m ON m.principal_id=p.id AND m.active=1 JOIN teams t ON t.id=m.team_id WHERE i.issuer=? AND i.subject=? AND (t.id=? OR t.name=?)',[person.issuer,person.subject,body.team_id,body.team_id]);
139
+ if(!row)fail(401,'authentication_required','no member of this team is linked to that identity; accept an invitation with it, or link it while signed in');
140
+ const now=api.clock.nowMs(),cid=api.ids.next('credential'),token=makeToken(cid);
141
+ await api.store.transaction([{sql:'INSERT INTO credentials(id,token_hash,principal_id,kind,team_id,expires_at,created_at) VALUES (?,?,?,?,?,?,?)',params:[cid,hashToken(token),row.principal_id,'user',null,now+2592000000,now]}]);
142
+ send(res,201,{principal:{id:row.principal_id,kind:'user',display_name:row.display_name},credential:{token,kind:'user',expires_at:now+2592000000},team:{id:row.team_id,name:row.team_name,revision:row.team_revision}},requestId);
143
+ }
144
+ /** A signed-in member links an identity service's subject to themselves, once per issuer. */
145
+ async function linkIdentity(api,req,res,auth,requestId){
146
+ if(auth.kind!=='user')fail(403,'forbidden','only a user links an identity');
147
+ const body=await parseJson(req);exact(body,['access_token']);
148
+ const person=await identityOf(api,body.access_token);if(!person)fail(401,'authentication_required','the identity token did not verify');
149
+ const taken=await api.store.one('SELECT principal_id FROM principal_identities WHERE issuer=? AND subject=?',[person.issuer,person.subject]);
150
+ if(taken&&taken.principal_id!==auth.principal_id)fail(409,'conflict','that identity is linked to another member');
151
+ if(!taken)await api.store.transaction([{sql:'DELETE FROM principal_identities WHERE principal_id=? AND issuer=?',params:[auth.principal_id,person.issuer]},{sql:'INSERT INTO principal_identities(issuer,subject,principal_id,linked_at) VALUES (?,?,?,?)',params:[person.issuer,person.subject,auth.principal_id,api.clock.nowMs()]}]);
152
+ send(res,200,{version:1,data:{principal_id:auth.principal_id,issuer:person.issuer,subject:person.subject}},requestId);
153
+ }
154
+ const membershipJson=row=>({team_id:row.team_id,principal:{id:row.principal_id,kind:row.kind,display_name:row.display_name},role:row.role,revision:row.revision});
155
+ async function collectionPage(api,auth,url,name,items){
156
+ for(const key of url.searchParams.keys())if(!['limit','cursor'].includes(key))fail(400,'invalid_request',`unknown query field ${key}`);
157
+ const limit=url.searchParams.has('limit')?Number(url.searchParams.get('limit')):50;if(!Number.isSafeInteger(limit)||limit<1||limit>100)fail(400,'invalid_request','limit must be 1..100');const normalized={collection:name,limit};let remaining=items;
158
+ if(url.searchParams.has('cursor')){const cursor=await api.store.one("SELECT * FROM cursors WHERE id=? AND team_id=? AND principal_id=? AND kind='list'",[cursorHash(url.searchParams.get('cursor')),auth.team_id,auth.principal_id]),team=await api.store.one('SELECT auth_epoch FROM teams WHERE id=?',[auth.team_id]);if(!cursor||cursor.expires_at<=api.clock.nowMs()||cursor.auth_epoch!==team.auth_epoch||canonicalJson(JSON.parse(cursor.query_json))!==canonicalJson(normalized))fail(409,'resync_required','collection cursor expired or authorization changed');remaining=JSON.parse(cursor.snapshot_json);}
159
+ const page=remaining.slice(0,limit),rest=remaining.slice(limit);let next=null;if(rest.length){next=cursorToken();const team=await api.store.one('SELECT auth_epoch,journal_revision FROM teams WHERE id=?',[auth.team_id]);await api.store.exec('INSERT INTO cursors(id,team_id,principal_id,kind,query_json,auth_epoch,revision,snapshot_json,expires_at) VALUES (?,?,?,?,?,?,?,?,?)',[cursorHash(next),auth.team_id,auth.principal_id,'list',JSON.stringify(normalized),team.auth_epoch,team.journal_revision,JSON.stringify(rest),api.clock.nowMs()+900000]);}return{version:1,context:{server_id:api.serverId,team_id:auth.team_id,principal_id:auth.principal_id},items:page,next_cursor:next};
160
+ }
161
+ async function listMembers(api,res,auth,url,requestId){const rows=auth.role==='viewer'?await api.store.query('SELECT m.*,p.kind,p.display_name FROM memberships m JOIN principals p ON p.id=m.principal_id WHERE m.team_id=? AND m.principal_id=? AND m.active=1',[auth.team_id,auth.principal_id]):await api.store.query('SELECT m.*,p.kind,p.display_name FROM memberships m JOIN principals p ON p.id=m.principal_id WHERE m.team_id=? AND m.active=1 ORDER BY p.display_name,m.principal_id',[auth.team_id]);send(res,200,await collectionPage(api,auth,url,'members',rows.map(membershipJson)),requestId);}
162
+ async function updateMember(api,req,res,auth,principalId,requestId){
163
+ if(!isAdmin(auth))fail(403,'forbidden','forbidden');
164
+ const body=await parseJson(req);exact(body,['role']);if(!['owner','admin','member','viewer'].includes(body.role))fail(400,'invalid_request','invalid team role');
165
+ const target=await api.store.one('SELECT m.*,p.kind,p.display_name FROM memberships m JOIN principals p ON p.id=m.principal_id WHERE m.team_id=? AND m.principal_id=? AND m.active=1',[auth.team_id,principalId]);if(!target)fail(404,'not_found','not found');
166
+ requireRevision(req,target.revision,'membership');
167
+ if((target.role==='owner'||body.role==='owner')&&auth.role!=='owner')fail(403,'forbidden','only owners may change owner role');
168
+ if(auth.principal_id===principalId&&auth.role==='admin'&&body.role==='owner')fail(403,'forbidden','administrators cannot promote themselves');
169
+ const now=api.clock.nowMs(),response=result(api,auth,membershipJson({...target,role:body.role,revision:target.revision+1})),guards=[...authGuards(auth,now),{sql:"SELECT 1 ok FROM memberships WHERE team_id=? AND principal_id=? AND active=1 AND revision=? AND (role!='owner' OR ?='owner' OR (SELECT count(*) FROM memberships WHERE team_id=? AND role='owner' AND active=1)>1)",params:[auth.team_id,principalId,target.revision,body.role,auth.team_id],code:'conflict',status:409,message:'membership changed or last owner would be removed'}];
170
+ await guardedMutation(api,res,auth,guards,[{sql:'UPDATE memberships SET role=?,revision=revision+1 WHERE team_id=? AND principal_id=?',params:[body.role,auth.team_id,principalId]},{sql:'UPDATE teams SET auth_epoch=auth_epoch+1 WHERE id=?',params:[auth.team_id]}],{status:200,body:response,targetId:principalId});api.events.publish(auth.team_id,{type:'authorization_changed',principal_id:principalId});send(res,200,response,requestId);
171
+ }
172
+ async function groupRoute(api,req,res,auth,groupId,principalId,url,requestId){
173
+ if(!isAdmin(auth))fail(403,'forbidden','forbidden');
174
+ if(req.method==='GET'){const [groups,members]=await api.store.snapshot([{kind:'query',sql:'SELECT * FROM groups_table WHERE team_id=? ORDER BY name,id',params:[auth.team_id]},{kind:'query',sql:'SELECT group_id,principal_id FROM group_members WHERE team_id=? ORDER BY principal_id',params:[auth.team_id]}]),items=groups.map(group=>({id:group.id,name:group.name,revision:group.revision,member_ids:members.filter(member=>member.group_id===group.id).map(member=>member.principal_id)}));return send(res,200,await collectionPage(api,auth,url,'groups',items),requestId);}
175
+ const now=api.clock.nowMs(),guards=authGuards(auth,now),statements=[];let status=204,response=null,target=groupId;
176
+ if(req.method==='POST'){const body=await parseJson(req);exact(body,['name']);if(typeof body.name!=='string'||!body.name.trim())fail(400,'invalid_request','group name is required');groupId=api.ids.next('group');target=groupId;status=201;response=result(api,auth,{id:groupId,name:body.name,revision:1,member_ids:[]});statements.push({sql:'INSERT INTO groups_table(team_id,id,name) VALUES (?,?,?)',params:[auth.team_id,groupId,body.name]});}
177
+ else if(principalId){const group=await api.store.one('SELECT revision FROM groups_table WHERE team_id=? AND id=?',[auth.team_id,groupId]);if(!group)fail(404,'not_found','group not found');requireRevision(req,group.revision,'group');guards.push({sql:'SELECT 1 ok FROM groups_table WHERE team_id=? AND id=? AND revision=?',params:[auth.team_id,groupId,group.revision],code:'revision_mismatch',status:409,message:'group revision changed'},{sql:'SELECT 1 ok FROM memberships WHERE team_id=? AND principal_id=? AND active=1',params:[auth.team_id,principalId],code:'not_found',status:404,message:'member not found'});statements.push(req.method==='PUT'?{sql:'INSERT OR IGNORE INTO group_members(team_id,group_id,principal_id) VALUES (?,?,?)',params:[auth.team_id,groupId,principalId]}:{sql:'DELETE FROM group_members WHERE team_id=? AND group_id=? AND principal_id=?',params:[auth.team_id,groupId,principalId]},{sql:'UPDATE groups_table SET revision=revision+1 WHERE team_id=? AND id=?',params:[auth.team_id,groupId]});}
178
+ else{const group=await api.store.one('SELECT revision FROM groups_table WHERE team_id=? AND id=?',[auth.team_id,groupId]);if(!group)fail(404,'not_found','group not found');requireRevision(req,group.revision,'group');guards.push({sql:'SELECT 1 ok FROM groups_table WHERE team_id=? AND id=? AND revision=?',params:[auth.team_id,groupId,group.revision],code:'revision_mismatch',status:409,message:'group revision changed'});statements.push({sql:'DELETE FROM session_grants WHERE team_id=? AND subject_kind=\'group\' AND subject_id=?',params:[auth.team_id,groupId]},{sql:'DELETE FROM group_members WHERE team_id=? AND group_id=?',params:[auth.team_id,groupId]},{sql:'DELETE FROM groups_table WHERE team_id=? AND id=?',params:[auth.team_id,groupId]});}
179
+ statements.push({sql:'UPDATE teams SET auth_epoch=auth_epoch+1 WHERE id=?',params:[auth.team_id]});await guardedMutation(api,res,auth,guards,statements,{status,body:response,targetId:target});api.events.publish(auth.team_id,{type:'authorization_changed',group_id:groupId});if(req.method==='DELETE')closeChannelsFor(api,auth.team_id,{groupId,principalId:principalId??null,reason:'group_changed'});send(res,status,response,requestId);
180
+ }
181
+ async function enroll(api,req,res,auth,requestId){if(auth.role==='viewer')fail(403,'forbidden','viewers cannot enroll machines');const b=await parseJson(req);exact(b,['name','enrollment_key']);if(!b.name||!b.enrollment_key)fail(400,'invalid_request','name and enrollment_key required');let machine=await api.store.one('SELECT m.*,s.id source_id,s.name source_name,s.kind source_kind,s.state source_state,s.visibility,s.revision source_revision,s.last_received_at,s.decoder,s.counters_json,s.last_error FROM machines m JOIN sources s ON s.team_id=m.team_id AND s.machine_id=m.id WHERE m.team_id=? AND m.custodian_id=? AND m.enrollment_key=?',[auth.team_id,auth.principal_id,b.enrollment_key]);if(machine){return send(res,200,result(api,auth,{machine:machineJson(machine,api.clock.nowMs()),source:sourceJson({id:machine.source_id,name:machine.source_name,kind:machine.source_kind,state:machine.source_state,machine_id:machine.id,custodian_id:machine.custodian_id,visibility:machine.visibility,revision:machine.source_revision,last_received_at:machine.last_received_at,decoder:machine.decoder,counters_json:machine.counters_json,last_error:machine.last_error}),credential:null}),requestId);}const mid=api.ids.next('machine'),sid=api.ids.next('source'),cid=api.ids.next('credential'),token=makeToken(cid),now=api.clock.nowMs(),data={machine:{id:mid,name:b.name,custodian_id:auth.principal_id,enrollment:'active',connection:'offline',last_seen_at:null,incarnation_id:null,revision:1},source:{id:sid,name:`${b.name} connector`,kind:'connector',state:'active',machine_id:mid,custodian_id:auth.principal_id,default_visibility:'restricted',revision:1,last_received_at:null,decoder:null},credential:{token,kind:'machine',expires_at:now+2592000000}},response=result(api,auth,data);await guardedMutation(api,res,auth,[...authGuards(auth,now),{sql:'SELECT 1 ok WHERE NOT EXISTS (SELECT 1 FROM sources WHERE team_id=? AND name=?)',params:[auth.team_id,`${b.name} connector`],code:'conflict',status:409,message:`a machine named ${b.name} is already enrolled in this team; enroll this one under another --name`}],[{sql:'INSERT INTO machines(team_id,id,name,custodian_id,enrollment_key) VALUES (?,?,?,?,?)',params:[auth.team_id,mid,b.name,auth.principal_id,b.enrollment_key]},{sql:"INSERT INTO sources(team_id,id,name,kind,machine_id,custodian_id,visibility,state) VALUES (?,?,?,?,?,?,?,'active')",params:[auth.team_id,sid,`${b.name} connector`,'connector',mid,auth.principal_id,'restricted']},{sql:'INSERT INTO credentials(id,token_hash,principal_id,kind,team_id,source_id,machine_id,expires_at,created_at) VALUES (?,?,?,?,?,?,?,?,?)',params:[cid,hashToken(token),auth.principal_id,'machine',auth.team_id,sid,mid,now+2592000000,now]}],{status:201,body:response,targetId:mid});send(res,201,response,requestId);}
182
+ const machineJson=(m,now)=>({id:m.id,name:m.name,custodian_id:m.custodian_id,enrollment:m.revoked_at==null?'active':'revoked',connection:m.last_seen_at!=null&&m.last_seen_at+45000>now?'online':'offline',last_seen_at:m.last_seen_at,incarnation_id:m.incarnation_id,host_key_id:m.host_key_id??null,revision:m.revision});
183
+ async function machineAdminRoute(api,req,res,auth,machineId,roots,requestId){
184
+ // Custodians and admins see their machines; a subject holding a live machine grant sees that machine (roots stay custodial).
185
+ const custody=roots?'(m.custodian_id=? OR ?=1)':'(m.custodian_id=? OR ?=1 OR EXISTS (SELECT 1 FROM machine_grants g WHERE g.team_id=m.team_id AND g.machine_id=m.id AND g.expires_at>? AND g.subject_id IN (SELECT value FROM json_each(?))))',params=roots?[auth.team_id,auth.principal_id,isAdmin(auth)?1:0]:[auth.team_id,auth.principal_id,isAdmin(auth)?1:0,api.clock.nowMs(),JSON.stringify([auth.principal_id,...(auth.group_ids??[])])];
186
+ if(machineId){const machine=await api.store.one(`SELECT m.* FROM machines m WHERE m.team_id=? AND ${custody} AND m.id=?`,[...params,machineId]);if(!machine)fail(404,'not_found','not found');if(roots){const rows=await api.store.query('SELECT value_json FROM machine_roots WHERE team_id=? AND machine_id=? ORDER BY id',[auth.team_id,machineId]);return send(res,200,{version:1,context:{server_id:api.serverId,team_id:auth.team_id,principal_id:auth.principal_id},items:rows.map(row=>JSON.parse(row.value_json)),next_cursor:null},requestId);}return send(res,200,result(api,auth,machineJson(machine,api.clock.nowMs())),requestId,{ETag:`"${machine.revision}"`});}
187
+ const rows=await api.store.query(`SELECT m.* FROM machines m WHERE m.team_id=? AND ${custody} ORDER BY m.name,m.id`,params);send(res,200,{version:1,context:{server_id:api.serverId,team_id:auth.team_id,principal_id:auth.principal_id},items:rows.map(row=>machineJson(row,api.clock.nowMs())),next_cursor:null},requestId);
188
+ }
189
+ async function revokeMachine(api,req,res,auth,machineId,requestId){
190
+ const machine=await api.store.one('SELECT * FROM machines WHERE team_id=? AND id=?',[auth.team_id,machineId]);if(!machine)fail(404,'not_found','not found');if(machine.custodian_id!==auth.principal_id&&!isAdmin(auth))fail(403,'forbidden','forbidden');requireRevision(req,machine.revision,'machine');const now=api.clock.nowMs(),guards=[...authGuards(auth,now),{sql:'SELECT 1 ok FROM machines WHERE team_id=? AND id=? AND revision=? AND revoked_at IS NULL AND (custodian_id=? OR ?=1)',params:[auth.team_id,machineId,machine.revision,auth.principal_id,isAdmin(auth)?1:0],code:'revision_mismatch',status:409,message:'machine changed'}],statements=[{sql:'UPDATE machines SET revoked_at=?,revision=revision+1 WHERE team_id=? AND id=?',params:[now,auth.team_id,machineId]},{sql:'UPDATE credentials SET revoked_at=? WHERE team_id=? AND machine_id=? AND revoked_at IS NULL',params:[now,auth.team_id,machineId]},{sql:"UPDATE sources SET state='revoked',revision=revision+1 WHERE team_id=? AND machine_id=?",params:[auth.team_id,machineId]},{sql:"UPDATE sync_rules SET state='removed',revision=revision+1 WHERE team_id=? AND machine_id=? AND state!='removed'",params:[auth.team_id,machineId]},{sql:"UPDATE attachments SET state='revoked',closed_at=? WHERE team_id=? AND machine_id=? AND state IN ('pending','active')",params:[now,auth.team_id,machineId]},{sql:"UPDATE operations SET state='unknown' WHERE team_id=? AND id IN (SELECT operation_id FROM native_effects WHERE team_id=? AND machine_id=? AND state IN ('pending','running'))",params:[auth.team_id,auth.team_id,machineId]},{sql:"UPDATE native_effects SET state='unknown',result_json=? WHERE team_id=? AND machine_id=? AND state IN ('pending','running')",params:[JSON.stringify({state:'unknown',error:null}),auth.team_id,machineId]},{sql:'UPDATE teams SET auth_epoch=auth_epoch+1 WHERE id=?',params:[auth.team_id]}];await guardedMutation(api,res,auth,guards,statements,{status:204,body:null,targetId:machineId});api.events.publish(auth.team_id,{type:'authorization_changed',machine_id:machineId});closeMachineChannels(api,auth.team_id,machineId,{reason:'machine_revoked'});send(res,204,null,requestId);
191
+ }
192
+ async function heartbeat(api,req,res,auth,machineId,requestId){if(auth.kind!=='machine'||auth.machine_id!==machineId)fail(403,'forbidden','exact machine credential required');const machine=await api.store.one('SELECT * FROM machines WHERE team_id=? AND id=?',[auth.team_id,machineId]);if(!machine||machine.revoked_at!=null)fail(403,'forbidden','machine enrollment is not active');const b=await parseJson(req);exact(b,['incarnation_id','sequence','bindings','roots','host_key_id']);if(b.host_key_id!==undefined&&(typeof b.host_key_id!=='string'||!/^[A-Za-z0-9_-]{16,128}$/.test(b.host_key_id)))fail(400,'invalid_request','invalid host_key_id');if(typeof b.incarnation_id!=='string'||!Number.isSafeInteger(b.sequence)||b.sequence<1||!Array.isArray(b.bindings)||!Array.isArray(b.roots))fail(400,'invalid_request','invalid heartbeat');if(machine.incarnation_id===b.incarnation_id&&b.sequence<=machine.heartbeat_sequence)return send(res,200,result(api,auth,machineJson(machine,api.clock.nowMs())),requestId);const now=api.clock.nowMs(),newIncarnation=machine.incarnation_id!==null&&machine.incarnation_id!==b.incarnation_id,statements=[{sql:'UPDATE machines SET incarnation_id=?,heartbeat_sequence=?,last_seen_at=?,host_key_id=COALESCE(?,host_key_id),revision=revision+1 WHERE team_id=? AND id=?',params:[b.incarnation_id,b.sequence,now,b.host_key_id??null,auth.team_id,machineId]},{sql:'DELETE FROM machine_roots WHERE team_id=? AND machine_id=?',params:[auth.team_id,machineId]}];if(newIncarnation)statements.push({sql:"UPDATE attachments SET state='closed',closed_at=? WHERE team_id=? AND machine_id=? AND state IN ('pending','active')",params:[now,auth.team_id,machineId]},{sql:"UPDATE operations SET state='unknown' WHERE team_id=? AND id IN (SELECT operation_id FROM native_effects WHERE team_id=? AND machine_id=? AND state IN ('pending','running'))",params:[auth.team_id,auth.team_id,machineId]},{sql:"UPDATE native_effects SET state='unknown',result_json=? WHERE team_id=? AND machine_id=? AND state IN ('pending','running')",params:[JSON.stringify({state:'unknown',error:null}),auth.team_id,machineId]});statements.push({sql:'DELETE FROM bindings WHERE team_id=? AND machine_id=? AND id NOT IN (SELECT binding_id FROM attachments WHERE team_id=? AND machine_id=?) AND id NOT IN (SELECT binding_id FROM native_effects WHERE team_id=? AND machine_id=? AND binding_id IS NOT NULL)',params:[auth.team_id,machineId,auth.team_id,machineId,auth.team_id,machineId]});for(const root of b.roots){exact(root,['id','machine_id','name','harnesses'],'root');if(root.machine_id!==machineId)fail(400,'invalid_request','root machine mismatch');statements.push({sql:'INSERT INTO machine_roots(team_id,machine_id,id,value_json) VALUES (?,?,?,?)',params:[auth.team_id,machineId,root.id,JSON.stringify(root)]});}for(const binding of b.bindings)statements.push({sql:'INSERT INTO bindings(team_id,id,machine_id,session_id,incarnation_id,descriptor_json,observed_at) SELECT ?,?,?,o.session_id,?,?,? FROM origins o WHERE o.team_id=? AND o.source_id=? AND o.harness=? AND o.native_id=? AND o.deleted_at IS NULL ON CONFLICT(team_id,id) DO UPDATE SET machine_id=excluded.machine_id,session_id=excluded.session_id,incarnation_id=excluded.incarnation_id,descriptor_json=excluded.descriptor_json,observed_at=excluded.observed_at',params:[auth.team_id,binding.id,machineId,b.incarnation_id,JSON.stringify(binding),now,auth.team_id,auth.source_id,binding.harness,binding.native_session_id]});const updated={...machine,incarnation_id:b.incarnation_id,heartbeat_sequence:b.sequence,last_seen_at:now,revision:machine.revision+1},response=result(api,auth,machineJson(updated,now)),guards=[{sql:"SELECT 1 ok FROM credentials c JOIN machines m ON m.team_id=c.team_id AND m.id=c.machine_id JOIN sources s ON s.team_id=c.team_id AND s.id=c.source_id JOIN memberships mem ON mem.team_id=m.team_id AND mem.principal_id=m.custodian_id WHERE c.id=? AND c.kind='machine' AND c.machine_id=? AND c.revoked_at IS NULL AND (c.expires_at IS NULL OR c.expires_at>?) AND m.revoked_at IS NULL AND mem.active=1 AND s.state!='revoked' AND (m.incarnation_id IS NULL OR m.incarnation_id!=? OR m.heartbeat_sequence<?)",params:[auth.credential_id,machineId,now,b.incarnation_id,b.sequence],code:'authentication_required',status:401,message:'machine authorization or heartbeat sequence changed'}];await guardedMutation(api,res,auth,guards,statements,{status:200,body:response,targetId:machineId});send(res,200,response,requestId);}
193
+ async function machineConfiguration(api,res,auth,machineId,requestId){
194
+ if(auth.kind!=='machine'||auth.machine_id!==machineId)fail(auth.kind==='machine'?404:403,auth.kind==='machine'?'not_found':'forbidden','not found');
195
+ const [machine,rules]=await api.store.snapshot([
196
+ {kind:'one',sql:'SELECT m.*,s.id source_id,s.name source_name,s.kind source_kind,s.state source_state,s.visibility,s.revision source_revision,s.last_received_at,s.decoder,s.counters_json,s.last_error,t.auth_epoch FROM machines m JOIN sources s ON s.team_id=m.team_id AND s.machine_id=m.id JOIN teams t ON t.id=m.team_id JOIN memberships mem ON mem.team_id=m.team_id AND mem.principal_id=m.custodian_id AND mem.active=1 WHERE m.team_id=? AND m.id=? AND m.revoked_at IS NULL',params:[auth.team_id,machineId]},
197
+ {kind:'query',sql:'SELECT r.* FROM sync_rules r JOIN machines m ON m.team_id=r.team_id AND m.id=r.machine_id JOIN sources s ON s.team_id=r.team_id AND s.id=r.source_id WHERE r.team_id=? AND r.machine_id=? AND m.revoked_at IS NULL AND s.machine_id=m.id ORDER BY r.id',params:[auth.team_id,machineId]},
198
+ ]);
199
+ if(!machine)fail(404,'not_found','not found');
200
+ if(machine.source_state==='revoked')fail(403,'source_revoked','source revoked');
201
+ send(res,200,result(api,auth,{machine:machineJson(machine,api.clock.nowMs()),source:sourceJson({id:machine.source_id,name:machine.source_name,kind:machine.source_kind,state:machine.source_state,machine_id:machineId,custodian_id:machine.custodian_id,visibility:machine.visibility,revision:machine.source_revision,last_received_at:machine.last_received_at,decoder:machine.decoder,counters_json:machine.counters_json,last_error:machine.last_error}),rules:rules.map(syncRuleJson),custodian_id:machine.custodian_id,machine_grants:await machineGrantRows(api,auth.team_id,machineId),authorization_revision:machine.auth_epoch}),requestId);
202
+ }
203
+ const syncRuleJson=r=>({id:r.id,machine_id:r.machine_id,source_id:r.source_id,selector:JSON.parse(r.selector_json),visibility:r.visibility,backfill:JSON.parse(r.backfill_json),state:r.state,revision:r.revision,capture_after:r.capture_after});
204
+ async function createSource(api,req,res,auth,requestId){if(!isAdmin(auth))fail(403,'forbidden','forbidden');const b=await parseJson(req);exact(b,['name','kind','custodian_id','visibility']);if(b.kind!=='otlp'||!['team','restricted'].includes(b.visibility))fail(400,'invalid_request','only otlp sources may be created');const id=api.ids.next('source'),now=api.clock.nowMs(),response=result(api,auth,{id,name:b.name,kind:'otlp',state:'active',machine_id:null,custodian_id:b.custodian_id,default_visibility:b.visibility,revision:1,last_received_at:null,decoder:null}),guards=[...authGuards(auth,now),{sql:'SELECT 1 ok FROM memberships WHERE team_id=? AND principal_id=? AND active=1',params:[auth.team_id,b.custodian_id],code:'invalid_request',status:400,message:'source custodian must be active'}];await guardedMutation(api,res,auth,guards,[{sql:"INSERT INTO sources(team_id,id,name,kind,custodian_id,visibility,state) VALUES (?,?,?,?,?,?,'active')",params:[auth.team_id,id,b.name,'otlp',b.custodian_id,b.visibility]}],{status:201,body:response,targetId:id});send(res,201,response,requestId);}
205
+ async function listSources(api,res,auth,url,requestId){const rows=await api.store.query('SELECT * FROM sources WHERE team_id=? AND (custodian_id=? OR ?=1) ORDER BY name',[auth.team_id,auth.principal_id,isAdmin(auth)?1:0]);send(res,200,await collectionPage(api,auth,url,'sources',rows.map(sourceJson)),requestId);}
206
+ async function getSource(api,res,auth,id,requestId){const row=await api.store.one('SELECT * FROM sources WHERE team_id=? AND id=? AND (custodian_id=? OR ?=1)',[auth.team_id,id,auth.principal_id,isAdmin(auth)?1:0]);if(!row)fail(404,'not_found','not found');send(res,200,result(api,auth,sourceJson(row)),requestId,{ETag:`"${row.revision}"`});}
207
+ const sourceJson=s=>({id:s.id,name:s.name,kind:s.kind,state:s.state,machine_id:s.machine_id,custodian_id:s.custodian_id,default_visibility:s.visibility,revision:s.revision,last_received_at:s.last_received_at,decoder:s.decoder,counts:JSON.parse(s.counters_json??'{}'),last_error:s.last_error});
208
+ async function patchSource(api,req,res,auth,id,requestId){const s=await api.store.one('SELECT * FROM sources WHERE team_id=? AND id=?',[auth.team_id,id]);if(!s)fail(404,'not_found','not found');if(s.custodian_id!==auth.principal_id&&!isAdmin(auth))fail(403,'forbidden','forbidden');requireRevision(req,s.revision,'source');const b=await parseJson(req);exact(b,['state','default_visibility','custodian_id']);if(!Object.keys(b).length)fail(400,'invalid_request','at least one field is required');if(s.state==='revoked')fail(403,'source_revoked','source revoked');if(b.custodian_id!==undefined&&!isAdmin(auth))fail(403,'forbidden','only administrators may reassign custody');const state=b.custodian_id!==undefined?'paused':b.state??s.state,vis=b.default_visibility??s.visibility,cust=b.custodian_id??s.custodian_id;if(!['active','paused','revoked'].includes(state)||!['team','restricted'].includes(vis))fail(400,'invalid_request','invalid source update');const now=api.clock.nowMs(),response=result(api,auth,sourceJson({...s,state,visibility:vis,custodian_id:cust,revision:s.revision+1})),guards=[...authGuards(auth,now),{sql:'SELECT 1 ok FROM sources WHERE team_id=? AND id=? AND revision=? AND state!=\'revoked\' AND (custodian_id=? OR ?=1)',params:[auth.team_id,id,s.revision,auth.principal_id,isAdmin(auth)?1:0],code:'revision_mismatch',status:409,message:'source changed'}];if(b.custodian_id!==undefined)guards.push({sql:'SELECT 1 ok FROM memberships WHERE team_id=? AND principal_id=? AND active=1',params:[auth.team_id,cust],code:'invalid_request',status:400,message:'source custodian must be active'});await guardedMutation(api,res,auth,guards,[{sql:'UPDATE sources SET state=?,visibility=?,custodian_id=?,revision=revision+1 WHERE team_id=? AND id=?',params:[state,vis,cust,auth.team_id,id]},...(b.custodian_id!==undefined?[{sql:'UPDATE credentials SET revoked_at=? WHERE team_id=? AND source_id=?',params:[now,auth.team_id,id]}]:[])],{status:200,body:response,targetId:id});send(res,200,response,requestId,{ETag:`"${s.revision+1}"`});}
209
+ async function sourceCredential(api,req,res,auth,id,requestId){const s=await api.store.one('SELECT * FROM sources WHERE team_id=? AND id=?',[auth.team_id,id]);if(!s)fail(404,'not_found','not found');if(s.custodian_id!==auth.principal_id&&!isAdmin(auth))fail(403,'forbidden','forbidden');const b=await parseJson(req);exact(b,['rotate']);if(typeof b.rotate!=='boolean')fail(400,'invalid_request','rotate must be boolean');const cid=api.ids.next('credential'),token=makeToken(cid),kind=s.kind==='connector'?'machine':'ingest',now=api.clock.nowMs(),response=result(api,auth,{token,kind,expires_at:now+2592000000}),guards=[...authGuards(auth,now),{sql:"SELECT 1 ok FROM sources s JOIN memberships m ON m.team_id=s.team_id AND m.principal_id=s.custodian_id LEFT JOIN machines mach ON mach.team_id=s.team_id AND mach.id=s.machine_id WHERE s.team_id=? AND s.id=? AND s.state!='revoked' AND m.active=1 AND (s.custodian_id=? OR ?=1) AND (s.kind!='connector' OR mach.revoked_at IS NULL)",params:[auth.team_id,id,auth.principal_id,isAdmin(auth)?1:0],code:'forbidden',status:403,message:'source authorization changed'}],statements=[];if(b.rotate)statements.push({sql:'UPDATE credentials SET revoked_at=? WHERE team_id=? AND source_id=? AND revoked_at IS NULL',params:[now,auth.team_id,id]});statements.push({sql:'INSERT INTO credentials(id,token_hash,principal_id,kind,team_id,source_id,machine_id,expires_at,created_at) VALUES (?,?,?,?,?,?,?,?,?)',params:[cid,hashToken(token),s.custodian_id,kind,auth.team_id,id,s.machine_id,now+2592000000,now]});await guardedMutation(api,res,auth,guards,statements,{status:201,body:response,targetId:id});send(res,201,response,requestId);}
210
+
211
+ export { fail, parseJson, result, send, guardedMutation, requireRevision, checkRate, grants, syncRuleJson, authGuards };
@@ -0,0 +1,15 @@
1
+ // Federation (protocol P02): a person signed in with an identity service
2
+ // (Volter, https://id.volter.ai) proves who they are with an access token
3
+ // whose audience is this server's public origin, verified from the issuer's
4
+ // published keys with no call per request. The principal is whoever that
5
+ // issuer and subject are linked to; the id never changes.
6
+ import { verifyIdentityToken } from '@volter/identity';
7
+
8
+ /** The issuer and subject a token names, or null when it does not verify or no issuer is configured. */
9
+ export async function identityOf(api, token) {
10
+ if (!api.identityIssuer || !api.publicOrigin || typeof token !== 'string' || !token) return null;
11
+ try {
12
+ const person = await verifyIdentityToken(token, { issuer: api.identityIssuer, audience: api.publicOrigin });
13
+ return { issuer: api.identityIssuer, subject: person.subject, email: person.email ?? null, name: person.name ?? null };
14
+ } catch { return null; }
15
+ }