@volter/teams 0.1.89 → 0.2.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.
- package/README.md +5 -0
- package/agent-profile.mjs +15 -0
- package/client.mjs +29 -0
- package/context.mjs +34 -0
- package/event-client.mjs +8 -0
- package/http-client.d.ts +149 -0
- package/http-client.mjs +232 -0
- package/identity-record.d.ts +21 -0
- package/lines.mjs +19 -0
- package/machine-door.mjs +36 -0
- package/package.json +16 -4
- package/sender-identity.mjs +55 -0
- package/server/agent-authority.mjs +21 -0
- package/server/agent-sessions.mjs +226 -0
- package/server/agents.mjs +286 -0
- package/server/apps.mjs +35 -3
- package/server/auth.mjs +9 -1
- package/server/core.mjs +4 -3
- package/server/event-catalogue.mjs +13 -0
- package/server/http.mjs +81 -21
- package/server/identity-record.mjs +152 -0
- package/server/identity.mjs +2 -1
- package/server/machine-events.mjs +1 -1
- package/server/machine-link.mjs +107 -11
- package/server/member-mirror.mjs +147 -0
- package/server/migrations/015.mjs +72 -0
- package/server/migrations/016.mjs +28 -0
- package/server/migrations/017.mjs +60 -0
- package/server/migrations/018.mjs +82 -0
- package/server/migrations/019.mjs +20 -0
- package/server/migrations/020.mjs +9 -0
- package/server/migrations/index.mjs +7 -1
- package/server/permissions.mjs +121 -0
- package/server/published-events.mjs +145 -0
- package/server/session-snapshot.mjs +28 -0
- package/viewer/session-view.css +1 -102
- package/viewer/viewer.mjs +26 -5
- package/server/identity-orgs.mjs +0 -54
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
// Teams owns agent identity. Orchestrator-owned assignments and profile values are references here.
|
|
2
|
+
import { AGENT_ACTIVE } from './agent-authority.mjs';
|
|
3
|
+
import { isAdmin, makeToken, hashToken, APP_LINEAGE_REVOKED } from './auth.mjs';
|
|
4
|
+
import { exact } from './request-json.mjs';
|
|
5
|
+
import { closeChannelsFor, closeActorChannels } from './machine-link.mjs';
|
|
6
|
+
import { nativeSessionSnapshot } from './session-snapshot.mjs';
|
|
7
|
+
const fail=(status,code,message)=>{throw Object.assign(new Error(message),{status,code});};
|
|
8
|
+
const parse=JSON.parse;
|
|
9
|
+
const stmt=(sql,...params)=>({sql,params});
|
|
10
|
+
const guard=(sql,params,message='agent authority changed')=>({sql,params,status:403,code:'forbidden',message});
|
|
11
|
+
const text=(value,label,max=256)=>{
|
|
12
|
+
if(typeof value!=='string'||!value.trim()||value.length>max||/[\x00-\x1f]/.test(value))fail(400,'invalid_request',`${label} must be a nonempty string`);
|
|
13
|
+
return value;
|
|
14
|
+
};
|
|
15
|
+
const kind=value=>({user:'person',service:'automation',agent:'agent'}[value]);
|
|
16
|
+
const liveGuards=(auth,now)=>[
|
|
17
|
+
guard('SELECT 1 FROM credentials WHERE id=? AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at>?)',[auth.credential_id,now]),
|
|
18
|
+
guard('SELECT 1 FROM memberships WHERE team_id=? AND principal_id=? AND active=1',[auth.team_id,auth.principal_id]),
|
|
19
|
+
];
|
|
20
|
+
function authority(auth){
|
|
21
|
+
if(!auth.active||!['user','service'].includes(auth.kind)||auth.app_grants!=null)fail(403,'forbidden','agent identity requires an unfenced member credential');
|
|
22
|
+
}
|
|
23
|
+
const personSql=`SELECT p.id FROM principals p WHERE p.id=? AND p.kind='user' AND
|
|
24
|
+
(EXISTS(SELECT 1 FROM memberships m WHERE m.team_id=? AND m.principal_id=p.id AND m.active=1)
|
|
25
|
+
OR EXISTS(SELECT 1 FROM identity_principals i WHERE i.team_id=? AND i.principal_id=p.id)
|
|
26
|
+
AND NOT EXISTS(SELECT 1 FROM memberships m WHERE m.team_id=? AND m.principal_id=p.id AND m.active=0))`;
|
|
27
|
+
async function person(api,team,id){return id?await api.store.one(personSql,[id,team,team,team]):null;}
|
|
28
|
+
const personGuard=(team,id)=>guard(personSql,[id,team,team,team],'sponsor must remain a person in this Org');
|
|
29
|
+
/** An explicit selector names an Org person; it is never an inferred requester link.
|
|
30
|
+
* Resolve ids first, then an unambiguous exact display name (including an email).
|
|
31
|
+
* Authorization still runs against the canonical person id, with the resolution guarded.
|
|
32
|
+
*/
|
|
33
|
+
async function selectedSponsor(api,team,selector,guards){
|
|
34
|
+
if(selector==null)return null;
|
|
35
|
+
text(selector,'sponsor',256);
|
|
36
|
+
const direct=await person(api,team,selector);if(direct)return direct.id;
|
|
37
|
+
const sql=personSql.replace('p.id=?','p.display_name=?');
|
|
38
|
+
const people=await api.store.query(sql,[selector,team,team,team]);
|
|
39
|
+
if(people.length!==1)fail(400,'invalid_request','sponsor must name one person in this Org; use their principal id if the name is ambiguous');
|
|
40
|
+
const id=people[0].id;
|
|
41
|
+
guards.push(guard(`SELECT 1 WHERE (SELECT count(*) FROM (${sql}))=1 AND EXISTS(${sql} AND p.id=?)`,[selector,team,team,team,selector,team,team,team,id],'sponsor selector changed'));
|
|
42
|
+
return id;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
/** Inheritance is recomputed from current standing; an inferred identity never enters this chain.
|
|
47
|
+
* Mutation callers retain the exact standing reads as atomic guards, including declined candidates.
|
|
48
|
+
*/
|
|
49
|
+
export async function agentSponsor(api,team,row,seen=new Set(),guards=null){
|
|
50
|
+
if(!row||seen.has(row.id))return null;seen.add(row.id);
|
|
51
|
+
if(guards)guards.push(guard('SELECT 1 FROM agents WHERE team_id=? AND id=? AND revision=?',[team,row.id,row.revision],'sponsor ancestry changed'));
|
|
52
|
+
const eligible=async id=>{
|
|
53
|
+
if(!id)return false;
|
|
54
|
+
const yes=Boolean(await person(api,team,id));
|
|
55
|
+
if(guards)guards.push(guard(`SELECT 1 WHERE EXISTS(${personSql})=?`,[id,team,team,team,yes?1:0],'person standing changed'));
|
|
56
|
+
return yes;
|
|
57
|
+
};
|
|
58
|
+
if(await eligible(row.sponsor_id))return row.sponsor_id;
|
|
59
|
+
if(row.parent_id){
|
|
60
|
+
const parent=await api.store.one("SELECT * FROM agents WHERE team_id=? AND id=? AND state!='ended'",[team,row.parent_id]);
|
|
61
|
+
const inherited=await agentSponsor(api,team,parent,seen,guards);if(inherited)return inherited;
|
|
62
|
+
}
|
|
63
|
+
let starter=parse(row.lineage_json).starter;
|
|
64
|
+
// A plain session's historical starter supplies accountability, never its identity.
|
|
65
|
+
while(starter?.kind==='session'&&starter.delegator)starter=starter.delegator;
|
|
66
|
+
if(!starter)return null;
|
|
67
|
+
if(starter.kind==='person'&&await eligible(starter.id))return starter.id;
|
|
68
|
+
if(starter.kind==='agent')return agentSponsor(api,team,await api.store.one("SELECT * FROM agents WHERE team_id=? AND id=? AND state!='ended'",[team,starter.id]),seen,guards);
|
|
69
|
+
const sponsorQuery='SELECT sponsor_id FROM principal_sponsors WHERE team_id=? AND principal_id=?';
|
|
70
|
+
const named=await api.store.one(sponsorQuery,[team,starter.id]);
|
|
71
|
+
if(guards)guards.push(guard(`SELECT 1 WHERE COALESCE((${sponsorQuery}),'')=?`,[team,starter.id,named?.sponsor_id??''],'automation sponsor changed'));
|
|
72
|
+
if(await eligible(named?.sponsor_id))return named.sponsor_id;
|
|
73
|
+
const installQuery="SELECT installer_id FROM app_installations WHERE team_id=? AND principal_id=? AND state='active' ORDER BY created_at DESC,id DESC LIMIT 1";
|
|
74
|
+
const installed=await api.store.one(installQuery,[team,starter.id]);
|
|
75
|
+
if(guards)guards.push(guard(`SELECT 1 WHERE COALESCE((${installQuery}),'')=?`,[team,starter.id,installed?.installer_id??''],'installation sponsor changed'));
|
|
76
|
+
return await eligible(installed?.installer_id)?installed.installer_id:null;
|
|
77
|
+
}
|
|
78
|
+
export async function agentIdentity(api,team,row){
|
|
79
|
+
const sponsor=await agentSponsor(api,team,row),names=[row.name],seen=new Set([row.id]);let parent=row.parent_id;
|
|
80
|
+
while(parent){const next=await api.store.one('SELECT name,parent_id FROM agents WHERE team_id=? AND id=?',[team,parent]);if(!next||seen.has(parent))break;seen.add(parent);names.unshift(next.name);parent=next.parent_id;}
|
|
81
|
+
return {id:row.id,kind:'agent',org:team,name:row.name,path:names.join('/'),parent:row.parent_id,
|
|
82
|
+
lineage:parse(row.lineage_json),sponsor,explicit_sponsor:row.sponsor_id,profile:row.profile_ref,
|
|
83
|
+
state:row.state==='active'&&!sponsor?'suspended':row.state,
|
|
84
|
+
suspension_reason:row.state==='active'&&!sponsor?'no eligible person sponsor':null,
|
|
85
|
+
main_session:row.main_session_id,created_at:row.created_at,ended_at:row.ended_at,end_reason:row.end_reason,revision:row.revision};
|
|
86
|
+
}
|
|
87
|
+
export async function manageableAgent(api,auth,row){
|
|
88
|
+
if(isAdmin(auth)&&!auth.installation_id)return true;
|
|
89
|
+
if(auth.installation_id)return false;
|
|
90
|
+
return row.custodian_id===auth.principal_id||row.parent_id===auth.principal_id||await agentSponsor(api,auth.team_id,row)===auth.principal_id;
|
|
91
|
+
}
|
|
92
|
+
export async function managementGuards(api,auth,row){
|
|
93
|
+
const common=guard("SELECT 1 FROM agents WHERE team_id=? AND id=? AND revision=? AND state!='ended'",[auth.team_id,row.id,row.revision]);
|
|
94
|
+
if(isAdmin(auth)&&!auth.installation_id)return [common,guard("SELECT 1 FROM memberships WHERE team_id=? AND principal_id=? AND active=1 AND role IN ('owner','admin')",[auth.team_id,auth.principal_id])];
|
|
95
|
+
if(auth.installation_id)fail(403,'forbidden','an installation does not administer another agent');
|
|
96
|
+
if(row.custodian_id===auth.principal_id||row.parent_id===auth.principal_id)return [common];
|
|
97
|
+
const standing=[];
|
|
98
|
+
if(await agentSponsor(api,auth.team_id,row,new Set(),standing)!==auth.principal_id)fail(403,'forbidden','agent sponsorship changed');
|
|
99
|
+
return [common,...standing];
|
|
100
|
+
}
|
|
101
|
+
const nameGuard=(team,row)=>({sql:"SELECT 1 WHERE NOT EXISTS(SELECT 1 FROM agents WHERE team_id=? AND parent_id IS ? AND name=? AND id!=? AND state!='ended')",params:[team,row.parent_id,row.name,row.id],status:409,code:'conflict',message:'agent name is already declared under this parent'});
|
|
102
|
+
export async function authorizeAgentReplay(api,auth,sub,body){
|
|
103
|
+
authority(auth);
|
|
104
|
+
if(sub==='/agents'){
|
|
105
|
+
if(body?.root&&(!isAdmin(auth)||auth.installation_id))fail(403,'forbidden','root declaration authority changed');
|
|
106
|
+
if(body?.parent&&(!(isAdmin(auth)&&!auth.installation_id))&&body.parent!==auth.principal_id)fail(403,'forbidden','parent declaration authority changed');
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
const id=decodeURIComponent(sub.split('/')[2]);
|
|
110
|
+
const row=await api.store.one('SELECT * FROM agents WHERE team_id=? AND id=?',[auth.team_id,id]);
|
|
111
|
+
if(!row||!await manageableAgent(api,auth,row))fail(403,'forbidden','agent management authority changed');
|
|
112
|
+
}
|
|
113
|
+
export function publication(api,auth,kind,id,data,now){return stmt('INSERT INTO org_events(id,team_id,kind,resource_kind,resource_id,actor_json,data_json,created_at) VALUES (?,?,?,?,?,?,?,?)',api.ids.next('event'),auth.team_id,kind,'agent',id,JSON.stringify({principal:auth.principal_id,session:null,lineage:auth.act??[]}),JSON.stringify(data),now);}
|
|
114
|
+
|
|
115
|
+
/** An automation's sponsor is a person an Org admin names; agents it starts inherit that sponsor. */
|
|
116
|
+
export async function principalSponsorRoute({api,req,res,auth,sub,requestId,parseJson,send,result,guardedMutation}){
|
|
117
|
+
const match=/^\/principals\/([^/]+)\/sponsor$/.exec(sub);
|
|
118
|
+
if(!match)return false;
|
|
119
|
+
const id=decodeURIComponent(match[1]),now=api.clock.nowMs();
|
|
120
|
+
const automation=await api.store.one("SELECT p.id FROM principals p JOIN memberships m ON m.team_id=? AND m.principal_id=p.id AND m.active=1 WHERE p.id=? AND p.kind='service'",[auth.team_id,id]);
|
|
121
|
+
if(req.method==='GET'){
|
|
122
|
+
if(!automation)fail(404,'not_found','automation not found in this Org');
|
|
123
|
+
const row=await api.store.one('SELECT sponsor_id,set_by,created_at FROM principal_sponsors WHERE team_id=? AND principal_id=?',[auth.team_id,id]);
|
|
124
|
+
send(res,200,result(api,auth,{principal:id,sponsor:row?.sponsor_id??null,set_by:row?.set_by??null,at:row?.created_at??null}),requestId);return true;
|
|
125
|
+
}
|
|
126
|
+
if(!isAdmin(auth)||auth.installation_id)fail(403,'forbidden','only an Org admin names an automation\'s sponsor');
|
|
127
|
+
if(!automation)fail(404,'not_found','automation not found in this Org');
|
|
128
|
+
const guards=[...liveGuards(auth,now),guard("SELECT 1 FROM memberships WHERE team_id=? AND principal_id=? AND active=1 AND role IN ('owner','admin')",[auth.team_id,auth.principal_id]),
|
|
129
|
+
guard("SELECT 1 FROM principals p JOIN memberships m ON m.team_id=? AND m.principal_id=p.id AND m.active=1 WHERE p.id=? AND p.kind='service'",[auth.team_id,id],'automation standing changed')];
|
|
130
|
+
const event=data=>stmt('INSERT INTO org_events(id,team_id,kind,resource_kind,resource_id,actor_json,data_json,created_at) VALUES (?,?,?,?,?,?,?,?)',api.ids.next('event'),auth.team_id,'principal.changed','principal',id,JSON.stringify({principal:auth.principal_id,session:null,lineage:auth.act??[]}),JSON.stringify(data),now);
|
|
131
|
+
if(req.method==='DELETE'){
|
|
132
|
+
const response=result(api,auth,{principal:id,sponsor:null});
|
|
133
|
+
await guardedMutation(api,res,auth,guards,[stmt('DELETE FROM principal_sponsors WHERE team_id=? AND principal_id=?',auth.team_id,id),event({id,kind:'automation',sponsor:null})],{status:200,body:response,targetId:id});
|
|
134
|
+
send(res,200,response,requestId);return true;
|
|
135
|
+
}
|
|
136
|
+
if(req.method!=='PUT')fail(404,'not_found','sponsor operation not found');
|
|
137
|
+
const body=await parseJson(req);exact(body,['sponsor']);const sponsor=text(body.sponsor,'sponsor',128);
|
|
138
|
+
// Never an agent or an automation: the sponsor is a person in this Org.
|
|
139
|
+
if(!await person(api,auth.team_id,sponsor))fail(400,'invalid_request','a sponsor must be a person in this Org');
|
|
140
|
+
const response=result(api,auth,{principal:id,sponsor});
|
|
141
|
+
await guardedMutation(api,res,auth,[...guards,personGuard(auth.team_id,sponsor)],[
|
|
142
|
+
stmt('INSERT INTO principal_sponsors(team_id,principal_id,sponsor_id,set_by,created_at) VALUES (?,?,?,?,?) ON CONFLICT(team_id,principal_id) DO UPDATE SET sponsor_id=excluded.sponsor_id,set_by=excluded.set_by,created_at=excluded.created_at',auth.team_id,id,sponsor,auth.principal_id,now),
|
|
143
|
+
event({id,kind:'automation',sponsor})],{status:200,body:response,targetId:id});
|
|
144
|
+
send(res,200,response,requestId);return true;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export async function agentRoute(ctx){
|
|
148
|
+
const {api,req,res,auth,sub,url,requestId,parseJson,send,result,guardedMutation,requireRevision}=ctx;
|
|
149
|
+
if(!/^\/agents(?:\/|$)/.test(sub))return false;
|
|
150
|
+
authority(auth);const now=api.clock.nowMs();
|
|
151
|
+
const match=/^\/agents\/([^/]+)(?:\/(end|sessions|credentials|closures|management))?$/.exec(sub),id=match?decodeURIComponent(match[1]):null,op=match?.[2];
|
|
152
|
+
if(req.method==='GET'&&sub==='/agents'){
|
|
153
|
+
const limit=Number(url.searchParams.get('limit')??100),after=url.searchParams.get('after')??'';
|
|
154
|
+
if(!Number.isSafeInteger(limit)||limit<1||limit>200)fail(400,'invalid_request','limit must be 1..200');
|
|
155
|
+
const rows=await api.store.query('SELECT * FROM agents WHERE team_id=? AND id>? ORDER BY id LIMIT ?',[auth.team_id,after,limit+1]);
|
|
156
|
+
send(res,200,{version:1,items:await Promise.all(rows.slice(0,limit).map(row=>agentIdentity(api,auth.team_id,row))),next_after:rows.length>limit?rows[limit-1].id:null},requestId);return true;
|
|
157
|
+
}
|
|
158
|
+
if(req.method==='GET'&&sub==='/agents/root'){
|
|
159
|
+
const org=await api.store.one('SELECT root_agent_id FROM teams WHERE id=?',[auth.team_id]);
|
|
160
|
+
const root=org?.root_agent_id?await api.store.one('SELECT * FROM agents WHERE team_id=? AND id=?',[auth.team_id,org.root_agent_id]):null;
|
|
161
|
+
if(!root)fail(404,'not_found','Org root agent has not been declared');
|
|
162
|
+
send(res,200,result(api,auth,await agentIdentity(api,auth.team_id,root)),requestId);return true;
|
|
163
|
+
}
|
|
164
|
+
if(req.method==='POST'&&sub==='/agents'){
|
|
165
|
+
const body=await parseJson(req);exact(body,['id','name','parent','sponsor','profile','root','starter_session']);
|
|
166
|
+
const aid=body.id??api.ids.next('agent');if(!/^agent_[0-9a-f]{32}$/.test(aid))fail(400,'invalid_request','id must be a locally minted agent id');
|
|
167
|
+
const name=text(body.name,'name');if(name.includes('/'))fail(400,'invalid_request','an agent name is one path component');
|
|
168
|
+
const parentId=body.parent??null,parent=parentId?await api.store.one("SELECT * FROM agents WHERE team_id=? AND id=? AND state!='ended'",[auth.team_id,parentId]):null;
|
|
169
|
+
if(parentId&&!parent)fail(404,'not_found','parent agent not found');
|
|
170
|
+
if(parentId&&!(isAdmin(auth)&&!auth.installation_id)&&parentId!==auth.principal_id)fail(403,'forbidden','declare under your own agent or through an Org admin');
|
|
171
|
+
if(body.root&&(!(isAdmin(auth)&&!auth.installation_id)||parentId))fail(403,'forbidden','only an Org admin declares its parentless root');
|
|
172
|
+
const principal=await api.store.one('SELECT kind FROM principals WHERE id=?',[auth.principal_id]);
|
|
173
|
+
let starter={kind:kind(principal.kind),id:auth.principal_id};
|
|
174
|
+
if(body.starter_session){
|
|
175
|
+
const session=await api.store.authorizedSession({teamId:auth.team_id,principalId:auth.principal_id,credentialId:auth.credential_id,ref:body.starter_session,now});
|
|
176
|
+
if(session.custodian_id!==auth.principal_id)fail(403,'forbidden','a starter session must be your own');
|
|
177
|
+
const owner=await api.store.one('SELECT agent_id FROM agent_sessions WHERE team_id=? AND session_id=?',[auth.team_id,session.id]);
|
|
178
|
+
starter=owner?.agent_id?{kind:'agent',id:owner.agent_id,session:session.id}:{kind:'session',id:session.id,delegator:{kind:starter.kind,id:starter.id}};
|
|
179
|
+
}
|
|
180
|
+
const sponsorGuards=[];
|
|
181
|
+
const sponsor=await selectedSponsor(api,auth.team_id,body.sponsor,sponsorGuards);
|
|
182
|
+
if(sponsor&&(!await person(api,auth.team_id,sponsor)||(!(isAdmin(auth)&&!auth.installation_id)&&sponsor!==auth.principal_id)))fail(403,'forbidden','an explicit sponsor must be you or a person you administer');
|
|
183
|
+
const row={id:aid,name,parent_id:parentId,custodian_id:auth.principal_id,lineage_json:JSON.stringify({starter,declared_by:auth.principal_id,at:now}),sponsor_id:sponsor,
|
|
184
|
+
profile_ref:text(body.profile,'profile',1024),state:'active',main_session_id:null,created_at:now,ended_at:null,end_reason:null,revision:1};
|
|
185
|
+
const data=await agentIdentity(api,auth.team_id,row),response=result(api,auth,data),guards=[...liveGuards(auth,now),...sponsorGuards];
|
|
186
|
+
guards.push(nameGuard(auth.team_id,row));
|
|
187
|
+
if(parent)guards.push(guard("SELECT 1 FROM agents WHERE team_id=? AND id=? AND revision=? AND state='active'",[auth.team_id,parentId,parent.revision],'parent agent changed'));
|
|
188
|
+
if(sponsor)guards.push(personGuard(auth.team_id,sponsor));
|
|
189
|
+
if(body.root)guards.push(guard("SELECT 1 FROM teams t WHERE t.id=? AND (t.root_agent_id IS NULL OR EXISTS(SELECT 1 FROM agents a WHERE a.team_id=t.id AND a.id=t.root_agent_id AND a.state='ended'))",[auth.team_id],'Org root is already declared'));
|
|
190
|
+
const statements=[stmt("INSERT INTO principals(id,kind,display_name) VALUES (?,'agent',?)",aid,name),
|
|
191
|
+
stmt('INSERT INTO agents(team_id,id,name,parent_id,custodian_id,lineage_json,sponsor_id,profile_ref,state,created_at) VALUES (?,?,?,?,?,?,?,?,?,?)',auth.team_id,aid,name,parentId,row.custodian_id,row.lineage_json,sponsor,row.profile_ref,'active',now),
|
|
192
|
+
publication(api,auth,'agent.declared',aid,data,now)];
|
|
193
|
+
if(body.root)statements.push(stmt('UPDATE teams SET root_agent_id=?,revision=revision+1 WHERE id=?',aid,auth.team_id));
|
|
194
|
+
await guardedMutation(api,res,auth,guards,statements,{status:201,body:response,targetId:aid});send(res,201,response,requestId);return true;
|
|
195
|
+
}
|
|
196
|
+
if(!match)fail(404,'not_found','agent operation not found');
|
|
197
|
+
const row=await api.store.one('SELECT * FROM agents WHERE team_id=? AND id=?',[auth.team_id,id]);if(!row)fail(404,'not_found','agent not found');
|
|
198
|
+
if(req.method==='GET'&&!op){send(res,200,result(api,auth,await agentIdentity(api,auth.team_id,row)),requestId,{ETag:`"${row.revision}"`});return true;}
|
|
199
|
+
if(!await manageableAgent(api,auth,row))fail(403,'forbidden','only the sponsor, parent, declarer or manager changes this agent');
|
|
200
|
+
if(req.method==='GET'&&op==='management') {
|
|
201
|
+
if(row.state!=='active'||!await api.store.one(AGENT_ACTIVE,[auth.team_id,id]))fail(409,'conflict','the agent is not active with live sponsorship');
|
|
202
|
+
send(res,200,result(api,auth,{id,revision:row.revision,profile:row.profile_ref,manageable:true}),requestId);return true;
|
|
203
|
+
}
|
|
204
|
+
if(req.method==='GET'&&op==='closures'){
|
|
205
|
+
const rows=await api.store.query('SELECT id,session_id,machine_id,harness,native_id,state,reason,created_at,claimed_at,finished_at,result_json FROM agent_session_closures WHERE team_id=? AND agent_id=? ORDER BY created_at,id LIMIT 200',[auth.team_id,id]);
|
|
206
|
+
send(res,200,result(api,auth,{items:rows.map(({result_json,...item})=>({...item,result:result_json?parse(result_json):null}))}),requestId);return true;
|
|
207
|
+
}
|
|
208
|
+
if(row.state==='ended')fail(409,'conflict','an ended agent cannot be changed');
|
|
209
|
+
requireRevision(req,row.revision,'agent');
|
|
210
|
+
if(req.method==='POST'&&op==='credentials'){
|
|
211
|
+
const body=await parseJson(req);exact(body,['expires_in_ms']);
|
|
212
|
+
const lifetime=body.expires_in_ms??3600000;
|
|
213
|
+
if(!Number.isSafeInteger(lifetime)||lifetime<1||lifetime>86400000)fail(400,'invalid_request','credential lifetime must be 1ms..24h');
|
|
214
|
+
if(!await api.store.one(AGENT_ACTIVE,[auth.team_id,id]))fail(409,'conflict','suspended agents cannot receive delegation');
|
|
215
|
+
const cid=api.ids.next('credential'),token=makeToken(cid),expiry=Math.min(now+lifetime,auth.expires_at??Infinity);
|
|
216
|
+
const response=result(api,auth,{id:cid,token,principal_id:id,kind:'service',expires_at:expiry,actions:['agents.read','sessions.read','sessions.mail.own']});
|
|
217
|
+
await guardedMutation(api,res,auth,[...liveGuards(auth,now),...await managementGuards(api,auth,row),
|
|
218
|
+
guard(AGENT_ACTIVE,[auth.team_id,id],'agent standing changed'),guard(`SELECT 1 WHERE NOT EXISTS(${APP_LINEAGE_REVOKED})`,[auth.credential_id,now],'delegator standing changed')],
|
|
219
|
+
[stmt("INSERT INTO memberships(team_id,principal_id,role,active) VALUES (?,?,'viewer',1) ON CONFLICT(team_id,principal_id) DO NOTHING",auth.team_id,id),
|
|
220
|
+
stmt('INSERT INTO credentials(id,token_hash,principal_id,kind,team_id,expires_at,created_at) VALUES (?,?,?,?,?,?,?)',cid,hashToken(token),id,'service',auth.team_id,expiry,now),
|
|
221
|
+
stmt('INSERT INTO agent_credentials(credential_id,parent_credential_id,agent_id,team_id,created_at) VALUES (?,?,?,?,?)',cid,auth.credential_id,id,auth.team_id,now)],{status:201,body:response,targetId:cid});
|
|
222
|
+
send(res,201,response,requestId);return true;
|
|
223
|
+
}
|
|
224
|
+
if(req.method==='PATCH'&&!op){
|
|
225
|
+
const body=await parseJson(req);exact(body,['name','parent','sponsor','profile','state']);
|
|
226
|
+
if(!Object.keys(body).length)fail(400,'invalid_request','an agent change is required');
|
|
227
|
+
const updated={...row,revision:row.revision+1};const guards=[...liveGuards(auth,now),...await managementGuards(api,auth,row)];
|
|
228
|
+
if(body.name!==undefined){updated.name=text(body.name,'name');if(updated.name.includes('/'))fail(400,'invalid_request','name is one path component');}
|
|
229
|
+
if(body.profile!==undefined)updated.profile_ref=text(body.profile,'profile',1024);
|
|
230
|
+
if(body.sponsor!==undefined){updated.sponsor_id=await selectedSponsor(api,auth.team_id,body.sponsor,guards);if(updated.sponsor_id){if(!(isAdmin(auth)&&!auth.installation_id)&&updated.sponsor_id!==auth.principal_id)fail(403,'forbidden','sponsor must be you or a person you administer');guards.push(personGuard(auth.team_id,updated.sponsor_id));}}
|
|
231
|
+
if(body.state!==undefined){if(!['active','suspended'].includes(body.state))fail(400,'invalid_request','state must be active or suspended; use end to end an agent');updated.state=body.state;}
|
|
232
|
+
if(body.parent!==undefined){
|
|
233
|
+
const org=await api.store.one('SELECT root_agent_id FROM teams WHERE id=?',[auth.team_id]);
|
|
234
|
+
if(org.root_agent_id===id&&body.parent!=null)fail(409,'conflict','the Org root cannot have a parent');
|
|
235
|
+
guards.push(guard('SELECT 1 FROM teams WHERE id=? AND root_agent_id IS ?',[auth.team_id,org.root_agent_id],'Org root changed'));
|
|
236
|
+
updated.parent_id=body.parent;
|
|
237
|
+
if(updated.parent_id){
|
|
238
|
+
const target=await api.store.one("SELECT * FROM agents WHERE team_id=? AND id=? AND state!='ended'",[auth.team_id,updated.parent_id]);if(!target||!await manageableAgent(api,auth,target))fail(403,'forbidden','new parent must be one you administer');
|
|
239
|
+
guards.push(...await managementGuards(api,auth,target));
|
|
240
|
+
const descendants=await api.store.query('WITH RECURSIVE tree(id) AS (SELECT ? UNION SELECT a.id FROM agents a JOIN tree t ON a.parent_id=t.id WHERE a.team_id=?) SELECT id FROM tree',[id,auth.team_id]);
|
|
241
|
+
if(descendants.some(a=>a.id===updated.parent_id))fail(409,'conflict','reparenting would create a cycle');
|
|
242
|
+
guards.push(guard("SELECT 1 FROM agents WHERE team_id=? AND id=? AND revision=? AND state!='ended'",[auth.team_id,target.id,target.revision],'new parent changed'));
|
|
243
|
+
guards.push(guard('WITH RECURSIVE tree(id) AS (SELECT ? UNION SELECT a.id FROM agents a JOIN tree t ON a.parent_id=t.id WHERE a.team_id=?) SELECT 1 WHERE NOT EXISTS(SELECT 1 FROM tree WHERE id=?)',[id,auth.team_id,target.id],'reparenting would create a cycle'));
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
guards.push(nameGuard(auth.team_id,updated));
|
|
247
|
+
const data=await agentIdentity(api,auth.team_id,updated),response=result(api,auth,data);
|
|
248
|
+
await guardedMutation(api,res,auth,guards,[stmt('UPDATE agents SET name=?,parent_id=?,sponsor_id=?,profile_ref=?,state=?,revision=revision+1 WHERE team_id=? AND id=?',updated.name,updated.parent_id,updated.sponsor_id,updated.profile_ref,updated.state,auth.team_id,id),stmt('UPDATE principals SET display_name=? WHERE id=?',updated.name,id),publication(api,auth,body.parent!==undefined?'agent.reparented':'agent.changed',id,data,now)],{status:200,body:response,targetId:id});send(res,200,response,requestId);return true;
|
|
249
|
+
}
|
|
250
|
+
if(req.method==='POST'&&op==='end'){
|
|
251
|
+
const body=await parseJson(req);exact(body,['reason']);const reason=text(body.reason,'reason',1024);
|
|
252
|
+
const children=await api.store.query('WITH RECURSIVE tree(id) AS (SELECT ? UNION SELECT a.id FROM agents a JOIN tree t ON a.parent_id=t.id WHERE a.team_id=?) SELECT a.* FROM agents a JOIN tree t ON a.id=t.id WHERE a.team_id=? AND a.state!=\'ended\'',[id,auth.team_id,auth.team_id]);
|
|
253
|
+
const statements=[];
|
|
254
|
+
for(const child of children){
|
|
255
|
+
statements.push(stmt(`WITH RECURSIVE family(id,root) AS (
|
|
256
|
+
SELECT session_id,session_id FROM agent_sessions WHERE team_id=? AND agent_id=? AND ended_at IS NULL
|
|
257
|
+
UNION SELECT s.id,f.root FROM sessions s JOIN family f ON s.canonical_id=f.id WHERE s.team_id=? AND s.deleted_at IS NULL)
|
|
258
|
+
INSERT OR IGNORE INTO agent_session_closures(team_id,id,agent_id,session_id,machine_id,source_id,harness,native_id,reason,created_at)
|
|
259
|
+
SELECT o.team_id,'close_'||lower(hex(randomblob(16))),?,f.root,src.machine_id,o.source_id,o.harness,o.native_id,?,?
|
|
260
|
+
FROM origins o JOIN family f ON f.id=o.session_id JOIN sources src ON src.team_id=o.team_id AND src.id=o.source_id
|
|
261
|
+
WHERE o.team_id=? AND o.deleted_at IS NULL AND src.machine_id IS NOT NULL`,auth.team_id,child.id,auth.team_id,child.id,reason,now,auth.team_id));
|
|
262
|
+
statements.push(stmt("UPDATE agents SET state='ended',ended_at=?,end_reason=?,revision=revision+1,main_session_id=NULL WHERE team_id=? AND id=?",now,reason,auth.team_id,child.id),
|
|
263
|
+
stmt('UPDATE credentials SET revoked_at=? WHERE team_id=? AND principal_id=? AND revoked_at IS NULL',now,auth.team_id,child.id),
|
|
264
|
+
stmt(`WITH RECURSIVE delegated(id) AS (
|
|
265
|
+
SELECT ac.credential_id FROM app_credentials ac JOIN credentials c ON c.id=ac.credential_id,json_each(ac.actor_json) actor
|
|
266
|
+
WHERE c.team_id=? AND actor.type='text' AND actor.value=?
|
|
267
|
+
UNION SELECT ac.credential_id FROM app_credentials ac JOIN delegated d ON ac.parent_id=d.id
|
|
268
|
+
UNION SELECT ac.credential_id FROM app_credentials ac JOIN delegated d ON ac.subject_credential_id=d.id
|
|
269
|
+
UNION SELECT ac.credential_id FROM agent_credentials ac JOIN delegated d ON ac.parent_credential_id=d.id)
|
|
270
|
+
UPDATE credentials SET revoked_at=? WHERE team_id=? AND id IN(SELECT id FROM delegated) AND revoked_at IS NULL`,auth.team_id,child.id,now,auth.team_id),
|
|
271
|
+
stmt('UPDATE memberships SET active=0,revision=revision+1 WHERE team_id=? AND principal_id=?',auth.team_id,child.id),
|
|
272
|
+
stmt('UPDATE agent_sessions SET ended_at=COALESCE(ended_at,?),end_reason=COALESCE(end_reason,?),main=0,revision=revision+1 WHERE team_id=? AND agent_id=?',now,reason,auth.team_id,child.id),
|
|
273
|
+
publication(api,auth,'agent.ended',child.id,{...await agentIdentity(api,auth.team_id,child),state:'ended',main_session:null,ended_at:now,end_reason:reason,revision:child.revision+1},now));
|
|
274
|
+
statements.push(stmt(`INSERT INTO org_events(id,team_id,kind,resource_kind,resource_id,actor_json,data_json,created_at)
|
|
275
|
+
SELECT 'evt_'||lower(hex(randomblob(16))),s.team_id,'session.changed','session',s.id,?,${nativeSessionSnapshot('s.team_id','s.id')},?
|
|
276
|
+
FROM sessions s JOIN agent_sessions own ON own.team_id=s.team_id AND own.session_id=s.id WHERE own.team_id=? AND own.agent_id=?`,
|
|
277
|
+
JSON.stringify({principal:auth.principal_id,session:null,lineage:auth.act??[]}),now,auth.team_id,child.id));
|
|
278
|
+
}
|
|
279
|
+
const response=result(api,auth,{id,state:'ended',ended:children.map(a=>a.id)});
|
|
280
|
+
statements.push(stmt('UPDATE teams SET auth_epoch=auth_epoch+1 WHERE id=?',auth.team_id));
|
|
281
|
+
await guardedMutation(api,res,auth,[...liveGuards(auth,now),...await managementGuards(api,auth,row),guard("WITH RECURSIVE tree(id) AS (SELECT ? UNION SELECT a.id FROM agents a JOIN tree t ON a.parent_id=t.id WHERE a.team_id=?) SELECT 1 WHERE (SELECT count(*) FROM agents a JOIN tree t ON a.id=t.id WHERE a.team_id=? AND a.state!='ended')=?",[id,auth.team_id,auth.team_id,children.length],'agent descendants changed'),...children.map(c=>guard('SELECT 1 FROM agents WHERE team_id=? AND id=? AND revision=?',[auth.team_id,c.id,c.revision],'descendant changed'))],statements,{status:200,body:response,targetId:id});
|
|
282
|
+
for(const child of children){closeChannelsFor(api,auth.team_id,{principalId:child.id,reason:'agent_ended'});closeActorChannels(api,auth.team_id,child.id);}
|
|
283
|
+
send(res,200,response,requestId);return true;
|
|
284
|
+
}
|
|
285
|
+
fail(404,'not_found','agent operation not found');
|
|
286
|
+
}
|
package/server/apps.mjs
CHANGED
|
@@ -4,14 +4,20 @@ import { randomBytes, createHash, createCipheriv, createDecipheriv, createHmac }
|
|
|
4
4
|
import { hashToken, makeToken, isAdmin, canRead, canManage, appGrantAllows, APP_LINEAGE_REVOKED } from './auth.mjs';
|
|
5
5
|
import { exact } from './request-json.mjs';
|
|
6
6
|
import { closeAppChannels, closeMachineChannels } from './machine-link.mjs';
|
|
7
|
+
import { APP_EVENTS } from './event-catalogue.mjs';
|
|
8
|
+
import { AGENT_ACTIVE } from './agent-authority.mjs';
|
|
9
|
+
import { mayReadEvent } from './published-events.mjs';
|
|
10
|
+
export { APP_EVENTS } from './event-catalogue.mjs';
|
|
7
11
|
|
|
8
12
|
export const APP_ACTIONS = Object.freeze([
|
|
13
|
+
'agents.read', 'agents.write',
|
|
14
|
+
'identity.read', 'identity.observe', 'identity.link', 'identity.infer',
|
|
9
15
|
'teams.read', 'members.read', 'members.write', 'groups.read', 'groups.write',
|
|
10
16
|
'sessions.read', 'sessions.write', 'sessions.control', 'sessions.access',
|
|
11
17
|
'machines.read', 'machines.control', 'machines.write', 'machines.access', 'sources.read', 'sources.write',
|
|
12
18
|
'identity.login', 'delegation.surfaces', 'delegation.sessions', 'delegation.notices',
|
|
19
|
+
'events.read', 'events.publish',
|
|
13
20
|
]);
|
|
14
|
-
export const APP_EVENTS = Object.freeze(['app.registered', 'app.installed', 'app.uninstalled']);
|
|
15
21
|
const TOKEN_MS = 90 * 86400000, CODE_MS = 10 * 60000, DELEGATION_MS = 15 * 60000;
|
|
16
22
|
const error = (status, code, message) => { throw Object.assign(new Error(message), { status, code }); };
|
|
17
23
|
const statement = (sql, ...params) => ({ sql, params });
|
|
@@ -68,6 +74,16 @@ function sessionAuthorityGuard(source,teamId,id,manage,now){
|
|
|
68
74
|
|
|
69
75
|
/** Restrict every ordinary door, including replay, before the existing resource evaluator. Unknown doors fail closed. */
|
|
70
76
|
export function appRouteAction(method, sub) {
|
|
77
|
+
if(/^\/agents(?:\/|$)/.test(sub))return method==='GET'?'agents.read':'agents.write';
|
|
78
|
+
if(sub.startsWith('/identity/')) {
|
|
79
|
+
if(method==='GET')return 'identity.read';
|
|
80
|
+
if(method==='POST'&&sub==='/identity/observations')return 'identity.observe';
|
|
81
|
+
if(sub.startsWith('/identity/inferences'))return 'identity.infer';
|
|
82
|
+
if(sub.startsWith('/identity/links'))return 'identity.link';
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
if(method==='GET'&&/^\/events(?:\/catalogue)?$/.test(sub))return 'events.read';
|
|
86
|
+
if(method==='POST'&&/^\/event-sources(?:\/[^/]+\/events)?$/.test(sub))return 'events.publish';
|
|
71
87
|
if (method === 'GET' && (sub === '' || sub === '/me' || sub === '/capabilities' || sub === '/teams')) return 'teams.read';
|
|
72
88
|
if (/^\/members(?:\/[^/]+)?$/.test(sub)) return method === 'GET' ? 'members.read' : 'members.write';
|
|
73
89
|
if (/^\/groups(?:\/[^/]+(?:\/members\/[^/]+)?)?$/.test(sub)) return method === 'GET' ? 'groups.read' : 'groups.write';
|
|
@@ -199,6 +215,10 @@ export async function appRoute(ctx) {
|
|
|
199
215
|
const body = await parseJson(req); exact(body, ['actions','actor','expires_in_ms','principal_id','grants','subject_token']);
|
|
200
216
|
const actions = list(body.actions, 'actions', auth.app_actions);
|
|
201
217
|
const actor = text(body.actor, 'actor');
|
|
218
|
+
const actorStanding=guard(`SELECT 1 WHERE NOT EXISTS(SELECT 1 FROM agents named WHERE named.team_id=? AND named.id=?
|
|
219
|
+
AND NOT EXISTS(${AGENT_ACTIVE.replace('VALUES(?,?)','SELECT named.team_id,named.id')}))`,[auth.team_id,actor],'a declared agent actor must remain active');
|
|
220
|
+
// An opaque audit actor supplies no authority. A known agent also carries its current lifetime fence.
|
|
221
|
+
if(!await api.store.one(actorStanding.sql,actorStanding.params))error(403,'forbidden',actorStanding.message);
|
|
202
222
|
const duration = body.expires_in_ms ?? DELEGATION_MS;
|
|
203
223
|
if (!Number.isSafeInteger(duration) || duration < 1000 || duration > DELEGATION_MS || auth.act.length >= 16) error(400, 'invalid_request', 'delegation is limited to fifteen minutes and sixteen actors');
|
|
204
224
|
const parent = await api.store.one('SELECT expires_at FROM credentials WHERE id=?', [auth.credential_id]);
|
|
@@ -236,7 +256,7 @@ export async function appRoute(ctx) {
|
|
|
236
256
|
}
|
|
237
257
|
const expiresAt=Math.min(now+duration,parent.expires_at??Infinity,source.expires_at??Infinity),cid=api.ids.next('credential'),token=makeToken(cid),act=[...auth.act,actor];
|
|
238
258
|
const response=result(api,auth,{token,kind:'service',expires_at:expiresAt,actions,subject,act,grants});
|
|
239
|
-
const boundaryNow=api.clock.nowMs(),checks=[
|
|
259
|
+
const boundaryNow=api.clock.nowMs(),checks=[actorStanding,...appAuthGuards(auth,boundaryNow),...appAuthGuards(source,boundaryNow),guard('SELECT 1 FROM teams WHERE id=? AND auth_epoch=?',[auth.team_id,epoch.auth_epoch]),guard('SELECT 1 FROM memberships WHERE team_id=? AND principal_id=? AND active=1 AND role=?',[auth.team_id,subject,targetMembership.role]),guard('SELECT 1 FROM credentials WHERE id=? AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at>?)',[source.credential_id,boundaryNow])];
|
|
240
260
|
for(const grant of grants??[]){const manage=grant.actions.some(a=>a!=='sessions.read');checks.push(sessionAuthorityGuard(source,auth.team_id,grant.id,manage,boundaryNow),sessionAuthorityGuard(target,auth.team_id,grant.id,manage,boundaryNow));}
|
|
241
261
|
await guardedMutation(api,res,auth,checks,[statement('INSERT INTO credentials(id,token_hash,principal_id,kind,team_id,expires_at,created_at) VALUES (?,?,?,\'service\',?,?,?)',cid,hashToken(token),subject,auth.team_id,expiresAt,now),statement('INSERT INTO app_credentials(credential_id,installation_id,parent_id,actions_json,actor_json,subject_principal_id,subject_credential_id,grants_json) VALUES (?,?,?,?,?,?,?,?)',cid,auth.installation_id,auth.credential_id,JSON.stringify(actions),JSON.stringify(act),subject,sourceId,JSON.stringify(grants))],{status:201,body:response,targetId:cid});
|
|
242
262
|
send(res, 201, response, requestId); return true;
|
|
@@ -349,12 +369,24 @@ export async function deliverAppWebhooks(api) {
|
|
|
349
369
|
try {
|
|
350
370
|
await api.store.guardedTransaction([guard("SELECT 1 FROM app_webhook_deliveries WHERE id=? AND state='pending' AND (lease_until IS NULL OR lease_until<=?)", [id, now])], [statement('UPDATE app_webhook_deliveries SET lease_until=?,attempts=attempts+1 WHERE id=?', lease, id)]);
|
|
351
371
|
} catch (e) { if (e.code === 'forbidden') continue; throw e; }
|
|
352
|
-
const row = await api.store.one(`SELECT d.*,i.signing_secret,i.state installation_state,a.webhook_url,a.state app_state,m.active,installer.active installer_active,installer.role installer_role
|
|
372
|
+
const row = await api.store.one(`SELECT d.*,i.signing_secret,i.team_id,i.principal_id,i.actions_json,i.state installation_state,a.webhook_url,a.state app_state,m.active,installer.active installer_active,installer.role installer_role
|
|
353
373
|
FROM app_webhook_deliveries d JOIN app_installations i ON i.id=d.installation_id JOIN apps a ON a.id=i.app_id JOIN memberships m ON m.team_id=i.team_id AND m.principal_id=i.principal_id JOIN memberships installer ON installer.team_id=i.team_id AND installer.principal_id=i.installer_id WHERE d.id=?`, [id]);
|
|
354
374
|
let status = null, delivered = false;
|
|
355
375
|
if (row.state !== 'pending' || row.installation_state !== 'active' || row.app_state !== 'active' || !row.active || !row.installer_active || !['owner','admin'].includes(row.installer_role)) {
|
|
356
376
|
await api.store.transaction([statement("UPDATE app_webhook_deliveries SET state='cancelled',lease_until=NULL WHERE id=?", id)]); continue;
|
|
357
377
|
}
|
|
378
|
+
const event=JSON.parse(row.body);
|
|
379
|
+
if(event.resource){
|
|
380
|
+
const credential=await api.store.one(`SELECT c.id,c.kind,c.principal_id,c.expires_at,m.role,m.active FROM app_credentials ac JOIN credentials c ON c.id=ac.credential_id JOIN memberships m ON m.team_id=? AND m.principal_id=c.principal_id WHERE ac.installation_id=? AND ac.parent_id IS NULL AND c.revoked_at IS NULL AND (c.expires_at IS NULL OR c.expires_at>?) ORDER BY c.created_at DESC LIMIT 1`,[row.team_id,row.installation_id,now]);
|
|
381
|
+
if(!credential){
|
|
382
|
+
await api.store.transaction([statement('UPDATE app_webhook_deliveries SET lease_until=NULL,next_at=?,attempts=attempts-1 WHERE id=?',now+30000,id)]);continue;
|
|
383
|
+
}
|
|
384
|
+
// Code redemption supplies the installation's reader credential; attenuation never widens the webhook.
|
|
385
|
+
const reader=credential&&await appAuthentication(api,{...credential,credential_id:credential.id,team_id:row.team_id});
|
|
386
|
+
if(!reader||!await mayReadEvent(api,reader,event)){
|
|
387
|
+
await api.store.transaction([statement("UPDATE app_webhook_deliveries SET state='cancelled',lease_until=NULL WHERE id=?",id)]);continue;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
358
390
|
try {
|
|
359
391
|
const secret = unseal(api, row.signing_secret, row.installation_id), timestamp = String(Math.floor(api.clock.nowMs() / 1000));
|
|
360
392
|
const signature = createHmac('sha256', Buffer.from(secret.slice(6), 'base64')).update(`${id}.${timestamp}.${row.body}`).digest('base64');
|
package/server/auth.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { AGENT_ACTIVE } from './agent-authority.mjs';
|
|
1
2
|
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
2
3
|
export const hashToken = token => createHash('sha256').update(token).digest('hex');
|
|
3
4
|
export function makeToken(credentialId, bytes = randomBytes(32)) { return `sct_${credentialId}.${Buffer.from(bytes).toString('base64url')}`; }
|
|
@@ -10,10 +11,12 @@ export function appGrantAllows(auth, kind, id, action) {
|
|
|
10
11
|
/** Both app parent and the credential that passed a represented principal's grants stay live. */
|
|
11
12
|
export const APP_LINEAGE = `WITH RECURSIVE lineage(id) AS (
|
|
12
13
|
SELECT ? UNION SELECT ac.parent_id FROM app_credentials ac JOIN lineage l ON ac.credential_id=l.id WHERE ac.parent_id IS NOT NULL
|
|
14
|
+
UNION SELECT d.parent_credential_id FROM agent_credentials d JOIN lineage l ON d.credential_id=l.id
|
|
13
15
|
UNION SELECT ac.subject_credential_id FROM app_credentials ac JOIN lineage l ON ac.credential_id=l.id WHERE ac.subject_credential_id IS NOT NULL
|
|
14
16
|
)`;
|
|
15
17
|
export const APP_LINEAGE_REVOKED = `${APP_LINEAGE}
|
|
16
18
|
SELECT 1 FROM lineage l JOIN credentials c ON c.id=l.id
|
|
19
|
+
JOIN principals p ON p.id=c.principal_id
|
|
17
20
|
LEFT JOIN memberships m ON m.team_id=c.team_id AND m.principal_id=c.principal_id
|
|
18
21
|
LEFT JOIN app_credentials ac ON ac.credential_id=c.id
|
|
19
22
|
LEFT JOIN identity_app_credentials ic ON ic.credential_id=c.id
|
|
@@ -21,7 +24,12 @@ export const APP_LINEAGE_REVOKED = `${APP_LINEAGE}
|
|
|
21
24
|
LEFT JOIN apps a ON a.id=i.app_id
|
|
22
25
|
LEFT JOIN memberships installer ON installer.team_id=i.team_id AND installer.principal_id=i.installer_id
|
|
23
26
|
LEFT JOIN memberships service ON service.team_id=i.team_id AND service.principal_id=i.principal_id
|
|
24
|
-
WHERE
|
|
27
|
+
WHERE (p.kind='agent' AND NOT EXISTS (${AGENT_ACTIVE.replace('VALUES(?,?)','SELECT c.team_id,c.principal_id')}))
|
|
28
|
+
OR EXISTS(SELECT 1 FROM json_each(COALESCE(ac.actor_json,'[]')) actor
|
|
29
|
+
JOIN agents named ON named.team_id=c.team_id AND named.id=actor.value
|
|
30
|
+
WHERE actor.type='text' AND NOT EXISTS (${AGENT_ACTIVE.replace('VALUES(?,?)','SELECT named.team_id,named.id')}))
|
|
31
|
+
OR EXISTS(SELECT 1 FROM agent_credentials d LEFT JOIN memberships dm ON dm.team_id=d.team_id AND dm.principal_id=c.principal_id WHERE d.parent_credential_id=c.id AND d.credential_id IN(SELECT id FROM lineage) AND COALESCE(dm.active,0)!=1)
|
|
32
|
+
OR c.revoked_at IS NOT NULL OR (c.expires_at IS NOT NULL AND c.expires_at<=?)
|
|
25
33
|
OR (c.team_id IS NOT NULL AND COALESCE(m.active,0)!=1)
|
|
26
34
|
OR (i.id IS NOT NULL AND (i.state!='active' OR a.state!='active' OR COALESCE(installer.active,0)!=1
|
|
27
35
|
OR installer.role NOT IN ('owner','admin') OR COALESCE(service.active,0)!=1)) LIMIT 1`;
|
package/server/core.mjs
CHANGED
|
@@ -15,13 +15,12 @@ const productionIds={next(kind){return `${prefix[kind]??kind}_${randomBytes(16).
|
|
|
15
15
|
const productionClock={nowMs:()=>Date.now()};
|
|
16
16
|
export { productionIds, productionClock };
|
|
17
17
|
|
|
18
|
-
export function createTeamsApi({clock=productionClock,ids=productionIds,publicOrigin=null,appRootSecret=null,
|
|
18
|
+
export function createTeamsApi({clock=productionClock,ids=productionIds,publicOrigin=null,appRootSecret=null,uploadLimitBytes=256*1024*1024}={}) {
|
|
19
19
|
if(typeof clock?.nowMs!=='function'||typeof ids?.next!=='function')throw new TypeError('clock.nowMs and ids.next are required');
|
|
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
|
-
|
|
24
|
-
return {clock,ids,events,publicOrigin,appRootSecret,identityOrganizations,uploadLimitBytes,rateState:new Map(),openStreams:new Map(),securityEvents:[],
|
|
23
|
+
return {clock,ids,events,publicOrigin,appRootSecret,uploadLimitBytes,rateState:new Map(),openStreams:new Map(),securityEvents:[],
|
|
25
24
|
get store(){return store;},get serverId(){return serverId;},get localOrigin(){return localOrigin;},get harnessRoutes(){return harnessRoutes;},
|
|
26
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;},
|
|
27
26
|
};
|
|
@@ -99,6 +98,8 @@ export async function maintain(api){const now=api.clock.nowMs();
|
|
|
99
98
|
...['messages','session_artifacts','session_versions'].map(table=>({sql:`DELETE FROM ${table} WHERE team_id=? AND session_id=? AND revision=?`,params:[team_id,session_id,revision]})),
|
|
100
99
|
]);
|
|
101
100
|
await api.store.transaction([
|
|
101
|
+
{sql:'INSERT INTO event_retention SELECT e.team_id,MAX(e.sequence) FROM org_events e LEFT JOIN (SELECT team_id,MIN(sequence) AS first FROM org_events WHERE created_at>=? GROUP BY team_id) n ON n.team_id=e.team_id WHERE e.created_at<? AND (n.first IS NULL OR e.sequence<n.first) GROUP BY e.team_id ON CONFLICT(team_id) DO UPDATE SET pruned_through=MAX(pruned_through,excluded.pruned_through)',params:[now-7*86400000,now-7*86400000]},
|
|
102
|
+
{sql:'DELETE FROM org_events WHERE sequence<=(SELECT pruned_through FROM event_retention WHERE team_id=org_events.team_id) AND sequence NOT IN (SELECT sequence FROM event_resources)',params:[]},
|
|
102
103
|
{sql:'DELETE FROM cursors WHERE expires_at<=?',params:[now]},
|
|
103
104
|
{sql:'DELETE FROM global_cursors WHERE expires_at<=?',params:[now]},
|
|
104
105
|
{sql:'DELETE FROM global_idempotency WHERE expires_at<=?',params:[now]},
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// Product-neutral ontology names; source-owned records are published through the same door.
|
|
2
|
+
export const APP_EVENTS = Object.freeze([
|
|
3
|
+
'app.registered', 'app.installed', 'app.uninstalled',
|
|
4
|
+
'membership.added', 'membership.changed', 'membership.removed',
|
|
5
|
+
'principal.created', 'principal.changed', 'principal.ended',
|
|
6
|
+
'agent.declared', 'agent.changed', 'agent.reassigned', 'agent.reparented', 'agent.ended',
|
|
7
|
+
'session.started', 'session.changed', 'session.ended', 'resource.changed',
|
|
8
|
+
'task.created', 'task.changed', 'task.started', 'task.review_requested', 'task.changes_requested',
|
|
9
|
+
'task.completed', 'task.cancelled', 'task.reopened', 'task.blocked', 'task.unblocked',
|
|
10
|
+
'task.paused', 'task.resumed', 'task.assigned', 'task.linked', 'task.unlinked', 'task.commented', 'task.specified',
|
|
11
|
+
]);
|
|
12
|
+
export const PUBLISHABLE_EVENTS = APP_EVENTS.filter(kind => /^(task|agent)\./.test(kind));
|
|
13
|
+
export const eventReadAction = kind => ({session:'sessions.read',membership:'members.read',machine:'machines.read',task:'events.read',agent:'events.read',app:'teams.read'}[kind] ?? 'events.read');
|